Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
28 changes: 28 additions & 0 deletions docs/manual.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
28 changes: 28 additions & 0 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -350,6 +373,7 @@ struct RawConfig {
title_banner: Option<bool>,
section_numbers: Option<bool>,
mouse: Option<bool>,
reload: Option<bool>,
scroll_step: Option<u16>,
body_width: Option<u16>,
#[serde(default)]
Expand Down Expand Up @@ -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"));
Expand Down Expand Up @@ -627,6 +654,7 @@ const KNOWN_KEYS: &[&str] = &[
"title_banner",
"section_numbers",
"mouse",
"reload",
"scroll_step",
"body_width",
"toc",
Expand Down
12 changes: 12 additions & 0 deletions src/config/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
8 changes: 8 additions & 0 deletions src/config/write.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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");
}
Expand Down
28 changes: 18 additions & 10 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
///
Expand Down Expand Up @@ -259,11 +271,12 @@ fn run(cli: Cli) -> anyhow::Result<ExitCode> {
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}");
Expand All @@ -277,14 +290,9 @@ fn run(cli: Cli) -> anyhow::Result<ExitCode> {
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);
Expand Down Expand Up @@ -331,7 +339,7 @@ fn run(cli: Cli) -> anyhow::Result<ExitCode> {
width: cli.width,
},
);
tui::run(&mut app)?;
tui::run(&mut app, source_path)?;
Ok(ExitCode::SUCCESS)
}

Expand Down
10 changes: 8 additions & 2 deletions src/tui.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -39,6 +40,7 @@ pub mod popup;
pub mod select;
pub mod stderr;
mod term;
mod watch;

#[cfg(test)]
mod tests;
Expand All @@ -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.
Expand Down
76 changes: 76 additions & 0 deletions src/tui/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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
Expand Down
Loading