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()