Files
opentui/packages/native
Simon Klee 542275a354 editor: show control characters as notation (#1636)
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
2026-10-11 21:15:07 +02:00
..
2026-10-10 22:02:51 +02:00

Native API

OpenTUI uses Context-owned scenes and Sessions for production rendering, including text, editors, custom paint hooks, images, detached surfaces, and split output. Standalone resources use the same checked ownership model without a terminal.

Read Resources and ownership for resource bindings, text copy units, and scoped framebuffer access. Frames and output defines mutation admission, painted drafts, and transport completion. How Core uses native explains TypeScript staging and resource ownership. Host I/O and time defines Context file operations, Session deadlines, and native diagnostic clocks.

API surfaces

  • src/opentui.zig is the public Zig module. It exports the checked Context API and raw Zig primitives, including CliRenderer, NativeRenderable, OptimizedBuffer, text buffers, and pools. Callers manage the lifetimes of raw primitives. Importing a raw primitive does not add Context checks to it.
  • include/opentui.h defines the checked ot_* C ABI for Contexts, Sessions, scenes, drawing, text, editors, styles, leases, clipboard, and diagnostics. The ABI is experimental and at version 1. Core renders through it.
  • ../core/src/zig.ts supplies TypeScript wrappers over that checked ABI. Its checked signatures, callbacks, constants, and record layouts come from native-abi.generated.ts.

ABI generation and builds

Use C headers and libraries from the same revision. Set each versioned record's exact struct_size and abi_version, and leave unused flags and reserved fields zero. Follow the output and failure contract of each operation in the header.

From packages/core:

bun run generate:abi
bun run check:abi
bun run test:abi

scripts/native-abi.ts uses Zig Translate-C and scripts/native-abi.zig reflection to derive scalar widths, signatures, callback types, constants, record sizes, alignment, and field offsets from the header. Pointer nullability, retention, address fields, and portable buffer/ptr policy live in scripts/native-abi-pointers.ts, because C types cannot prove lifetimes. Review that metadata when ownership contracts change. Do not edit generated bindings. check:abi detects stale output. Use bun run check:abi --all-targets to also compare the layouts of all supported targets. An unsupported record shape or calling convention fails the script. It does not produce partial metadata. C compiler assertions also check complete function and callback prototypes, record layouts, field types, and constant values. Translate-C alone is not enough, because it can drop callback calling-convention attributes and ignore #pragma pack.

From packages/native, bun run build installs the header and libraries under lib/<target>/ for the host target. Linux and macOS produce libopentui.a beside the shared library. Windows produces opentui-static.lib, opentui.lib for DLL imports, and opentui.dll. Static linkage also needs the platform and C++ runtime libraries. zig build -Dall builds all supported targets. zig build -Dlibrary-target=<target> builds one target.

zig build test-abi --summary all

This command runs the C fixture against the static and shared libraries on the host. On a glibc Linux host, the fixture targets glibc 2.17. check:abi --all-targets checks layouts for all eight supported targets. It does not test macOS or Windows runtime linkage or terminal behavior.

The external examples/hello package imports the public Zig module without JavaScript.