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/PageDownten,Home/Endjump 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). Supportsmin/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/:verticalorientation. An integer range keeps integer values; any other range works in floats rounded to the decimals the step implies.Drafter.get_widget_value/1andset_widget_value/2read and write its number.- Slider rendering through
french_curve—renderer: :brailledraws 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:textrenderer draws characters from the active skin, which now carries asliderglyph 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.Fontexposesheight/1,glyph_width/2,text_width/2andsupports?/2for laying out around a headline. See the Large Text guide. - FIGlet fonts in
digits—Drafter.Widget.Digits.Figlet.load/1reads a.flffile andDrafter.Widget.Digits.Font.register/2names it, after whichdigits("Vellum", font: :slant)works like any built-in. FIGlet fonts are proportional, so measure withtext_width/2rather than assuming a cell. digitscan render as a transmitted image withrenderer:, using the same graphics protocols as charts and sliders.31_font_catalogue.exsand32_slider.exsexamples. Thewidgets.exsshowcase and the theme sandbox gallery gained sliders too.Drafter.Clipboard—copy/2writes 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 throughpbcopy,clip,wl-copy,xcliporxselwhen one is onPATH.paste/0reads the clipboard of the machine the app process runs on. Both are configured byclipboard:inDrafter.run/2orconfig :drafter—falsemakes 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 implementingDrafter.Widget.handle_paste/2. Drafter.Pty— runs a program on a pseudoterminal and hands its byte stream to the caller.spawn/2allocates 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/2feeds standard input andresize/3sets the size, which makes the kernel deliverSIGWINCHto the program.- Pluggable character-width tables.
Drafter.CharacterWidthmeasures 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 withconfig :drafter, character_width: MyTables; the setting is read withApplication.compile_env/3, so the calls compile to direct dispatch. refresh_rateaccepts a frame-rate string —"30fps","7.5fps","unlimited",:unlimited, or a millisecond integer, inDrafter.run/2or the app'sDrafter.App.refresh_rate/0. Anything else raisesArgumentErrorrather than pacing at a silently wrong rate.Drafter.Test.screen_text/1andscreen_lines/1— what is on screen as plain text, so a test can assert on rendered output instead of only on state.sync/1blocks until the app has drained what it was sent.Docking in layout — a child carrying
dock: :top | :bottom | :left | :rightis taken out of the normal flow and given the full span of that edge; the remaining space is what its undocked siblings share.footerdocks 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:colslays 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 ofhandles: [: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.Probeperforms the exchange;FrenchCurve.Capability.probe/0andfrom_probe/1say what to send and what the answers mean. Drafter.Session.Context.terminal_env/0andterminal_protocol/0— the terminal a session is attached to, carried between processes bycapture/0andadopt/1like the rest of the context, and falling back to the host's environment outside a session.Drafter.run_session/3sets both from:terminal_envand:terminal_protocolkeys on the session context a transport builds.- Telnet negotiates TERMINAL-TYPE, so a telnet client's
TERMis known even when it does not answer the graphics probe. A client that answers neither starts anyway after 250 ms. Drafter.CellSession.take_lines/1andtake_text/1— the cell grid as plain text, styling dropped and trailing blanks trimmed. The same viewDrafter.Test.screen_lines/1gives 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.CellSessionandDrafter.Formatwere documented on the modules but named nowhere a reader would find them. - Two new guides — Large Text, on choosing a
digitsfont 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.WidgetHierarchyis documented.Drafter.Test.get_widget_hierarchy/1returns 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_clocknow defaults tofalse, so a header draws a title and starts no timer of its own. Passshow_clock: truefor the previous behaviour.option_listdraws its▶cursor only while focused, and takes:focusedas 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/1reports the field.Drafter.Compositor.put_image/4takes the placement map a widget'simage/3returns (%{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 Sparktraits do ... endblock 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 theuseoptions. use Drafter.Appappends its catch-allhandle_event/2andon_timer/2clauses 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 raisingFunctionClauseErroron 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?/2before placing or generating an image, so a chart, gauge, digits or slider in a:textor:braillemode 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 withDrafter.get_widget_value/1always agree. - Per-session services are resolved from the calling process rather than from a global name.
Drafter.Session.Contextholds the association, withcapture/0andadopt/1for 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'ssend_*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 twohandle_eventarities and which events reach each, screen types and their option defaults, selector syntax, and headless testing. Several previously documented behaviours were wrong — notablyCtrl+Cwas never a global quit key;Ctrl+Qis, andCtrl+Cis delivered to the app and is the copy binding inside text widgets.
Removed
Drafter.Examples.*and thelib/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 underexamples/. The two with no script counterpart are nowexamples/spark/33_css_styling.exsandexamples/spark/34_breakpoints.exs.Drafter.Util.normalize_class/1andnormalize_classes/1moved toDrafter.Style, which is where the:classoption they serve belongs;safe_to_existing_atom/1had no callers.Drafter.Transport.SSHChannel. An unused:ssh_server_channelimplementation. The ssh transport runs an app through an OTPshell: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.DslandDrafter.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 inuse 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 fromTERMalone — a kitty session that does not exportKITTY_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_areaandfile_pickeras well as to widgets declaringhandles: [: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_tablescrollbar 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
switchis 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
:renderercan 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,checkboxa boolean). The discrete counterpart toget_widget_value/1; routes through the widget's ownupdate/2so 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/1andget_widget_state/1read the widget'sWidgetServerdirectly 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_opacitychart option — controls the brightness of area fill relative to the series edge color, from0.0(invisible) to1.0(same as edge). Default0.6. Applies to:areaand:braille_areachart types.- Braille area example —
26_braille_area.exsdemonstrates stacked braille area charts with per-series opacity. - SSH anonymous authentication — pass
auth: :anonymoustoDrafter.Serverto 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.exsexample now supports multiple chat rooms./join #channelswitches rooms,/channelslists active rooms,/helpshows available commands. scrollable/2:focusable: falseoption — 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_arearenders 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.runnow work correctly when launched from the example gallery or any push-session context.
Changed
- NIF compilation — switched from a custom build script to
elixir_makewith a standard Makefile. Runmix deps.getafter 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_termglobal GC.
Removed
- FocusRegistry — unused global keybinding store that would have caused bugs with multiple SSH sessions.
- Event.CustomRegistry — unused runtime schema validation registry. Use
defstructand 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:
| Unit | Meaning | Example |
|---|---|---|
:fps | fires N times per second | set_interval(30, :fps) → ~33 ms |
:ms | fires every N milliseconds | set_interval(500, :ms) → 500 ms |
:tick | alias 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 pollApps using
send(self(), {:set_interval, ms, id})directly must switch toDrafter.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 startsTreeSitterDaemonautomatically, 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/2called withsyntax_highlighting: truefrom inside a running app (gallery → sub-example) now correctly startsTreeSitterDaemon; previously the tree-sitter daemon was only started for the rootrun/2call, so sub-sessions always fell back to plain-text renderingset_intervalcalled duringon_readyis 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); thekeybindingshint was declared but the key handlers were missing
[0.1.28] - 2026-03-20
Added
run_examples.exs— interactive example gallery; run withelixir run_examples.exsto browse and launch all bundled examples. Returns to the gallery after each example exits. Note: the SSH example terminates the launcher onCtrl+C— this is expected.Gaugewidget — 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: trueprop — displays binary files as a hex dump instead of attempting text rendering. Thecode_browserexample 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/2calls in sequence (e.g. viarun_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 widgeton_select/on_clickcallback now correctly stops the application- Tree-sitter "No language found" messages no longer bleed into the TUI output
StyleHelpers: stylesheet detection broken for apps usinguse Drafter.Appwith CSS (fixes #1)widgets.exsexample crashed on text input
[0.1.22] - 2026-03-17
Added
Chart:area_fill: :invertedoption 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_fillprop was silently overwritten to:defaulton every re-render becauseComponentRendereralways passed a default value; now passesnilwhen unspecified soupdate/2preserves the mounted value
[0.1.21] - 2026-03-16
Added
ScrollableContainer:click_to_scroll: trueopt-in mode — scroll events are claimed by the parent container by default;Ctrl+Clickinside 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_atnow consultsscroll_exceptions— a non-click_to_scrollinner container (e.g.DataTable's internal scroller) is skipped when an outerclick_to_scrollcontainer has it registered as an exception and is not scroll-locked; Ctrl+Click locking the outer container restores normal inner-scroll behaviourapp_event_loop: first keypress after closing a modal no longer dropped — whendispatch_event_synccauses all screens to pop,render_appis called immediately to produce a fresh, consistentwidget_hierarchy; previously the stale hierarchy caused a spuriousphash2mismatch that setconsumed = trueand silently swallowed the eventComponentRenderer: auto-generated widget IDs are now namespaced byapp_module(e.g.ThemeSandbox_button_1vsInputModal_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 openedDrafter.App:on_scroll_active/1optional callback — fires once on the first scroll event of a gesture; return updated state (e.g.%{state | scrolling: true})Drafter.App:on_scroll_idle/1optional 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/2optional 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: falseopt-out — disables the fast render/debounce path and triggers a fullrender_appon every scroll tick. Default istrue.
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 persistedWidgetServer:event_syncno longer callsnotify_render_needed— the event loop renders afterevent_syncreturns; calling it again was flooding the mailbox with one{:widget_render_needed}per scroll tickWidgetHierarchy:update_widgetno longer blocks onWidgetServer.get_state/1after castingupdate_props—update_propsis now a true fire-and-forget cast; ETS has the authoritative strips so rendering is unaffectedapp_event_loop/shared_session_loop:{:widget_render_needed}handler drains all pending notifications before doing a singlerender_hierarchy, eliminating N-fold duplicate composites when multiple widgets fire at onceapp_event_loop/shared_session_loop::scroll_debounce_renderhandler drains all accumulated debounce messages before triggering onerender_app, preventing update debt accumulation during slow/continuous scrollingrender_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 noComponentRendererre-run.- Removed
sync_widget_states/1— no longer needed;render_hierarchyreads strips directly from ETS
[0.1.19] - 2026-03-16
Changed
WidgetServer: each widget owns its strip buffer viaWidgetStripCache(ETS, public,read_concurrency: true) — rendering happens inside the widget's own GenServer process and results are written to ETS;create_widget_layers_from_hierarchyreads from ETS directly (no inter-process messaging, no round-trips)WidgetServer:update_propsrenders 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 afterrender_appWidgetServer:update_propswith identical resulting state is a no-op (no render, no ETS write)ScrollableContainer: scroll events use a fast render path —render_hierarchyre-clips ETS-cached strips without runningComponentRenderer; 150 ms debounce firesrender_apponce after scroll settlesMouseProcessor:mouse_movewhile a button is held routes to themouse_down_widgetregardless of cursor position, enabling drag-out-of-bounds behaviour;mouse_upafter drag-release outside the originating widget notifies themouse_down_widgetso it can clear drag state
Added
WidgetStripCache: ETS-backed strip store keyed by widget ID; lock-free reads from any processScrollableContainer: click on scrollbar track jumps one viewport page toward the thumbScrollableContainer: drag the scrollbar thumb —mouse_downon thumb begins drag,mouse_movecontinuously updates scroll offset,mouse_upends drag
[0.1.18] - 2026-03-15
Fixed
DataTable: click and Enter now toggle selection in both:singleand:multiplemodes — clicking or pressing Enter on an already-selected row deselects it; previouslychange_selection/3always set selection, while Space already toggled correctly viaaction_toggle_selectionDataTable: arrow key navigation no longer inadvertently toggles selection in:multiplemode —action_cursor_up/downnow passtrigger_select: falseso moving the cursor never changes the selected set; only Enter, Space, and click change selectionCollapsible: hidden children no longer receive mouse events —find_widget_atnow excludeshidden_widgetsfrom hit testing, preventing clicks intended for widgets beneath a collapsed section (e.g. aDataTableheader) from being intercepted by invisible child widgetsCollapsible: widget content (list) no longer renders over siblings below it — two root causes fixed:Collapsible.update/2was resettingcontent_heightto the default (10) on every re-render when onlycontentwas passed inupdated_props, corrupting the stored height after the first renderget_child_vertical_spec/get_preferred_heightignored theexpanded:andcontent_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 dotsSparkline:orientation: :horizontalrenders each data point as a left-to-right bar using left-aligned eighth-block characters (▏▎▍▌▋▊▉█)Chart:pixel_style: :quadrantoption 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: improvedBglyph in both large and small sizes — more distinguishable from8and6; 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, andline_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 depthSelectionList:on_item_toggle:callback fires with{index, selected?}on each individual item toggle;Home/Endjump to first/last item;Ctrl+Atoggles select-all / deselect-all in:multiplemodeMaskedInput:on_submit:callback fires with the raw unmasked value onEnterTextArea: text selection (Shift+Arrow,Ctrl+A), copy/cut/paste (Ctrl+C/X/V), undo/redo (Ctrl+Z/Y),read_only:,tab_behavior:(:focusor: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 viacolor_fn: (raw_value -> {r,g,b} | nil)on column definitions; applied when the row is not selectedDataTable: 3-state column sort cycle — click cycles ascending → descending → unsorted (restores original data order);↕indicator shown on all sortable-but-unsorted columns whensortable: trueDataTable: table-levelsortable: falseoption disables all sort indicators and click-to-sortDataTable: column width drag-resize — drag a column header to resize (whenlocked: true, the default); minimum 3 charactersDataTable: column reorder —Shift+←/Shift+→moves the cursor column; drag a header whilelocked: falseswaps columns liveDataTable:locked:option —true(default) makes header-drag resize;falsemakes header-drag reorderDataTable:on_layout_change:callback — fires with%{col_widths: [...], col_order: [...]}after any resize or reorderDataTable:col_widths:andcol_order:mount/update props to restore a previously saved layoutDataTable: keyboard resize (+/-) fireson_layout_changeafter each stepDataTable:FocusRegistryintegration — footer key-binding bar updates dynamically when the table gains focusFocusRegistry: newGenServertracking the focused widget's key bindings; consumed byFooterfor dynamic displayEventRouter:{:key, key, mods}events now dispatch tohandle_key/3if exported, falling back tohandle_key/2
[0.1.14] - 2026-03-14
Fixed
- Timer-driven re-renders skipped when
on_timer/2returns state unchanged (===); applies to bothapp_event_loopandshared_session_loop. Eliminates redundantrender_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 triggersComponentRenderer.render_tree. It now callsrender_hierarchywhich re-composites directly from the already-synced widget states, avoidingupdate_widgetcalls — and thereforefilter_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_barchart type — grouped multi-series bars with half-block resolution:stacked_barchart type — series stack from baseline; supports mixed positive/negative values:range_barchart type — each bar spans a[low, high]range- Negative value support documented and verified across all chart types
multi_series_charts.exsexample demonstrating all new chart variants
Fixed
- Area chart crash (
ArithmeticError) when passed multi-series data; now dispatches torender_multi_seriesmatching the same guard added to line chart
Changed
Chartmoduledoc 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_componentcalls, reducing GenServer traffic per frame for large scrollable lists
Changed
count_component_slots/1introduced 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/1for values ≥ 1000
[0.1.9] - 2026-03-14
Fixed
- Binding resolution:
Checkboxnow reads:checkedfrom opts at mount (was alwaysfalse) ComponentRenderercheckbox update path now syncs:checkedand:on_changeon re-renderComponentRendererradio_setupdate path now passes:optionsand:selected(was only:on_changeand:classes, leaving options frozen after mount)RadioSet.update/2no longer resetshighlighted_indexon 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.phash2hash); 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 earlyTextInput: typed text no longer reset on re-render when widget has no:bindor:valueprop
[0.1.6] - 2026-03-14 ★
Fixed
RadioSet: options passed as raw tuples were not normalised at mount; now always stored as%{id: _, label: _}mapsRadioSet: options not updating on re-render after first mountRadioSet:highlighted_indexfrozen after navigating before first selection
[0.1.5] - 2026-03-14 ★
Added
Collapsiblewidget now supports interactive child widgets (buttons, inputs, etc.) inside the expanded body, not just plain text
Fixed
Collapsible.update/2:content_heightno 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.Appbehaviour, 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