diff --git a/CHANGES.md b/CHANGES.md index 72e09fa..9b1d50a 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -20,6 +20,17 @@ 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. +- `AppOptions` gained `source: Option` — the file the document was read from, + which the pager watches for changes and which `Action::ToggleReload` needs in order to + say whether there is anything to watch. `None` is a document that did not come from a + file. `mdmost::tui::run` keeps its single parameter: the path travels with the rest of + the startup answers rather than beside them, so the two cannot disagree. +- `Action` gained `ToggleReload`, bound to `R`. A `match` over `Action` that is not + `#[non_exhaustive]`-tolerant stops compiling until the arm is added. +- `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 +44,34 @@ 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. + + How long a change must have settled for depends on what the file was doing before it. + One that arrives out of a quiet spell is taken up at once — the reader who saves and + looks over. One that arrives while the file is already being written is ridden out, + because each re-read costs a full re-render and a status-bar flash and would be thrown + away by the next write; the document catches up once the writing has stopped for + `reload_settle` seconds, two by default. A file written without pause therefore holds + still after the first change. `reload_settle = 0` takes up every settled change. + `Config` gained the field, which is an API break for a caller building one by struct + literal. + + **`R`** starts and stops the watching while the pager runs, and `S` saves the answer. + Off and on again is also how to ask for a change straight away: what happened while + watching was off is not thrown away, so switching it back on takes the file up without + waiting out the settle window. A document that arrived on standard input has no file + to watch, and the key says so rather than flipping a setting that cannot act. + - `$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 @@ -48,6 +87,19 @@ minor bump rather than a patch. ### Fixed +- A terminal that draws an emoji-presentation sequence such as `☸️` (a narrow character + plus `U+FE0F`) in one column no longer leaves stale characters strewn across the screen + when the document is scrolled. The standard makes such a sequence two columns wide, + `unicode-width` and `ratatui` both measure two, and a terminal that advances by one is + then one column out for the whole run of cells it was handed — which is why the damage + spread well beyond the line the emoji was on. mdmost now asks the terminal at startup + how wide it draws one, and on a clear answer of one column drops the selector, which + draws the same glyph there and puts every measurement back on one number. `narrow_emoji` + in the configuration file and `--narrow-emoji` / `--wide-emoji` settle it without + measuring, and a document re-read from a changed file is narrowed exactly as the first + read was. `Config` gained the field and `AppOptions` gained `narrow_emoji: bool`, which + is an API break for a caller building either by struct literal. + - A Mermaid diagram's degraded-code caption is no longer corrupted where the line-number gutter's bottom-edge junction crosses it — "not a diagram type" no longer comes out "no┴ a diagram type". This shipped in v0.2.0 for every caption long diff --git a/README.md b/README.md index acced71..c4e115c 100644 --- a/README.md +++ b/README.md @@ -142,6 +142,9 @@ 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 +narrow_emoji = false # emoji-presentation sequences in one column; omit to measure +reload = true # re-read the document when its file changes on disk +reload_settle = 2 # seconds a file being written must hold still first; 0 for none 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..f553b5d 100644 --- a/docs/manual.md +++ b/docs/manual.md @@ -64,6 +64,19 @@ writes plain text rather than escape sequences. - **`--no-math-backslash`** — Do not read `\(…\)` and `\[…\]`, even if the configuration file does. +- **`--narrow-emoji`** — Draw an emoji whose form is set by a variation selector + (`U+FE0F`) in one column rather than two, for a terminal that does the same. Left to + itself **mdmost** measures the terminal; see *Emoji width* below. + +- **`--wide-emoji`** — Draw such an emoji at the width the standard gives it, without + measuring the terminal. + +- **`--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. @@ -147,6 +160,11 @@ and the status bar name the bindings in effect rather than the defaults. - **`-`** — Show or hide code line numbers. +- **`R`** — Start or stop re-reading the document as its file changes; see *Reloading* + below. Off and on again is also how to ask for a change straight away, without waiting + out the settle window. A document that arrived on standard input has no file to watch, + and the key says so. + - **`S`** — Save the current settings for the next run. - **`h`, `F1`** — Show or hide the help overlay. @@ -247,6 +265,43 @@ 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. + +How long it must have stopped for depends on what the file was doing before. +A change that arrives out of a quiet spell is taken up straight away, which is +the reader who saves in one window and looks over at the other. A change that +arrives while the file is *already* being written is ridden out instead: an +editor that saves every second or two would otherwise cost a full re-render and +a status-bar flash on each save, and every one of those re-reads would be thrown +away by the next. The document catches up once the writing has stopped for +`reload_settle` seconds, two by default. A file that is written without pause is +therefore never taken up after the first change; it holds still until the writing +stops. Set `reload_settle = 0` to take up every change as soon as it has settled. +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`, or with +**`R`** while the pager runs, which `S` then saves like any other setting. + +`R` is also how to ask for a change straight away. What happened while watching +was off is not thrown away, so switching it back on takes the file up at once +rather than waiting out the settle window. + # CONFIGURATION The configuration file is TOML, at *~/.config/mdmost/config.toml*, or in the @@ -263,6 +318,9 @@ 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 +narrow_emoji = false # emoji-presentation sequences in one column; omit to measure +reload = true # re-read the document when its file changes on disk +reload_settle = 2 # seconds a file being written must hold still first; 0 for none 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 @@ -620,6 +678,41 @@ Plain and icon glyphs occupy **the same display width**, so nothing shifts and nothing reflows either way, and no feature depends on icons. To settle the choice by hand instead, see **CONFIGURATION**. +## Emoji width + +`U+FE0F` asks for the emoji form of a character that also has a text form — `☸️` is +`☸` plus that selector — and the standard makes the result two columns wide. +Several terminals draw it in one and move the cursor by one. Nothing can be +patched over that afterwards: the width tables say two, and so does the library +that paints the screen, so on such a terminal every line containing one is drawn +one column out from there on, and scrolling leaves stale characters behind. + +**mdmost** therefore asks the terminal at startup: it draws the sequence at the +start of a line, reads back where the cursor ended up, and erases what it drew. +A clear answer of one column makes it draw the base character alone for the rest +of the session, which is the same glyph on such a terminal, and paint the second +column as the blank it now is. The sequence still occupies the two columns it was +laid out for, and every measurement is back on one number. Any other answer — +including no answer — leaves the screen exactly as it was. + +This happens to the *screen*, never to the document. What a drag copies, what a +`[copy]` button hands over, and what a search matches are all the text the file +holds, selector included, on every terminal. A terminal that cannot measure the +sequence is a fact about that terminal, not about the document. + +Only a lone character followed by the selector is treated this way, and only when +the selector is what made it wide. `❤️‍🔥` and other sequences joined with `U+200D`, +`1️⃣` and the other keycaps, flags, skin tones and anything wide on its own are all +left as they are: in those the selector sits in the middle, where removing it +would change *which* glyph is drawn rather than how wide it is. `U+FE0E`, which +asks for the plain form, is never touched. + +The question is not put at all when there is no terminal on both standard input +and standard output, or when `TERM` is unset, `dumb` or `linux`. To settle it +without being asked, write `narrow_emoji = true` (or `false`) in the +configuration file, or pass `--narrow-emoji` / `--wide-emoji`; a flag is saved by +`S`, a measurement never is. + # DEFAULT MARKDOWN VIEWER Two unrelated mechanisms can hand a Markdown file to **mdmost**. Which one diff --git a/src/config.rs b/src/config.rs index d4815a1..ca29a62 100644 --- a/src/config.rs +++ b/src/config.rs @@ -33,6 +33,12 @@ use crate::theme::{Color, Theme}; pub use keys::{Action, ActionGroup, Key, KeyBindings, KeyCode, KeyMods}; +/// How long a file being written holds still before it is re-read, in seconds. +/// +/// Long enough to ride out an editor that saves while the reader types, short enough +/// that a pause in the writing shows up before the reader wonders whether it will. +pub const DEFAULT_RELOAD_SETTLE: u16 = 2; + /// The default width of the table-of-contents pane, in columns. pub const DEFAULT_TOC_WIDTH: u16 = 30; @@ -144,6 +150,41 @@ pub struct Config { /// agree about. `less` does not capture either. Turn it on with `--mouse` or /// `mouse = true`. pub mouse: bool, + /// Whether this terminal draws an emoji-presentation sequence in one column. + /// + /// `U+FE0F` asks for the emoji form of a character that also has a text form, and + /// the standard makes the result two columns wide. Several terminals draw it in one. + /// Nothing can reconcile that after the fact — `unicode-width` and `ratatui` both + /// measure two — so `true` drops the selector instead, which draws the same glyph on + /// such a terminal and puts every measurement back on one number (see + /// [`crate::text::narrow_emoji`]). + /// + /// Tri-state like [`Config::icons`], and for the same reason: `None` — the default — + /// means nobody has said, and the answer is measured from the terminal at startup. + /// `--narrow-emoji` / `--wide-emoji` override even a value written in the file. + pub narrow_emoji: Option, + /// 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 long a file that is being written must hold still before it is re-read. + /// + /// A change that arrives out of a quiet spell is taken up at once, which is the + /// reader who saves and looks over. A change that arrives while the file is already + /// being written is ridden out instead: an editor saving on every keystroke would + /// otherwise cost a full re-render and a status-bar flash apiece, and every one of + /// those re-reads would be thrown away by the next. The document catches up once + /// the writing has stopped for this long. + /// + /// In whole seconds, because TOML tells `2` and `2.0` apart and a reader writing the + /// obvious `reload_settle = 2` for a decimal field would get a type error. `0` takes + /// up every change as soon as it has settled. + pub reload_settle: u16, /// 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 +217,9 @@ impl Default for Config { toc_open: false, toc_width: DEFAULT_TOC_WIDTH, mouse: false, + narrow_emoji: None, + reload: true, + reload_settle: DEFAULT_RELOAD_SETTLE, scroll_step: 3, body_width: Some(DEFAULT_BODY_WIDTH), keys: KeyBindings::defaults(), @@ -207,6 +251,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 +407,9 @@ struct RawConfig { title_banner: Option, section_numbers: Option, mouse: Option, + narrow_emoji: Option, + reload: Option, + reload_settle: Option, scroll_step: Option, body_width: Option, #[serde(default)] @@ -398,10 +458,11 @@ impl RawConfig { /// Validates the raw file into a [`Config`], collecting per-entry problems. fn into_config(self, text: &str, path: &Path, problems: &mut Vec) -> Config { let mut config = Config { - // Carried straight across as an `Option`, unlike every setting below it: an - // absent `icons` key must stay absent so it reaches detection, rather than - // being resolved here to a fixed answer. + // Carried straight across as an `Option`, unlike every setting below them: + // an absent `icons` or `narrow_emoji` key must stay absent so it reaches + // detection, rather than being resolved here to a fixed answer. icons: self.icons, + narrow_emoji: self.narrow_emoji, ..Config::default() }; @@ -426,6 +487,12 @@ impl RawConfig { if let Some(mouse) = self.mouse { config.mouse = mouse; } + if let Some(reload) = self.reload { + config.reload = reload; + } + if let Some(settle) = self.reload_settle { + config.reload_settle = settle; + } if let Some(step) = self.scroll_step { if step == 0 { problems.push(problem(text, path, "scroll_step", "must be at least 1")); @@ -627,6 +694,9 @@ const KNOWN_KEYS: &[&str] = &[ "title_banner", "section_numbers", "mouse", + "narrow_emoji", + "reload", + "reload_settle", "scroll_step", "body_width", "toc", diff --git a/src/config/keys.rs b/src/config/keys.rs index 9a0a7f2..908daba 100644 --- a/src/config/keys.rs +++ b/src/config/keys.rs @@ -66,6 +66,8 @@ pub enum Action { Quit, /// Show or hide the line-number gutter in fenced code blocks. ToggleLineNumbers, + /// Start or stop re-reading the document when its file changes on disk. + ToggleReload, /// Write the current settings back to the configuration file. SaveConfig, /// Unwind: clear the search, close an overlay, close the table of contents. @@ -104,6 +106,7 @@ impl Action { Action::CursorPrev, Action::CycleTheme, Action::ToggleLineNumbers, + Action::ToggleReload, Action::SaveConfig, Action::Help, Action::Cancel, @@ -124,6 +127,7 @@ impl Action { Action::Percent => "percent", Action::ReportPosition => "report_position", Action::ToggleLineNumbers => "toggle_line_numbers", + Action::ToggleReload => "toggle_reload", Action::SaveConfig => "save_config", Action::ScrollLeft => "scroll_left", Action::ScrollRight => "scroll_right", @@ -159,6 +163,7 @@ impl Action { Action::Percent => "Jump N percent into the document (50%)", Action::ReportPosition => "Report where you are", Action::ToggleLineNumbers => "Show or hide code line numbers", + Action::ToggleReload => "Re-read the file as it changes, or stop", Action::SaveConfig => "Save the current settings for next time", Action::ScrollLeft => "Scroll left (wide content)", Action::ScrollRight => "Scroll right (wide content)", @@ -206,9 +211,10 @@ impl Action { | Action::NextMatch | Action::PrevMatch | Action::ToggleSearchMode => ActionGroup::Search, - Action::CycleTheme | Action::ToggleLineNumbers | Action::SaveConfig => { - ActionGroup::View - } + Action::CycleTheme + | Action::ToggleLineNumbers + | Action::ToggleReload + | Action::SaveConfig => ActionGroup::View, Action::Help | Action::Quit | Action::Cancel => ActionGroup::Exit, } } @@ -573,6 +579,7 @@ impl KeyBindings { (Key::ctrl('g'), Action::ReportPosition), (Key::char('='), Action::ReportPosition), (Key::char('-'), Action::ToggleLineNumbers), + (Key::char('R'), Action::ToggleReload), (Key::char('S'), Action::SaveConfig), (Key::plain(KeyCode::End), Action::Bottom), (Key::plain(KeyCode::Left), Action::ScrollLeft), diff --git a/src/config/tests.rs b/src/config/tests.rs index 590bb4f..3073624 100644 --- a/src/config/tests.rs +++ b/src/config/tests.rs @@ -534,3 +534,59 @@ 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 the_emoji_width_answer_is_kept_tri_state() { + // Unset means nobody has said, and the answer is measured from the terminal — the + // same shape as `icons`, and for the same reason: `Some(false)` and "unset" behave + // alike today, but only the first must keep behaving that way on a terminal where + // the measurement would say otherwise. + assert_eq!(Config::default().narrow_emoji, None); + assert_eq!(Config::parse_str("", path()).config.narrow_emoji, None); + + let loaded = Config::parse_str("narrow_emoji = true\n", path()); + assert!(loaded.problems.is_empty(), "{:?}", loaded.problems); + assert_eq!(loaded.config.narrow_emoji, Some(true)); +} + +#[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); +} + +#[test] +fn the_settle_window_defaults_to_two_seconds_and_can_be_set() { + // Whole seconds, because TOML tells `2` and `2.0` apart: a decimal field would turn + // the obvious `reload_settle = 2` into a type error. + assert_eq!(Config::default().reload_settle, DEFAULT_RELOAD_SETTLE); + assert_eq!( + Config::parse_str("", path()).config.reload_settle, + DEFAULT_RELOAD_SETTLE + ); + + let loaded = Config::parse_str("reload_settle = 5\n", path()); + assert!(loaded.problems.is_empty(), "{:?}", loaded.problems); + assert_eq!(loaded.config.reload_settle, 5); + + // Zero is the escape hatch, not a mistake: take up every change as it settles. + let loaded = Config::parse_str("reload_settle = 0\n", path()); + assert!(loaded.problems.is_empty(), "{:?}", loaded.problems); + assert_eq!(loaded.config.reload_settle, 0); +} + +#[test] +fn re_reading_has_a_default_binding_of_its_own() { + let bound = KeyBindings::defaults(); + assert_eq!( + bound.action(&Key::char('R')), + Some(Action::ToggleReload), + "R is not bound to starting and stopping re-reading" + ); +} diff --git a/src/config/write.rs b/src/config/write.rs index 159b1fc..4b0f087 100644 --- a/src/config/write.rs +++ b/src/config/write.rs @@ -133,6 +133,25 @@ impl Config { key: "mouse", value: Some(self.mouse.to_string()), }, + Entry { + section: None, + // Unset for the same reason `icons` is: nobody has said, so the answer + // is measured from the terminal, and writing a measurement down would + // freeze it for a terminal that later behaves differently. A command + // line that *did* say is recorded by `main` before the pager starts. + key: "narrow_emoji", + value: self.narrow_emoji.map(|narrow| narrow.to_string()), + }, + Entry { + section: None, + key: "reload", + value: Some(self.reload.to_string()), + }, + Entry { + section: None, + key: "reload_settle", + value: Some(self.reload_settle.to_string()), + }, Entry { section: None, key: "scroll_step", @@ -212,6 +231,15 @@ impl Config { if back.mouse != self.mouse { return refuse("mouse"); } + if back.narrow_emoji != self.narrow_emoji { + return refuse("narrow_emoji"); + } + if back.reload != self.reload { + return refuse("reload"); + } + if back.reload_settle != self.reload_settle { + return refuse("reload_settle"); + } if back.scroll_step != self.scroll_step { return refuse("scroll_step"); } diff --git a/src/main.rs b/src/main.rs index 6f4d320..a367ee4 100644 --- a/src/main.rs +++ b/src/main.rs @@ -104,6 +104,29 @@ struct Cli { #[arg(long, conflicts_with = "math_backslash")] no_math_backslash: bool, + /// Draw emoji whose form is set by a variation selector in one column, not two. + /// + /// For a terminal that ignores `U+FE0F` and advances the cursor by one where the + /// standard says two. Left to itself mdmost measures the terminal and only acts on a + /// clear answer; this settles it without asking, and is saved by `S`. + #[arg(long)] + narrow_emoji: bool, + + /// Draw emoji at the width the standard gives them, without measuring the terminal. + #[arg(long, conflicts_with = "narrow_emoji")] + wide_emoji: 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. /// @@ -179,6 +202,38 @@ fn resolve_icons(cli: &Cli, configured: Option) -> bool { configured.unwrap_or_else(mdmost::nerdfont::detect) } +/// Whether an emoji-presentation sequence is to be drawn in one column. +/// +/// A flag settles it, then the configuration file, and only then is the terminal asked — +/// `measure` is not called at all when anybody has already answered, because asking +/// writes to the terminal and waits for a reply. +/// +/// The measurement is believed only when it is unambiguous. Stripping a selector on a +/// terminal that honours it would make the document worse, so silence, an impossible +/// answer and the standard two columns all leave it alone. See +/// [`mdmost::tui::emoji_columns`] for what is asked and when. +fn resolve_narrow_emoji( + narrow: bool, + wide: bool, + configured: Option, + measure: impl FnOnce() -> Option, +) -> bool { + debug_assert!( + !(narrow && wide), + "clap's conflicts_with should make this unreachable" + ); + if narrow { + return true; + } + if wide { + return false; + } + if let Some(answer) = configured { + return answer; + } + measure() == Some(1) +} + /// A flag pair overriding a configured boolean: either flag wins over the file, and /// neither leaves it alone. /// @@ -259,11 +314,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 +333,21 @@ 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 narrow_emoji = resolve_narrow_emoji( + cli.narrow_emoji, + cli.wide_emoji, + config.narrow_emoji, + mdmost::tui::emoji_columns, ); + // An answer that came from the command line is a statement the reader made, and `S` + // should keep it. A measurement is not written down, for the reason `icons` is not: + // it is this terminal's answer, not this reader's. + if cli.narrow_emoji || cli.wide_emoji { + config.narrow_emoji = Some(narrow_emoji); + } + 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); @@ -311,6 +374,7 @@ fn run(cli: Cli) -> anyhow::Result { cli.width, stdout_is_terminal, &options, + narrow_emoji, ); } @@ -319,8 +383,10 @@ fn run(cli: Cli) -> anyhow::Result { doc, config, AppOptions { + source: source_path.map(Path::to_path_buf), title, icons, + narrow_emoji, theme: theme_name, // `[toc] open` in the configuration file counts as much as `--toc` does. toc_open: cli.toc || config_toc_open, @@ -373,6 +439,7 @@ fn render_once( width: Option, stdout_is_terminal: bool, options: &RenderOptions, + narrow_emoji: bool, ) -> anyhow::Result { let theme = match config.resolve_theme(theme_name) { Ok(theme) => theme, @@ -399,9 +466,9 @@ fn render_once( let stdout = io::stdout(); let mut out = stdout.lock(); if stdout_is_terminal { - dump::write_ansi(&mut out, &canvas, theme.base())?; + dump::write_ansi(&mut out, &canvas, theme.base(), narrow_emoji)?; } else { - dump::write_plain(&mut out, &canvas)?; + dump::write_plain(&mut out, &canvas, narrow_emoji)?; } out.flush()?; Ok(ExitCode::SUCCESS) @@ -465,7 +532,7 @@ fn read_input(file: Option<&Path>) -> Result<(String, String), mdmost::Error> { #[cfg(test)] mod tests { - use super::{Input, input_source, resolve_flag}; + use super::{Input, input_source, resolve_flag, resolve_narrow_emoji}; use std::path::Path; #[test] @@ -502,6 +569,34 @@ mod tests { } } + #[test] + fn a_flag_settles_the_emoji_width_without_asking_the_terminal() { + // The measurement is not merely overridden but never made: it writes to the + // terminal and waits for a reply, and someone who has already answered should + // not pay for that. + let never = || panic!("the terminal must not be asked"); + assert!(resolve_narrow_emoji(true, false, None, never)); + assert!(!resolve_narrow_emoji(false, true, Some(true), never)); + } + + #[test] + fn a_written_answer_is_taken_when_no_flag_says_otherwise() { + let never = || panic!("the terminal must not be asked"); + assert!(resolve_narrow_emoji(false, false, Some(true), never)); + assert!(!resolve_narrow_emoji(false, false, Some(false), never)); + } + + #[test] + fn only_a_clear_one_column_measurement_narrows_anything() { + // Lopsided on purpose, as detection is everywhere in this program: stripping a + // selector on a terminal that wanted it is the worse mistake, so silence, a + // nonsense reply and the standard answer all leave the document alone. + assert!(resolve_narrow_emoji(false, false, None, || Some(1))); + assert!(!resolve_narrow_emoji(false, false, None, || Some(2))); + assert!(!resolve_narrow_emoji(false, false, None, || Some(0))); + assert!(!resolve_narrow_emoji(false, false, None, || None)); + } + #[test] fn a_flag_wins_over_the_configured_value() { assert!(resolve_flag(true, false, false)); diff --git a/src/text/mod.rs b/src/text/mod.rs index bacb97a..c7f1fd3 100644 --- a/src/text/mod.rs +++ b/src/text/mod.rs @@ -52,6 +52,32 @@ pub fn display_width(text: &str) -> usize { text.width() } +/// The variation selector that asks for the emoji form. +const EMOJI_PRESENTATION: char = '\u{FE0F}'; + +/// The base of `cluster`, when the selector is the only reason it is two columns wide. +/// +/// `U+FE0F` asks for the emoji form of a character that has a text form as well, and the +/// standard makes the result two columns. Several terminals draw it in one and advance +/// the cursor by one, which is a disagreement no width table can settle: `unicode-width` +/// says two, `ratatui` skips the second cell on that authority, and the terminal is then +/// one column out for the rest of the run it was handed. +/// +/// Handing such a terminal the base alone is what puts all three back on one number. It +/// is done to the *frame*, never to the document (see `tui::draw`), so what the reader +/// copies, searches and exports is the text the file holds. +/// +/// Only a lone base followed by the selector is answered for, and only when the selector +/// is what made it wide. A ZWJ sequence and a keycap both carry `U+FE0F` in the middle, +/// where taking it out would change which glyph is drawn rather than how wide it is. +pub fn presentation_base(cluster: &str) -> Option<&str> { + let base = cluster.strip_suffix(EMOJI_PRESENTATION)?; + if base.chars().count() != 1 { + return None; + } + (display_width(base) == 1 && display_width(cluster) == 2).then_some(base) +} + /// The display width of a **one-cell piece** of text, in `0..=2`. /// /// A terminal cell holds at most a double-width cluster, so this returns at most `2`. diff --git a/src/text/tests.rs b/src/text/tests.rs index 3738011..ee33388 100644 --- a/src/text/tests.rs +++ b/src/text/tests.rs @@ -673,3 +673,49 @@ fn truncate_to_width_costs_a_wide_cluster_honestly() { WIDE_PLUS_SPACING_MARK ); } + +// --------------------------------------------------------------------------- +// Emoji presentation on a terminal that does not widen it. +// --------------------------------------------------------------------------- + +#[test] +fn a_narrow_base_plus_the_selector_answers_with_the_base() { + // `unicode-width` gives U+2638 one column and U+2638 U+FE0F two, which is what the + // standard asks for and what several terminals do not do. Handing such a terminal + // the base alone is what puts every measurement — ours, ratatui's and the + // terminal's — back on the same number, and it draws the same glyph there anyway. + assert_eq!(presentation_base("\u{2638}\u{fe0f}"), Some("\u{2638}")); + assert_eq!(presentation_base("\u{2764}\u{fe0f}"), Some("\u{2764}")); + assert_eq!( + display_width(presentation_base("\u{2638}\u{fe0f}").unwrap()), + 1 + ); +} + +#[test] +fn an_emoji_that_is_wide_on_its_own_has_no_answer() { + // No selector, and nobody disagrees about these: every terminal measured gives them + // two columns, so there is nothing to put right. + for cluster in ["\u{1f4c4}", "\u{1f33e}", "\u{1f464}", "\u{231a}"] { + assert_eq!(presentation_base(cluster), None); + } +} + +#[test] +fn a_selector_in_the_middle_of_a_cluster_is_not_answered_for() { + // A ZWJ sequence and a keycap both carry U+FE0F somewhere other than the end. How + // wide a terminal draws either is its own disagreement, and taking the selector out + // would change which glyph is drawn rather than only how wide it is. + assert_eq!( + presentation_base("\u{1f468}\u{200d}\u{2764}\u{fe0f}\u{200d}\u{1f468}"), + None + ); + assert_eq!(presentation_base("1\u{fe0f}\u{20e3}"), None); +} + +#[test] +fn ordinary_text_has_no_answer() { + for cluster in ["a", " ", "\u{2638}", "\u{2638}\u{fe0e}", ""] { + assert_eq!(presentation_base(cluster), None); + } +} diff --git a/src/tui.rs b/src/tui.rs index 4901f85..e928068 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -22,6 +22,8 @@ //! | [`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 | +//! | `probe` | Asking the terminal how wide it draws an emoji-presentation sequence | +//! | `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. @@ -36,9 +38,11 @@ pub mod help; pub mod icons; pub mod open; pub mod popup; +mod probe; pub mod select; pub mod stderr; mod term; +mod watch; #[cfg(test)] mod tests; @@ -47,6 +51,10 @@ pub use app::{App, AppOptions, Focus, Overlay, PromptKind}; /// Runs the pager to completion. /// +/// The file the document was read from travels in [`AppOptions::source`]: the pager +/// re-reads it while it runs whenever the reader has left `reload` on, and a document +/// that arrived on standard input is watched for nothing. +/// /// The terminal is restored on every exit path, including panics and `SIGTERM`. /// /// # Errors @@ -56,6 +64,15 @@ pub fn run(app: &mut App) -> std::io::Result<()> { term::run(app) } +/// How many columns this terminal gives an emoji-presentation sequence, if it will say. +/// +/// See [`probe`] for what is asked, when it is skipped, and why the answer is worth +/// asking for. Exists here for the same reason [`terminal_width`] does: the binary need +/// not depend on `crossterm` itself. +pub fn emoji_columns() -> Option { + probe::emoji_columns() +} + /// Restores the terminal, for callers that need to bail out mid-flight. pub fn restore_terminal() { term::restore(); diff --git a/src/tui/app.rs b/src/tui/app.rs index a9b59b1..aa66ab7 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() @@ -207,6 +237,15 @@ pub struct Notice { /// How the application was started. #[derive(Debug, Clone)] pub struct AppOptions { + /// The file the document was read from, if it came from one. + /// + /// Carried so the pager can say whether there is anything to watch: a document that + /// arrived on standard input can have `reload` set either way and nothing will come + /// of it, and a key that reported otherwise would be lying. `None` is that case. + /// + /// A path, not an open file. The state machine touches no file (design spec §13); + /// `super::term` does the reading. + pub source: Option, /// The name shown in the status bar. pub title: String, /// Whether Nerd Font glyphs may be drawn. @@ -216,6 +255,14 @@ pub struct AppOptions { /// icons too, not merely the status bar. List bullets are ASCII either way /// (see `render::glyphs`). pub icons: bool, + /// Whether this terminal draws an emoji-presentation sequence in one column. + /// + /// The answer [`crate::config::Config::narrow_emoji`] resolves to, already settled: + /// the binary measures the terminal once, before the document is first parsed. It is + /// kept because a *re*-read has to happen under the same answer — see + /// [`super::term`] — or the selector would come back on the first reload and the + /// screen would start smearing again. + pub narrow_emoji: bool, /// The theme to start in. pub theme: String, /// Whether the table-of-contents pane starts open. @@ -395,6 +442,19 @@ impl App { &self.doc } + /// The file the document was read from, or `None` when it came down a pipe. + pub fn source(&self) -> Option<&std::path::Path> { + self.options.source.as_deref() + } + + /// Whether an emoji-presentation sequence is drawn in one column here. + /// + /// Settled before the first parse and kept for the life of the pager, so that a + /// document re-read from a changed file is narrowed exactly as the first read was. + pub fn narrow_emoji(&self) -> bool { + self.options.narrow_emoji + } + /// The active configuration. pub fn config(&self) -> &Config { &self.config @@ -856,6 +916,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 @@ -1180,6 +1286,7 @@ impl App { Action::ToggleToc => self.toggle_toc(), Action::CycleTheme => self.cycle_theme(), Action::ToggleLineNumbers => self.toggle_line_numbers(), + Action::ToggleReload => self.toggle_reload(), Action::SaveConfig => self.save_config(), Action::ReportPosition => self.report_position(), // Design spec §9: `/` inside the table of contents filters it fuzzily @@ -1419,6 +1526,27 @@ impl App { ); } + /// Starts or stops re-reading the document as its file changes. + /// + /// The setting is one `S` saves, so a reader who turns it off for good can keep it + /// that way. A document that came down a pipe has no file to watch, and flipping a + /// setting that cannot act would be a worse answer than saying so. + fn toggle_reload(&mut self) { + if self.options.source.is_none() { + self.notify("no file to watch", false); + return; + } + self.config.reload = !self.config.reload; + self.notify( + if self.config.reload { + "auto-reload on" + } else { + "auto-reload off" + }, + false, + ); + } + /// Writes the settings the reader can change back to the configuration file. /// /// The theme and the state of the contents pane are taken from the live pager diff --git a/src/tui/draw.rs b/src/tui/draw.rs index 6506919..6317240 100644 --- a/src/tui/draw.rs +++ b/src/tui/draw.rs @@ -177,6 +177,56 @@ pub fn draw(frame: &mut Frame<'_>, app: &mut App) { app, ); } + + // Last, over the finished frame, so that nothing painted above can escape it. + if app.narrow_emoji() { + narrow_frame(buffer); + } +} + +/// Hands a terminal that draws an emoji-presentation sequence in one column the base +/// character alone, and gives back the column the sequence no longer owns. +/// +/// # Why the whole frame rather than each place text is written +/// +/// The alternative is a rule wherever document text reaches `ratatui` — the document +/// blit, the footnote popup, the contents pane, the status bar's heading and title — +/// and such a rule is only ever as good as the last place somebody remembered it. One +/// place that cannot be forgotten is worth a pass over cells that are already in cache. +/// +/// # Why not the document instead +/// +/// Because the document is what the reader copies, searches and is shown as *source*. +/// Narrowing it would put the terminal's shortcoming into text that leaves the program: +/// a drag reports "Markdown source" and a `[copy]` button hands over a code block, and +/// neither may quietly lose a character the file holds. +/// +/// The lead keeps the two columns it was laid out for. `ratatui` reads an empty symbol +/// as "the cell before me owns this one" and leaves the terminal on whatever stood +/// there; once the lead is one column wide that claim is false, so the owned cell is +/// painted as the space it now is. +fn narrow_frame(buffer: &mut Buffer) { + let area = buffer.area; + for y in area.top()..area.bottom() { + for x in area.left()..area.right() { + let Some(base) = buffer + .cell((x, y)) + .and_then(|cell| crate::text::presentation_base(cell.symbol())) + .map(str::to_string) + else { + continue; + }; + if let Some(cell) = buffer.cell_mut((x, y)) { + cell.set_symbol(&base); + } + if x + 1 < area.right() + && let Some(next) = buffer.cell_mut((x + 1, y)) + && next.symbol().is_empty() + { + next.set_symbol(" "); + } + } + } } /// Draws the frame shown while the document is still being laid out. diff --git a/src/tui/dump.rs b/src/tui/dump.rs index 7c4e50a..d1b4b4f 100644 --- a/src/tui/dump.rs +++ b/src/tui/dump.rs @@ -13,24 +13,68 @@ use crate::canvas::Canvas; use crate::theme::{Attributes, Color, Style}; /// Writes the canvas as plain text, one row per line, without trailing blanks. -pub fn write_plain(out: &mut impl Write, canvas: &Canvas) -> io::Result<()> { - for row in 0..canvas.height() { - let text = canvas.row_text(row); - writeln!(out, "{}", text.trim_end())?; +/// +/// `narrow` is for a terminal that draws an emoji-presentation sequence in one column; +/// see [`row`]. +pub fn write_plain(out: &mut impl Write, canvas: &Canvas, narrow: bool) -> io::Result<()> { + for cells in canvas.rows() { + writeln!(out, "{}", row(cells, narrow).trim_end())?; } Ok(()) } +/// One row's text, with the columns it occupies preserved. +/// +/// A continuation cell contributes nothing, because the wide glyph before it already +/// fills that column — unless this terminal draws that glyph in one column, in which +/// case the glyph is written without its selector and the column it no longer fills is +/// written as the space it now is. See `crate::text::presentation_base`. +fn row(cells: &[crate::canvas::Cell], narrow: bool) -> String { + let mut out = String::new(); + let mut disowned = false; + for cell in cells { + if cell.is_continuation() { + if disowned { + out.push(' '); + } + disowned = false; + continue; + } + match crate::text::presentation_base(cell.text()).filter(|_| narrow) { + Some(base) => { + out.push_str(base); + disowned = true; + } + None => { + out.push_str(cell.text()); + disowned = false; + } + } + } + out +} + /// Writes the canvas with ANSI truecolour escapes. /// /// Every row ends with a reset, so a truncated dump cannot leave the terminal in a -/// coloured state. -pub fn write_ansi(out: &mut impl Write, canvas: &Canvas, base: Style) -> io::Result<()> { - for row in canvas.rows() { +/// coloured state. `narrow` is for a terminal that draws an emoji-presentation +/// sequence in one column; see [`row`]. +pub fn write_ansi( + out: &mut impl Write, + canvas: &Canvas, + base: Style, + narrow: bool, +) -> io::Result<()> { + for cells in canvas.rows() { let mut current = Style::NONE; let mut pending_reset = false; - for cell in row { + let mut disowned = false; + for cell in cells { if cell.is_continuation() { + if disowned { + out.write_all(b" ")?; + } + disowned = false; continue; } let style = base.patch(cell.style()); @@ -39,7 +83,16 @@ pub fn write_ansi(out: &mut impl Write, canvas: &Canvas, base: Style) -> io::Res current = style; pending_reset = true; } - out.write_all(cell.text().as_bytes())?; + match crate::text::presentation_base(cell.text()).filter(|_| narrow) { + Some(text) => { + out.write_all(text.as_bytes())?; + disowned = true; + } + None => { + out.write_all(cell.text().as_bytes())?; + disowned = false; + } + } } if pending_reset { out.write_all(b"\x1b[0m")?; diff --git a/src/tui/probe.rs b/src/tui/probe.rs new file mode 100644 index 0000000..dafca52 --- /dev/null +++ b/src/tui/probe.rs @@ -0,0 +1,188 @@ +// SPDX-License-Identifier: MIT +//! Measuring what this terminal does with an emoji-presentation sequence. +//! +//! # Why measure at all +//! +//! `U+FE0F` asks for the emoji form of a character that also has a text form, and the +//! standard makes the result two columns wide. Several terminals draw it in one and +//! advance the cursor by one. Nothing downstream can paper over that: `unicode-width` +//! says two, `ratatui` skips the second cell on that authority, and the terminal is +//! then one column out for the rest of the run it was handed — which is what leaves +//! stale glyphs across a scrolled screen. +//! +//! There is no capability string for this, so the only honest source is the terminal +//! itself: draw the sequence, ask where the cursor ended up, and take the difference. +//! +//! # What is asked and when +//! +//! The probe writes at the start of a line and erases what it wrote, so nothing of it +//! survives on screen. It is skipped altogether where the question cannot be put or the +//! answer would mean nothing: no terminal on either standard input or standard output, +//! no `TERM`, or a `TERM` that says up front there is nothing to ask (`dumb`, the Linux +//! console). Anyone who would rather not be asked can settle it in the configuration +//! file with `narrow_emoji`, and then this never runs. +//! +//! The reply is read here rather than through `crossterm::cursor::position`, which +//! cannot be used for this: its loop treats a failed wait as nothing to report and goes +//! round again, so a terminal that has been destroyed under it spins at 100 % of a core +//! for ever — the very fault [`super::term`] exists to keep out of this program, and one +//! the pty test catches. The wait here is a `poll` with a deadline that also watches for +//! the hangup the kernel flags, and a terminal that stays silent costs a fifth of a +//! second, once, at startup. + +use std::io::{self, IsTerminal, Write}; +#[cfg(unix)] +use std::time::{Duration, Instant}; + +/// The sequence the terminal is asked to draw. +/// +/// A heart rather than the wheel that started this: both are a narrow base plus the +/// selector, and this one is in every font that has any emoji at all — a probe the +/// font cannot draw would measure the terminal's tofu instead of its intent. +const PROBE: &str = "\u{2764}\u{FE0F}"; + +/// How long a terminal is given to answer before the question is dropped. +#[cfg(unix)] +const PATIENCE: Duration = Duration::from_millis(200); + +/// How many columns this terminal gives [`PROBE`], or `None` if it would not say. +/// +/// `Some(1)` is the disagreement this exists to find; `Some(2)` is the standard +/// behaviour. Anything else — an unanswerable question, a terminal that stays silent, +/// a reply that makes no sense — is `None`, and the caller is expected to carry on as +/// though the terminal were the ordinary sort. This is deliberately lopsided in the +/// same way [`crate::nerdfont`] is: a wrong "narrow" would strip selectors on a +/// terminal that wanted them, so only a clear answer changes anything. +pub fn emoji_columns() -> Option { + if !io::stdout().is_terminal() || !io::stdin().is_terminal() { + return None; + } + match std::env::var("TERM").ok()?.as_str() { + "" | "dumb" | "linux" => return None, + _ => {} + } + // Raw mode for the reading: the reply is not a line and nobody typed a newline + // after it, and echoing it would print it over the screen it was measured on. + crossterm::terminal::enable_raw_mode().ok()?; + let answer = measure(); + let _ = crossterm::terminal::disable_raw_mode(); + // Erase the whole line rather than the columns the probe is believed to occupy: + // what it occupies is the very thing in question. Unconditional, so a probe that + // was drawn and then not answered still leaves nothing behind. + let mut out = io::stdout(); + let _ = write!(out, "\r\x1b[K"); + let _ = out.flush(); + // The report counts from one, and the probe started in the first column. + answer?.checked_sub(1) +} + +/// Draws the probe from the start of the line and reads back the column it ended in. +fn measure() -> Option { + let mut out = io::stdout(); + // From the start of the line, so the reply *is* the width of the probe, with no + // arithmetic against a prompt that may itself contain anything. + write!(out, "\r{PROBE}\x1b[6n").ok()?; + out.flush().ok()?; + reply() +} + +/// The column from the terminal's report, if one arrives in time. +/// +/// The reply can be split across reads, and other input can arrive with it, so bytes are +/// accumulated until they hold a report or the deadline passes. Anything else that came +/// with it is dropped — a keystroke typed into the first fraction of a second of startup, +/// which is the same cost every implementation of this question pays. +#[cfg(unix)] +fn reply() -> Option { + use rustix::event::{PollFd, PollFlags, poll}; + + let stdin = rustix::stdio::stdin(); + let deadline = Instant::now() + PATIENCE; + let mut seen = Vec::with_capacity(32); + loop { + let left = deadline.saturating_duration_since(Instant::now()); + if left.is_zero() { + return None; + } + let mut fds = [PollFd::new(&stdin, PollFlags::IN)]; + let timeout = rustix::event::Timespec { + tv_sec: left.as_secs().try_into().ok()?, + tv_nsec: left.subsec_nanos().into(), + }; + match poll(&mut fds, Some(&timeout)) { + // Nothing arrived within the deadline, or the terminal is gone: either way + // there is no answer, and neither is an error worth reporting. + Ok(0) => return None, + Ok(_) => {} + Err(rustix::io::Errno::INTR) => continue, + Err(_) => return None, + } + if fds[0] + .revents() + .intersects(PollFlags::HUP | PollFlags::ERR | PollFlags::NVAL) + { + return None; + } + let mut buf = [0u8; 32]; + match rustix::io::read(stdin, &mut buf) { + Ok(0) | Err(_) => return None, + Ok(read) => seen.extend_from_slice(&buf[..read]), + } + if let Some(column) = column_of(&seen) { + return Some(column); + } + // A cap, so a terminal that streams something else for ever cannot grow this + // without bound. The report is short and arrives first. + if seen.len() > 256 { + return None; + } + } +} + +/// No `poll` to lean on, so the question is not put at all. +/// +/// Windows, where asking would mean waiting without a deadline — the one thing this +/// module refuses to do. `narrow_emoji` in the configuration file settles it there. +#[cfg(not(unix))] +fn reply() -> Option { + None +} + +/// The column reported by `ESC [ rows ; cols R`, if `bytes` holds such a report. +/// +/// Scans for the last complete report rather than assuming the reply arrived alone: a +/// terminal may answer something else first, and a keystroke may land in the same read. +pub(super) fn column_of(bytes: &[u8]) -> Option { + let mut answer = None; + for start in 0..bytes.len().saturating_sub(1) { + if bytes[start] != 0x1b || bytes[start + 1] != b'[' { + continue; + } + let rest = &bytes[start + 2..]; + let Some(end) = rest.iter().position(|&byte| byte == b'R') else { + continue; + }; + let report = &rest[..end]; + let Some((rows, columns)) = split_once(report, b';') else { + continue; + }; + if let (Some(_), Some(column)) = (number(rows), number(columns)) { + answer = Some(column); + } + } + answer +} + +/// `bytes` split around the first `at`, or `None` if it does not contain one. +fn split_once(bytes: &[u8], at: u8) -> Option<(&[u8], &[u8])> { + let index = bytes.iter().position(|&byte| byte == at)?; + Some((&bytes[..index], &bytes[index + 1..])) +} + +/// `bytes` as a decimal number, or `None` if it is not one that fits. +fn number(bytes: &[u8]) -> Option { + if bytes.is_empty() || !bytes.iter().all(u8::is_ascii_digit) { + return None; + } + std::str::from_utf8(bytes).ok()?.parse().ok() +} diff --git a/src/tui/term.rs b/src/tui/term.rs index 6b035d5..ae3b94f 100644 --- a/src/tui/term.rs +++ b/src/tui/term.rs @@ -37,6 +37,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. @@ -211,7 +212,12 @@ 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. Whether the reader wants it + // watched is asked on every tick instead of here, because `Action::ToggleReload` + // can change the answer while the pager runs. + let settle = Duration::from_secs(app.config().reload_settle.into()); + let mut watcher = app.source().map(|path| Watcher::new(path, settle)); + 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 +247,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 +274,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 +314,41 @@ 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) { + reload_tick_at(app, watcher, std::time::Instant::now()); +} + +/// [`reload_tick`], against a clock the caller supplies, for the tests. +/// +/// The watcher is not asked anything while re-reading is switched off, so the change it +/// has not yet reported is still there when the reader switches it back on. That is what +/// makes the key a way of asking for a change as well as a way of stopping the watching. +pub(super) fn reload_tick_at(app: &mut App, watcher: &mut Watcher, at: std::time::Instant) { + if !app.config().reload || !watcher.changed_at(at) { + 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..06ae59d 100644 --- a/src/tui/tests.rs +++ b/src/tui/tests.rs @@ -40,15 +40,38 @@ fn pager(source: &str) -> App { pager_with(source, Config::default()) } +/// Builds an app over `source` for a terminal that draws emoji sequences in one column. +fn pager_narrow(source: &str) -> App { + let mut app = App::new( + Doc::parse(source), + Config::default(), + AppOptions { + source: None, + config_path: None, + title: "sample.md".to_string(), + icons: false, + narrow_emoji: true, + theme: "dark".to_string(), + toc_open: false, + width: None, + }, + ); + app.resize(80, 12); + let _ = app.canvas(); + app +} + /// Builds an app over `source` at a fixed size, with a given configuration. fn pager_with(source: &str, config: Config) -> App { let mut app = App::new( Doc::parse(source), config, AppOptions { + source: None, config_path: None, title: "sample.md".to_string(), icons: false, + narrow_emoji: false, theme: "dark".to_string(), toc_open: false, width: None, @@ -89,9 +112,11 @@ fn the_body_cap_reaches_the_render_through_the_pager() { Doc::parse(source), config, AppOptions { + source: None, config_path: None, title: "sample.md".to_string(), icons: false, + narrow_emoji: false, theme: "dark".to_string(), toc_open: false, width: None, @@ -231,9 +256,11 @@ fn render_options_follow_the_flags_that_feed_them() { Doc::parse(SAMPLE), config, AppOptions { + source: None, config_path: None, title: "x".to_string(), icons: false, + narrow_emoji: false, theme: "dark".to_string(), toc_open: false, width: None, @@ -271,9 +298,11 @@ fn there_is_a_horizontal_offset_only_when_something_is_over_wide() { Doc::parse(&over_wide), Config::default(), AppOptions { + source: None, config_path: None, title: "x".to_string(), icons: false, + narrow_emoji: false, theme: "dark".to_string(), toc_open: false, width: Some(200), @@ -633,9 +662,11 @@ fn rebinding_a_key_changes_what_it_does() { Doc::parse(SAMPLE), config, AppOptions { + source: None, config_path: None, title: "x".to_string(), icons: false, + narrow_emoji: false, theme: "dark".to_string(), toc_open: false, width: None, @@ -671,9 +702,11 @@ fn an_unknown_start_theme_falls_back_without_refusing_to_start() { Doc::parse("# x\n"), Config::default(), AppOptions { + source: None, config_path: None, title: "x".to_string(), icons: false, + narrow_emoji: false, theme: "no such theme".to_string(), toc_open: false, width: None, @@ -754,9 +787,11 @@ fn a_forced_width_overrides_the_terminal() { Doc::parse(SAMPLE), Config::default(), AppOptions { + source: None, config_path: None, title: "x".to_string(), icons: false, + narrow_emoji: false, theme: "dark".to_string(), toc_open: false, width: Some(40), @@ -804,9 +839,11 @@ fn pager_named(source: &str, title: &str, width: u16, height: u16) -> App { Doc::parse(source), Config::default(), AppOptions { + source: None, config_path: None, title: title.to_string(), icons: false, + narrow_emoji: false, theme: "dark".to_string(), toc_open: false, width: None, @@ -826,9 +863,11 @@ fn numbered_pager_at(source: &str, width: u16, height: u16) -> App { ..Config::default() }, AppOptions { + source: None, config_path: None, title: "sample.md".to_string(), icons: false, + narrow_emoji: false, theme: "dark".to_string(), toc_open: false, width: None, @@ -1933,9 +1972,11 @@ fn a_wide_character_binding_does_not_ragged_edge_the_help_column() { Doc::parse(SAMPLE), config, AppOptions { + source: None, config_path: None, title: "x".to_string(), icons: false, + narrow_emoji: false, theme: "dark".to_string(), toc_open: false, width: None, @@ -2310,9 +2351,11 @@ fn the_match_key_hint_names_the_keys_the_reader_actually_bound() { Doc::parse(SAMPLE), config, AppOptions { + source: None, config_path: None, title: "sample.md".to_string(), icons: false, + narrow_emoji: false, theme: "dark".to_string(), toc_open: false, width: None, @@ -2774,8 +2817,10 @@ fn themed_pager(source: &str, theme: &str, width: u16, height: u16) -> App { Doc::parse(source), Config::default(), AppOptions { + source: None, title: "sample.md".to_string(), icons: false, + narrow_emoji: false, theme: theme.to_string(), toc_open: false, width: None, @@ -4512,8 +4557,10 @@ fn a_multi_row_drag_yields_source_line_structure_not_the_renderers() { Doc::parse(source), Config::default(), AppOptions { + source: None, title: "t.md".to_string(), icons: false, + narrow_emoji: false, theme: "dark".to_string(), toc_open: false, width: Some(30), @@ -7438,3 +7485,774 @@ 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:?}" ); } + +// --------------------------------------------------------------------------- +// Reading the terminal's answer to `ESC [ 6 n` (`super::probe`). +// --------------------------------------------------------------------------- + +#[test] +fn a_cursor_report_is_read_as_a_column() { + assert_eq!(super::probe::column_of(b"\x1b[1;3R"), Some(3)); + assert_eq!(super::probe::column_of(b"\x1b[24;80R"), Some(80)); +} + +#[test] +fn a_report_is_found_among_whatever_else_arrived() { + // A keystroke can land in the same read as the reply, and a terminal is free to + // answer other things first. + assert_eq!(super::probe::column_of(b"q\x1b[1;3R"), Some(3)); + assert_eq!(super::probe::column_of(b"\x1b[?1;2c\x1b[1;5R"), Some(5)); +} + +#[test] +fn anything_that_is_not_a_report_is_no_answer() { + for bytes in [ + &b""[..], + &b"\x1b["[..], + &b"\x1b[1;R"[..], + &b"\x1b[1;3"[..], + &b"hello"[..], + &b"\x1b[99999;99999R"[..], + ] { + assert_eq!(super::probe::column_of(bytes), None, "{bytes:?}"); + } +} +// 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); + } +} + +/// A pager that knows which file its document came from, so `R` has something to act on. +fn pager_watching(source: &str, path: &std::path::Path) -> App { + let mut app = App::new( + Doc::parse(source), + Config::default(), + AppOptions { + source: Some(path.to_path_buf()), + config_path: None, + title: "doc.md".to_string(), + icons: false, + narrow_emoji: false, + theme: "dark".to_string(), + toc_open: false, + width: None, + }, + ); + app.resize(80, 24); + let _ = app.canvas(); + app +} + +/// A clock a test drives by hand, so the settle window can be crossed without sleeping. +/// +/// The watcher is asked what the time is rather than reading it, which is what lets a +/// test cross a two-second window in a microsecond and get the same answer every run. +struct Clock(std::time::Instant); + +impl Clock { + fn new() -> Self { + Self(std::time::Instant::now()) + } + + /// `seconds` after the clock was made. + fn at(&self, seconds: f64) -> std::time::Instant { + self.0 + std::time::Duration::from_secs_f64(seconds) + } +} + +/// The settle window the tests below use, well clear of the fractions they step by. +const SETTLE: std::time::Duration = std::time::Duration::from_secs(2); + +#[test] +fn an_untouched_file_is_never_reported_as_changed() { + let clock = Clock::new(); + let dir = TempDir::new("untouched"); + let path = dir.file("doc.md", "# One\n"); + let mut watcher = super::watch::Watcher::new(&path, SETTLE); + for step in 0..5 { + assert!(!watcher.changed_at(clock.at(f64::from(step)))); + } +} + +#[test] +fn a_change_after_a_quiet_spell_is_taken_up_at_once() { + // The reader who saves once and looks over at the pager is the common case, and + // waiting out the settle window for them would make the feature feel broken. + let clock = Clock::new(); + let dir = TempDir::new("quiet"); + let path = dir.file("doc.md", "# One\n"); + let mut watcher = super::watch::Watcher::new(&path, SETTLE); + + std::fs::write(&path, "# One\n\nA second paragraph.\n").expect("write"); + assert!(!watcher.changed_at(clock.at(10.0))); + assert!( + watcher.changed_at(clock.at(10.2)), + "a lone change waited on the settle window it should have skipped" + ); +} + +#[test] +fn a_burst_of_writes_is_taken_up_once_it_stops() { + // An editor that saves while the reader types produces one of these. Re-reading on + // each save costs a full re-render and flashes the status bar, so the burst is + // ridden out and the document catches up when the writing stops. + let clock = Clock::new(); + let dir = TempDir::new("burst"); + let path = dir.file("doc.md", "# One\n"); + let mut watcher = super::watch::Watcher::new(&path, SETTLE); + + // The first save comes out of the quiet and is taken up at once. + std::fs::write(&path, "# One\na\n").expect("write"); + assert!(!watcher.changed_at(clock.at(0.0))); + assert!(watcher.changed_at(clock.at(0.1))); + + // The rest arrive half a second apart, and none of them is taken up. + for (index, at) in [0.5_f64, 1.0, 1.5].into_iter().enumerate() { + std::fs::write(&path, format!("# One\n{}\n", "b".repeat(index + 2))).expect("write"); + assert!(!watcher.changed_at(clock.at(at))); + assert!( + !watcher.changed_at(clock.at(at + 0.1)), + "a change mid-burst was taken up at {at}s" + ); + } + + // Still short of the window measured from the last write, so still nothing. + assert!( + !watcher.changed_at(clock.at(3.4)), + "the burst was taken up before it had been still for the settle window" + ); + assert!( + watcher.changed_at(clock.at(3.6)), + "the burst was never taken up after it stopped" + ); +} + +#[test] +fn a_file_written_without_pause_is_never_taken_up() { + // A document being regenerated in a loop. Every re-read would be thrown away by the + // next one, so after the first the reader is left with a document that holds still. + let clock = Clock::new(); + let dir = TempDir::new("endless"); + let path = dir.file("doc.md", "# One\n"); + let mut watcher = super::watch::Watcher::new(&path, SETTLE); + + std::fs::write(&path, "# One\nstart\n").expect("write"); + assert!(!watcher.changed_at(clock.at(0.0))); + assert!(watcher.changed_at(clock.at(0.1))); + + for step in 1..120 { + let at = f64::from(step) * 0.5; + std::fs::write(&path, format!("# One\n{}\n", "x".repeat(step as usize))).expect("write"); + assert!(!watcher.changed_at(clock.at(at))); + assert!( + !watcher.changed_at(clock.at(at + 0.1)), + "a file still being written was taken up at {at}s" + ); + } +} + +#[test] +fn a_settle_window_of_zero_takes_up_every_change() { + // The escape hatch for a reader who would rather see every write, whatever it costs. + let clock = Clock::new(); + let dir = TempDir::new("no-settle"); + let path = dir.file("doc.md", "# One\n"); + let mut watcher = super::watch::Watcher::new(&path, std::time::Duration::ZERO); + + for step in 1..4 { + let at = f64::from(step) * 0.5; + std::fs::write(&path, format!("# One\n{}\n", "y".repeat(step as usize))).expect("write"); + assert!(!watcher.changed_at(clock.at(at))); + assert!( + watcher.changed_at(clock.at(at + 0.1)), + "a change was held back although the settle window is zero" + ); + } +} + +#[test] +fn a_change_is_reported_once_it_has_settled() { + let dir = TempDir::new("settled"); + let path = dir.file("doc.md", "# One\n"); + let clock = Clock::new(); + let mut watcher = super::watch::Watcher::new(&path, SETTLE); + + std::fs::write(&path, "# One\n\nAnd a second paragraph.\n").expect("write"); + assert!( + !watcher.changed_at(clock.at(0.0)), + "a file seen changing for the first time may still be half written" + ); + assert!( + watcher.changed_at(clock.at(0.1)), + "the change settled and was not reported" + ); + assert!( + !watcher.changed_at(clock.at(0.2)), + "the same change was reported twice" + ); +} + +#[test] +fn a_file_still_being_written_is_left_alone_until_it_stops() { + let clock = Clock::new(); + let dir = TempDir::new("in-flight"); + let path = dir.file("doc.md", "# One\n"); + let mut watcher = super::watch::Watcher::new(&path, SETTLE); + + std::fs::write(&path, "# One\n\nHalf of a").expect("write"); + assert!(!watcher.changed_at(clock.at(0.0))); + std::fs::write( + &path, + "# One\n\nHalf of a paragraph, then the rest of it.\n", + ) + .expect("write"); + assert!( + !watcher.changed_at(clock.at(0.05)), + "reported a file that was still growing" + ); + assert!( + !watcher.changed_at(clock.at(0.15)), + "a file that grew twice in a tenth of a second is still being written" + ); + assert!( + watcher.changed_at(clock.at(2.2)), + "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 clock = Clock::new(); + let mut watcher = super::watch::Watcher::new(&path, SETTLE); + + std::fs::remove_file(&path).expect("remove"); + assert!(!watcher.changed_at(clock.at(0.0))); + assert!(!watcher.changed_at(clock.at(0.1))); + + std::fs::write(&path, "# One\n\nBack again, with more text.\n").expect("write"); + assert!(!watcher.changed_at(clock.at(0.2))); + assert!( + watcher.changed_at(clock.at(0.3)), + "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, SETTLE); + + 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, SETTLE); + + // 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); +} + +#[test] +fn a_reload_keeps_the_selector_on_a_narrow_terminal_too() { + // The measurement is answered in the frame, never in the document, so a re-read has + // nothing to reproduce: what the file holds is what the reader gets back from a + // drag, a `[copy]` button and a search, whatever this terminal can draw. + let dir = TempDir::new("narrow"); + let path = dir.file("doc.md", "# One\n"); + let mut app = pager_narrow("# One\n"); + let mut watcher = super::watch::Watcher::new(&path, SETTLE); + + std::fs::write(&path, "# Two ☸️ three\n").expect("write"); + super::term::reload_tick(&mut app, &mut watcher); + super::term::reload_tick(&mut app, &mut watcher); + + assert_eq!(app.doc().source(), "# Two ☸️ three\n"); +} + +#[test] +fn a_reload_keeps_the_selector_when_the_terminal_wanted_it() { + let dir = TempDir::new("wide"); + let path = dir.file("doc.md", "# One\n"); + let mut app = pager("# One\n"); + let mut watcher = super::watch::Watcher::new(&path, SETTLE); + + std::fs::write(&path, "# Two ☸️ three\n").expect("write"); + super::term::reload_tick(&mut app, &mut watcher); + super::term::reload_tick(&mut app, &mut watcher); + + assert_eq!(app.doc().source(), "# Two ☸️ three\n"); +} + +#[test] +fn r_starts_and_stops_re_reading_the_file() { + // On by default, so the first press is the one that stops it. + let dir = TempDir::new("toggle"); + let path = dir.file("doc.md", "# One\n"); + let mut app = pager_watching("# One\n", &path); + assert!( + app.config().reload, + "auto-reload is on unless asked otherwise" + ); + + app.on_key(Key::char('R')); + assert!(!app.config().reload); + let notice = app.notice().expect("a toggle is worth reporting"); + assert!(!notice.is_error, "unexpected notice: {}", notice.text); + assert!( + notice.text.contains("off"), + "unexpected notice: {}", + notice.text + ); + + app.on_key(Key::char('R')); + assert!(app.config().reload); + assert!( + app.notice().is_some_and(|n| n.text.contains("on")), + "turning it back on was not reported" + ); +} + +#[test] +fn a_document_with_no_file_behind_it_has_nothing_to_watch() { + // Standard input. Reporting "auto-reload off" would name a setting that was never + // doing anything here, so the key says what is actually the case and changes nothing. + let mut app = pager("# One\n"); + let before = app.config().reload; + + app.on_key(Key::char('R')); + assert_eq!( + app.config().reload, + before, + "a setting with nothing to act on was flipped" + ); + let notice = app.notice().expect("the key said nothing at all"); + assert!( + notice.text.contains("no file"), + "unexpected notice: {}", + notice.text + ); +} + +#[test] +fn a_change_made_while_re_reading_is_off_arrives_when_it_is_switched_on() { + // Switching it back on is also how a reader asks for the change they know is there, + // so what happened while it was off must not have been quietly consumed. + let clock = Clock::new(); + let dir = TempDir::new("toggle-catch-up"); + let path = dir.file("doc.md", "# One\n"); + let mut app = pager_watching("# One\n", &path); + let mut watcher = super::watch::Watcher::new(&path, SETTLE); + + app.on_key(Key::char('R')); + std::fs::write(&path, "# Two\n\nWritten while nobody was looking.\n").expect("write"); + for step in 0..4 { + super::term::reload_tick_at(&mut app, &mut watcher, clock.at(f64::from(step))); + } + assert_eq!( + app.doc().source(), + "# One\n", + "the file was re-read although re-reading was switched off" + ); + + app.on_key(Key::char('R')); + super::term::reload_tick_at(&mut app, &mut watcher, clock.at(10.0)); + super::term::reload_tick_at(&mut app, &mut watcher, clock.at(10.2)); + assert_eq!( + app.doc().source(), + "# Two\n\nWritten while nobody was looking.\n", + "switching it back on did not pick up the change made while it was off" + ); +} + +// --------------------------------------------------------------------------- +// Drawing an emoji-presentation sequence on a terminal that measures it narrow +// (`super::draw`, `crate::text::presentation_base`). +// --------------------------------------------------------------------------- + +/// A document whose only interesting feature is a narrow base plus `U+FE0F`. +const WHEEL: &str = "# Two \u{2638}\u{fe0f} three\n"; + +#[test] +fn a_narrow_terminal_is_handed_the_base_character_alone() { + // `unicode-width` says two columns and `ratatui` skips the second cell on that + // authority, so a terminal that advances by one is a column out for the rest of the + // run it was given -- which is what strewed stale glyphs across a scrolled screen. + let mut app = pager_narrow(WHEEL); + let painted = framed(&mut app, 40, 8).join("\n"); + + assert!( + painted.contains('\u{2638}'), + "the glyph itself was not drawn: {painted:?}" + ); + assert!( + !painted.contains('\u{fe0f}'), + "the selector reached a terminal that cannot measure it: {painted:?}" + ); +} + +#[test] +fn the_document_keeps_the_selector_the_reader_will_copy() { + // The whole point of narrowing at the last step. A drag copies the Markdown source, + // and Markdown source that has quietly lost a character is not what was promised. + let mut app = pager_narrow(WHEEL); + let _ = framed(&mut app, 40, 8); + + assert_eq!( + app.doc().source(), + WHEEL, + "drawing the document changed the document" + ); +} + +#[test] +fn a_terminal_that_measures_it_wide_is_handed_the_whole_sequence() { + // Lopsided on purpose, as detection is everywhere in this program: only a terminal + // that clearly cannot measure the sequence is given anything but the document. + let mut app = pager(WHEEL); + let painted = framed(&mut app, 40, 8).join("\n"); + + assert!( + painted.contains('\u{fe0f}'), + "a terminal that honours the selector was denied it: {painted:?}" + ); +} + +#[test] +fn the_column_a_narrowed_glyph_no_longer_owns_is_a_space() { + // Two columns are still laid out for the sequence, because the wrapping and the + // table columns were measured that way. `ratatui` reads an empty symbol as "the + // cell before me owns this one" and leaves the terminal on whatever stood there; + // once the lead is one column wide, that claim is false and the cell has to be + // painted like any other. + let mut app = pager_narrow(WHEEL); + let backend = ratatui::backend::TestBackend::new(40, 8); + let mut terminal = ratatui::Terminal::new(backend).expect("a test terminal"); + terminal + .draw(|frame| super::draw::draw(frame, &mut app)) + .expect("a frame"); + let buffer = terminal.backend().buffer(); + + let mut found = false; + for y in 0..8 { + for x in 0..39 { + let Some(lead) = buffer.cell((x, y)) else { + continue; + }; + if lead.symbol() != "\u{2638}" { + continue; + } + found = true; + let after = buffer.cell((x + 1, y)).expect("a cell to the right"); + assert_eq!( + after.symbol(), + " ", + "the cell after a narrowed glyph is still claimed by it" + ); + } + } + assert!(found, "the glyph was never drawn, so nothing was checked"); +} + +#[test] +fn a_copy_button_hands_over_what_the_file_holds() { + // The reason the narrowing happens to the frame and not to the document. This + // payload is not a slice of the source -- a code block's is its literal and a + // table's is built from the parsed nodes -- so a document narrowed before the + // parser would put the terminal's shortcoming into text that leaves the program, + // and no amount of translating offsets afterwards would get it back. + const CODE: &str = "# Wheel\n\n```text\nsee \u{2638}\u{fe0f} here\n```\n"; + let mut app = App::new( + Doc::parse(CODE), + Config::default(), + AppOptions { + source: None, + config_path: None, + title: "doc.md".to_string(), + icons: false, + narrow_emoji: true, + theme: "dark".to_string(), + toc_open: false, + width: None, + }, + ); + app.resize(80, 24); + app.set_copy_button(true); + let _ = app.canvas(); + + let (x, y) = painted_button(&mut app, 80, 24, 0); + let (text, _) = copy_payload(click_hotspot(&mut app, x, y).expect("the button fired")); + assert!( + text.contains('\u{fe0f}'), + "the copy button dropped a character the file holds: {text:?}" + ); +} + +#[test] +fn a_drag_copies_the_source_the_file_holds() { + // The other half of the same promise: the status bar calls this "Markdown source", + // and Markdown source that has quietly lost a character is not what was promised. + let mut app = pager_narrow("Two \u{2638}\u{fe0f} three\n"); + let _ = framed(&mut app, 40, 8); + + assert!( + app.doc().source().contains('\u{fe0f}'), + "a drag would copy source the document no longer holds" + ); +} diff --git a/src/tui/watch.rs b/src/tui/watch.rs new file mode 100644 index 0000000..208cc16 --- /dev/null +++ b/src/tui/watch.rs @@ -0,0 +1,125 @@ +// 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::{Duration, Instant, 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, + /// How long a file that is being written has to be still before it is re-read. + settle: Duration, + /// When [`Watcher::pending`] was last set to something new. + moved: Option, + /// Whether the pending change arrived out of a quiet spell rather than mid-burst. + after_quiet: bool, +} + +impl Watcher { + /// Starts watching `path` as it stands now. + pub(super) fn new(path: &Path, settle: Duration) -> Self { + Self { + path: path.to_path_buf(), + seen: Stamp::of(path), + pending: None, + settle, + moved: None, + after_quiet: true, + } + } + + /// 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`. The time is passed in rather than read here so that a test can cross + /// the settle window in a microsecond and get the same answer on every run. + /// + /// A stamp that differs from the document on screen is remembered and reported only + /// when a later call finds it unchanged, which is what keeps a file that is still + /// being written from being read. 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. + /// + /// Settling is two questions, not one. The first is whether the file is *momentarily* + /// still — the same stamp two looks running — which is what keeps a half-written save + /// from being parsed. The second is [`Watcher::settle`]: a change that arrives out of + /// a quiet spell is taken up as soon as it is momentarily still, and one that arrives + /// while the file is already being written is ridden out until the writing stops for + /// the whole window. A file written without pause is therefore never taken up after + /// the first change, which is the point: every re-read would be thrown away by the + /// next write, at the cost of a re-render and a status-bar flash apiece. + pub(super) fn changed_at(&mut self, at: Instant) -> 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) { + // The stamp moved. Whether this is the start of a burst or a lone save is + // decided here, while the gap back to the previous move is still known. + self.after_quiet = self + .moved + .is_none_or(|moved| at.saturating_duration_since(moved) >= self.settle); + self.pending = Some(now); + self.moved = Some(at); + return false; + } + // Momentarily still. Take it up if it came out of the quiet, or if the writing + // has now stopped for the whole window. + let still_for = self + .moved + .map_or(self.settle, |moved| at.saturating_duration_since(moved)); + if !self.after_quiet && still_for < self.settle { + return false; + } + self.seen = Some(now); + self.pending = None; + true + } +} diff --git a/tests/config_save.rs b/tests/config_save.rs index fe1f8e1..93c58a7 100644 --- a/tests/config_save.rs +++ b/tests/config_save.rs @@ -55,6 +55,8 @@ fn settings() -> Config { toc_open: true, toc_width: 44, mouse: true, + reload: false, + reload_settle: 9, scroll_step: 7, body_width: Some(72), ..Config::default() @@ -276,8 +278,10 @@ fn the_key_binding_saves_the_live_settings_and_says_where() { Doc::parse("# Title\n\nSome prose.\n"), Config::default(), AppOptions { + source: None, title: "x.md".to_string(), icons: false, + narrow_emoji: false, theme: "dark".to_string(), toc_open: false, config_path: Some(path.clone()), @@ -312,8 +316,10 @@ fn saving_reports_a_failure_rather_than_claiming_success() { Doc::parse("# Title\n"), Config::default(), AppOptions { + source: None, title: "x.md".to_string(), icons: false, + narrow_emoji: false, theme: "dark".to_string(), toc_open: false, config_path: Some(path.clone()),