Text, Bidi & IME
From bytes to grapheme clusters, bidirectional paragraphs and IME composition: zenit treats text as a system capability, not a row of glyphs laid out per code point.
One line, four layers
Storage, edit boundaries, direction resolution and platform shaping each have exactly one authority, and together produce visual lines you can hit-test and select. No layer makes decisions on another’s behalf.
src/text_coresrc/text_core/grapheme.zigsrc/i18n/bidi.zigsrc/render/text_renderer.zigGraphemes, not code points
A family emoji is seven Unicode scalars (four people and three ZWJs), é can be e plus a combining acute, and a flag is two regional indicators. To the user each is one character. zenit uses extended grapheme clusters as the atom for caret movement, deletion, selection and capacity truncation.
Bidirectional text
Text is stored in logical order and shown in visual order. UAX #9 first resolves an embedding level for every character, then rule L2 reverses runs from the highest level down. Below, abc אבג 123 sits in an LTR paragraph: the Hebrew resolves to level 1 and the digits that follow it to level 2.
The UAX #9 resolver is zenit’s own portable implementation in src/i18n/bidi.zig, with property tables generated from pinned Unicode 17.0.0 data. On macOS, final glyph shaping and RTL joining are still handed to CoreText a whole run at a time. The text renderer never splits an RTL run per code point.
Unicode 17 conformance
Runtime property tables are generated from Unicode 17.0.0 data pinned in the repo; the official test files are vendored unmodified under vendor/unicode/17.0.0 and pinned by SHA-256 in SHA256SUMS. “Supported” here doesn’t mean a handful of emoji unit tests. It means running the complete official suites.
python3 tools/generate_grapheme_data.py --check
python3 tools/generate_bidi_data.py --check
(cd vendor/unicode/17.0.0 && shasum -a 256 -c SHA256SUMS)
zig build test-text-core
zig build test-bidi-conformance--check confirms the generated Zig tables match the data; test-text-core runs every GraphemeBreakTest case; test-bidi-conformance runs both complete official bidi files (through UAX #9 rule L2) and is also part of zig build test-headless.
IME as a first-class citizen
IME isn’t the final characters disguised as a keystroke. Platform events keep the phases: ime_preedit carries the composing text plus a caret offset inside it, and ime_commit carries the final text. While composing, Input and Textarea render underlined marked text, but the canonical buffer changes only on commit. There is no separate “discard” event: an empty preedit cancels the composition.
ime_preedit / ime_commit keep the phase; an empty preedit cancelsime_phase, ime_preedit_len, buffer, cursor_pos, anchorscripts/verify_ime.sh drives the full NSTextInputClient path with the system Pinyin / Japanese input sourcesTesting IME
The E2E client exposes both phases as separate RPCs, and inputState reads an input’s real state back by test id.
import { imeCommit, imePreedit, inputState } from "./client";
// The input must already have focus (e.g. click it first).
await imePreedit("nihongo");
let s = await inputState("story.input.name");
// s.ime_phase === "composing", s.ime_preedit_len === 7, s.buffer === ""
await imeCommit("日本語");
s = await inputState("story.input.name");
// s.buffer === "日本語", s.ime_preedit_len === 0
await imePreedit(""); // empty preedit cancels an active compositionSee E2E harness for the full harness workflow.


