From f568f9cbb2ecce1a8061d1f8c507c2177d9e4612 Mon Sep 17 00:00:00 2001 From: vasilito Date: Mon, 6 Jul 2026 21:42:00 +0300 Subject: [PATCH] W31: Search history with Up/Down arrow keys MC has search history navigation in the find prompt via arrow keys. TLC's SearchState already had a history() list but no way to navigate it without using the Alt-/ popup. Add history_up() / history_down() / reset_history_cursor() to SearchState. Wire Up/Down in the Find/Replace prompt to cycle through the history. Down past the newest entry clears the prompt back to the live pattern. Added 3 unit tests covering: walk backwards, walk forward with stop signal, empty history. Tests: 1396 pass (was 1393), zero warnings. --- .../tui/tlc/source/src/editor/handlers.rs | 32 ++++++++ .../tui/tlc/source/src/editor/search.rs | 81 +++++++++++++++++++ 2 files changed, 113 insertions(+) diff --git a/local/recipes/tui/tlc/source/src/editor/handlers.rs b/local/recipes/tui/tlc/source/src/editor/handlers.rs index 75a23959ca..ef3e45cc17 100644 --- a/local/recipes/tui/tlc/source/src/editor/handlers.rs +++ b/local/recipes/tui/tlc/source/src/editor/handlers.rs @@ -857,6 +857,38 @@ impl Editor { } } } + // Up/Down arrows in the Find/Replace prompt cycle through + // the search history. + if let Mode::Prompt(PromptKind::Find | PromptKind::Replace) = self.mode { + match key { + Key::UP => { + if let Some(p) = self.search.history_up() { + self.prompt_input.text = p; + self.prompt_input.cursor = self.prompt_input.text.chars().count(); + } + return EditorResult::Running; + } + Key::DOWN => { + if let Some(p) = self.search.history_down() { + match p { + Some(s) => { + self.prompt_input.text = s; + self.prompt_input.cursor = + self.prompt_input.text.chars().count(); + } + None => { + // Past newest entry: clear the + // prompt back to the live pattern. + self.prompt_input.clear(); + self.search.reset_history_cursor(); + } + } + } + return EditorResult::Running; + } + _ => {} + } + } // Alt-/ opens the search-history popup when the active // prompt is Find or Replace. Other prompt kinds ignore it. if key.mods == Modifiers::ALT && key.code == b'/' as u32 { diff --git a/local/recipes/tui/tlc/source/src/editor/search.rs b/local/recipes/tui/tlc/source/src/editor/search.rs index 99c2326556..af7bd0cc68 100644 --- a/local/recipes/tui/tlc/source/src/editor/search.rs +++ b/local/recipes/tui/tlc/source/src/editor/search.rs @@ -63,6 +63,10 @@ pub struct SearchState { last_match: Option>, history: Vec, last_wrapped: bool, + /// Position within `history` when the user is scrolling through + /// past patterns with Up/Down. `None` means "not navigating" + /// (showing the live pattern, not a history entry). + history_cursor: Option, } impl SearchState { @@ -148,6 +152,45 @@ impl SearchState { &self.history } + /// Move backward through the history (Up arrow in the find + /// prompt). Returns the pattern to display, or `None` if the + /// history is empty. + pub fn history_up(&mut self) -> Option { + if self.history.is_empty() { + return None; + } + let next = match self.history_cursor { + None => self.history.len().checked_sub(1)?, + Some(0) => 0, + Some(c) => c - 1, + }; + self.history_cursor = Some(next); + Some(self.history[next].clone()) + } + + /// Move forward through the history (Down arrow). Returns the + /// pattern to display, or `None` to signal "stop navigating, + /// back to the live pattern". + pub fn history_down(&mut self) -> Option> { + match self.history_cursor { + None => Some(None), + Some(c) if c + 1 >= self.history.len() => { + self.history_cursor = None; + Some(None) + } + Some(c) => { + self.history_cursor = Some(c + 1); + Some(Some(self.history[c + 1].clone())) + } + } + } + + /// Reset history navigation (call when the find prompt is + /// closed or the pattern is committed). + pub fn reset_history_cursor(&mut self) { + self.history_cursor = None; + } + /// Find the next match starting from byte position `from` in /// `buf`. Wraps around: if no match is found at or after `from`, /// the search continues from position 0. Updates @@ -651,4 +694,42 @@ mod tests { assert_eq!(m2.len(), 0); assert!(m2.is_empty()); } + + #[test] + fn history_up_walks_backwards_through_entries() { + let mut s = SearchState::new(); + s.set_pattern("a".to_string()); + s.push_history(); + s.set_pattern("b".to_string()); + s.push_history(); + s.set_pattern("c".to_string()); + s.push_history(); + assert_eq!(s.history_up(), Some("c".to_string())); + assert_eq!(s.history_up(), Some("b".to_string())); + assert_eq!(s.history_up(), Some("a".to_string())); + // Stays at oldest entry on further Up. + assert_eq!(s.history_up(), Some("a".to_string())); + } + + #[test] + fn history_down_walks_forward_and_signals_stop() { + let mut s = SearchState::new(); + s.set_pattern("a".to_string()); + s.push_history(); + s.set_pattern("b".to_string()); + s.push_history(); + s.set_pattern("c".to_string()); + s.push_history(); + let _ = s.history_up(); // -> "c" + let _ = s.history_up(); // -> "b" + assert_eq!(s.history_down(), Some(Some("c".to_string()))); + // Past the newest entry: Down signals stop (None). + assert_eq!(s.history_down(), Some(None)); + } + + #[test] + fn history_up_on_empty_history_returns_none() { + let mut s = SearchState::new(); + assert!(s.history_up().is_none()); + } }