Skip to content
52 changes: 52 additions & 0 deletions CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,17 @@ minor bump rather than a patch.
spec §10). `SearchSpan` has no constructor and is not `#[non_exhaustive]`, so any
consumer building one by struct literal — as this crate itself does, in sixteen places —
stops compiling until the new field is added.
- `AppOptions` gained `source: Option<PathBuf>` — the file the document was read from,
which the pager watches for changes and which `Action::ToggleReload` needs in order to
say whether there is anything to watch. `None` is a document that did not come from a
file. `mdmost::tui::run` keeps its single parameter: the path travels with the rest of
the startup answers rather than beside them, so the two cannot disagree.
- `Action` gained `ToggleReload`, bound to `R`. A `match` over `Action` that is not
`#[non_exhaustive]`-tolerant stops compiling until the arm is added.
- `Config` gained a public field, `reload: bool` (default `true`), and a method,
`Config::math_syntax`, which is the one place `math` and `math_backslash` are turned
into a `MathSyntax`. As with the fields below, only a caller building a `Config` by
struct literal has to change.
- `RenderOptions` gained a public field, `math_inline: bool`, and `Config` gained three,
`math: bool`, `math_inline: bool` and `math_backslash: bool`. Both types already had a
builder (`RenderOptions::with_math_inline` is new alongside it) and `Default`, so an
Expand All @@ -33,6 +44,34 @@ minor bump rather than a patch.

### New

- A document read from a file is re-read while the pager is open, so **mdmost** left
beside an editor keeps up with what is being written. The reading position survives
the edit: the source offset at the top of the screen is carried across the changed
region, so text inserted above what you are reading does not push you off it. A live
search is re-run, the contents pane is rebuilt, and a footnote popup closes because
the marker it points at may have moved. The file is looked at once every eighth of a
second — one `stat`, no new dependency — and a change is acted on only once it has
stopped changing, so a half-written save is never shown; a path that momentarily
vanishes, which is how many editors save, is waited out rather than treated as an
empty document. Standard input is watched for nothing, there being no file. On by
default; `--no-reload`, `--reload` and `reload = false` control it.

How long a change must have settled for depends on what the file was doing before it.
One that arrives out of a quiet spell is taken up at once — the reader who saves and
looks over. One that arrives while the file is already being written is ridden out,
because each re-read costs a full re-render and a status-bar flash and would be thrown
away by the next write; the document catches up once the writing has stopped for
`reload_settle` seconds, two by default. A file written without pause therefore holds
still after the first change. `reload_settle = 0` takes up every settled change.
`Config` gained the field, which is an API break for a caller building one by struct
literal.

**`R`** starts and stops the watching while the pager runs, and `S` saves the answer.
Off and on again is also how to ask for a change straight away: what happened while
watching was off is not thrown away, so switching it back on takes the file up without
waiting out the settle window. A document that arrived on standard input has no file
to watch, and the key says so rather than flipping a setting that cannot act.

- `$E = mc^2$` reads as `E = mc²` on the line, wherever inline math appears in a
document: a paragraph, a table cell, a list item, a footnote. Scripts are Unicode
where a full raised or lowered form exists and written flat (`x^q`) where it does
Expand All @@ -48,6 +87,19 @@ minor bump rather than a patch.

### Fixed

- A terminal that draws an emoji-presentation sequence such as `☸️` (a narrow character
plus `U+FE0F`) in one column no longer leaves stale characters strewn across the screen
when the document is scrolled. The standard makes such a sequence two columns wide,
`unicode-width` and `ratatui` both measure two, and a terminal that advances by one is
then one column out for the whole run of cells it was handed — which is why the damage
spread well beyond the line the emoji was on. mdmost now asks the terminal at startup
how wide it draws one, and on a clear answer of one column drops the selector, which
draws the same glyph there and puts every measurement back on one number. `narrow_emoji`
in the configuration file and `--narrow-emoji` / `--wide-emoji` settle it without
measuring, and a document re-read from a changed file is narrowed exactly as the first
read was. `Config` gained the field and `AppOptions` gained `narrow_emoji: bool`, which
is an API break for a caller building either by struct literal.

- A Mermaid diagram's degraded-code caption is no longer corrupted where the
line-number gutter's bottom-edge junction crosses it — "not a diagram type" no
longer comes out "no┴ a diagram type". This shipped in v0.2.0 for every caption long
Expand Down
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,9 @@ starting: the problem is reported and the rest of the file still applies.
```toml
theme = "dark" # name of a built-in or a [themes.*] table
line_numbers = false # line-number gutter in fenced code blocks
narrow_emoji = false # emoji-presentation sequences in one column; omit to measure
reload = true # re-read the document when its file changes on disk
reload_settle = 2 # seconds a file being written must hold still first; 0 for none
mouse = false # wheel, drag-to-copy, and [copy] buttons
body_width = 72 # widest the prose body is laid out; 0 for no cap
section_numbers = true # number headings when a document nests three levels or more
Expand Down
93 changes: 93 additions & 0 deletions docs/manual.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,19 @@ writes plain text rather than escape sequences.
- **`--no-math-backslash`** — Do not read `\(…\)` and `\[…\]`, even if the configuration
file does.

- **`--narrow-emoji`** — Draw an emoji whose form is set by a variation selector
(`U+FE0F`) in one column rather than two, for a terminal that does the same. Left to
itself **mdmost** measures the terminal; see *Emoji width* below.

- **`--wide-emoji`** — Draw such an emoji at the width the standard gives it, without
measuring the terminal.

- **`--no-reload`** — Do not re-read the document when the file it came from changes on
disk. Watching is on by default; see *Reloading* below.

- **`--reload`** — Re-read the document when its file changes, even if the configuration
file turns it off.

- **`--mouse`** — Capture the mouse: the wheel scrolls, the scrollbar drags, a click in
the contents pane jumps, and a drag over the document copies the Markdown source
behind it.
Expand Down Expand Up @@ -147,6 +160,11 @@ and the status bar name the bindings in effect rather than the defaults.

- **`-`** — Show or hide code line numbers.

- **`R`** — Start or stop re-reading the document as its file changes; see *Reloading*
below. Off and on again is also how to ask for a change straight away, without waiting
out the settle window. A document that arrived on standard input has no file to watch,
and the key says so.

- **`S`** — Save the current settings for the next run.

- **`h`, `F1`** — Show or hide the help overlay.
Expand Down Expand Up @@ -247,6 +265,43 @@ becomes `copied`.
Capturing the mouse takes away the terminal's own drag-select for as long as
**mdmost** runs.

# RELOADING

A document read from a file is re-read whenever that file changes on disk, so a
pager left open beside an editor keeps up with what is being written. The
reading position is kept: **mdmost** remembers which part of the *source* was at
the top of the screen and puts the viewport back on it, carrying it across the
edit, so text inserted above what you are reading does not push you off it.

A live search is re-run against the new text, and the contents pane is rebuilt.
A footnote popup closes, because the marker it points at may have moved.

The file is looked at once every eighth of a second, and a change is acted on
only once it has stopped changing, so a document is never shown half-written.

How long it must have stopped for depends on what the file was doing before.
A change that arrives out of a quiet spell is taken up straight away, which is
the reader who saves in one window and looks over at the other. A change that
arrives while the file is *already* being written is ridden out instead: an
editor that saves every second or two would otherwise cost a full re-render and
a status-bar flash on each save, and every one of those re-reads would be thrown
away by the next. The document catches up once the writing has stopped for
`reload_settle` seconds, two by default. A file that is written without pause is
therefore never taken up after the first change; it holds still until the writing
stops. Set `reload_settle = 0` to take up every change as soon as it has settled.
An editor that saves by renaming a new file over the old one leaves a moment
where the path does not exist; that is a save in progress, not a reason to
throw away what is on screen. A file that cannot be read, or that is not text,
is reported in the status bar and leaves the document alone.

Nothing is watched when the document arrived on standard input: there is no file
to look at. Turn watching off with `--no-reload` or `reload = false`, or with
**`R`** while the pager runs, which `S` then saves like any other setting.

`R` is also how to ask for a change straight away. What happened while watching
was off is not thrown away, so switching it back on takes the file up at once
rather than waiting out the settle window.

# CONFIGURATION

The configuration file is TOML, at *~/.config/mdmost/config.toml*, or in the
Expand All @@ -263,6 +318,9 @@ icons = true # Nerd Font glyphs; false is plain Unicode; omit to det
line_numbers = false # line-number gutter in fenced code blocks
title_banner = false # off; true sets a lone `#` title as a wrapped FIGlet banner
section_numbers = true # number headings when a document nests three levels or more
narrow_emoji = false # emoji-presentation sequences in one column; omit to measure
reload = true # re-read the document when its file changes on disk
reload_settle = 2 # seconds a file being written must hold still first; 0 for none
mouse = false # wheel scrolls, scrollbar drags, TOC clicks jump, drag copies
# source, and code frames and tables get a [copy] button
scroll_step = 3 # document lines per mouse-wheel notch
Expand Down Expand Up @@ -620,6 +678,41 @@ Plain and icon glyphs occupy **the same display width**, so nothing shifts and
nothing reflows either way, and no feature depends on icons. To settle the choice
by hand instead, see **CONFIGURATION**.

## Emoji width

`U+FE0F` asks for the emoji form of a character that also has a text form — `☸️` is
`☸` plus that selector — and the standard makes the result two columns wide.
Several terminals draw it in one and move the cursor by one. Nothing can be
patched over that afterwards: the width tables say two, and so does the library
that paints the screen, so on such a terminal every line containing one is drawn
one column out from there on, and scrolling leaves stale characters behind.

**mdmost** therefore asks the terminal at startup: it draws the sequence at the
start of a line, reads back where the cursor ended up, and erases what it drew.
A clear answer of one column makes it draw the base character alone for the rest
of the session, which is the same glyph on such a terminal, and paint the second
column as the blank it now is. The sequence still occupies the two columns it was
laid out for, and every measurement is back on one number. Any other answer —
including no answer — leaves the screen exactly as it was.

This happens to the *screen*, never to the document. What a drag copies, what a
`[copy]` button hands over, and what a search matches are all the text the file
holds, selector included, on every terminal. A terminal that cannot measure the
sequence is a fact about that terminal, not about the document.

Only a lone character followed by the selector is treated this way, and only when
the selector is what made it wide. `❤️‍🔥` and other sequences joined with `U+200D`,
`1️⃣` and the other keycaps, flags, skin tones and anything wide on its own are all
left as they are: in those the selector sits in the middle, where removing it
would change *which* glyph is drawn rather than how wide it is. `U+FE0E`, which
asks for the plain form, is never touched.

The question is not put at all when there is no terminal on both standard input
and standard output, or when `TERM` is unset, `dumb` or `linux`. To settle it
without being asked, write `narrow_emoji = true` (or `false`) in the
configuration file, or pass `--narrow-emoji` / `--wide-emoji`; a flag is saved by
`S`, a measurement never is.

# DEFAULT MARKDOWN VIEWER

Two unrelated mechanisms can hand a Markdown file to **mdmost**. Which one
Expand Down
76 changes: 73 additions & 3 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -144,6 +150,41 @@ pub struct Config {
/// agree about. `less` does not capture either. Turn it on with `--mouse` or
/// `mouse = true`.
pub mouse: bool,
/// Whether this terminal draws an emoji-presentation sequence in one column.
///
/// `U+FE0F` asks for the emoji form of a character that also has a text form, and
/// the standard makes the result two columns wide. Several terminals draw it in one.
/// Nothing can reconcile that after the fact — `unicode-width` and `ratatui` both
/// measure two — so `true` drops the selector instead, which draws the same glyph on
/// such a terminal and puts every measurement back on one number (see
/// [`crate::text::narrow_emoji`]).
///
/// Tri-state like [`Config::icons`], and for the same reason: `None` — the default —
/// means nobody has said, and the answer is measured from the terminal at startup.
/// `--narrow-emoji` / `--wide-emoji` override even a value written in the file.
pub narrow_emoji: Option<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 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.
Expand Down Expand Up @@ -176,6 +217,9 @@ impl Default for Config {
toc_open: false,
toc_width: DEFAULT_TOC_WIDTH,
mouse: false,
narrow_emoji: None,
reload: true,
reload_settle: DEFAULT_RELOAD_SETTLE,
scroll_step: 3,
body_width: Some(DEFAULT_BODY_WIDTH),
keys: KeyBindings::defaults(),
Expand Down Expand Up @@ -207,6 +251,19 @@ impl Loaded {
}

impl Config {
/// Which math delimiters a document should be parsed with.
///
/// Lives here because two callers need the same answer — the binary at startup and
/// the pager when it re-reads a file that changed — and two derivations of it are
/// two chances to disagree about what `math = false` covers.
pub fn math_syntax(&self) -> crate::doc::MathSyntax {
crate::doc::MathSyntax {
dollars: self.math,
// `math` dominates: with the parser off there is nothing to extend.
backslash: self.math && self.math_backslash,
}
}

/// The path configuration is read from when none is given on the command line.
///
/// Returns `None` when the platform has no home directory to speak of.
Expand Down Expand Up @@ -350,6 +407,9 @@ struct RawConfig {
title_banner: Option<bool>,
section_numbers: Option<bool>,
mouse: Option<bool>,
narrow_emoji: Option<bool>,
reload: Option<bool>,
reload_settle: Option<u16>,
scroll_step: Option<u16>,
body_width: Option<u16>,
#[serde(default)]
Expand Down Expand Up @@ -398,10 +458,11 @@ impl RawConfig {
/// Validates the raw file into a [`Config`], collecting per-entry problems.
fn into_config(self, text: &str, path: &Path, problems: &mut Vec<ConfigError>) -> 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()
};

Expand All @@ -426,6 +487,12 @@ impl RawConfig {
if let Some(mouse) = self.mouse {
config.mouse = mouse;
}
if let Some(reload) = self.reload {
config.reload = reload;
}
if let Some(settle) = self.reload_settle {
config.reload_settle = settle;
}
if let Some(step) = self.scroll_step {
if step == 0 {
problems.push(problem(text, path, "scroll_step", "must be at least 1"));
Expand Down Expand Up @@ -627,6 +694,9 @@ const KNOWN_KEYS: &[&str] = &[
"title_banner",
"section_numbers",
"mouse",
"narrow_emoji",
"reload",
"reload_settle",
"scroll_step",
"body_width",
"toc",
Expand Down
Loading