Skip to content

fix(tui): re-read a changed file under the measured emoji width - #12

Merged
oetiker merged 8 commits into
oetiker:mainfrom
moetiker:fix/reload-narrow-emoji
Sep 10, 2026
Merged

fix(tui): re-read a changed file under the measured emoji width#12
oetiker merged 8 commits into
oetiker:mainfrom
moetiker:fix/reload-narrow-emoji

Conversation

@moetiker

@moetiker moetiker commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

Follow-up to #10 and #11, and only meaningful once both are in.

The gap

#11 measures the terminal once, before the document is first parsed, and drops the
emoji-presentation selector when the answer is one column. #10 re-reads the document
whenever its file changes. Neither branch knows about the other, so the re-read went
through the parser without narrowing: the first reload put every U+FE0F back and the
screen started smearing again — exactly the fault the measurement exists to prevent,
returning the moment the document changed underneath.

The fix

The resolved answer travels in AppOptions, beside icons, which is the same kind of
value: settled by the binary before the pager starts and needed again later. The
measurement itself is still never written to the configuration file. reload_tick
narrows what it read exactly as main narrows the first read.

Reading this PR

It contains a merge of both branches, because the change is one that neither can carry
alone. Once #10 and #11 are merged, what remains here is the last commit: the
AppOptions field, the accessor, four lines in reload_tick and two tests. Merge this
one last.

API break

AppOptions gained narrow_emoji: bool. Recorded in CHANGES.md alongside the fix.

Testing

Two new tests. The first was watched fail with the fix backed out — it reported
"# Two ☸\u{fe0f} three\n" where "# Two ☸ three\n" was wanted, which is the bug
itself. The second holds the other direction: a terminal that wanted the selector still
gets it after a reload.

cargo test (34 binaries), cargo clippy --all-targets and cargo fmt --check are
clean on the merged branch.

moetiker and others added 8 commits September 9, 2026 12:10
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.
`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.
# Conflicts:
#	README.md
#	docs/manual.md
#	src/config.rs
#	src/config/tests.rs
#	src/config/write.rs
#	src/main.rs
#	src/tui.rs
#	src/tui/tests.rs
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.
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) <noreply@anthropic.com>
…ng 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) <noreply@anthropic.com>
`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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
@oetiker
oetiker merged commit 7b3d4d5 into oetiker:main Sep 10, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants