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

[0.5.0]

Breaking changes

  • Drafter.Event.Delegation is removed (delegate_to_children/4, delegate_to_parent/3, delegate_to_siblings/4, broadcast_to_descendants/3). Address a widget by id through Drafter.WidgetPidRegistry, as Drafter.push_data/2 does.
  • Drafter.Session.start_isolated/3 is now start_isolated/2. Drop the driver_config argument and pass every option, :driver_pid included, in opts.
  • :ease_in_out (and :ease, its alias) is the quadratic curve. Pass :ease_in_out_cubic for the previous motion.
  • Drafter.Clipboard.paste/0 is governed by the paste setting alone. With clipboard: [copy: true, paste: false] it now returns {:error, :disabled}; set paste: true to read the clipboard.
  • An app started as a session, such as one Drafter.Server runs over ssh or telnet, mounts with the props it was given, not %{}. An app that ignored or worked around the empty map receives the real props in mount/1.
  • Drafter.Widget.Chart.Pixel.mode/2 and Drafter.Widget.Slider.Pixel.mode/1 can return {:cells, set} besides :pixel, :braille and :text (type Drafter.Widget.Chart.Pixel.drawing/0), and Chart.Pixel.modes/0 lists :half, :quad, :sextant and :octant. A custom widget matching on the result adds a {:cells, set} clause, drawn with cells_strips/3.
  • iTerm2 draws with its own inline images, even when it answers the kitty graphics query. Set FRENCH_CURVE_ITERM2=kitty to draw with kitty graphics there.
  • A pointer motion report for a button that is not held is a {:mouse, %{type: :move}} event with no :button key, not a :drag. Hover logic that read Ghostty's motion as :drag moves to handle_hover/3 or a :move clause. Drafter.Terminal.InputBuffer gains a :held field, so a match against the whole struct literal no longer equals it.
  • Drafter.Runtime.FrameClock.interval_for/1 raises ArgumentError for "0fps", a non-positive integer and any other term it cannot use, where it raised ArithmeticError or FunctionClauseError. Rescue ArgumentError.
  • Drafter.Widget.RichLog line numbers count from the first line of the log in both directions. A test asserting on the numbers shown in a scrolled or reversed log updates its expected text.
  • A scatter or bubble chart is scaled by the y values of its points. {x, y} tuples and %{x:, y:} maps are read as points; a chart that wants a fixed axis passes :min_value and :max_value.
  • french_curve is required at ~> 0.2, which builds its native code with make through elixir_make. Update a lock that pins an earlier release.
  • The :scroll_optimization option of Drafter.run/2 and Drafter.run_session/3 is removed. Drop it from the call. Every scroll tick renders, paced like any other input, with images kept on screen. Drafter.App.on_scroll_active/1 and Drafter.App.on_scroll_idle/1 are still called.
  • The built-in image widgets export image_raster/2 instead of image/3: Drafter.Widget.Chart, PieChart, Gauge, Slider and Digits. image/4 on Drafter.Widget.Chart.Pixel and on Drafter.Widget.Slider.Pixel is now picture/3, and render/5 on Drafter.Widget.Digits.Image is now picture/4; each returns a Drafter.Render.Picture.t/0, encoded with Drafter.Render.Picture.encode/3.
  • Drafter.Widget.Chart.update_props_from_mount/3 is the default one, passing the props through.
  • An :animated line, area or step chart of one series rotates its points in every renderer, pixel and braille included, where only the text renderer did.

Added

  • Each ssh session's start and end are logged at notice level: the user and their address, and at the end how long it lasted, whether the client closed it, how long before the end the client last sent input and output last went out, and the bytes sent. Each ssh connection's end is logged too, with ssh's reason: the network connection closed by the far side, or the disconnect message the client sent.

  • A widget picture (Drafter.Render.Picture) for a kitty terminal that keeps images may ask for format: :png, sent as a PNG, and swap: true, kept under two image ids so each frame is stored under the one not on screen, placed, and only then the old one deleted, all as one synchronized update. Neither is on by default, and without them a picture is sent as before.

  • A warning is logged when a terminal has taken no output for 5 seconds, frames being skipped meanwhile, and again when it takes output once more; and when an ssh session that does not end in time is killed, with where it was.

  • Drafter.Server.start_ssh/2 takes :send_buffer, the bytes of each connection's send buffer, and :login_timeout, the milliseconds a client has to log in.

  • Drafter.Widget.image_raster/2 — a widget returns a Drafter.Render.Picture.t/0 (a FrenchCurve.Raster, the protocol to draw it with and the cell box it fills) or nil, and drafter encodes it, moves it with the widget and shows only its visible part when it is partly scrolled out of view. Drafter.Widget.draws_images?/1 says whether a module draws images by either callback.

  • Drafter.Render.Picture — encodes a widget's raster for Drafter.Compositor.put_image/4: stored once under its kitty id where the terminal keeps images, or the visible part alone for iTerm2 and sixel.

  • Drafter.Compositor.place_image/4 takes the window an image may be drawn in, and a region given to put_image/4 may carry :crop naming a stored kitty image, which the compositor moves and crops with placements alone.

  • A changed row is written as the runs of cells that changed (Drafter.Render.RowDamage), joined where they are close and written whole where most of it changed, rather than always as the whole row.

  • Game controllers — use Drafter.App, controller: true delivers {:controller, event} to handle_event/2: connections, buttons and axes in the Xbox layout. The reading is done by the separate gamepad package, which an app that wants controllers adds to its deps; drafter itself gains no build step. Local sessions only. Drafter.terminal_opts/1 reports the setting as :controller.

  • Block-character rendering for charts and sliders — renderer: :half | :quad | :sextant | :octant (also as DRAFTER_MODE values and the mode: run option) draws the same raster the pixel path builds as block characters of that FrenchCurve.Backend.Cells glyph set, two colours per cell, through Drafter.Widget.Chart.Pixel.cells_strips/3 and Drafter.Widget.Slider.Pixel.cells_strips/3. Octants (Unicode 16) match braille's resolution with solid pixels where the terminal or font draws them.

  • Drafter.Widget.Chart.Pixel.cell_sets/0 — the glyph sets accepted as modes, and the Drafter.Widget.Chart.Pixel.drawing/0 type naming what mode/2 returns.

  • Drafter.Draw.Strip.from_cells/1 — a strip from one row of {glyph, fg, bg} cells, with neighbouring cells in the same two colours joined into one segment.

  • Drafter.Accounts.list/1 — every account in registration order, each with its :number, username and props.

  • examples/spark/35_isometric_town.exs — an isometric and a first-person raycast view of one town, drawn as pixels or in any block-character set, with mouse targeting and game controller input.

Changed

  • The compiled native code is built into and loaded from priv/native/<system architecture> (Drafter.Native) instead of priv/, so builds for macOS and Linux in one checkout no longer overwrite each other. mix.exs passes the folder to make as PRIV_DIR.

  • An image handed to the compositor with the same bytes and placement as the one it shows is not sent to the terminal again. Drafter.Compositor.put_image/4 keeps a digest of each region's bytes and bumps the region's version only when the bytes or placement differ.

  • The x and y of Drafter.Widget.handle_press/3, handle_mouse_up/3 and handle_drag/3 are documented as cells relative to the widget's rect, which is what they are.

  • A pie chart's raster covers only the circle's bounding square, so a large pie costs less to draw.

Fixed

  • A widget server stops when a process of the session it was started in ends, so a widget outliving its session no longer renders against a stopped theme manager and logs an error.

  • Elixir syntax highlighting works on Elixir 1.20, whose tokenizer returns three elements where 1.19's returns two; on 1.20 every snippet came back uncoloured.

  • Drafter.Server.stop_ssh/1,2 returns :ok when the daemon ends by itself while it is being stopped, as it does once its port is closed and its last session ends; it exited with :noproc or shutdown.

  • End and PageDown on an empty data table do nothing; they set the selection to row -1.

  • Drafter.Test.send_click/2,3 sends a press and then a release, as a terminal reports a click, so widgets that act on the press, such as an option list, can be clicked in tests.

  • examples/*/15_screens.exs: the confirm modal reports :cancelled when it is closed with Esc or No, not only with Enter; 15_screens and widgets show "none yet" instead of nil before anything is chosen.

  • A theme or skin change redraws every widget at once (Drafter.WidgetServer.restyle/1). A widget whose props had not changed kept the old theme until something else redrew it, such as the header until its clock's next second. An app returned to after another one changed the theme is redrawn in the new theme.

  • A local app stops when its terminal goes away: when stdin ends or fails, the loop that called Drafter.Terminal.Driver.start_input/0 is sent :drafter_end_session. The input reader stopped silently, leaving the app running with no keys.

  • Drafter.Server.stop_ssh/1,2 ends each connected session before stopping the daemon: the client's terminal leaves the alternate screen with mouse and keyboard reporting off, a notice is printed (stop_ssh/2; default "The server closed this session."), and the connection closes. A client was left on its last frame. Stopping the application does the same for every ssh session ("The server stopped."). Drafter.Server.stop_ssh_listening/1 closes the port alone, leaving connected sessions running; the message :drafter_end_session ends a session's app loop and every app beneath it.

  • use Drafter.App appends its catch-all handle_event/2 and on_timer/2 clauses only when the module has no catch-all of its own, so a module that writes one compiles without a redundant-clause warning.

  • Images in a scrollable stay on screen while it scrolls. A partly visible chart shows its visible rows, cropped from the full picture. On a kitty terminal a scroll sends placements and no pixels; on iTerm2 and sixel only the visible rows are encoded.

  • A widget scrolled into view is drawn on the tick it appears, not 150 ms after scrolling stops.

  • A chart that is not animated keeps the same state while its data does not change, so its picture is not drawn again on every render of the app. An animated chart redraws once per animation frame.

  • An image the compositor can no longer draw is taken off the terminal with its clear sequence, rather than left where it was last drawn.

  • Text under an image is written when it changes, and the image is placed again over it in the same frame.

  • Data pushed with Drafter.push_data/2 or push_data_many/2 is drawn without waiting for something else to redraw the widget; a burst of pushes becomes one render.

  • Home then Page Down no longer crashes a scrollable widget.

  • A widget created under a previously used id draws its image at its own height, not clipped to the old widget's visible region.

  • A trait declaring :click in handles/0 receives clicks.

  • Drafter.Pty.close/2 with :kill_after does not signal a reused process id. It slept and then sent SIGKILL without checking the program was still there; it now probes the group with signal 0 first.

  • A widget scrolled into view draws its image without waiting for another redraw.

  • Drafter.focus/1 and Drafter.blur/1 called in on_ready/1 take effect in the first frame, for an app started by Drafter.run/2, a Drafter.CellSession, or a pushed app, whatever input arrives meanwhile.

  • A widget is rendered once per frame, not twice. An unchanged position no longer re-renders a widget before its new props arrive, so a widget with an expensive render no longer falls behind its input.

  • No image bytes reach the terminal after it is restored on exit. A widget's image task finishing during shutdown could hand a picture to the compositor after Drafter.Terminal.Driver.cleanup/0 had left the alternate screen, and the escape sequence printed as base64 on the normal screen. Drafter.run/2 now flushes and stops the compositor before restoring the terminal.

  • A terminal that answers the startup probe without naming a protocol is judged by its environment. Drafter.Widget.Chart.Pixel.protocol/1 took {:ok, nil} from the probe as final, so an iTerm2 that did not answer the version query drew charts, sliders and every image widget that asks it as text; it now falls back to FrenchCurve.Capability.detect/1 as it already did for a terminal never probed. Drafter.Widget.Chart.Pixel.terminal/0 exposes the answer.

  • Tab after a click on a widget that handles presses but is not focusable no longer crashes the event router.

  • A frame's image bytes are written inside synchronized-update markers of their own. They stay a separate write after the text, so a slow terminal still delays nothing, but a terminal that draws images as cells (iTerm2) no longer shows the erased region between taking the old picture down and putting the new one up.

  • A widget switched off its picture by props shows its glyphs instead of the withdrawn image.

[0.4.0]

Added

  • Key release events — use Drafter.App, key_release: true turns on the kitty keyboard protocol in the terminal of every session of that app, locally and over SSH and telnet. A press is then followed by {:key_down, key, modifiers}, a release arrives as {:key_up, key, modifiers}, and {:key_release_support, true} is delivered once the terminal confirms it speaks the protocol; Drafter.Session.Context.key_release?/0 reports the same. key is the unshifted key, so a press and its release name the same thing. The {:key, ...} stream is unchanged, and an app that does not opt in sees exactly what it saw before. Drafter.Terminal.KittyKeyboard holds the sequences and the parser.
  • use Drafter.App, frame_pacing: :always — draws at most once per refresh_rate/0 interval whatever caused the change, so a burst of events or timer ticks becomes one frame showing the latest state. The default :animations paces only animation frames, as before.
  • Drafter.Accounts — a file-backed account store with PBKDF2 password hashes, per-account props, case-insensitive unique names, an equal cost for unknown names and wrong passwords, and a per-peer lockout after repeated failures. Drafter.Server.start_ssh/2 takes auth: {:accounts, server} to check logins against it, and with register_as: "new" (the default) a client connecting as that user is shown Drafter.Accounts.RegisterApp, creates an account, and continues straight into the app under the new name. An account's props are merged into its sessions' mount props.
  • Drafter.Server.start_ssh/2 takes tunnel: true to accept ssh -R reverse forwards, and auth: entries of the form {username, password, props} whose map is merged into that user's mount props.
  • use Drafter.App, cell_size: true — asks each session's terminal how many pixels a cell is (CSI 16 t); the answer arrives as {:cell_size, {width, height}} and Drafter.Session.Context.cell_size/0 keeps it. Drafter.Terminal.Reports holds the query and its parser.
  • Drafter.blur/1 — takes focus away from a widget and keeps it away: a render no longer re-focuses the first focusable widget after an explicit blur, until something is focused again by the app, a click, or Tab.
  • Drafter.set_refresh_rate/1 — changes a running app's refresh rate; pacing and tick-based image throttles follow from the next frame.
  • image_priority: on any widget with an image/3 callback — the scheduler priority its image tasks run at (:low, :normal, :high), alongside the existing image_throttle:. The default stays :low, or DRAFTER_GEN_PRIORITY.
  • Drafter.terminal_opts/1 — the :mouse_hover, :key_release and :cell_size settings an app module declares, as transports read them.
  • The accounts file is text — one Erlang term per account, read by :file.consult/1; a binary file from before is read and rewritten as text.
  • start_ssh's :ip says exactly what to bind — an address, {0, 0, 0, 0} for every IPv4 interface, {0, 0, 0, 0, 0, 0, 0, 0} for every IPv6 one, :any for both families, or a list of these bound by one daemon each on the same port ({:ok, [pid]}; Drafter.Server.stop_ssh/1 stops either shape). A host reachable over IPv6 whose daemon was IPv4-only refused clients that resolved it to the IPv6 address.
  • Drafter.Accounts numbers its accounts and takes default_props: — a function from an account's number (0, 1, 2, … in registration order; a file from before numbering is numbered in name order when loaded) to props every account has unless its own say otherwise, so a server can hand each account something of its own.
  • label/2 takes runs — a list of {text, style} pieces drawn side by side on one line as one widget, each in its own style over the label's; a line of many colours no longer needs a horizontal of many labels.

Changed

  • SSH and telnet drivers write synchronously. A frame is handed to the connection before the next is composed, so a client that cannot keep up sees fewer, complete frames instead of a growing backlog.
  • Key replies sent before the app loop starts are kept. A session subscribes to its event manager before the terminal probe, so answers that arrive during the probe reach the app.
  • The telnet driver parses what arrived during the probe at once rather than at the next keypress.

Fixed

  • Release support is claimed only when the terminal reports event types. Any reply to the kitty keyboard query counted as "releases are reported", so a terminal that speaks the protocol without event types (bit 2 of its flags) — Alacritty — left apps waiting for key_up events that never came, and held keys stuck. The reply's flags are read now; {:key_release_support, false} says the terminal answered without them.

  • Text beside an image no longer tears it. A changed row an image lies on is written around the image, the cells it covers left alone, instead of over it with the image re-sent afterwards — on iTerm2 and sixel the rewrite erased a band of the image until the resend arrived, a flicker on every text change beside it, and the resend itself was the cost of a whole image per text change.

  • unmount/1 is called — once, with the app's last state, on every way an app stops: {:stop, reason} from a callback, the global quit key, the runtime's :shutdown, and a linked process exiting. It was documented and never called.

  • An arrow key with nothing focused reaches the app. It used to focus the first focusable widget — a chat log or an input the app never asked to focus — and vanish. Drafter.blur/1 now keeps focus away even when the widget it names is not focused or not drawn yet, and Drafter.focus/1 on a widget not drawn yet takes effect once it appears. scrollable/2's focusable: false is honoured, as its docs said.

  • A refused password is a denial, not an "Internal error". With auth: {:accounts, _}, a wrong password or an unknown user crashed the daemon's password check on its first refusal (the failure count started as :undefined), so the client was disconnected with Internal error instead of Permission denied, please try again; only a correct password on the first try ever got in.

  • An SSH session ends when its input does — a dropped connection reaches the app as :shutdown, as a dropped telnet connection already did, so the app's unmount/1 runs and nothing it started (processes, connections) outlives the session.

  • Bound values are current for the next event. A bind: update is applied before the following event is handled, so an app-level handler reading bound state after a burst of keystrokes sees all of them.

  • A focused text input no longer swallows keys it cannot type. :escape, function keys and other named keys bubble to the app instead of being dropped.

  • Drafter.Test.sync/1 waits at every hop. It returns once the headless driver has forwarded every injected event and the frame the app drew for it is on the driver — send_key/3 and the other input helpers with it — so a second key can no longer overtake the blur or focus change the first one asked for, and HeadlessDriver.get_render_count/0 right after an input counts that input's frame. Drafter.Compositor.sync/1 is new.

  • A lone Escape over SSH is delivered after the input buffer's flush interval rather than waiting for the next byte.

  • SSH session cleanup writes to the session's channel, not the server's /dev/tty, so mouse tracking and the keyboard protocol are turned off in the client's terminal.

  • The local driver writes every control sequence straight to /dev/tty. Setup, mouse toggles, terminal queries and app writes no longer go through the BEAM stdio server, where they waited behind the stdin reader's blocking read until the terminal happened to send a byte; the graphics probe's queries therefore reach the terminal before the probe's deadline in every terminal, not only in those that answer something else first.

  • Terminal queries are sent after the local driver's stdin drains. The kitty keyboard and cell size queries are written by Drafter.Terminal.Driver.query_terminal/0 once input starts, so their replies are no longer discarded with the stale bytes a local session flushes before its first frame; key_release?/0 and cell_size/0 are answered in local sessions as they were over ssh.

  • A control sequence the parser does not know is consumed, not delivered as the escape key followed by its bytes. A terminal's reply to a query drafter did not ask, or asked after a probe had finished, no longer reads as Esc plus typed characters.

  • Every byte for the terminal goes through Drafter.Render.Writer, one process per session, so no escape sequence is ever cut by another write; a frame's image bytes follow its text, outside the synchronized update, and never block the compositor: a terminal slow to accept an image (iTerm2 decodes each inline image before reading further) delays neither text nor input handling, and while it is busy only the newest image is kept for it. Trace lines V record each image write.

[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

  • Drafter.Pty.close/2 takes :kill_after — milliseconds to wait before sending SIGKILL to the program's process group, for a program that ignores the SIGHUP that releasing the descriptors sends. The signal goes to the group, so children the program started go with it. close/1 sends nothing further.
  • Drafter.Terminal.TermiosNif.load_error/0 — why the native library is not in use, or nil when it loaded, carrying the path that was tried and the runtime's reason for refusing it. The loader leaves the Elixir fallbacks in place rather than stopping the module from loading, so a load failure otherwise showed up only as :nif_not_loaded at a later call. Drafter.Pty.spawn/2 now returns {:error, {:nif_unavailable, reason}} in that case instead of raising.
  • Drafter.ScrollMath.visible_range/3, scroll_by/4, page/4 and to_oldest/2 — the operations a widget rendering virtual rows needs, so a large row set is windowed without materialising what falls outside it.
  • 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_curve — renderer: :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 digits — Drafter.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.Clipboard — copy/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 :drafter — false 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 guides — Large 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 example — 26_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_props — update_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