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.
This commit is contained in:
2026-07-06 21:42:00 +03:00
parent daa126cc07
commit f568f9cbb2
2 changed files with 113 additions and 0 deletions
@@ -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 {
@@ -63,6 +63,10 @@ pub struct SearchState {
last_match: Option<Range<usize>>,
history: Vec<String>,
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<usize>,
}
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<String> {
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<Option<String>> {
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());
}
}