From 8adf546d5ceafb78624a3b21d7995b3ab3afb8a1 Mon Sep 17 00:00:00 2001 From: RivoLink Date: Sat, 30 May 2026 14:37:43 +0300 Subject: [PATCH] feat: goto line with ctrl+l --- README.md | 2 + src/app/content.rs | 3 ++ src/app/goto_line.rs | 92 +++++++++++++++++++++++++++++++++++++++++ src/app/mod.rs | 19 +++++++++ src/render/content.rs | 23 ++++++++--- src/render/popup.rs | 36 +++++++++------- src/render/status.rs | 44 +++++++++++++++++++- src/runtime/keyboard.rs | 23 ++++++++++- 8 files changed, 220 insertions(+), 22 deletions(-) create mode 100644 src/app/goto_line.rs diff --git a/README.md b/README.md index d490b3c..4c5cd99 100644 --- a/README.md +++ b/README.md @@ -232,8 +232,10 @@ See [`gruvbox.toml`](gruvbox.toml) for a complete example with all available col | `Shift+Sel` | Select text | | `Shift+T` | Open theme picker | | `Shift+E` | Open editor picker | +| `Shift+L` | Toggle line numbers | | `Shift+P` | Open file browser | | `Ctrl+E` | Open in editor | +| `Ctrl+L` | Go to line | | `Ctrl+P` | Open fuzzy picker | | `Ctrl+F` / `/` | Find | | `Ctrl+Click` | Open link | diff --git a/src/app/content.rs b/src/app/content.rs index 34f2722..8a7905f 100644 --- a/src/app/content.rs +++ b/src/app/content.rs @@ -119,6 +119,7 @@ impl App { self.theme_picker.open = false; self.search.mode = false; self.reset_search_state(); + self.clear_active_goto_line(); self.invalidate_theme_preview_cache(); self.store_current_theme_preview_from(&lines, &toc); self.replace_content(lines, toc, link_spans, block_starts); @@ -148,6 +149,8 @@ impl App { self.invalidate_theme_preview_cache(); self.store_current_theme_preview_from(&new_lines, &new_toc); self.replace_content(new_lines, new_toc, link_spans, block_starts); + self.goto_line.target = None; + self.goto_line.error = false; if !self.search.query.is_empty() && !self.search.mode { self.run_search(); } diff --git a/src/app/goto_line.rs b/src/app/goto_line.rs new file mode 100644 index 0000000..70442c8 --- /dev/null +++ b/src/app/goto_line.rs @@ -0,0 +1,92 @@ +use super::App; +use crate::markdown::hash_str; + +const GOTO_LINE_CONTEXT_OFFSET: usize = 5; + +pub(crate) struct GotoLineState { + pub(super) mode: bool, + pub(super) draft: String, + pub(super) target: Option, + pub(super) error: bool, + pub(super) draft_hash: u64, +} + +impl App { + pub(crate) fn is_goto_line_mode(&self) -> bool { + self.goto_line.mode + } + + pub(crate) fn goto_line_draft(&self) -> &str { + &self.goto_line.draft + } + + pub(crate) fn has_active_goto_line(&self) -> bool { + self.goto_line.target.is_some() || self.goto_line.error + } + + pub(crate) fn goto_line_error(&self) -> bool { + self.goto_line.error + } + + pub(crate) fn goto_line_target(&self) -> Option { + self.goto_line.target + } + + pub(crate) fn begin_goto_line(&mut self) { + self.reset_numkey_state(); + self.clear_active_search(); + self.goto_line.mode = true; + self.goto_line.draft.clear(); + self.goto_line.draft_hash = 0; + self.goto_line.error = false; + self.goto_line.target = None; + self.line_number_visible = true; + } + + pub(crate) fn push_goto_draft(&mut self, ch: char) { + if ch.is_ascii_digit() { + self.goto_line.draft.push(ch); + self.goto_line.draft_hash = hash_str(&self.goto_line.draft); + } + } + + pub(crate) fn pop_goto_draft(&mut self) { + self.goto_line.draft.pop(); + self.goto_line.draft_hash = hash_str(&self.goto_line.draft); + } + + pub(crate) fn confirm_goto_line(&mut self) { + self.goto_line.mode = false; + if self.goto_line.draft.is_empty() { + self.clear_active_goto_line(); + return; + } + let logical = match self.goto_line.draft.parse::() { + Ok(n) if n >= 1 => n, + _ => { + self.goto_line.error = true; + return; + } + }; + if let Some(render_index) = self.find_render_index_for_logical(logical) { + self.goto_line.target = Some(render_index); + self.goto_line.error = false; + let scroll_pos = render_index.saturating_sub(GOTO_LINE_CONTEXT_OFFSET); + self.scroll = scroll_pos.min(self.max_scroll()); + } else { + self.goto_line.error = true; + } + } + + pub(crate) fn clear_active_goto_line(&mut self) { + self.goto_line.mode = false; + self.goto_line.draft.clear(); + self.goto_line.target = None; + self.goto_line.error = false; + self.goto_line.draft_hash = 0; + } + + fn find_render_index_for_logical(&self, logical: usize) -> Option { + self.line_number_map.iter().position(|&n| n == logical) + } +} diff --git a/src/app/mod.rs b/src/app/mod.rs index 27420a7..55bd459 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -17,6 +17,9 @@ use std::{ mod search; pub(crate) use search::SearchState; +mod goto_line; +pub(crate) use goto_line::GotoLineState; + mod file_picker; mod fuzzy; pub(crate) use file_picker::{FilePickerMode, FilePickerState, PickerIndexTruncation}; @@ -51,6 +54,10 @@ pub(crate) struct StatusCacheKey { search_query_len: usize, search_match_count: usize, search_idx: usize, + goto_line_mode: bool, + goto_line_draft_hash: u64, + goto_line_target: Option, + goto_line_error: bool, watch: bool, flash_active: bool, editor_flash_active: bool, @@ -81,6 +88,7 @@ pub(crate) struct App { line_number_map: Vec, line_number_visible: bool, pub(super) search: SearchState, + pub(super) goto_line: GotoLineState, pub(super) debug_input: bool, pub(super) filename: String, pub(super) source: String, @@ -198,6 +206,13 @@ impl App { draft_hash: 0, query_hash: 0, }, + goto_line: GotoLineState { + mode: false, + draft: String::new(), + target: None, + error: false, + draft_hash: 0, + }, debug_input, filename, source, @@ -488,6 +503,10 @@ impl App { search_query_len: self.search.query.len(), search_match_count: self.search.matches.len(), search_idx: self.search.idx, + goto_line_mode: self.goto_line.mode, + goto_line_draft_hash: self.goto_line.draft_hash, + goto_line_target: self.goto_line.target, + goto_line_error: self.goto_line.error, watch: self.watch, flash_active: self .reload_flash diff --git a/src/render/content.rs b/src/render/content.rs index df160bf..dc020fd 100644 --- a/src/render/content.rs +++ b/src/render/content.rs @@ -54,19 +54,32 @@ pub(super) fn render_content_panel(f: &mut Frame, app: &mut App, area: Rect) { if app.is_line_number_visible() { let digit_width = app.line_number_total().max(1).to_string().len(); - let blank_gutter = format!("{:>w$}│ ", "", w = digit_width); let gutter_style = Style::default().fg(theme.markdown.code_gutter); + let goto_target = app.goto_line_target(); + let highlight_bg = theme.markdown.search_highlight_bg; for (i, line) in visible_lines.iter_mut().enumerate() { let idx = scroll + i; let logical = app.line_number_at(idx); let is_first = logical > 0 && (idx == 0 || app.line_number_at(idx.saturating_sub(1)) != logical); - let gutter = if is_first { - format!("{:>w$}│ ", logical, w = digit_width) + let is_goto_target = goto_target == Some(idx); + let num_part = if is_first { + format!("{:>w$}", logical, w = digit_width) } else { - blank_gutter.clone() + format!("{:>w$}", "", w = digit_width) }; - line.spans.insert(0, Span::styled(gutter, gutter_style)); + if is_goto_target { + let hl = gutter_style.bg(highlight_bg); + line.spans.insert(0, Span::styled("│ ", hl)); + line.spans + .insert(0, Span::styled(num_part, hl.fg(Color::White))); + for span in &mut line.spans[2..] { + span.style = span.style.bg(highlight_bg); + } + } else { + let gutter = format!("{num_part}│ "); + line.spans.insert(0, Span::styled(gutter, gutter_style)); + } } } diff --git a/src/render/popup.rs b/src/render/popup.rs index 5f4dd95..3c114df 100644 --- a/src/render/popup.rs +++ b/src/render/popup.rs @@ -97,26 +97,29 @@ pub(super) fn render_help_popup(f: &mut Frame, _app: &App) { )]), Line::from(""), Line::from(vec![Span::styled( - "Navigation Search", + "Navigation Mouse", section_style, )]), Line::from(vec![ Span::styled("j/k, ↑/↓ ", key_style), Span::styled("scroll", text_style), Span::raw(" "), - Span::styled("ctrl+f ", key_style), - Span::styled("find", text_style), + Span::styled("dbl-click ", key_style), + Span::styled("copy link", text_style), ]), Line::from(vec![ Span::styled("u/d ", key_style), Span::styled("page up/down", text_style), Span::raw(" "), - Span::styled("n/N ", key_style), - Span::styled("next/prev", text_style), + Span::styled("ctrl+click ", key_style), + Span::styled("open link", text_style), ]), Line::from(vec![ Span::styled("g/G ", key_style), Span::styled("top/bottom", text_style), + Span::raw(" "), + Span::styled("shift+slct ", key_style), + Span::styled("select text", text_style), ]), Line::from(vec![ Span::styled("1-9/0+1-9 ", key_style), @@ -124,27 +127,23 @@ pub(super) fn render_help_popup(f: &mut Frame, _app: &App) { ]), Line::from(""), Line::from(vec![ - Span::styled("Mouse ", section_style), + Span::styled("Search ", section_style), Span::styled("Watch", section_style), ]), Line::from(vec![ - Span::styled("dbl-click ", key_style), - Span::styled("copy link", text_style), - Span::raw(" "), + Span::styled("ctrl+f ", key_style), + Span::styled("find", text_style), + Span::raw(" "), Span::styled("ctrl+w, w ", key_style), Span::styled("toggle", text_style), ]), Line::from(vec![ - Span::styled("ctrl+click ", key_style), - Span::styled("open link", text_style), + Span::styled("n/N ", key_style), + Span::styled("next/prev", text_style), Span::raw(" "), Span::styled("ctrl+r, r ", key_style), Span::styled("reload", text_style), ]), - Line::from(vec![ - Span::styled("shift+slct ", key_style), - Span::styled("select text", text_style), - ]), Line::from(""), Line::from(vec![Span::styled("Actions", section_style)]), Line::from(vec![ @@ -154,6 +153,13 @@ pub(super) fn render_help_popup(f: &mut Frame, _app: &App) { Span::styled("ctrl+e ", key_style), Span::styled("edit", text_style), ]), + Line::from(vec![ + Span::styled("shift+l ", key_style), + Span::styled("line number", text_style), + Span::raw(" "), + Span::styled("ctrl+l ", key_style), + Span::styled("goto", text_style), + ]), Line::from(vec![ Span::styled("shift+p ", key_style), Span::styled("file browser", text_style), diff --git a/src/render/status.rs b/src/render/status.rs index 9cf24ba..941918f 100644 --- a/src/render/status.rs +++ b/src/render/status.rs @@ -140,9 +140,47 @@ pub(crate) fn status_search_section(app: &App) -> Option>> { Some(vec![span]) } +pub(crate) fn status_goto_line_section(app: &App) -> Option>> { + let theme = app_theme(); + if app.is_goto_line_mode() { + return Some(vec![Span::styled( + format!(" :{} ", app.goto_line_draft()), + Style::default() + .fg(theme.ui.status_search_fg) + .bg(theme.ui.status_search_bg), + )]); + } + + if !app.has_active_goto_line() { + return None; + } + + let span = if app.goto_line_error() { + Span::styled( + format!(" ✗ :{} ", app.goto_line_draft()), + Style::default() + .fg(theme.ui.status_error_fg) + .bg(theme.ui.status_error_bg), + ) + } else if let Some(target) = app.goto_line_target() { + let logical = app.line_number_at(target); + Span::styled( + format!(" :{} ", logical), + Style::default() + .fg(theme.ui.status_success_fg) + .bg(theme.ui.status_success_bg), + ) + } else { + return None; + }; + Some(vec![span]) +} + pub(crate) fn status_hint_segments(app: &App) -> &'static [&'static str] { - if app.is_search_mode() { + if app.is_goto_line_mode() || app.is_search_mode() { &["enter confirm", "esc cancel"] + } else if app.has_active_goto_line() { + &["esc cancel"] } else if app.has_active_search() { &["n/N next/prev", "esc cancel"] } else { @@ -291,6 +329,10 @@ pub(crate) fn build_status_bar(app: &App, pct: u16) -> Vec> { left_section.extend(section); } + if let Some(section) = status_goto_line_section(app) { + left_section.extend(section); + } + let file_open = app.has_content() || (!app.is_file_picker_open() && !app.is_picker_loading()); if file_open { if let Some(section) = status_watch_section(app) { diff --git a/src/runtime/keyboard.rs b/src/runtime/keyboard.rs index 43a620c..01800a7 100644 --- a/src/runtime/keyboard.rs +++ b/src/runtime/keyboard.rs @@ -206,6 +206,17 @@ pub(super) fn handle_key_event( } _ => state_changed = false, } + } else if app.is_goto_line_mode() { + match key.code { + KeyCode::Esc => app.clear_active_goto_line(), + KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => { + app.clear_active_goto_line(); + } + KeyCode::Enter => app.confirm_goto_line(), + KeyCode::Backspace => app.pop_goto_draft(), + KeyCode::Char(c) => app.push_goto_draft(c), + _ => state_changed = false, + } } else if app.is_search_mode() { match key.code { KeyCode::Esc => app.cancel_search(), @@ -219,6 +230,7 @@ pub(super) fn handle_key_event( } } else { match key.code { + KeyCode::Esc if app.has_active_goto_line() => app.clear_active_goto_line(), KeyCode::Esc if app.has_active_search() => app.clear_active_search(), KeyCode::Enter if app.has_active_search() => app.next_match(), KeyCode::Char('q') if key.modifiers.contains(KeyModifiers::CONTROL) => { @@ -228,6 +240,8 @@ pub(super) fn handle_key_event( KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => { if app.has_active_search() { app.clear_active_search(); + } else if app.has_active_goto_line() { + app.clear_active_goto_line(); } else { return Ok(HandleResult::Break); } @@ -275,9 +289,13 @@ pub(super) fn handle_key_event( } } KeyCode::Char('f') if key.modifiers.contains(KeyModifiers::CONTROL) => { + app.clear_active_goto_line(); + app.begin_search() + } + KeyCode::Char('/') => { + app.clear_active_goto_line(); app.begin_search() } - KeyCode::Char('/') => app.begin_search(), KeyCode::Char('n') => app.next_match(), KeyCode::Char('N') => app.prev_match(), KeyCode::Char('R') => { @@ -286,6 +304,9 @@ pub(super) fn handle_key_event( KeyCode::Char('A') => { app.copy_path_to_clipboard_absolute(); } + KeyCode::Char('l') if key.modifiers.contains(KeyModifiers::CONTROL) => { + app.begin_goto_line() + } KeyCode::Char('l') | KeyCode::Char('L') => app.toggle_line_numbers(), KeyCode::Char('e') if key.modifiers.contains(KeyModifiers::CONTROL) => { handle_open_in_editor(terminal, app, ss, themes)?;