All notable changes to Drafter are documented here. Versions marked with ★ were published to Hex.pm.

[0.3.0] - 2026-08-03

Upgrading from 0.2.x: see Removed and the first three Changed entries. An application that only uses Drafter.App, the widget constructors and Drafter.run/2 needs no source changes; a custom widget that paints images or declares traits does.

Added

  • slider(opts) widget — a draggable value slider with a track, a fill and a thumb. Arrow keys move one step, PageUp/PageDown ten, Home/End jump to the ends of the range, and a press or drag anywhere on the track moves the thumb there (the gesture keeps tracking once the pointer leaves the widget). Supports min/max/step, bind: two-way binding, on_change, a label and a formatted readout that reserves the widest value in the range so the track never shifts, :disabled, per-part colour overrides, and :horizontal / :vertical orientation. An integer range keeps integer values; any other range works in floats rounded to the decimals the step implies. Drafter.get_widget_value/1 and set_widget_value/2 read and write its number.
  • Slider rendering through french_curverenderer: :braille draws the rounded track and disc thumb as braille cells, and a graphics protocol (:auto, :pixel, :kitty, :iterm2, :sixel) transmits it as a picture, falling back to braille where the terminal has none. The default :text renderer draws characters from the active skin, which now carries a slider glyph group.
  • A font catalogue for digits(value, opts)font: selects :block (7×5 box-drawing outlines, the default), :compact (5×3), :tall (8×4 half blocks), :pixel (4×4 quadrants) or :braille (4×4). Every built-in font covers digits, upper and lower case, and common punctuation, so swapping one for another never drops characters, and a character a font cannot draw renders as blanks of the font's widest glyph rather than raising. Drafter.Widget.Digits.Font exposes height/1, glyph_width/2, text_width/2 and supports?/2 for laying out around a headline. See the Large Text guide.
  • FIGlet fonts in digitsDrafter.Widget.Digits.Figlet.load/1 reads a .flf file and Drafter.Widget.Digits.Font.register/2 names it, after which digits("Vellum", font: :slant) works like any built-in. FIGlet fonts are proportional, so measure with text_width/2 rather than assuming a cell.
  • digits can render as a transmitted image with renderer:, using the same graphics protocols as charts and sliders.
  • 31_font_catalogue.exs and 32_slider.exs examples. The widgets.exs showcase and the theme sandbox gallery gained sliders too.
  • Drafter.Clipboardcopy/2 writes OSC 52 to the terminal the session is attached to, so a copy from an app served over SSH or telnet lands on the client's clipboard, and additionally writes the local clipboard through pbcopy, clip, wl-copy, xclip or xsel when one is on PATH. paste/0 reads the clipboard of the machine the app process runs on. Both are configured by clipboard: in Drafter.run/2 or config :drafterfalse makes them no-ops returning {:error, :disabled}, and a keyword list sets the two directions separately (clipboard: [copy: true, paste: false]). Key bindings come from :clipboard_keys.
  • A widget receives pasted text by declaring handles: [:paste] and implementing Drafter.Widget.handle_paste/2.
  • Drafter.Pty — runs a program on a pseudoterminal and hands its byte stream to the caller. spawn/2 allocates the pty, starts the program in a new session with the pty as its controlling terminal, and returns a handle whose ports deliver {:data, bytes} and {:exit_status, status} straight to the calling process, with no relay in between. write/2 feeds standard input and resize/3 sets the size, which makes the kernel deliver SIGWINCH to the program.
  • Pluggable character-width tables. Drafter.CharacterWidth measures per grapheme cluster and is the single point every width measurement goes through — strip widths, truncation, wrapping, cursor placement and the compositor's column arithmetic. A host that owns the grid Drafter draws into can supply its own tables with config :drafter, character_width: MyTables; the setting is read with Application.compile_env/3, so the calls compile to direct dispatch.
  • refresh_rate accepts a frame-rate string"30fps", "7.5fps", "unlimited", :unlimited, or a millisecond integer, in Drafter.run/2 or the app's Drafter.App.refresh_rate/0. Anything else raises ArgumentError rather than pacing at a silently wrong rate.
  • Drafter.Test.screen_text/1 and screen_lines/1 — what is on screen as plain text, so a test can assert on rendered output instead of only on state. sync/1 blocks until the app has drained what it was sent.
  • Docking in layout — a child carrying dock: :top | :bottom | :left | :right is taken out of the normal flow and given the full span of that edge; the remaining space is what its undocked siblings share. footer docks to the bottom without being asked.

  • Translucent colours. A colour may carry an alpha component, which the compositor blends against what is already in the cell rather than replacing it.
  • radio_set(options, opts) takes :width, which fixes the column width when :cols lays the options out in a grid.
  • Drafter.Widget.image_active?/1 — an optional callback saying whether a widget is painting a transmitted image right now.
  • Drafter.Compositor.write_raw/1 — writes bytes to the terminal through the compositor, so output from an embedded program interleaves with the frame rather than racing it.
  • handles: [:click] is accepted as a spelling of handles: [:press].
  • Terminal resize is driven by SIGWINCH, so a window change is picked up as it happens rather than at the next poll.
  • The terminal is asked what graphics it supports instead of being guessed at. At startup Drafter writes XTVERSION (CSI > q) and primary device attributes (CSI c) and reads the answers, which name the terminal and list its features. This settles kitty, iTerm2, WezTerm, ghostty and sixel support from the terminal itself, so it is right under a multiplexer, over ssh and over telnet alike — none of which carry the environment variables a guess depends on. A terminal that answers neither query within 250 ms falls back to the environment as before. Drafter.Terminal.Probe performs the exchange; FrenchCurve.Capability.probe/0 and from_probe/1 say what to send and what the answers mean.
  • Drafter.Session.Context.terminal_env/0 and terminal_protocol/0 — the terminal a session is attached to, carried between processes by capture/0 and adopt/1 like the rest of the context, and falling back to the host's environment outside a session. Drafter.run_session/3 sets both from :terminal_env and :terminal_protocol keys on the session context a transport builds.
  • Telnet negotiates TERMINAL-TYPE, so a telnet client's TERM is known even when it does not answer the graphics probe. A client that answers neither starts anyway after 250 ms.
  • Drafter.CellSession.take_lines/1 and take_text/1 — the cell grid as plain text, styling dropped and trailing blanks trimmed. The same view Drafter.Test.screen_lines/1 gives of a headless app, so a host embedding a session can assert on or log what is displayed without flattening strips itself.
  • README sections for embedding and number formatting. Drafter.CellSession and Drafter.Format were documented on the modules but named nowhere a reader would find them.
  • Two new guidesLarge Text, on choosing a digits font and how the fonts are built, and Design Notes, on how the internals fit together, for anyone writing widgets or changing the framework.

Changed

  • Drafter.WidgetHierarchy is documented. Drafter.Test.get_widget_hierarchy/1 returns it and the README describes its fields, but the module was marked internal, so the struct a test is handed had no reference to read.
  • Element examples render again in the published docs. A doctest written inside an option list was absorbed into that list, and its result line — {:label, "Ready", []} and fourteen others — was parsed as a Markdown attribute list and dropped. The examples always ran; only the rendered page lost them.
  • header(title, opts) no longer shows a clock unless asked. :show_clock now defaults to false, so a header draws a title and starts no timer of its own. Pass show_clock: true for the previous behaviour.
  • option_list draws its cursor only while focused, and takes :focused as a mount option. Several lists can now sit side by side — arrow keys move between them and the highlight of an unfocused list stays put without competing for the eye. Drafter.get_widget_state/1 reports the field.
  • Drafter.Compositor.put_image/4 takes the placement map a widget's image/3 returns (%{dx:, dy:, cols:, rows:, stamp:, place:}) instead of nine positional arguments. Custom widgets that transmit images must update their calls.
  • Widget traits are declared with use Drafter.Widget, traits: [...]. The Spark traits do ... end block is gone, and with it Drafter's dependency on a DSL compiler for widget definitions. A widget that used the block moves its trait list into the use options.
  • use Drafter.App appends its catch-all handle_event/2 and on_timer/2 clauses after a module's own clauses rather than defining them ahead of an override. A module that already ends with its own catch-all keeps working; one that never wrote a catch-all now falls through to {:noreply, state} instead of raising FunctionClauseError on the first unnamed key. A module whose own catch-all is followed by more clauses will now see an unreachable-clause warning where the clauses were previously silently dropped.
  • A widget that draws characters costs nothing on the image path. The renderer and the widget server ask Drafter.Widget.image_active?/2 before placing or generating an image, so a chart, gauge, digits or slider in a :text or :braille mode never enters it. A widget that was painting and stops has its image withdrawn once at the transition rather than re-cleared every frame.
  • Widget value reads and writes go through Drafter.WidgetValue, so a value read through the app loop and one read directly with Drafter.get_widget_value/1 always agree.
  • Per-session services are resolved from the calling process rather than from a global name. Drafter.Session.Context holds the association, with capture/0 and adopt/1 for carrying it to another process, and falls back to a globally registered process so a widget can still be rendered outside any session. Concurrent SSH and telnet sessions no longer see each other's widget registrations.
  • The framework's ETS tables are owned by a supervised process for the lifetime of the application, so a crash in the process that happened to create one no longer takes the table with it.
  • Terminal input is buffered across reads, so an escape sequence split across two reads — common on slow links and under multiplexers — is reassembled instead of being delivered as garbage keys.
  • Drafter.Test's send_* functions block until the app has finished handling the input, so a send and the assertion after it need no sleep between them.
  • The API documentation has been rewritten throughout, covering every public callback, element constructor, option and return value, with executable examples. The README now documents Drafter.run/2's options, the two handle_event arities and which events reach each, screen types and their option defaults, selector syntax, and headless testing. Several previously documented behaviours were wrong — notably Ctrl+C was never a global quit key; Ctrl+Q is, and Ctrl+C is delivered to the app and is the copy binding inside text widgets.

Removed

  • Drafter.Examples.* and the lib/drafter/examples/ tree. Twenty-one demo modules compiled into the library and shipped to every application that depended on it, several of them scratch files (button_debug, button_test, scrollable_test, input_debugger) and most of them stale duplicates of the maintained scripts under examples/. The two with no script counterpart are now examples/spark/33_css_styling.exs and examples/spark/34_breakpoints.exs.
  • Drafter.Util. normalize_class/1 and normalize_classes/1 moved to Drafter.Style, which is where the :class option they serve belongs; safe_to_existing_atom/1 had no callers.
  • Drafter.Transport.SSHChannel. An unused :ssh_server_channel implementation. The ssh transport runs an app through an OTP shell: function, which the runtime graphics probe reaches as readily as a custom channel would, so nothing needed it.
  • Drafter.TreeDiff, Drafter.Widget.DirtyTracker, Drafter.Widget.Trait.Dsl and Drafter.Visualization.BinarySearch. These were internal to render-path bookkeeping and the trait DSL, and nothing replaces them at the call site: fingerprinting and dirty tracking now live in the compositor's per-row cache, and traits are declared in use Drafter.Widget.

Fixed

  • Terminals that speak sixel are detected. Konsole, foot and mlterm drew braille where they could have drawn images: Drafter carried its own copy of the terminal rules, and that copy knew no sixel terminal at all. Detection now goes through FrenchCurve.Capability.detect/1, which also recognises kitty and ghostty from TERM alone — a kitty session that does not export KITTY_WINDOW_ID, such as one inside a multiplexer, previously fell back to braille.
  • A remote session detects the connecting client's terminal rather than the host's. Every ssh and telnet session read the environment of the machine the server runs on, so a server started under a plain shell served braille to every client whatever they were running, and a server started inside kitty sent kitty escape sequences to clients that could not draw them.
  • Switching a chart off a pixel renderer clears the graphic it already sent, instead of leaving the old image on screen underneath the new glyphs, where the two appeared to alternate as rows repainted.
  • Escape sequences in pasted text never reach a widget's buffer. A paste is sanitized where it is handed to the focused widget, so it applies to text_input, text_area and file_picker as well as to widgets declaring handles: [:paste]. Ordinary text and the newlines of a multi-line paste come through unchanged.
  • A widget rendered into a rect too small to draw in produces strips that fit it rather than raising — reachable in a narrow split, a collapsed pane, or a small terminal.
  • A rapid second click is reported as consumed, so a double click no longer leaks its second half to the widget underneath.
  • Dragging a data_table scrollbar no longer also selects the row released on, no longer leaves the table stuck in drag mode after a plain click, and clamps at the ends instead of overscrolling. A table dragging its scrollbar captures the pointer, so the drag keeps tracking once the pointer leaves the table.
  • A scrollbar thumb round-trips: the row a thumb is drawn at maps back to the offset that drew it, so dragging a thumb no longer drifts against the content.
  • A hovered switch is drawn differently, rather than only recording that it is hovered.
  • A widget scrolled entirely off screen is marked invisible, so it stops transmitting images that would be clipped away.
  • A slider's :renderer can change after mount, so an app switching render modes at runtime moves its mounted sliders with it.
  • A telnet session registers its loop while it runs and leaves none behind once it closes, and the last of several rapid keystrokes reaches the wire.

[0.2.11] - 2026-06-29

Added

  • Drafter.set_widget_value/2 — imperatively set a widget's value on demand (text widgets take a string, checkbox a boolean). The discrete counterpart to get_widget_value/1; routes through the widget's own update/2 so text widgets reclamp the cursor. Lets a client own a self-managed input widget and write into it at specific moments rather than mirroring state on every keystroke.

Changed

  • Per-keystroke render cost greatly reduced for editor-heavy UIs. Incremental compositing now tracks dirty regions per row (a cache-key per strip) instead of per widget, so editing one line recomposites a single row instead of the whole widget; and a keystroke that changes neither app state nor layout now skips the component-tree reconcile via the render fast path.
  • get_widget_value/1 and get_widget_state/1 read the widget's WidgetServer directly through the widget registry instead of round-tripping a message through the app loop. They are now deadlock-free and safe to call from inside app event handlers.

[0.2.10] - 2026-03-27

Added

  • Weighted scatter points — scatter data points accept an optional weight component ([x, y, weight] or {x, y, weight}) where weight is a float 0.0–1.0. Higher weights produce denser braille dot clusters and brighter colors, providing visual density feedback for clustered data.
  • fill_opacity chart option — controls the brightness of area fill relative to the series edge color, from 0.0 (invisible) to 1.0 (same as edge). Default 0.6. Applies to :area and :braille_area chart types.
  • Braille area example26_braille_area.exs demonstrates stacked braille area charts with per-series opacity.
  • SSH anonymous authentication — pass auth: :anonymous to Drafter.Server to allow anonymous SSH connections (still need password, but do not need to be registered).

Changed

  • Adaptive color depth in braille area charts — fill color now scales inversely with span thickness, producing more natural gradient fills for stacked series.

[0.2.3] - 2026-03-24

Added

  • SSH chat channels — the ssh_chat.exs example now supports multiple chat rooms. /join #channel switches rooms, /channels lists active rooms, /help shows available commands.
  • scrollable/2: focusable: false option — excludes a scrollable container from the tab/focus cycle on a per-instance basis.

Fixed

  • Various bug fixes and rendering improvements across SSH, layout, focus management, and screen lifecycle.

[0.2.2] - 2026-03-24

Added

  • CJK / multi-byte character input — Japanese, Chinese, Korean, and emoji characters can now be typed into text inputs and text areas.
  • Braille area chart — new chart_type: :braille_area renders smooth stacked area charts using braille characters with per-series color blending. Ideal for live metrics dashboards.
  • Collapsible focus behaviour — children of collapsed sections are automatically excluded from keyboard navigation. Expanding a section makes its children navigable again.

Fixed

  • Arrow key navigation — arrow keys now correctly move focus between widgets. A regression had silently disabled all arrow-based navigation.
  • Text input race condition — rapidly typed characters no longer get dropped due to async binding updates overwriting widget state.
  • Split pane resize crash — dragging a pane to a very narrow width no longer crashes the box widget.
  • Action handlers in sub-apps — custom action handlers registered before Drafter.run now work correctly when launched from the example gallery or any push-session context.

Changed

  • NIF compilation — switched from a custom build script to elixir_make with a standard Makefile. Run mix deps.get after upgrading.
  • Session isolation — ScreenManager, ThemeManager, EventHandler, and Event.Manager are no longer started as global named processes. Each session creates its own instances, preventing state leakage between concurrent SSH/telnet sessions.
  • Event.Manager simplified — removed the internal queue; events are dispatched directly in handle_cast, matching standard GenServer semantics.
  • SkinManager — character set selection is now per-session instead of global, avoiding cross-session interference and persistent_term global GC.

Removed

  • FocusRegistry — unused global keybinding store that would have caused bugs with multiple SSH sessions.
  • Event.CustomRegistry — unused runtime schema validation registry. Use defstruct and pattern matching instead.
  • Event.Processor — stub module with unimplemented functions; all functionality lives in WidgetHierarchy.

[0.2.0] - 2026-03-21

⚠ Breaking Changes

Drafter.set_interval/2 — new unit-aware API

The second argument to set_interval is now a unit atom that also serves as the timer ID passed to on_timer/2. Any app using set_interval must update both the call site and the matching on_timer clause.

Before:

def on_ready(state) do
  Drafter.set_interval(33, :my_timer)
  state
end

def on_timer(:my_timer, state), do: ...

After — choose the unit that matches your intent:

def on_ready(state) do
  Drafter.set_interval(30, :fps)   # 30 fps  → ~33 ms interval
  # or
  Drafter.set_interval(500, :ms)   # 500 ms interval
  state
end

def on_timer(:fps, state), do: ...
# or
def on_timer(:ms, state), do: ...

The available units are:

UnitMeaningExample
:fpsfires N times per secondset_interval(30, :fps) → ~33 ms
:msfires every N millisecondsset_interval(500, :ms) → 500 ms
:tickalias for :ms (backward compat)set_interval(500, :tick)

The timer ID used in on_timer/2 is always the unit atom you passed, not a separate name. If you need two independent timers, use two different unit atoms (or combine :tick for one and :fps/:ms for the other):

def on_ready(state) do
  Drafter.set_interval(30, :fps)
  Drafter.set_interval(1000, :ms)
  state
end

def on_timer(:fps, state), do: ...   # animation tick
def on_timer(:ms, state), do: ...    # slow poll

Apps using send(self(), {:set_interval, ms, id}) directly must switch to Drafter.set_interval/2. Direct sends bypass session isolation and can cause timers from one session to fire in the next.

Added

  • run_examples.exs: example gallery now starts TreeSitterDaemon automatically, so syntax-highlighted examples (e.g. code_browser) work without any extra flags when launched from the gallery

Fixed

  • Timer events from a finished session no longer bleed into the next session — stale {:timer, _} messages are drained from the process mailbox when a new session starts
  • Drafter.run/2 called with syntax_highlighting: true from inside a running app (gallery → sub-example) now correctly starts TreeSitterDaemon; previously the tree-sitter daemon was only started for the root run/2 call, so sub-sessions always fell back to plain-text rendering
  • set_interval called during on_ready is now captured synchronously and immune to race conditions when the gallery launches two examples in quick succession
  • Gauge example +/- keyboard shortcuts now work (previously only the on-screen buttons worked); the keybindings hint was declared but the key handlers were missing

[0.1.28] - 2026-03-20

Added

  • run_examples.exs — interactive example gallery; run with elixir run_examples.exs to browse and launch all bundled examples. Returns to the gallery after each example exits. Note: the SSH example terminates the launcher on Ctrl+C — this is expected.

  • Gauge widget — semi-circular arc gauge rendered with braille characters; colour transitions through configurable low/mid/high thresholds as value increases.

    gauge(value: 0.72)
    gauge(value: cpu_usage, label: "CPU", low_threshold: 0.6, high_threshold: 0.8)
  • CodeView: hex_view: true prop — displays binary files as a hex dump instead of attempting text rendering. The code_browser example enables this automatically for non-text files.

Fixed

  • Calculator keyboard input dropped characters and missed button animations (PR #3, nshkrdotcom; follow-up fix for hierarchy state sync)
  • Running multiple Drafter.run/2 calls in sequence (e.g. via run_examples.exs) no longer causes leftover widget processes, stale renders, or corrupted input from a previous app bleeding into the next
  • {:stop, :normal} returned from a widget on_select / on_click callback now correctly stops the application
  • Tree-sitter "No language found" messages no longer bleed into the TUI output
  • StyleHelpers: stylesheet detection broken for apps using use Drafter.App with CSS (fixes #1)
  • widgets.exs example crashed on text input

[0.1.22] - 2026-03-17

Added

  • Chart: area_fill: :inverted option for area charts — fills from the baseline upward (braille dots at bottom, empty space above); default behaviour (dots at top) is unchanged

Fixed

  • Chart: area_fill prop was silently overwritten to :default on every re-render because ComponentRenderer always passed a default value; now passes nil when unspecified so update/2 preserves the mounted value

[0.1.21] - 2026-03-16

Added

  • ScrollableContainer: click_to_scroll: true opt-in mode — scroll events are claimed by the parent container by default; Ctrl+Click inside the viewport toggles scroll-lock on that container (border highlights to show active state); clicking outside clears the lock. Nested scroll containers register themselves as exceptions at mount time so the per-event routing check only runs when exceptions exist (zero overhead when no nesting).

Fixed

  • WidgetHierarchy: find_scroll_container_at now consults scroll_exceptions — a non-click_to_scroll inner container (e.g. DataTable's internal scroller) is skipped when an outer click_to_scroll container has it registered as an exception and is not scroll-locked; Ctrl+Click locking the outer container restores normal inner-scroll behaviour
  • app_event_loop: first keypress after closing a modal no longer dropped — when dispatch_event_sync causes all screens to pop, render_app is called immediately to produce a fresh, consistent widget_hierarchy; previously the stale hierarchy caused a spurious phash2 mismatch that set consumed = true and silently swallowed the event
  • ComponentRenderer: auto-generated widget IDs are now namespaced by app_module (e.g. ThemeSandbox_button_1 vs InputModal_button_1), eliminating ETS strip collisions between base-app and modal hierarchies that caused base-app widgets (e.g. the "Open Modal" button) to visually disappear when a modal was opened
  • Drafter.App: on_scroll_active/1 optional callback — fires once on the first scroll event of a gesture; return updated state (e.g. %{state | scrolling: true})

  • Drafter.App: on_scroll_idle/1 optional callback — fires when the 150 ms debounce settles after the last scroll event; return updated state (e.g. flush pending data, clear scrolling flag)
  • Drafter.App: on_message/2 optional callback — receives any process message not recognised by the drafter event loop (PubSub, send/2, GenServer casts, etc.); return updated state. Previously all such messages were silently dropped.
  • Drafter.run/2 / run_session/3: scroll_optimization: false opt-out — disables the fast render/debounce path and triggers a full render_app on every scroll tick. Default is true.
def on_scroll_active(state), do: %{state | scrolling: true}

def on_scroll_idle(state) do
  state = if state.pending_data, do: apply_pending_data(state), else: state
  %{state | scrolling: false, pending_data: nil}
end

def on_message({:data_refreshed, _uid, payload}, state), do: %{state | rows: payload.rows}

Drafter.run(MyApp, scroll_optimization: false)

Fixed

  • Modal focus isolation: base app widgets are now defocused (blurred) whenever a modal/screen is active — the button that opened the modal no longer retains focus styling or responds to keyboard events while the modal is open; focus is restored to the first focusable widget after the modal closes
  • ScreenManager: meaningful_hierarchy_change? now compares widget state hashes (phash2), so text input changes inside modals are correctly detected as meaningful and the updated hierarchy is persisted
  • WidgetServer: event_sync no longer calls notify_render_needed — the event loop renders after event_sync returns; calling it again was flooding the mailbox with one {:widget_render_needed} per scroll tick
  • WidgetHierarchy: update_widget no longer blocks on WidgetServer.get_state/1 after casting update_propsupdate_props is now a true fire-and-forget cast; ETS has the authoritative strips so rendering is unaffected
  • app_event_loop / shared_session_loop: {:widget_render_needed} handler drains all pending notifications before doing a single render_hierarchy, eliminating N-fold duplicate composites when multiple widgets fire at once
  • app_event_loop / shared_session_loop: :scroll_debounce_render handler drains all accumulated debounce messages before triggering one render_app, preventing update debt accumulation during slow/continuous scrolling
  • render_hierarchy (fast scroll path): modals, popovers, and toasts are now correctly composited during scroll — previously the fast path painted only base app widgets, overwriting any open modal. Now reads screen and toast layers from stored ETS/hierarchy state with no ComponentRenderer re-run.
  • Removed sync_widget_states/1 — no longer needed; render_hierarchy reads strips directly from ETS

[0.1.19] - 2026-03-16

Changed

  • WidgetServer: each widget owns its strip buffer via WidgetStripCache (ETS, public, read_concurrency: true) — rendering happens inside the widget's own GenServer process and results are written to ETS; create_widget_layers_from_hierarchy reads from ETS directly (no inter-process messaging, no round-trips)
  • WidgetServer: update_props renders and writes to ETS when state changes but does not send {:widget_render_needed} — only autonomous widget state changes (events, timers) notify the event loop, eliminating redundant re-composites after render_app
  • WidgetServer: update_props with identical resulting state is a no-op (no render, no ETS write)
  • ScrollableContainer: scroll events use a fast render path — render_hierarchy re-clips ETS-cached strips without running ComponentRenderer; 150 ms debounce fires render_app once after scroll settles
  • MouseProcessor: mouse_move while a button is held routes to the mouse_down_widget regardless of cursor position, enabling drag-out-of-bounds behaviour; mouse_up after drag-release outside the originating widget notifies the mouse_down_widget so it can clear drag state

Added

  • WidgetStripCache: ETS-backed strip store keyed by widget ID; lock-free reads from any process
  • ScrollableContainer: click on scrollbar track jumps one viewport page toward the thumb
  • ScrollableContainer: drag the scrollbar thumb — mouse_down on thumb begins drag, mouse_move continuously updates scroll offset, mouse_up ends drag

[0.1.18] - 2026-03-15

Fixed

  • DataTable: click and Enter now toggle selection in both :single and :multiple modes — clicking or pressing Enter on an already-selected row deselects it; previously change_selection/3 always set selection, while Space already toggled correctly via action_toggle_selection
  • DataTable: arrow key navigation no longer inadvertently toggles selection in :multiple mode — action_cursor_up/down now pass trigger_select: false so moving the cursor never changes the selected set; only Enter, Space, and click change selection
  • Collapsible: hidden children no longer receive mouse events — find_widget_at now excludes hidden_widgets from hit testing, preventing clicks intended for widgets beneath a collapsed section (e.g. a DataTable header) from being intercepted by invisible child widgets
  • Collapsible: widget content (list) no longer renders over siblings below it — two root causes fixed:
    • Collapsible.update/2 was resetting content_height to the default (10) on every re-render when only content was passed in updated_props, corrupting the stored height after the first render
    • get_child_vertical_spec / get_preferred_height ignored the expanded: and content_height: options when the widget was not yet in the hierarchy (first render), always returning height 1 and placing the next sibling at the wrong y position

[0.1.17] - 2026-03-15

Added

  • Digits: bg_data: prop renders a braille line chart (4× vertical resolution per terminal row) behind the digit glyphs; color: sets the line colour; digits take priority where glyphs overlap braille dots
  • Sparkline: orientation: :horizontal renders each data point as a left-to-right bar using left-aligned eighth-block characters (▏▎▍▌▋▊▉█)
  • Chart: pixel_style: :quadrant option for line and scatter charts — uses quadrant block characters (▖▗▘▝▚▞▛▜▟▙▀▄▌▐█) at 2×2 pixel resolution per cell, giving larger/more visible dots than braille

[0.1.16] - 2026-03-15

Changed

  • Digits: improved B glyph in both large and small sizes — more distinguishable from 8 and 6; large uses flat spine with / bump sides, small uses divider in the middle row

Added

  • Rule: new widget — horizontal/vertical divider line with optional embedded title, title_align, and line_style (:solid, :double, :dashed, :thick)
  • Tree: on_node_highlight: callback fires whenever cursor moves to a new node; Shift+←/Shift+→ navigates to previous/next sibling at the same depth
  • SelectionList: on_item_toggle: callback fires with {index, selected?} on each individual item toggle; Home/End jump to first/last item; Ctrl+A toggles select-all / deselect-all in :multiple mode
  • MaskedInput: on_submit: callback fires with the raw unmasked value on Enter
  • TextArea: text selection (Shift+Arrow, Ctrl+A), copy/cut/paste (Ctrl+C/X/V), undo/redo (Ctrl+Z/Y), read_only:, tab_behavior: (:focus or :indent), tab_size:, max_checkpoints:, word navigation (Ctrl+←/), page up/down, highlight_cursor_line:

[0.1.15] - 2026-03-15

Added

  • DataTable: per-cell background colouring via color_fn: (raw_value -> {r,g,b} | nil) on column definitions; applied when the row is not selected

  • DataTable: 3-state column sort cycle — click cycles ascending → descending → unsorted (restores original data order); indicator shown on all sortable-but-unsorted columns when sortable: true
  • DataTable: table-level sortable: false option disables all sort indicators and click-to-sort
  • DataTable: column width drag-resize — drag a column header to resize (when locked: true, the default); minimum 3 characters
  • DataTable: column reorder — Shift+← / Shift+→ moves the cursor column; drag a header while locked: false swaps columns live
  • DataTable: locked: option — true (default) makes header-drag resize; false makes header-drag reorder
  • DataTable: on_layout_change: callback — fires with %{col_widths: [...], col_order: [...]} after any resize or reorder
  • DataTable: col_widths: and col_order: mount/update props to restore a previously saved layout
  • DataTable: keyboard resize (+/-) fires on_layout_change after each step
  • DataTable: FocusRegistry integration — footer key-binding bar updates dynamically when the table gains focus
  • FocusRegistry: new GenServer tracking the focused widget's key bindings; consumed by Footer for dynamic display
  • EventRouter: {:key, key, mods} events now dispatch to handle_key/3 if exported, falling back to handle_key/2

[0.1.14] - 2026-03-14

Fixed

  • Timer-driven re-renders skipped when on_timer/2 returns state unchanged (===); applies to both app_event_loop and shared_session_loop. Eliminates redundant render_app / widget tree traversal on poll timers that find no new data.
  • {:widget_render_needed} (fired by widget-internal timers such as the header clock) no longer triggers ComponentRenderer.render_tree. It now calls render_hierarchy which re-composites directly from the already-synced widget states, avoiding update_widget calls — and therefore filter_list — on every clock tick.

[0.1.13] - 2026-03-14 *

Added

  • Multi-series line charts: pass a list of series (list of lists) to chart_type: :line
  • Multi-series scatter charts: pass a list of point-lists to chart_type: :scatter
  • :clustered_bar chart type — grouped multi-series bars with half-block resolution
  • :stacked_bar chart type — series stack from baseline; supports mixed positive/negative values
  • :range_bar chart type — each bar spans a [low, high] range
  • Negative value support documented and verified across all chart types
  • multi_series_charts.exs example demonstrating all new chart variants

Fixed

  • Area chart crash (ArithmeticError) when passed multi-series data; now dispatches to render_multi_series matching the same guard added to line chart

Changed

  • Chart moduledoc expanded with sections for negative values, multi-series API, and all bar types

[0.1.11] - 2026-03-14 ★

Added

  • Scrollable viewport culling: off-screen children skipped during render_component calls, reducing GenServer traffic per frame for large scrollable lists

Changed

  • count_component_slots/1 introduced to advance the ID counter for culled components, preserving auto-generated widget IDs for on-screen widgets

[0.1.10] - 2026-03-14

Fixed

  • Chart axis labels: float concatenation crash in format_axis_value/1 for values ≥ 1000

[0.1.9] - 2026-03-14

Fixed

  • Binding resolution: Checkbox now reads :checked from opts at mount (was always false)
  • ComponentRenderer checkbox update path now syncs :checked and :on_change on re-render
  • ComponentRenderer radio_set update path now passes :options and :selected (was only :on_change and :classes, leaving options frozen after mount)
  • RadioSet.update/2 no longer resets highlighted_index on every timer-driven re-render

[0.1.8] - 2026-03-14 ★

Added

  • Differential rendering in compositor: row-level dirty detection via Strip.cache_key (:erlang.phash2 hash); unchanged rows skipped each frame, drastically reducing terminal output on static or partially-static screens
  • Stale test cleanup: removed 10 test files referencing renamed/removed modules

Fixed

  • TextInput: scroll offset was double-subtracting border width, causing scroll to trigger 2 characters early
  • TextInput: typed text no longer reset on re-render when widget has no :bind or :value prop

[0.1.6] - 2026-03-14 ★

Fixed

  • RadioSet: options passed as raw tuples were not normalised at mount; now always stored as %{id: _, label: _} maps
  • RadioSet: options not updating on re-render after first mount
  • RadioSet: highlighted_index frozen after navigating before first selection

[0.1.5] - 2026-03-14 ★

Added

  • Collapsible widget now supports interactive child widgets (buttons, inputs, etc.) inside the expanded body, not just plain text

Fixed

  • Collapsible.update/2: content_height no longer inherits stale value when content type changes between renders

[0.1.4] - 2026-03-13 ★

Fixed

  • SSH: reverse entry bug introduced when SSH support was added
  • Local startup issues with terminal initialisation

[0.1.3] - 2026-03-13 ★

Fixed

  • Input handling cleanup following SSH integration

[0.1.2] - 2026-03-13 ★

Added

  • Guide: Remote TUI over SSH/Telnet (guides/remote_tui.md)

[0.1.1] - 2026-03-13 ★

Added

  • SSH and Telnet remote TUI support via Drafter.Server
  • Remote client connects over standard SSH; full terminal interaction over the wire

Fixed

  • Theme switching between light and dark modes

[0.1.0] - 2026-03-12 ★

Added

  • Initial public release
  • Core framework: Drafter.App behaviour, widget lifecycle, event system
  • Widget library: Label, Button, TextInput, TextArea, Checkbox, Switch, RadioSet, SelectionList, OptionList, MaskedInput, Link, DataTable, Tree, DirectoryTree, Chart, Sparkline, ProgressBar, LoadingIndicator, Pretty, Digits, Log, RichLog, Rule, Placeholder, Markdown, CodeView, Collapsible, TabbedContent, Card, Container, ScrollableContainer, Grid, Header, Footer
  • Theming system with light/dark built-in themes and custom theme support
  • Braille-dot chart rendering with line, area, bar, scatter, and candlestick types
  • Layout engine: vertical, horizontal, scrollable containers with flex sizing
  • Focus management: tab and arrow-key geometric navigation
  • Multi-screen navigation stack with modal support
  • Toast notification system with 9 positions and stack limiting
  • Tree-sitter syntax highlighting integration (opt-in)
  • Windows terminal support
  • Dynamic actions and native alert/confirm dialogs
  • Custom action handler API