diff --git a/README.md b/README.md index 7c0ce6e..70494a6 100644 --- a/README.md +++ b/README.md @@ -200,7 +200,7 @@ the reader and the file browser. `w`/`b` (word), `0`/`$` (line ends), `g`/`G` (document ends), `Ctrl+d`/`Ctrl+u` (half page). **`y`** copies and exits; `Esc` cancels. - **Drag with the mouse** to select, release to copy. Double-click takes the word, triple-click the - line. + line. A single click on a link follows it instead (see below). - **`c`** labels every code block on screen — press its letter to copy the block's *raw* source: no borders, no line numbers, no syntax-highlighting escapes. - **`Y`** copies the markdown source of the section you are reading (heading included, down to the @@ -219,6 +219,8 @@ they select what you can see. Reach for `c` and `Y` when you want the source ins Press `f` to label every link on screen with a letter; the popup lists each link's text next to its URL, so you can tell which is which. Press that letter to open web and mail links in your browser, or to follow a relative `.md` link or `#heading` anchor right inside ink (`[` goes back). +Clicking works too: a plain click on a link (press and release without moving) does exactly what its letter would. Dragging across a link still selects it. With `--no-mouse` ink leaves clicks to your terminal, which opens links its own way (usually Cmd- or Ctrl-click). + ### Help overlay Press `?` any time for a popup listing every active keybinding — including your own overrides. @@ -227,6 +229,8 @@ Press `?` any time for a popup listing every active keybinding — including you Press `t` to toggle a sidebar showing every heading in the document. Tracks your position as you scroll. +Press `o` to move into it (it opens if it was closed). `j`/`k` or the arrows move, `g`/`G` go to the first and last heading, `Ctrl+d`/`Ctrl+u` page, and `Enter` jumps there — `[` brings you back. `h`/`l` fold and unfold a section's subheadings in the sidebar, and `/` filters the list as you type (every word you type must appear, case doesn't matter). `Esc` clears the filter; `Esc` again, or `o`, goes back to the document without moving. With the mouse, click a heading to jump to it; the wheel over the sidebar scrolls the sidebar, not the page. + ### 8 built-in themes Dark, Light, Dracula, Catppuccin, Nord, Tokyo Night, Gruvbox, and Solarized. Press `T` to open the theme picker and preview each one live. @@ -306,7 +310,9 @@ ink diff old.md new.md | `v` / `V` | Select text — character-wise / line-wise (`y` copies, `Esc` cancels) | | `c` | Copy a code block by letter | | `Y` | Copy the current section as markdown | +| `e` | Edit the file in `$VISUAL` / `$EDITOR` (opens at the heading you are reading, reloads when you quit the editor) | | `t` | Toggle table of contents | +| `o` | Focus the table of contents (`j`/`k` move, `Enter` jumps, `h`/`l` fold, `/` filters, `Esc` returns) | | `T` | Theme picker (choice is saved to config) | | `?` | Help overlay | | `Enter` | Follow first visible link | @@ -326,6 +332,8 @@ preset = "emacs" # default | vim | emacs [keybindings.bindings] # Per-action overrides, applied on top of the preset. toggle_toc = ["ctrl-t"] +toc_focus = ["ctrl-o"] +edit = ["alt-e"] ``` The **emacs** preset binds `Ctrl+N`/`Ctrl+P` (line nav), `Ctrl+V`/`Alt+V` (page nav), `Ctrl+A`/`Ctrl+E` (home/end), `Ctrl+S` (search), `Ctrl+F`/`Ctrl+B` (next/prev heading), and `Ctrl+X Ctrl+C` (chord exit). diff --git a/docs/llms-full.txt b/docs/llms-full.txt index 8006d55..015f1c2 100644 --- a/docs/llms-full.txt +++ b/docs/llms-full.txt @@ -53,8 +53,10 @@ ink is a feature-rich terminal markdown reader built in Rust. It renders markdow - Keyboard link opening: press f to label on-screen links, then a letter to open web/mail links or follow a relative .md link - Help overlay: press ? for a popup of the active keybindings - Table of contents sidebar showing heading structure, tracks scroll position +- Interactive TOC: o focuses it (opening it if closed); j/k/arrows move, g/G first/last, Ctrl+d/Ctrl+u page, Enter jumps (recorded in back/forward history), h/l fold subheadings, / filters headings as you type, Esc returns; click a heading to jump, the wheel over the sidebar scrolls it - Navigation history with back/forward ([ ] keys or Alt+Arrow) -- Mouse wheel scrolling, plus drag-to-select (can be disabled via behavior.mouse_capture so the terminal handles clicks/selection instead) +- e opens the current local file in $VISUAL / $EDITOR (fallback vi; notepad on Windows) at the line of the heading being read (+N for vi/vim/nvim/nano/emacs/kak/micro, --goto file:N for VS Code/Codium/Cursor, file:N for subl/zed/hx), then reloads it and keeps the position; stdin and URL documents cannot be edited +- Mouse wheel scrolling, click a link to follow it (same path as f: browser for web/mail, in-app for .md and #anchor), plus drag-to-select (can be disabled via behavior.mouse_capture so the terminal handles clicks/selection instead) - Event-driven redraw (no CPU when idle) and virtualized rendering for large documents - Responsive to terminal resizing; shows a message instead of breaking on very small terminals diff --git a/src/app.rs b/src/app.rs index 9277a01..df314e5 100644 --- a/src/app.rs +++ b/src/app.rs @@ -34,6 +34,10 @@ struct Tab { /// The markdown actually parsed: frontmatter stripped, wikilinks expanded. /// Heading `source_line`s index into this, so `Y` slices sections from it. content: String, + /// Lines of `source` above `content` (a stripped frontmatter block), so a + /// `content` line maps back to a line of the file; `None` when that + /// mapping is not known to hold. + line_offset: Option, styled_lines: Vec, ratatui_lines: Vec>, /// Per-line rendered text. Selection columns, clipboard extraction, and @@ -206,6 +210,19 @@ pub(crate) fn restore_terminal() -> io::Result<()> { raw.and(screen) } +/// Take the terminal over for the TUI: raw mode, alternate screen, and mouse +/// reporting when `mouse_capture` is on. The counterpart of +/// [`restore_terminal`], shared by startup (reader and file browser) and the +/// return from `$EDITOR`. +pub(crate) fn enter_tui(mouse_capture: bool) -> io::Result<()> { + enable_raw_mode()?; + execute!(io::stdout(), EnterAlternateScreen)?; + if mouse_capture { + execute!(io::stdout(), EnableMouseCapture)?; + } + Ok(()) +} + /// Restore the terminal before a panic reaches the default handler. /// /// The normal restore path runs after `run_inner` returns, which a panic skips @@ -235,36 +252,122 @@ mod term_signal { use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; use std::sync::{Arc, OnceLock}; + use signal_hook::SigId; + use std::sync::Mutex; + struct Flags { which: Arc, any: Arc, + /// The SIGINT registrations, so they can be paused while an + /// external editor owns the terminal. + interrupt: Mutex>, } static FLAGS: OnceLock = OnceLock::new(); + fn register(sig: i32, flags: &Flags) -> Vec { + // A second signal while the first is still pending (the loop is + // stuck) terminates at once instead of being swallowed. + [ + signal_hook::flag::register_conditional_shutdown( + sig, + 128 + sig, + Arc::clone(&flags.any), + ), + signal_hook::flag::register(sig, Arc::clone(&flags.any)), + signal_hook::flag::register_usize(sig, Arc::clone(&flags.which), sig as usize), + ] + .into_iter() + .flatten() + .collect() + } + pub fn install() { FLAGS.get_or_init(|| { use signal_hook::consts::{SIGHUP, SIGINT, SIGTERM}; let flags = Flags { which: Arc::new(AtomicUsize::new(0)), any: Arc::new(AtomicBool::new(false)), + interrupt: Mutex::new(Vec::new()), }; - for sig in [SIGTERM, SIGHUP, SIGINT] { - // A second signal while the first is still pending (the loop - // is stuck) terminates at once instead of being swallowed. - let _ = signal_hook::flag::register_conditional_shutdown( - sig, - 128 + sig, - Arc::clone(&flags.any), - ); - let _ = signal_hook::flag::register(sig, Arc::clone(&flags.any)); - let _ = - signal_hook::flag::register_usize(sig, Arc::clone(&flags.which), sig as usize); + for sig in [SIGTERM, SIGHUP] { + register(sig, &flags); + } + let ids = register(SIGINT, &flags); + if let Ok(mut held) = flags.interrupt.lock() { + *held = ids; } + let _ = std::thread::Builder::new() + .name("ink-signal-watchdog".into()) + .spawn(watchdog); flags }); } + /// Set while an external editor owns the terminal. + static SUSPENDED: AtomicBool = AtomicBool::new(false); + + /// How long a signal may stay pending before the watchdog gives up on + /// the event loop. + const GRACE: std::time::Duration = std::time::Duration::from_secs(1); + + /// The event loop notices a signal within one 50 ms poll — unless it is + /// stuck where it cannot look. crossterm's event reader spins forever on + /// a terminal that has hung up (a closed SSH session, a killed tmux): + /// its read loop never sees `WouldBlock` again. The SIGHUP that came + /// with the hangup then sat unread and ink burned a core indefinitely. + /// If a signal is still pending after `GRACE`, restore what can be + /// restored and exit with the status the loop would have used. + fn watchdog() { + let tick = std::time::Duration::from_millis(100); + let mut pending_for = std::time::Duration::ZERO; + loop { + std::thread::sleep(tick); + if pending().is_none() || SUSPENDED.load(Ordering::Relaxed) { + pending_for = std::time::Duration::ZERO; + continue; + } + pending_for += tick; + if pending_for >= GRACE { + // The restore writes to stdout, whose lock the stuck thread + // might hold: give it a moment, never wait on it. + let (done, wait) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + let _ = super::restore_terminal(); + let _ = done.send(()); + }); + let _ = wait.recv_timeout(std::time::Duration::from_millis(300)); + std::process::exit(128 + pending().unwrap_or(0)); + } + } + } + + /// While an external editor runs in cooked mode, Ctrl-C sends SIGINT to + /// the whole foreground process group — ink included. It is meant for + /// the editor: ignore it until `resume`. SIGTERM and SIGHUP still end + /// ink, but only once the editor has returned (the watchdog waits too), + /// so ink never exits underneath a running editor. + pub fn suspend() { + SUSPENDED.store(true, Ordering::Relaxed); + if let Some(flags) = FLAGS.get() { + if let Ok(mut held) = flags.interrupt.lock() { + for id in held.drain(..) { + signal_hook::low_level::unregister(id); + } + } + } + } + + pub fn resume() { + if let Some(flags) = FLAGS.get() { + let ids = register(signal_hook::consts::SIGINT, flags); + if let Ok(mut held) = flags.interrupt.lock() { + held.extend(ids); + } + } + SUSPENDED.store(false, Ordering::Relaxed); + } + pub fn pending() -> Option { let flags = FLAGS.get()?; if !flags.any.load(Ordering::Relaxed) { @@ -317,18 +420,13 @@ pub fn run(source: String, args: Args) -> Result { }; install_panic_hook(); - enable_raw_mode()?; + enter_tui(mouse_capture)?; // A background-colour reply that missed the startup query must not be // read as key presses. if theme::detect::reply_may_arrive_late() { input::discard_late_reply(); } - let mut stdout = io::stdout(); - execute!(stdout, EnterAlternateScreen)?; - if mouse_capture { - execute!(stdout, EnableMouseCapture)?; - } - let backend = CrosstermBackend::new(stdout); + let backend = CrosstermBackend::new(io::stdout()); let mut terminal = Terminal::new(backend)?; let result = run_inner(&mut terminal, source, args, &graphics); @@ -373,6 +471,9 @@ fn run_inner( let mut flash: Option<(String, Instant)> = None; // Where the document was last drawn, for translating mouse coordinates. let mut doc_rect = Rect::new(0, 0, 0, 0); + // Where the TOC's heading list was last drawn (empty when hidden), for + // clicks and wheel scrolls over the sidebar. + let mut toc_rect = Rect::new(0, 0, 0, 0); // Bumped on every theme change so tabs know their cached layout is stale. let mut theme_gen: u32 = 0; @@ -490,6 +591,10 @@ fn run_inner( .saturating_sub(viewport_height as usize); new_tab.scroll_offset = scroll.min(new_max); new_tab.toc.visible = tabs[active_tab].toc.visible; + new_tab + .toc + .inherit(std::mem::take(&mut tabs[active_tab].toc.nav)); + new_tab.toc.update_selection(new_tab.scroll_offset); tabs[active_tab] = new_tab; search.update_matches(&tabs[active_tab].lowered); if file_missing { @@ -533,6 +638,12 @@ fn run_inner( } } + // The heading list sits under the sidebar's title row. + let toc_rows = viewport_height.saturating_sub(1) as usize; + if tabs[active_tab].toc.visible { + tabs[active_tab].toc.follow(toc_rows); + } + let tab = &tabs[active_tab]; let total_lines = tab.ratatui_lines.len(); let max_scroll = total_lines.saturating_sub(viewport_height as usize); @@ -625,8 +736,9 @@ fn run_inner( }; if let Some(toc_area) = toc_area { - render::render_toc(frame, toc_area, &tab.toc.headings, tab.toc.selected, &t); + render::render_toc(frame, toc_area, &tab.toc, &t); } + toc_rect = toc_area.map_or(Rect::default(), render::toc_list_area); // Remembered for the next mouse event: screen coordinates only // mean something relative to where the document was drawn. @@ -731,6 +843,13 @@ fn run_inner( render::render_flash_bar(frame, bottom_bar_area, msg, &t); } else if search.active { render::render_search_bar(frame, bottom_bar_area, &search, &t); + } else if tab.toc.nav.focused { + render::render_toc_bar( + frame, + bottom_bar_area, + tab.toc.nav.filter.is_some(), + &t, + ); } else { render::render_bottom_bar( frame, @@ -755,6 +874,12 @@ fn run_inner( input::InputMode::LinkHint } else if visual_mode { input::InputMode::Visual + } else if tabs[active_tab].toc.nav.focused && !help_open && !theme_picker_open { + if tabs[active_tab].toc.nav.filter.is_some() { + input::InputMode::TocFilter + } else { + input::InputMode::Toc + } } else if args.slides && !theme_picker_open && !help_open { input::InputMode::Slides } else { @@ -764,6 +889,27 @@ fn run_inner( // Any recognized action changes state → redraw on the next iteration. dirty = true; + // The wheel scrolls what is under the pointer: the sidebar, or + // (as an ordinary scroll) the document. + let action = match action { + Action::WheelUp(col, row) | Action::WheelDown(col, row) + if toc_rect.contains(Position::new(col, row)) + && !help_open + && !theme_picker_open => + { + let delta = if matches!(action, Action::WheelUp(..)) { + -3 + } else { + 3 + }; + tabs[active_tab].toc.scroll_by(delta, toc_rows); + continue; + } + Action::WheelUp(..) => Action::ScrollUp(3), + Action::WheelDown(..) => Action::ScrollDown(3), + other => other, + }; + // Help overlay is modal: any key closes it. if help_open { if action != Action::None { @@ -951,6 +1097,16 @@ fn run_inner( continue; } + // The focused sidebar takes its own keys; anything else (mouse, + // resize, wheel) falls through to the ordinary handling below. + if let Action::Toc(key) = action { + let tab = &mut tabs[active_tab]; + if let Some(i) = tab.toc.handle(key, toc_rows) { + jump_to_heading(tab, &mut nav, i, viewport_height as usize); + } + continue; + } + // A confirmed search shows highlighted matches; the first Esc/q // dismisses them (vim/less convention) rather than quitting. if action == Action::ExitApp && !search.matches.is_empty() { @@ -1065,6 +1221,25 @@ fn run_inner( } // TOC + Action::TocFocus => { + let width = terminal.size()?.width; + let tab = &mut tabs[active_tab]; + if tab.toc.headings.is_empty() { + flash = Some(("no headings in this document".into(), Instant::now())); + } else if width <= 40 { + flash = Some(( + "window too narrow for the table of contents".into(), + Instant::now(), + )); + } else { + if !tab.toc.visible { + tab.toc.toggle(); + rebuild_tab(tab, &args, width, theme_gen, graphics); + } + tab.toc.update_selection(tab.scroll_offset); + tab.toc.focus(); + } + } Action::ToggleToc => { tabs[active_tab].toc.toggle(); rebuild_tab( @@ -1167,25 +1342,28 @@ fn run_inner( search.update_matches(&tabs[active_tab].lowered); } - // Follow the first visible local link (a .md file or a - // #heading anchor), through the same path as link-hint mode. + // Follow the first link on screen that can be followed (web, + // mail, a .md file or a #heading anchor), through the same + // path as link-hint mode. Action::FollowLink => { - let offset = tabs[active_tab].scroll_offset; - let found_link = tabs[active_tab] - .styled_lines - .iter() - .skip(offset) - .take(6) - .flat_map(|line| &line.spans) - .filter_map(|span| span.style.link_url.as_deref()) - .find(|url| is_followable_local(url)) - .map(str::to_string); - if let Some(link) = found_link { - if let Some(msg) = open_link( - &link, &mut tabs, active_tab, &mut nav, &args, terminal, theme_gen, - graphics, - ) { - flash = Some((msg, Instant::now())); + let tab = &tabs[active_tab]; + let found_link = first_followable_link( + &tab.styled_lines, + tab.scroll_offset, + viewport_height, + ) + .map(str::to_string); + match found_link { + Some(link) => { + if let Some(msg) = open_link( + &link, &mut tabs, active_tab, &mut nav, &args, terminal, theme_gen, + graphics, + ) { + flash = Some((msg, Instant::now())); + } + } + None => { + flash = Some(("no links on screen".into(), Instant::now())); } } } @@ -1273,6 +1451,23 @@ fn run_inner( } } + // Open the file in $VISUAL/$EDITOR at the line on screen, + // then reload it. + Action::Edit => { + let width = terminal.size()?.width; + let msg = edit_in_editor( + terminal, + &mut tabs[active_tab], + &args, + viewport_height as usize, + width, + theme_gen, + graphics, + ); + search.update_matches(&tabs[active_tab].lowered); + flash = msg.map(|m| (m, Instant::now())); + } + // Copy the section the viewport starts in, as markdown source. Action::CopySection => { let tab = &tabs[active_tab]; @@ -1283,7 +1478,16 @@ fn run_inner( // Mouse selection. Only reachable when ink holds the mouse; // with `mouse_capture = false` these events never arrive and // the terminal's own selection keeps working. + // A click on a sidebar row jumps to that heading. + Action::MouseDown(col, row) if toc_rect.contains(Position::new(col, row)) => { + let tab = &mut tabs[active_tab]; + tab.toc.leave(); + if let Some(i) = tab.toc.heading_at((row - toc_rect.y) as usize) { + jump_to_heading(tab, &mut nav, i, viewport_height as usize); + } + } Action::MouseDown(col, row) => { + tabs[active_tab].toc.leave(); if let Some(pos) = screen_to_doc(doc_rect, &tabs[active_tab], col, row) { let clicks = match last_click { Some((at, c, r, n)) @@ -1329,7 +1533,12 @@ fn run_inner( } } } - Action::MouseUp(_, _) => { + Action::MouseUp(col, row) => { + // Pressed and released on one cell with no drag, and not + // the second press of a double click. + let clicked = dragging + && !drag_moved + && matches!(last_click, Some((_, c, r, 1)) if c == col && r == row); dragging = false; match sel { // A plain click with no drag is a dismiss, not a copy. @@ -1342,6 +1551,22 @@ fn run_inner( None => {} } drag_moved = false; + // A click on a link follows it, the way `f` would. + let link = screen_to_doc(doc_rect, &tabs[active_tab], col, row) + .filter(|_| clicked) + .and_then(|pos| link_at(&tabs[active_tab], pos)) + .map(str::to_string); + if let Some(url) = link { + // The page under the pointer changes: the next press + // is a fresh click, not the second of a double. + last_click = None; + if let Some(msg) = open_link( + &url, &mut tabs, active_tab, &mut nav, &args, terminal, theme_gen, + graphics, + ) { + flash = Some((msg, Instant::now())); + } + } } Action::Resize(_, _) => { @@ -1394,6 +1619,221 @@ fn screen_to_doc(area: Rect, tab: &Tab, col: u16, row: u16) -> Option { Some(Pos::new(line, col)) } +/// Why a document cannot be opened in an editor, or `None` when it can. +fn not_editable(tab: &Tab) -> Option { + if tab.filename == "stdin" || tab.filename == "-" { + Some("nothing to edit: document came from stdin".into()) + } else if !is_local_file(&tab.filename) { + Some("nothing to edit: document came from a URL".into()) + } else if tab.mtime.is_none() { + Some(format!("nothing to edit: {} is not a file", tab.filename)) + } else { + None + } +} + +/// The heading the viewport is in: the last one at or above its top (one +/// on the second row counts, as for the TOC), with its index. +fn viewport_heading(tab: &Tab) -> Option<(usize, &crate::toc::TocEntry)> { + tab.toc + .headings + .iter() + .enumerate() + .rev() + .find(|(_, h)| h.line_index <= tab.scroll_offset + 1) +} + +/// The 1-based line of the file to open the editor at, for the top of the +/// viewport. +/// +/// Layout records source positions for headings only, so this is the line +/// of the heading the viewport is in (line 1 at the very top of the +/// document), not the exact line on screen. The line is only trusted when +/// the file on disk (`file`) still has, at that line, what the document was +/// loaded from; otherwise there is no line. +fn editor_line(tab: &Tab, file: &str) -> Option { + let content_line = match viewport_heading(tab) { + Some((_, h)) => h.source_line, + None if tab.scroll_offset == 0 => 1, + None => return None, + }; + let line = content_line.checked_add(tab.line_offset?)?; + let loaded = tab.source.lines().nth(line.checked_sub(1)?)?; + (file.lines().nth(line - 1) == Some(loaded)).then_some(line) +} + +/// Suspend the TUI, run the editor on the current file, and restore the TUI +/// and reload the file. Returns the message for the status bar. +#[allow(clippy::too_many_arguments)] +fn edit_in_editor( + terminal: &mut Terminal>, + tab: &mut Tab, + args: &Args, + viewport: usize, + term_width: u16, + gen: u32, + graphics: &crate::graphics::Graphics, +) -> Option { + if let Some(why) = not_editable(tab) { + return Some(why); + } + let on_disk = std::fs::read_to_string(&tab.filename).unwrap_or_default(); + let line = editor_line(tab, &on_disk); + let command = crate::editor::editor_command( + std::env::var("VISUAL").ok().as_deref(), + std::env::var("EDITOR").ok().as_deref(), + ); + let Some(argv) = crate::editor::editor_argv(&command, &tab.filename, line) else { + return Some("no editor: set $VISUAL or $EDITOR".into()); + }; + let outcome = run_suspended(terminal, &argv, args.mouse_capture); + let reload = reload_after_edit(tab, args, viewport, term_width, gen, graphics); + match (outcome, reload) { + (Err(e), _) | (_, Err(e)) => Some(e), + (Ok(status), Ok(())) if !status.success() => Some(editor_failure(&argv[0], status)), + (Ok(_), Ok(())) => Some(format!("reloaded {}", short_name(&tab.filename))), + } +} + +fn short_name(path: &str) -> &str { + std::path::Path::new(path) + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or(path) +} + +fn editor_failure(program: &str, status: std::process::ExitStatus) -> String { + #[cfg(unix)] + { + use std::os::unix::process::ExitStatusExt; + if let Some(sig) = status.signal() { + return format!("{} was killed by signal {sig}", short_name(program)); + } + } + match status.code() { + Some(code) => format!("{} exited with status {code}", short_name(program)), + None => format!("{} failed", short_name(program)), + } +} + +/// Hand the terminal to `argv` and take it back: the same restore and setup +/// as exit and startup, so every path (the editor failing to start, exiting +/// non-zero, killed by a signal) ends with the TUI restored. Ctrl-C is the +/// editor's while it runs. +fn run_suspended( + terminal: &mut Terminal>, + argv: &[String], + mouse_capture: bool, +) -> Result { + let _ = restore_terminal(); + #[cfg(unix)] + term_signal::suspend(); + let status = std::process::Command::new(&argv[0]) + .args(&argv[1..]) + .status(); + #[cfg(unix)] + term_signal::resume(); + let back = enter_tui(mouse_capture).and_then(|()| { + // Everything on screen is gone: a fresh terminal has no previous + // frame to diff against, so the next draw repaints every cell. + // (`Terminal::clear` would ask the terminal for its cursor position + // and stall on one that does not answer.) + execute!( + io::stdout(), + crossterm::terminal::Clear(crossterm::terminal::ClearType::All) + )?; + *terminal = Terminal::new(CrosstermBackend::new(io::stdout()))?; + Ok(()) + }); + back.map_err(|e| format!("could not restore the terminal: {e}"))?; + status.map_err(|e| format!("could not run {}: {e}", argv[0])) +} + +/// Re-read an edited file and keep the reader where it was: on the same +/// heading (matched by text and occurrence, so lines added above it do not +/// matter) at the same distance below it, or at the same offset when there +/// is no such heading. +fn reload_after_edit( + tab: &mut Tab, + args: &Args, + viewport: usize, + term_width: u16, + gen: u32, + graphics: &crate::graphics::Graphics, +) -> Result<(), String> { + let src = std::fs::read_to_string(&tab.filename) + .map_err(|e| format!("cannot reload {}: {e}", short_name(&tab.filename)))?; + let anchor = viewport_heading(tab).map(|(i, h)| { + let nth = tab.toc.headings[..i] + .iter() + .filter(|o| o.text == h.text) + .count(); + ( + h.text.clone(), + nth, + tab.scroll_offset as isize - h.line_index as isize, + ) + }); + let scroll = tab.scroll_offset; + let toc_visible = tab.toc.visible; + let toc_nav = std::mem::take(&mut tab.toc.nav); + let filename = tab.filename.clone(); + *tab = build_tab( + src, + &filename, + args, + effective_width(term_width, toc_visible), + gen, + graphics, + ); + tab.toc.visible = toc_visible; + tab.toc.inherit(toc_nav); + let max_scroll = tab.ratatui_lines.len().saturating_sub(viewport); + let same_heading = anchor.and_then(|(text, nth, delta)| { + let h = tab + .toc + .headings + .iter() + .filter(|h| h.text == text) + .nth(nth)?; + Some((h.line_index as isize + delta).max(0) as usize) + }); + tab.scroll_offset = same_heading.unwrap_or(scroll).min(max_scroll); + tab.toc.update_selection(tab.scroll_offset); + Ok(()) +} + +/// The link under a document position, if any: the `link_url` of the span +/// covering that display column (wide characters count as two columns, +/// and the left margin is part of the line). +fn link_at(tab: &Tab, pos: Pos) -> Option<&str> { + use unicode_width::UnicodeWidthStr; + let mut start = 0; + for span in &tab.styled_lines.get(pos.line)?.spans { + let end = start + span.text.width(); + if pos.col < end { + return span.style.link_url.as_deref(); + } + start = end; + } + None +} + +/// Scroll to heading `i` of the TOC, framed the way `n`/`N` frame a heading, +/// and record where the reader was so `[` comes back. +fn jump_to_heading(tab: &mut Tab, nav: &mut NavHistory, i: usize, viewport: usize) { + let Some(h) = tab.toc.headings.get(i) else { + return; + }; + let max_scroll = tab.ratatui_lines.len().saturating_sub(viewport); + let line = h.line_index.saturating_sub(1).min(max_scroll); + if line != tab.scroll_offset { + nav.record(NavEntry::of(tab)); + tab.scroll_offset = line; + } + tab.toc.update_selection(line); +} + /// The markdown source of the section the viewport currently starts in, plus a /// label for the status bar. /// @@ -1535,17 +1975,10 @@ fn open_link( gen: u32, graphics: &crate::graphics::Graphics, ) -> Option { - // Web / mail: hand off to the OS. (URLs are already scheme-validated by - // sanitize_url during layout, but re-check defensively.) - if let Some(safe) = crate::sanitize::sanitize_url(url) { - let lower = safe.to_ascii_lowercase(); - if lower.starts_with("http://") - || lower.starts_with("https://") - || lower.starts_with("mailto:") - { - let _ = open::that_detached(&safe); - return None; - } + // Web / mail: hand off to the OS. + if let Some(safe) = web_target(url) { + let _ = open::that_detached(&safe); + return None; } if !is_followable_local(url) { return Some(format!("cannot follow {url}")); @@ -1592,6 +2025,32 @@ fn open_link( } } +/// A web or mail link, as handed to the OS. (URLs are already +/// scheme-validated by sanitize_url during layout, but re-check +/// defensively.) +fn web_target(url: &str) -> Option { + let safe = crate::sanitize::sanitize_url(url)?; + let lower = safe.to_ascii_lowercase(); + (lower.starts_with("http://") || lower.starts_with("https://") || lower.starts_with("mailto:")) + .then_some(safe) +} + +/// The first link `Enter` can follow among the `height` lines from +/// `offset`: a web or mail link, or one ink follows itself. +fn first_followable_link( + lines: &[crate::layout::StyledLine], + offset: usize, + height: u16, +) -> Option<&str> { + lines + .iter() + .skip(offset) + .take(height as usize) + .flat_map(|line| &line.spans) + .filter_map(|span| span.style.link_url.as_deref()) + .find(|url| web_target(url).is_some() || is_followable_local(url)) +} + /// Links ink follows itself: a local markdown file, optionally with a /// `#heading` suffix, or a bare `#heading` in the current document. fn is_followable_local(url: &str) -> bool { @@ -1759,15 +2218,18 @@ fn rebuild_tab( let filename = tab.filename.clone(); let scroll = tab.scroll_offset; let toc_visible = tab.toc.visible; + let toc_nav = std::mem::take(&mut tab.toc.nav); let mtime = tab.mtime; let width = effective_width(term_width, toc_visible); *tab = build_tab(source, &filename, args, width, gen, graphics); + tab.toc.inherit(toc_nav); // Same source as before, so the same snapshot time. tab.mtime = mtime; // Clamp: the new layout may have far fewer lines (e.g. after widening) — // an unclamped stale offset blanked the viewport and panicked link-mode. tab.scroll_offset = scroll.min(tab.ratatui_lines.len().saturating_sub(1)); tab.toc.visible = toc_visible; + tab.toc.update_selection(tab.scroll_offset); } /// Refresh a tab only if it was laid out for a different width or theme. @@ -1804,14 +2266,20 @@ fn build_tab( } else { None }; - let (_, content) = if args.frontmatter { + let (_, stripped) = if args.frontmatter { (None, source.to_string()) } else { frontmatter::strip_frontmatter(&source) }; // Pre-process wikilinks before parsing - let content = crate::wikilink::process_wikilinks(&content); + let content = crate::wikilink::process_wikilinks(&stripped); + // Frontmatter comes off the top whole, and wikilinks never add or remove + // a line; if either ever stops holding, the mapping is unknown. + let line_offset = source + .strip_suffix(stripped.as_str()) + .map(|head| head.matches('\n').count()) + .filter(|_| content.matches('\n').count() == stripped.matches('\n').count()); let arena = Arena::new(); let options = crate::parser::options_for(&content); @@ -1910,6 +2378,7 @@ fn build_tab( source, mtime, content, + line_offset, styled_lines, ratatui_lines, plain, @@ -2123,6 +2592,7 @@ mod section_tests { source: content.into(), mtime: None, content: content.into(), + line_offset: Some(0), styled_lines: Vec::new(), ratatui_lines: Vec::new(), plain, @@ -2431,6 +2901,136 @@ mod section_tests { assert_eq!(heading_scroll(&t, "gamma", 10), None); } + #[test] + fn enter_follows_the_first_followable_link_anywhere_on_screen() { + let mut doc: String = (0..12).map(|i| format!("para {i}\n\n")).collect(); + doc.push_str("[bad](javascript:alert) [site](https://example.com) [doc](other.md)\n"); + let t = built(&doc); + let line = t.plain.iter().position(|l| l.contains("site")).unwrap(); + // Far below the old six-line window, but on screen. + assert!(line > 6); + assert_eq!( + first_followable_link(&t.styled_lines, 0, line as u16 + 1), + Some("https://example.com") + ); + // Not on screen: nothing. + assert_eq!(first_followable_link(&t.styled_lines, 0, line as u16), None); + assert_eq!(web_target("mailto:a@b.c").as_deref(), Some("mailto:a@b.c")); + assert_eq!(web_target("javascript:alert(1)"), None); + assert_eq!(web_target("other.md"), None); + } + + #[test] + fn link_at_counts_display_columns_past_wide_characters() { + use unicode_width::UnicodeWidthStr; + let t = built("漢字 [go](#x) after\n"); + let line = t.plain.iter().position(|l| l.contains("go")).unwrap(); + let text = &t.plain[line]; + let col = text[..text.find("go").unwrap()].width(); + assert_eq!(link_at(&t, Pos::new(line, col)), Some("#x")); + assert_eq!(link_at(&t, Pos::new(line, col + 1)), Some("#x")); + assert_eq!(link_at(&t, Pos::new(line, col + 2)), None); + assert_eq!(link_at(&t, Pos::new(line, col - 1)), None); + // The left margin is not a link either. + assert_eq!(link_at(&t, Pos::new(line, 0)), None); + assert_eq!(link_at(&t, Pos::new(999, 0)), None); + } + + const FM_DOC: &str = "---\ntitle: x\n---\n# A\n\ntext\n\n## B\n\nmore\n"; + + fn scrolled_to(mut t: Tab, heading: &str) -> Tab { + let h = t.toc.headings.iter().find(|h| h.text == heading).unwrap(); + t.scroll_offset = h.line_index.saturating_sub(1); + t + } + + #[test] + fn the_editor_line_is_the_viewport_heading_in_file_coordinates() { + let t = built(FM_DOC); + // Three frontmatter lines sit above the parsed content. + assert_eq!(t.line_offset, Some(3)); + let t = scrolled_to(t, "B"); + assert_eq!(editor_line(&t, FM_DOC), Some(8)); + assert_eq!(FM_DOC.lines().nth(7), Some("## B")); + // Scrolled a little past the heading: still that heading's line. + let mut t = t; + t.scroll_offset += 2; + assert_eq!(editor_line(&t, FM_DOC), Some(8)); + // At the very top: the first heading. + let t = built(FM_DOC); + assert_eq!(editor_line(&t, FM_DOC), Some(4)); + } + + #[test] + fn the_editor_line_is_dropped_when_it_cannot_be_trusted() { + let t = scrolled_to(built(FM_DOC), "B"); + // The file changed on disk at that line since it was loaded. + let edited = FM_DOC.replace("## B", "## Renamed"); + assert_eq!(editor_line(&t, &edited), None); + // An unknown source-to-file mapping. + let mut t = t; + t.line_offset = None; + assert_eq!(editor_line(&t, FM_DOC), None); + // Scrolled into text above any heading. + let mut t = built("intro\n\n\n\n\n\n\n\n# Late\n"); + t.scroll_offset = 2; + assert_eq!(editor_line(&t, "intro\n\n\n\n\n\n\n\n# Late\n"), None); + } + + #[test] + fn only_local_files_are_editable() { + let mut t = built("# x\n"); + t.filename = "stdin".into(); + assert!(not_editable(&t).unwrap().contains("stdin")); + t.filename = "https://example.com/a.md".into(); + assert!(not_editable(&t).unwrap().contains("URL")); + t.filename = "t.md".into(); + t.mtime = None; + assert!(not_editable(&t).is_some()); + t.mtime = Some(SystemTime::now()); + assert_eq!(not_editable(&t), None); + } + + #[test] + fn reloading_after_an_edit_stays_on_the_same_heading() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("doc.md"); + let doc: String = (0..30) + .map(|i| format!("## H{i}\n\nbody {i}\n\n")) + .collect(); + std::fs::write(&path, &doc).unwrap(); + let graphics = crate::graphics::Graphics::halfblocks(); + let args = test_args(); + let name = path.to_str().unwrap(); + let t = build_tab(doc.as_str(), name, &args, 80, 0, &graphics); + let mut t = scrolled_to(t, "H20"); + t.scroll_offset += 1; + // Lines added above the heading the reader is in. + std::fs::write(&path, format!("# New\n\nadded\n\nlines\n\n{doc}")).unwrap(); + reload_after_edit(&mut t, &args, 10, 80, 0, &graphics).unwrap(); + let h20 = t.toc.headings.iter().find(|h| h.text == "H20").unwrap(); + assert_eq!(t.scroll_offset, h20.line_index); + assert!(t.content.starts_with("# New")); + // A file that is gone reports it. + std::fs::remove_file(&path).unwrap(); + let err = reload_after_edit(&mut t, &args, 10, 80, 0, &graphics).unwrap_err(); + assert!(err.contains("cannot reload"), "{err}"); + } + + #[test] + fn a_toc_jump_is_recorded_in_history_and_marks_the_heading() { + let mut t = tab(DOC, HEADINGS, 0); + t.ratatui_lines = vec![Line::default(); 40]; + let mut nav = NavHistory::default(); + jump_to_heading(&mut t, &mut nav, 3, 10); + assert_eq!(t.scroll_offset, 19); + assert_eq!(t.toc.selected, 3); + assert_eq!(nav.back.last().map(|e| e.scroll_offset), Some(0)); + // Jumping to where the reader already is records nothing. + jump_to_heading(&mut t, &mut nav, 3, 10); + assert_eq!(nav.back.len(), 1); + } + fn entry(name: &str, scroll: usize) -> NavEntry { NavEntry { filename: name.into(), diff --git a/src/browser.rs b/src/browser.rs index 244e3c9..5f071a9 100644 --- a/src/browser.rs +++ b/src/browser.rs @@ -1,8 +1,6 @@ use crate::theme; use anyhow::Result; -use crossterm::event::{self, EnableMouseCapture, Event, KeyCode, KeyModifiers}; -use crossterm::execute; -use crossterm::terminal::{enable_raw_mode, EnterAlternateScreen}; +use crossterm::event::{Event, KeyCode, KeyModifiers}; use ratatui::prelude::*; use ratatui::widgets::Paragraph; use std::collections::HashSet; @@ -32,13 +30,13 @@ pub fn browse(dir: &Path, theme_name: &str, mouse_capture: bool) -> Result { - filter_active = false; - filter.clear(); - } - KeyCode::Enter => { - filter_active = false; - } - KeyCode::Backspace => { - filter.pop(); - } - KeyCode::Char(c) => { - if key.modifiers.contains(KeyModifiers::CONTROL) && c == 'c' { - filter_active = false; - filter.clear(); - } else { - filter.push(c); - } - } - _ => {} - } - continue; - } - + // Through the reader's queue, so a late OSC 11 reply that was + // filtered out at startup is never typed into the filter. + if let Some(Event::Key(key)) = + crate::input::next_event(std::time::Duration::from_millis(50))? + { + // Windows reports key releases too; acting on them would + // move twice and type every filter character twice. + if !crate::input::is_actionable_key(&key) { + continue; + } + if filter_active { match key.code { - KeyCode::Char('q') | KeyCode::Esc => return Ok(None), - KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => { - return Ok(None); - } - KeyCode::Down | KeyCode::Char('j') - if !filtered.is_empty() && selected < filtered.len() - 1 => - { - selected += 1; - } - KeyCode::Up | KeyCode::Char('k') => { - selected = selected.saturating_sub(1); + KeyCode::Esc => { + filter_active = false; + filter.clear(); } - KeyCode::Char('G') | KeyCode::End if !filtered.is_empty() => { - selected = filtered.len() - 1; + KeyCode::Enter => { + filter_active = false; } - KeyCode::Home | KeyCode::Char('g') => { - selected = 0; + KeyCode::Backspace => { + filter.pop(); } - KeyCode::Enter => { - if let Some(&file_idx) = filtered.get(selected) { - return Ok(Some(files[file_idx].full_path.clone())); + KeyCode::Char(c) => { + if key.modifiers.contains(KeyModifiers::CONTROL) && c == 'c' { + filter_active = false; + filter.clear(); + } else { + filter.push(c); } } - KeyCode::Char('/') => { - filter_active = true; - } _ => {} } + continue; + } + + match key.code { + KeyCode::Char('q') | KeyCode::Esc => return Ok(None), + KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => { + return Ok(None); + } + KeyCode::Down | KeyCode::Char('j') + if !filtered.is_empty() && selected < filtered.len() - 1 => + { + selected += 1; + } + KeyCode::Up | KeyCode::Char('k') => { + selected = selected.saturating_sub(1); + } + KeyCode::Char('G') | KeyCode::End if !filtered.is_empty() => { + selected = filtered.len() - 1; + } + KeyCode::Home | KeyCode::Char('g') => { + selected = 0; + } + KeyCode::Enter => { + if let Some(&file_idx) = filtered.get(selected) { + return Ok(Some(files[file_idx].full_path.clone())); + } + } + KeyCode::Char('/') => { + filter_active = true; + } + _ => {} } } } diff --git a/src/cli.rs b/src/cli.rs index 792ba18..45169de 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -794,6 +794,7 @@ fn print_keybindings() { NextTab => "next_tab", PrevTab => "prev_tab", ToggleToc => "toggle_toc", + TocFocus => "toc_focus", Search => "search", ThemePicker => "theme_picker", FollowLink => "follow_link", @@ -805,6 +806,7 @@ fn print_keybindings() { SelectLineMode => "select_line_mode", CopyCode => "copy_code", CopySection => "copy_section", + Edit => "edit", _ => "?", } .to_string() diff --git a/src/editor.rs b/src/editor.rs new file mode 100644 index 0000000..6d671d2 --- /dev/null +++ b/src/editor.rs @@ -0,0 +1,243 @@ +//! Opening the current document in the user's editor (`e`). +//! +//! Only the pure parts live here — which editor, how to split its command +//! line, how to ask it for a line — so they can be tested without spawning +//! anything. Suspending and restoring the TUI around the editor is in `app`. + +/// The editor command line to run: `$VISUAL`, then `$EDITOR`, then the +/// platform's fallback. Blank values count as unset. +pub fn editor_command(visual: Option<&str>, editor: Option<&str>) -> String { + [visual, editor] + .into_iter() + .flatten() + .map(str::trim) + .find(|v| !v.is_empty()) + .map(str::to_string) + .unwrap_or_else(|| { + if cfg!(windows) { + "notepad".into() + } else { + "vi".into() + } + }) +} + +/// Split a command line the way a POSIX shell splits words, without +/// expanding anything: whitespace separates, `'…'` is literal, `"…"` keeps +/// spaces and honours `\"`, `\\`, `\$` and `` \` ``, and a backslash outside +/// quotes escapes the next character. With `backslash_escapes` off (Windows, +/// where `\` is the path separator) backslashes are always literal. +pub fn split_command(s: &str, backslash_escapes: bool) -> Vec { + let mut words = Vec::new(); + let mut word = String::new(); + // A word has started, even if it is empty so far (`''`). + let mut in_word = false; + let mut chars = s.chars().peekable(); + while let Some(c) = chars.next() { + match c { + c if c.is_whitespace() => { + if in_word { + words.push(std::mem::take(&mut word)); + in_word = false; + } + } + '\'' => { + in_word = true; + for c in chars.by_ref() { + if c == '\'' { + break; + } + word.push(c); + } + } + '"' => { + in_word = true; + while let Some(c) = chars.next() { + match c { + '"' => break, + '\\' if backslash_escapes + && matches!(chars.peek(), Some('"' | '\\' | '$' | '`')) => + { + word.extend(chars.next()); + } + c => word.push(c), + } + } + } + '\\' if backslash_escapes => { + in_word = true; + word.extend(chars.next()); + } + c => { + in_word = true; + word.push(c); + } + } + } + if in_word { + words.push(word); + } + words +} + +/// How an editor is told which line to open at. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum LineArg { + /// `+N file` (vi, vim, nvim, nano, emacs, emacsclient, kak, micro). + Plus, + /// `--goto file:N` (VS Code and its forks). + Goto, + /// `file:N` (Sublime Text, Zed, Helix). + Suffix, + /// Unknown editor: just the file. + None, +} + +fn line_arg_for(program: &str) -> LineArg { + let name = std::path::Path::new(program) + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or(program) + .to_ascii_lowercase(); + let name = name.strip_suffix(".exe").unwrap_or(&name); + match name { + "vi" | "vim" | "nvim" | "gvim" | "view" | "nano" | "emacs" | "emacsclient" | "kak" + | "micro" => LineArg::Plus, + "code" | "code-insiders" | "codium" | "vscodium" | "cursor" => LineArg::Goto, + "subl" | "zed" | "hx" | "helix" => LineArg::Suffix, + _ => LineArg::None, + } +} + +/// A path no editor can mistake for an option or a command: a leading `-` +/// (an option almost everywhere) or `+` (an Ex command in vi, `+N` in many +/// others) gets `./` in front. A file reached by following a link in an +/// untrusted document can be called anything. +fn safe_path(path: &str) -> String { + if path.starts_with('-') || path.starts_with('+') { + format!("./{path}") + } else { + path.to_string() + } +} + +/// The argv to run: the editor command (split shell-style, never handed to +/// a shell), any line argument the editor understands, and the file as its +/// own element. `None` when the command is empty. +pub fn editor_argv(command: &str, path: &str, line: Option) -> Option> { + let mut argv = split_command(command, !cfg!(windows)); + let program = argv.first()?.clone(); + let path = safe_path(path); + match (line.filter(|&n| n > 0), line_arg_for(&program)) { + (Some(n), LineArg::Plus) => { + argv.push(format!("+{n}")); + argv.push(path); + } + (Some(n), LineArg::Goto) => { + argv.push("--goto".into()); + argv.push(format!("{path}:{n}")); + } + (Some(n), LineArg::Suffix) => argv.push(format!("{path}:{n}")), + _ => argv.push(path), + } + Some(argv) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn v(words: &[&str]) -> Vec { + words.iter().map(|w| w.to_string()).collect() + } + + #[test] + fn visual_wins_then_editor_then_the_fallback() { + assert_eq!(editor_command(Some("nvim"), Some("nano")), "nvim"); + assert_eq!(editor_command(Some(" "), Some("nano")), "nano"); + assert_eq!(editor_command(None, Some(" hx ")), "hx"); + let fallback = if cfg!(windows) { "notepad" } else { "vi" }; + assert_eq!(editor_command(None, None), fallback); + assert_eq!(editor_command(Some(""), Some("")), fallback); + } + + #[test] + fn commands_split_like_a_shell_without_expansion() { + assert_eq!(split_command("code --wait", true), v(&["code", "--wait"])); + assert_eq!( + split_command(" emacsclient -t ", true), + v(&["emacsclient", "-t"]) + ); + assert_eq!( + split_command("'/Applications/My Editor/bin/ed' -w", true), + v(&["/Applications/My Editor/bin/ed", "-w"]) + ); + assert_eq!( + split_command(r#""C:\Program Files\ed.exe" --x"#, false), + v(&[r"C:\Program Files\ed.exe", "--x"]) + ); + assert_eq!( + split_command(r#"ed "a \"q\" b" c\ d"#, true), + v(&["ed", r#"a "q" b"#, "c d"]) + ); + // No expansion: `$HOME` and `;` are just characters. + assert_eq!(split_command("vim $HOME;rm", true), v(&["vim", "$HOME;rm"])); + assert_eq!(split_command("ed ''", true), v(&["ed", ""])); + assert!(split_command(" ", true).is_empty()); + } + + #[test] + fn each_editor_family_gets_its_line_syntax() { + let argv = |cmd: &str, line| editor_argv(cmd, "doc.md", line).unwrap(); + for cmd in ["vi", "vim", "nvim", "nano", "emacs", "kak", "micro"] { + assert_eq!(argv(cmd, Some(12)), v(&[cmd, "+12", "doc.md"]), "{cmd}"); + } + assert_eq!( + argv("emacsclient -t", Some(3)), + v(&["emacsclient", "-t", "+3", "doc.md"]) + ); + assert_eq!( + argv("/usr/local/bin/nvim", Some(3)), + v(&["/usr/local/bin/nvim", "+3", "doc.md"]) + ); + for cmd in ["code", "codium", "cursor"] { + assert_eq!( + argv(&format!("{cmd} --wait"), Some(7)), + v(&[cmd, "--wait", "--goto", "doc.md:7"]) + ); + } + for cmd in ["subl", "zed", "hx"] { + assert_eq!(argv(cmd, Some(7)), v(&[cmd, "doc.md:7"])); + } + // Unknown editors and unknown lines: just the file. + assert_eq!(argv("ed", Some(7)), v(&["ed", "doc.md"])); + assert_eq!(argv("vim", None), v(&["vim", "doc.md"])); + assert_eq!(argv("vim", Some(0)), v(&["vim", "doc.md"])); + assert_eq!(editor_argv(" ", "doc.md", Some(1)), None); + } + + #[test] + fn a_path_that_looks_like_an_option_or_command_is_defused() { + assert_eq!( + editor_argv("vim", "-c:!cmd.md", Some(2)).unwrap(), + v(&["vim", "+2", "./-c:!cmd.md"]) + ); + assert_eq!( + editor_argv("vi", "+!rm -rf ~.md", None).unwrap(), + v(&["vi", "./+!rm -rf ~.md"]) + ); + assert_eq!( + editor_argv("code", "--help.md", Some(1)).unwrap(), + v(&["code", "--goto", "./--help.md:1"]) + ); + // The path stays one argv element, spaces and all. + assert_eq!( + editor_argv("nano", "my notes; rm x.md", None).unwrap(), + v(&["nano", "my notes; rm x.md"]) + ); + assert_eq!( + editor_argv("vim", "/abs/-x.md", None).unwrap(), + v(&["vim", "/abs/-x.md"]) + ); + } +} diff --git a/src/input/keymap.rs b/src/input/keymap.rs index 8d7dd30..b6d302f 100644 --- a/src/input/keymap.rs +++ b/src/input/keymap.rs @@ -115,6 +115,7 @@ pub fn action_from_id(id: &str) -> Option { "next_tab" => Action::NextTab, "prev_tab" => Action::PrevTab, "toggle_toc" => Action::ToggleToc, + "toc_focus" => Action::TocFocus, "search" => Action::Search, "theme_picker" => Action::ThemePicker, "follow_link" => Action::FollowLink, @@ -126,6 +127,7 @@ pub fn action_from_id(id: &str) -> Option { "select_line_mode" => Action::SelectLineMode, "copy_code" => Action::CopyCode, "copy_section" => Action::CopySection, + "edit" => Action::Edit, _ => return None, }) } diff --git a/src/input/mod.rs b/src/input/mod.rs index b766439..fb9ad86 100644 --- a/src/input/mod.rs +++ b/src/input/mod.rs @@ -73,10 +73,21 @@ pub enum Action { /// Inside the link-hint overlay: copy the URL instead of opening it. HintCopyToggle, + /// Move keyboard focus into the table of contents (opening it). + TocFocus, + /// Open the current file in `$VISUAL` / `$EDITOR`. + Edit, + /// A key for the focused table of contents. + Toc(crate::toc::TocKey), + /// Mouse press / drag / release at a (column, row) on screen. MouseDown(u16, u16), MouseDrag(u16, u16), MouseUp(u16, u16), + /// Mouse wheel at a (column, row): the reader scrolls whatever is under + /// the pointer (the document, or the TOC sidebar). + WheelUp(u16, u16), + WheelDown(u16, u16), None, } @@ -94,6 +105,10 @@ pub enum InputMode { Slides, /// Visual selection — motions move the cursor, `y` copies. Visual, + /// The table of contents has focus — motions move its cursor. + Toc, + /// Typing a filter into the focused table of contents. + TocFilter, } /// Process-wide resolved keymap. Initialized once at startup via `init_keymap`. @@ -176,11 +191,13 @@ pub fn keymap_summary() -> Vec<(&'static str, Vec)> { "next / prev heading", "search", "table of contents", + "focus contents", "follow link", "link hints", "select text", "copy code block", "copy section", + "edit in $EDITOR", "theme picker", "tabs", "back / forward", @@ -207,11 +224,13 @@ fn action_label(a: &Action) -> &'static str { Action::NextHeading | Action::PrevHeading => "next / prev heading", Action::Search => "search", Action::ToggleToc => "table of contents", + Action::TocFocus => "focus contents", Action::FollowLink => "follow link", Action::LinkMode => "link hints", Action::SelectMode | Action::SelectLineMode => "select text", Action::CopyCode => "copy code block", Action::CopySection => "copy section", + Action::Edit => "edit in $EDITOR", Action::ThemePicker => "theme picker", Action::NextTab | Action::PrevTab => "tabs", Action::NavBack | Action::NavForward => "back / forward", @@ -348,6 +367,23 @@ fn strip_leading_osc_reply(events: Vec) -> Vec { } } +/// The next raw event, for screens that read events themselves (the file +/// browser): events held back by [`discard_late_reply`] first, then the +/// terminal, waiting up to `timeout`. Key releases are dropped, as in +/// [`poll_action`]. +pub fn next_event(timeout: std::time::Duration) -> std::io::Result> { + let queued = PENDING.lock().ok().and_then(|mut p| p.pop_front()); + let event = match queued { + Some(e) => e, + None if event::poll(timeout)? => event::read()?, + None => return Ok(None), + }; + match &event { + Event::Key(key) if !is_actionable_key(key) => Ok(None), + _ => Ok(Some(event)), + } +} + pub fn poll_action(timeout: std::time::Duration, mode: InputMode) -> Option { let queued = PENDING.lock().ok().and_then(|mut p| p.pop_front()); if let Some(event) = queued { @@ -374,6 +410,8 @@ fn map_event(event: Event, mode: InputMode) -> Action { InputMode::LinkHint => map_link_hint_key(key), InputMode::Slides => map_slides_key(key), InputMode::Visual => map_visual_key(key), + InputMode::Toc => map_toc_key(key), + InputMode::TocFilter => map_toc_filter_key(key), InputMode::Normal => map_key(key), }, Event::Mouse(mouse) => map_mouse(mouse), @@ -428,6 +466,74 @@ fn map_visual_key(key: KeyEvent) -> Action { } } +/// Keys while the table of contents has focus. +/// +/// Fixed, like visual mode: a small vim-shaped table plus arrows. The +/// configured `toc_focus` key (and `toggle_toc`, which closes the sidebar) +/// still work here so the key that came in also goes out. +fn map_toc_key(key: KeyEvent) -> Action { + use crate::toc::TocKey as T; + if key.modifiers.contains(KeyModifiers::CONTROL) { + let fixed = match key.code { + KeyCode::Char('c') => Some(T::Leave), + KeyCode::Char('d') | KeyCode::Char('f') => Some(T::PageDown), + KeyCode::Char('u') | KeyCode::Char('b') => Some(T::PageUp), + _ => None, + }; + if let Some(k) = fixed { + return Action::Toc(k); + } + } else { + let fixed = match key.code { + KeyCode::Char('j') | KeyCode::Down => Some(T::Down), + KeyCode::Char('k') | KeyCode::Up => Some(T::Up), + KeyCode::Char('g') | KeyCode::Home => Some(T::First), + KeyCode::Char('G') | KeyCode::End => Some(T::Last), + KeyCode::PageDown | KeyCode::Char(' ') => Some(T::PageDown), + KeyCode::PageUp => Some(T::PageUp), + KeyCode::Enter => Some(T::Jump), + KeyCode::Esc | KeyCode::Char('q') => Some(T::Leave), + KeyCode::Char('/') => Some(T::StartFilter), + KeyCode::Char('h') | KeyCode::Left => Some(T::Collapse), + KeyCode::Char('l') | KeyCode::Right => Some(T::Expand), + _ => None, + }; + if let Some(k) = fixed { + return Action::Toc(k); + } + } + let kb = normalize_event(key); + match KEYMAP.get().and_then(|km| km.singles.get(&kb)) { + Some(Action::TocFocus) => Action::Toc(T::Leave), + Some(Action::ToggleToc) => Action::ToggleToc, + Some(Action::Help) => Action::Help, + _ => Action::None, + } +} + +/// Keys while typing a TOC filter: text goes into the filter, arrows move, +/// Enter jumps, Esc clears. +fn map_toc_filter_key(key: KeyEvent) -> Action { + use crate::toc::TocKey as T; + if key.modifiers.contains(KeyModifiers::CONTROL) { + return match key.code { + KeyCode::Char('c') => Action::Toc(T::ClearFilter), + _ => Action::None, + }; + } + match key.code { + KeyCode::Esc => Action::Toc(T::ClearFilter), + KeyCode::Enter => Action::Toc(T::Jump), + KeyCode::Backspace => Action::Toc(T::FilterBack), + KeyCode::Down => Action::Toc(T::Down), + KeyCode::Up => Action::Toc(T::Up), + KeyCode::PageDown => Action::Toc(T::PageDown), + KeyCode::PageUp => Action::Toc(T::PageUp), + KeyCode::Char(c) => Action::Toc(T::FilterChar(c)), + _ => Action::None, + } +} + fn map_slides_key(key: KeyEvent) -> Action { if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c') { return Action::ExitApp; @@ -517,8 +623,8 @@ fn normalize_event(key: KeyEvent) -> (KeyCode, KeyModifiers) { fn map_mouse(mouse: MouseEvent) -> Action { use crossterm::event::MouseButton; match mouse.kind { - MouseEventKind::ScrollUp => Action::ScrollUp(3), - MouseEventKind::ScrollDown => Action::ScrollDown(3), + MouseEventKind::ScrollUp => Action::WheelUp(mouse.column, mouse.row), + MouseEventKind::ScrollDown => Action::WheelDown(mouse.column, mouse.row), MouseEventKind::Down(MouseButton::Left) => Action::MouseDown(mouse.column, mouse.row), MouseEventKind::Drag(MouseButton::Left) => Action::MouseDrag(mouse.column, mouse.row), MouseEventKind::Up(MouseButton::Left) => Action::MouseUp(mouse.column, mouse.row), @@ -555,12 +661,96 @@ mod tests { InputMode::LinkHint, InputMode::Slides, InputMode::Visual, + InputMode::Toc, + InputMode::TocFilter, ] { let ev = Event::Key(key(KeyEventKind::Release)); assert_eq!(map_event(ev, mode), Action::None); } } + fn press(code: KeyCode, modifiers: KeyModifiers) -> KeyEvent { + KeyEvent::new(code, modifiers) + } + + #[test] + fn the_focused_toc_has_its_own_motions() { + use crate::toc::TocKey as T; + let none = KeyModifiers::NONE; + let cases = [ + (KeyCode::Char('j'), none, T::Down), + (KeyCode::Up, none, T::Up), + (KeyCode::Char('G'), KeyModifiers::SHIFT, T::Last), + (KeyCode::Char('d'), KeyModifiers::CONTROL, T::PageDown), + (KeyCode::PageUp, none, T::PageUp), + (KeyCode::Enter, none, T::Jump), + (KeyCode::Esc, none, T::Leave), + (KeyCode::Char('/'), none, T::StartFilter), + (KeyCode::Char('h'), none, T::Collapse), + (KeyCode::Right, none, T::Expand), + ]; + for (code, mods, want) in cases { + assert_eq!( + map_toc_key(press(code, mods)), + Action::Toc(want), + "{code:?}" + ); + } + // While filtering, letters are text, not motions. + assert_eq!( + map_toc_filter_key(press(KeyCode::Char('j'), none)), + Action::Toc(T::FilterChar('j')) + ); + assert_eq!( + map_toc_filter_key(press(KeyCode::Esc, none)), + Action::Toc(T::ClearFilter) + ); + assert_eq!( + map_toc_filter_key(press(KeyCode::Enter, none)), + Action::Toc(T::Jump) + ); + } + + #[test] + fn toc_focus_is_bound_and_rebindable() { + use crate::input::keymap::parse_key; + for preset in [preset::DEFAULT, preset::EMACS] { + let (km, warnings) = build_keymap(preset, None); + assert!(warnings.is_empty(), "{warnings:?}"); + assert_eq!( + km.singles.get(&parse_key("o").unwrap()), + Some(&Action::TocFocus) + ); + } + let overrides = HashMap::from([("toc_focus".to_string(), vec!["ctrl-o".to_string()])]); + let (km, warnings) = build_keymap(preset::DEFAULT, Some(&overrides)); + assert!(warnings.is_empty(), "{warnings:?}"); + assert_eq!( + km.singles.get(&parse_key("ctrl-o").unwrap()), + Some(&Action::TocFocus) + ); + assert_eq!(km.singles.get(&parse_key("o").unwrap()), None); + } + + #[test] + fn the_wheel_reports_where_the_pointer_is() { + use crossterm::event::MouseEvent; + let wheel = |kind| MouseEvent { + kind, + column: 7, + row: 3, + modifiers: KeyModifiers::NONE, + }; + assert_eq!( + map_mouse(wheel(MouseEventKind::ScrollUp)), + Action::WheelUp(7, 3) + ); + assert_eq!( + map_mouse(wheel(MouseEventKind::ScrollDown)), + Action::WheelDown(7, 3) + ); + } + /// Bytes as crossterm decodes them into key events (`ESC x` → Alt-x, /// BEL → Ctrl-g). fn typed(bytes: &[u8]) -> Vec { diff --git a/src/input/preset.rs b/src/input/preset.rs index 8d98d8e..9fab27c 100644 --- a/src/input/preset.rs +++ b/src/input/preset.rs @@ -18,6 +18,7 @@ pub const DEFAULT: Preset = &[ ("home", &["home"]), ("end", &["end", "shift-g"]), ("toggle_toc", &["t"]), + ("toc_focus", &["o"]), ("search", &["/"]), ("next_heading", &["n"]), ("prev_heading", &["shift-n"]), @@ -33,6 +34,7 @@ pub const DEFAULT: Preset = &[ ("select_line_mode", &["shift-v"]), ("copy_code", &["c"]), ("copy_section", &["shift-y"]), + ("edit", &["e"]), ]; /// Vim preset is just an alias for the default. @@ -53,6 +55,7 @@ pub const EMACS: Preset = &[ ("home", &["ctrl-a", "home"]), ("end", &["ctrl-e", "end", "shift-g"]), ("toggle_toc", &["t"]), + ("toc_focus", &["o"]), ("search", &["ctrl-s", "/"]), ("next_heading", &["ctrl-f", "n"]), ("prev_heading", &["shift-n"]), @@ -68,6 +71,7 @@ pub const EMACS: Preset = &[ ("select_line_mode", &["shift-v"]), ("copy_code", &["c"]), ("copy_section", &["shift-y"]), + ("edit", &["e"]), ]; pub fn lookup(name: &str) -> Option { diff --git a/src/lib.rs b/src/lib.rs index 38d2396..b17e62a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -4,6 +4,7 @@ pub mod cli; pub mod clipboard; pub mod config; pub mod doctor; +pub mod editor; pub mod glyphs; pub mod graphics; pub mod highlight; diff --git a/src/render/mod.rs b/src/render/mod.rs index d92a299..de9e474 100644 --- a/src/render/mod.rs +++ b/src/render/mod.rs @@ -510,31 +510,83 @@ pub fn render_search_bar(frame: &mut Frame, area: Rect, search: &SearchState, t: frame.render_widget(Paragraph::new(vec![line]), area); } +/// The part of the TOC sidebar that lists headings: under the title row, +/// left of the divider. Mouse rows are measured from its top. +pub fn toc_list_area(area: Rect) -> Rect { + toc_block(Style::default(), Style::default(), String::new(), None).inner(area) +} + +fn toc_block( + border: Style, + title_style: Style, + title: String, + bg: Option, +) -> Block<'static> { + let mut block = Block::default() + .borders(Borders::RIGHT) + .border_set(plain_border()) + .border_style(border) + .title(title) + .title_style(title_style); + if let Some(bg) = bg { + block = block.style(Style::default().bg(bg)); + } + block +} + /// Render the table of contents sidebar. -pub fn render_toc( - frame: &mut Frame, - area: Rect, - entries: &[crate::toc::TocEntry], - selected: usize, - t: &theme::Theme, -) { +/// +/// Unfocused, it marks the heading the document is scrolled to. Focused +/// (`o`), the cursor row is drawn in reverse video across the full width, +/// every heading with subheadings shows a fold marker, and the title is +/// inverted too — so the state reads without colour and in ASCII mode. +pub fn render_toc(frame: &mut Frame, area: Rect, toc: &crate::toc::TocState, t: &theme::Theme) { + use crate::toc::Fold; + let g = crate::glyphs::current(); let active_color = theme::hex_to_color(&t.colors.toc_active); let inactive_color = theme::hex_to_color(&t.colors.toc_inactive); let border_color = theme::hex_to_color(&t.colors.table_border); let toc_bg = t.colors.bg.as_ref().map(|bg| theme::hex_to_color(bg)); + let focused = toc.nav.focused; - let lines: Vec> = entries + let list = toc_list_area(area); + let rows = toc.rows(); + let highlight = toc.anchor_row(); + let lines: Vec> = rows .iter() .enumerate() - .map(|(i, entry)| { + .skip(toc.nav.top) + .take(list.height as usize) + .map(|(row, &i)| { + let entry = &toc.headings[i]; let indent = " ".repeat((entry.level as usize).saturating_sub(1)); - let marker = if i == selected { - format!("{} ", crate::glyphs::current().pointer) + let current = Some(row) == highlight; + let fold = toc.fold(i); + let text = if focused { + let marker = match (fold, g.ascii) { + (Fold::Leaf, _) => " ", + (Fold::Open, false) => "▾ ", + (Fold::Closed, false) => "▸ ", + (Fold::Open, true) => "- ", + (Fold::Closed, true) => "+ ", + }; + format!("{indent}{marker}{}", entry.text) } else { - " ".to_string() + let marker = if current { + format!("{} ", g.pointer) + } else { + " ".to_string() + }; + // Unfocused, only a folded subtree is marked: the sidebar + // otherwise looks exactly as it always has. + let more = if fold == Fold::Closed { + format!(" {}", g.ellipsis) + } else { + String::new() + }; + format!("{indent}{marker}{}{more}", entry.text) }; - let text = format!("{indent}{marker}{}", entry.text); - let color = if i == selected { + let color = if current { active_color } else { inactive_color @@ -543,30 +595,90 @@ pub fn render_toc( if let Some(bg) = toc_bg { style = style.bg(bg); } - if i == selected { + if current { style = style.add_modifier(Modifier::BOLD); } + if current && focused { + // A full-width bar, so the cursor is unmistakable. + let width = unicode_width::UnicodeWidthStr::width(text.as_str()); + let pad = (list.width as usize).saturating_sub(width); + let text = format!("{text}{}", " ".repeat(pad)); + return Line::from(Span::styled(text, style.add_modifier(Modifier::REVERSED))); + } Line::from(Span::styled(text, style)) }) .collect(); + let lines = if lines.is_empty() && toc.nav.filter.is_some() { + vec![Line::from(Span::styled( + " no matching headings", + Style::default().fg(inactive_color), + ))] + } else { + lines + }; - let mut block = Block::default() - .borders(Borders::RIGHT) - .border_set(plain_border()) - .border_style(Style::default().fg(border_color)) - .title(" Contents ") - .title_style( - Style::default() - .fg(active_color) - .add_modifier(Modifier::BOLD), - ); - if let Some(bg) = toc_bg { - block = block.style(Style::default().bg(bg)); + let mut title_style = Style::default() + .fg(active_color) + .add_modifier(Modifier::BOLD); + let mut border_style = Style::default().fg(border_color); + let title = match &toc.nav.filter { + Some(q) => format!(" /{q}{} ", g.block), + None => " Contents ".to_string(), + }; + if focused { + title_style = title_style.add_modifier(Modifier::REVERSED); + border_style = Style::default().fg(active_color); } - + let block = toc_block(border_style, title_style, title, toc_bg); frame.render_widget(Paragraph::new(lines).block(block), area); } +/// The key reminder that replaces the bottom bar while the table of contents +/// has focus. +pub fn render_toc_bar(frame: &mut Frame, area: Rect, filtering: bool, t: &theme::Theme) { + let bg = theme::hex_to_color(&t.colors.status_bar_bg); + let fg = theme::hex_to_color(&t.colors.status_bar_fg); + let dim_fg = theme::hex_to_color(&t.colors.link_url); + let dot = crate::glyphs::current().dot; + let keys: &[(&str, &str)] = if filtering { + &[ + ("type", "to filter"), + ("up/down", "move"), + ("Enter", "jump"), + ("Esc", "clear"), + ] + } else { + &[ + ("j/k", "move"), + ("Enter", "jump"), + ("h/l", "fold"), + ("/", "filter"), + ("Esc", "back"), + ] + }; + let mut spans: Vec> = Vec::new(); + for (i, (key, desc)) in keys.iter().enumerate() { + if i > 0 { + spans.push(Span::styled( + format!(" {dot} "), + Style::default().fg(dim_fg).bg(bg), + )); + } + spans.push(Span::styled( + format!(" {key} "), + Style::default().fg(fg).bg(bg).add_modifier(Modifier::BOLD), + )); + spans.push(Span::styled( + desc.to_string(), + Style::default().fg(dim_fg).bg(bg), + )); + } + frame.render_widget( + Paragraph::new(vec![Line::from(spans)]).style(Style::default().bg(bg)), + area, + ); +} + /// Render the help overlay: a centered popup of keybindings. pub fn render_help(frame: &mut Frame, area: Rect, t: &theme::Theme) { let entries = crate::input::keymap_summary(); diff --git a/src/toc.rs b/src/toc.rs index c835faa..5fe697f 100644 --- a/src/toc.rs +++ b/src/toc.rs @@ -1,4 +1,5 @@ use crate::parser::Heading; +use std::collections::HashSet; /// Table of contents state. #[derive(Debug)] @@ -7,6 +8,9 @@ pub struct TocState { pub headings: Vec, pub selected: usize, pub width: u16, + /// What the reader is doing in the sidebar: focus, cursor, filter, + /// folded subtrees, how far it is scrolled. + pub nav: TocNav, } #[derive(Debug, Clone)] @@ -18,6 +22,70 @@ pub struct TocEntry { pub source_line: usize, } +/// Interaction state of the sidebar. Survives a re-layout of the same +/// document (resize, theme change, TOC toggle); a new document starts fresh. +#[derive(Debug, Clone, Default, PartialEq)] +pub struct TocNav { + /// Keys go to the sidebar instead of the document. + pub focused: bool, + /// The heading under the cursor while focused (an index into `headings`). + pub cursor: usize, + /// `Some` while the reader is typing a filter; only matching headings + /// are listed, folds ignored. + pub filter: Option, + /// Headings whose subtrees are folded away in the sidebar. + pub collapsed: HashSet, + /// First list row shown at the top of the sidebar. + pub top: usize, + /// The heading and row count the sidebar last scrolled to keep in view. + /// It only re-follows when that changes, so a wheel scroll is not undone + /// by the next frame. + followed: Option<(usize, usize)>, +} + +/// A key the focused sidebar understands. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TocKey { + Up, + Down, + First, + Last, + PageUp, + PageDown, + /// Jump the document to the heading under the cursor. + Jump, + /// Give focus back to the document without moving it. + Leave, + /// Start typing a filter. + StartFilter, + FilterChar(char), + FilterBack, + /// Drop the filter, staying in the sidebar. + ClearFilter, + /// Fold the subtree under the cursor (or step to its parent). + Collapse, + /// Unfold it (or step to its first child). + Expand, +} + +/// How a row's subtree marker is drawn. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Fold { + /// No subheadings. + Leaf, + Open, + Closed, +} + +/// Whether `text` matches a filter `query`: every whitespace-separated word +/// of the query appears in it, ignoring case. An empty query matches all. +pub fn filter_matches(text: &str, query: &str) -> bool { + let text = text.to_lowercase(); + query + .split_whitespace() + .all(|word| text.contains(&word.to_lowercase())) +} + impl TocState { #[allow(dead_code)] pub fn new(headings: &[Heading], line_mapping: &[(usize, usize)]) -> Self { @@ -43,6 +111,7 @@ impl TocState { headings: entries, selected: 0, width: 30, + nav: TocNav::default(), } } @@ -52,21 +121,450 @@ impl TocState { headings: Vec::new(), selected: 0, width: 30, + nav: TocNav::default(), } } pub fn toggle(&mut self) { self.visible = !self.visible; + if !self.visible { + self.leave(); + } } /// Update the selected heading based on the current scroll position. + /// + /// A heading on the viewport's second row already counts: that is where + /// `n`/`N` and TOC jumps put it, under its blank spacing line, and the + /// sidebar used to mark the heading before it. pub fn update_selection(&mut self, current_line: usize) { for (i, entry) in self.headings.iter().enumerate().rev() { - if entry.line_index <= current_line { + if entry.line_index <= current_line + 1 { self.selected = i; return; } } self.selected = 0; } + + /// Does heading `i` have subheadings? + pub fn has_children(&self, i: usize) -> bool { + match (self.headings.get(i), self.headings.get(i + 1)) { + (Some(h), Some(next)) => next.level > h.level, + _ => false, + } + } + + /// The fold marker for heading `i`. + pub fn fold(&self, i: usize) -> Fold { + if !self.has_children(i) { + Fold::Leaf + } else if self.nav.collapsed.contains(&i) { + Fold::Closed + } else { + Fold::Open + } + } + + /// The headings listed in the sidebar, in order: those matching the + /// filter while one is typed, otherwise every heading not inside a + /// folded subtree. + pub fn rows(&self) -> Vec { + if let Some(query) = &self.nav.filter { + return (0..self.headings.len()) + .filter(|&i| filter_matches(&self.headings[i].text, query)) + .collect(); + } + let mut rows = Vec::with_capacity(self.headings.len()); + let mut folded_under: Option = None; + for (i, h) in self.headings.iter().enumerate() { + if let Some(level) = folded_under { + if h.level > level { + continue; + } + folded_under = None; + } + rows.push(i); + if self.nav.collapsed.contains(&i) && self.has_children(i) { + folded_under = Some(h.level); + } + } + rows + } + + /// The row heading `i` is shown on, or the row of the nearest listed + /// heading above it (its folded ancestor) when it is hidden. + fn row_of(rows: &[usize], i: usize) -> Option { + match rows.binary_search(&i) { + Ok(r) => Some(r), + Err(0) => (!rows.is_empty()).then_some(0), + Err(r) => Some(r - 1), + } + } + + /// The heading the sidebar keeps in view: the cursor while focused, + /// otherwise the one the document is scrolled to. + fn anchor(&self) -> usize { + if self.nav.focused { + self.nav.cursor + } else { + self.selected + } + } + + /// The row highlighted in the sidebar. + pub fn anchor_row(&self) -> Option { + Self::row_of(&self.rows(), self.anchor()) + } + + /// The heading the cursor is on, if it is listed. + pub fn selected_heading(&self) -> Option { + let rows = self.rows(); + let r = Self::row_of(&rows, self.nav.cursor)?; + rows.get(r).copied() + } + + /// Move keyboard focus into the sidebar, the cursor on the current + /// heading (or the folded heading that hides it). + pub fn focus(&mut self) { + self.nav.filter = None; + self.nav.focused = true; + let rows = self.rows(); + self.nav.cursor = Self::row_of(&rows, self.selected) + .and_then(|r| rows.get(r).copied()) + .unwrap_or(0); + } + + /// Give focus back to the document; any filter is dropped. + pub fn leave(&mut self) { + self.nav.focused = false; + self.nav.filter = None; + } + + /// Move the cursor `delta` rows, clamped to the list. + pub fn move_by(&mut self, delta: isize) { + let rows = self.rows(); + if rows.is_empty() { + return; + } + let at = Self::row_of(&rows, self.nav.cursor).unwrap_or(0) as isize; + let to = at.saturating_add(delta).clamp(0, rows.len() as isize - 1); + self.nav.cursor = rows[to as usize]; + } + + /// Act on a key while focused. Returns the heading to jump the document + /// to, if the key asked for one; the caller handles the jump. + pub fn handle(&mut self, key: TocKey, page: usize) -> Option { + let page = page.saturating_sub(1).max(1) as isize; + match key { + TocKey::Up => self.move_by(-1), + TocKey::Down => self.move_by(1), + TocKey::First => self.move_by(isize::MIN), + TocKey::Last => self.move_by(isize::MAX), + TocKey::PageUp => self.move_by(-page), + TocKey::PageDown => self.move_by(page), + TocKey::Jump => { + let target = self.selected_heading(); + self.leave(); + return target; + } + TocKey::Leave => self.leave(), + TocKey::StartFilter => { + self.nav.filter = Some(String::new()); + } + // A changed query puts the cursor on the best (first) match, + // the way finders do. + TocKey::FilterChar(c) => { + if let Some(q) = &mut self.nav.filter { + q.push(c); + } + self.nav.cursor = self.rows().first().copied().unwrap_or(self.nav.cursor); + } + TocKey::FilterBack => { + if let Some(q) = &mut self.nav.filter { + q.pop(); + } + self.nav.cursor = self.rows().first().copied().unwrap_or(self.nav.cursor); + } + TocKey::ClearFilter => { + self.nav.filter = None; + self.snap_cursor_to_rows(); + } + TocKey::Collapse => self.collapse(), + TocKey::Expand => self.expand(), + } + None + } + + /// After the list changed: keep the cursor where it is if that heading + /// is still listed, otherwise put it on the first listed one after it + /// (or the last one). + fn snap_cursor_to_rows(&mut self) { + let rows = self.rows(); + if rows.contains(&self.nav.cursor) { + return; + } + if let Some(&next) = rows.iter().find(|&&i| i >= self.nav.cursor).or(rows.last()) { + self.nav.cursor = next; + } + } + + fn parent(&self, i: usize) -> Option { + let level = self.headings.get(i)?.level; + (0..i).rev().find(|&j| self.headings[j].level < level) + } + + /// Fold an open subtree; on a leaf or an already folded heading, step + /// out to the parent (the way tree views do). + fn collapse(&mut self) { + if self.nav.filter.is_some() { + return; + } + let i = self.nav.cursor; + if self.fold(i) == Fold::Open { + self.nav.collapsed.insert(i); + } else if let Some(p) = self.parent(i) { + self.nav.cursor = p; + } + } + + /// Unfold a folded subtree; on an open one, step into its first child. + fn expand(&mut self) { + if self.nav.filter.is_some() { + return; + } + let i = self.nav.cursor; + match self.fold(i) { + Fold::Closed => { + self.nav.collapsed.remove(&i); + } + Fold::Open => self.nav.cursor = i + 1, + Fold::Leaf => {} + } + } + + /// Scroll the sidebar so the highlighted row is inside a list `height` + /// rows tall. Called before each frame; only re-follows when the + /// highlighted heading or the list changed since last time, so the + /// reader can wheel the sidebar away from it. + pub fn follow(&mut self, height: usize) { + let rows = self.rows(); + let key = (self.anchor(), rows.len()); + if self.nav.followed != Some(key) { + self.nav.followed = Some(key); + if let Some(r) = Self::row_of(&rows, self.anchor()) { + if r < self.nav.top { + self.nav.top = r; + } else if height > 0 && r >= self.nav.top + height { + self.nav.top = r + 1 - height; + } + } + } + self.nav.top = self.nav.top.min(rows.len().saturating_sub(height.max(1))); + } + + /// Wheel-scroll the sidebar by `delta` rows. + pub fn scroll_by(&mut self, delta: isize, height: usize) { + let max = self.rows().len().saturating_sub(height.max(1)); + self.nav.top = (self.nav.top as isize) + .saturating_add(delta) + .clamp(0, max as isize) as usize; + } + + /// The heading on list row `y` of the sidebar (0 = first row under the + /// title), as currently scrolled. + pub fn heading_at(&self, y: usize) -> Option { + self.rows().get(self.nav.top + y).copied() + } + + /// Carry the interaction state over to a re-layout of the same document. + pub fn inherit(&mut self, mut nav: TocNav) { + let n = self.headings.len(); + nav.collapsed.retain(|&i| i < n); + nav.cursor = nav.cursor.min(n.saturating_sub(1)); + nav.followed = None; + self.nav = nav; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// # Alpha / ## Alpha one / ### Deep dive / ## Alpha two / # Beta / + /// ## Beta one / # Gamma + fn toc() -> TocState { + let mut t = TocState::empty(); + t.headings = [ + (1, "Alpha"), + (2, "Alpha one"), + (3, "Deep dive"), + (2, "Alpha two"), + (1, "Beta"), + (2, "Beta one"), + (1, "Gamma"), + ] + .iter() + .enumerate() + .map(|(i, (level, text))| TocEntry { + level: *level, + text: (*text).to_string(), + line_index: i * 10, + source_line: i * 3 + 1, + }) + .collect(); + t.visible = true; + t + } + + #[test] + fn focus_starts_on_the_current_heading_and_moves_clamped() { + let mut t = toc(); + t.update_selection(35); // inside "Alpha two" + t.focus(); + assert!(t.nav.focused); + assert_eq!(t.nav.cursor, 3); + t.handle(TocKey::Down, 5); + assert_eq!(t.nav.cursor, 4); + t.handle(TocKey::Last, 5); + assert_eq!(t.nav.cursor, 6); + t.handle(TocKey::Down, 5); + assert_eq!(t.nav.cursor, 6); + t.handle(TocKey::First, 5); + assert_eq!(t.nav.cursor, 0); + t.handle(TocKey::Up, 5); + assert_eq!(t.nav.cursor, 0); + // A page is the list height less one row of overlap. + t.handle(TocKey::PageDown, 4); + assert_eq!(t.nav.cursor, 3); + t.handle(TocKey::PageUp, 4); + assert_eq!(t.nav.cursor, 0); + } + + #[test] + fn enter_returns_the_heading_and_leaves_focus() { + let mut t = toc(); + t.focus(); + t.handle(TocKey::Down, 5); + assert_eq!(t.handle(TocKey::Jump, 5), Some(1)); + assert!(!t.nav.focused); + t.focus(); + assert_eq!(t.handle(TocKey::Leave, 5), None); + assert!(!t.nav.focused); + } + + #[test] + fn collapsing_hides_the_subtree_in_the_sidebar_only() { + let mut t = toc(); + t.focus(); + t.handle(TocKey::Collapse, 5); // fold Alpha + assert_eq!(t.rows(), vec![0, 4, 5, 6]); + assert_eq!(t.fold(0), Fold::Closed); + assert_eq!(t.fold(4), Fold::Open); + assert_eq!(t.fold(6), Fold::Leaf); + // Moving skips the hidden rows. + t.handle(TocKey::Down, 5); + assert_eq!(t.nav.cursor, 4); + t.handle(TocKey::Up, 5); + t.handle(TocKey::Expand, 5); + assert_eq!(t.rows().len(), 7); + // Expand on an open heading steps into it; collapse on a leaf + // steps out to the parent. + t.handle(TocKey::Expand, 5); + assert_eq!(t.nav.cursor, 1); + t.handle(TocKey::Expand, 5); + assert_eq!(t.nav.cursor, 2); + t.handle(TocKey::Collapse, 5); + assert_eq!(t.nav.cursor, 1); + // The document's headings are untouched. + assert_eq!(t.headings.len(), 7); + } + + #[test] + fn a_hidden_current_heading_highlights_its_folded_ancestor() { + let mut t = toc(); + t.nav.collapsed.insert(0); + t.update_selection(25); // "Deep dive", inside folded Alpha + assert_eq!(t.anchor_row(), Some(0)); + t.focus(); + assert_eq!(t.nav.cursor, 0); + } + + #[test] + fn filter_is_case_insensitive_and_every_word_must_match() { + assert!(filter_matches("Alpha Two", "two")); + assert!(filter_matches("Alpha Two", "TWO al")); + assert!(!filter_matches("Alpha Two", "alpha three")); + assert!(filter_matches("anything", " ")); + + let mut t = toc(); + t.nav.collapsed.insert(0); + t.focus(); + t.handle(TocKey::StartFilter, 5); + for c in "one".chars() { + t.handle(TocKey::FilterChar(c), 5); + } + // Folds do not hide filter matches. + assert_eq!(t.rows(), vec![1, 5]); + assert_eq!(t.nav.cursor, 1); + t.handle(TocKey::Down, 5); + assert_eq!(t.nav.cursor, 5); + for _ in 0..3 { + t.handle(TocKey::FilterBack, 5); + } + assert_eq!(t.rows().len(), 7); + // Nothing matches: no heading to jump to. + t.handle(TocKey::FilterChar('z'), 5); + assert!(t.rows().is_empty()); + assert_eq!(t.selected_heading(), None); + t.handle(TocKey::ClearFilter, 5); + assert!(t.nav.focused && t.nav.filter.is_none()); + // Enter from a filtered list jumps to the match. + t.handle(TocKey::StartFilter, 5); + t.handle(TocKey::FilterChar('g'), 5); + t.handle(TocKey::FilterChar('a'), 5); + assert_eq!(t.handle(TocKey::Jump, 5), Some(6)); + assert!(t.nav.filter.is_none()); + } + + #[test] + fn the_sidebar_scrolls_to_keep_the_cursor_visible_and_wheel_sticks() { + let mut t = toc(); + t.focus(); + t.follow(3); + assert_eq!(t.nav.top, 0); + t.handle(TocKey::Last, 3); + t.follow(3); + assert_eq!(t.nav.top, 4); + assert_eq!(t.heading_at(2), Some(6)); + t.handle(TocKey::First, 3); + t.follow(3); + assert_eq!(t.nav.top, 0); + // The wheel moves the list; the next frame does not snap back. + t.scroll_by(3, 3); + t.follow(3); + assert_eq!(t.nav.top, 3); + assert_eq!(t.heading_at(0), Some(3)); + // Clamped at both ends. + t.scroll_by(100, 3); + assert_eq!(t.nav.top, 4); + t.scroll_by(-100, 3); + assert_eq!(t.nav.top, 0); + assert_eq!(t.heading_at(7), None); + } + + #[test] + fn closing_the_sidebar_drops_focus_and_relayout_keeps_folds() { + let mut t = toc(); + t.focus(); + t.handle(TocKey::Collapse, 5); + t.toggle(); + assert!(!t.visible && !t.nav.focused); + let mut fresh = toc(); + fresh.headings.truncate(3); + let mut nav = t.nav.clone(); + nav.collapsed.insert(5); + fresh.inherit(nav); + assert_eq!(fresh.nav.collapsed, HashSet::from([0])); + } } diff --git a/tests/pty.rs b/tests/pty.rs index 6d34749..2cc62a1 100644 --- a/tests/pty.rs +++ b/tests/pty.rs @@ -460,3 +460,266 @@ fn ascii_mode_reader_and_overlays_write_only_7_bit_bytes() { assert!(contains(&s.output, b"\x1b[?1049l"), "reader did not exit"); assert!(s.output.is_ascii(), "non-ASCII output: {text}"); } + +type Marks = std::rc::Rc>>; + +/// Once the reader is up, types each step 400 ms apart, then quits (`q`, +/// repeated until it takes). `marks` records how much output there was when +/// each step went out, so a test can tell what was drawn before and after. +fn type_steps(steps: Vec>, marks: Marks) -> impl FnMut(&[u8], Duration) -> Option> { + let mut up_at = None; + let mut sent = 0; + let mut last_q = Duration::ZERO; + move |out, t| { + if up_at.is_none() && contains(out, b"\x1b[?1049h") { + up_at = Some(t); + } + let due = up_at? + Duration::from_millis(700 + 400 * sent as u64); + if t < due { + return None; + } + if sent < steps.len() { + sent += 1; + marks.borrow_mut().push(out.len()); + return Some(steps[sent - 1].clone()); + } + if t > last_q + Duration::from_millis(500) { + last_q = t; + return Some(b"q".to_vec()); + } + None + } +} + +/// A document whose first line links to a heading far below the first +/// screen. On the 100x30 test terminal the link text "go to target" is drawn +/// on row 2, columns 15-26 (1-based). +fn long_doc_with_anchor_link() -> tempfile::NamedTempFile { + let mut doc = String::from("Start here: [go to target](#target-heading) then more.\n\n"); + for i in 0..60 { + doc.push_str(&format!("filler {i}\n\n")); + } + doc.push_str("## Target heading\n\nXYZZY ARRIVED\n"); + let file = tempfile::Builder::new().suffix(".md").tempfile().unwrap(); + std::fs::write(file.path(), doc).unwrap(); + file +} + +fn reader_args(path: &std::path::Path) -> Vec { + [ + "--color=never", + "--theme", + "dark", + "--image-protocol", + "halfblocks", + "--no-images", + path.to_str().unwrap(), + ] + .iter() + .map(|s| s.to_string()) + .collect() +} + +#[test] +fn the_toc_takes_focus_filters_and_jumps() { + let doc = long_doc_with_anchor_link(); + let marks = Marks::default(); + let steps = vec![b"o".to_vec(), b"/targ".to_vec(), b"\r".to_vec()]; + let args = reader_args(doc.path()); + let args: Vec<&str> = args.iter().map(String::as_str).collect(); + let s = run_in_pty(&args, &[], type_steps(steps, Marks::clone(&marks))); + let marks = marks.borrow(); + assert_eq!(marks.len(), 3, "steps not all sent"); + let before = &s.output[..marks[2]]; + let after = &s.output[marks[2]..]; + // Focused: the sidebar opened and its key reminder (the filtering one, + // after `/`) replaced the status bar. + let text = String::from_utf8_lossy(before); + assert!(text.contains("Contents"), "sidebar did not open"); + assert!(text.contains("to filter"), "no TOC key reminder"); + // The cursor is reverse video, which reads without colour. + assert!(contains(before, b"7m"), "no reverse-video cursor"); + assert!(!contains(before, b"ARRIVED")); + // Enter jumped to the heading. + assert!(contains(after, b"ARRIVED"), "did not jump"); + assert!(contains(&s.output, b"\x1b[?1049l"), "reader did not exit"); +} + +#[test] +fn the_focused_toc_writes_only_7_bit_bytes_in_ascii_mode() { + let fixture = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/fixtures/ascii.md"); + let mut args = reader_args(std::path::Path::new(fixture)); + args.insert(0, "--ascii".into()); + let args: Vec<&str> = args.iter().map(String::as_str).collect(); + // Focus, fold the first heading, filter, clear the filter. + let steps = vec![ + b"o".to_vec(), + b"h".to_vec(), + b"/a".to_vec(), + b"\x1b".to_vec(), + ]; + let s = run_in_pty(&args, &[], type_steps(steps, Marks::default())); + let text = String::from_utf8_lossy(&s.output); + assert!(text.contains("Contents") && text.contains("fold"), "{text}"); + assert!(contains(&s.output, b"\x1b[?1049l"), "reader did not exit"); + assert!(s.output.is_ascii(), "non-ASCII output: {text}"); +} + +/// An SGR mouse report: `btn` 0 = left press, 32 = left drag; `press` +/// false = release. Columns and rows are 1-based. +fn mouse(btn: u8, col: u16, row: u16, press: bool) -> Vec { + let end = if press { 'M' } else { 'm' }; + format!("\x1b[<{btn};{col};{row}{end}").into_bytes() +} + +/// A config directory whose clipboard route is OSC 52 only, so a test that +/// copies never touches the machine's real clipboard. +fn osc52_only_config() -> tempfile::TempDir { + let dir = tempfile::tempdir().unwrap(); + std::fs::create_dir(dir.path().join("ink")).unwrap(); + std::fs::write( + dir.path().join("ink/config.toml"), + "[behavior]\nclipboard = \"osc52\"\n", + ) + .unwrap(); + dir +} + +/// Runs the reader on the long anchor-link document with mouse capture on, +/// sends `gesture` once it is up, and returns the output from before and +/// after it. +fn after_gesture(gesture: Vec) -> (Vec, Vec) { + let doc = long_doc_with_anchor_link(); + let config = osc52_only_config(); + let marks = Marks::default(); + let args = reader_args(doc.path()); + let args: Vec<&str> = args.iter().map(String::as_str).collect(); + let s = run_in_pty( + &args, + &[("XDG_CONFIG_HOME", config.path().to_str().unwrap())], + type_steps(vec![gesture], Marks::clone(&marks)), + ); + assert!(contains(&s.output, b"\x1b[?1049l"), "reader did not exit"); + let at = *marks.borrow().first().expect("gesture not sent"); + (s.output[..at].to_vec(), s.output[at..].to_vec()) +} + +#[test] +fn a_click_on_an_anchor_link_follows_it() { + // "go to target" is on row 2, columns 15-26. + let mut click = mouse(0, 18, 2, true); + click.extend(mouse(0, 18, 2, false)); + let (before, after) = after_gesture(click); + assert!(!contains(&before, b"ARRIVED")); + assert!( + contains(&after, b"ARRIVED"), + "the click did not follow the link" + ); + assert!(!contains(&after, b"\x1b]52;"), "a click copied something"); +} + +#[test] +fn a_drag_across_a_link_selects_and_copies_instead_of_following() { + let mut drag = mouse(0, 15, 2, true); + drag.extend(mouse(32, 20, 2, true)); + drag.extend(mouse(32, 26, 2, true)); + drag.extend(mouse(0, 26, 2, false)); + let (_, after) = after_gesture(drag); + // "go to target" as OSC 52 base64. + assert!( + contains(&after, b"\x1b]52;c;Z28gdG8gdGFyZ2V0\x07"), + "no OSC 52 copy of the selection: {:?}", + String::from_utf8_lossy(&after) + ); + assert!(!contains(&after, b"ARRIVED"), "the drag followed the link"); +} + +#[test] +fn a_click_off_any_link_does_nothing() { + let mut click = mouse(0, 6, 2, true); + click.extend(mouse(0, 6, 2, false)); + let (_, after) = after_gesture(click); + assert!(!contains(&after, b"ARRIVED")); + assert!(!contains(&after, b"\x1b]52;")); +} + +/// Index of the first `needle` at or after `from`. +fn find_from(hay: &[u8], needle: &[u8], from: usize) -> Option { + hay.get(from..)? + .windows(needle.len()) + .position(|w| w == needle) + .map(|i| i + from) +} + +/// Runs the reader on a short document with `editor` as `$EDITOR`, presses +/// `e`, and returns the output from the key press on, plus the document. +fn after_edit(editor: &str) -> (Vec, String) { + let dir = tempfile::tempdir().unwrap(); + let doc = dir.path().join("doc.md"); + std::fs::write(&doc, "# Notes\n\nfirst line\n").unwrap(); + let marks = Marks::default(); + let args = reader_args(&doc); + let args: Vec<&str> = args.iter().map(String::as_str).collect(); + let s = run_in_pty( + &args, + &[("EDITOR", editor), ("VISUAL", "")], + type_steps(vec![b"e".to_vec()], Marks::clone(&marks)), + ); + assert!(contains(&s.output, b"\x1b[?1049l"), "reader did not exit"); + let at = *marks.borrow().first().expect("e not sent"); + ( + s.output[at..].to_vec(), + std::fs::read_to_string(&doc).unwrap(), + ) +} + +#[test] +fn e_suspends_for_the_editor_and_reloads_the_file() { + let dir = tempfile::tempdir().unwrap(); + let script = dir.path().join("fake-editor.sh"); + // Appends a line to its last argument (the file) and records its argv. + std::fs::write( + &script, + format!( + "#!/bin/sh\necho \"$@\" > '{}'\nfor last; do :; done\nprintf 'EDITEDBYSCRIPT\\n' >> \"$last\"\n", + dir.path().join("argv").display() + ), + ) + .unwrap(); + std::fs::set_permissions(&script, std::os::unix::fs::PermissionsExt::from_mode(0o755)).unwrap(); + let (after, doc) = after_edit(script.to_str().unwrap()); + assert!(doc.ends_with("first line\nEDITEDBYSCRIPT\n"), "{doc:?}"); + // An unknown editor gets just the file, no line argument. + let argv = std::fs::read_to_string(dir.path().join("argv")).unwrap(); + assert!(argv.trim_end().ends_with("doc.md"), "{argv:?}"); + assert!(!argv.contains('+'), "{argv:?}"); + // The alternate screen was left for the editor and entered again… + let left = find_from(&after, b"\x1b[?1049l", 0).expect("never left the alternate screen"); + let back = find_from(&after, b"\x1b[?1049h", left).expect("never came back"); + // …with the mouse captured again, and the new content drawn. + assert!( + find_from(&after, b"\x1b[?1006h", left).is_some(), + "mouse not re-enabled" + ); + assert!( + find_from(&after, b"EDITEDBYSCRIPT", back).is_some(), + "not reloaded" + ); + assert!( + find_from(&after, b"reloaded", back).is_some(), + "no reload message" + ); +} + +#[test] +fn an_editor_that_cannot_start_leaves_the_reader_usable() { + let (after, doc) = after_edit("/nonexistent/editor --flag"); + assert_eq!(doc, "# Notes\n\nfirst line\n"); + let left = find_from(&after, b"\x1b[?1049l", 0).expect("never left the alternate screen"); + let back = find_from(&after, b"\x1b[?1049h", left).expect("never came back"); + assert!( + find_from(&after, b"/nonexistent/editor:", back).is_some(), + "{:?}", + String::from_utf8_lossy(&after) + ); +}