Skip to content
Updated View as Markdown

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.

markdown demo

  • Binary size: a default tuika-codeformatters build measured ~24.0 MiB on a highlighting probe binary; a rust + python build is ~4.8 MiB. A tuika host that places no images drops ~8.4 KiB of graphics encoders.
  • Dependencies: 66 crates to 55 (the async graph, 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 passes default-features = false now 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: highlight returns None and the caller renders plain code.

  • Paragraph and dialog copy wrap differently. Both now use tuika’s own wrap solver — the one Wrap/wrap_lines has always used — instead of textwrap. Three visible differences, all of them making Paragraph agree 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: Paragraph wrapped with textwrap’s per-char width model and then measured the result with str_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-codeformatters binary 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 a rust + python build ~4.8 MiB. Highlighting output under default features is unchanged.

  • Graphics protocol encoders are now pay-for-use. ImageLayer::emit runs after every frame in every Runner host and chose its encoder by matching on ImageSupport, 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 the Image view — 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 (with smawk and unicode-linebreak) — replaced by the wrap solver Wrap already used. See Breaking Changes for the visible difference.
    • ratatui-crossterm (with the instability/darling proc-macro tree behind it) — tuika now writes to the terminal through its own crossterm Backend. It already implemented that whole trait once, in HyperlinkBackend, 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 the scrolling-regions feature, which forwards its matching flag so a host’s own copy still compiles.
    • tokio-stream (under async) — replaced by futures-core, which the build already contained: async enables crossterm/event-stream, and that pulls it in. tokio_stream::Stream is futures_core::Stream, so a host passing a ReceiverStream is unaffected; tokio-stream stays a dev-dependency and the doc example still uses it. The async graph 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 routing module (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 every Event kind follows the same route, so a paste can no longer take a different path from a key and land behind an open overlay. Router reads the FocusRegistry an overlay-bearing Scene already synchronized — and does not hold it, so a stage closure may take &mut on the host — resolves stages in a fixed order (global chord, declared exceptions, the active surface, last-chance global), and reports through Delivery which surface received what, including the case where nothing did. See the routing guide.

  • term::hyperlink::apply_buffer_links_in: the area-clipped counterpart to apply_buffer_links, for a component embedding OSC 8 runs into a sub-rect of a shared buffer. apply_buffer_links is 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 through Runner and AsyncRunner over 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 Terminal last 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. Paragraph and Markdown embed 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 (```rust inside 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 as 1. … 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 (1 before it becomes 1.).

    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 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.

Navigation

Type to search…

↑↓ navigate↵ selectEsc close