tuika 0.11.0
Released 2026-08-22. crates.io · docs.rs · GitHub release
Released alongside tuika-charts 0.1.2, tuika-codeformatters 0.5.0,
tuika-html 0.1.4, and tuika-mermaid 0.3.2. Their tuika dependency
requirements now track 0.11.
Highlights
One route for every event — a host registers each surface once and every
Event kind (key, paste, mouse, focus) follows the same path to whichever
surface owns input this frame, so a paste can no longer take a different route
from a key and land behind an open overlay. Router resolves stages in a fixed
order and Delivery reports which surface received what, including the case
where nothing did.
Streaming markdown renders identically to a one-shot render — two cache boundaries that only misfired at particular chunk splits are fixed, so a document fed in as it arrives renders exactly like the same document rendered whole.

- Binary size: a default
tuika-codeformattersbuild measured ~24.0 MiB on a highlighting probe binary; arust+pythonbuild is ~4.8 MiB. A tuika host that places no images drops ~8.4 KiB of graphics encoders. - Dependencies: 66 crates to 55 (the
asyncgraph, 71 to 59).
Breaking Changes
-
tuika-codeformatters: every tree-sitter grammar now sits behind its own cargo feature. All fourteen are on by default, so a dependant taking the crate normally is unaffected. A dependant that already passesdefault-features = falsenow gets no grammars until it names them:tuika-codeformatters = { version = "0.4", default-features = false, features = ["rust", "python"] }Feature names are the language keys lowercased without punctuation —
rust,python,typescript(covers TSX/JSX),go,java,ruby,css,html,csharp,php,zig,scala,sql. A language whose feature is off behaves exactly like one the crate never supported:highlightreturnsNoneand the caller renders plain code. -
Paragraphand dialog copy wrap differently. Both now use tuika’s own wrap solver — the oneWrap/wrap_lineshas always used — instead oftextwrap. Three visible differences, all of them makingParagraphagree with the rest of the toolkit:- Line breaks are chosen greedily (first-fit) rather than by textwrap’s optimal-fit minimum-raggedness pass, so a paragraph’s rows may break at different words than in 0.10.
- A run of whitespace between two words is one break opportunity and renders as a single space, instead of being copied through verbatim.
- Breaks happen only at whitespace. textwrap also broke at Unicode line-break
opportunities such as
/, which could split a linkified URL after its scheme; a URL now stays on one row unless it is wider than the row.
Widths are counted in tuika’s grapheme-aware display columns, which fixes a latent bug:
Paragraphwrapped with textwrap’s per-charwidth model and then measured the result withstr_cols, so text containing a ZWJ emoji (a family, a flag, a skin-tone sequence) wrapped earlier than its own measurement said it needed to.
Changed
-
tuika-codeformattersbinary size: grammars are selected by a runtime language string, so the config builder named all fourteen and anchored every parse table into any binary that highlighted anything — roughly 21 MiB of read-only data, C# alone about 5 MiB. Selecting grammars by feature lets a host drop the ones it does not use: measured on a probe binary highlighting a single snippet, the default build is ~24.0 MiB and arust+pythonbuild ~4.8 MiB. Highlighting output under default features is unchanged. -
Graphics protocol encoders are now pay-for-use.
ImageLayer::emitruns after every frame in everyRunnerhost and chose its encoder by matching onImageSupport, which linked the Kitty, iTerm2, and Sixel encoders plus the PNG writer into every binary that used tuika at all. The encoder is now resolved where an image is recorded — reachable only from theImageview — so a host that places no images no longer carries any of them: about 8.4 KiB less tuika code in a minimal host. The emitted bytes are unchanged for all three protocols, and no public API changed. -
Dependencies: 66 crates to 55. Three runtime dependencies were removed by taking work tuika was already half-doing:
textwrap(withsmawkandunicode-linebreak) — replaced by the wrap solverWrapalready used. See Breaking Changes for the visible difference.ratatui-crossterm(with theinstability/darlingproc-macro tree behind it) — tuika now writes to the terminal through its own crosstermBackend. It already implemented that whole trait once, inHyperlinkBackend, to wrap the one being removed. The emitted byte stream is held byte-for-byte identical by a differential test that draws the same cells through both backends. The crate remains an optional dependency of thescrolling-regionsfeature, which forwards its matching flag so a host’s own copy still compiles.tokio-stream(underasync) — replaced byfutures-core, which the build already contained:asyncenablescrossterm/event-stream, and that pulls it in.tokio_stream::Streamisfutures_core::Stream, so a host passing aReceiverStreamis unaffected;tokio-streamstays a dev-dependency and the doc example still uses it. Theasyncgraph goes from 71 crates to 59.
No public API changed, and no rendered output changed outside the wrapping differences above.
Added
-
Input routing: the new
routingmodule (Router,Delivery,RouteStage,InputTarget, all re-exported from the crate root and the prelude) delivers an event to the surface that owns input this frame. A host registers each surface once and everyEventkind follows the same route, so a paste can no longer take a different path from a key and land behind an open overlay.Routerreads theFocusRegistryan overlay-bearingScenealready synchronized — and does not hold it, so a stage closure may take&muton the host — resolves stages in a fixed order (global chord, declared exceptions, the active surface, last-chance global), and reports throughDeliverywhich surface received what, including the case where nothing did. See the routing guide. -
term::hyperlink::apply_buffer_links_in: the area-clipped counterpart toapply_buffer_links, for a component embedding OSC 8 runs into a sub-rect of a shared buffer.apply_buffer_linksis unchanged and still clips to the whole buffer. -
Fuzz and black-box robustness suites (
src/tests/fuzz.rs,tests/robustness.rs): adversarial text and event streams through the wrap solver, composed view trees, the stateful components, and the parsers that read untrusted terminal replies; plus a sweep of every component against every corpus at every degenerate size, asserting no panic, no paint outside the component’s own rect, and no control byte in a cell. Both fixes below came out of them. Test-only — no API change. -
Session stress suite (
tests/stress_ui.rs): every screen mode driven throughRunnerandAsyncRunnerover an in-memory screen that resizes between frames, plus mode changes mid-session, adversarial scrollback publishing, and shell/overlay/dock composition at degenerate sizes. The split-footer fix below came out of it. Test-only — no API change.
Fixed
-
A split footer is pinned and published at the terminal’s current size. Both runners write the footer’s reserved rows, and render each queued scrollback block, at the geometry the
Terminallast observed — and neither observed a resize before the loop’s first frame or before draining the publish queue. Two consequences, both on the ordinary path: a window resized between the terminal being constructed and the first frame pinned the footer at the old size, and a block published in response to a resize (a host logging the new dimensions, a status line reflowing) was committed to the scrollback at the previous width. Both loops now learn the size first. -
A component no longer stamps hyperlink markers outside its own rect.
ParagraphandMarkdownembed OSC 8 runs into the cells of a linked label after painting, and the placement was clipped to the buffer rather than to the rect the component was laid out in. A markdown block whose links ran past the rows or columns it was given wrote an escape onto a neighbouring component’s cell — visible as a stray link, or an orphaned escape once the neighbour repainted. Placements outside the area are now dropped. -
Streamed markdown renders identically to a one-shot render. The settled prefix is cached at the last blank line that ends a block, and two constructs were mistaken for one:
- A fence line carrying an info string (
```rustinside an already-open block) was treated as a closer, so a later blank line settled a boundary inside a code block; everything after it rendered as markdown while a re-render of the same source rendered it as code. - A blank line between list items settled the prefix, so the rest of the list
was parsed in isolation and its numbering restarted — a streamed
1. … 2. … 3. …rendered as1. … 1. … 1. …. A blank line inside a list is a boundary only once a following top-level block proves the list ended, and never on the strength of an in-flight partial line (1before it becomes1.).
Both were found by a new streaming/one-shot fuzz differential, and both depended on where the stream’s chunk boundaries happened to fall.
- A fence line carrying an info string (
-
A link’s underline stops at the URL. When markdown wrapped a run of spans, the space it re-inserted between two words inherited the style and OSC 8 target of the word before it, so the underline and the link’s hit area ran one cell past a
[label](url)or a bare URL into the text that followed. The separator now carries the style of the source whitespace it stands for, and a boundary with no whitespace behind it no longer has a space invented for it.