`EditBuffer.measureText(text)` returns the editor offset at the end of a
text: its cells under the buffer's width method, tab width, and control
notation, plus one per line break. The result depends only on the text
and those three settings, but an app needed a live native `EditBuffer`
to get it. opencode passed a measure closure into otherwise pure helpers,
kept a spare detached `EditBuffer` for when no textarea was mounted, and
built a renderer and a textarea in tests only to measure strings.
`measureEditText(text, { widthMethod, tabWidth })` returns what
`measureText()` and the end offset after `setText()` give on an edit
buffer with those settings, without a buffer or an owner. `tabWidth`
defaults to the tab width of a new edit buffer and rounds as
`setTabWidth()` does. It calls the new `ot_edit_measure_text(context,
bytes, byte_count, width_method, tab_width, out_width)`. That entry point
checks the width method as `ot_unicode_create` does, rejects a tab width
over 255 and rounds it as `ot_edit_buffer_set_tab_width` does, applies
the byte limit and UTF-8 check of `set_text`, and leaves the output
unchanged on failure. Tab width 0 rounds to the tab width of a new
buffer, so TypeScript passes 0 for the default and keeps no second copy
of it.
Both entry points share one native function, `Context.measureEditText`.
It adds the width of each line between LF, CR, and CRLF, as
`textToSegments` splits them, and one per break. It does not build
segments, so neither entry point allocates any more. `validateTextBytes`
takes the tab width instead of a buffer.
Each native call needs a Context, so the library owns one for these
measures: `FFIRenderLib` creates it on the first call and releases it in
`dispose()`, as it does for audio engines. A module-level
`ResourceContext` would be smaller, but `dispose()` refuses while any
other Context is live, so one measure would make the public
`resolveRenderLib().dispose()` fail with no way to release that Context.
No test or tool otherwise checks for leaked Contexts. Creating the
Context and measuring need no Yoga mutation and use read admission, so a
measure works in input handlers, during render, and in a layout
callback, as `EditBuffer.measureText()` does.
A differential table in `edit-buffer.test.ts` runs under Bun and Node.
For every public width method and the tab widths default, 0, 1, 3, 8,
and 255, it measures C0 controls, DEL, C1, tabs, CJK with a combining
mark, ZWJ, skin-tone, and flag emoji, CR, LF, CRLF, mixed lines, a CRLF
across the 16-byte blocks of `findLineBreaks` before a long line, and the
empty string. `measureEditText()`, `measureText()`, and the end offset
after `setText()` agree on each. New rows in the native malformed-argument
table cover a null span, malformed UTF-8, an unknown width method, tab
width 256, a null output, and one byte over the limit; one byte less
measures the widest tabs within u32. The dispose test measures first in
a Yoga callback, keeps the measure Context through a refused dispose,
and releases it in the next. Hidden controls, CRLF as two breaks, LF-only
breaks, no break count, no tab rounding, a wrong default, each removed
argument check, write admission, an `assertMutable()` guard, and each
dispose change make a test fail.
The native renderer writes each changed run of cells as a cursor move,
its colors, its attributes, and the text, and writes `ESC[0m` when an
unchanged cell or a style change ends the run. A run that ended in a
row's last column got no reset. The next run, on a later row, sets only
its colors, so a bold, dim, italic, underlined, blinking, inverse,
hidden, or struck-through cell at the right edge leaked into the first
cells drawn after it. @FasterPHP reported this with exact bytes in
JetBrains and Zed terminals; any terminal behaves this way, because the
renderer only turns attributes on and relies on the reset to turn them
off.
The frame diff now closes the open run after each row when that run set
an attribute. A run always writes both colors, so a run without
attributes cannot leak and plain frames keep their size; a row that ends
in an attributed run costs 4 bytes more. This also covers a wide glyph
whose continuation fills the last column, rows skipped because they did
not change, and forced full draws. The `runStart` state always matched
`runLength == 0`, so it is gone. The split-footer and scrollback writer
already reset after every row and is unchanged.
A table test feeds the renderer's output into Ghostty's VT and checks
that the next run's cell has no attributes, for each attribute, a run
ending before the last column, in it, or with a wide glyph that fills
it,
a next run on the next row or after an unchanged row, and diff and
forced frames. It fails without the reset, with a reset keyed on the
last printed column, and with a reset only in diff frames.
Fixes#1633
When two inserts put contiguous text in the editor's rope, the join gave
the merged chunk the sum of the two widths. A later part can continue
the grapheme that the first part ends with: a skin-tone modifier, a ZWJ
sequence, Hangul jamo, U+FE0F. Terminals send such an emoji as one
chunk and the key parser turns it into one key per code point, so
typing 👍🏽 in a Textarea stored 4 cells for a 2-cell emoji. The cursor
landed at column 4, the line was 2 cells wider than drawn, it wrapped
early, a selection by columns ended one character short, and Right
could not get past the emoji.
The rope now merges two chunks only through a merge callback that the
edit buffer passes with its split callback. The callback measures the
joined bytes with the buffer's tab width, width method, and control
display, the same as layout and `setText`. Rope operations without the
callback no longer merge; none of their callers joins contiguous text.
`insertText` and an empty-range replacement take the cursor column from
the rope weight, because a merge changes the width of the text before
the insert.
A differential table in `edit-buffer_test.zig` types each row in parts
and compares it with the same text set at once, for every width method:
the text, the width of every chunk against its bytes, the cursor after
typing, and every Right stop. Rows cover skin tones, ZWJ sequences,
regional indicators, Hangul, VS16, combining marks, Thai, a control,
line breaks, and merges before existing text and on a later line. It
replaces the segment-merge example tests that it covers. Summing the
widths again, measuring with hidden controls, dropping the merge, or
moving the cursor column or offset by one in either insert path each
makes a row fail.
Fixes#1644
An embedded terminal did not redraw rows already on screen when the
child
changed the default foreground or background (OSC 10/11, reset with OSC
110/111) or toggled reverse video (`CSI ? 5 h` / `l`). Those rows kept
the
old colors until the child wrote them again or the host called
`invalidate()`, so a visual bell that flashes reverse video did not
show,
and since default colors are now always set, a bell that coincides with
output could leave the screen reversed.
Ghostty's library changes these colors and the reverse-video mode
without
marking any row dirty, and the compositor redraws only dirty rows.
Compose
now compares the render state's default foreground and background before
and after the update and does a full redraw when either one changes.
Ghostty applies reverse video to these two colors, so one comparison
covers
every trigger, in opaque and transparent-background mode. Palette
changes
(OSC 4) and RIS already force a full redraw. A compose with no color
change
does no extra work.
Tests: the embedded terminal sentinel test gains a table of writes (OSC
11,
OSC 10, reverse video on and off, OSC 110, OSC 111, OSC 4, RIS, and two
writes that change no color) that checks the composed colors without
`invalidate()` and whether every row was redrawn. Reverting the fix,
dropping either color comparison, forcing a full redraw on every
compose,
or promoting a partial redraw to a full one fails the table.
Fixes#1647
A combining mark in another rope chunk than its base glyph was not
drawn. Edits, `TextBuffer.append`, and streamed text split chunks, so
typing U+0301 after `abc` in an editor showed `abc`, while the text held
`abc\u0301`. A chunk of only marks has no columns, and the draw loop
stopped at the chunk's last column, so it never drew the mark. A mark
that starts a chunk with more text was dropped when its base was in the
last column of the target, the viewport, or the scissor, because those
checks ran on the mark's column.
The draw loop now walks the chunk's bytes, and a zero-width cluster
joins the glyph this draw wrote before it as soon as it is read. That
glyph already passed the edge and scissor checks. The skip for chunks
past the right edge waits while such a glyph can still take marks. A
mark with no drawn glyph before it is still dropped: at line start,
after control notation, after a base that is scrolled out or clipped,
and after a truncation ellipsis, which now never takes a mark. A
truncated line's prefix keeps a mark-only chunk that follows its last
glyph. A mark that would change the cluster's width, such as U+FE0F
after a 1-cell glyph, is dropped instead of putting a 2-cell emoji in a
1-cell slot.
The test that attaches marks is now a table. Each row draws split chunks
and the same text in one chunk, through `TextBuffer.append` (controls
hidden) and through editor inserts (controls shown as notation). Rows
vary the x offset, viewport, scissor, wrap, truncation, selection, and
opacity, and name the cell that must hold the mark. Each of eleven
plausible breaks of the change (column loop, chunk skip, attach order,
base reset, ellipsis, prefix, width check) fails a row. In random tests,
split chunks now draw the same as one chunk in every case except joiners
(ZWJ, U+FE0F that widens a glyph), word wrap, and one truncation prefix
shape that main also has.
Fixes#1646
Keep controls in editor text and render them as safe, visible notation.
This lets the cursor, selection, wrapping, and offsets handle controls
consistently without sending them to the terminal. Strip terminal escape
sequences from pasted text so pasted controls cannot change terminal
state.
Alternative to #1632 and aligned with how vim does it.
We accidentally in pre 0.6 and explicit in 0.6.x rejects edit text that
contains a C0 control other than tab, carriage return, and line feed,
DEL, or a C1 control: `setText()`, `insertText()`, `replaceText()`,
Textarea `initialValue`, and `Input.value` throw `NativeError` with
`InvalidArgument`. The reason is that the editor places its cursor by
display cell, and a control took no cells, so the cursor could neither
move past it nor delete it. In 0.5.14, which accepted controls, the
cursor in `"a\fb"` could not reach column 0, Backspace stopped at
`"a\f"`, and rendering `"A\x07B\x1bC\fD"` wrote raw BEL and ESC bytes to
the terminal while `C` and `D` vanished.
This is an alternative to #1632, which drops these characters. Instead,
editors keep them and show them the way Vim and Neovim do. Measured with
Neovim 0.12.5 and Vim 9.2, both show `a^Lb^@c^[d<85>e^?f`: a C0 control
or DEL as caret notation in 2 cells, and a C1 control as hex in 4 cells.
The cursor stops on each one, `x` deletes one, and writing the file back
gives identical bytes.
Editors now do the same. A per-buffer `ControlDisplay` setting is passed
with the tab width to the native width functions, so cursor movement,
deletion, wrapping, selection, history, and drawing share one rule:
- C0 controls other than tab, LF, and CR, and DEL, show as `^@`..`^_`
and `^?` in 2 cells. C1 controls show as `<80>`..`<9f>` in 4 cells. Tab,
LF, and CR keep their current behavior.
- Cells still never hold a raw control: drawing writes the notation's
printable glyphs.
- Each control is its own grapheme, so it no longer joins the next
character, and a soft wrap never splits its notation.
- `getText()`, undo and redo, and selection copy return the original
bytes.
- Read-only text keeps hiding controls with zero width, so
`TextRenderable`, the console, and other displayed text do not change.
Only `EditBuffer` and its placeholder use notation.
Text input changes with it:
- Paste keeps controls, as Vim does, and still removes escape sequences.
The stripper is now one shared implementation, so Bun and Node give the
same result, and core no longer depends on `strip-ansi`. A lone ESC and
a truncated sequence are removed, so a paste never inserts ESC. Key
sequences with a control stay ignored, so Escape does not insert `^[`.
- `Input` puts its cursor at the native end of the value instead of the
string length.
- Extmarks convert display offsets through native `offsetToPosition`
instead of JavaScript string widths, and Right stops at the native end
of the buffer before a virtual extmark. `deleteLine` adjusts extmarks by
native line offsets.
- `EditBuffer.measureText(text)` and `ot_edit_buffer_measure_text`
return the display offset at the end of a text as the buffer would lay
it out, without storing it. JavaScript width functions give a control
and a tab no cells, so an app that computes editor offsets in JavaScript
needs this: in `"\f@fi"` the `@` is at offset 2, not 0.
Along the way, these bugs that already exist in are fixed:
- A standalone zero-width cluster (zero-width space, word joiner, BOM)
blocked Left and Backspace in the Unicode width methods.
- After `"日\u{fffd}"`, Backspace deleted the wide character. In the
no_zwj method, one emoji step had the wrong size.
- U+FFFD inside a joining sequence, such as `"क्\u{FFFD}\u{301}ष"`, made
layout and cursor movement disagree: Right got stuck, and Backspace
deleted the wrong cells.
- An extmark ending at a line end after a tab lost its last cell. Right
could step into a virtual extmark after CJK text. `deleteLine` moved
extmarks by UTF-16 indexes. `Input` placed its cursor in the middle
after CJK text.
Backspace no longer measures the whole chunk for each step. The native
"backspace 500 chars" benchmark takes 0.21 ms instead of 25.3 ms on
v0.6.0; other benchmarks match v0.6.0 within noise.
Most of the diff passes the new setting to the width functions: of the
added lines, most only add `.hidden`, `.notation`, or `control_display`,
and others of them in existing tests. Net source is +86 lines and net
tests +95.
Not included: a distinct style for the notation (like Vim's SpecialKey),
a literal-insert key, `^M` for a lone CR, controls as word boundaries,
and clipping wide units cell by cell at the left edge of a horizontally
scrolled view.
Fixes#1642
An app started inside an SSH or mosh login runs in auto-detected remote
mode. In that mode, native detection
returned before it read `TERM` and `COLORTERM`, so neither `ansi256` nor
`rgb` was ever set, and the renderer sent
truecolor SGR to every remote terminal. A 256-color endpoint such as GNU
screen 4.x then showed the UI without
color, while the same `TERM` worked locally.
This supersedes #1492 by @yurial and keeps its commit as authored. The
fix is that commit's design: read the color
depth keys (a `TERM` that contains `256color`, a `COLORTERM` of
`truecolor` or `24bit`) before the auto remote
return, and keep the return for the identity keys such as
`TERM_PROGRAM`, the multiplexer variables, and the
`OPENTUI_*` overrides, which can describe the wrong endpoint on the
process host. The later commits port its
renderer test to the current test fixtures (it used the pool API that
#1479 removed, so `test:native` did not
compile), read the two keys in one place instead of through a helper
called on both sides of the return, and fold
the tests into one table. Local and explicit remote mode, which
`@opentui/ssh` uses, give the same result as
before. Color depth values in `environment` now take effect in auto
remote mode too; the other values still need
`remote: true`, and the terminal capabilities docs now say so.
There is a trade-off. A truecolor terminal that reaches the server with
`TERM=xterm-256color` and no `COLORTERM`,
for example iTerm2, WezTerm, or Ghostty over a stock macOS ssh client,
used to get exact colors by accident and
now gets 256-color approximations, which is what it gets locally. A
Kitty XTVERSION reply still turns on `rgb`.
The docs explain how to keep truecolor: export `COLORTERM=truecolor` on
the server, forward it with
`SendEnv`/`AcceptEnv`, or pass it in `environment`.
A table in `terminal_test.zig` runs the same `SSH_CONNECTION`, `TERM`,
and `COLORTERM` rows through local, auto,
and explicit remote mode, with both an `env_map` passed at init and
forwarded `setHostEnvVar` values. The renderer
fallback test now gets `ansi256` from an auto remote forwarded `TERM`
and checks for `38;5` output, and the
remote TypeScript tests check `ansi256` and `rgb` from forwarded and
explicit values. On main the forwarded
auto remote rows and these tests fail. Moving the checks back after the
return, dropping the `TERM` check or `24bit`,
accepting any `COLORTERM`, reading only forwarded maps, matching
`256color` only as a suffix, or deleting the
return each makes at least one test fail.
Fixes#1491
---------
Co-authored-by: Yuri Dyachenko <pub.yurial@gmail.com>
A Context's native object table had a fixed size of `object_capacity`
slots, and a renderer creates its Context with 65,536. Every Markdown
table cell holds a text buffer and a view, so a long document or chat
transcript failed after about 32,500 cells with
`ot_text_buffer_view_create failed: ObjectLimit`, whether it was set at
once or streamed. Plain renderables hit the same limit at 65,534.
The table now starts at `object_capacity` and doubles when it is full,
up to 4,194,304 slots, or `object_capacity` if that is larger. Past
that, creation still returns `ObjectLimit`. Handles keep their slot
index and generation, so existing handles stay valid, and no code holds
a pointer into the slot array across an insert. The cap comes from
memory, not the handle format: a slot is 32 bytes, so the cap is 128
MiB, and each live slot owns a much larger native object. There is no
ABI change. The comments in `opentui.h` and the docs now call
`object_capacity` the initial size. A clipboard start that fails to grow
the table reports out of memory instead of limit exceeded.
The handle-table model test starts at 2 slots and checks growth to 4 and
5, old handles, stale handles, and `ObjectLimit` at the maximum. A
Markdown test renders a table that needs more objects than a 64-slot
renderer, and it fails on main with the issue's error. Tests that check
cleanup at `ObjectLimit` now pin the maximum. Reverting growth, the
doubling, the clamp, the `>=` maximum check, or growth inside `insert`
each fails a test.
Fixes#1611
A highlight that reached the end of its line also colored text that
later
grew the line. TextBuffer.append, EditBuffer inserts and undo, and a
line
that setText emptied and that then grew again all drew the new cells in
the
highlight's style, while getLineHighlights still reported the old range.
Styled text did the same when its chunks covered the whole line. The
cause
is that the last style span ended at the line width, and the renderer
keeps
the last span for every later column.
Span building now always ends a highlighted line with a default-style
span
that runs to the end of any line, so the spans depend only on the
highlights
and stay right when the line grows without a rebuild. The line width is
no
longer needed to build spans, so its computation goes away from the
callers.
The styled-text span builder closes its spans the same way.
A new native table compares the spans after append, insert, setText("")
followed by append, and replaceText("") followed by undo with a fresh
build of
the final text; it replaces two duplicated span tests. Dropping the
closing
span, shortening it, or reverting either builder each makes a test fail.
Fixes#1612
An empty Textarea whose placeholder takes more than one row let Down
move the cursor off the text. That happens when the placeholder has a
newline, or when it is wider than the Textarea and wraps (the default).
While the text is empty, the view lays out the placeholder instead of
the text, and Down read a row and column of the placeholder as a cursor
position in the empty buffer. The next edit (typing, Enter, Backspace)
then failed with `InvalidArgument`. In a Textarea the key handler only
logs that error, so typing looked dead until the user pressed Left.
Code that calls `moveDownVisual()` and then `insertText` got the
exception.
Down now does nothing while the placeholder is shown, because an empty
buffer has one position. The check is part of the existing row-count
check in `moveDownVisual`. Up already stops at the first visual row,
which is the only row the cursor can have then.
The vertical-move table in `editor-view_test.zig` gets rows for an
empty buffer with a two-line placeholder and with a wrapping
placeholder: they move up and down, check that the cursor stays at
(0, 0), and then insert text. One row with text now also has a
placeholder, so Down must still move when the text is not empty. The
seeded Textarea undo/redo model now uses a placeholder that wraps and
has two lines. It presses Down on an empty buffer and fails on main as
soon as it types. Removing the check, making it depend on whether a
placeholder exists instead of whether it is shown, or changing `or` to
`and` each makes a row fail.
Fixes#1609
In split-footer mode, native keeps appended output above the footer with
a temporary scroll region (`ESC[1;Nr`, N = rows above the footer). With
one row above the footer it sent `ESC[1;1r`, which terminals ignore,
because a scroll region needs two rows; with no row it sent no region.
Each CRLF on row 1 then moved into the footer and erased it, later
frames
did not repaint it, and nothing reached the scrollback. The default
footer height (12) hits this in any terminal of 13 rows or fewer, for
example a small tmux pane or after a resize.
With fewer than two rows above the footer, native now uses the region
`1;2`, which borrows the footer's top row, sends one more `ESC D` after
the payload so that the last line is back on row 1, and repaints the
footer. When a first row wraps onto the borrowed row, the existing
pre-scroll also clears that row. With no row above the footer, a row
that
fills the width ends there and scrolls into the scrollback, and the next
commit starts at column 1. A one-row terminal skips the extra `ESC D`.
One case stays lossy: when the footer covers the whole terminal, a write
shorter than the width and without a newline has no row to stay on, so
it is lost if a later write continues it.
The screen table in `renderer.native-scene-split.test.ts` gets rows with
a terminal size: panes of two rows, one row (also under the default
footer and in a two-row terminal), no row, a one-row terminal, and a
footer that shrinks after a lost partial row. They write captured lines,
a two-row writer snapshot, a wrapping first row, and a full row, then
grow the VT with the cursor on the bottom row so the scrollback shows
above the screen. The native ordering test runs at pinned offsets 2, 1,
and 0. Reverting the region, dropping the extra `ESC D`, the footer
repaint, the borrowed-row clear, the full-row end, the tail reset, or
the
one-row guard each makes at least one row fail.
Fixes#1607
An embedded terminal never answered OSC 10 or OSC 11 color queries, so
programs such as Neovim that query the background to choose a dark or
light scheme had to guess. A child that set only one default color was
also ignored: after `OSC 11 ; rgb:ff/00/00` the background stayed black
until an OSC 10 set the foreground too.
The terminal was created without default colors, so Ghostty left its
dynamic foreground and background unset. An unset color has no query
reply, and the render state copies the terminal colors only when both
are set. Native now passes the colors the compositor already used when
they were unset: a black background, a white foreground, no cursor
color (the cursor follows the foreground), and the default palette. As
in xterm and Ghostty, OSC 12 and Kitty OSC 21 color queries now reply
too, and OSC 110/111 reset to white and black.
Reverse video (`CSI ? 5 h`) now also takes effect without OSC 10/11;
this makes nothing worse for shells, Neovim, less, or htop by default,
but rows already on screen are not redrawn when it changes, which shows
only with an opt-in pausing visual bell or a program that leaves reverse
video on, and a follow-up fixes that.
Tests: the PTY response table gains OSC 10 (BEL) and OSC 11 (ST) query
rows; the transparent-background test sends OSC 11 alone and checks the
exact opaque and transparent background; the cursor state test sends
OSC 10 alone and compares the whole cursor, including its color. Leaving
the background, foreground, or cursor default unset or seeded wrongly
fails at least one of them.
Fixes#1605
Store positions in a fixed array indexed by marker type. Marker types
are
known at compile time, so a hash map and per-node marker counts add
overhead
without improving lookup behavior.
https://opentui-bench.fly.dev/investigations/3
native: drop highlights on removed lines when replacing text
setText kept user highlights on every stored line, also on lines past
the
new line count. getHighlightCount and getLineHighlights reported them,
and
text that grew back into such a line (append) was colored by them.
setPlainText now builds the kept highlight lists during preparation, for
lines below the new line count only, and swaps them in when it publishes
the text, like the styled-text path. The lists past the new end are
freed
with the old storage. This replaces the in-place internal-highlight
filter.
The fix covers setText, setTextFromMemId*, setTextFromMemRangeWithUndo
(EditBuffer.replaceText), and replaceText (EditBuffer.setText).
Source additions (net -5 lines): the staged highlight lists and their
cleanup hold the kept highlights until publication, and one comment
states
the line-count bound.
Fixes https://github.com/anomalyco/opentui/issues/1588
Up and Down kept the desired display column and committed it as is. When
the target line had a wide character (CJK, emoji) or a tab at that
column, the cursor landed on its second cell: the logical moves
(`moveUp`/`moveDown`), the visual moves that Textarea's arrow keys use
(`moveUpVisual`/`moveDownVisual`, also Shift+Up/Down), and
`setViewport(..., moveCursor = true)`. The terminal cursor was drawn in
the middle of the character, and typed text went in front of it while
the cursor moved on to the next mid-character column.
Each of these now passes its column through `EditBuffer.snapColumn`,
which returns the start of the cursor unit under the column. Left/Right
and mouse clicks already snap to that start. The desired column stays
unsnapped, so the cursor returns to it on the next line that has a
boundary there. A cursor unit never spans two virtual lines, so the
snapped column stays on the target visual row.
Source additions: `snapColumn` is the one shared snap (3 call sites);
`moveToRow` replaces the two copies of the move body in `moveUp` and
`moveDown`; `makeCursorVisible` keeps an unsnapped desired column in
its own variable.
Tests: one table in `editor-view_test.zig` replaces seven vertical-move
tests (each old assertion is a row, most now exact) and adds CJK, tab,
skin-tone and ZWJ emoji rows for both width methods, the
https://github.com/anomalyco/opentui/issues/1289 lines,
a wrapped https://github.com/anomalyco/opentui/issues/1289 line, and
`setViewport(moveCursor)`. The seeded
Textarea model test presses Up/Down and requires a grapheme boundary.
The Textarea Shift+Up/Down tests become one table with the
https://github.com/anomalyco/opentui/issues/1289 rows
in both selection occupancies. One `editor-view.test.ts` assertion
pinned the mid-emoji column 8; it is now 7.
Issue: 001
Fixes https://github.com/anomalyco/opentui/issues/1289
A width-only rewrap in word mode reset the virtual-line arena with
retain_capacity, so the arena kept the capacity of its largest layout
until the next edit. A 94 KiB text wrapped at width 1 and then at width
80 still held about 46 MB; char wrap returned to about 3 MB.
Reset the arena with the same text-bounded limit in every mode. The
width-independent word layout cache is still kept across width-only
changes. Rewraps at normal widths still reuse the arena without new
allocations; a squeeze to width 1 and back now costs 3-4 allocations
per cycle, as in char mode.
The rewrap test now runs in char and word mode and checks the arena
capacity after width 1 and then width 80. The test grows by one line,
the
mode loop; the capacity check is a new observation for word mode.
Issue: 011
Fixes#1589
In split-footer mode native records the column where the last scrollback
row ends, so the next inline commit (captured stdout without a newline,
or a writer or surface with startOnNewLine: false) continues on that
row.
snapshotRowEnd trimmed trailing continuation cells as if they were
empty, so a row that ended in a wide character ended one cell early. The
next commit started on the wide character's second cell, and the
terminal erased the character: the writes "你好", "世界", "!\n" showed
as "你 世 !". A row that filled the width with a wide character was
treated as one cell short, so the next row started on the last column
instead of a new line, and the footer sat one row too high. The
pre-scroll before a first row that wraps used the same short width, so a
writer row on a background color that ended in a wide character could
still fill the wrapped line with that color.
snapshotRowEnd now trims only empty cells. A continuation cell belongs
to
the wide character before it, and writeSnapshotCommit writes nothing for
it, so the commit bytes do not change. The end now matches the
TypeScript prediction (getSplitTailColumnAfterCommit), which already
counts the full cell width. The orelse arm goes: both callers pass a
limit within the snapshot width and a row within its height, so the
lookup cannot fail.
Source additions: a doc comment that says why continuation cells count,
and the loop and return that replace the 15-line body.
The screen table gains a captured line continued after wide characters
(["你好", "a世界", "x\n"]: the second write continues mid-row, the
third starts after a full row that ends in a wide character). It
replaces the ASCII "continued" row and still fails when the first erase
widens to ESC[2K. The pinned row gains a red writer row that ends in a
wide character, and pins with blank lines so that it stays on one line;
its top rows scroll out. Both rows fail on main. Reverting the trim only
where the model publishes the row fails the first; reverting it only in
the pre-scroll test fails the second.
Issue: 014
Fixes#1583
Ghostty's key encoder treats macOS Option as a text modifier unless
macos_option_as_alt is set, and Options.fromTerminal always clears it.
On macOS the embedded terminal therefore dropped the ESC prefix for
legacy Alt keys (Alt+b sent b), cleared Alt for modifyOtherKeys, and let
Alt keep associated text in Kitty report-associated mode. The host
terminal has already applied its own Option setting before a key reaches
OpenTUI, so an Alt bit in an embedded terminal key means Alt. Set
macos_option_as_alt to true; on every other OS the field is compiled out
of these branches, so only macOS changes, and it now matches Linux.
Source +3: export KeyEncodeOptions so the encoder options can be
overridden after fromTerminal; one line to set the field; one comment.
The core Alt conformance rows are the check; they reach this branch only
on the macOS CI runners.
Issue: 066
EmbeddedTerminalRenderable never answered Primary, Secondary, or
Tertiary Device Attributes (`CSI c`, `CSI > c`, `CSI = c`). Ghostty
answers them only through its `device_attributes` effect, and the
embedded terminal set only `write_pty`. Programs that send DA1 last in a
batch of queries and wait for its reply, such as fish 4.1 and later at
startup, stalled for about two seconds and then printed a warning.
The embedded terminal now sets the effect to Ghostty's default
attributes, so DA1 replies `ESC[?62;22c`, DA2 `ESC[>1;0;0c`, and DA3
`ESC P!|00000000 ESC\`, through the same bounded response queue as DSR.
The PTY response drain test is now a table of query and reply, with the
existing DSR row and one row each for DA1, DA2, and DA3.
Source additions: the effect assignment and its 3-line callback are the
fix; the 2-line alias in ghostty.zig names the attributes type, which
lib-vt does not export. Test lines grow by 3: the three new rows, with
the old buffer-copy lines removed by draining into one buffer.
Issue: 003
Fixes#1584
`setInternal` pinned `new_link_id` on entry before checking whether the
old or new cell was a grapheme cluster, forcing a stack frame and
register spills on every scalar cell write. Handle non-cluster cells
directly in `setInternal` and move cluster span cleanup to a cold
helper, and draw printable ASCII in `drawTextChecked` without copying
the input or building prepared runs.
| Commit | Run | Avg time |
|---|---|---|
| `31a93fbe6` (baseline) | 793 | 67.26 µs |
| `c1bb64ba1` (regression) | 794 | 77.26 µs |
| `70b56b8ed` (this PR) | 795 | **49.89 µs** |
https://opentui-bench.fly.dev/investigations/2
In split-footer mode, native wrote `ESC[K` after each scrollback row to
clear what was left to its right. After a row that ends on the last
column, the cursor stays on that column with a wrap pending, and `ESC[K`
erases from the cursor, so terminals such as Ghostty removed the row's
last character. Captured stdout splits long lines into rows of the full
width, so every line longer than the terminal lost one character at each
wrap point, and a line of exactly the width lost its last one. Writer
snapshots and surfaces that fill the width lost it too.
Native now erases before each row instead: once where the commit starts,
then after each CRLF. That erase also clears the row that a trailing
newline makes current, so the separate erase for it goes. A row that
fits clears the same cells as before.
The old erase after a row also reset the rest of the line that a row
wraps onto. When a row continues mid-line at the pinned bottom and
wraps, the wrap scrolls that line in, and terminals with background
color erase fill it with the background of the cell that wraps, so a
writer row on a background color left that color across the rest of the
line. When the first row of a commit does not fit in the rest of its
line, native now scrolls the next line in first, with default
attributes, and moves back up (`ESC D`, `ESC[A`), so the wrap moves onto
a line that already exists. Nothing is erased after a row, so no cell
can be lost at a pending wrap, and the number of scrolled lines does not
change.
A table in `renderer.native-scene-split.test.ts` feeds the renderer's
terminal bytes into libghostty-vt (`EmbeddedTerminalRenderable`) and
compares the screen: captured lines shorter than, equal to, and longer
than the width, with ASCII and wide characters, a line continued mid-row
over old text on the start row, and writer rows on a red background at
the pinned bottom, whose runs show in brackets. A footer marker on the
screen checks the row count that the table used to read from
`renderOffset`, and the full-width rows fail on main with the last cell
missing. Deleting the first erase, widening it to `ESC[2K`, moving it
back after the row, deleting the pre-scroll, dropping its `ESC[A`, or
changing its test to `>=` or to the snapshot width each makes at least
one row fail.
Other split-footer defects found along the way are not part of this PR;
they will be filed separately.
Fixes#1580
TypeScript used to own the renderable tree. Each frame walked it in
JavaScript, read layout back from Yoga, and made one FFI call per draw,
so frame cost grew with node and draw count. Native objects lived in
global registries and crossed FFI as raw pointers, which made renderer
teardown fragile.
A Context-owned Scene now holds the retained tree, its Yoga nodes, and
its paint state. Renderables stage property changes and flush them in
one call per frame. Zig lays out, culls, and paints the built-in
widgets, fills the hit grid, and hands the frame to Session for the
terminal diff. A frame without paint hooks completes in one step call.
Paint hooks run in one batch before painting and record into a byte
stream that native replays in paint order, so painting never pauses
for JavaScript and a hook cannot tear the frame.
Every native resource sits behind a generational handle, so a stale
handle fails with an error instead of touching freed memory, and
destroying a Context releases everything it owns.
Without OSC 8 the terminal detects URLs one row at a time, so a wrapped
link opens only the part on the clicked row. Enable hyperlinks for
xterm.js (XTVERSION), TERM_PROGRAM vscode/zed/mintty, and VTE 0.52.2 or
newer. TERM_PROGRAM and VTE_VERSION count only outside a multiplexer and
are revoked when one is detected later. FORCE_HYPERLINK overrides
detection; 0, false, or off disables hyperlinks.
Negative JavaScript coordinates arrive through Bun FFI as wrapped u32
values. Reject them before signed conversion so off-screen blended
writes
cannot overflow or address an invalid cell.
Fix#1552
Rope nodes live in the text buffer's arena and are never freed one at a
time. setText and setTextFromMemId cleared the rope and built the new
document in the same arena without resetting it, so every call kept the
whole previous rope. A 2000-line text grew RSS by about 1.24 MB per
call; 2000 calls reached 2.5 GB. EditBuffer.setText leaked the same way
even though it clears undo history first. setStyledText did not leak
because it reset the arena itself.
clear() now resets the arena when the rope has no undo or redo history,
so nothing can reference the old nodes. With history it still clears in
place. The arena keeps its capacity, as setStyledText's reset did, so a
document of similar size reuses the same pages. After the reset the rope
points at a static empty document, so the release never allocates and
running out of memory cannot leave the rope dangling. reset() and
setStyledText use the same release instead of resetting the arena and
rebuilding a rope that could fail after the old one was freed.
loadFile stored the file bytes in the rope arena while the memory
registry pointed at them, so a reset would hand those bytes to the next
rope. The registry now owns them.
#1543
Baseline x86-64 has no rounding instruction, so @round in
rgbaComponentToU8 called roundf for every channel of every blended,
filtered or faded cell. Values are clamped to [0, 255], so truncation
plus
an exact fraction test gives the same result.
The gain benchmark process drops from 37.3 to 19.6 billion user-space
instructions and the colormatrix benchmark from 23.9 to 13.7 billion.
OptimizedBuffer.resize reallocated all four cell arrays on every size
change. When a reallocation moved, it copied cells that clear() then
overwrote. If one reallocation failed, the arrays were left with
different lengths while width and height kept the old size, so a failed
shrink left a cell array shorter than the buffer's cells.
Resize now reuses the arrays while the new cells fit and the capacity
stays within half again the cells. Growing past the capacity reserves
half
of it again, so a buffer that grows a row at a time or alternates sizes
reallocates rarely. A larger jump or a shrink takes the exact cells. All
four arrays are allocated before any is freed, so a failed resize leaves
the buffer unchanged.
Inside tmux, startup probes went through DCS passthrough. The outer
terminal's replies carry no pane identity, so a pane switch before a
reply delivered the bytes to the newly active pane as input.
Send all probes unwrapped; tmux answers the ones it implements for the
pane that asked. Skip the Kitty graphics and DA probes under tmux,
remove the passthrough retry after late XTVERSION detection, and ignore
graphics replies once tmux is known.
tmux before 3.6 does not answer DECRQM, but it has reported pane focus
for mode 1004 since 1.8, so enable focus tracking whenever tmux is
detected.
An explicit Sixel protocol is now honored under tmux through
passthrough, like explicit Kitty. Automatic selection still uses
blocks.
Closes#1459
When a scrollback snapshot starts in the middle of a line, its first
fragment continues text already written to the terminal. Keep it flush
with the preceding tail so rendering and hit testing identify the same
cells.
Make named and bare links clickable without exposing their destinations
when the terminal supports OSC 8. Preserve visible URL fallbacks
elsewhere and keep links intact across formatting, streaming, wrapping,
and late terminal capability detection.
Fix#1439