fix(tui): re-read a changed file under the measured emoji width - #12
Merged
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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+FE0Fback and thescreen 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, besideicons, which is the same kind ofvalue: settled by the binary before the pager starts and needed again later. The
measurement itself is still never written to the configuration file.
reload_ticknarrows what it read exactly as
mainnarrows 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
AppOptionsfield, the accessor, four lines inreload_tickand two tests. Merge thisone last.
API break
AppOptionsgainednarrow_emoji: bool. Recorded inCHANGES.mdalongside 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 bugitself. 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-targetsandcargo fmt --checkareclean on the merged branch.