W34: Goto line history with Up/Down arrows

Similar to the search history (W31), the goto-line prompt now
keeps a history of recently-visited line numbers. Up/Down arrows
cycle through the history; Down past the newest entry clears
back to the live input.

- Editor::goto_history: Vec<u32> field (max 50 entries)
- Editor::goto_history_cursor: Option<usize> navigation position
- Editor::push_goto_history(line) / goto_history_up() /
  goto_history_down() / reset_goto_history_cursor()
- GotoLine prompt commit pushes the line to history
- Up/Down arrows in the GotoLine prompt cycle through history
- push_goto_history skips consecutive duplicates

Added 2 unit tests covering: walk backwards/forwards, dedup.

Tests: 1399 pass (was 1397), zero warnings.
This commit is contained in:
2026-07-06 22:43:08 +03:00
parent 4826d9f950
commit 0a16b05232
2 changed files with 125 additions and 0 deletions
@@ -889,6 +889,38 @@ impl Editor {
_ => {}
}
}
// Up/Down arrows in the GotoLine prompt cycle through the
// goto-line history.
if let Mode::Prompt(PromptKind::GotoLine) = self.mode {
match key {
Key::UP => {
if let Some(line) = self.goto_history_up() {
self.prompt_input.text = line.to_string();
self.prompt_input.cursor = self.prompt_input.text.chars().count();
}
return EditorResult::Running;
}
Key::DOWN => {
if let Some(p) = self.goto_history_down() {
match p {
Some(line) => {
self.prompt_input.text = line.to_string();
self.prompt_input.cursor =
self.prompt_input.text.chars().count();
}
None => {
// Past newest entry: clear the
// prompt back to the live input.
self.prompt_input.clear();
self.reset_goto_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 {
@@ -972,9 +1004,11 @@ impl Editor {
if let Ok(off) = crate::editor::goto::line_to_offset(&self.buffer, n) {
self.buffer.set_cursor(off);
self.cursor.set_position(self.buffer.cursor(), &self.buffer);
self.push_goto_history(n);
}
}
}
self.reset_goto_history_cursor();
}
PromptKind::GotoCol => {
// 1-based column on the **current** line. We read
@@ -120,6 +120,14 @@ pub struct Editor {
modified: bool,
/// Recently opened files (most recent last).
history: History,
/// Recently visited line numbers via the goto-line prompt
/// (most recent last). Up/Down arrows in the prompt cycle
/// through this list. The cursor position is `None` when the
/// user is editing the live input.
goto_history: Vec<u32>,
/// Position within `goto_history` when navigating with Up/Down.
/// `None` means "not navigating — show the live input".
goto_history_cursor: Option<usize>,
/// Status line message (set by the last action).
message: Option<String>,
/// Current mode — Normal/Insert/Prompt(PromptKind). The
@@ -254,6 +262,7 @@ impl Editor {
let title = format!(" {} {} ", crate::locale::t("dialog_title_editor"), path.display());
let mut history = History::with_capacity(20);
history.push(&path);
let goto_history: Vec<u32> = Vec::new();
#[cfg(feature = "syntect")]
let hl = crate::editor::syntax::Highlighter::new(&path);
Self {
@@ -264,6 +273,8 @@ impl Editor {
path: Some(path),
modified: false,
history,
goto_history,
goto_history_cursor: None,
message: None,
mode: Mode::Insert,
prompt_input: PromptInput::new(),
@@ -314,6 +325,8 @@ impl Editor {
path: None,
modified: false,
history: History::with_capacity(20),
goto_history: Vec::new(),
goto_history_cursor: None,
message: Some("New buffer — Ctrl-S to save".to_string()),
mode: Mode::Insert,
prompt_input: PromptInput::new(),
@@ -856,6 +869,58 @@ impl Editor {
}
}
/// Push a successful goto-line result to the history. No-op if
/// the line is identical to the most recent entry.
pub fn push_goto_history(&mut self, line: u32) {
if self.goto_history.last() == Some(&line) {
return;
}
self.goto_history.push(line);
if self.goto_history.len() > 50 {
let drop = self.goto_history.len() - 50;
self.goto_history.drain(0..drop);
}
self.goto_history_cursor = None;
}
/// Move backward through the goto history (Up arrow). Returns
/// the line number to display, or `None` if empty.
pub fn goto_history_up(&mut self) -> Option<u32> {
if self.goto_history.is_empty() {
return None;
}
let next = match self.goto_history_cursor {
None => self.goto_history.len().checked_sub(1)?,
Some(0) => 0,
Some(c) => c - 1,
};
self.goto_history_cursor = Some(next);
Some(self.goto_history[next])
}
/// Move forward through the goto history (Down arrow). Returns
/// `Some(Some(n))` to show a line, `Some(None)` to clear back
/// to the live input, or `None` if not navigating.
pub fn goto_history_down(&mut self) -> Option<Option<u32>> {
match self.goto_history_cursor {
None => Some(None),
Some(c) if c + 1 >= self.goto_history.len() => {
self.goto_history_cursor = None;
Some(None)
}
Some(c) => {
self.goto_history_cursor = Some(c + 1);
Some(Some(self.goto_history[c + 1]))
}
}
}
/// Reset goto history navigation (call when the goto prompt
/// is closed or the line is committed).
pub fn reset_goto_history_cursor(&mut self) {
self.goto_history_cursor = None;
}
fn update_bracket_flash(&mut self) {
let pos = self.cursor.position();
let text = self.buffer.as_string();
@@ -3747,5 +3812,31 @@ mod tests {
// still the same (or slightly less, since time passed).
assert!(frac > 0.0 && frac <= 1.0);
}
#[test]
fn goto_history_walks_backwards_and_forwards() {
let mut e = make_empty();
e.push_goto_history(10);
e.push_goto_history(20);
e.push_goto_history(30);
// Up walks from newest to oldest.
assert_eq!(e.goto_history_up(), Some(30));
assert_eq!(e.goto_history_up(), Some(20));
assert_eq!(e.goto_history_up(), Some(10));
// Down walks back forward through every entry, then
// signals stop past the newest.
assert_eq!(e.goto_history_down(), Some(Some(20)));
assert_eq!(e.goto_history_down(), Some(Some(30)));
assert_eq!(e.goto_history_down(), Some(None));
}
#[test]
fn goto_history_skips_duplicates() {
let mut e = make_empty();
e.push_goto_history(10);
e.push_goto_history(10);
e.push_goto_history(20);
assert_eq!(e.goto_history, vec![10, 20]);
}
}