All notable changes to this project will be documented in this file.

The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.

Unreleased

0.10.2 - 2026-06-12

Added

  • Per-widget example isolates — every built-in widget now has a focused, copyable demo. New standalone examples under examples/widgets/ cover the widgets that previously only appeared inside large multi-tab or app examples: barchart, sparkline, chart, calendar, canvas, checkbox, gauge, line_gauge, scrollbar, tabs, list, markdown, popup, widget_list, slash_commands, textarea, and clear. Each is a short, single-purpose script so a widget can be lifted out without untangling it from a larger app.

  • Two guide companions under examples/observability/. telemetry.exs attaches a :telemetry handler to the runtime's own render/event :stop spans and renders the live event counts — a runnable counterpart to the Telemetry guide. state_machine.exs demonstrates the screen-as-data dispatch and modal-overlay patterns from the State Machine Patterns guide (a :screen atom names the active state; an :overlay field intercepts input for a confirm-quit Popup). throbber.exs runs on the reducer runtime, driving its animation from a Subscription.interval.

  • Examples catalog published to hexdocs. examples/README.md is now an ExDoc extra titled Examples, grouped by folder with a "Start here" on-ramp, so the catalog is reachable from the published docs and not just GitHub.

  • usage-rules.md ships in the Hex package (and as a docs extra) for downstream AI-agent context.

  • Architecture guide. New guides/internals/architecture.md documents the NIF bridge (encode/decode pipeline, DirtyIo event polling, ResourceArc terminal state, layout on the Rust side) and the per-transport process trees — content previously squeezed into the README's "How It Works" section, now a first-class hexdocs page. The Callback Runtime guide gained a "Callback or Reducer?" comparison table (moved from the README) that the Reducer guide links to.

  • Elixir 1.20 compatibility — 1.20 compiler warnings are resolved and 1.20 is part of the CI matrix.

Changed

  • README rewritten around a scannable feature list. The ~22-bullet Features section (several of them paragraph-length, with API signatures inline) is now 14 one-line **capability** — description bullets in the style of Oban's README; every dropped detail already lives in a guide or the cheatsheet. The "Choosing a Runtime" and "Choosing a Transport" tables moved to the guides (the runtime table to Callback or Reducer?, the transport story to the Transports matrix), replaced by a short "Runtimes and Transports" section that links them. "How It Works" shrank to a two-sentence summary pointing at the new Architecture guide. The standalone Examples section is gone (Quick Start already links the catalog), and sections now follow the conventional flow: Features → Installation → Quick Start → Runtimes and Transports → Guides → How It Works → Ecosystem → Built with → Contributing.

  • One documentation voice and shape. Prose across the guides, README, cheatsheet, and moduledocs no longer addresses the reader as "you" — imperative or subjectless phrasing throughout (the Getting Started tutorial keeps its conversational register). Section headings are sentence case, every guide closes with the same "Related" section, and footer links share one plain-link style.

  • Guides deduplicated — each fact now lives in one place. The reducer guide defers to the callback guide for the error-handling, transports, and testing sections they shared verbatim, keeping only its deltas; intents are documented once instead of three times. The SSH and Distributed guides' shared "multiple transports" and "app_opts forwarding" sections moved into the canonical Transports guide; ssh_transport's retellings of the ExRatatui.SSH moduledoc (pty_req story, CPR walkthrough, auto_host_key ×3) collapsed to the user-facing facts. Building UIs' 530-line per-widget catalog — a near-1:1 duplicate of the cheatsheet — is now a purpose-grouped index plus the pattern-teaching subsections (stateful inputs, overlays, WidgetList scrolling, slash commands); its constraint table gained the missing {:fill, weight} and split/4 opts. The telemetry event catalog is canonical in ExRatatui.Telemetry's moduledoc (which gained the image/code_block spans) with the guide keeping the narrative. Rot-prone internals dropped throughout: Rust enum names and upstream symbols in the images guide, NIF tuple shapes, hardcoded source line references, hardware-dependent timings, version-specific migration notes.

  • Guides reorganized into grouped subfolders. The flat guides/ directory is now grouped into introduction/, core/, runtimes/, transports/, and internals/ (plus the existing cheatsheets/), and the hexdocs sidebar mirrors the same structure: Introduction (Getting Started, Examples), Building UIs, Runtimes, Transports, Going Deeper, and Cheatsheets replace the single flat Guides group. Page filenames are unchanged, so existing hexdocs URLs keep working; only in-repo paths moved (e.g. guides/building_uis.mdguides/core/building_uis.md).

  • Examples reorganized into grouped subfolders. The flat examples/ directory is now grouped into basics/, widgets/, layout/, apps/, cell_session/, images/, and observability/. Run paths change accordingly — e.g. mix run examples/counter_app.exs is now mix run examples/basics/counter_app.exs. Notable moves and renames: the 1066-line widget_showcase.exs is retired in favor of the per-widget isolates above, chat_interface.exsapps/chat.exs, data_table.exswidgets/table.exs, and the Ecto task_manager/ app → apps/task_manager_db/. The full mapping is reflected in the examples catalog.

  • native/ex_ratatui/Cargo.lock is committed for reproducible NIF builds from source.

Fixed

  • usage-rules.md covers the silent-failure key vocabulary. The rules file now leads its gotcha list with the lowercase-string contract for Event.Key / Event.Mouse fields (code: "up", modifiers: ["ctrl"] — wrong values compile and never match), indexes ExRatatui.Focus, ExRatatui.Theme, and ExRatatui.CellSession so agents stop hand-rolling them, and corrects the same commands:/render?: reducer-only claim fixed across the other docs.

  • Six modules' doc examples now run as doctests. ExRatatui.Command, ExRatatui.Subscription, ExRatatui.Image (render_size/4), ExRatatui.Widgets.BarGroup, ExRatatui.Widgets.Canvas.Map, and ExRatatui.Widgets.Canvas.Label all carried iex> examples that were never wired into the suite (Canvas.Line/Rectangle/Circle/Points were — Map and Label were an oversight). ExRatatui.Distributed's quick-start transcript dropped its iex> prompt so the side-effecting attach/2 line can never be picked up as a doctest.

  • Documentation accuracy pass — every code snippet verified against the API. Roughly 25 factual errors across the guides, the cheatsheet, and the moduledocs are fixed:

    • Snippets that raised or silently never matched on copy-paste: %Event.Key{modifiers: [:ctrl]} → modifiers are strings (["ctrl"]); %Event.Mouse{column:, row:} → fields are x/y with string kind/button; %Event.Resize{columns:, rows:}width/height; Command.async(fun, :atom) → the mapper must be an arity-1 function; a Task.Supervisor.async_nolink example that sent its result to the task's own pid; the Images quick start used a nonexistent view/2 callback; Focus caller examples (guide and moduledoc) returned bare state instead of {:noreply, state}.
    • Wrong claims: the runtime comparison table said tracing is reducer-only (snapshot/trace/inject work under both runtimes); commands:/render?: were documented as no-ops under the callback runtime but the server executes them for both — docs now describe the actual behaviour; the reducer guide claimed init/1 and update/2 have defaults (they're required); the debugging guide inverted :transient restart semantics; poll_event/1's doc and Event.FocusGained claimed focus reporting is default-on (it's opt-in via focus_events: true); the transports matrix wrote :iterm for :iterm2 and atom shorthand for tuple transport shapes; the distributed guide mis-ordered the attach sequence; WidgetList docs claimed only visible rows cross the NIF boundary (encoding walks all items).
    • Silent-zero telemetry examples: three Telemetry.Metrics definitions pointed at event/measurement names that never fire; the default-logger doc now notes the image/code_block spans are excluded.
    • Stale references: the SSH and Distributed option tables were missing image_protocol/image_font_size; the quoted server_transport contract in Custom Transports lagged the Server (and ExRatatui.Transport's typespec gained the 4-tuple :cell_session intent variant the Server already accepted); phantom file/function names (continue_init_ssh/3, test/property/, pre-reorg example paths) and three broken anchors/relative links.

0.10.1 - 2026-06-01

Added

  • ExRatatui.Theme — pure-data palette struct (Layer A theming). Eleven semantic slots (:primary, :accent, :border, :border_focused, :surface, :surface_alt, :text, :text_dim, :success, :warning, :danger) each accepting the full ExRatatui.Style.color/0 shape (named atoms, {:rgb, r, g, b}, {:indexed, n}, or nil). Two starter constructors ship: default/0 (terminal-respecting; surface: nil so the same theme works on light and dark terminals) and light/0 (dark text on white surface). Three composition helpers cover the common patterns: border_style(theme, focused: true|false), text_style(theme, dim: true|false), selection_style(theme). Apps thread the theme through render code by hand — no Application.get_env magic, no automatic widget injection. Layer B (opt-in per-widget defaults at render time) is intentionally deferred.

  • Bracketed paste (%ExRatatui.Event.Paste{content: binary}). ExRatatui.run/2 enables crossterm's EnableBracketedPaste on init and disables it on restore. The local terminal's poll_event/1 decodes Event::Paste(String) into a new %Event.Paste{} struct so pasted multi-line / multi-byte content arrives as one event instead of being shredded across individual keystrokes. Two batch-insert NIFs consume the payload in one shot: ExRatatui.text_input_insert_str/2 (single-line — strips every control char) and ExRatatui.textarea_insert_str/2 (multi-line — \n and \r\n become real new lines via ratatui_textarea::insert_str; lone \r is dropped). Pasting a 5,000-character URL is now one NIF call rather than 5,000. Best-effort: terminals without bracketed paste ignore the request and the per-char keystream still arrives — apps don't need a conditional path. Out of scope this release: bracketed paste decode on the byte-stream input parser used by :session / :ssh / :distributed transports — their custom VTE state machine doesn't decode CSI 200~/201~ markers yet, so apps on those transports construct %Event.Paste{} directly and feed it into their event pipeline.

  • %ExRatatui.Event.FocusGained{} / %ExRatatui.Event.FocusLost{} — terminal-window focus reporting. Opt-in via ExRatatui.run(fun, focus_events: true). When enabled, crossterm emits focus events as the parent emulator window gains or loses focus; apps use them to pause expensive animations / background ticks while the user is elsewhere. Two separate payload-less structs (matching crossterm's variant shape, and avoiding any clash with the existing ExRatatui.Focus module). Off by default: enabling focus reporting leaves CSI ?1004h on the user's tty, and any window-switch queues focus bytes that leak back into unrelated stdin consumers (a plain shell or mix test started later) as ^[[I / ^[[O sequences. Same upstream caveat as bracketed paste: only the local terminal decodes these; byte-stream transports would need VTE-parser work.

  • Local-terminal mouse capture is now opt-in via ExRatatui.run(fun, mouse_capture: true). When enabled, crossterm's EnableMouseCapture reports clicks / scroll / drag / move as %Event.Mouse{} from poll_event/1. Off by default because mouse-reporting mode captures the terminal's native text selection — the user can no longer select and copy text the usual way until the app exits. ExRatatui.Server.start_link (and therefore every ExRatatui.App) accepts the same :mouse_capture and :focus_events opts for the :local transport. SSH and Distributed transports decode mouse sequences unconditionally because their VTE-based input parser handles them regardless of this flag — Focus.handle_mouse/2 has always worked there.

  • ExRatatui.Focus gains mouse routing. New fields and functions extend the existing keyboard focus ring with a regions: %{id => Rect} map and handle_mouse/2. Apps register hit-test regions after computing layout (typically inside a %Event.Resize{} handler) via Focus.set_region/3 or Focus.set_regions/2; Focus.handle_mouse/2 mirrors handle_key/2 shape — on a left-button-down inside a registered region, focus moves to that ID and the event is passed through so the underlying widget can still react (toggle a checkbox, place a cursor, start a drag). Every other mouse kind (right/middle click, scroll, drag, move, up) is pass-through with focus untouched. Focus.at/3 exposes the same hit-test for apps that prefer "scroll the widget under the cursor" over "scroll the focused widget". Overlapping regions resolve to the smallest by area (leaf-inside-container picks the leaf). Boundaries are half-open (x >= rx and x < rx + w) — natural for ratatui rect semantics.

  • %ExRatatui.Widgets.Block.Title{} + multi-title support on Block. Four new Block fields close the most-asked ergonomics gap: :titles (list of additional titles, each a %Block.Title{content, position, alignment, style} struct OR a raw line-like value that inherits the block defaults), :title_position (:top default, :bottom), :title_alignment (:left default, :center, :right), :title_style (default %Style{} for any title without its own). Backward compatible — the existing :title field stays a single-title shortcut at the block's default position and alignment. The classic "filename | scroll %" header is title: "filename", titles: [%Block.Title{content: "[3/12]", alignment: :right}]. All title fields validated at Bridge encode with ArgumentError; entries in :titles that are neither %Block.Title{} nor line-like are rejected with a descriptive message.

  • Eight additional Block border types. Beyond :plain / :rounded / :double / :thick, the widget now accepts :light_double_dashed, :heavy_double_dashed, :light_triple_dashed, :heavy_triple_dashed, :light_quadruple_dashed, :heavy_quadruple_dashed (the border line broken into 2/3/4 dash segments) and :quadrant_inside / :quadrant_outside (blocky half-cell borders drawn inside or outside the content area). Closes the upstream BorderType surface — every ratatui variant is now reachable.

  • Layout :margin / :horizontal_margin / :vertical_margin opts. ExRatatui.Layout.split/4 can now inset the area before splitting — margin: 1 leaves a 1-cell border on all four sides; :horizontal_margin / :vertical_margin override per-axis. Complements the existing :spacing (gaps between segments). Validated from Elixir like the other opts.

  • Style :underline_color. %ExRatatui.Style{} gains an :underline_color field — the color of the underline drawn by the :underlined modifier, distinct from :fg. nil (default) uses the foreground color. Honoured by terminals with colored-underline support (kitty, wezterm); others fall back to a plain underline.

  • ExRatatui.Layout.Padding ergonomic constructors. uniform/1, symmetric/2 (horizontal, vertical), horizontal/1, vertical/1, and new/4 return the {left, right, top, bottom} tuple that Block's :padding field already accepts — %Block{padding: Padding.uniform(1)} instead of padding: {1, 1, 1, 1}. Pure functions, no struct, no runtime cost beyond building the tuple.

  • ExRatatui.set_terminal_title/1. Sets the terminal window / tab title via OSC 0/2 (crossterm SetTitle). Useful for daemon TUIs, dashboards, and multi-tab terminals. Best-effort: terminals that don't honour the escape ignore it. One new NIF set_terminal_title/1.

  • Layout Flex modes + Constraint::Fill + segment spacing. ExRatatui.Layout.split/4 adds two keyword opts: :flex (one of :legacy, :start, :end, :center, :space_between, :space_around) and :spacing (non-negative cells inserted between every pair of adjacent segments). New constraint shape {:fill, weight} distributes the leftover space after higher-priority constraints (Length / Percentage / Ratio / Min / Max) are satisfied — {:fill, 1} + {:fill, 2} splits the remainder 1:2. Unlocks centered popups (flex: :center), end-aligned status bars (flex: :end), evenly-spaced toolbars (flex: :space_between), growable dashboard panels ({:fill, 1} / {:fill, 2}), and gutters between segments (spacing: 2). Backward compatible: existing Layout.split/3 calls land via default opts. Both opts raise ArgumentError from Elixir on invalid values.

  • List: :direction, :scroll_padding, :repeat_highlight_symbol. direction: :bottom_to_top paints the first item at the bottom of the area and grows upward — natural for chat logs, REPL history, and event streams where the newest entry pins to the bottom edge. scroll_padding keeps the selected item at least N rows from the viewport edge when the list auto-scrolls (same idea as vim's scrolloff). repeat_highlight_symbol: true repeats the highlight symbol on every wrapped row of a multi-line item instead of only the first. All three validated at Bridge encode.

  • Table: :footer, :header_style, :footer_style, :column_highlight_style, :cell_highlight_style, :selected_column, :highlight_spacing. Footer renders at the bottom of the area; header / footer styles override the table's :style for those rows. column_highlight_style / cell_highlight_style paint the selected column / cell (intersection of selected row + column) — useful for spreadsheet-style navigation. :selected_column is the gating field for the column / cell styles: without it the styles never fire (ratatui's TableState::select_column/1 is the activation mechanism). Validated like :selected against the widest of widths / header / first-row column count. highlight_spacing (:always, :when_selected default — matches ratatui, :never) controls when the symbol column is reserved; :always pins column positions even when nothing is selected so the row doesn't shift on select.

  • Three new examples covering the round-1 surface end-to-end:

    • examples/chat_log.exs%List{direction: :bottom_to_top} history pinned to the bottom edge, multi-line %Textarea{} composer with bracketed paste support via textarea_insert_str/2, multi-title %Block{} header with right-aligned unread count, Tab-cycled focus between log and composer.
    • examples/data_table.exs — every new Table field on one screen: footer, header / footer styles, column / cell highlight styles wired to a live :selected_column, and :highlight_spacing: :always. Arrow keys move the row + column cursor.
    • examples/theme_picker.exs — visual reference for every ExRatatui.Theme slot plus a live preview rendering border_style/2 (focused + unfocused), text_style/2 (default + dim), and selection_style/1 in real widgets. Press 1/2/3 to switch between default/0, light/0, and a custom Nord-ish theme.
  • Numeric input validation pass on every bounded widget. Gauge and LineGauge raise ArgumentError when :ratio is outside 0.0..1.0 or not a number (integers 0 and 1 still coerce to floats — the existing "clamped automatically" moduledoc note was a lie and now matches behavior). List, Table, Tabs, WidgetList reject :selected values that are not nil or an integer in 0..length(items) - 1; empty collections get a distinct error message ("expected nil (collection is empty)"). Failures surface from Elixir with widget.field context strings instead of a downstream Rust panic. Sparkline and BarChart numeric validation was already in place — this pass closed the remaining gaps.

  • test/ex_ratatui/property/widget_render_property_test.exs — per-widget property invariants. Two ExUnitProperties properties stress every stateless widget type the library ships (24 of 29 total): Bridge.encode_commands!/1 accepts arbitrary valid inputs at any rect, and CellSession.draw + take_cells produces a snapshot with exactly width × height cells. Covers Paragraph, Block, Clear, List, Table, Gauge, LineGauge, Tabs, Sparkline, BarChart, Throbber, Scrollbar, Checkbox, Calendar, Markdown, BigText, plus the stateful TextInput / Textarea / Image and composite Popup / WidgetList / Canvas / CodeBlock. Chart is intentionally excluded from the render property — upstream ratatui panics on multiple Chart input combinations (narrow axis bounds, empty datasets, :bar graph_type with sparse data, small rects after block borders) and proving "Chart never panics" isn't a guarantee the rendering layer can give. The encode property still covers Chart at any rect.

  • guides/transports.md — canonical cross-transport feature matrix. Five-row transport table introduces each entry point (Local, Session, SSH, Distributed, CellSession); 14-row feature matrix covers widget rendering, every Event.* variant, image protocols + auto-probe, OSC 52 clipboard, intents, runtime opts, and telemetry — with three states (✓ supported, — not applicable, ✗ known gap) and inline notes on every gap row. Replaces the implicit assumption that the per-transport guides spell this out; they don't, and now they don't have to.

  • guides/paste_and_clipboard.md. Walks through bracketed paste behaviour, the two insert_str helpers, the SSH / distributed transport caveat, and ships a short OSC 52 snippet for apps that want clipboard copy without bundling another module. OSC 52 is not built into the library — too many opinions in the ecosystem (system clipboard via arboard, OSC 52, route through a Phoenix LiveView intent). The guide is the canonical place to discover the snippet.

Changed

  • Native.init_terminal arity bumped to 2(focus_events :: bool, mouse_capture :: bool). ExRatatui.run/1 becomes ExRatatui.run/2 accepting :focus_events and :mouse_capture opts; run/2 with no opts is equivalent to the old run/1. ExRatatui.Server.start_link (and every ExRatatui.App) accepts the same two opts for the :local transport. The init_terminal/0 form is gone — direct NIF callers (custom transports, examples) must pass both booleans explicitly.

  • [:ex_ratatui, :transport, :connect] span metadata gains :focus_events and :mouse_capture for the :local transport, reflecting the boolean opts the app started with. Other transports' connect metadata is unchanged.

  • ExRatatui.Widgets.Block defaults. defstruct now includes :titles, :title_position, :title_alignment, :title_style with backward-compatible defaults ([], :top, :left, nil). Apps constructing blocks with %Block{...} and only the old fields are unaffected; the doctest in Block.moduledoc shows the new shape.

  • ExRatatui.Widgets.List defaults. defstruct adds :direction (:top_to_bottom), :scroll_padding (0), :repeat_highlight_symbol (false). Matches ratatui's defaults.

  • ExRatatui.Widgets.Table defaults. defstruct adds :footer (nil), :column_highlight_style (nil), :cell_highlight_style (nil), :header_style (nil), :footer_style (nil), :highlight_spacing (:when_selected — matches ratatui).

  • Rust-side BlockData, ListData, TableData gain Default impls. Test fixtures and Rust callers (one per widget that composes a Block) shrink to ..Default::default() — same backward-compat guarantee inside the native code as on the Elixir side.

0.10.0 - 2026-05-19

Added

  • ExRatatui.Widgets.CodeBlock — syntax-highlighted source code. Display-only widget powered by syntect's bundled SyntaxSet and ThemeSet. Fields: :content, :language (any syntect token name; nil for plain text fallback), :theme (seven curated atoms — :base16_ocean_dark, :base16_ocean_light, :base16_eighties_dark, :base16_mocha_dark, :inspired_github, :solarized_dark, :solarized_light — or any raw string for custom theme sets), :line_numbers + :starting_line (right-aligned dim gutter with separator, width grows with the last visible line), :highlight_lines (list of ints + ranges like [3, 7..9], normalised to a sorted unique list, rendered with a theme-derived background that brightens dark themes and dims light themes by 20/256 per channel). Composes with Block, Popup, and WidgetList like every other widget. One new widget type ("code_block") on the render decoder.

  • ExRatatui.CodeBlock.highlight/3 — raw highlighted lines for composite widgets. Returns [%ExRatatui.Text.Line{}] with per-token styled spans for callers building DiffViewer / Inspector-style composites without dropping a full CodeBlock in the tree. Backed by a single new NIF highlight_code/3 (scheduled on DirtyCpu) returning Vec<Vec<HighlightedSpan>> (NifMap with content, fg/bg as Option<(u8, u8, u8)>, plus bold/italic/underlined flags). ExRatatui.CodeBlock.resolve_theme/1 is the canonical theme-atom resolver used by both the widget encoder and the helper — single source of truth. ExRatatui.CodeBlock.from_native/1 is a documented conversion seam for callers using Native.highlight_code/3 directly (e.g. hot loops reusing the same theme).

  • [:ex_ratatui, :code_block, :highlight] telemetry span. Each ExRatatui.CodeBlock.highlight/3 call emits start + stop via :telemetry.span/3 with language (string or nil), theme (resolved syntect name), and bytes (source byte size); the stop event adds line_count. Mirrors the [:ex_ratatui, :image, :decode] span shape.

  • Shared widgets::highlighter Rust module. OnceLock-cached SyntaxSet (load_defaults_newlines) + ThemeSet (load_defaults), lines_for(code, language, theme) returning Vec<Line<'static>>, theme_bg(theme) for the emphasis-color base. The ~25-line syntect→ratatui style/color/modifier translation is inlined (MIT-attributed in the module header) because the existing syntect-tui adapter is pinned to older ratatui versions (0.28/0.29) and we're on 0.30.

  • examples/code_block_demo.exs. Interactive viewer that cycles through all seven themes and five sample languages (Rust, Python, Ruby, JavaScript, JSON), toggles the line-number gutter, and toggles emphasis on lines 3..5. Status panel echoes the active config live.

  • Cheatsheet entry under Text & content next to BigText, and a new "Widgets: Code" ex_doc group covering ExRatatui.CodeBlock + ExRatatui.Widgets.CodeBlock.

  • Bundled Elixir syntax. native/ex_ratatui/syntaxes/Elixir.sublime-syntax is vendored from elixir-editors/elixir-sublime-syntax (MIT, copyright Po Chen — LICENSE alongside) and added to syntect's SyntaxSet at startup via into_builder()SyntaxDefinition::load_from_str(include_str!(...)).add().build(). language: "elixir" now produces real per-token highlighting (defmodule/do/end as keywords, atoms, strings, sigils — six distinct fg colors on a typical snippet under base16-ocean.dark). Adds ~80 KB to the binary. Other BEAM languages: Erlang ships in syntect's defaults; EEx / HEEx / Surface are not yet bundled — same approach can extend to them when needed.

  • Known limitation: ExRatatui.Widgets.Markdown fenced code blocks still use base16-ocean.darktui-markdown 0.3.7 hardcodes the syntect theme internally and does not expose it through its public API. A :code_theme knob on the Markdown widget depends on upstream cooperation and is out of scope for this release; users who need themed fenced blocks today can pre-extract the source and render via CodeBlock directly.

  • ExRatatui.Widgets.BigText — oversized 8×8 pixel text for slide titles and banners. Drop-in widget backed by tui-big-text 0.8.4; each character is rasterised through the font8x8 bitmap font. ExRatatui.BigText.new/2 coerces text-like input through the shared text-coercion path (binary / %Span{} / %Line{} / %Text{} / homogeneous lists), validates :pixel_size and :alignment, and merges any outer %Text{} style underneath the widget's own. Eight pixel_size densities cover the full upstream variant set: :full (default — 1 cell per pixel, 8 rows tall), :half_height, :half_width, :quadrant, :third_height, :sextant, :quarter_height, :octant (1 row × half cols). Composes with Block, Popup, and WidgetList like every other widget. Adds one new widget type ("big_text") to the render decoder; no new NIFs.

  • examples/big_text_demo.exs. Interactive viewer that cycles every pixel_size, three alignments, six colors, and four sample slide titles at runtime. Status panel echoes the active settings live. Helpful when picking a variant for a real slide deck.

  • ExRatatui.Widgets.Image — image rendering across every transport. Decodes PNG / JPEG / GIF / WebP / BMP bytes and renders them through ratatui-image with Kitty, Sixel, iTerm2, or Unicode halfblocks. ExRatatui.Image.new/2 returns a stateful widget handle ({:ok, %ExRatatui.Widgets.Image{}}) or {:error, {:decode_failed, msg}}; protocol / resize mode / background are set at construction and stored on a NIF resource so re-encoding only happens when the resolved protocol or rect dimensions change. Three resize strategies: :fit (preserve aspect inside the rect), :crop (preserve aspect, fill, crop overflow), :scale (stretch to fill).

  • Transport-aware protocol resolution. Each transport stamps a TransportCaps value the render path consults: CellSession forces :halfblocks (escape sequences can't survive cell diffing — Livebook / Kino apps that share model code with a real terminal just work); the local terminal can cache a Picker::from_query_stdio probe via ExRatatui.Image.auto_local_protocol/1 so :auto resolves to the detected protocol with the right font size; SSH and Distributed accept an :image_protocol opt at start time, optionally paired with :image_font_size for accurate Kitty / Sixel / iTerm2 scaling (ExRatatui.SSH.Daemon.start_link(..., image_protocol: :kitty, image_font_size: {10, 20}), ExRatatui.Distributed.attach(..., image_protocol: :kitty, image_font_size: {10, 20})). Explicit per-image protocol selections at Image.new/2 are always honored.

  • Image rendering over ExRatatui.Distributed. Image widgets cross node boundaries via a snapshot path: the server runtime calls image_snapshot/1 on each %ExRatatui.Widgets.Image{state: ref} in the render tree before sending the widget list over the wire (a NIF ResourceArc can't cross nodes). The client node re-decodes the bytes into a fresh ImageResource per draw. Snapshot wire shape is {bytes, protocol, resize, background}. Costs roughly the source PNG size per frame on the wire — fine for stills, watch bandwidth for animations on large images.

  • probe_image_protocol: true runtime opt for mount/1. ExRatatui.App apps can opt into auto-probing the local terminal by returning {:ok, state, probe_image_protocol: true}. The runtime calls ExRatatui.Image.auto_local_protocol/1 once after mount on the :local transport — :auto images then resolve to the detected protocol without the app needing access to the terminal reference. Skipped under test_mode. Other transports ignore the opt.

  • ExRatatui.Image.render_size/4. Pure-Elixir prediction of the rendered output pixel dimensions for a given (source dims, cell area, font size, resize mode) combination. Mirrors ratatui-image's Resize::needs_resize_pixels + fit_area_proportionally byte-for-byte. Useful for status panels, layout decisions, or just understanding why :fit and :crop produce identical output when the source is smaller than the target.

  • :background accepts the full ExRatatui.Style.color/0 shape. Named atoms (:red, :dark_gray, …), {:rgb, r, g, b}, {:indexed, n} xterm 256-color codes, raw {r, g, b} tuples, and nil all work. Named and indexed values are converted to RGB at the Elixir boundary via the standard ANSI palette.

  • New public API surface. ExRatatui.Image.{new,dimensions,probe_terminal,auto_local_protocol,render_size}/{1,2,4}, ExRatatui.Session.{set_image_protocol,set_image_font_size}/2, ExRatatui.set_image_protocol/2. Eight new NIFs: image_new/2, image_dimensions/1, image_snapshot/1, image_probe_terminal/0, session_set_image_protocol/2, session_set_image_font_size/2, terminal_set_image_protocol/2, terminal_set_local_probe/3.

  • [:ex_ratatui, :image, :decode] telemetry span. Each Image.new/2 call emits start + stop with format (sniffed from magic bytes — :png / :jpeg / :gif / :webp / :bmp / :unknown), bytes, and width / height on success. Per-render encode timing stays inside the existing [:ex_ratatui, :render, :frame] span.

  • Examples. examples/image_demo.exs is an interactive viewer with runtime protocol / resize toggling and a live status panel showing rendered output dimensions; supports --ssh and --distributed flags for smoke-testing those transports end-to-end. examples/headless_image.exs renders an image through CellSession (with ANSI fg/bg per cell) for Livebook / snapshot consumers. Both accept IMAGE_PATH to skip the network fetch, default to picsum.photos, and embed a 1×1 fallback PNG for offline use.

  • Images guide and cheatsheet entry. Walks through the API, the full transport / protocol resolution table, the font-size caveat for Kitty / Sixel / iTerm2 scaling, telemetry, and known v1 limitations (no GIF animation, no SVG, no streaming decode, Resize::Fit doesn't upscale).

Changed

  • Rust toolchain bumped to 1.86+ to match ratatui-image 11.0.2's rust-version (edition 2024). ex_ratatui itself stays on edition 2021; precompiled binaries are unaffected — you only hit this if you build the NIF yourself with EX_RATATUI_BUILD=1.

  • Binary size grows by ~1.6 MB. ratatui-image pulls in image (with PNG / JPEG / GIF / WebP / BMP decoders) and bundled Kitty / Sixel / iTerm2 encoders. No new system dependencies — chafa is feature-gated off, sixel uses pure-Rust icy_sixel.

Fixed

  • Precompiled musl NIFs now load under musl runtimes. native/ex_ratatui/.cargo/config.toml's x86_64-unknown-linux-musl and aarch64-unknown-linux-musl targets now pass -C link-arg=-static-libgcc alongside the existing target-feature=-crt-static. Without it the NIF .so linked against the build host's glibc libgcc_s.so.1 and the musl loader on the consumer side refused it. Alpine and other musl-libc deploys can now consume the precompiled artifact directly — no source rebuild required.

0.9.0 - 2026-05-7

Added

  • Property-based tests for the :intents runtime opt. New test/ex_ratatui/property/intents_property_test.exs (async: true) with two properties under the 4-tuple {:cell_session, cs, cell_writer_fn, intent_writer_fn} transport tag: (1) for any mount-intent list and any sequence of handle_info-supplied batches, the writer receives every intent in concat order with no reordering, drops, or extras; (2) empty intents: [] batches never fire on the writer regardless of how many sequential empty handle_info calls a TUI makes. Complements the existing scenario unit tests in ExRatatui.Server.IntentsTest (mount-time, handle_event, handle_info, stop-with-intent, drop-without-writer, shape validation) — together they pin the full intent contract. Reuses the existing ExRatatui.Test.ServerApps.Intents fixture.

  • Documented :intents runtime opt across the public surface. ExRatatui.App's moduledoc now has a dedicated Runtime opts section listing every key (:commands, :intents, :render?, :trace?) with types, defaults, scope (callback vs reducer), and the "intents from {:stop, ...} fire before the server exits" guarantee. The t:callback_opts/0 typedoc points at it; a new t:intent/0 typedoc names the opaque-term contract. The ExRatatui.App.handle_event/2 and ExRatatui.App.handle_info/2 callback typespecs now include the {:noreply | :stop, state, callback_opts} 3-tuple variants — they were always accepted by the runtime but the typespecs declared only the 2-tuple shape, hiding the feature from Dialyzer and HexDocs. Their @doc strings now point at the Runtime opts section. The Callback Runtime guide has a new Runtime opts section between handle_info/2 and Error Handling. The Reducer Runtime guide's existing Runtime Options table grew an intents: row with a follow-up Intents subsection that names the consumer-defined vocabulary contract and the transport-portability story. The Cell Sessions guide gained a Driving an ExRatatui.App over a CellSession section walking through the 3-tuple vs 4-tuple transport shapes, sample cell_writer_fn and intent_writer_fn definitions, and the lifecycle the runtime drives. End-user code is unchanged; this is documentation of the existing feature.

  • ExRatatui.CellSession — non-terminal rendering primitive — sibling of ExRatatui.Session for consumers that aren't terminals (Phoenix LiveView painting <span> cells, embedded framebuffers, screenshot tools, headless tests). Backed by ratatui's TestBackend, it exposes the cell buffer directly instead of ANSI bytes. Same widget tree, input parser, and draw/2 / resize/3 / feed_input/2 / close/1 lifecycle as Session; the only API divergence is take_output/1 being replaced by take_cells/1 (full snapshot) and take_cells_diff/1 (cells that changed since the last diff call). Cells are %{row, col, symbol, fg, bg, modifiers, skip} in row-major order, with colors and modifiers using the same ExRatatui.Style vocabulary as the rest of the library. take_cells_diff/1 returns a full payload on its first call, after resize/3, and after reconstruction; otherwise only cells that differ structurally. Adds 9 NIFs, the ExRatatui.CellSession{,.Cell,.Snapshot,.Diff} modules, a Rendering to Non-Terminal Surfaces guide, and a headless cell_dump.exs example.

  • :intents runtime opt + {:cell_session, cs, cell_writer_fn, intent_writer_fn} 4-tuple transport tagExRatatui.App callbacks can now return {:ok, state, intents: [...]} from mount/1 and {:noreply | :stop, state, intents: [...]} from handle_event/2 / handle_info/2. Intents are opaque to ex_ratatui — they're forwarded verbatim to the transport's intent_writer_fn (the optional 4th element of the cell_session transport tag) in the order they were emitted. phoenix_ex_ratatui consumes this to dispatch inter-page navigation ({:navigate, "/path"}push_navigate, {:patch, "/path"}push_patch, {:redirect, "/url"}redirect); other consumers can define their own intent vocabulary. Transports that don't supply an intent writer (the existing 3-tuple cell_session shape, plus :local / :session / :distributed_server) silently drop intents — apps stay portable across transports. Intents from {:stop, state, intents: ...} transitions fire BEFORE the server exits, so a TUI can return {:stop, state, intents: [{:redirect, "/login"}]} and trust the redirect reaches the consumer before the linked-server EXIT propagates.

  • {:cell_session, cell_session, cell_writer_fn} Server transport tag — ExRatatui.Server now accepts a fourth :transport shape (alongside :local, :session, and :distributed_server) that drives an ExRatatui.App against a CellSession instead of a byte-stream Session. On every render the Server calls CellSession.draw/2, then CellSession.take_cells_diff/1, then hands the resulting %CellSession.Diff{} to the user-supplied cell_writer_fn — same call shape as the byte-stream :session transport, just a different payload type. ExRatatui.Transport.start_server/1 accepts the new shape unchanged via the t:server_transport/0 union; the t:cell_writer_fn/0 type lives next to the existing t:writer_fn/0. Mount opts are augmented identically to the byte-stream path: opts[:transport] = :cell_session, opts[:width] / opts[:height] populated from CellSession.size/1. Telemetry mirrors the existing taxonomy: [:ex_ratatui, :transport, :connect] and [:ex_ratatui, :session, :lifecycle, :open] fire on init with transport: :cell_session; [:ex_ratatui, :session, :lifecycle, :close] and [:ex_ratatui, :transport, :disconnect] fire on terminate/2. Resize semantics match :session exactly — the transport must call CellSession.resize/3 before forwarding {:ex_ratatui_resize, w, h} to the Server, then the Server updates the cached size and dispatches a %Event.Resize{} to the App; the next render's diff payload after a resize is always full (prior baseline at the old area is no longer comparable).

0.8.2 - 2026-04-29

Fixed

  • %Event.Resize{} now reaches handle_event/2 over byte-stream transports — when the runtime ran over a byte-stream transport (:session for SSH / Kino / custom TCP, :distributed_server for distribution), the {:ex_ratatui_resize, w, h} message synthesized by ExRatatui.Transport.ByteStream was intercepted by the Server's own handle_info/2 clause: it updated the cached width / height and re-rendered, but never dispatched a %Event.Resize{} to the App. The local-tty path delivered Resize through the polling event loop, so an App that worked when run over the real terminal would silently miss resize events the moment it was supervised over SSH, distribution, or a Livebook notebook. The Server now updates its cached size first (so the follow-up render sees the new dims) and then routes the resize through dispatch_event/2, giving the App's handle_event/2 exactly the same %Event.Resize{width: w, height: h} it would see on local tty. Apps that only had a fall-through handle_event(_, state) clause are unaffected; apps that explicitly track terminal size in their own state can now do so over every transport. The two transport tests (session_transport_test.exs, distributed_transport_test.exs) were updated to assert App delivery in addition to the re-render

0.8.1 - 2026-04-27

Added

  • Telemetry instrumentation (#56) — new ExRatatui.Telemetry module emits :telemetry events across the runtime so apps can plug in logging, metrics, or OpenTelemetry tracing without forking the server. Five span events (:start / :stop / :exception) wrap the costly stages: [:ex_ratatui, :runtime, :init] around mount/1, [:ex_ratatui, :runtime, :event] around terminal-input handle_event/2, [:ex_ratatui, :runtime, :update] around info-message handle_info/2 (subscriptions, async results, user messages), [:ex_ratatui, :render, :frame] around the build+draw cycle (adds :widget_count to stop metadata), and [:ex_ratatui, :transport, :connect] around local/SSH/distributed handshakes. Four single events fire for point-in-time facts: [:ex_ratatui, :session, :lifecycle, :open] when a session-backed runtime adopts a session (carries :width / :height), [:ex_ratatui, :session, :lifecycle, :close] when it releases the session (carries :reason; fires exactly once per session even when the transport's own teardown defensively closes the same ref afterwards), [:ex_ratatui, :render, :dropped] on draw errors (with a TODO placeholder for future frame-skip backpressure), and [:ex_ratatui, :transport, :disconnect] on server teardown. Every event carries :mod and :transport in its metadata. ExRatatui.Telemetry.span/3 is a thin helper that prefixes events with :ex_ratatui and forwards start metadata to stop; execute/3 does the same for single events and auto-adds :system_time. attach_default_logger/1 / detach_default_logger/0 ship a handler that logs every event at a configurable level. New Telemetry guide walks through the full event catalogue, a Telemetry.Metrics example, and an opentelemetry_telemetry wiring snippet. Added {:telemetry, "~> 1.0"} as an explicit dependency and to extra_applications so the handler registry is available on every node (including peers in distributed tests)
  • Transport behaviour — new ExRatatui.Transport module documents the protocol between the runtime server and the processes that carry I/O for a running ExRatatui.App. Declares the server_transport, writer_fn, and to_server typespecs, plus one optional child_spec/1 callback and a public start_server/1 entrypoint for custom transports to boot the runtime without depending on internal modules. ExRatatui.SSH, ExRatatui.SSH.Daemon, and ExRatatui.Distributed.Listener all adopt the behaviour. New ExRatatui.Transport.ByteStream helper packages the byte-pump pattern (forward_input/3, forward_resize/4) so any byte-oriented transport — SSH today, Kino/Livebook next, a custom TCP bridge — can plug in without reimplementing event dispatch or Resize absorption. ExRatatui.SSH now delegates to ByteStream instead of hand-rolling the loop. New Custom Transports guide walks through the contract and a working TCP example covering separation of acceptor and per-connection worker (so the listener survives across disconnects and serves concurrent clients), the alt-screen enter/leave sequences, runtime-server monitoring, and the raw-mode client requirement (stty raw -echo; nc …; stty sane) that plain TCP needs since it has no equivalent of SSH's PTY negotiation
  • Property-based test pass over the recently-added surface — five new files under test/ex_ratatui/property/ covering the modules introduced by the telemetry + transport-behaviour work, plus older surface that previously lacked sweeping coverage. session_input_property_test.exs proves ExRatatui.Session.feed_input/2's byte stitchability (splitting any byte stream at any boundary, including byte-by-byte, yields the same event sequence) along with shape invariants for printable bytes, bare ESC, and arrow keys. byte_stream_property_test.exs covers ExRatatui.Transport.ByteStream.forward_input/3's contract: no %Event.Resize{} ever leaks as a regular event, every parsed event is delivered in order, and forward_resize/4 always emits an :ex_ratatui_resize notification. bridge_property_test.exs covers Bridge.encode_commands!/1's list-level contract — length / order preservation, {map, map} shape, rect coordinate passthrough, and ArgumentError on malformed entries — across structurally simple widgets (Paragraph, Block, Clear, Gauge, Throbber). text_encode_property_test.exs covers Text.Encode.to_wire_text!/to_wire_line! line/span count and content preservation, alignment serialization, and per-line agreement between the two encoders. normalize_property_test.exs covers Subscription.normalize/1 and Command.normalize/1 idempotence, leaf-count preservation across nested batches, order preservation, and refusal of garbage shapes. The exhaustive per-widget validation property suite (one shape generator per widget × known-malformed shapes raising ArgumentError, ~22 widgets) is intentionally deferred — the structural invariants above are uniform across widgets, so the next pass is a per-widget effort rather than a generic one

Changed

  • Internal :ssh transport tag renamed to :session — the Server's internal transport tag always described "generic byte-stream session"; SSH just happened to be the first user. The new name makes room for other byte-stream transports (Kino, custom TCP) to share the same runtime without impersonating SSH. User-facing routing shorthand is untouched: {MyApp, transport: :ssh} still routes to SSH.Daemon. Affected surfaces: Runtime.snapshot/1 .transport now returns :session for byte-stream sessions (was :ssh); telemetry metadata %{transport: _} on [:runtime, :init], [:transport, :connect], etc. is now :session on byte-stream events; mount(opts)[:transport] is now :session for SSH apps (was :ssh). If your app branches on opts[:transport] to detect SSH specifically, switch to a different signal (you control the {MyApp, transport: :ssh} line in your own supervision tree)

0.8.0 - 2026-04-21

Added

  • Chart widget — new ExRatatui.Widgets.Chart struct plus Chart.Dataset and Chart.Axis companions wrap ratatui's Chart for x/y line, scatter, and bar plots with axes, labels, legend, and multi-series support. Each %Dataset{} carries a :name (shown in the legend), a list of {x, y} numeric tuples in :data, plus its own :marker (:braille / :dot / :block / :bar / :half_block), :graph_type (:line / :scatter / :bar), and :style, so a single chart can mix line and scatter overlays. The required :x_axis / :y_axis are %Axis{} structs with :bounds ({min, max} numeric tuple), optional tick :labels (string / %Span{} / %Line{}), :title, :style, and :labels_alignment (:left / :center / :right). :legend_position accepts :top, :top_left, :top_right (default), :bottom, :bottom_left, :bottom_right, :left, :right, or nil to hide the legend entirely. :hidden_legend_constraints takes a {width_constraint, height_constraint} pair using the same shapes as ExRatatui.Layout (:length / :percentage / :ratio / :min / :max / :fill) — the legend is hidden whenever its rendered size would exceed those bounds against the chart area. :block wraps the chart in a framed Block. Missing axes, non-list :datasets, non-%Dataset{} entries, malformed data points, non-numeric coordinates, unknown markers, unknown :graph_types, unknown :legend_positions, malformed :bounds, malformed :hidden_legend_constraints, and unknown :labels_alignment values raise ArgumentError at the bridge boundary. See the widget cheatsheet for examples
  • Canvas widget (#30) — new ExRatatui.Widgets.Canvas struct and six shape structs (Canvas.Line, Canvas.Rectangle, Canvas.Circle, Canvas.Points, Canvas.Map, Canvas.Label) wrap ratatui's Canvas for 2D plotting. Shapes live in a virtual coordinate space defined by :x_bounds / :y_bounds (both {min, max} tuples with min <= max) and are rasterized onto cells via :marker:braille (default), :dot, :block, :bar, or :half_block. Rectangle draws an outline anchored at its bottom-left corner, Circle draws an outline centered on {x, y}, Points plots a list of {x, y} tuples, Map paints the world's coastlines at :low or :high resolution (pair with {-180, 180} × {-90, 90} bounds), and Label writes a styled text annotation at the given canvas-space coordinate. Every drawable shape takes a plain Color.t() rather than a Style — canvas pixels sample individual cells, so text modifiers don't apply; Label uses the color as the text foreground. :background_color fills the area, and :block wraps the canvas in a framed Block. Non-tuple bounds, inverted bounds, unknown markers, unknown map resolutions, missing required shape fields, negative width / height / radius, non-string Label.text, malformed Points.coords entries, and unknown shape structs all raise ArgumentError at the bridge boundary. See the widget cheatsheet for examples
  • Calendar widget (#31) — new ExRatatui.Widgets.Calendar struct renders ratatui's Monthly calendar. :display_date is a native %Date{} that drives which month is shown and which day is highlighted. :events accepts either a list of {%Date{}, %Style{}} tuples or a %{Date => Style} map — map entries with a nil value are skipped so toggling individual days stays ergonomic. :show_month_header and :show_weekdays_header (booleans, default true) toggle the two header rows, with independent :header_style / :weekday_style overrides. Set :show_surrounding to a %Style{} to bleed the previous/next month into empty grid cells; leave it nil to hide them. :default_style paints unstyled days, and :block wraps the widget in a framed Block. Non-Date :display_date, non-boolean header toggles, and malformed event entries raise ArgumentError at the bridge boundary. See the widget cheatsheet for examples
  • Sparkline widget (#27) — new ExRatatui.Widgets.Sparkline struct renders ratatui's Sparkline, a compact single-line bar chart for streaming or time-series data. :data is a list of non-negative integers with nil entries representing missing samples; set :max to a positive integer or leave nil to auto-scale. Choose a rendering style via :bar_set:nine_levels for smooth gradients, :three_levels for low-density glyphs, or a non-empty list of strings that's proportionally mapped across ratatui's nine density slots. Direction (:left_to_right / :right_to_left), absent-value symbol and style, chart-wide :style, and :block are all configurable. Floats, negative values, non-list data, unknown bar-set atoms, and empty custom lists raise ArgumentError at the bridge boundary. See the widget cheatsheet for examples
  • BarChart widget (#23) — new ExRatatui.Widgets.BarChart, ExRatatui.Widgets.Bar, and ExRatatui.Widgets.BarGroup structs render ratatui's BarChart in either :vertical or :horizontal orientation. Each %Bar{} carries a :label, non-negative integer :value, optional per-bar :style that overrides the chart-wide :bar_style, and an optional :text_value to replace the numeric readout. Chart-level fields include :data (single anonymous group of bars), :groups (list of %BarGroup{} for side-by-side clusters with shared captions), :bar_width, :bar_gap, :group_gap (cells between adjacent clusters), :bar_style, :value_style, :label_style, :max (nil auto-scales to the largest value), :direction, and :block. Set either :data or :groups. Floats, negative values, non-list :groups, non-%BarGroup{} entries, non-string group labels, and negative :group_gap raise ArgumentError at the bridge boundary. See the widget cheatsheet for examples
  • Focus management (#48) — new ExRatatui.Focus struct for multi-panel apps. Declare an ordered ring of focusable IDs with Focus.new/2, route every key event through Focus.handle_key/2, and pattern-match on Focus.current/1 to dispatch. Tab / Shift+Tab / back_tab are consumed by default; :next_keys / :prev_keys accept %Event.Key{} entries to override (:kind ignored, modifiers compared as a set). Focus.focused?/2 drives border styling without Focus ever touching widget structs. Pure Elixir, no Rust changes. The new "Focus management" section in Building UIs walks through the caller pattern
  • Widget protocol (#24) — new ExRatatui.Widget protocol lets you build composite widgets in pure Elixir by implementing render/2 on your own struct. The Bridge flattens custom widgets into primitive {widget, rect} tuples recursively (with a 32-level depth cap and argument validation) before encoding, so ExRatatui.draw/2 accepts primitive and custom widgets interchangeably at the top level. Custom widgets inside Popup.content / WidgetList.items are not supported yet. The public widget() type splits into primitive_widget() (built-ins, unchanged) and widget() (primitive or any struct implementing the protocol); the new Custom Widgets guide walks through the API. Protocol consolidation is now limited to :prod, so test-time defimpl blocks (and your own tests of custom widgets) work without fuss
  • Rich text primitives (#26) — new ExRatatui.Text.Span and ExRatatui.Text.Line structs let text-bearing widget fields carry per-span colors and modifiers instead of a single style for the entire string. Paragraph.text, List.items, Table.rows/Table.header, Tabs.titles, and Block.title now accept any mix of String.t(), %Span{}, %Line{}, or [%Span{}]. Plain strings continue to work on every field; fields that are semantically single-line (table cells, tab titles, block titles) raise on strings containing embedded newlines. The new "Rich Text" section in Building UIs walks through the API

Fixed

  • TextInput cursor invisible at end of double-width input (#45) — when a TextInput contained CJK or other double-width characters that overflowed the widget's display width, moving the cursor to the end made it disappear. Viewport scrolling and span construction tracked positions in char counts but the widget's display width is measured in terminal cells, so wide chars consumed twice their accounted-for space and the trailing cursor span was truncated. Both the viewport adjustment and the rendered spans are now cell-aware via the unicode-width crate

Changed

  • Documentation expanded. Five new guides ship in guides/: Getting Started walks mix new → supervised todo app with TextInput + List + manual focus; State Machine Patterns covers mode-atom dispatch, screen stacks, modals via Popup, multi-screen transitions, and sibling-GenServer escape hatches; Testing documents the headless backend, test_mode, Runtime.inject_event/2, and three assertion patterns (snapshot, test_pid, :sys.get_state); Debugging covers Runtime.snapshot/1, enable_trace/2 events, buffer-inspection-as-dev-tool, common errors (terminal_init_failed, garbled output, SSH -t, mix run stdin), and Rust NIF rebuilds; Performance covers the render loop, render?: false, keeping render/2 cheap, large trees, poll-interval tuning, subscription cost, async effects, and timing with :timer.tc + traces. The widgets cheatsheet was rewritten as task-grouped columns (Styles, Layout, Text, Lists, Progress, Input, Charts, Containers, Calendar) using {: .col-2} annotations for scan-ability. A new examples/README.md catalogs all 12 examples with SSH and Erlang-distribution one-liners. The top-level README dropped its Learning Path, Testing sample, and Troubleshooting sections (all absorbed by the new guides) and trimmed the Examples table to two (hello_world + counter_app), now pointing to the examples catalog. Hex sidebar reordered: onboarding → concepts → patterns → ops → cheatsheet
  • Test suite expanded. New coverage: property-based invariants for Layout.split/3, style encoding, text coercion, Focus ring semantics, and decode_event/1 round-tripping key/mouse/resize tuples via stream_data; unicode and emoji rendering across CJK, combining marks, ZWJ sequences, and BMP/SMP emoji on every text-bearing widget; stress tests for 2 000-widget scenes, 1 000 redraws, and 1×1 / 500×500 terminals; cross-transport parity tests proving the same App module produces identical widget trees under local, SSH, and Erlang distribution; raw-example smoke tests for system_monitor (App-based) and chat_interface (raw ExRatatui.run/1 loop). Distributed integration now also exercises Chart, grouped BarChart, and Canvas with a Map shape to catch any future NIF-field regression across node boundaries. Coverage remains at 100% across all 55 modules
  • Test layout mirrors lib/. test/ex_ratatui/widgets_test.exs was split into per-widget files under test/ex_ratatui/widgets/, one-for-one with lib/ex_ratatui/widgets/. Cross-cutting integration tests (cross-transport parity, unicode rendering, stress, focus integration, full-stack rendering) now live under test/integration/. server_runtime_test.exs was renamed to runtime_test.exs and test_backend_test.exs folded into terminal_test.exs to match the modules they cover. server_test.exs was further split by transport: SSH and distributed unit tests now live in test/ex_ratatui/server/ssh_transport_test.exs and test/ex_ratatui/server/distributed_transport_test.exs, each organized under describe blocks for lifecycle, message handling, reducer support, and helpers. Duplicated fixture apps across the three server test files were consolidated into ExRatatui.Test.ServerApps (Echo, StopOnAnyEvent, FailingMount) under test/support/, and SSH test helpers into ExRatatui.Test.SshHelper
  • CI hardening. The distributed and slow jobs merged into a single extras job that runs both tag filters sequentially, saving a runner. The lint matrix now runs mix xref graph --format cycles --fail-above 0 to catch dependency cycles at CI time. Doctests added to ExRatatui.Command and ExRatatui.Subscription for the reducer side-effect helpers
  • API polish. ExRatatui moduledoc now documents the error-handling convention: programmer errors raise ArgumentError, runtime/I/O failures return {:error, reason}. ExRatatui.Command.async/2 docs now enumerate the mapper's error shapes and include an example. TextInput and Textarea moduledocs note their :state references are node-local NIF resources that must not be serialized, compared, or sent across distribution. The Bridge module is hidden from HexDocs (@moduledoc false) since it's internal to the NIF boundary
  • Shutdown robustness. The internal server's terminate/2 now cancels any armed subscription timers across all three transports, so pending ticks can't be delivered to a supervisor-restarted process carrying a stale mailbox

0.7.1 - 2026-04-13

Fixed

  • SSH bare Esc key not detected — VTE's state machine swallows 0x1B as the start of an escape sequence, so a bare Esc press over SSH produced no event. The SSH transport now schedules a 50 ms timeout after a lone 0x1B with no follow-up bytes; if the timer fires it resets the parser and dispatches a synthetic %Event.Key{code: "esc"} press. Follow-up bytes (the normal case for multi-byte sequences like arrow keys) cancel the timer before it fires. Added Session.reset_parser/1 and its backing session_reset_parser NIF
  • Distributed transport crashes on stateful widgetsTextInput and Textarea store their mutable state in NIF resource references that cannot cross BEAM node boundaries via Erlang distribution. The distributed server now snapshots stateful widget state into plain tuples before sending, and the Rust decoder reconstructs temporary resources from the snapshot on the client node. Stateless widgets are unaffected. Added text_input_snapshot and textarea_snapshot NIFs

0.7.0 - 2026-04-13

Added

  • Reducer runtime for ExRatatui.App via use ExRatatui.App, runtime: :reducer. Reducer apps implement init/1, render/2, and update/2, receive terminal input as {:event, event}, mailbox messages as {:info, msg}, and can declare subscriptions with subscriptions/1
  • ExRatatui.Command — reducer side-effect helpers for immediate messages, delayed messages, async work, and batched command execution
  • ExRatatui.Subscription — reducer timer/subscription helpers for interval and one-shot self-messages reconciled by stable id
  • ExRatatui.Runtime — runtime inspection helpers exposing snapshots, trace events, and trace enable/disable controls for supervised TUI processes
  • ExRatatui.Runtime.inject_event/2 — deterministic synthetic event injection for supervised apps under test_mode
  • Example: examples/reducer_counter_app.exs — simple reducer-driven counter showing update/2 and subscriptions/1

Changed

  • ExRatatui.App now supports two runtime styles: the existing callback runtime and the new reducer runtime selected with runtime: :reducer
  • The internal server now supports reducer runtime options for commands, render suppression, trace state, runtime snapshots, async command tracking, and subscription reconciliation
  • Render-command encoding moved into a shared internal bridge, making ExRatatui.draw/2 and ExRatatui.Session.draw/2 share one validation and encoding path
  • Native render-command decoding was refactored into reusable helpers in native/ex_ratatui/src/decode.rs and shared between local terminal rendering and session rendering
  • Bumped ratatui-textarea Rust dependency from 0.8 to 0.9
  • credo dependency restricted to :dev environment only

Fixed

  • New subscriptions now store their timer reference correctly instead of keeping the {timer_ref, token} tuple in the timer_ref field, which broke timer cancellation/rearming paths in the reducer runtime
  • Async command mappers are now wrapped the same way as async functions, so mapper exceptions/exits return structured error tuples and active_async_commands bookkeeping is always decremented
  • The mount/1 callback contract now includes the supported {:ok, state, callback_opts} form, which keeps reducer-style startup shims aligned with Dialyzer and the runtime's actual behavior
  • Invalid reducer/runtime payloads and malformed render commands now fail earlier with clearer Elixir-side or Rust-side validation errors
  • Parallel cold compile crash — the NIF bridge no longer loads its NIF via @on_load during dependency compilation. Precompiled/source artifacts are still prepared at compile time, but the NIF now loads lazily on first use, which stops isolated compiler VMs from crashing under parallel cold compiles on this host
  • test_mode input flake — local supervised apps and distributed attach clients no longer poll the real terminal while running headless tests, which removes ambient crossterm events from async test runs and stops spurious renders like the render?: false reducer flake
  • SSH auto_host_key bootstrap — host-key generation now recreates the parent <priv_dir>/ssh/ directory immediately before writing the key, so first boot succeeds even if the app's priv tree was absent or cleaned between runs

Docs

  • Extracted runtime and widget content from README into dedicated guides: guides/callback_runtime.md, guides/reducer_runtime.md, and guides/building_uis.md
  • Added widget cheatsheet: guides/cheatsheets/widgets.cheatmd
  • README now documents the reducer runtime, reducer example app, command/subscription helpers, and runtime inspection API
  • README and ExRatatui.App docs now call out that mount/1 may return runtime opts and that WidgetList.scroll_offset is row-based with partial clipping semantics
  • Expanded public moduledocs for ExRatatui.App, ExRatatui.Command, ExRatatui.Subscription, and ExRatatui.Runtime
  • HexDocs module grouping now includes reducer-runtime modules
  • README now notes that the native library loads lazily on first use

Tests

  • Added reducer runtime coverage for commands, subscriptions, tracing, render suppression, async failure handling, and invalid runtime return values
  • Added coverage for public Command, Subscription, Runtime, App, and shared bridge validation paths
  • Elixir test coverage remains at 100%

0.6.2 - 2026-04-12

Added

  • Distribution-attach transport — serve any ExRatatui.App to remote BEAM nodes over Erlang distribution. New transport: :distributed option on ExRatatui.App and a standalone ExRatatui.Distributed.Listener for direct supervision-tree use. Each attaching node gets its own isolated TUI session; widget lists travel as plain BEAM terms with zero NIF on the app node
  • ExRatatui.Distributed — main API module with attach/3 for connecting to a remote TUI
  • ExRatatui.Distributed.Listener — supervisor wrapping a DynamicSupervisor for per-attach sessions, with config stored in :persistent_term
  • Distributed.Client (internal) — local rendering proxy that takes over the terminal, polls events, and forwards them to the remote server
  • Server (internal) learns a transport: {:distributed_server, client_pid, width, height} init path that sends {:ex_ratatui_draw, widgets} over distribution instead of rendering locally
  • Guide: guides/distributed_transport.md — architecture, quick start, options reference, testing, troubleshooting
  • README "Running over Erlang Distribution" section
  • examples/system_monitor.exs now supports --distributed flag for running over Erlang distribution
  • :peer-based integration tests for the full cross-node roundtrip (tagged :distributed, run with elixir --sname test -S mix test --only distributed)

Changed

  • test_mode now means fully headless local runtime behaviour: it disables live terminal input polling on both the server and Distributed.Client, and runtime snapshots expose whether polling is enabled

  • Server event and resize handlers are now shared between :ssh and :distributed_server transports

  • ExRatatui.App dispatch_start/1 routes :distributed to ExRatatui.Distributed.Listener.start_link/1

  • WidgetList scroll_offset is now row-based — previously scroll_offset skipped whole items by index; it now skips rows of content. To scroll to a specific item, sum the heights of all preceding items. Items partially above the viewport are clipped at the row level, enabling smooth pixel-row scrolling for chat histories and similar variable-height lists. This is a breaking change for callers that relied on the item-index interpretation

    Migration: If you set scroll_offset to an item index (e.g., scroll_offset: selected), replace it with the cumulative row height of preceding items. For example, if all items have height 3: scroll_offset: selected * 3. For variable-height items, sum their heights: scroll_offset: items |> Enum.take(selected) |> Enum.map(&elem(&1, 1)) |> Enum.sum()

Fixed

  • WidgetList partial-item clipping — items straddling the top edge of the viewport are now correctly rendered via an off-screen buffer blit instead of being skipped entirely

0.6.1 - 2026-04-09

Fixed

  • SSH subsystem dispatchssh host -s Elixir.MyApp.TUI (and ExRatatui.SSH.subsystem/1 under nerves_ssh) would hang forever instead of rendering. The channel handler was waiting for a {:ssh_cm, _, {:subsystem, ...}} message inside handle_ssh_msg/2, but OTP :ssh consumes that request internally when it matches a name in the daemon's :subsystems config — the handler only ever receives {:ssh_channel_up, ...}. ExRatatui.SSH now detects subsystem-mode dispatch (via a new subsystem: true flag baked into the init args by subsystem/1 and ExRatatui.SSH.Daemon) and synthesizes a default 80x24 session + starts the TUI server directly from {:ssh_channel_up, ...}. Shell-mode startup (via ssh_cli) is unchanged — it still waits for pty_req + shell_req as before
  • SSH subsystem + -t pty_req races — when a client connects with ssh -t -s Elixir.MyApp.TUI, OTP fires ssh_channel_up first (we start an 80x24 session + server) and then delivers the client's pty_req with the real dimensions. The previous pty_req handler created a brand-new Session on every call, which left the SSH channel pointing at a session the running Server no longer rendered into. The handler now splits on session: nil vs session: %Session{} and resizes the existing session in place when one is already there, mirroring the window_change path
  • SSH subsystem pty-size discovery on nerves_ssh — even with the pty_req race fixed, a subsystem TUI riding on nerves_ssh (or any :ssh.daemon that configures a default CLI handler) would stay stuck at the hardcoded 80x24 fallback instead of filling the client's real terminal. Root cause: OTP's ssh_connection:handle_cli_msg/3 hands pty_req to the daemon's default CLI handler when the channel's user pid is still undefined, and then silently orphans that CLI handler the moment the subsequent subsystem request rebinds the pid to us — so the subsystem handler never sees pty_req on those deployments, no matter how early it arrives. ExRatatui.SSH now sidesteps the whole OTP path by emitting a Cursor Position Report roundtrip (ESC[s ESC[9999;9999H ESC[6n ESC[u) on {:ssh_channel_up, ...}: the client clamps the bogus cursor position to its real pty size, responds with ESC[<row>;<col>R, the session's ANSI input parser decodes that as a %ExRatatui.Event.Resize{}, and the {:data, ...} handler resizes the session in place + notifies the running server via {:ex_ratatui_resize, w, h}. Shell-mode startup is unaffected — it still reads the dimensions straight off pty_req
  • session_input.rs CPR parsing — the VTE-driven input parser now recognizes ESC[<row>;<col>R Cursor Position Report responses and emits them as NifEvent::Resize(col, row) so the SSH transport's CPR-based pty-size discovery has something to intercept. The handler runs before the simple-CSI dispatch that would otherwise silently drop any R final byte
  • SSH subsystem startup — added a shell-vs-subsystem section to ExRatatui.SSH's moduledoc and the guides/ssh_transport.md guide explaining which message triggers server boot in each mode, plus a loud "always pass -t" caveat (OpenSSH doesn't allocate a PTY for subsystem invocations by default, which leaves the client's local terminal in cooked mode — keystrokes get line-buffered and echoed locally on top of the TUI)

0.6.0 - 2026-04-09

Added

  • SSH transport — serve any ExRatatui.App to remote clients over OTP :ssh. New transport: :ssh option on ExRatatui.App and a standalone ExRatatui.SSH.Daemon for direct supervision-tree use. Each connected client gets its own isolated TUI session; works as a primary daemon or as a nerves_ssh subsystem via ExRatatui.SSH.subsystem/1
  • ExRatatui.Session — in-memory transport-agnostic terminal session (Rust SharedWriter + Viewport::Fixed) with new/2, draw/2, take_output/1, feed_input/2, resize/3, size/1, and close/1
  • ExRatatui.SSH:ssh_server_channel implementation that drives a Session per channel, parses ANSI input via vte, and handles PTY negotiation, window_change, and alt-screen lifecycle
  • ExRatatui.SSH.Daemon — GenServer wrapping :ssh.daemon/2 with port/1 and daemon_ref/1 introspection helpers
  • ExRatatui.SSH.Daemon :auto_host_key option — when set, the daemon resolves the OTP application that owns :mod, ensures <priv_dir>/ssh/ exists, and generates a 2048-bit RSA host key on first boot. Subsequent boots reuse the same key. Lets Phoenix admin TUIs and similar drop the daemon straight into a supervision tree without hand-rolling host-key bootstrap
  • ExRatatui.SSH.Daemon :system_dir accepts binary paths in addition to charlists; the daemon converts them before forwarding to :ssh.daemon/2
  • VTE-based input parser covering arrows, function keys, SS3, CSI modifiers, Alt+letter, Ctrl+letter, and partial-sequence buffering across feeds (SSH delivers byte-at-a-time during interactive use)
  • 7 new session NIFs on ExRatatui.Native: session_new/2, session_close/1, session_draw/2, session_take_output/1, session_feed_input/2, session_resize/3, session_size/1
  • Guide: guides/ssh_transport.md — architecture, quick start, nerves_ssh integration, options reference, host-key generation, troubleshooting
  • Examples ship an SSH mode: examples/system_monitor.exs --ssh and examples/task_manager via TASK_MANAGER_SSH=1 (multiple clients share one SQLite database)
  • README "Running Over SSH" section
  • CI enforces 100% Elixir test coverage threshold (NIF modules excluded)
  • Missing doctests for ExRatatui, Event, Event.Key, Event.Mouse, Event.Resize, and SlashCommands
  • Callback documentation for all ExRatatui.App callbacks

Changed

  • ExRatatui.Server learns a transport: :local | :ssh option and an alternate init/1 path that drives an injected Session + writer function instead of the local terminal

  • ExRatatui.App gains a :transport option that dispatches between ExRatatui.Server start_link/1 and ExRatatui.SSH.Daemon.start_link/1

Docs

  • Expanded moduledoc prose for ExRatatui, Event, and event struct modules
  • Added coverage requirement note to CONTRIBUTING.md

Tests

  • Bumped Elixir test coverage to 100% — added server, rendering, layout, event, and widget tests
  • End-to-end SSH integration test exercising :ssh.daemon/2 + :ssh.connect/3 round trip with a generated host key (mount → render bytes → keystroke roundtrip → window_change)

0.5.1 - 2026-03-25

Added

  • ExRatatui.Widgets.Markdown — markdown rendering widget with syntax-highlighted code blocks, powered by tui-markdown (pulldown-cmark + syntect)
  • ExRatatui.Widgets.Textarea — multiline text editor with undo/redo, cursor movement, and Emacs-style shortcuts. Second stateful widget — state lives in Rust via ResourceArc
  • ExRatatui.Widgets.Throbber — loading spinner widget with 12 animation sets (braille, dots, ascii, arrow, clock, and more)
  • ExRatatui.Widgets.Popup — centered modal overlay widget for dialogs, confirmations, and command palettes
  • ExRatatui.Widgets.WidgetList — vertical list of heterogeneous widgets with selection and scrolling, ideal for chat message histories
  • ExRatatui.Widgets.SlashCommands — slash command parsing, matching, and autocomplete popup rendering
  • Textarea NIF functions: textarea_new/0, textarea_handle_key/3, textarea_get_value/1, textarea_set_value/2, textarea_cursor/1, textarea_line_count/1
  • Example: chat_interface.exs — AI chat interface demonstrating Markdown, Textarea, Throbber, Popup, WidgetList, and SlashCommands

Fixed

  • Replaced deprecated Padding::zero() with Padding::ZERO in Rust widget renderers
  • Wired up unused style field in WidgetList render function (was #[allow(dead_code)])
  • Fixed flaky Rust throbber step test — calc_step(0) uses random index, now tests with deterministic non-zero steps
  • Throbber animation set test now covers all 12 sets (was 7)

0.5.0 - 2026-03-22

Added

  • ExRatatui.Widgets.Tabs — a tab bar widget for switching between views, with customizable selection highlight, divider, and padding
  • ExRatatui.Widgets.Scrollbar — a scrollbar widget for indicating scroll position, supporting all four orientations (vertical right/left, horizontal bottom/top)
  • ExRatatui.Widgets.LineGauge — a thin single-line progress bar using line-drawing characters, with separate filled/unfilled styles
  • ExRatatui.Widgets.Checkbox — a checkbox widget for boolean toggles, with customizable checked/unchecked symbols and styles
  • ExRatatui.Widgets.TextInput — a single-line text input widget with cursor navigation, viewport scrolling, and placeholder support. First stateful widget — state lives in Rust via ResourceArc
  • Example: widget_showcase.exs — interactive demo with tabs, progress bars, checkboxes, text input, scrollable logs, and scrollbar (replaces individual tabs_demo.exs, scrollbar_demo.exs, line_gauge_demo.exs)
  • Doctests for Tabs, Scrollbar, LineGauge, and Checkbox struct modules
  • Updated task_manager.exs example to use Tabs (header), LineGauge (progress), Scrollbar (task table), and TextInput (new task creation — replaces hand-rolled input buffer with proper cursor navigation, viewport scrolling, and placeholder support)
  • Updated examples/task_manager/ App to use Tabs (filter bar with Tab/Shift+Tab navigation), LineGauge (replaces Gauge), Scrollbar (task table), and TextInput (replaces hand-rolled input buffer)
  • Comprehensive TextInput state management tests: get/set value, cursor positioning, backspace, delete, left/right, home/end, and mid-text insertion

Fixed

  • Checkbox moduledoc now correctly states that :checked_symbol and :unchecked_symbol default to nil (rendered as "[x]" / "[ ]" by the Rust backend)
  • Removed redundant .padding() call in Rust tabs renderer that was always overwritten

0.4.2 - 2026-03-06

Added

  • ExRatatui.Widgets.Clear — a widget that resets all cells in its area to empty (space) characters, useful for rendering overlays

Fixed

  • Put back Elixir. prefix from List calls in task_manager.exs example

0.4.1 - 2026-02-23

Fixed

  • init_terminal NIF now cleans up raw mode and alternate screen on partial initialization failure
  • All I/O-bound NIFs (init_terminal, restore_terminal, draw_frame, terminal_size) now run on the DirtyIo scheduler to avoid blocking normal BEAM schedulers
  • App.render/2 callback typespec narrowed from term() to ExRatatui.widget() for proper Dialyzer coverage
  • Constraint::Ratio with denominator zero now returns an error instead of panicking
  • Gauge ratio now validates the value is finite, preventing a panic on NaN input
  • App.mount/1 callback typespec now includes {:error, reason} return
  • ExRatatui.run/1 after block no longer masks the original exception if terminal restore also fails
  • Server render errors now log the full stacktrace for easier debugging
  • Added missing @impl true on fallback terminate/2 clause in the server
  • ExRatatui.Frame struct defaults to width: 0, height: 0 instead of nil (typespec now matches actual usage)
  • Deduplicated encode_constraint/1ExRatatui.Layout is now the single source of truth
  • Fixed flaky poll_event tests that failed when terminal events arrived during the test run
  • Event.Mouse typespec fields are now non-nullable to match actual NIF output
  • Fixed system_monitor.exs to cache hostname between refreshes with Map.get_lazy/3
  • Removed unnecessary Elixir. prefix from List calls in task_manager.exs example
  • Added server tests for {:stop, state} from handle_info/2 and terminate/2 callback

Docs

  • HexDocs "View Source" links now point to the correct version tag
  • Expanded ExRatatui moduledoc with quick start, core API overview, and cross-references
  • README demo GIF now uses an absolute URL so it renders on Hex.pm
  • README modifiers list now shows all six supported modifiers
  • Documented :test_mode option in ExRatatui.App for headless testing
  • Clarified system_monitor.exs is Linux/Nerves only in README

0.4.0 - 2026-02-23

Changed

  • BREAKING: Terminal state is now per-process via Rust ResourceArc instead of a global mutex
  • Terminal is automatically restored when the terminal reference is garbage collected (crash safety)
  • Test terminal instances are now independent, enabling async: true for rendering tests

Added

  • Comprehensive API documentation: all key codes, mouse events, colors, modifiers, and App options
  • Doctests for Layout, Style, Frame, all widgets, and test backend
  • CONTRIBUTING.md with development setup

0.3.0 - 2026-02-23

Added

  • Typespecs (@type t) for all widget, event, and frame structs
  • Function specs (@spec) for all public API functions
  • Dialyzer static analysis in CI

Changed

  • Extracted Event.Key, Event.Mouse, Event.Resize and Layout.Rect into their own files

Fixed

  • Server start_link/1 now supports name: nil to start without process registration
  • App-based TUI processes hanging on macOS — the event poll loop now delegates the timeout to the NIF on the DirtyIo scheduler instead of using Process.send_after/3, which was causing the GenServer to stop processing messages

0.2.0 - 2026-02-21

Changed

  • Simplified release workflow by using rustler-precompiled-action instead of manual build and packaging steps

Added

  • Precompiled NIF target for riscv64gc-unknown-linux-gnu (Nerves RISC-V boards)
  • System monitor example (examples/system_monitor.exs) for running on Nerves devices via SSH

0.1.1 - 2026-02-19

Changed

  • Improved HexDocs module grouping: Frame moved under Layout, App under new Application group
  • Added demo GIF to README

Fixed

  • Changelog formatting for ex_doc compatibility

0.1.0 - 2026-02-19

Added

  • Widgets: Paragraph (with alignment, wrapping, scrolling), Block (borders, titles, padding), List (selectable with highlight), Table (headers, rows, column constraints), and Gauge (progress bar)
  • Layout engine: Constraint-based area splitting via ExRatatui.Layout.split/3 with support for :percentage, :length, :min, :max, and :ratio constraints
  • Event polling: Non-blocking keyboard, mouse, and resize event handling on BEAM's DirtyIo scheduler
  • Styling system: Named colors, RGB ({:rgb, r, g, b}), 256-color indexed ({:indexed, n}), and text modifiers (bold, italic, underlined, dim, crossed out, etc.)
  • Terminal lifecycle: ExRatatui.run/1 for automatic terminal init and cleanup
  • OTP App behaviour: ExRatatui.App with LiveView-inspired callbacks (mount/1, render/2, handle_event/2, handle_info/2) for building supervised TUI applications
  • GenServer runtime: manages terminal lifecycle, self-scheduling event polling, and callback dispatch under OTP supervision
  • Frame struct: ExRatatui.Frame carries terminal dimensions to render/2 callbacks
  • Test backend: Headless TestBackend via init_test_terminal/2 and get_buffer_content/0 for CI-friendly rendering verification
  • Precompiled NIFs: Via rustler_precompiled for Linux, macOS, and Windows (x86_64 and aarch64) — no Rust toolchain required
  • Examples: hello_world.exs (minimal display), counter.exs (interactive key events), counter_app.exs (App-based counter), task_manager.exs (full app with all widgets), and examples/task_manager/ (supervised Ecto + SQLite CRUD app)