From e1a61a6ebd6413ced40ebe6de3faf2706ff1a6a4 Mon Sep 17 00:00:00 2001 From: Manuel Oetiker Date: Wed, 9 Sep 2026 12:10:51 +0200 Subject: [PATCH 1/7] feat(tui): re-read the document when its file changes A pager left open beside an editor now keeps up with what is being written. The event loop already wakes every 120 ms to look at the termination flag, so watching is one `stat` per tick against the file's modification time and length and needs no new dependency. A change is acted on only once a second look finds it unchanged, so a half-written save is never parsed, and a path that briefly vanishes -- how many editors save -- is waited out rather than treated as an empty document. The reading position survives the way it survives a resize, through the source offset of the topmost visible text. Unlike a resize, that offset has to be carried across the edit first: `remap_offset` compares the two sources from both ends, so text inserted above what the reader is looking at no longer pushes them off it. A live search is re-run against the new source rather than re-projected, the contents pane is rebuilt, and the footnote popup closes because the marker it points at may have moved. Reading and parsing stay in `term`: the state machine touches no file (design spec 13), so it is handed a parsed document. A file that cannot be read is reported in the status bar and leaves the document on screen alone. On by default, with `--no-reload`, `--reload` and `reload = false` to control it. Standard input is watched for nothing, there being no file. --- CHANGES.md | 19 +++ README.md | 1 + docs/manual.md | 28 ++++ src/config.rs | 28 ++++ src/config/tests.rs | 12 ++ src/config/write.rs | 8 ++ src/main.rs | 28 ++-- src/tui.rs | 10 +- src/tui/app.rs | 76 ++++++++++ src/tui/term.rs | 41 +++++- src/tui/tests.rs | 333 +++++++++++++++++++++++++++++++++++++++++++ src/tui/watch.rs | 92 ++++++++++++ tests/config_save.rs | 1 + 13 files changed, 663 insertions(+), 14 deletions(-) create mode 100644 src/tui/watch.rs diff --git a/CHANGES.md b/CHANGES.md index 72e09fa..6186785 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -20,6 +20,13 @@ minor bump rather than a patch. spec §10). `SearchSpan` has no constructor and is not `#[non_exhaustive]`, so any consumer building one by struct literal — as this crate itself does, in sixteen places — stops compiling until the new field is added. +- `mdmost::tui::run` gained a second parameter, `source: Option<&Path>` — the file the + document was read from, which the pager now watches for changes. Pass `None` for a + document that did not come from a file, which is what the old signature meant. +- `Config` gained a public field, `reload: bool` (default `true`), and a method, + `Config::math_syntax`, which is the one place `math` and `math_backslash` are turned + into a `MathSyntax`. As with the fields below, only a caller building a `Config` by + struct literal has to change. - `RenderOptions` gained a public field, `math_inline: bool`, and `Config` gained three, `math: bool`, `math_inline: bool` and `math_backslash: bool`. Both types already had a builder (`RenderOptions::with_math_inline` is new alongside it) and `Default`, so an @@ -33,6 +40,18 @@ minor bump rather than a patch. ### New +- A document read from a file is re-read while the pager is open, so **mdmost** left + beside an editor keeps up with what is being written. The reading position survives + the edit: the source offset at the top of the screen is carried across the changed + region, so text inserted above what you are reading does not push you off it. A live + search is re-run, the contents pane is rebuilt, and a footnote popup closes because + the marker it points at may have moved. The file is looked at once every eighth of a + second — one `stat`, no new dependency — and a change is acted on only once it has + stopped changing, so a half-written save is never shown; a path that momentarily + vanishes, which is how many editors save, is waited out rather than treated as an + empty document. Standard input is watched for nothing, there being no file. On by + default; `--no-reload`, `--reload` and `reload = false` control it. + - `$E = mc^2$` reads as `E = mc²` on the line, wherever inline math appears in a document: a paragraph, a table cell, a list item, a footnote. Scripts are Unicode where a full raised or lowered form exists and written flat (`x^q`) where it does diff --git a/README.md b/README.md index acced71..8b4b97d 100644 --- a/README.md +++ b/README.md @@ -142,6 +142,7 @@ starting: the problem is reported and the rest of the file still applies. ```toml theme = "dark" # name of a built-in or a [themes.*] table line_numbers = false # line-number gutter in fenced code blocks +reload = true # re-read the document when its file changes on disk mouse = false # wheel, drag-to-copy, and [copy] buttons body_width = 72 # widest the prose body is laid out; 0 for no cap section_numbers = true # number headings when a document nests three levels or more diff --git a/docs/manual.md b/docs/manual.md index 1f02291..5cf116b 100644 --- a/docs/manual.md +++ b/docs/manual.md @@ -64,6 +64,12 @@ writes plain text rather than escape sequences. - **`--no-math-backslash`** — Do not read `\(…\)` and `\[…\]`, even if the configuration file does. +- **`--no-reload`** — Do not re-read the document when the file it came from changes on + disk. Watching is on by default; see *Reloading* below. + +- **`--reload`** — Re-read the document when its file changes, even if the configuration + file turns it off. + - **`--mouse`** — Capture the mouse: the wheel scrolls, the scrollbar drags, a click in the contents pane jumps, and a drag over the document copies the Markdown source behind it. @@ -247,6 +253,27 @@ becomes `copied`. Capturing the mouse takes away the terminal's own drag-select for as long as **mdmost** runs. +# RELOADING + +A document read from a file is re-read whenever that file changes on disk, so a +pager left open beside an editor keeps up with what is being written. The +reading position is kept: **mdmost** remembers which part of the *source* was at +the top of the screen and puts the viewport back on it, carrying it across the +edit, so text inserted above what you are reading does not push you off it. + +A live search is re-run against the new text, and the contents pane is rebuilt. +A footnote popup closes, because the marker it points at may have moved. + +The file is looked at once every eighth of a second, and a change is acted on +only once it has stopped changing, so a document is never shown half-written. +An editor that saves by renaming a new file over the old one leaves a moment +where the path does not exist; that is a save in progress, not a reason to +throw away what is on screen. A file that cannot be read, or that is not text, +is reported in the status bar and leaves the document alone. + +Nothing is watched when the document arrived on standard input: there is no file +to look at. Turn watching off with `--no-reload` or `reload = false`. + # CONFIGURATION The configuration file is TOML, at *~/.config/mdmost/config.toml*, or in the @@ -263,6 +290,7 @@ icons = true # Nerd Font glyphs; false is plain Unicode; omit to det line_numbers = false # line-number gutter in fenced code blocks title_banner = false # off; true sets a lone `#` title as a wrapped FIGlet banner section_numbers = true # number headings when a document nests three levels or more +reload = true # re-read the document when its file changes on disk mouse = false # wheel scrolls, scrollbar drags, TOC clicks jump, drag copies # source, and code frames and tables get a [copy] button scroll_step = 3 # document lines per mouse-wheel notch diff --git a/src/config.rs b/src/config.rs index d4815a1..8b33cc7 100644 --- a/src/config.rs +++ b/src/config.rs @@ -144,6 +144,15 @@ pub struct Config { /// agree about. `less` does not capture either. Turn it on with `--mouse` or /// `mouse = true`. pub mouse: bool, + /// Whether the document is re-read when the file it came from changes on disk. + /// + /// On by default: a pager pointed at a file somebody is editing in another window + /// is expected to keep up, and the reader who wanted a frozen copy of a moving + /// file can pipe it in instead. Turn it off with `--no-reload` or `reload = false`. + /// + /// It has nothing to act on when the document arrived on standard input: there is + /// no file to watch, and the setting is ignored rather than being an error. + pub reload: bool, /// How many document lines one mouse-wheel notch scrolls. pub scroll_step: u16, /// The widest the document body is laid out, however wide the terminal is. @@ -176,6 +185,7 @@ impl Default for Config { toc_open: false, toc_width: DEFAULT_TOC_WIDTH, mouse: false, + reload: true, scroll_step: 3, body_width: Some(DEFAULT_BODY_WIDTH), keys: KeyBindings::defaults(), @@ -207,6 +217,19 @@ impl Loaded { } impl Config { + /// Which math delimiters a document should be parsed with. + /// + /// Lives here because two callers need the same answer — the binary at startup and + /// the pager when it re-reads a file that changed — and two derivations of it are + /// two chances to disagree about what `math = false` covers. + pub fn math_syntax(&self) -> crate::doc::MathSyntax { + crate::doc::MathSyntax { + dollars: self.math, + // `math` dominates: with the parser off there is nothing to extend. + backslash: self.math && self.math_backslash, + } + } + /// The path configuration is read from when none is given on the command line. /// /// Returns `None` when the platform has no home directory to speak of. @@ -350,6 +373,7 @@ struct RawConfig { title_banner: Option, section_numbers: Option, mouse: Option, + reload: Option, scroll_step: Option, body_width: Option, #[serde(default)] @@ -426,6 +450,9 @@ impl RawConfig { if let Some(mouse) = self.mouse { config.mouse = mouse; } + if let Some(reload) = self.reload { + config.reload = reload; + } if let Some(step) = self.scroll_step { if step == 0 { problems.push(problem(text, path, "scroll_step", "must be at least 1")); @@ -627,6 +654,7 @@ const KNOWN_KEYS: &[&str] = &[ "title_banner", "section_numbers", "mouse", + "reload", "scroll_step", "body_width", "toc", diff --git a/src/config/tests.rs b/src/config/tests.rs index 590bb4f..a7d39fb 100644 --- a/src/config/tests.rs +++ b/src/config/tests.rs @@ -534,3 +534,15 @@ fn a_misspelt_math_key_is_reported_and_dropped() { let loaded = Config::parse_str("mathinline = true\n", path()); assert_eq!(loaded.problems.len(), 1, "{:?}", loaded.problems); } + +#[test] +fn auto_reload_is_on_unless_the_file_turns_it_off() { + // On by default: a document the reader is editing in another window should keep + // up without anybody having to ask for it. + assert!(Config::default().reload); + assert!(Config::parse_str("", path()).config.reload); + + let loaded = Config::parse_str("reload = false\n", path()); + assert!(loaded.problems.is_empty(), "{:?}", loaded.problems); + assert!(!loaded.config.reload); +} diff --git a/src/config/write.rs b/src/config/write.rs index 159b1fc..ed36222 100644 --- a/src/config/write.rs +++ b/src/config/write.rs @@ -133,6 +133,11 @@ impl Config { key: "mouse", value: Some(self.mouse.to_string()), }, + Entry { + section: None, + key: "reload", + value: Some(self.reload.to_string()), + }, Entry { section: None, key: "scroll_step", @@ -212,6 +217,9 @@ impl Config { if back.mouse != self.mouse { return refuse("mouse"); } + if back.reload != self.reload { + return refuse("reload"); + } if back.scroll_step != self.scroll_step { return refuse("scroll_step"); } diff --git a/src/main.rs b/src/main.rs index 6f4d320..0252bfe 100644 --- a/src/main.rs +++ b/src/main.rs @@ -104,6 +104,18 @@ struct Cli { #[arg(long, conflicts_with = "math_backslash")] no_math_backslash: bool, + /// Do not re-read the document when the file it came from changes on disk. + /// + /// Watching is on by default and costs one `stat` every eighth of a second. Turn it + /// off to keep reading the document as it was when it was opened, whatever the + /// writer does to the file in the meantime. + #[arg(long)] + no_reload: bool, + + /// Re-read the document when its file changes, even if the config file says not to. + #[arg(long, conflicts_with = "no_reload")] + reload: bool, + /// Capture the mouse: wheel scrolls, the scrollbar drags, clicks jump in the contents /// pane, and dragging over the document copies the Markdown source behind it. /// @@ -259,11 +271,12 @@ fn run(cli: Cli) -> anyhow::Result { return Ok(ExitCode::from(EXIT_USAGE)); } - let (source, title) = match read_input(match input { + let source_path = match input { Input::File(path) => Some(path), // `Nothing` returned above, so this is standard input either way. Input::Stdin | Input::Nothing => None, - }) { + }; + let (source, title) = match read_input(source_path) { Ok(pair) => pair, Err(error) => { let _ = writeln!(io::stderr(), "mdmost: {error}"); @@ -277,14 +290,9 @@ fn run(cli: Cli) -> anyhow::Result { cli.no_math_backslash, config.math_backslash, ); + config.reload = resolve_flag(cli.reload, cli.no_reload, config.reload); - let doc = Doc::parse_auto_with( - &source, - mdmost::doc::MathSyntax { - dollars: config.math, - backslash: config.math && config.math_backslash, - }, - ); + let doc = Doc::parse_auto_with(&source, config.math_syntax()); let theme_name = cli.theme.clone().unwrap_or_else(|| config.theme.clone()); let icons = resolve_icons(&cli, config.icons); @@ -331,7 +339,7 @@ fn run(cli: Cli) -> anyhow::Result { width: cli.width, }, ); - tui::run(&mut app)?; + tui::run(&mut app, source_path)?; Ok(ExitCode::SUCCESS) } diff --git a/src/tui.rs b/src/tui.rs index 4901f85..f194d55 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -22,6 +22,7 @@ //! | [`dump`] | `--render-once` output, ANSI or plain | //! | [`wide`] | Rendering over-wide blocks so they stay horizontally reachable | //! | `term` | Terminal lifecycle, signal safety and the event loop | +//! | `watch` | Noticing that the file behind the document changed on disk | //! //! The split exists because design spec §13 requires application state to be testable //! without a terminal: [`app::App`] never touches one. @@ -39,6 +40,7 @@ pub mod popup; pub mod select; pub mod stderr; mod term; +mod watch; #[cfg(test)] mod tests; @@ -47,13 +49,17 @@ pub use app::{App, AppOptions, Focus, Overlay, PromptKind}; /// Runs the pager to completion. /// +/// `source` is the file the document was read from, if it came from one: the pager +/// re-reads it while it runs whenever the reader has left `reload` on. `None` — a +/// document that arrived on standard input — is watched for nothing. +/// /// The terminal is restored on every exit path, including panics and `SIGTERM`. /// /// # Errors /// /// Returns any I/O failure raised by the terminal. -pub fn run(app: &mut App) -> std::io::Result<()> { - term::run(app) +pub fn run(app: &mut App, source: Option<&std::path::Path>) -> std::io::Result<()> { + term::run(app, source) } /// Restores the terminal, for callers that need to bail out mid-flight. diff --git a/src/tui/app.rs b/src/tui/app.rs index a9b59b1..610fd30 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -41,6 +41,36 @@ const MAX_REPEAT: usize = 10_000; /// the label back. Nothing here schedules a wake-up. pub const FLASH_FOR: u64 = 600; +/// Where `offset` in `old` ends up in `new`, for one contiguous edit. +/// +/// The reading position is remembered as a byte offset into the document source, and an +/// edit above it moves the bytes it counts. Comparing the two sources from both ends +/// finds the region that actually changed: an offset before it keeps its value, an +/// offset after it shifts by however much the document grew or shrank, and an offset +/// *inside* it has no answer — the text it named is what was edited — so it keeps its +/// value and the reader lands as near as the render allows. +/// +/// Exact for the single edited region an editor's save produces, which is the case this +/// exists for. Several edits at once collapse into the one region that spans them, which +/// is approximate rather than wrong: only the position is at stake, and the reader can +/// see where they are. +pub(super) fn remap_offset(old: &str, new: &str, offset: usize) -> usize { + let old = old.as_bytes(); + let new = new.as_bytes(); + let common = old.len().min(new.len()); + let prefix = (0..common).take_while(|&i| old[i] == new[i]).count(); + if offset < prefix { + return offset; + } + let suffix = (0..common - prefix) + .take_while(|&i| old[old.len() - 1 - i] == new[new.len() - 1 - i]) + .count(); + if offset >= old.len() - suffix { + return (offset + new.len()).saturating_sub(old.len()); + } + offset.min(new.len()) +} + /// Whether `key` is a bare digit, and so part of a repeat count. fn is_count_digit(key: Key) -> bool { matches!(key.code, KeyCode::Char(ch) if ch.is_ascii_digit()) && key.mods.is_empty() @@ -856,6 +886,52 @@ impl App { self.track_toc(); } + /// Replaces the document with a newly parsed one, keeping the reader in place. + /// + /// The file behind the pager changed and [`super::term`] read it again; the state + /// machine touches no file itself (design spec §13), so it is handed the parsed + /// result. Everything derived from the old document is rebuilt here rather than + /// carried over — the table of contents, the search hits, the render — and + /// everything anchored to the *old canvas* is dropped, exactly as a reflow drops it. + /// + /// The reading position survives the way it survives a resize, through the source + /// offset of the topmost visible text; unlike a resize, that offset has to be + /// carried across the edit first (see [`remap_offset`]), because the bytes it counts + /// have moved. + pub fn reload(&mut self, doc: Doc) { + self.ensure_rendered(); + let anchor = self + .source_offset_at(self.scroll) + .map(|offset| remap_offset(self.doc.source(), doc.source(), offset)); + // Both are anchored to geometry the new render is about to replace, the same + // way a resize invalidates them. + self.bar_grab = None; + self.popup = None; + self.doc = doc; + // Rebuilt before the render, because `ensure_rendered` re-attaches the pane's + // anchors to the new canvas and would otherwise attach the old pane to it. + self.toc = Toc::from_doc( + &self.doc, + &crate::numbering::Numbering::enabled(&self.doc, self.config.section_numbers), + ); + // Re-run rather than re-located: `ensure_rendered` projects existing hits onto + // the new canvas, but the hits themselves came from the old *source* and a + // reload is precisely the case where that source changed. + if !self.search.query().is_empty() { + self.search = Search::new(self.doc.source(), self.search.query(), self.search.mode()) + .unwrap_or_else(|_| Search::empty()); + } + self.search_index = None; + self.ensure_rendered(); + if let Some(offset) = anchor { + self.scroll = self.row_for_source_offset(offset); + } + self.clamp(); + self.refilter_toc(); + self.track_toc(); + self.notify("reloaded", false); + } + /// Renders the document if the cache is stale, then re-attaches anchors and hits. /// /// Dropping the cache changes nothing visible: everything derived from a render is diff --git a/src/tui/term.rs b/src/tui/term.rs index 6b035d5..19549cf 100644 --- a/src/tui/term.rs +++ b/src/tui/term.rs @@ -23,6 +23,7 @@ //! than open-coded anywhere. use std::io::{self, Write}; +use std::path::Path; use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; use std::time::Duration; @@ -37,6 +38,7 @@ use crate::canvas::HotspotKind; use crate::config::{Key, KeyCode, KeyMods}; use super::app::{Activation, App, Focus}; +use super::watch::Watcher; use super::{chrome, draw}; /// How long the loop waits for input before checking the termination flag. @@ -178,7 +180,7 @@ impl Input { /// /// Returns any I/O failure from the terminal, including [`terminal_gone`] when the /// terminal is hung up under the pager. The terminal is restored either way. -pub fn run(app: &mut App) -> io::Result<()> { +pub fn run(app: &mut App, source: Option<&Path>) -> io::Result<()> { install_panic_hook(); // Declared before the `Restore` guard so that it is dropped after it: what a // library complained about is printed once the terminal is the reader's again, not @@ -211,7 +213,10 @@ pub fn run(app: &mut App) -> io::Result<()> { // restoration has to be part of that order rather than a scope-end surprise. let guard = Restore; - let result = event_loop(app, &mut terminal, &input, &terminate); + // Nothing to watch when the document came down a pipe, and nothing to watch when + // the reader turned it off. + let mut watcher = source.filter(|_| app.config().reload).map(Watcher::new); + let result = event_loop(app, &mut terminal, &input, &terminate, watcher.as_mut()); if result.is_err() { // `ratatui`'s `Terminal` complains into standard error from its destructor // when it cannot show the cursor — and when a terminal has just died, that @@ -241,6 +246,7 @@ fn event_loop( terminal: &mut ratatui::DefaultTerminal, input: &Input, terminate: &Arc, + mut watched: Option<&mut Watcher>, ) -> io::Result<()> { // Laying out a large document takes real time, and an empty alternate screen is // indistinguishable from a hang (usability review B5). One cheap frame first says @@ -267,6 +273,11 @@ fn event_loop( if waited == Wait::Gone { return Err(terminal_gone()); } + // Before the events, so that a document which changed under a reader who is + // holding a movement key is still re-read rather than starved by the input. + if let Some(watcher) = watched.as_deref_mut() { + reload_tick(app, watcher); + } // The descriptor was live a moment ago, so `crossterm` may look at it. Zero // timeout: the waiting has already been done, and asking even when nothing // arrived is what hands over events its parser is still holding from an @@ -302,6 +313,32 @@ fn event_loop( Ok(()) } +/// Re-reads the document when the file behind it has changed and settled. +/// +/// Reading and parsing live here rather than in [`App`] because the state machine +/// touches no file (design spec §13); what it is handed is a parsed document. +/// +/// A file that cannot be read or is not text is reported in the status bar and changes +/// nothing else: the document on screen is the last one that *was* readable, which is +/// more use to the reader than an empty screen. The change is consumed either way, so a +/// file that stays broken says so once rather than on every tick. +pub(super) fn reload_tick(app: &mut App, watcher: &mut Watcher) { + if !watcher.changed() { + return; + } + let path = watcher.path(); + match std::fs::read_to_string(path) { + Ok(source) => app.reload(crate::doc::Doc::parse_auto_with( + &source, + app.config().math_syntax(), + )), + Err(error) => app.notify( + format!("could not re-read {}: {error}", path.display()), + true, + ), + } +} + /// Dispatches a key event. fn on_key(app: &mut App, event: KeyEvent) { if event.kind == KeyEventKind::Release { diff --git a/src/tui/tests.rs b/src/tui/tests.rs index 43fd0c9..133c6a4 100644 --- a/src/tui/tests.rs +++ b/src/tui/tests.rs @@ -7438,3 +7438,336 @@ fn a_popup_shows_the_source_of_math_that_will_not_draw() { "a formula that will not draw shows its source, not a hole: {text:?}" ); } + +// --------------------------------------------------------------------------- +// Reloading a document whose file changed underneath the pager. +// +// The state machine touches no file (design spec §13): `super::term` reads and parses, +// and hands the new document over. What is tested here is everything that happens on +// this side of that hand-over. +// --------------------------------------------------------------------------- + +/// [`SAMPLE`] with enough filler under it that any of its headings can be scrolled to +/// the top of a twelve-row viewport, which is what makes a reading position observable. +fn padded_sample() -> String { + format!("{SAMPLE}\n{}", "Filler line of prose.\n\n".repeat(20)) +} + +/// The row the heading named `text` was rendered on. +fn row_of_heading(app: &mut App, text: &str) -> usize { + let _ = app.canvas(); + let index = app + .toc() + .entries() + .iter() + .position(|entry| entry.text == text) + .unwrap_or_else(|| panic!("no heading called {text}")); + app.toc() + .row_of(index) + .unwrap_or_else(|| panic!("{text} was not rendered")) +} + +#[test] +fn reloading_keeps_the_reading_position_when_the_edit_is_below_it() { + let source = padded_sample(); + let mut app = pager(&source); + let details = row_of_heading(&mut app, "Details"); + app.scroll_by(details as isize); + assert_eq!( + app.scroll(), + details, + "the sample is too short to test this" + ); + + let mut edited = source.clone(); + edited.push_str("\n## Afterword\n\nNu xi omicron.\n"); + app.reload(Doc::parse(&edited)); + + assert_eq!( + app.scroll(), + row_of_heading(&mut app, "Details"), + "the reader was left somewhere else by an edit below them" + ); +} + +#[test] +fn reloading_keeps_the_reading_position_when_the_edit_is_above_it() { + let source = padded_sample(); + let mut app = pager(&source); + let summary = row_of_heading(&mut app, "Summary"); + app.scroll_by(summary as isize); + assert_eq!( + app.scroll(), + summary, + "the sample is too short to test this" + ); + + // What an editor does most: text appears above what is on screen. The anchor is a + // byte offset into the old source, so it has to be carried across the edit rather + // than used as it stands, or the reader slides by the size of the insertion. + let edited = format!("Preface. One more line of it.\n\n{source}"); + app.reload(Doc::parse(&edited)); + + assert_eq!( + app.scroll(), + row_of_heading(&mut app, "Summary"), + "the reader slid off the section they were reading" + ); +} + +#[test] +fn reloading_a_shorter_document_clamps_to_its_end() { + let mut app = pager(SAMPLE); + app.act(Action::Bottom); + assert!(app.scroll() > 0); + + app.reload(Doc::parse("# Tiny\n\nOne line.\n")); + + assert!( + app.scroll() <= app.max_scroll(), + "scrolled past the end of the new document" + ); +} + +#[test] +fn reloading_rebuilds_the_table_of_contents() { + let mut app = pager(SAMPLE); + app.reload(Doc::parse("# Fresh\n\n## Second\n")); + let _ = app.canvas(); + + let headings: Vec<&str> = app + .toc() + .entries() + .iter() + .map(|entry| entry.text.as_str()) + .collect(); + assert_eq!(headings, ["Fresh", "Second"]); +} + +#[test] +fn reloading_re_runs_the_live_search() { + let mut app = pager(SAMPLE); + app.act(Action::SearchForward); + for ch in "Needle".chars() { + app.on_key(Key::char(ch)); + } + app.on_key(Key::plain(KeyCode::Enter)); + assert_eq!(app.search().len(), 2); + + let edited = format!("{SAMPLE}\nNeedle once more.\n"); + app.reload(Doc::parse(&edited)); + let _ = app.canvas(); + + assert_eq!( + app.search().len(), + 3, + "the search was not re-run against the new document" + ); +} + +#[test] +fn reloading_closes_a_footnote_popup() { + // The box is anchored to a marker at a position the new render may not have, and a + // box pointing at a sentence that has moved is worse than no box (design spec §6). + let mut app = open_footnote("a[^n]\n\n[^n]: short\n", 80, 24); + app.reload(Doc::parse("b[^n]\n\n[^n]: short\n")); + assert!(app.popup().is_none()); +} + +#[test] +fn reloading_says_so_in_the_status_bar() { + let mut app = pager(SAMPLE); + app.reload(Doc::parse("# Fresh\n")); + let notice = app.notice().expect("a reload is worth reporting"); + assert!(!notice.is_error, "a reload is not a failure"); + assert!( + notice.text.contains("reloaded"), + "unexpected notice: {}", + notice.text + ); +} + +#[test] +fn an_offset_before_an_edit_keeps_its_value() { + let old = "alpha\nbeta\n"; + let new = "alpha\nbeta\ngamma\n"; + assert_eq!(super::app::remap_offset(old, new, 2), 2); +} + +#[test] +fn an_offset_after_an_edit_moves_with_it() { + let old = "alpha\nbeta\n"; + let new = "one\ntwo\nalpha\nbeta\n"; + // "beta" starts at 6 in the old source and at 14 in the new one. + assert_eq!(super::app::remap_offset(old, new, 6), 14); + + // And the same in reverse, when the lines above are deleted again. + assert_eq!(super::app::remap_offset(new, old, 14), 6); +} + +#[test] +fn an_offset_inside_an_edit_stays_within_the_new_source() { + let old = "alpha\nbeta\ngamma\n"; + let new = "alpha\nB\ngamma\n"; + let mapped = super::app::remap_offset(old, new, 8); + assert!(mapped <= new.len(), "{mapped} is past the end of {new:?}"); +} + +#[test] +fn an_unchanged_source_maps_every_offset_to_itself() { + let text = "alpha\nbeta\n"; + for offset in 0..=text.len() { + assert_eq!(super::app::remap_offset(text, text, offset), offset); + } +} + +// --------------------------------------------------------------------------- +// Noticing that the file changed (`super::watch`). +// --------------------------------------------------------------------------- + +/// A directory that removes itself, so a test cannot leak into the developer's home. +struct TempDir(std::path::PathBuf); + +impl TempDir { + fn new(name: &str) -> Self { + let base = std::env::temp_dir().join(format!( + "mdmost-watch-{}-{}-{name}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|since| since.as_nanos()) + .unwrap_or_default() + )); + std::fs::create_dir_all(&base).expect("temp dir"); + Self(base) + } + + fn file(&self, name: &str, content: &str) -> std::path::PathBuf { + let path = self.0.join(name); + std::fs::write(&path, content).expect("write"); + path + } +} + +impl Drop for TempDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +#[test] +fn an_untouched_file_is_never_reported_as_changed() { + let dir = TempDir::new("untouched"); + let path = dir.file("doc.md", "# One\n"); + let mut watcher = super::watch::Watcher::new(&path); + for _ in 0..5 { + assert!(!watcher.changed()); + } +} + +#[test] +fn a_change_is_reported_once_it_has_settled() { + let dir = TempDir::new("settled"); + let path = dir.file("doc.md", "# One\n"); + let mut watcher = super::watch::Watcher::new(&path); + + std::fs::write(&path, "# One\n\nAnd a second paragraph.\n").expect("write"); + assert!( + !watcher.changed(), + "a file seen changing for the first time may still be half written" + ); + assert!(watcher.changed(), "the change settled and was not reported"); + assert!(!watcher.changed(), "the same change was reported twice"); +} + +#[test] +fn a_file_still_being_written_is_left_alone_until_it_stops() { + let dir = TempDir::new("in-flight"); + let path = dir.file("doc.md", "# One\n"); + let mut watcher = super::watch::Watcher::new(&path); + + std::fs::write(&path, "# One\n\nHalf of a").expect("write"); + assert!(!watcher.changed()); + std::fs::write( + &path, + "# One\n\nHalf of a paragraph, then the rest of it.\n", + ) + .expect("write"); + assert!(!watcher.changed(), "reported a file that was still growing"); + assert!(watcher.changed(), "the finished file was never reported"); +} + +#[test] +fn a_file_that_vanishes_mid_save_is_not_a_change() { + // Editors that save by writing a new file and renaming it over the old one leave a + // window where the path does not exist. That is a save in progress, not a document + // to load, and certainly not a reason to throw away the one on screen. + let dir = TempDir::new("renamed"); + let path = dir.file("doc.md", "# One\n"); + let mut watcher = super::watch::Watcher::new(&path); + + std::fs::remove_file(&path).expect("remove"); + assert!(!watcher.changed()); + assert!(!watcher.changed()); + + std::fs::write(&path, "# One\n\nBack again, with more text.\n").expect("write"); + assert!(!watcher.changed()); + assert!(watcher.changed(), "the replacement file was never reported"); +} + +#[test] +fn a_settled_change_reaches_the_document() { + let dir = TempDir::new("tick"); + let path = dir.file("doc.md", "# One\n"); + let mut app = pager("# One\n"); + let mut watcher = super::watch::Watcher::new(&path); + + std::fs::write(&path, "# Two\n\nWith a paragraph.\n").expect("write"); + super::term::reload_tick(&mut app, &mut watcher); + assert_eq!( + app.doc().source(), + "# One\n", + "a change was taken up before it had settled" + ); + + super::term::reload_tick(&mut app, &mut watcher); + assert_eq!(app.doc().source(), "# Two\n\nWith a paragraph.\n"); + assert_eq!(app.toc().entries()[0].text, "Two"); +} + +#[test] +fn a_file_that_cannot_be_read_keeps_the_document_on_screen() { + let dir = TempDir::new("unreadable"); + let path = dir.file("doc.md", "# One\n"); + let mut app = pager("# One\n"); + let mut watcher = super::watch::Watcher::new(&path); + + // Not text, so reading it back as a document fails where opening it did not. + std::fs::write(&path, [0x23, 0x20, 0xff, 0xfe, 0x0a]).expect("write"); + super::term::reload_tick(&mut app, &mut watcher); + super::term::reload_tick(&mut app, &mut watcher); + + assert_eq!( + app.doc().source(), + "# One\n", + "an unreadable file replaced the document that was on screen" + ); + let notice = app.notice().expect("the failure was not reported"); + assert!(notice.is_error, "unexpected notice: {}", notice.text); +} + +#[test] +fn the_math_syntax_follows_the_configuration() { + let mut config = Config::default(); + assert!(config.math_syntax().dollars); + assert!(!config.math_syntax().backslash); + + config.math_backslash = true; + assert!(config.math_syntax().backslash); + + // `math = false` dominates: with the parser off there is nothing for either of the + // other two keys to act on. + config.math = false; + assert!(!config.math_syntax().dollars); + assert!(!config.math_syntax().backslash); +} diff --git a/src/tui/watch.rs b/src/tui/watch.rs new file mode 100644 index 0000000..22a4aee --- /dev/null +++ b/src/tui/watch.rs @@ -0,0 +1,92 @@ +// SPDX-License-Identifier: MIT +//! Noticing that the file behind the document changed. +//! +//! The event loop already wakes every [`POLL_INTERVAL`](super::term) to look at the +//! termination flag, so watching costs one `stat` per tick and no dependency at all. +//! An inotify-style watcher would buy earlier notice of a file a reader is *reading*, +//! which is not a deadline anybody can feel, at the price of a crate that has to be +//! ported per platform. +//! +//! What the tick reports is deliberately one tick behind: a change is remembered when +//! it is first seen and only acted on when the next look finds the same file, so a +//! document is never parsed half-written. See [`Watcher::changed`]. + +use std::path::{Path, PathBuf}; +use std::time::SystemTime; + +/// What is compared to decide whether a file changed. +/// +/// Modification time and length, which is what a `stat` gives cheaply. An edit that +/// preserves the length *and* lands inside the same modification-time tick is missed; +/// on the nanosecond timestamps Linux, macOS and Windows all keep, that is a write +/// racing itself rather than a case anyone reaches by editing a document. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct Stamp { + modified: Option, + len: u64, +} + +impl Stamp { + /// The file's current stamp, or `None` if it cannot be looked at right now. + fn of(path: &Path) -> Option { + let meta = std::fs::metadata(path).ok()?; + Some(Self { + modified: meta.modified().ok(), + len: meta.len(), + }) + } +} + +/// A file, and what it looked like the last time the pager agreed with it. +#[derive(Debug)] +pub(super) struct Watcher { + path: PathBuf, + /// The stamp of the document currently on screen. + seen: Option, + /// A stamp seen once and not yet confirmed by a second look. + pending: Option, +} + +impl Watcher { + /// Starts watching `path` as it stands now. + pub(super) fn new(path: &Path) -> Self { + Self { + path: path.to_path_buf(), + seen: Stamp::of(path), + pending: None, + } + } + + /// The file being watched. + pub(super) fn path(&self) -> &Path { + &self.path + } + + /// Whether the file has changed and settled since the last time this said so. + /// + /// One `stat`. A stamp that differs from the document on screen is remembered and + /// reported only when the *next* call finds it unchanged, which is what keeps a + /// file that is still being written from being read: a write in progress moves the + /// stamp again and the wait starts over. + /// + /// A path that cannot be looked at — the window between the temporary file and the + /// rename that editors save through — is not a change and is not an error. The + /// document stays as it is and the next tick looks again. + pub(super) fn changed(&mut self) -> bool { + let Some(now) = Stamp::of(&self.path) else { + self.pending = None; + return false; + }; + if Some(now) == self.seen { + self.pending = None; + return false; + } + if self.pending == Some(now) { + self.seen = Some(now); + self.pending = None; + return true; + } + self.pending = Some(now); + false + } +} diff --git a/tests/config_save.rs b/tests/config_save.rs index fe1f8e1..26da8be 100644 --- a/tests/config_save.rs +++ b/tests/config_save.rs @@ -55,6 +55,7 @@ fn settings() -> Config { toc_open: true, toc_width: 44, mouse: true, + reload: false, scroll_step: 7, body_width: Some(72), ..Config::default() From d1fe67130e0cf1eae4cca5d3e4f84469262f36d5 Mon Sep 17 00:00:00 2001 From: Manuel Oetiker Date: Wed, 9 Sep 2026 14:06:41 +0200 Subject: [PATCH 2/7] fix(tui): measure how wide this terminal draws an emoji sequence `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 absorb that: `unicode-width` says two, `ratatui` skips the second cell on that authority, and the terminal is one column out for the rest of the run of cells it was handed -- which is why a single such glyph left stale characters strewn well beyond its own line whenever the screen was updated in place. So the terminal is asked. The probe draws the sequence at the start of a line, reads back where the cursor ended up, and erases the line; a clear answer of one column drops the selector for the rest of the session, which draws the same glyph on such a terminal and puts every measurement back on one number. Any other answer -- silence, nonsense, or the standard two -- leaves the document alone, the same lopsided rule the Nerd Font detection follows. The reply is read here rather than through `crossterm::cursor::position`, whose loop treats a failed wait as nothing to report and so spins at 100 % of a core on a terminal that has been destroyed under it. That is the fault `term` exists to keep out of this program, and the pty test catches it. This waits on a `poll` with a deadline and gives up on the hangup the kernel flags. Stripping happens on the source before the parser, so wrapping, table columns, the canvas and the diff all measure text they agree about. `narrow_emoji` in the configuration file and `--narrow-emoji` / `--wide-emoji` settle it without asking. --- CHANGES.md | 12 +++ README.md | 1 + docs/manual.md | 30 +++++++ src/config.rs | 23 +++++- src/config/tests.rs | 14 ++++ src/config/write.rs | 12 +++ src/main.rs | 94 +++++++++++++++++++++- src/text/mod.rs | 51 ++++++++++++ src/text/tests.rs | 38 +++++++++ src/tui.rs | 11 +++ src/tui/probe.rs | 188 ++++++++++++++++++++++++++++++++++++++++++++ src/tui/tests.rs | 32 ++++++++ 12 files changed, 502 insertions(+), 4 deletions(-) create mode 100644 src/tui/probe.rs diff --git a/CHANGES.md b/CHANGES.md index 72e09fa..6634179 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -48,6 +48,18 @@ 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. `Config` gained the field, which is an API break for a caller building one + 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..0148f8c 100644 --- a/README.md +++ b/README.md @@ -142,6 +142,7 @@ starting: the problem is reported and the rest of the file still applies. ```toml theme = "dark" # name of a built-in or a [themes.*] table line_numbers = false # line-number gutter in fenced code blocks +narrow_emoji = false # emoji-presentation sequences in one column; omit to measure 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..4c1fcec 100644 --- a/docs/manual.md +++ b/docs/manual.md @@ -64,6 +64,13 @@ 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. + - **`--mouse`** — Capture the mouse: the wheel scrolls, the scrollbar drags, a click in the contents pane jumps, and a drag over the document copies the Markdown source behind it. @@ -247,6 +254,28 @@ becomes `copied`. Capturing the mouse takes away the terminal's own drag-select for as long as **mdmost** runs. +## 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 drop the selector for the rest of the +session, which draws the same glyph on such a terminal and puts every +measurement back on one number. Any other answer — including no answer — leaves +the document exactly as it is. + +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. + # CONFIGURATION The configuration file is TOML, at *~/.config/mdmost/config.toml*, or in the @@ -263,6 +292,7 @@ icons = true # Nerd Font glyphs; false is plain Unicode; omit to det line_numbers = false # line-number gutter in fenced code blocks title_banner = false # off; true sets a lone `#` title as a wrapped FIGlet banner section_numbers = true # number headings when a document nests three levels or more +narrow_emoji = false # emoji-presentation sequences in one column; omit to measure mouse = false # wheel scrolls, scrollbar drags, TOC clicks jump, drag copies # source, and code frames and tables get a [copy] button scroll_step = 3 # document lines per mouse-wheel notch diff --git a/src/config.rs b/src/config.rs index d4815a1..2d6df53 100644 --- a/src/config.rs +++ b/src/config.rs @@ -144,6 +144,19 @@ 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, /// 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 +189,7 @@ impl Default for Config { toc_open: false, toc_width: DEFAULT_TOC_WIDTH, mouse: false, + narrow_emoji: None, scroll_step: 3, body_width: Some(DEFAULT_BODY_WIDTH), keys: KeyBindings::defaults(), @@ -350,6 +364,7 @@ struct RawConfig { title_banner: Option, section_numbers: Option, mouse: Option, + narrow_emoji: Option, scroll_step: Option, body_width: Option, #[serde(default)] @@ -398,10 +413,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() }; @@ -627,6 +643,7 @@ const KNOWN_KEYS: &[&str] = &[ "title_banner", "section_numbers", "mouse", + "narrow_emoji", "scroll_step", "body_width", "toc", diff --git a/src/config/tests.rs b/src/config/tests.rs index 590bb4f..f289e7d 100644 --- a/src/config/tests.rs +++ b/src/config/tests.rs @@ -534,3 +534,17 @@ 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)); +} diff --git a/src/config/write.rs b/src/config/write.rs index 159b1fc..d212431 100644 --- a/src/config/write.rs +++ b/src/config/write.rs @@ -133,6 +133,15 @@ 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: "scroll_step", @@ -212,6 +221,9 @@ impl Config { if back.mouse != self.mouse { return refuse("mouse"); } + if back.narrow_emoji != self.narrow_emoji { + return refuse("narrow_emoji"); + } if back.scroll_step != self.scroll_step { return refuse("scroll_step"); } diff --git a/src/main.rs b/src/main.rs index 6f4d320..5d62f6e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -104,6 +104,18 @@ struct Cli { #[arg(long, conflicts_with = "math_backslash")] no_math_backslash: bool, + /// 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, + /// 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 +191,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. /// @@ -278,6 +322,26 @@ fn run(cli: Cli) -> anyhow::Result { 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); + } + // Before the parser, so that every measurement downstream — wrapping, table columns, + // the canvas, `ratatui`'s own diff — is taken from text all of them agree about. + let source = if narrow_emoji { + mdmost::text::narrow_emoji(&source).into_owned() + } else { + source + }; + let doc = Doc::parse_auto_with( &source, mdmost::doc::MathSyntax { @@ -465,7 +529,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 +566,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..86412fc 100644 --- a/src/text/mod.rs +++ b/src/text/mod.rs @@ -52,6 +52,57 @@ pub fn display_width(text: &str) -> usize { text.width() } +/// `text` with the emoji-presentation selector dropped where a terminal ignores it. +/// +/// `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 wide. 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 everything it was handed — +/// leaving stale glyphs behind wherever the frame was updated in place. +/// +/// Dropping the selector is what puts all three back on one number. The pixels do not +/// change on a terminal that was ignoring the selector; on one that honours it, this +/// is never applied (see `Config::narrow_emoji`). +/// +/// Only a lone base character followed by the selector is touched, and only when the +/// selector is what made it wide. A ZWJ sequence is several emoji glued together, and +/// taking a selector out of the middle of one would change which glyph is drawn rather +/// than how wide it is. +pub fn narrow_emoji(text: &str) -> std::borrow::Cow<'_, str> { + if !text.contains(EMOJI_PRESENTATION) { + return std::borrow::Cow::Borrowed(text); + } + let mut out = String::with_capacity(text.len()); + let mut dropped = false; + for cluster in graphemes(text) { + match narrowed(cluster) { + Some(base) => { + out.push_str(base); + dropped = true; + } + None => out.push_str(cluster), + } + } + if dropped { + std::borrow::Cow::Owned(out) + } else { + std::borrow::Cow::Borrowed(text) + } +} + +/// 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. +fn narrowed(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..16f112e 100644 --- a/src/text/tests.rs +++ b/src/text/tests.rs @@ -673,3 +673,41 @@ 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_variation_selector_is_dropped_from_a_narrow_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. Dropping the selector + // is what puts every measurement — ours, ratatui's and the terminal's — back on the + // same number, and it draws the same glyph on a terminal that ignored it anyway. + assert_eq!(narrow_emoji("a ☸️ b"), "a ☸ b"); + assert_eq!(display_width(&narrow_emoji("a ☸️ b")), 5); +} + +#[test] +fn an_emoji_that_is_wide_on_its_own_is_left_alone() { + // No selector to drop, and nobody disagrees about these: every terminal measured + // gives them two columns. + for text in ["📄 Report", "🌾 Agrocheck", "👤 Fritz"] { + assert_eq!(narrow_emoji(text), text); + } +} + +#[test] +fn a_zwj_sequence_keeps_its_selectors() { + // Several emoji glued into one cluster. How wide a terminal draws that is its own + // disagreement, and taking a selector out of the middle would change which glyph + // is drawn rather than only how wide it is. + let family = "👨‍❤️‍👨"; + assert_eq!(narrow_emoji(family), family); +} + +#[test] +fn text_with_nothing_to_drop_is_not_copied() { + let text = "plain prose with no emoji at all"; + assert!(matches!(narrow_emoji(text), std::borrow::Cow::Borrowed(_))); +} diff --git a/src/tui.rs b/src/tui.rs index 4901f85..9d2932f 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -22,6 +22,7 @@ //! | [`dump`] | `--render-once` output, ANSI or plain | //! | [`wide`] | Rendering over-wide blocks so they stay horizontally reachable | //! | `term` | Terminal lifecycle, signal safety and the event loop | +//! | `probe` | Asking the terminal how wide it draws an emoji-presentation sequence | //! //! The split exists because design spec §13 requires application state to be testable //! without a terminal: [`app::App`] never touches one. @@ -36,6 +37,7 @@ pub mod help; pub mod icons; pub mod open; pub mod popup; +mod probe; pub mod select; pub mod stderr; mod term; @@ -56,6 +58,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/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/tests.rs b/src/tui/tests.rs index 43fd0c9..b351689 100644 --- a/src/tui/tests.rs +++ b/src/tui/tests.rs @@ -7438,3 +7438,35 @@ 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:?}"); + } +} From 334c5e6faf84c33961f97ac04611295e5b87abf3 Mon Sep 17 00:00:00 2001 From: Manuel Oetiker Date: Wed, 9 Sep 2026 14:37:55 +0200 Subject: [PATCH 3/7] fix(tui): re-read a changed file under the measured emoji width The terminal is measured once, before the document is first parsed, and the selector is dropped on that answer. A file re-read while the pager runs went through the parser without it, so the first reload put every `U+FE0F` back and the screen began smearing again -- the fault the measurement exists to prevent, returning the moment the document changed underneath. The resolved answer therefore travels in `AppOptions`, beside `icons`, which is the same kind of value: settled by the binary before the pager starts, and needed again later. `reload_tick` narrows what it read exactly as `main` narrowed the first read. --- CHANGES.md | 5 ++-- src/main.rs | 1 + src/tui/app.rs | 16 +++++++++++ src/tui/term.rs | 18 ++++++++++--- src/tui/tests.rs | 64 ++++++++++++++++++++++++++++++++++++++++++++ tests/config_save.rs | 2 ++ 6 files changed, 100 insertions(+), 6 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index 423727e..8046d3a 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -76,8 +76,9 @@ minor bump rather than a patch. 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. `Config` gained the field, which is an API break for a caller building one - by struct literal. + 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 diff --git a/src/main.rs b/src/main.rs index 876feb9..04c3c69 100644 --- a/src/main.rs +++ b/src/main.rs @@ -392,6 +392,7 @@ fn run(cli: Cli) -> anyhow::Result { AppOptions { 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, diff --git a/src/tui/app.rs b/src/tui/app.rs index 610fd30..db9a239 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -246,6 +246,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. @@ -425,6 +433,14 @@ impl App { &self.doc } + /// 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 diff --git a/src/tui/term.rs b/src/tui/term.rs index 19549cf..a5a3523 100644 --- a/src/tui/term.rs +++ b/src/tui/term.rs @@ -328,10 +328,20 @@ pub(super) fn reload_tick(app: &mut App, watcher: &mut Watcher) { } 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(), - )), + // Narrowed first, exactly as the binary narrowed the first read: the terminal was + // measured once, and a document re-read under a different answer would put the + // selector back and start the screen smearing again (see `super::probe`). + Ok(source) => { + let source = if app.narrow_emoji() { + crate::text::narrow_emoji(&source).into_owned() + } else { + 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, diff --git a/src/tui/tests.rs b/src/tui/tests.rs index 3ca3982..936006e 100644 --- a/src/tui/tests.rs +++ b/src/tui/tests.rs @@ -40,6 +40,26 @@ 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 { + 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( @@ -49,6 +69,7 @@ fn pager_with(source: &str, config: Config) -> App { config_path: None, title: "sample.md".to_string(), icons: false, + narrow_emoji: false, theme: "dark".to_string(), toc_open: false, width: None, @@ -92,6 +113,7 @@ fn the_body_cap_reaches_the_render_through_the_pager() { config_path: None, title: "sample.md".to_string(), icons: false, + narrow_emoji: false, theme: "dark".to_string(), toc_open: false, width: None, @@ -234,6 +256,7 @@ fn render_options_follow_the_flags_that_feed_them() { config_path: None, title: "x".to_string(), icons: false, + narrow_emoji: false, theme: "dark".to_string(), toc_open: false, width: None, @@ -274,6 +297,7 @@ fn there_is_a_horizontal_offset_only_when_something_is_over_wide() { config_path: None, title: "x".to_string(), icons: false, + narrow_emoji: false, theme: "dark".to_string(), toc_open: false, width: Some(200), @@ -636,6 +660,7 @@ fn rebinding_a_key_changes_what_it_does() { config_path: None, title: "x".to_string(), icons: false, + narrow_emoji: false, theme: "dark".to_string(), toc_open: false, width: None, @@ -674,6 +699,7 @@ fn an_unknown_start_theme_falls_back_without_refusing_to_start() { config_path: None, title: "x".to_string(), icons: false, + narrow_emoji: false, theme: "no such theme".to_string(), toc_open: false, width: None, @@ -757,6 +783,7 @@ fn a_forced_width_overrides_the_terminal() { config_path: None, title: "x".to_string(), icons: false, + narrow_emoji: false, theme: "dark".to_string(), toc_open: false, width: Some(40), @@ -807,6 +834,7 @@ fn pager_named(source: &str, title: &str, width: u16, height: u16) -> App { config_path: None, title: title.to_string(), icons: false, + narrow_emoji: false, theme: "dark".to_string(), toc_open: false, width: None, @@ -829,6 +857,7 @@ fn numbered_pager_at(source: &str, width: u16, height: u16) -> App { config_path: None, title: "sample.md".to_string(), icons: false, + narrow_emoji: false, theme: "dark".to_string(), toc_open: false, width: None, @@ -1936,6 +1965,7 @@ fn a_wide_character_binding_does_not_ragged_edge_the_help_column() { config_path: None, title: "x".to_string(), icons: false, + narrow_emoji: false, theme: "dark".to_string(), toc_open: false, width: None, @@ -2313,6 +2343,7 @@ fn the_match_key_hint_names_the_keys_the_reader_actually_bound() { config_path: None, title: "sample.md".to_string(), icons: false, + narrow_emoji: false, theme: "dark".to_string(), toc_open: false, width: None, @@ -2776,6 +2807,7 @@ fn themed_pager(source: &str, theme: &str, width: u16, height: u16) -> App { AppOptions { title: "sample.md".to_string(), icons: false, + narrow_emoji: false, theme: theme.to_string(), toc_open: false, width: None, @@ -4514,6 +4546,7 @@ fn a_multi_row_drag_yields_source_line_structure_not_the_renderers() { AppOptions { title: "t.md".to_string(), icons: false, + narrow_emoji: false, theme: "dark".to_string(), toc_open: false, width: Some(30), @@ -7801,3 +7834,34 @@ fn the_math_syntax_follows_the_configuration() { assert!(!config.math_syntax().dollars); assert!(!config.math_syntax().backslash); } + +#[test] +fn a_reload_narrows_emoji_the_way_the_first_read_did() { + // The answer was measured once, before the document was first parsed. A file that + // changes underneath must be read under the same answer, or the selector comes back + // on the reload and the screen starts smearing again. + 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); + + 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); + + 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"); +} diff --git a/tests/config_save.rs b/tests/config_save.rs index 26da8be..9f3e63d 100644 --- a/tests/config_save.rs +++ b/tests/config_save.rs @@ -279,6 +279,7 @@ fn the_key_binding_saves_the_live_settings_and_says_where() { AppOptions { title: "x.md".to_string(), icons: false, + narrow_emoji: false, theme: "dark".to_string(), toc_open: false, config_path: Some(path.clone()), @@ -315,6 +316,7 @@ fn saving_reports_a_failure_rather_than_claiming_success() { AppOptions { title: "x.md".to_string(), icons: false, + narrow_emoji: false, theme: "dark".to_string(), toc_open: false, config_path: Some(path.clone()), From 10a23c09e0c6f2c7c488e0d3856fe127616d6766 Mon Sep 17 00:00:00 2001 From: Tobias Oetiker Date: Thu, 10 Sep 2026 09:08:41 +0200 Subject: [PATCH 4/7] docs: give RELOADING a section of its own in the built manual Three defects, two of them artifacts of merging the two branches: The blank line before `# RELOADING` was lost, so pandoc read the heading as part of the paragraph above it and the whole section landed inside SELECTING AND COPYING, with a literal "# RELOADING" printed mid-sentence: flag is saved by S, a measurement never is. # RELOADING `## Emoji width` was filed under SELECTING AND COPYING as well, where it has nothing to do with the surrounding text. It answers a question about what this terminal can do, so it moves to TERMINAL SETUP, beside icon detection, which answers the same kind of question. The one cross reference to it still points forward. The `--wide-emoji` and `--no-reload` bullets ran together without the blank line every other option in that list has. Co-Authored-By: Claude Opus 5 (1M context) --- docs/manual.md | 44 +++++++++++++++++++++++--------------------- 1 file changed, 23 insertions(+), 21 deletions(-) diff --git a/docs/manual.md b/docs/manual.md index 532e21b..72b7099 100644 --- a/docs/manual.md +++ b/docs/manual.md @@ -70,6 +70,7 @@ writes plain text rather than escape sequences. - **`--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. @@ -259,27 +260,6 @@ becomes `copied`. Capturing the mouse takes away the terminal's own drag-select for as long as **mdmost** runs. -## 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 drop the selector for the rest of the -session, which draws the same glyph on such a terminal and puts every -measurement back on one number. Any other answer — including no answer — leaves -the document exactly as it is. - -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. # RELOADING A document read from a file is re-read whenever that file changes on disk, so a @@ -676,6 +656,28 @@ 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 drop the selector for the rest of the +session, which draws the same glyph on such a terminal and puts every +measurement back on one number. Any other answer — including no answer — leaves +the document exactly as it is. + +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 From 868133ba97814fd5852eeba1dc74661e80c891e7 Mon Sep 17 00:00:00 2001 From: Tobias Oetiker Date: Thu, 10 Sep 2026 09:14:47 +0200 Subject: [PATCH 5/7] feat(tui): ride out a file that is being written rather than re-reading it A file saved once and then left alone is still re-read at once. A file that is being written repeatedly no longer is: each re-read costs a full re-render and a status-bar flash, and every one of them would be thrown away by the next write. An editor that saves while the reader types, or a document regenerated in a loop, made the pager unusable. Settling is now two questions rather than one. The first is unchanged: the same stamp two looks running, which keeps a half-written save from being parsed. The second is new. When the stamp moves, the gap back to the previous move decides what kind of change this is -- one that arrives out of a quiet spell is taken up as soon as it is momentarily still, and one that arrives mid-burst is ridden out until the writing has stopped for `reload_settle` seconds. A file written without pause is therefore never taken up after the first change, which is the point: it holds still until the writing stops. `reload_settle` is in whole seconds and defaults to 2. 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 settled change, which is what this branch did before. There is no command-line flag, following `scroll_step`: it is a preference rather than a per-run decision. The watcher now reads the clock through `changed_at`, so the tests cross a two-second window in a microsecond and give the same answer every run rather than sleeping. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGES.md | 10 +++ README.md | 1 + docs/manual.md | 12 ++++ src/config.rs | 25 ++++++++ src/config/tests.rs | 20 ++++++ src/config/write.rs | 8 +++ src/tui/term.rs | 5 +- src/tui/tests.rs | 149 +++++++++++++++++++++++++++++++++++++++---- src/tui/watch.rs | 56 +++++++++++++--- tests/config_save.rs | 1 + 10 files changed, 267 insertions(+), 20 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index 8046d3a..9981f3c 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -52,6 +52,16 @@ minor bump rather than a patch. 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. + - `$E = mc^2$` reads as `E = mc²` on the line, wherever inline math appears in a document: a paragraph, a table cell, a list item, a footnote. Scripts are Unicode where a full raised or lowered form exists and written flat (`x^q`) where it does diff --git a/README.md b/README.md index 0848ae7..c4e115c 100644 --- a/README.md +++ b/README.md @@ -144,6 +144,7 @@ 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 72b7099..451d62a 100644 --- a/docs/manual.md +++ b/docs/manual.md @@ -273,6 +273,17 @@ 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, @@ -299,6 +310,7 @@ title_banner = false # off; true sets a lone `#` title as a wrapped FIGlet b 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 diff --git a/src/config.rs b/src/config.rs index fbdc5ea..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; @@ -166,6 +172,19 @@ pub struct Config { /// 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. @@ -200,6 +219,7 @@ impl Default for Config { mouse: false, narrow_emoji: None, reload: true, + reload_settle: DEFAULT_RELOAD_SETTLE, scroll_step: 3, body_width: Some(DEFAULT_BODY_WIDTH), keys: KeyBindings::defaults(), @@ -389,6 +409,7 @@ struct RawConfig { mouse: Option, narrow_emoji: Option, reload: Option, + reload_settle: Option, scroll_step: Option, body_width: Option, #[serde(default)] @@ -469,6 +490,9 @@ impl RawConfig { 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")); @@ -672,6 +696,7 @@ const KNOWN_KEYS: &[&str] = &[ "mouse", "narrow_emoji", "reload", + "reload_settle", "scroll_step", "body_width", "toc", diff --git a/src/config/tests.rs b/src/config/tests.rs index 06a284f..84b86c7 100644 --- a/src/config/tests.rs +++ b/src/config/tests.rs @@ -560,3 +560,23 @@ fn auto_reload_is_on_unless_the_file_turns_it_off() { 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); +} diff --git a/src/config/write.rs b/src/config/write.rs index bc3884c..4b0f087 100644 --- a/src/config/write.rs +++ b/src/config/write.rs @@ -147,6 +147,11 @@ impl Config { 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", @@ -232,6 +237,9 @@ impl Config { 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/tui/term.rs b/src/tui/term.rs index a5a3523..40735d8 100644 --- a/src/tui/term.rs +++ b/src/tui/term.rs @@ -215,7 +215,10 @@ pub fn run(app: &mut App, source: Option<&Path>) -> io::Result<()> { // Nothing to watch when the document came down a pipe, and nothing to watch when // the reader turned it off. - let mut watcher = source.filter(|_| app.config().reload).map(Watcher::new); + let settle = Duration::from_secs(app.config().reload_settle.into()); + let mut watcher = source + .filter(|_| app.config().reload) + .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 diff --git a/src/tui/tests.rs b/src/tui/tests.rs index 936006e..d636f56 100644 --- a/src/tui/tests.rs +++ b/src/tui/tests.rs @@ -7718,21 +7718,137 @@ impl Drop for TempDir { } } +/// 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 dir = TempDir::new("untouched"); let path = dir.file("doc.md", "# One\n"); - let mut watcher = super::watch::Watcher::new(&path); + let mut watcher = super::watch::Watcher::new(&path, SETTLE); for _ in 0..5 { assert!(!watcher.changed()); } } +#[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 mut watcher = super::watch::Watcher::new(&path); + let mut watcher = super::watch::Watcher::new(&path, SETTLE); std::fs::write(&path, "# One\n\nAnd a second paragraph.\n").expect("write"); assert!( @@ -7745,19 +7861,30 @@ fn a_change_is_reported_once_it_has_settled() { #[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); + let mut watcher = super::watch::Watcher::new(&path, SETTLE); std::fs::write(&path, "# One\n\nHalf of a").expect("write"); - assert!(!watcher.changed()); + 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(), "reported a file that was still growing"); - assert!(watcher.changed(), "the finished file was never reported"); + 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] @@ -7767,7 +7894,7 @@ fn a_file_that_vanishes_mid_save_is_not_a_change() { // to load, and certainly not a reason to throw away the one on screen. let dir = TempDir::new("renamed"); let path = dir.file("doc.md", "# One\n"); - let mut watcher = super::watch::Watcher::new(&path); + let mut watcher = super::watch::Watcher::new(&path, SETTLE); std::fs::remove_file(&path).expect("remove"); assert!(!watcher.changed()); @@ -7783,7 +7910,7 @@ 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); + 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); @@ -7803,7 +7930,7 @@ 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); + 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"); @@ -7843,7 +7970,7 @@ fn a_reload_narrows_emoji_the_way_the_first_read_did() { 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); + 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); @@ -7857,7 +7984,7 @@ 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); + 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); diff --git a/src/tui/watch.rs b/src/tui/watch.rs index 22a4aee..0d977e6 100644 --- a/src/tui/watch.rs +++ b/src/tui/watch.rs @@ -12,7 +12,7 @@ //! document is never parsed half-written. See [`Watcher::changed`]. use std::path::{Path, PathBuf}; -use std::time::SystemTime; +use std::time::{Duration, Instant, SystemTime}; /// What is compared to decide whether a file changed. /// @@ -45,15 +45,24 @@ pub(super) struct Watcher { 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) -> Self { + 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, } } @@ -73,6 +82,23 @@ impl Watcher { /// rename that editors save through — is not a change and is not an error. The /// document stays as it is and the next tick looks again. pub(super) fn changed(&mut self) -> bool { + self.changed_at(Instant::now()) + } + + /// [`Watcher::changed`], against a clock the caller supplies. + /// + /// 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. + /// + /// 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; @@ -81,12 +107,26 @@ impl Watcher { self.pending = None; return false; } - if self.pending == Some(now) { - self.seen = Some(now); - self.pending = None; - return true; + 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.pending = Some(now); - false + self.seen = Some(now); + self.pending = None; + true } } diff --git a/tests/config_save.rs b/tests/config_save.rs index 9f3e63d..722617c 100644 --- a/tests/config_save.rs +++ b/tests/config_save.rs @@ -56,6 +56,7 @@ fn settings() -> Config { toc_width: 44, mouse: true, reload: false, + reload_settle: 9, scroll_step: 7, body_width: Some(72), ..Config::default() From 4c8b6aecc32e382cea55c15d3910e865ccefb4a4 Mon Sep 17 00:00:00 2001 From: Tobias Oetiker Date: Thu, 10 Sep 2026 09:45:51 +0200 Subject: [PATCH 6/7] feat(tui): start and stop re-reading the file from the keyboard `R` turns the watching off and on while the pager runs, and `S` saves the answer like any other setting. On by default, so the first press is the one that stops it. Off and on again is also how a reader asks for a change straight away. While the watching is off the file is not looked at, so the change is still there rather than consumed: switching it back on finds a stamp that last moved long ago, treats it as arriving out of a quiet spell, and takes it up at once instead of waiting out the settle window. That is a manual re-read in two keystrokes, which is why there is no separate action for one. The watcher is therefore built whenever the document came from a file, rather than only when `reload` was on at startup -- otherwise there would be nothing left to switch back on. Whether the reader wants it is asked on each tick instead. A document that arrived on standard input has no file to watch. The key says so and changes nothing, rather than reporting a setting that was never going to act on anything. `AppOptions` carries the path for that, beside `icons` and `narrow_emoji`: settled by the binary before the pager starts and needed again later. `tui::run` drops the parameter it took the same path through, so the two cannot disagree -- a caller that passed one path and named another in `AppOptions` would have had the key report "no file to watch" over a file that was being watched. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGES.md | 16 ++++- docs/manual.md | 12 +++- src/config/keys.rs | 13 +++- src/config/tests.rs | 10 +++ src/main.rs | 3 +- src/tui.rs | 10 +-- src/tui/app.rs | 36 +++++++++++ src/tui/term.rs | 23 ++++--- src/tui/tests.rs | 146 ++++++++++++++++++++++++++++++++++++++++--- src/tui/watch.rs | 23 +++---- tests/config_save.rs | 2 + 11 files changed, 249 insertions(+), 45 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index 9981f3c..9b1d50a 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -20,9 +20,13 @@ minor bump rather than a patch. spec §10). `SearchSpan` has no constructor and is not `#[non_exhaustive]`, so any consumer building one by struct literal — as this crate itself does, in sixteen places — stops compiling until the new field is added. -- `mdmost::tui::run` gained a second parameter, `source: Option<&Path>` — the file the - document was read from, which the pager now watches for changes. Pass `None` for a - document that did not come from a file, which is what the old signature meant. +- `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 @@ -62,6 +66,12 @@ minor bump rather than a patch. `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 diff --git a/docs/manual.md b/docs/manual.md index 451d62a..1c74978 100644 --- a/docs/manual.md +++ b/docs/manual.md @@ -160,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. @@ -290,7 +295,12 @@ 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`. +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 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 84b86c7..3073624 100644 --- a/src/config/tests.rs +++ b/src/config/tests.rs @@ -580,3 +580,13 @@ fn the_settle_window_defaults_to_two_seconds_and_can_be_set() { 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/main.rs b/src/main.rs index 04c3c69..fe2fae5 100644 --- a/src/main.rs +++ b/src/main.rs @@ -390,6 +390,7 @@ fn run(cli: Cli) -> anyhow::Result { doc, config, AppOptions { + source: source_path.map(Path::to_path_buf), title, icons, narrow_emoji, @@ -403,7 +404,7 @@ fn run(cli: Cli) -> anyhow::Result { width: cli.width, }, ); - tui::run(&mut app, source_path)?; + tui::run(&mut app)?; Ok(ExitCode::SUCCESS) } diff --git a/src/tui.rs b/src/tui.rs index a834906..e928068 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -51,17 +51,17 @@ pub use app::{App, AppOptions, Focus, Overlay, PromptKind}; /// Runs the pager to completion. /// -/// `source` is the file the document was read from, if it came from one: the pager -/// re-reads it while it runs whenever the reader has left `reload` on. `None` — a -/// document that arrived on standard input — is watched for nothing. +/// The 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 /// /// Returns any I/O failure raised by the terminal. -pub fn run(app: &mut App, source: Option<&std::path::Path>) -> std::io::Result<()> { - term::run(app, source) +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. diff --git a/src/tui/app.rs b/src/tui/app.rs index db9a239..aa66ab7 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -237,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. @@ -433,6 +442,11 @@ 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 @@ -1272,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 @@ -1511,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/term.rs b/src/tui/term.rs index 40735d8..c8263a7 100644 --- a/src/tui/term.rs +++ b/src/tui/term.rs @@ -23,7 +23,6 @@ //! than open-coded anywhere. use std::io::{self, Write}; -use std::path::Path; use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; use std::time::Duration; @@ -180,7 +179,7 @@ impl Input { /// /// Returns any I/O failure from the terminal, including [`terminal_gone`] when the /// terminal is hung up under the pager. The terminal is restored either way. -pub fn run(app: &mut App, source: Option<&Path>) -> io::Result<()> { +pub fn run(app: &mut App) -> io::Result<()> { install_panic_hook(); // Declared before the `Restore` guard so that it is dropped after it: what a // library complained about is printed once the terminal is the reader's again, not @@ -213,12 +212,11 @@ pub fn run(app: &mut App, source: Option<&Path>) -> io::Result<()> { // restoration has to be part of that order rather than a scope-end surprise. let guard = Restore; - // Nothing to watch when the document came down a pipe, and nothing to watch when - // the reader turned it off. + // 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 = source - .filter(|_| app.config().reload) - .map(|path| Watcher::new(path, settle)); + 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 @@ -326,7 +324,16 @@ fn event_loop( /// more use to the reader than an empty screen. The change is consumed either way, so a /// file that stays broken says so once rather than on every tick. pub(super) fn reload_tick(app: &mut App, watcher: &mut Watcher) { - if !watcher.changed() { + 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(); diff --git a/src/tui/tests.rs b/src/tui/tests.rs index d636f56..9963771 100644 --- a/src/tui/tests.rs +++ b/src/tui/tests.rs @@ -46,6 +46,7 @@ fn pager_narrow(source: &str) -> App { Doc::parse(source), Config::default(), AppOptions { + source: None, config_path: None, title: "sample.md".to_string(), icons: false, @@ -66,6 +67,7 @@ fn pager_with(source: &str, config: Config) -> App { Doc::parse(source), config, AppOptions { + source: None, config_path: None, title: "sample.md".to_string(), icons: false, @@ -110,6 +112,7 @@ 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, @@ -253,6 +256,7 @@ 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, @@ -294,6 +298,7 @@ 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, @@ -657,6 +662,7 @@ fn rebinding_a_key_changes_what_it_does() { Doc::parse(SAMPLE), config, AppOptions { + source: None, config_path: None, title: "x".to_string(), icons: false, @@ -696,6 +702,7 @@ 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, @@ -780,6 +787,7 @@ fn a_forced_width_overrides_the_terminal() { Doc::parse(SAMPLE), Config::default(), AppOptions { + source: None, config_path: None, title: "x".to_string(), icons: false, @@ -831,6 +839,7 @@ 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, @@ -854,6 +863,7 @@ 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, @@ -1962,6 +1972,7 @@ 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, @@ -2340,6 +2351,7 @@ 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, @@ -2805,6 +2817,7 @@ 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, @@ -4544,6 +4557,7 @@ 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, @@ -7718,6 +7732,27 @@ impl Drop for TempDir { } } +/// 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 @@ -7740,11 +7775,12 @@ 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 _ in 0..5 { - assert!(!watcher.changed()); + for step in 0..5 { + assert!(!watcher.changed_at(clock.at(f64::from(step)))); } } @@ -7848,15 +7884,22 @@ fn a_settle_window_of_zero_takes_up_every_change() { 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(), + !watcher.changed_at(clock.at(0.0)), "a file seen changing for the first time may still be half written" ); - assert!(watcher.changed(), "the change settled and was not reported"); - assert!(!watcher.changed(), "the same change was reported twice"); + 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] @@ -7894,15 +7937,19 @@ fn a_file_that_vanishes_mid_save_is_not_a_change() { // 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()); - assert!(!watcher.changed()); + 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()); - assert!(watcher.changed(), "the replacement file was never reported"); + assert!(!watcher.changed_at(clock.at(0.2))); + assert!( + watcher.changed_at(clock.at(0.3)), + "the replacement file was never reported" + ); } #[test] @@ -7992,3 +8039,84 @@ fn a_reload_keeps_the_selector_when_the_terminal_wanted_it() { 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" + ); +} diff --git a/src/tui/watch.rs b/src/tui/watch.rs index 0d977e6..208cc16 100644 --- a/src/tui/watch.rs +++ b/src/tui/watch.rs @@ -73,22 +73,15 @@ impl Watcher { /// Whether the file has changed and settled since the last time this said so. /// - /// One `stat`. A stamp that differs from the document on screen is remembered and - /// reported only when the *next* call finds it unchanged, which is what keeps a - /// file that is still being written from being read: a write in progress moves the - /// stamp again and the wait starts over. + /// 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 path that cannot be looked at — the window between the temporary file and the - /// rename that editors save through — is not a change and is not an error. The - /// document stays as it is and the next tick looks again. - pub(super) fn changed(&mut self) -> bool { - self.changed_at(Instant::now()) - } - - /// [`Watcher::changed`], against a clock the caller supplies. - /// - /// 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 diff --git a/tests/config_save.rs b/tests/config_save.rs index 722617c..93c58a7 100644 --- a/tests/config_save.rs +++ b/tests/config_save.rs @@ -278,6 +278,7 @@ 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, @@ -315,6 +316,7 @@ 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, From ca8cf0d4eb3a0f3ea3cac799bc01185bbf707fb0 Mon Sep 17 00:00:00 2001 From: Tobias Oetiker Date: Thu, 10 Sep 2026 11:12:38 +0200 Subject: [PATCH 7/7] fix(tui): answer the emoji measurement in the frame, not in the document The measurement was applied to the source before the parser, so a terminal that cannot draw an emoji-presentation sequence made the document itself lose a character. That reached the reader: a drag reports "Markdown source" and handed back source the file does not hold, a `[copy]` button handed over a code block with a character missing, and a search for text containing the selector could not match. The document is what leaves the program; a terminal's shortcoming does not belong in it. It is answered on the finished frame instead. The lead cell is drawn with the base character alone -- the same glyph on such a terminal, and one column, which is what it will actually advance by -- and the cell it no longer owns is painted as the space it now is, rather than left as the empty symbol ratatui reads as "the cell before me owns this one". The sequence keeps the two columns it was laid out for, so nothing downstream has to be re-measured. One pass over the frame rather than a rule at each place text is written. The document blit, the footnote box, the contents pane and the status bar's heading and title are four such places already, and a rule spread over them is only ever as good as the last place somebody remembered it. `--render-once` writes from the canvas rather than a frame, so `dump` carries the same rule at its own single chokepoint. `text::narrow_emoji` is gone with the source narrowing it existed for; `narrowed` is promoted to `text::presentation_base` and is now the whole of the rule. `reload_tick` no longer narrows what it read, because there is no longer anything to reproduce -- which is what the previous commit on this branch was for. Costs one column of spacing: the glyph is drawn in the first of the two columns and the second is blank, where narrowing the source closed it up to one. A standards-compliant terminal leaves that column blank too. Co-Authored-By: Claude Opus 5 (1M context) --- docs/manual.md | 21 +++++-- src/main.rs | 14 ++--- src/text/mod.rs | 55 +++++------------ src/text/tests.rs | 50 +++++++++------- src/tui/draw.rs | 50 ++++++++++++++++ src/tui/dump.rs | 71 +++++++++++++++++++--- src/tui/term.rs | 18 ++---- src/tui/tests.rs | 146 ++++++++++++++++++++++++++++++++++++++++++++-- 8 files changed, 322 insertions(+), 103 deletions(-) diff --git a/docs/manual.md b/docs/manual.md index 1c74978..f553b5d 100644 --- a/docs/manual.md +++ b/docs/manual.md @@ -689,10 +689,23 @@ 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 drop the selector for the rest of the -session, which draws the same glyph on such a terminal and puts every -measurement back on one number. Any other answer — including no answer — leaves -the document exactly as it is. +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 diff --git a/src/main.rs b/src/main.rs index fe2fae5..a367ee4 100644 --- a/src/main.rs +++ b/src/main.rs @@ -347,14 +347,6 @@ fn run(cli: Cli) -> anyhow::Result { if cli.narrow_emoji || cli.wide_emoji { config.narrow_emoji = Some(narrow_emoji); } - // Before the parser, so that every measurement downstream — wrapping, table columns, - // the canvas, `ratatui`'s own diff — is taken from text all of them agree about. - let source = if narrow_emoji { - mdmost::text::narrow_emoji(&source).into_owned() - } else { - source - }; - let doc = Doc::parse_auto_with(&source, config.math_syntax()); let theme_name = cli.theme.clone().unwrap_or_else(|| config.theme.clone()); @@ -382,6 +374,7 @@ fn run(cli: Cli) -> anyhow::Result { cli.width, stdout_is_terminal, &options, + narrow_emoji, ); } @@ -446,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, @@ -472,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) diff --git a/src/text/mod.rs b/src/text/mod.rs index 86412fc..c7f1fd3 100644 --- a/src/text/mod.rs +++ b/src/text/mod.rs @@ -52,50 +52,25 @@ pub fn display_width(text: &str) -> usize { text.width() } -/// `text` with the emoji-presentation selector dropped where a terminal ignores it. -/// -/// `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 wide. 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 everything it was handed — -/// leaving stale glyphs behind wherever the frame was updated in place. -/// -/// Dropping the selector is what puts all three back on one number. The pixels do not -/// change on a terminal that was ignoring the selector; on one that honours it, this -/// is never applied (see `Config::narrow_emoji`). -/// -/// Only a lone base character followed by the selector is touched, and only when the -/// selector is what made it wide. A ZWJ sequence is several emoji glued together, and -/// taking a selector out of the middle of one would change which glyph is drawn rather -/// than how wide it is. -pub fn narrow_emoji(text: &str) -> std::borrow::Cow<'_, str> { - if !text.contains(EMOJI_PRESENTATION) { - return std::borrow::Cow::Borrowed(text); - } - let mut out = String::with_capacity(text.len()); - let mut dropped = false; - for cluster in graphemes(text) { - match narrowed(cluster) { - Some(base) => { - out.push_str(base); - dropped = true; - } - None => out.push_str(cluster), - } - } - if dropped { - std::borrow::Cow::Owned(out) - } else { - std::borrow::Cow::Borrowed(text) - } -} - /// 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. -fn narrowed(cluster: &str) -> Option<&str> { +/// +/// `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; diff --git a/src/text/tests.rs b/src/text/tests.rs index 16f112e..ee33388 100644 --- a/src/text/tests.rs +++ b/src/text/tests.rs @@ -679,35 +679,43 @@ fn truncate_to_width_costs_a_wide_cluster_honestly() { // --------------------------------------------------------------------------- #[test] -fn a_variation_selector_is_dropped_from_a_narrow_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. Dropping the selector - // is what puts every measurement — ours, ratatui's and the terminal's — back on the - // same number, and it draws the same glyph on a terminal that ignored it anyway. - assert_eq!(narrow_emoji("a ☸️ b"), "a ☸ b"); - assert_eq!(display_width(&narrow_emoji("a ☸️ b")), 5); +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_is_left_alone() { - // No selector to drop, and nobody disagrees about these: every terminal measured - // gives them two columns. - for text in ["📄 Report", "🌾 Agrocheck", "👤 Fritz"] { - assert_eq!(narrow_emoji(text), text); +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_zwj_sequence_keeps_its_selectors() { - // Several emoji glued into one cluster. How wide a terminal draws that is its own - // disagreement, and taking a selector out of the middle would change which glyph - // is drawn rather than only how wide it is. - let family = "👨‍❤️‍👨"; - assert_eq!(narrow_emoji(family), family); +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 text_with_nothing_to_drop_is_not_copied() { - let text = "plain prose with no emoji at all"; - assert!(matches!(narrow_emoji(text), std::borrow::Cow::Borrowed(_))); +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/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/term.rs b/src/tui/term.rs index c8263a7..ae3b94f 100644 --- a/src/tui/term.rs +++ b/src/tui/term.rs @@ -338,20 +338,10 @@ pub(super) fn reload_tick_at(app: &mut App, watcher: &mut Watcher, at: std::time } let path = watcher.path(); match std::fs::read_to_string(path) { - // Narrowed first, exactly as the binary narrowed the first read: the terminal was - // measured once, and a document re-read under a different answer would put the - // selector back and start the screen smearing again (see `super::probe`). - Ok(source) => { - let source = if app.narrow_emoji() { - crate::text::narrow_emoji(&source).into_owned() - } else { - source - }; - app.reload(crate::doc::Doc::parse_auto_with( - &source, - app.config().math_syntax(), - )); - } + 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, diff --git a/src/tui/tests.rs b/src/tui/tests.rs index 9963771..06ae59d 100644 --- a/src/tui/tests.rs +++ b/src/tui/tests.rs @@ -8010,10 +8010,10 @@ fn the_math_syntax_follows_the_configuration() { } #[test] -fn a_reload_narrows_emoji_the_way_the_first_read_did() { - // The answer was measured once, before the document was first parsed. A file that - // changes underneath must be read under the same answer, or the selector comes back - // on the reload and the screen starts smearing again. +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"); @@ -8023,7 +8023,7 @@ fn a_reload_narrows_emoji_the_way_the_first_read_did() { super::term::reload_tick(&mut app, &mut watcher); super::term::reload_tick(&mut app, &mut watcher); - assert_eq!(app.doc().source(), "# Two ☸ three\n"); + assert_eq!(app.doc().source(), "# Two ☸️ three\n"); } #[test] @@ -8120,3 +8120,139 @@ fn a_change_made_while_re_reading_is_off_arrives_when_it_is_switched_on() { "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" + ); +}