diff --git a/src/app.rs b/src/app.rs index 7388ec17..a88af4e1 100644 --- a/src/app.rs +++ b/src/app.rs @@ -25,6 +25,7 @@ use cosmic::{ window::{self, Event as WindowEvent, Id as WindowId}, Alignment, Event, Length, Size, Subscription, }, + iced_core::SmolStr, iced_runtime::clipboard, style, theme, widget::{ @@ -278,7 +279,7 @@ pub enum Message { DialogUpdate(DialogPage), DialogUpdateComplete(DialogPage), ExtractHere(Option), - Key(Modifiers, Key), + Key(Modifiers, Key, Option), LaunchUrl(String), MaybeExit, Modifiers(Modifiers), @@ -471,6 +472,49 @@ pub struct FavoriteIndex(usize); pub struct MounterData(MounterKey, MounterItem); +struct PrefixSearch { + search: String, + reset_delay: u64, + next_reset: Option, +} + +impl PrefixSearch { + pub fn new(reset_delay: u64) -> PrefixSearch { + PrefixSearch { + search: String::new(), + reset_delay, + next_reset: None, + } + } + + pub fn reset(&mut self) { + self.search.clear(); + self.next_reset = None; + } + + pub fn update_search(&mut self, string: &str) { + // Clear the word when the last typed character is older then the reset delay + if let Some(next_reset) = self.next_reset { + if next_reset <= Instant::now() { + self.reset(); + } + } + // Add the typed character + self.search.push_str(string); + // Restart the reset timeout + let delay = time::Duration::from_millis(self.reset_delay); + self.next_reset = Instant::now().checked_add(delay); + // Reset the search term when calculating the next reset failed + if self.next_reset.is_none() { + self.search.clear(); + } + } + + pub fn word(&self) -> &str { + &self.search + } +} + #[derive(Clone, Debug)] pub enum WindowKind { Desktop(Entity), @@ -538,6 +582,7 @@ pub struct App { tab_dnd_hover: Option<(Entity, Instant)>, nav_drag_id: DragId, tab_drag_id: DragId, + prefix_search: PrefixSearch, } impl App { @@ -1466,6 +1511,7 @@ impl Application for App { tab_dnd_hover: None, nav_drag_id: DragId::new(), tab_drag_id: DragId::new(), + prefix_search: PrefixSearch::new(500), // TODO do not hardcode delay? }; let mut commands = vec![app.update_config()]; @@ -1938,13 +1984,21 @@ impl Application for App { } } } - Message::Key(modifiers, key) => { + Message::Key(modifiers, key, text) => { let entity = self.tab_model.active(); for (key_bind, action) in self.key_binds.iter() { if key_bind.matches(modifiers, &key) { + self.prefix_search.reset(); return self.update(action.message(Some(entity))); } } + if let Some(text) = text { + self.prefix_search.update_search(&text); + return self.update(Message::TabMessage( + Some(entity), + tab::Message::SelectNextPrefix(self.prefix_search.word().into()), + )); + } } Message::MaybeExit => { if self.window_id_opt.is_none() && self.pending_operations.is_empty() { @@ -4161,8 +4215,8 @@ impl Application for App { let mut subscriptions = vec![ event::listen_with(|event, status, _window_id| match event { - Event::Keyboard(KeyEvent::KeyPressed { key, modifiers, .. }) => match status { - event::Status::Ignored => Some(Message::Key(modifiers, key)), + Event::Keyboard(KeyEvent::KeyPressed { key, modifiers, text, ..}) => match status { + event::Status::Ignored => Some(Message::Key(modifiers, key, text)), event::Status::Captured => None, }, Event::Keyboard(KeyEvent::ModifiersChanged(modifiers)) => { diff --git a/src/tab.rs b/src/tab.rs index cedb092a..3b6f98a6 100644 --- a/src/tab.rs +++ b/src/tab.rs @@ -27,7 +27,7 @@ use cosmic::{ Size, Subscription, }, - iced_core::{mouse::ScrollDelta, widget::tree}, + iced_core::{mouse::ScrollDelta, widget::tree, SmolStr}, theme, widget::{ self, @@ -1071,6 +1071,7 @@ pub enum Message { SearchContext(Location, SearchContextWrapper), SearchReady(bool), SelectAll, + SelectNextPrefix(SmolStr), SetSort(HeadingOptions, bool), Thumbnail(PathBuf, ItemThumbnail), ToggleShowHidden, @@ -1857,6 +1858,68 @@ impl Tab { } } + pub fn select_next_prefix(&mut self, prefix: &str) { + *self.cached_selected.borrow_mut() = None; + if let Some(ref mut items) = self.items_opt { + // Special case: when all entered characters are the same, only search for said character. + // This allows quickly cycling through items starting with the same character. + let term = match prefix.chars().next() { + Some(first) if prefix.chars().all(|c| c == first) => &prefix[..1], + Some(_) => prefix, + None => return (), + }; + + // Have to add 1 to the start index when not reversing because the + // currently selected item should be included in until so it gets + // considered last instead of first. + // When ordered reverse, we don't want to do so because moving it + // last would put it as first item when iterating in reverse. + let start = self + .select_focus + .map_or(0, |i| if self.sort_direction { i + 1 } else { i }); + let (until, after) = items.split_at_mut(start); + + // First iterate over all items after the current selection, then wrap around + let iter = after + .iter_mut() + .enumerate() + .map(|x| (x.0 + start, x.1)) + .chain(until.iter_mut().enumerate()); + + let found = if self.sort_direction { + Self::select_first_prefix(term, iter, self.config.show_hidden) + } else { + Self::select_first_prefix(term, iter.rev(), self.config.show_hidden) + }; + + if found.is_some() { + self.select_focus = found; + } else if let Some(focus) = self.select_focus { + items[focus].selected = true; + }; + } + } + + fn select_first_prefix<'a>( + prefix: &str, + iterator: impl Iterator, + consider_hidden: bool, + ) -> Option { + let mut selected = None; + for (i, item) in iterator { + if selected.is_none() + && (!item.hidden || consider_hidden) + && item.display_name.to_lowercase().starts_with(prefix) + { + selected = Some(i); + item.selected = true; + } else { + item.selected = false; + } + } + selected + } + pub fn select_path(&mut self, path: PathBuf) { let location = Location::Path(path); *self.cached_selected.borrow_mut() = None; @@ -2807,6 +2870,17 @@ impl Tab { )); } } + Message::SelectNextPrefix(s) => { + self.select_next_prefix(&s); + if let Some(offset) = self.select_focus_scroll() { + commands.push(Command::Iced( + scrollable::scroll_to(self.scrollable_id.clone(), offset).into(), + )); + } + if let Some(id) = self.select_focus_id() { + commands.push(Command::Iced(widget::button::focus(id).into())); + } + } Message::SetSort(heading_option, dir) => { if !matches!(self.location, Location::Search(..)) { self.sort_name = heading_option;