Skip to content

Swap the vt100 terminal engine for rio-vt, without a shim - #140

Merged
gold-silver-copper merged 3 commits into
mainfrom
rio-vt-native
Aug 10, 2026
Merged

Swap the vt100 terminal engine for rio-vt, without a shim#140
gold-silver-copper merged 3 commits into
mainfrom
rio-vt-native

Conversation

@gold-silver-copper

@gold-silver-copper gold-silver-copper commented Aug 4, 2026

Copy link
Copy Markdown
Collaborator

Replaces vt100 with rio-vt as Ratty's VT state machine. The integration uses Rio's native grid and protocol APIs rather than reproducing vt100's interface behind a shim. portable-pty remains Ratty's PTY layer.

This is an alternative implementation of #138. Full credit to @raphamorim for the engine work and for identifying the Rio integration gaps; the main difference here is the adapter strategy.

Why a native adapter

src/vt.rs centralizes the Rio event listener, borrowed screen/grid helpers, terminal capability replies, and state not modeled by the engine. TerminalRuntime owns a Crosswords and Processor, while both renderers borrow the grid directly.

This avoids three mismatches that were easy to hide behind a vt100-shaped shim:

  • older Rio releases tied visible_rows() to the DECSTBM scroll region;
  • visible_rows() deep-copies rows, while Ratty reads the screen several times per frame;
  • a single Square::c() is not the full grapheme cluster.

Rio now exposes the APIs needed to handle these natively (visible_line_bounds, Grid::cell_text, keyboard mode, modifyOtherKeys, and X10 mouse state).

Correctness improvements

  • Native resize/reflow preserves scrollback instead of rebuilding the parser from only visible rows.
  • Rendering preserves combining marks and other full grapheme content.
  • Cursor visibility and position come from Rio's resolved cursor state, including scrollback and wide-cell behavior.
  • Wide-character owner and continuation cells are diffed independently in the in-memory Parley backend, so CJK/emoji backgrounds remain complete and scrolling cannot retain stale symbols; wrapped leading spacers stay unstyled.
  • Kitty placeholder discovery uses Rio's per-row marker instead of scanning every cell.
  • Kitty keyboard, modifyOtherKeys, mouse protocol precedence, X10 mouse mode, colors, and scrollback behavior come from engine state and are covered by tests.
  • DA1, DA2, and XTVERSION replies are structurally rewritten so Ratty does not advertise unsupported capabilities or identify itself as Rio.

Rio 0.5.19 update

This PR now uses rio-vt 0.5.19 with default-features = false. Rio added a pty feature after the original PR was opened, so embedders such as Ratty can omit Rio's unused PTY implementation while continuing to use portable-pty.

That reduces the root lockfile from the original PR's 695 packages to 676 (main has 658): +18 rather than +37. The unused teletypewriter/corcovado chain and its old platform dependencies are gone.

Rio 0.5.19 still hardcodes Rio's device capabilities/identity, so the local reply rewriting remains necessary.

Verification

Local verification on the final diff:

  • cargo fmt --all -- --check
  • cargo fmt --all --manifest-path widget/Cargo.toml -- --check
  • cargo test --lib --locked — 103 passed, 0 failed
  • cargo check --all-targets --locked
  • cargo check --all-targets --locked --manifest-path widget/Cargo.toml
  • cargo clippy --all-targets --locked -- -D warnings
  • cargo clippy --all-targets --locked --manifest-path widget/Cargo.toml -- -D warnings

The workflow matrix was also corrected so all five targets run against both manifests (10 check jobs), with native ARM64 Linux on ubuntu-24.04-arm. A Linux library-test job now runs the full 103-test suite in CI.

Manual visual pass still recommended

The remaining high-value manual pass is GPU-backed behavior: vim/less/htop/tmux bottom-row alignment, resize and scrollback, truecolor/attributes, Unicode and CJK text, mouse reporting, Kitty images, and RGP inline objects in both 2D and 3D.

@gold-silver-copper

Copy link
Copy Markdown
Collaborator Author

rio-vt upstream notes

Collecting the rio-vt findings from doing this integration, since several of them explain why the
code here looks the way it does — the workarounds in src/vt.rs and src/terminal.rs all trace
back to something in this list.

Posting it here rather than as a file in the diff to keep the PR to one thing. @raphamorim, most of
this is probably more useful to you than to this PR; happy to open individual issues on
rio for any of them. §2 and §3 you'd already flagged yourself
in #138.

Full notes (12 items, with repros)

Findings from integrating rio-vt into ratty as an embedder.

Versions. Line references are to rio-vt 0.5.1 (the version ratty PR
#138
pins). Every finding was re-confirmed present in
0.5.8, the newest 0.5.x — and the one a rio-vt = "0.5.1" requirement actually resolves to.
Items marked Repro were executed against 0.5.8.

Upstream: https://github.com/raphamorim/rio

§1 and §12 are correctness bugs — §12 is an outright panic, and §1 likely affects Rio's own
renderer. §2–4 are API gaps an embedder must hack around; §5–6 are packaging; §7–11 are contract
and DX. Section numbers are stable and referenced from ratty's source, so new findings are appended
rather than inserted; the summary table at the end is ordered by severity.


1. visible_rows() returns the DECSTBM scroll region, not the visible screen

Severity: high — silent data loss, likely affects Rio's own renderer.

fill_visible_rows (src/crosswords/mod.rs:1771-1795) computes its row range from
self.scroll_region:

let mut start = self.scroll_region.start.0;
let mut end   = self.scroll_region.end.0;
let scroll = self.display_offset() as i32;
if scroll != 0 { start -= scroll; end -= scroll; }

scroll_region is set by DECSTBM (set_scrolling_region, :3761-3762) and only reset on resize
(:845) or RIS (:2742). So once an app narrows the scroll region — which vim, less, htop, top,
tmux, and man all do — visible_rows() silently returns fewer rows, offset from the top of the
screen, while screen_lines() keeps reporting the full height.

snapshot_visible (:1678-1685) has the identical math, so the render-oriented damage path is
affected too.

Repro (10-row × 20-col grid):

for i in 0..10 { feed(format!("\x1b[{};1Hline{}", i + 1, i)); }
assert_eq!(term.visible_rows().len(), 10);        // ok

feed("\x1b[2;8r");                                 // DECSTBM rows 2..8
assert_eq!(term.screen_lines(), 10);               // still 10
assert_eq!(term.visible_rows().len(), 7);          // <-- 7
// and row 0 is now grid line 1 ("line1"), not "line0"

Also reproduces on the alternate screen (\x1b[?1049h then \x1b[1;3r on a 6-row grid → 3 rows).

Expected: both functions should iterate 0..screen_lines() (offset by display_offset) and be
independent of scroll_region. The scroll region constrains where scrolling happens; it has no
bearing on what is displayed.

Suggested fix — the existing offset math is right, only the bounds are wrong:

let scroll = self.display_offset() as i32;
let start = -scroll;
let end   = self.grid.screen_lines() as i32 - scroll;

Note: dst.len() != count in snapshot_visible and the unused let _ = cols; suggest this
path may be under-exercised. Worth a test that sets DECSTBM and asserts full-height output.


2. No public accessor for the active kitty-keyboard flags

Severity: medium — API gap.

Crosswords maintains keyboard_mode_stack and reports the current mode over the wire in
report_keyboard_mode (:3280-3284), but exposes no getter. Embedders that encode key events
themselves must reconstruct the flag byte from Mode bits:

let mut flags = 0u8;
if mode.contains(Mode::DISAMBIGUATE_ESC_CODES) { flags |= 0b0_0001; }
if mode.contains(Mode::REPORT_EVENT_TYPES)     { flags |= 0b0_0010; }
if mode.contains(Mode::REPORT_ALTERNATE_KEYS)  { flags |= 0b0_0100; }
if mode.contains(Mode::REPORT_ALL_KEYS_AS_ESC) { flags |= 0b0_1000; }
if mode.contains(Mode::REPORT_ASSOCIATED_TEXT) { flags |= 0b1_0000; }

This duplicates the bit-order contract in every embedder and will drift.

Ask: pub fn keyboard_mode(&self) -> KeyboardModes (the type already exists and already has a
From<KeyboardModes> for Mode at :150-167), or a pub fn kitty_keyboard_flags(&self) -> u8.


3. modifyOtherKeys (CSI > 4 ; n m) is not modelled

Severity: medium — API gap.

xterm's modifyOtherKeys has no Mode bit and no handler, so an embedder that needs it (to encode
Ctrl+Enter, Ctrl+Tab, and friends the way the foreground app asked) has to sniff raw PTY bytes
outside the parser. That is unreliable by construction: PTY reads split arbitrarily, so a sequence
straddling a read boundary is missed, and a naive scanner matches bytes inside DCS/OSC payloads the
parser is consuming as data.

Ask: track the level in Crosswords (a pub fn modify_other_keys(&self) -> Option<u8>), or
failing that, surface unhandled CSI sequences to the EventListener so embedders can handle them
in parser-synchronized order. The latter is what vt100's Callbacks::unhandled_csi provided and
is generally useful — see §11.


4. No accessor for a cell's full text (base char + zero-width marks)

Severity: medium — easy to get silently wrong.

Square::c() returns a single char. Combining marks, ZWJ joiners, and variation selectors live
in Extras::zerowidth, reachable only by going through two public-but-internal-feeling hops:

let text = grid.extras_table
    .get(square.extras_id()?)?      // and only valid when content_tag() == Codepoint
    .zerowidth;

The obvious-looking code (square.c()) compiles, runs, and quietly drops every combining mark.
Repro: feeding "e\u{0301}X" and reading square.c() per cell yields "eX" — the acute
accent is gone, with extras_id() == Some(1) holding it.

Ask: Square-adjacent helper such as Grid::cell_text(&self, pos) -> impl Iterator<Item = char>
or Row::cell_chars(&self, col, &ExtrasTable), so the correct path is also the shortest one. Also
worth documenting on Square::c() that it is not the cell's text.


5. teletypewriter and corcovado are unconditional dependencies

Severity: medium — supply chain.

Both are non-optional cfg(not(target_arch = "wasm32")) dependencies. An embedder using rio-vt
purely as a VT state machine — with its own PTY layer, as ratty does with portable-pty — still
links an entire second PTY implementation and a mio-0.6 fork.

Measured on ratty: 658 → 697 packages (+39) for the vt100 → rio-vt swap. What comes in via
corcovado alone:

Crate Version Status
fuchsia-zircon 0.3.3 unmaintained, last published 2018
fuchsia-zircon-sys 0.3.3 unmaintained, last published 2018
iovec 0.1.4 deprecated in favour of std::io::IoSlice
miow 0.5.0 pulls windows-sys 0.42.0
windows 0.42.0 2022-era

Plus teletypewriter itself (signal-hook, miow 0.6, a second windows-sys), and via other
paths regex, regex-automata, url/idna, flate2, phf.

This also makes rio-vt a harder sell to security-conscious downstreams: the ratty PR proposing the
swap argued it improved the dependency picture, and the opposite is true.

Ask: put the PTY driver behind a feature, e.g.

[features]
default = ["pty"]
pty = ["dep:teletypewriter", "dep:corcovado"]

so default-features = false gives a pure VT-core build. The crate description ("VT state machine,
grid, PTY driver, selection, search") already frames these as separable concerns.

Secondary: parking_lot is pulled with features = ["nightly", "hardware-lock-elision"]
unconditionally. Both should be opt-in — embedders shouldn't inherit a nightly-flavoured lock
implementation transitively.


6. DA1 advertises capabilities the build may not have and the embedder may not implement

Severity: medium — protocol correctness.

Primary device attributes is a hardcoded string (:3255):

let text = String::from("\x1b[?62;4;6;22;52c");

Two problems:

  • 4 = sixel graphics is reported even when the graphics feature is off, in which case all
    the image-decoding paths are #[cfg]-compiled out. Apps feature-detect sixel, emit it, and it
    renders as nothing.
  • 52 = OSC 52 clipboard access is not rio-vt's call to make. The clipboard is the embedder's
    responsibility (RioEvent::ClipboardStore/ClipboardLoad), and an embedder that ignores those
    events — the default, since VoidListener drops everything — will advertise clipboard support it
    does not have. This one has a security dimension: OSC 52 read-back is a known exfiltration
    vector, and terminals generally gate it behind explicit config.

Ask: derive the DA1 parameter list from compiled features, and let the embedder opt into
capability bits it actually implements (a Capabilities bitflag on Crosswords::new, or a setter).
At minimum, gate 4 behind #[cfg(feature = "graphics")].


7. Terminal identity is hardcoded to Rio

Severity: low — but wrong for every embedder.

  • XTVERSION (:3272-3276): format!("\x1bP>|Rio {version}\x1b\\") — hardcoded "Rio", versioned
    with env!("CARGO_PKG_VERSION"), i.e. rio-vt's own crate version.
  • Secondary DA (:3260-3264): \x1b[>0;{version};1c, same version source.

Every embedder therefore identifies itself as Rio. Apps do branch on XTVERSION for
terminal-specific quirks, so this actively misroutes behavior.

Ask: an embedder-supplied name/version — Crosswords::new(..., identity: TerminalIdentity) or a
setter defaulting to Rio <rio-vt version> for backwards compatibility.


8. Reply-callback events have no documented contract

Severity: low-medium — DX / silent hangs.

RioEvent::ColorRequest, TextAreaSizeRequest, and ClipboardLoad carry
Arc<dyn Fn(...) -> String> callbacks whose results the embedder is expected to write back to the
PTY. Nothing in the trait docs says so, and VoidListener silently drops them.

The failure mode is bad: an app issues OSC 11 ; ? ST to detect a light/dark background (neovim,
delta, bat, fzf all do this), gets no reply, and blocks until its timeout. It looks like a hang in
the app, not a missing terminal feature.

Ask: document on EventListener which variants require a response, and consider splitting them
into a distinct fn request(&self, req: RioRequest) -> Option<String> so the type system makes the
obligation visible instead of leaving it as a runtime convention.


9. EventListener::event() is required but never called

Severity: low — dead API surface.

fn event(&self) -> (Option<RioEvent>, bool) is a required trait method. Grepping the entire crate
for .event() finds no call sites. Every implementor writes (None, false) and wonders what it is
for.

Ask: remove it, or give it a default body and document its intended role.


10. No X10 mouse reporting (CSI ? 9 h)

Severity: low.

ansi/mode.rs:111-113 maps 1000 / 1002 / 1003 only; there is no mode-9 bit, so an embedder
mirroring a fuller mouse-mode enum ends up with an unconstructable variant. X10 is largely
obsolete, so this may be a deliberate "won't fix" — worth saying so in the docs either way.


11. Screen-reading API surface is awkward for embedders

Severity: low — DX, but it is what produced §1 and §4 in practice.

Taken together the read path has some sharp edges:

  • Crosswords::grid is a bare pub field (:430), so embedders reach into internals for
    grid.style_set.styles() and grid.extras_table — both necessary to resolve a cell.
  • The two obvious "read the screen" entry points (visible_rows, snapshot_visible) are the ones
    broken by §1, and visible_rows() additionally allocates and deep-copies the whole visible grid
    every call. Measured: 5.0 µs per call on a 50×200 grid in release (~50 allocations, ~80 KB
    copied). An embedder that reasonably assumes it is cheap — because in vt100 the equivalent was
    a plain borrow — pays it several times per frame and per PTY chunk.
  • The correct, cheap path (index grid[Line(row - display_offset)] and borrow) is not documented
    anywhere, and requires knowing that Grid's Index<Line> is display-offset-relative.
  • Crosswords::new takes WindowId and a route_id: usize, which are Rio-multiplexer concepts
    leaking into an API sold as embeddable. Embedders pass WindowId::from(0), 0 and move on.

Ask: a documented, borrowing, correctness-by-default read API — something like
pub fn visible_row(&self, row: usize) -> Option<&Row<Square>> and
pub fn styles(&self) -> &[Style] — with visible_rows()'s allocation behavior called out in its
doc comment. That single pair of accessors would have prevented three of the bugs found during this
integration.


12. A double-width glyph in a single-column grid panics

Severity: high — panic on a valid grid size.

Writing any 2-cell character into a 1-column grid panics inside the grid layer. Narrow text is
fine, and two columns is fine, so it is specifically the wide-glyph placement path.

Repro:

let mut term = Crosswords::new(CrosswordsSize::new(1, 5), CursorShape::Block, listener,
                               WindowId::from(0), 0, 100);
Processor::default().advance(&mut term, "你".as_bytes());
// panicked at crosswords/grid/mod.rs:358: index out of bounds: the len is 1 but the index is 1
grid narrow ASCII one wide char emoji
1 column ok panic panic
2 columns ok ok ok

Single-row grids are fine — a 1×40 terminal survives wide characters, wrapping, scrolling, the
alternate screen, DECSTBM, and resize.

A terminal core should not panic on a grid the embedder is entitled to create. Window managers,
tiling layouts, and drag-resize all transiently produce very narrow terminals, and the embedder has
no way to know the floor is two columns without hitting it. Expected behaviour is to drop or
replace the glyph the way other terminals do.

Workaround in ratty: clamp the grid to at least two columns in resize_to_fit.


Summary

§ Ask Severity Kind
1 visible_rows/snapshot_visible must not use scroll_region high bug
12 wide glyph in a 1-column grid panics high bug
2 expose active kitty-keyboard flags medium API gap
3 model modifyOtherKeys, or surface unhandled CSI medium API gap
4 accessor for full cell text incl. zero-width marks medium API gap
5 feature-gate teletypewriter / corcovado; make parking_lot features opt-in medium packaging
6 derive DA1 from features + embedder capabilities medium protocol
7 embedder-supplied terminal identity for XTVERSION / DA2 low protocol
8 document reply-callback obligations low-med contract
9 remove or document EventListener::event() low API
10 X10 mouse mode, or document as won't-fix low feature
11 documented borrowing screen-read API low DX

§1, §2, §3, §5, and §12 are the ones that materially change whether the ratty integration is clean.
Note that §2 and §3 were independently identified by rio-vt's author in the
ratty PR #138 description as gaps they intended to
upstream.

Ratty's own workarounds live in src/vt.rs (§1, §2, §3, §4) and src/terminal.rs (§12), each
commented with the rio-vt behaviour that forces it, so they can be found and removed as fixes land
upstream.

@gold-silver-copper
gold-silver-copper force-pushed the rio-vt-native branch 2 times, most recently from 7eadedb to 3d3df2d Compare August 4, 2026 05:10
@raphamorim

Copy link
Copy Markdown
Contributor

Noice! Will address those @gold-silver-copper ! Only closed the old pr because didn't want to feel like "forcing" you folks to migrate lol

@gold-silver-copper

Copy link
Copy Markdown
Collaborator Author

@raphamorim Thanks, none of them are real blockers, most have workarounds. The best one to tackle first would be: 5. teletypewriter and corcovado are unconditional dependencies . Since this is something that we cant work around ourselves. We don't plan on getting rid of portable-pty yet, so being able to disable teletypewriter would be the biggest win for us. All the other issues are mainly nice to haves.

@raphamorim

Copy link
Copy Markdown
Contributor

Yea, that make sense. I was looking into that yesterday. Might take few versions for it, but will ping here back

@gold-silver-copper

gold-silver-copper commented Aug 4, 2026

Copy link
Copy Markdown
Collaborator Author

Test plan

Since the automated suite only reaches the VT boundary and the ratatui buffer, here's a plan for
the part that actually gates this — the render and input path above it, which nothing here can
cover: Vello, Parley, the GPU texture, the 3D scene.

Two things worth pulling out of it before you open the fold:

Build both engines and A/B them. The real question isn't "does it work" but "is it a
regression", and side-by-side beats judging one binary in isolation:

cargo build --release                                              # this branch
git worktree add /tmp/ratty-main origin/main
cargo build --release --manifest-path /tmp/ratty-main/Cargo.toml   # vt100

Two checks carry most of the weight. Phase 4 (vim/less/htop/tmux, watching the bottom rows and
vertical alignment) proves the defect this PR exists for is gone. Phase 7 (seq 1 5000, then drag
the window narrower) proves the biggest win: scrollback survives a horizontal resize here and
does not on main
, because the old path serialized only the visible screen and replayed it.

Phase 2 exists because CI is thinner than the green check suggests — it never runs cargo test,
and on this PR only the two x86_64-pc-windows-msvc check jobs actually ran; the declared macOS
and Linux matrix entries didn't. That's a repo-wide gap, not specific to this PR, but it means the
100 tests are verified on one machine.

Edited: the Phase 5 box-drawing command was wrong in the first version of this comment — it
put a box: label on only the first line, which shifts the top border five columns and makes
the box look broken in any terminal. Corrected below, and the pass criteria now separate cell
alignment (a real terminal bug) from glyph seams (font rasterization, identical on both engines).

Full test plan (12 phases, with commands and pass criteria)

Every step says what changed, what to run, and what a pass looks like. Phases 0–2 are
mechanical. Phases 3+ need a human at a GPU display and are the ones that actually gate the merge,
because the engine swap touches the whole render and input path and nothing automated covers Vello,
Parley, the GPU texture, or the 3D scene.

Branch: rio-vt-native · PR: #140


Phase 0 — Setup

git fetch origin
git checkout rio-vt-native
git log --oneline -1          # expect: refactor(vt): swap the vt100 terminal engine for rio-vt

Build both sides so you can A/B against the old engine — this is the single highest-value
technique here, since "is this a regression?" is the actual question:

cargo build --release                                   # new engine  -> target/release/ratty
git worktree add /tmp/ratty-main origin/main
cargo build --release --manifest-path /tmp/ratty-main/Cargo.toml   # old vt100 build

Keep both binaries. Anywhere below that says compare against main, run the same input in
/tmp/ratty-main/target/release/ratty and diff what you see.


Phase 1 — Automated checks

cargo fmt --all -- --check
cargo clippy --all-targets -- -D warnings
cargo test --lib                                   # expect 99 passed, 0 failed
cargo check --all-targets
cargo check --all-targets --manifest-path widget/Cargo.toml

The 32 tests added by this PR:

cargo test --lib vt::           # 26 — engine boundary
cargo test --lib terminal::     # 3  — widget render through the ratatui buffer
cargo test --lib mouse::        # 7  — selection text (4 new here)

Two of them are the regression guards that motivated the PR; they should fail against #138's
implementation and pass here:

cargo test --lib every_row_stays_reachable_with_a_scroll_region_set
cargo test --lib widget_draws_every_row_with_a_scroll_region_set

Pass: all green, zero warnings.


Phase 2 — Cross-platform (fills a real CI gap)

CI on this repo only runs cargo check, cargo clippy, and cargo fmtit never runs
cargo test
— and on this PR only the two x86_64-pc-windows-msvc check jobs actually executed;
the declared macOS and Linux matrix entries did not run. So the test suite is verified on whatever
machine you build on and nowhere else.

This matters more than usual because rio-vt drags in teletypewriter and corcovado, which carry
platform-specific code (ConPTY on Windows, fuchsia-zircon/iovec on Unix) that ratty has never
linked before.

# whatever targets you have toolchains for
cargo check --target x86_64-unknown-linux-gnu
cargo check --target aarch64-apple-darwin
cargo check --target x86_64-pc-windows-msvc

# and run the suite on at least one non-development platform
cargo test --lib

Pass: builds on every platform you ship, and the suite passes on more than one.


Phase 3 — Basic terminal sanity

Changed: the entire PTY→parse→render path (runtime.rs, systems.rs, terminal.rs).

cargo run --release

Then inside ratty:

echo hello
ls -la
seq 1 200                # scrolling
printf 'a\tb\tc\n'       # tabs
yes | head -5000         # bulk output, watch for tearing or lag
clear

Pass: text lands where expected, no dropped or duplicated lines, no visual corruption.


Phase 4 — Scroll regions (the headline fix)

Changed: vt::visible_row replaces rio-vt's visible_rows, which is bound to the DECSTBM region.
This is the defect the whole PR exists for.

Full-screen apps set a scroll region, which is exactly what the shim approach got wrong:

vim /etc/hosts        # :q to exit
less /usr/share/dict/words
htop                  # or top
tmux                  # then: create panes, split, exit
man ls

Look specifically at:

  • the bottom two or three rows — under the bug they go blank
  • vertical alignment — under the bug the whole grid shifts up by the scroll region's top margin
  • the status/ruler line in vim and less staying pinned to the last row

Explicit DECSTBM check — set a region, then paint every row:

printf '\e[2J\e[H'; printf '\e[3;20r'; for i in $(seq 1 24); do printf '\e[%d;1Hrow%d' $i $i; done; printf '\e[r\e[25;1H\n'

Pass: all 24 rows show rowN with N matching the visual line number, nothing blank at the
bottom. Compare against main — output must be identical.


Phase 5 — Unicode

Changed: vt::push_cell_text now builds the full grapheme cluster from rio-vt's extras table;
reading only the base codepoint silently drops combining marks.

printf 'wide: 你好世界 ok\n'
printf 'combining: e\xcc\x81 a\xcc\x80 n\xcc\x83 -- should read: é à ñ\n'
printf 'emoji: 😀 👩‍💻 🇯🇵 ok\n'
# every line must start at column 0 — a label on only the first line
# shifts the top border and makes the box look broken in ANY terminal
printf '┌───┬───┐\n│ a │ b │\n├───┼───┤\n│ c │ d │\n└───┴───┘\n'
printf 'mixed: 日本語text混在\n'

Pass: accents render on their letters (not as bare e a n); CJK occupies two columns with no
doubled glyph in the spacer cell; the ZWJ emoji renders as one glyph if the font supports it.
Compare against main.

For the box, separate the two things that can go wrong:

  • Cell alignment — do the corners of each row land in the same column? Every box-drawing
    character is one cell wide, so they must. A failure here is a terminal bug.
  • Glyph seams — do the segments visually join, or are there hairline gaps between cells?
    That is font rasterization (Parley/Vello), identical on both engines, and unrelated to this PR.

Phase 6 — Cursor

Changed: visibility now comes from rio-vt's resolved CursorState, which also hides the cursor
during scrollback and snaps it off a wide-character spacer.

printf '\e[?25l'; sleep 2; printf '\e[?25h'      # hide 2s, then show
printf '\e[10;40Hcursor here\n'                  # absolute positioning
printf '\e[10;40f cursor here via HVP\n'         # HVP — the old rewriter is deleted

Then scroll into history with Alt+PageUp.

Pass: DECTCEM hides and restores the cursor; HVP positions identically to CUP (this exercises
the deleted normalize_hvp_sequences); the cursor is not drawn while scrolled back (this is a
fix — main draws one); it returns on scrolling to the bottom.


Phase 7 — Scrollback and resize

Changed: resize is now a native rio-vt reflow. The old path serialized only the visible screen and
replayed it, destroying all scrollback on every horizontal resize — the biggest single fix here.

seq 1 5000
  • Alt+PageUp / Alt+PageDown — page through history
  • Alt+Up / Alt+Down — line by line
  • mouse wheel — same

Then, the important one:

seq 1 5000
# now drag the window narrower, then wider

Pass: scrollback survives the resize and long lines reflow to the new width. On main the
scrollback is gone after any width change — confirm that difference, it's the headline win.

Also check scrollback is inert on the alternate screen:

vim            # scroll keys should do nothing here, then :q

Phase 8 — Mouse and selection

Changed: mouse.rs selection text extraction, mode/encoding queries via vt::.

Before reporting a copy/paste bug: ratty binds copy to Ctrl+Alt+C and paste to
Ctrl+Alt+V — not Cmd+C/Cmd+V or Ctrl+Shift+C/Ctrl+Shift+V. Those defaults are
pre-existing and untouched by this PR. Rebind with with = "super" in ratty.toml if you want
the platform-native chord.

  • click-drag to select; Ctrl+Alt+C to copy; paste elsewhere to verify content
  • select across a line with CJK and with combining marks — the copied text must match what's drawn
  • Ctrl+Alt+V to paste; check bracketed paste inside vim (insert mode, no auto-indent cascade)

Mouse reporting inside an app that uses it:

htop           # click column headers to sort; F10 to quit
tmux           # click to switch panes

Pass: selection matches rendering exactly (especially wide chars); clicks register on the right
cell with no horizontal drift.


Phase 9 — Protocol replies (new interception code)

Changed: vt::rewrite_reply filters rio-vt's DA1 capability list and rewrites the Rio identity
strings. This code is new in this PR and worth direct verification.

Save as query.sh and run it inside ratty (bash, not sh):

#!/usr/bin/env bash
query() {
  local seq="$1" reply="" ch saved
  saved=$(stty -g)
  stty raw -echo
  printf '%b' "$seq" > /dev/tty
  while IFS= read -r -s -n1 -t 0.3 ch; do reply+="$ch"; done
  stty "$saved"
  printf '%-12s %s\n' "$2" "$(printf '%s' "$reply" | cat -v)"
}
query '\e[0c'  'DA1'
query '\e[>0c' 'DA2'
query '\e[>0q' 'XTVERSION'
query '\e[5n'  'DSR'
query '\e[6n'  'CPR'

Pass:

Query Expected
DA1 ^[[?62;6;22cno ;4 (sixel) and no ;52 (OSC 52)
DA2 ^[[>0;500;1c500 is ratty 0.5.0, not rio-vt's version
XTVERSION ^[P>|ratty 0.5.0^[\must not say Rio
DSR ^[[0n — untouched
CPR ^[[<row>;<col>R — untouched, and matching the actual cursor position

The last two matter as much as the first three: they prove the rewriter isn't mangling replies it
shouldn't touch.


Phase 10 — Keyboard protocols

Changed: kitty flags now read from rio-vt's mode bits; modifyOtherKeys is tracked by a byte
sniffer over whole PTY reads, since rio-vt does not model it.

printf '\e[>4;2m'     # enable modifyOtherKeys level 2
# type Ctrl+Enter, Ctrl+Tab, Ctrl+Shift+letters
printf '\e[>4m'       # reset

Best real-world exercise — apps that negotiate the kitty keyboard protocol:

nvim           # then try Ctrl+Enter, Ctrl+/ , Shift+Enter in insert mode

Pass: modified keys reach the app; nothing is doubled or swallowed. Compare against main
this is the area where the mechanism genuinely got weaker (parser callback → byte sniffer), so it
deserves the most scepticism.


Phase 11 — ratty-specific: images, RGP, 3D

Changed: kitty.rs placeholder scanning is now gated on rio-vt's per-row flag; inline.rs cursor
anchoring goes through vt::cursor_position.

Kitty images (whatever you normally use):

# e.g. an image via the kitty graphics protocol, including a Unicode-placeholder placement

RGP inline objects, from protocols/graphics.md:

printf '\e_ratty;g;r;id=7;fmt=obj;path=CairoSpinyMouse.obj\e\\'
printf '\e_ratty;g;p;id=7;row=5;col=10;w=3;h=2;animate=1;scale=1.0;depth=1.5;ry=30\e\\'
printf '\e_ratty;g;u;id=7;ry=180\e\\'
printf '\e_ratty;g;d;id=7\e\\'

Then scroll the terminal with an object placed, and resize the window.

3D presentation modes:

  • Ctrl+Alt+Enter — orthographic 3D
  • Ctrl+Alt+P — perspective 3D
  • Ctrl+Alt+M — Möbius
  • Ctrl+Alt+Up/Down — warp
  • Ctrl+Alt+Shift+0..9 — camera slots

Camera via protocol:

printf '\e_ratty;g;c;id=0;set=1;type=Persp;fov=50;rx=10;ry=20\e\\'
printf '\e_ratty;g;c;id=0;set=1;type=Flat\e\\'

Pass: images anchor to the right cells and track the text as it scrolls (this is the code
path the new per-row placeholder gate touches — if the gate is wrong, anchors silently stop
updating); the 3D debug texture in Ctrl+Alt+Enter shows correct per-cell colours and attributes;
mode transitions are smooth.


Phase 12 — Degenerate sizes and stress

Changed: the grid floor is now 2 columns × 1 row (was 2×2). rio-vt panics placing a double-width
glyph in a single-column grid.

# drag the window as narrow and as short as it will go, with CJK on screen:
printf '你好世界\n'
# then resize to a sliver in both dimensions

Pass: no panic, no crash. This is the one place rio-vt is known to abort, so push it.

Throughput and font size:

time (yes | head -200000)
cat /dev/urandom | head -c 100000 | base64      # dense output
# Ctrl+= / Ctrl+- to change font size, Ctrl+Alt+0 to reset

Pass: no stall or runaway memory. Compare against main — the render path no longer
deep-copies the grid per call, so this should be no worse, and likely better under bulk output.


Sign-off checklist

  • Phase 1 automated checks green
  • Phase 2 built and tested on at least one platform other than the dev machine
  • Phase 4 — vim / less / htop / tmux with bottom rows and alignment correct
  • Phase 5 — combining marks render on their letters
  • Phase 7 — scrollback survives a horizontal resize (regression fix vs main)
  • Phase 9 — DA1 has no ;4 / ;52; XTVERSION says ratty
  • Phase 10 — modified keys work in nvim
  • Phase 11 — kitty/RGP objects anchor and track scrolling; 3D modes render
  • Phase 12 — no panic at minimum window size with CJK on screen

Open questions for the maintainer that testing cannot settle: whether +37 packages
(teletypewriter, corcovado, fuchsia-zircon, iovec, miow 0.5, windows 0.42) is an
acceptable cost, and whether losing X10 mouse mode (CSI ? 9 h) matters.

@raphamorim

Copy link
Copy Markdown
Contributor

1-4 ref: raphamorim/rio#1805

@gold-silver-copper
gold-silver-copper force-pushed the rio-vt-native branch 2 times, most recently from 9763e65 to fa0da2e Compare August 4, 2026 07:22
@gold-silver-copper

Copy link
Copy Markdown
Collaborator Author

Epic 😎

@raphamorim

Copy link
Copy Markdown
Contributor

@gold-silver-copper created a rio discord (again lmao), anything ping me there https://discord.gg/qTV8HmSRV

@raphamorim

Copy link
Copy Markdown
Contributor

fix event trait raphamorim/rio#1808

@raphamorim

Copy link
Copy Markdown
Contributor

rio-vt: Support X10 mouse reporting (CSI ? 9 h) raphamorim/rio#1809

@gold-silver-copper

Copy link
Copy Markdown
Collaborator Author

Rebased onto rio-vt 0.5.10 — 7 of the 12 upstream notes are fixed

Thanks @raphamorim, that was fast. Adopted all of it:

§ 0.5.10 What it replaced here
1 visible_line_bounds() the scroll-region truncation is gone at the source
2 Crosswords::keyboard_mode() hand-rebuilt Mode bit ordering
3 Crosswords::modify_other_keys() a raw-byte sniffer, now deleted
4 Grid::cell_text() manual extras_table plumbing
9 EventListener::event() removed dead impl
10 Mode::MOUSE_REPORT_X10 X10 mouse works again
12 1-column wide-glyph panic fixed grid floor back to one column

Net −159 lines, and the part I was least comfortable shipping is gone: the modifyOtherKeys
byte sniffer, carry buffer and state machine included. It also had a bug I hadn't caught —
rio-vt reports level 0 as None (disabled), while the sniffer reported Some(0), which ratty's
key encoder reads as enabled. So an application turning the mode off with CSI > 4 ; 0 m kept
getting modified-key encodings. Reading the engine fixes that by construction.

The cell_text() accessor is a good example of §4's point: the obvious code compiles and silently
drops combining marks, so having the correct path also be the shortest one is what makes it safe.

Re-verified after the refactor — a full buffer dump (symbol + fg + bg + modifiers, widths
104/40/20/14/9) is byte-identical to main on vt100. 100 tests, clippy -D warnings and fmt clean.

§1 note for Rio itself: snapshot_visible shares the old fill_visible_rows math, so if Rio's
renderer goes through that path it may still be affected — worth a check on your side.

Still open, in priority order for this integration:

  1. §5 — feature-gating teletypewriter/corcovado. Still the only item ratty can't work around,
    and the reason the dependency tree grows 658 → 695. No rush given you've said it may take a few
    versions; flagging that it's now the sole blocker of its kind.
  2. §6/§7 — DA1 advertising sixel and OSC 52, and XTVERSION reporting Rio. Worked around
    locally by rewriting those payloads in the listener, so not blocking, but deriving DA1 from
    compiled features and letting embedders set their own identity would let that code go too.

@gold-silver-copper

Copy link
Copy Markdown
Collaborator Author

I apologize for the AI autoposting replies lmao

Replaces vt100 with rio-vt as ratty's VT state machine, written directly
against rio-vt's API rather than behind a vt100-shaped compatibility
shim. `TerminalRuntime` now owns a `Crosswords` plus a `Processor`, and
the renderers borrow the grid instead of snapshotting it.

The new `src/vt.rs` holds the event listener, the screen-reading helpers
the renderers share, and the state rio-vt does not model. It is an
adapter, not a shim: nothing in it is shaped like a vt100 type.

Screen reads index the grid and borrow rather than calling rio-vt's
`visible_rows`, which deep-copies every visible row on each call. Ratty
reads the screen several times per frame and per PTY chunk, so the copy is
worth avoiding.

Deletes workarounds that existed only for vt100:

- `normalize_hvp_sequences`, which rewrote HVP to CUP; rio-vt handles HVP
- the snapshot/replay resize dance; rio-vt reflows and resets the scroll
  region natively
- the two-row grid floor; only the column floor is still required

Behavioural notes:

- Cursor visibility now comes from rio-vt's resolved `CursorState`, which
  also hides the cursor while scrolled into history and snaps the column
  off a wide-cell spacer.
- Cell text is the full grapheme cluster (base codepoint plus the extras
  table's zero-width marks), so combining marks and ZWJ sequences survive.
- Kitty placeholder scanning is gated on rio-vt's per-row
  `kitty_virtual_placeholder` flag.
- X10 mouse reporting (`CSI ? 9 h`) is supported.
- Both of rio-vt's spacer kinds are left blank. `LeadingSpacer` is the pad
  written at end-of-line when a wide glyph wraps; it carries the active SGR
  style, so drawing it painted a styled block there and defeated
  parley_ratatui's wide-cell heuristic, which only treats a trailing space
  as a continuation when it has no background. vt100 had no equivalent
  cell, so this restores buffer parity with the old engine.
- `modifyOtherKeys` and the kitty keyboard flags come from rio-vt's own
  accessors. Level 0 correctly reads as disabled, which the previous
  byte-sniffing workaround got wrong.
- Replies (DA, DSR, CPR, XTVERSION, kitty mode reports) now come from the
  engine, which owns the protocol framing. Three of them describe rio-vt
  rather than ratty, so the listener patches the payloads on the way out:
  the DA1 capability list drops sixel (`4`) and OSC 52 (`52`), which
  nothing here implements, and XTVERSION and the DA2 firmware field report
  ratty's name and version instead of Rio's. Parsed structurally, so a
  rio-vt release that changes its capability list is still filtered.
- The cursor reports column `width - 1` at end-of-line rather than
  `width`, matching alacritty-style pending-wrap semantics.
- The warn-once unhandled-sequence logging is gone. rio-vt logs via
  `tracing` and `bevy_log` installs the global subscriber, so its
  diagnostics still surface, without the per-sequence deduplication.

rio-vt is declared as 0.5.13. Upstream has since fixed most of what the
integration had to work around: the scroll-region-bound row accessor, the
missing kitty-keyboard and modifyOtherKeys accessors, the missing
cell-text accessor, X10 mouse reporting, the dead `EventListener::event`
method, and the abort on placing a wide glyph in a single-column grid.
Adopting those removes the byte sniffer entirely and drops the grid floor
back to one column. This grows the dependency tree from 658 to 695
packages,
including `teletypewriter` (an unused second PTY implementation, kept out
of the way -- `portable-pty` still drives the PTY) and `corcovado` with
its unmaintained transitive deps. See rio-upstream-asks.md.

Adds 33 tests. In `vt`: the scroll-region regression, combining marks,
wide-cell spacers, cursor visibility, scrollback clamping, resize reflow,
engine replies and their rewriting, mouse mode precedence, kitty keyboard
flags, colour resolution, and `modifyOtherKeys` including split sequences.
In `terminal`: the scroll-region and wide-character cases rendered end to
end through the ratatui buffer, one layer above where the vt tests stop
and where the defect actually showed. In `mouse`: selection text across
rows, wide characters, and combining marks, a path that was rewritten
here and previously had no coverage.

Co-authored-by: Raphael Amorim <rapha850@gmail.com>
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