# Changelog

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

## [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.Test.HeadlessDriver.sync/0` and `Drafter.Compositor.sync/1` are the new hops.
- **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](guides/large_text.md).
- **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 `c: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 `c: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.
- **`c: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](guides/large_text.md), on choosing a `digits` font and how the fonts are built, and [Design Notes](guides/design_notes.md), 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:**
```elixir
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:**
```elixir
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):

```elixir
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.

  ```elixir
  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`.

```elixir
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
