From e1a61a6ebd6413ced40ebe6de3faf2706ff1a6a4 Mon Sep 17 00:00:00 2001 From: Manuel Oetiker Date: Wed, 9 Sep 2026 12:10:51 +0200 Subject: [PATCH] feat(tui): re-read the document when its file changes A pager left open beside an editor now keeps up with what is being written. The event loop already wakes every 120 ms to look at the termination flag, so watching is one `stat` per tick against the file's modification time and length and needs no new dependency. A change is acted on only once a second look finds it unchanged, so a half-written save is never parsed, and a path that briefly vanishes -- how many editors save -- is waited out rather than treated as an empty document. The reading position survives the way it survives a resize, through the source offset of the topmost visible text. Unlike a resize, that offset has to be carried across the edit first: `remap_offset` compares the two sources from both ends, so text inserted above what the reader is looking at no longer pushes them off it. A live search is re-run against the new source rather than re-projected, the contents pane is rebuilt, and the footnote popup closes because the marker it points at may have moved. Reading and parsing stay in `term`: the state machine touches no file (design spec 13), so it is handed a parsed document. A file that cannot be read is reported in the status bar and leaves the document on screen alone. On by default, with `--no-reload`, `--reload` and `reload = false` to control it. Standard input is watched for nothing, there being no file. --- CHANGES.md | 19 +++ README.md | 1 + docs/manual.md | 28 ++++ src/config.rs | 28 ++++ src/config/tests.rs | 12 ++ src/config/write.rs | 8 ++ src/main.rs | 28 ++-- src/tui.rs | 10 +- src/tui/app.rs | 76 ++++++++++ src/tui/term.rs | 41 +++++- src/tui/tests.rs | 333 +++++++++++++++++++++++++++++++++++++++++++ src/tui/watch.rs | 92 ++++++++++++ tests/config_save.rs | 1 + 13 files changed, 663 insertions(+), 14 deletions(-) create mode 100644 src/tui/watch.rs diff --git a/CHANGES.md b/CHANGES.md index 72e09fa..6186785 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -20,6 +20,13 @@ minor bump rather than a patch. spec §10). `SearchSpan` has no constructor and is not `#[non_exhaustive]`, so any consumer building one by struct literal — as this crate itself does, in sixteen places — stops compiling until the new field is added. +- `mdmost::tui::run` gained a second parameter, `source: Option<&Path>` — the file the + document was read from, which the pager now watches for changes. Pass `None` for a + document that did not come from a file, which is what the old signature meant. +- `Config` gained a public field, `reload: bool` (default `true`), and a method, + `Config::math_syntax`, which is the one place `math` and `math_backslash` are turned + into a `MathSyntax`. As with the fields below, only a caller building a `Config` by + struct literal has to change. - `RenderOptions` gained a public field, `math_inline: bool`, and `Config` gained three, `math: bool`, `math_inline: bool` and `math_backslash: bool`. Both types already had a builder (`RenderOptions::with_math_inline` is new alongside it) and `Default`, so an @@ -33,6 +40,18 @@ minor bump rather than a patch. ### New +- A document read from a file is re-read while the pager is open, so **mdmost** left + beside an editor keeps up with what is being written. The reading position survives + the edit: the source offset at the top of the screen is carried across the changed + region, so text inserted above what you are reading does not push you off it. A live + search is re-run, the contents pane is rebuilt, and a footnote popup closes because + the marker it points at may have moved. The file is looked at once every eighth of a + second — one `stat`, no new dependency — and a change is acted on only once it has + stopped changing, so a half-written save is never shown; a path that momentarily + vanishes, which is how many editors save, is waited out rather than treated as an + empty document. Standard input is watched for nothing, there being no file. On by + default; `--no-reload`, `--reload` and `reload = false` control it. + - `$E = mc^2$` reads as `E = mc²` on the line, wherever inline math appears in a document: a paragraph, a table cell, a list item, a footnote. Scripts are Unicode where a full raised or lowered form exists and written flat (`x^q`) where it does diff --git a/README.md b/README.md index acced71..8b4b97d 100644 --- a/README.md +++ b/README.md @@ -142,6 +142,7 @@ starting: the problem is reported and the rest of the file still applies. ```toml theme = "dark" # name of a built-in or a [themes.*] table line_numbers = false # line-number gutter in fenced code blocks +reload = true # re-read the document when its file changes on disk mouse = false # wheel, drag-to-copy, and [copy] buttons body_width = 72 # widest the prose body is laid out; 0 for no cap section_numbers = true # number headings when a document nests three levels or more diff --git a/docs/manual.md b/docs/manual.md index 1f02291..5cf116b 100644 --- a/docs/manual.md +++ b/docs/manual.md @@ -64,6 +64,12 @@ writes plain text rather than escape sequences. - **`--no-math-backslash`** — Do not read `\(…\)` and `\[…\]`, even if the configuration file does. +- **`--no-reload`** — Do not re-read the document when the file it came from changes on + disk. Watching is on by default; see *Reloading* below. + +- **`--reload`** — Re-read the document when its file changes, even if the configuration + file turns it off. + - **`--mouse`** — Capture the mouse: the wheel scrolls, the scrollbar drags, a click in the contents pane jumps, and a drag over the document copies the Markdown source behind it. @@ -247,6 +253,27 @@ becomes `copied`. Capturing the mouse takes away the terminal's own drag-select for as long as **mdmost** runs. +# RELOADING + +A document read from a file is re-read whenever that file changes on disk, so a +pager left open beside an editor keeps up with what is being written. The +reading position is kept: **mdmost** remembers which part of the *source* was at +the top of the screen and puts the viewport back on it, carrying it across the +edit, so text inserted above what you are reading does not push you off it. + +A live search is re-run against the new text, and the contents pane is rebuilt. +A footnote popup closes, because the marker it points at may have moved. + +The file is looked at once every eighth of a second, and a change is acted on +only once it has stopped changing, so a document is never shown half-written. +An editor that saves by renaming a new file over the old one leaves a moment +where the path does not exist; that is a save in progress, not a reason to +throw away what is on screen. A file that cannot be read, or that is not text, +is reported in the status bar and leaves the document alone. + +Nothing is watched when the document arrived on standard input: there is no file +to look at. Turn watching off with `--no-reload` or `reload = false`. + # CONFIGURATION The configuration file is TOML, at *~/.config/mdmost/config.toml*, or in the @@ -263,6 +290,7 @@ icons = true # Nerd Font glyphs; false is plain Unicode; omit to det line_numbers = false # line-number gutter in fenced code blocks title_banner = false # off; true sets a lone `#` title as a wrapped FIGlet banner section_numbers = true # number headings when a document nests three levels or more +reload = true # re-read the document when its file changes on disk mouse = false # wheel scrolls, scrollbar drags, TOC clicks jump, drag copies # source, and code frames and tables get a [copy] button scroll_step = 3 # document lines per mouse-wheel notch diff --git a/src/config.rs b/src/config.rs index d4815a1..8b33cc7 100644 --- a/src/config.rs +++ b/src/config.rs @@ -144,6 +144,15 @@ pub struct Config { /// agree about. `less` does not capture either. Turn it on with `--mouse` or /// `mouse = true`. pub mouse: bool, + /// Whether the document is re-read when the file it came from changes on disk. + /// + /// On by default: a pager pointed at a file somebody is editing in another window + /// is expected to keep up, and the reader who wanted a frozen copy of a moving + /// file can pipe it in instead. Turn it off with `--no-reload` or `reload = false`. + /// + /// It has nothing to act on when the document arrived on standard input: there is + /// no file to watch, and the setting is ignored rather than being an error. + pub reload: bool, /// How many document lines one mouse-wheel notch scrolls. pub scroll_step: u16, /// The widest the document body is laid out, however wide the terminal is. @@ -176,6 +185,7 @@ impl Default for Config { toc_open: false, toc_width: DEFAULT_TOC_WIDTH, mouse: false, + reload: true, scroll_step: 3, body_width: Some(DEFAULT_BODY_WIDTH), keys: KeyBindings::defaults(), @@ -207,6 +217,19 @@ impl Loaded { } impl Config { + /// Which math delimiters a document should be parsed with. + /// + /// Lives here because two callers need the same answer — the binary at startup and + /// the pager when it re-reads a file that changed — and two derivations of it are + /// two chances to disagree about what `math = false` covers. + pub fn math_syntax(&self) -> crate::doc::MathSyntax { + crate::doc::MathSyntax { + dollars: self.math, + // `math` dominates: with the parser off there is nothing to extend. + backslash: self.math && self.math_backslash, + } + } + /// The path configuration is read from when none is given on the command line. /// /// Returns `None` when the platform has no home directory to speak of. @@ -350,6 +373,7 @@ struct RawConfig { title_banner: Option, section_numbers: Option, mouse: Option, + reload: Option, scroll_step: Option, body_width: Option, #[serde(default)] @@ -426,6 +450,9 @@ impl RawConfig { if let Some(mouse) = self.mouse { config.mouse = mouse; } + if let Some(reload) = self.reload { + config.reload = reload; + } if let Some(step) = self.scroll_step { if step == 0 { problems.push(problem(text, path, "scroll_step", "must be at least 1")); @@ -627,6 +654,7 @@ const KNOWN_KEYS: &[&str] = &[ "title_banner", "section_numbers", "mouse", + "reload", "scroll_step", "body_width", "toc", diff --git a/src/config/tests.rs b/src/config/tests.rs index 590bb4f..a7d39fb 100644 --- a/src/config/tests.rs +++ b/src/config/tests.rs @@ -534,3 +534,15 @@ fn a_misspelt_math_key_is_reported_and_dropped() { let loaded = Config::parse_str("mathinline = true\n", path()); assert_eq!(loaded.problems.len(), 1, "{:?}", loaded.problems); } + +#[test] +fn auto_reload_is_on_unless_the_file_turns_it_off() { + // On by default: a document the reader is editing in another window should keep + // up without anybody having to ask for it. + assert!(Config::default().reload); + assert!(Config::parse_str("", path()).config.reload); + + let loaded = Config::parse_str("reload = false\n", path()); + assert!(loaded.problems.is_empty(), "{:?}", loaded.problems); + assert!(!loaded.config.reload); +} diff --git a/src/config/write.rs b/src/config/write.rs index 159b1fc..ed36222 100644 --- a/src/config/write.rs +++ b/src/config/write.rs @@ -133,6 +133,11 @@ impl Config { key: "mouse", value: Some(self.mouse.to_string()), }, + Entry { + section: None, + key: "reload", + value: Some(self.reload.to_string()), + }, Entry { section: None, key: "scroll_step", @@ -212,6 +217,9 @@ impl Config { if back.mouse != self.mouse { return refuse("mouse"); } + if back.reload != self.reload { + return refuse("reload"); + } if back.scroll_step != self.scroll_step { return refuse("scroll_step"); } diff --git a/src/main.rs b/src/main.rs index 6f4d320..0252bfe 100644 --- a/src/main.rs +++ b/src/main.rs @@ -104,6 +104,18 @@ struct Cli { #[arg(long, conflicts_with = "math_backslash")] no_math_backslash: bool, + /// Do not re-read the document when the file it came from changes on disk. + /// + /// Watching is on by default and costs one `stat` every eighth of a second. Turn it + /// off to keep reading the document as it was when it was opened, whatever the + /// writer does to the file in the meantime. + #[arg(long)] + no_reload: bool, + + /// Re-read the document when its file changes, even if the config file says not to. + #[arg(long, conflicts_with = "no_reload")] + reload: bool, + /// Capture the mouse: wheel scrolls, the scrollbar drags, clicks jump in the contents /// pane, and dragging over the document copies the Markdown source behind it. /// @@ -259,11 +271,12 @@ fn run(cli: Cli) -> anyhow::Result { return Ok(ExitCode::from(EXIT_USAGE)); } - let (source, title) = match read_input(match input { + let source_path = match input { Input::File(path) => Some(path), // `Nothing` returned above, so this is standard input either way. Input::Stdin | Input::Nothing => None, - }) { + }; + let (source, title) = match read_input(source_path) { Ok(pair) => pair, Err(error) => { let _ = writeln!(io::stderr(), "mdmost: {error}"); @@ -277,14 +290,9 @@ fn run(cli: Cli) -> anyhow::Result { cli.no_math_backslash, config.math_backslash, ); + config.reload = resolve_flag(cli.reload, cli.no_reload, config.reload); - let doc = Doc::parse_auto_with( - &source, - mdmost::doc::MathSyntax { - dollars: config.math, - backslash: config.math && config.math_backslash, - }, - ); + let doc = Doc::parse_auto_with(&source, config.math_syntax()); let theme_name = cli.theme.clone().unwrap_or_else(|| config.theme.clone()); let icons = resolve_icons(&cli, config.icons); @@ -331,7 +339,7 @@ fn run(cli: Cli) -> anyhow::Result { width: cli.width, }, ); - tui::run(&mut app)?; + tui::run(&mut app, source_path)?; Ok(ExitCode::SUCCESS) } diff --git a/src/tui.rs b/src/tui.rs index 4901f85..f194d55 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -22,6 +22,7 @@ //! | [`dump`] | `--render-once` output, ANSI or plain | //! | [`wide`] | Rendering over-wide blocks so they stay horizontally reachable | //! | `term` | Terminal lifecycle, signal safety and the event loop | +//! | `watch` | Noticing that the file behind the document changed on disk | //! //! The split exists because design spec §13 requires application state to be testable //! without a terminal: [`app::App`] never touches one. @@ -39,6 +40,7 @@ pub mod popup; pub mod select; pub mod stderr; mod term; +mod watch; #[cfg(test)] mod tests; @@ -47,13 +49,17 @@ pub use app::{App, AppOptions, Focus, Overlay, PromptKind}; /// Runs the pager to completion. /// +/// `source` is the file the document was read from, if it came from one: the pager +/// re-reads it while it runs whenever the reader has left `reload` on. `None` — a +/// document that arrived on standard input — is watched for nothing. +/// /// The terminal is restored on every exit path, including panics and `SIGTERM`. /// /// # Errors /// /// Returns any I/O failure raised by the terminal. -pub fn run(app: &mut App) -> std::io::Result<()> { - term::run(app) +pub fn run(app: &mut App, source: Option<&std::path::Path>) -> std::io::Result<()> { + term::run(app, source) } /// Restores the terminal, for callers that need to bail out mid-flight. diff --git a/src/tui/app.rs b/src/tui/app.rs index a9b59b1..610fd30 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -41,6 +41,36 @@ const MAX_REPEAT: usize = 10_000; /// the label back. Nothing here schedules a wake-up. pub const FLASH_FOR: u64 = 600; +/// Where `offset` in `old` ends up in `new`, for one contiguous edit. +/// +/// The reading position is remembered as a byte offset into the document source, and an +/// edit above it moves the bytes it counts. Comparing the two sources from both ends +/// finds the region that actually changed: an offset before it keeps its value, an +/// offset after it shifts by however much the document grew or shrank, and an offset +/// *inside* it has no answer — the text it named is what was edited — so it keeps its +/// value and the reader lands as near as the render allows. +/// +/// Exact for the single edited region an editor's save produces, which is the case this +/// exists for. Several edits at once collapse into the one region that spans them, which +/// is approximate rather than wrong: only the position is at stake, and the reader can +/// see where they are. +pub(super) fn remap_offset(old: &str, new: &str, offset: usize) -> usize { + let old = old.as_bytes(); + let new = new.as_bytes(); + let common = old.len().min(new.len()); + let prefix = (0..common).take_while(|&i| old[i] == new[i]).count(); + if offset < prefix { + return offset; + } + let suffix = (0..common - prefix) + .take_while(|&i| old[old.len() - 1 - i] == new[new.len() - 1 - i]) + .count(); + if offset >= old.len() - suffix { + return (offset + new.len()).saturating_sub(old.len()); + } + offset.min(new.len()) +} + /// Whether `key` is a bare digit, and so part of a repeat count. fn is_count_digit(key: Key) -> bool { matches!(key.code, KeyCode::Char(ch) if ch.is_ascii_digit()) && key.mods.is_empty() @@ -856,6 +886,52 @@ impl App { self.track_toc(); } + /// Replaces the document with a newly parsed one, keeping the reader in place. + /// + /// The file behind the pager changed and [`super::term`] read it again; the state + /// machine touches no file itself (design spec §13), so it is handed the parsed + /// result. Everything derived from the old document is rebuilt here rather than + /// carried over — the table of contents, the search hits, the render — and + /// everything anchored to the *old canvas* is dropped, exactly as a reflow drops it. + /// + /// The reading position survives the way it survives a resize, through the source + /// offset of the topmost visible text; unlike a resize, that offset has to be + /// carried across the edit first (see [`remap_offset`]), because the bytes it counts + /// have moved. + pub fn reload(&mut self, doc: Doc) { + self.ensure_rendered(); + let anchor = self + .source_offset_at(self.scroll) + .map(|offset| remap_offset(self.doc.source(), doc.source(), offset)); + // Both are anchored to geometry the new render is about to replace, the same + // way a resize invalidates them. + self.bar_grab = None; + self.popup = None; + self.doc = doc; + // Rebuilt before the render, because `ensure_rendered` re-attaches the pane's + // anchors to the new canvas and would otherwise attach the old pane to it. + self.toc = Toc::from_doc( + &self.doc, + &crate::numbering::Numbering::enabled(&self.doc, self.config.section_numbers), + ); + // Re-run rather than re-located: `ensure_rendered` projects existing hits onto + // the new canvas, but the hits themselves came from the old *source* and a + // reload is precisely the case where that source changed. + if !self.search.query().is_empty() { + self.search = Search::new(self.doc.source(), self.search.query(), self.search.mode()) + .unwrap_or_else(|_| Search::empty()); + } + self.search_index = None; + self.ensure_rendered(); + if let Some(offset) = anchor { + self.scroll = self.row_for_source_offset(offset); + } + self.clamp(); + self.refilter_toc(); + self.track_toc(); + self.notify("reloaded", false); + } + /// Renders the document if the cache is stale, then re-attaches anchors and hits. /// /// Dropping the cache changes nothing visible: everything derived from a render is diff --git a/src/tui/term.rs b/src/tui/term.rs index 6b035d5..19549cf 100644 --- a/src/tui/term.rs +++ b/src/tui/term.rs @@ -23,6 +23,7 @@ //! than open-coded anywhere. use std::io::{self, Write}; +use std::path::Path; use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; use std::time::Duration; @@ -37,6 +38,7 @@ use crate::canvas::HotspotKind; use crate::config::{Key, KeyCode, KeyMods}; use super::app::{Activation, App, Focus}; +use super::watch::Watcher; use super::{chrome, draw}; /// How long the loop waits for input before checking the termination flag. @@ -178,7 +180,7 @@ impl Input { /// /// Returns any I/O failure from the terminal, including [`terminal_gone`] when the /// terminal is hung up under the pager. The terminal is restored either way. -pub fn run(app: &mut App) -> io::Result<()> { +pub fn run(app: &mut App, source: Option<&Path>) -> io::Result<()> { install_panic_hook(); // Declared before the `Restore` guard so that it is dropped after it: what a // library complained about is printed once the terminal is the reader's again, not @@ -211,7 +213,10 @@ pub fn run(app: &mut App) -> io::Result<()> { // restoration has to be part of that order rather than a scope-end surprise. let guard = Restore; - let result = event_loop(app, &mut terminal, &input, &terminate); + // Nothing to watch when the document came down a pipe, and nothing to watch when + // the reader turned it off. + let mut watcher = source.filter(|_| app.config().reload).map(Watcher::new); + let result = event_loop(app, &mut terminal, &input, &terminate, watcher.as_mut()); if result.is_err() { // `ratatui`'s `Terminal` complains into standard error from its destructor // when it cannot show the cursor — and when a terminal has just died, that @@ -241,6 +246,7 @@ fn event_loop( terminal: &mut ratatui::DefaultTerminal, input: &Input, terminate: &Arc, + mut watched: Option<&mut Watcher>, ) -> io::Result<()> { // Laying out a large document takes real time, and an empty alternate screen is // indistinguishable from a hang (usability review B5). One cheap frame first says @@ -267,6 +273,11 @@ fn event_loop( if waited == Wait::Gone { return Err(terminal_gone()); } + // Before the events, so that a document which changed under a reader who is + // holding a movement key is still re-read rather than starved by the input. + if let Some(watcher) = watched.as_deref_mut() { + reload_tick(app, watcher); + } // The descriptor was live a moment ago, so `crossterm` may look at it. Zero // timeout: the waiting has already been done, and asking even when nothing // arrived is what hands over events its parser is still holding from an @@ -302,6 +313,32 @@ fn event_loop( Ok(()) } +/// Re-reads the document when the file behind it has changed and settled. +/// +/// Reading and parsing live here rather than in [`App`] because the state machine +/// touches no file (design spec §13); what it is handed is a parsed document. +/// +/// A file that cannot be read or is not text is reported in the status bar and changes +/// nothing else: the document on screen is the last one that *was* readable, which is +/// more use to the reader than an empty screen. The change is consumed either way, so a +/// file that stays broken says so once rather than on every tick. +pub(super) fn reload_tick(app: &mut App, watcher: &mut Watcher) { + if !watcher.changed() { + return; + } + let path = watcher.path(); + match std::fs::read_to_string(path) { + Ok(source) => app.reload(crate::doc::Doc::parse_auto_with( + &source, + app.config().math_syntax(), + )), + Err(error) => app.notify( + format!("could not re-read {}: {error}", path.display()), + true, + ), + } +} + /// Dispatches a key event. fn on_key(app: &mut App, event: KeyEvent) { if event.kind == KeyEventKind::Release { diff --git a/src/tui/tests.rs b/src/tui/tests.rs index 43fd0c9..133c6a4 100644 --- a/src/tui/tests.rs +++ b/src/tui/tests.rs @@ -7438,3 +7438,336 @@ fn a_popup_shows_the_source_of_math_that_will_not_draw() { "a formula that will not draw shows its source, not a hole: {text:?}" ); } + +// --------------------------------------------------------------------------- +// Reloading a document whose file changed underneath the pager. +// +// The state machine touches no file (design spec §13): `super::term` reads and parses, +// and hands the new document over. What is tested here is everything that happens on +// this side of that hand-over. +// --------------------------------------------------------------------------- + +/// [`SAMPLE`] with enough filler under it that any of its headings can be scrolled to +/// the top of a twelve-row viewport, which is what makes a reading position observable. +fn padded_sample() -> String { + format!("{SAMPLE}\n{}", "Filler line of prose.\n\n".repeat(20)) +} + +/// The row the heading named `text` was rendered on. +fn row_of_heading(app: &mut App, text: &str) -> usize { + let _ = app.canvas(); + let index = app + .toc() + .entries() + .iter() + .position(|entry| entry.text == text) + .unwrap_or_else(|| panic!("no heading called {text}")); + app.toc() + .row_of(index) + .unwrap_or_else(|| panic!("{text} was not rendered")) +} + +#[test] +fn reloading_keeps_the_reading_position_when_the_edit_is_below_it() { + let source = padded_sample(); + let mut app = pager(&source); + let details = row_of_heading(&mut app, "Details"); + app.scroll_by(details as isize); + assert_eq!( + app.scroll(), + details, + "the sample is too short to test this" + ); + + let mut edited = source.clone(); + edited.push_str("\n## Afterword\n\nNu xi omicron.\n"); + app.reload(Doc::parse(&edited)); + + assert_eq!( + app.scroll(), + row_of_heading(&mut app, "Details"), + "the reader was left somewhere else by an edit below them" + ); +} + +#[test] +fn reloading_keeps_the_reading_position_when_the_edit_is_above_it() { + let source = padded_sample(); + let mut app = pager(&source); + let summary = row_of_heading(&mut app, "Summary"); + app.scroll_by(summary as isize); + assert_eq!( + app.scroll(), + summary, + "the sample is too short to test this" + ); + + // What an editor does most: text appears above what is on screen. The anchor is a + // byte offset into the old source, so it has to be carried across the edit rather + // than used as it stands, or the reader slides by the size of the insertion. + let edited = format!("Preface. One more line of it.\n\n{source}"); + app.reload(Doc::parse(&edited)); + + assert_eq!( + app.scroll(), + row_of_heading(&mut app, "Summary"), + "the reader slid off the section they were reading" + ); +} + +#[test] +fn reloading_a_shorter_document_clamps_to_its_end() { + let mut app = pager(SAMPLE); + app.act(Action::Bottom); + assert!(app.scroll() > 0); + + app.reload(Doc::parse("# Tiny\n\nOne line.\n")); + + assert!( + app.scroll() <= app.max_scroll(), + "scrolled past the end of the new document" + ); +} + +#[test] +fn reloading_rebuilds_the_table_of_contents() { + let mut app = pager(SAMPLE); + app.reload(Doc::parse("# Fresh\n\n## Second\n")); + let _ = app.canvas(); + + let headings: Vec<&str> = app + .toc() + .entries() + .iter() + .map(|entry| entry.text.as_str()) + .collect(); + assert_eq!(headings, ["Fresh", "Second"]); +} + +#[test] +fn reloading_re_runs_the_live_search() { + let mut app = pager(SAMPLE); + app.act(Action::SearchForward); + for ch in "Needle".chars() { + app.on_key(Key::char(ch)); + } + app.on_key(Key::plain(KeyCode::Enter)); + assert_eq!(app.search().len(), 2); + + let edited = format!("{SAMPLE}\nNeedle once more.\n"); + app.reload(Doc::parse(&edited)); + let _ = app.canvas(); + + assert_eq!( + app.search().len(), + 3, + "the search was not re-run against the new document" + ); +} + +#[test] +fn reloading_closes_a_footnote_popup() { + // The box is anchored to a marker at a position the new render may not have, and a + // box pointing at a sentence that has moved is worse than no box (design spec §6). + let mut app = open_footnote("a[^n]\n\n[^n]: short\n", 80, 24); + app.reload(Doc::parse("b[^n]\n\n[^n]: short\n")); + assert!(app.popup().is_none()); +} + +#[test] +fn reloading_says_so_in_the_status_bar() { + let mut app = pager(SAMPLE); + app.reload(Doc::parse("# Fresh\n")); + let notice = app.notice().expect("a reload is worth reporting"); + assert!(!notice.is_error, "a reload is not a failure"); + assert!( + notice.text.contains("reloaded"), + "unexpected notice: {}", + notice.text + ); +} + +#[test] +fn an_offset_before_an_edit_keeps_its_value() { + let old = "alpha\nbeta\n"; + let new = "alpha\nbeta\ngamma\n"; + assert_eq!(super::app::remap_offset(old, new, 2), 2); +} + +#[test] +fn an_offset_after_an_edit_moves_with_it() { + let old = "alpha\nbeta\n"; + let new = "one\ntwo\nalpha\nbeta\n"; + // "beta" starts at 6 in the old source and at 14 in the new one. + assert_eq!(super::app::remap_offset(old, new, 6), 14); + + // And the same in reverse, when the lines above are deleted again. + assert_eq!(super::app::remap_offset(new, old, 14), 6); +} + +#[test] +fn an_offset_inside_an_edit_stays_within_the_new_source() { + let old = "alpha\nbeta\ngamma\n"; + let new = "alpha\nB\ngamma\n"; + let mapped = super::app::remap_offset(old, new, 8); + assert!(mapped <= new.len(), "{mapped} is past the end of {new:?}"); +} + +#[test] +fn an_unchanged_source_maps_every_offset_to_itself() { + let text = "alpha\nbeta\n"; + for offset in 0..=text.len() { + assert_eq!(super::app::remap_offset(text, text, offset), offset); + } +} + +// --------------------------------------------------------------------------- +// Noticing that the file changed (`super::watch`). +// --------------------------------------------------------------------------- + +/// A directory that removes itself, so a test cannot leak into the developer's home. +struct TempDir(std::path::PathBuf); + +impl TempDir { + fn new(name: &str) -> Self { + let base = std::env::temp_dir().join(format!( + "mdmost-watch-{}-{}-{name}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|since| since.as_nanos()) + .unwrap_or_default() + )); + std::fs::create_dir_all(&base).expect("temp dir"); + Self(base) + } + + fn file(&self, name: &str, content: &str) -> std::path::PathBuf { + let path = self.0.join(name); + std::fs::write(&path, content).expect("write"); + path + } +} + +impl Drop for TempDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +#[test] +fn an_untouched_file_is_never_reported_as_changed() { + let dir = TempDir::new("untouched"); + let path = dir.file("doc.md", "# One\n"); + let mut watcher = super::watch::Watcher::new(&path); + for _ in 0..5 { + assert!(!watcher.changed()); + } +} + +#[test] +fn a_change_is_reported_once_it_has_settled() { + let dir = TempDir::new("settled"); + let path = dir.file("doc.md", "# One\n"); + let mut watcher = super::watch::Watcher::new(&path); + + std::fs::write(&path, "# One\n\nAnd a second paragraph.\n").expect("write"); + assert!( + !watcher.changed(), + "a file seen changing for the first time may still be half written" + ); + assert!(watcher.changed(), "the change settled and was not reported"); + assert!(!watcher.changed(), "the same change was reported twice"); +} + +#[test] +fn a_file_still_being_written_is_left_alone_until_it_stops() { + let dir = TempDir::new("in-flight"); + let path = dir.file("doc.md", "# One\n"); + let mut watcher = super::watch::Watcher::new(&path); + + std::fs::write(&path, "# One\n\nHalf of a").expect("write"); + assert!(!watcher.changed()); + std::fs::write( + &path, + "# One\n\nHalf of a paragraph, then the rest of it.\n", + ) + .expect("write"); + assert!(!watcher.changed(), "reported a file that was still growing"); + assert!(watcher.changed(), "the finished file was never reported"); +} + +#[test] +fn a_file_that_vanishes_mid_save_is_not_a_change() { + // Editors that save by writing a new file and renaming it over the old one leave a + // window where the path does not exist. That is a save in progress, not a document + // to load, and certainly not a reason to throw away the one on screen. + let dir = TempDir::new("renamed"); + let path = dir.file("doc.md", "# One\n"); + let mut watcher = super::watch::Watcher::new(&path); + + std::fs::remove_file(&path).expect("remove"); + assert!(!watcher.changed()); + assert!(!watcher.changed()); + + std::fs::write(&path, "# One\n\nBack again, with more text.\n").expect("write"); + assert!(!watcher.changed()); + assert!(watcher.changed(), "the replacement file was never reported"); +} + +#[test] +fn a_settled_change_reaches_the_document() { + let dir = TempDir::new("tick"); + let path = dir.file("doc.md", "# One\n"); + let mut app = pager("# One\n"); + let mut watcher = super::watch::Watcher::new(&path); + + std::fs::write(&path, "# Two\n\nWith a paragraph.\n").expect("write"); + super::term::reload_tick(&mut app, &mut watcher); + assert_eq!( + app.doc().source(), + "# One\n", + "a change was taken up before it had settled" + ); + + super::term::reload_tick(&mut app, &mut watcher); + assert_eq!(app.doc().source(), "# Two\n\nWith a paragraph.\n"); + assert_eq!(app.toc().entries()[0].text, "Two"); +} + +#[test] +fn a_file_that_cannot_be_read_keeps_the_document_on_screen() { + let dir = TempDir::new("unreadable"); + let path = dir.file("doc.md", "# One\n"); + let mut app = pager("# One\n"); + let mut watcher = super::watch::Watcher::new(&path); + + // Not text, so reading it back as a document fails where opening it did not. + std::fs::write(&path, [0x23, 0x20, 0xff, 0xfe, 0x0a]).expect("write"); + super::term::reload_tick(&mut app, &mut watcher); + super::term::reload_tick(&mut app, &mut watcher); + + assert_eq!( + app.doc().source(), + "# One\n", + "an unreadable file replaced the document that was on screen" + ); + let notice = app.notice().expect("the failure was not reported"); + assert!(notice.is_error, "unexpected notice: {}", notice.text); +} + +#[test] +fn the_math_syntax_follows_the_configuration() { + let mut config = Config::default(); + assert!(config.math_syntax().dollars); + assert!(!config.math_syntax().backslash); + + config.math_backslash = true; + assert!(config.math_syntax().backslash); + + // `math = false` dominates: with the parser off there is nothing for either of the + // other two keys to act on. + config.math = false; + assert!(!config.math_syntax().dollars); + assert!(!config.math_syntax().backslash); +} diff --git a/src/tui/watch.rs b/src/tui/watch.rs new file mode 100644 index 0000000..22a4aee --- /dev/null +++ b/src/tui/watch.rs @@ -0,0 +1,92 @@ +// SPDX-License-Identifier: MIT +//! Noticing that the file behind the document changed. +//! +//! The event loop already wakes every [`POLL_INTERVAL`](super::term) to look at the +//! termination flag, so watching costs one `stat` per tick and no dependency at all. +//! An inotify-style watcher would buy earlier notice of a file a reader is *reading*, +//! which is not a deadline anybody can feel, at the price of a crate that has to be +//! ported per platform. +//! +//! What the tick reports is deliberately one tick behind: a change is remembered when +//! it is first seen and only acted on when the next look finds the same file, so a +//! document is never parsed half-written. See [`Watcher::changed`]. + +use std::path::{Path, PathBuf}; +use std::time::SystemTime; + +/// What is compared to decide whether a file changed. +/// +/// Modification time and length, which is what a `stat` gives cheaply. An edit that +/// preserves the length *and* lands inside the same modification-time tick is missed; +/// on the nanosecond timestamps Linux, macOS and Windows all keep, that is a write +/// racing itself rather than a case anyone reaches by editing a document. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct Stamp { + modified: Option, + len: u64, +} + +impl Stamp { + /// The file's current stamp, or `None` if it cannot be looked at right now. + fn of(path: &Path) -> Option { + let meta = std::fs::metadata(path).ok()?; + Some(Self { + modified: meta.modified().ok(), + len: meta.len(), + }) + } +} + +/// A file, and what it looked like the last time the pager agreed with it. +#[derive(Debug)] +pub(super) struct Watcher { + path: PathBuf, + /// The stamp of the document currently on screen. + seen: Option, + /// A stamp seen once and not yet confirmed by a second look. + pending: Option, +} + +impl Watcher { + /// Starts watching `path` as it stands now. + pub(super) fn new(path: &Path) -> Self { + Self { + path: path.to_path_buf(), + seen: Stamp::of(path), + pending: None, + } + } + + /// The file being watched. + pub(super) fn path(&self) -> &Path { + &self.path + } + + /// Whether the file has changed and settled since the last time this said so. + /// + /// One `stat`. A stamp that differs from the document on screen is remembered and + /// reported only when the *next* call finds it unchanged, which is what keeps a + /// file that is still being written from being read: a write in progress moves the + /// stamp again and the wait starts over. + /// + /// A path that cannot be looked at — the window between the temporary file and the + /// rename that editors save through — is not a change and is not an error. The + /// document stays as it is and the next tick looks again. + pub(super) fn changed(&mut self) -> bool { + let Some(now) = Stamp::of(&self.path) else { + self.pending = None; + return false; + }; + if Some(now) == self.seen { + self.pending = None; + return false; + } + if self.pending == Some(now) { + self.seen = Some(now); + self.pending = None; + return true; + } + self.pending = Some(now); + false + } +} diff --git a/tests/config_save.rs b/tests/config_save.rs index fe1f8e1..26da8be 100644 --- a/tests/config_save.rs +++ b/tests/config_save.rs @@ -55,6 +55,7 @@ fn settings() -> Config { toc_open: true, toc_width: 44, mouse: true, + reload: false, scroll_step: 7, body_width: Some(72), ..Config::default()