v0.10.1
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 fullt:ExRatatui.Style.color/0shape (named atoms,{:rgb, r, g, b},{:indexed, n}, ornil). Two starter constructors ship:default/0(terminal-respecting;surface: nilso the same theme works on light and dark terminals) andlight/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 — noApplication.get_envmagic, 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/2enables crossterm'sEnableBracketedPasteon init and disables it on restore. The local terminal'spoll_event/1decodesEvent::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) andExRatatui.textarea_insert_str/2(multi-line —\nand\r\nbecome real new lines viaratatui_textarea::insert_str; lone\ris 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/:distributedtransports — their custom VTE state machine doesn't decodeCSI 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 viaExRatatui.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 existingExRatatui.Focusmodule). Off by default: enabling focus reporting leavesCSI ?1004hon the user's tty, and any window-switch queues focus bytes that leak back into unrelated stdin consumers (a plain shell ormix teststarted later) as^[[I/^[[Osequences. 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'sEnableMouseCapturereports clicks / scroll / drag / move as%Event.Mouse{}frompoll_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 everyExRatatui.App) accepts the same:mouse_captureand:focus_eventsopts for the:localtransport. 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.Focusgains mouse routing. New fields and functions extend the existing keyboard focus ring with aregions: %{id => Rect}map andhandle_mouse/2. Apps register hit-test regions after computing layout (typically inside a%Event.Resize{}handler) viaFocus.set_region/3orFocus.set_regions/2;Focus.handle_mouse/2mirrorshandle_key/2shape — 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/3exposes 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(:topdefault,:bottom),:title_alignment(:leftdefault,:center,:right),:title_style(default%Style{}for any title without its own). Backward compatible — the existing:titlefield stays a single-title shortcut at the block's default position and alignment. The classic "filename | scroll %" header istitle: "filename", titles: [%Block.Title{content: "[3/12]", alignment: :right}]. All title fields validated at Bridge encode withArgumentError; entries in:titlesthat are neither%Block.Title{}nor line-like are rejected with a descriptive message. -
Eight additional
Blockborder 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 upstreamBorderTypesurface — every ratatui variant is now reachable. -
Layout
:margin/:horizontal_margin/:vertical_marginopts.ExRatatui.Layout.split/4can now inset the area before splitting —margin: 1leaves a 1-cell border on all four sides;:horizontal_margin/:vertical_marginoverride per-axis. Complements the existing:spacing(gaps between segments). Validated from Elixir like the other opts. -
Style
:underline_color.%ExRatatui.Style{}gains an:underline_colorfield — the color of the underline drawn by the:underlinedmodifier, 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.Paddingergonomic constructors.uniform/1,symmetric/2(horizontal, vertical),horizontal/1,vertical/1, andnew/4return the{left, right, top, bottom}tuple thatBlock's:paddingfield already accepts —%Block{padding: Padding.uniform(1)}instead ofpadding: {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 (crosstermSetTitle). Useful for daemon TUIs, dashboards, and multi-tab terminals. Best-effort: terminals that don't honour the escape ignore it. One new NIFset_terminal_title/1. -
Layout Flex modes +
Constraint::Fill+ segment spacing.ExRatatui.Layout.split/4adds 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: existingLayout.split/3calls land via default opts. Both opts raiseArgumentErrorfrom Elixir on invalid values. -
List:
:direction,:scroll_padding,:repeat_highlight_symbol.direction: :bottom_to_toppaints 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_paddingkeeps the selected item at least N rows from the viewport edge when the list auto-scrolls (same idea as vim'sscrolloff).repeat_highlight_symbol: truerepeats 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:stylefor those rows.column_highlight_style/cell_highlight_stylepaint the selected column / cell (intersection of selected row + column) — useful for spreadsheet-style navigation.:selected_columnis the gating field for the column / cell styles: without it the styles never fire (ratatui'sTableState::select_column/1is the activation mechanism). Validated like:selectedagainst the widest of widths / header / first-row column count.highlight_spacing(:always,:when_selecteddefault — matches ratatui,:never) controls when the symbol column is reserved;:alwayspins 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 viatextarea_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 everyExRatatui.Themeslot plus a live preview renderingborder_style/2(focused + unfocused),text_style/2(default + dim), andselection_style/1in real widgets. Press 1/2/3 to switch betweendefault/0,light/0, and a custom Nord-ish theme.
-
Numeric input validation pass on every bounded widget.
GaugeandLineGaugeraiseArgumentErrorwhen:ratiois outside0.0..1.0or not a number (integers0and1still coerce to floats — the existing "clamped automatically" moduledoc note was a lie and now matches behavior).List,Table,Tabs,WidgetListreject:selectedvalues that are notnilor an integer in0..length(items) - 1; empty collections get a distinct error message ("expected nil (collection is empty)"). Failures surface from Elixir withwidget.fieldcontext strings instead of a downstream Rust panic.SparklineandBarChartnumeric validation was already in place — this pass closed the remaining gaps. -
test/ex_ratatui/property/widget_render_property_test.exs— per-widget property invariants. TwoExUnitPropertiesproperties stress every stateless widget type the library ships (24 of 29 total):Bridge.encode_commands!/1accepts arbitrary valid inputs at any rect, andCellSession.draw + take_cellsproduces a snapshot with exactlywidth × heightcells. 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,:bargraph_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, everyEvent.*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 twoinsert_strhelpers, 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 viaarboard, OSC 52, route through a Phoenix LiveView intent). The guide is the canonical place to discover the snippet.
Changed
-
Native.init_terminalarity bumped to 2 —(focus_events :: bool, mouse_capture :: bool).ExRatatui.run/1becomesExRatatui.run/2accepting:focus_eventsand:mouse_captureopts;run/2with no opts is equivalent to the oldrun/1.ExRatatui.Server.start_link(and everyExRatatui.App) accepts the same two opts for the:localtransport. Theinit_terminal/0form is gone — direct NIF callers (custom transports, examples) must pass both booleans explicitly. -
[:ex_ratatui, :transport, :connect]span metadata gains:focus_eventsand:mouse_capturefor the:localtransport, reflecting the boolean opts the app started with. Other transports' connect metadata is unchanged. -
ExRatatui.Widgets.Blockdefaults.defstructnow includes:titles,:title_position,:title_alignment,:title_stylewith backward-compatible defaults ([],:top,:left,nil). Apps constructing blocks with%Block{...}and only the old fields are unaffected; the doctest inBlock.moduledocshows the new shape. -
ExRatatui.Widgets.Listdefaults.defstructadds:direction(:top_to_bottom),:scroll_padding(0),:repeat_highlight_symbol(false). Matches ratatui's defaults. -
ExRatatui.Widgets.Tabledefaults.defstructadds: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,TableDatagainDefaultimpls. 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.