Skip to content

Latest commit

 

History

History
895 lines (808 loc) · 67.9 KB

File metadata and controls

895 lines (808 loc) · 67.9 KB

Changelog

All notable changes to this project will be documented in this file.

The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.

Unreleased

Changed

  • Mounted buffers reflow on window resize by default: vui-mount and vui-mount-inline now install vui-rerender-on-resize themselves, so vui-flex and vui-grid layouts whose :width follows the window respond to resizes with no setup. The hook remembers the width it last rendered for and skips height-only changes (the echo area growing, a split below) - layout reads widths only - and re-renders coalesce through the normal deferred scheduling. Set vui-rerender-on-resize-default to nil for the previous opt-in behavior; plain vui-render buffers still need the explicit call (#134).
  • vui-render unmounts a component tree previously mounted in its buffer before erasing it. A live instance left behind would re-render the old UI over the new content from a timer, an async callback, or (now that mounted buffers reflow by default) a window resize.

Fixed

  • :width 'window now measures the window showing the buffer being rendered, not whichever window happens to be selected. A vui-flex or vui-grid in a buffer displayed somewhere other than the selected window - a side window, a popup frame that deliberately keeps focus elsewhere - laid itself out to an unrelated window’s width, and only looked right once that window was selected. The resize hook already measured the buffer’s own window, so such a tree re-rendered because its own window resized and then laid out for a different one. Measure passes resolve against the origin buffer’s window, the same way fill-column is carried into them, and a buffer displayed nowhere still falls back to the selected window (#153).
  • Measuring a mounted component now sees its current state. Layout containers measure children by rendering them into a temp buffer; a component child used to measure as a throwaway instance with INITIAL state, so e.g. an expanded collapsible measured as collapsed and vui-flex computed its row from the wrong width. Measurement now reconciles against the live tree (same key/index rules as rendering) and renders a matched instance’s render function over its current state, without touching the instance - the real render still does the actual reconciliation. Concretely, “without touching” means: the render runs against a shim copy, so render-time ref, memo, and callback writes are discarded (the previous-value ref pattern still fires once per committed render); a component whose :should-update says skip measures as its cached vtree, exactly what the real render commits; a vui-stream region is never re-bound while measuring, so live handles keep their real buffer; and static vui-flex-item children are measured in the same pass, so the reconciliation cursor advances past them exactly as the real render will. Matching is exact for keyed children; unkeyed same-type siblings around a function grower or a composed row should carry a :key. Groundwork for the responsive layout pass of #134, which measures every layout child this way (vui--measure-block).
  • Measurement carries the origin buffer’s fill-column into its temp buffer, so content resolving :width 'fill-column (a nested flex in a table cell, a wrapped panel) measures against the buffer it lands in instead of the global default. Other buffer-local variables are still not carried; composed content should not depend on them.
  • The sticky header of vui-table lines up with the table body when display-line-numbers-mode is on. :align-to 0 is the start of the text area, but Emacs reserves room for display-line-numbers there, so the pinned header now aligns to (line-number-display-width 'columns) instead. Contributed by ginqi7 (#148).

Documentation

  • Added an External Updates guide (docs/guide/12-external-updates.org) covering updates that start outside a component: a process sentinel, a global timer, a hook. It documents the two approaches - publishing a vui-with-async-context closure from the component, or updating props from outside with vui-get-instance plus vui-update - and when each fits (#151).
  • Documented that props do not re-seed state. A component whose :state is initialized from a prop keeps the value it captured at mount, so vui-update appears to do nothing: the props change and the buffer does not, with no error or warning. Now called out in the vui-update and vui-update-props docstrings and in the API reference, with the two fixes (read the prop in :render, or sync it in :on-update) (#151).
  • Documented in vui-defcomponent that a function returned from :on-mount becomes the unmount cleanup, which silently captures a :on-mount form ending in a setq of a lambda (#151).

Added

  • vui-grid: a responsive grid of equal-width tracks (#134). Cells fill rows in source order; the column count starts from :columns and falls while tracks of at least :min-column-width stop fitting the total, never below one - so panels sit three across in a wide window and stack in a narrow one, from the same code. Tracks are equal integers with remainders on the earlier tracks; a cell wider than its track widens that whole column across every row, keeping columns aligned. A cell may be a function of the track width (in characters), the way a table or field fills its track exactly. :row-spacing inserts blank lines between rows, and an empty cell keeps its track so columns stay aligned. Identity follows vui-flex :wrap: single-line rows render in place, rows with multi-line cells are composed as text (components in composed rows never mount; keep stateful components in single-line cells). Works in both width modes. Example 19 (vui-example-responsive-dashboard) shows the containers together: a grid of bordered stat cards that drops columns as the width shrinks, panels of different heights stacking, a width-filling gauge, and a collapsible section whose body is itself a grid.
  • vui-flex accepts :wrap t: children flow into rows in source order when their widths stop fitting the total, instead of overflowing the line (#134). Each row distributes width independently through the pure core in vui-layout.el: growers grow into their row’s leftover, and the new vui-flex-item :min-width lets a child shrink down to a floor before the row wraps (for a function child it is also the width it occupies during partitioning). Multi-line children become real blocks: a row containing one lays its children side by side, line by line, as tall as its tallest block - so panels and boxes sit next to each other when the buffer is wide and stack when it is narrow. A function child that renders multi-line composes the same way (its block is measured at its assigned width), and a child that renders nothing is dropped from the row like in a non-wrapped flex. Identity is per row: a row whose children are all single-line renders in place exactly like a non-wrapped flex (components, fields, and buttons keep their identity), while a composed multi-line row is text like a table cell - buttons work, but components in composed rows never mount (no state, no lifecycle) and widget fields do not survive composition; keep stateful components on single-line rows. :justify is not applied under :wrap. Works in both char and pixel width modes.
  • vui-layout.el: the pure layout core behind the responsive containers planned in #134. Pure functions over integers, plists, and lists of strings, with no dependency on vui.el: proportional integer shares with deterministic remainder distribution (vui-layout-shares), source-order row partitioning at minimum widths (vui-layout-partition), per-row allocation with grow, shrink toward :min, :rigid floors, and a lone oversized child clamped to the row unless rigid (vui-layout-allocate), end-to-end placements (vui-layout-solve), responsive equal-track grid math (vui-layout-grid-columns, vui-layout-grid-tracks, vui-layout-grid), and a block composer that widens a column deterministically when a block overflows its assignment (vui-layout-compose). Widths are unit-agnostic integers: the core never measures, and the composer measures and pads through caller-supplied functions (cells by default), so cells vs pixels is the consumer’s choice. The semantics deliberately match yibie/textui’s engine where sensible, so the conformance comparison explored in yibie/textui#1 stays meaningful.

v1.4.0 - 2026-08-18

Added

  • vui-width-mode: choose how layout measures and pads text. The default, char, is the existing behaviour (string-width columns). Setting it to pixel switches every layout primitive (vui-table, vui-box, vui-flex, vui-hstack / vui-vstack indent, vui-space, button :max-width, field placeholders) to measure with string-pixel-width and pad with real spaces plus a sub-space (space :relative-width F) spacer for the remainder, so a table stays aligned when a cell mixes fonts: CJK, emoji, Nerd Font glyphs, or anything the font renders at a width string-width does not predict. All width-aware code goes through two internal primitives (vui--text-width / vui--pad), so the mode is one switch rather than a per-component choice. Widths you pass in (:width, :size, :indent) are still in characters in both modes; pixel mode converts them internally. Pixel measurements are memoized in a global cache cleared on after-setting-font-hook. Measured on the table benchmarks pixel mode costs about 1.2x char mode at 1000x5 and allocates the same, and it stays opt in for now because char is what a tty does and what existing tests and text-processing consumers expect. On a tty frame the two modes render identically. Emacs 29 and 30 use a binary-search fallback for truncate-string-pixelwise. Contributed by ginqi7 (#120, #121).
  • vui-table accepts :sticky-header t: while the window is scrolled into the table’s body (the in-buffer header row above the window start, rows still visible), a copy of the header row is pinned in header-line-format, so long tables keep their column labels in view. This is the sticky-scroll model, not the tabulated-list-mode one: the table renders into the buffer unchanged (header included), content before and after the table behaves normally, several sticky tables in one buffer hand the pinned header over as you scroll between them, and evaluation is per window via an :eval header-line construct. The pinned copy is read live from the buffer at redisplay, so it always matches current column widths, borders, faces, and surrounding indentation. Unmounting or remounting restores the previous header-line-format. Sticky headers must be plain strings (the pinned copy is unreachable by vui navigation); a vnode header signals an error (#117).
  • Large-state :memo scenarios in the benchmark suite (benchmarks/vui-bench.el): the :memo bail-out now compares state with the property-aware vui--vnode-equal (a Lisp walk) instead of C-level equal, and nothing measured that cost against a large state value. Three end-to-end cells against a btop-snapshot-shaped state (1000 process plists plus two 120-sample histories): identity-stable bail (the common case, stays at microseconds regardless of state size), a small scalar change (memo must not bail), and the worst case - replacing the state with a fresh but equal structure, where the walk runs to completion (~1.2ms at this size, vs ~0.07ms for the C walk it replaced; the flush is comparison-dominated). A micro scenario prints the raw C-vs-Lisp walk ratio. Mechanism is asserted throughout: a render counter proves each cell bails or renders exactly as claimed, and the bail path is checked to refresh prev-state so the worst-case cell cannot silently degrade to the eq fast path (#128).
  • Table scenarios in the benchmark suite (benchmarks/vui-bench.el): re-render cost vs row count, vs column count, and string cells vs vnode cells. Tables are the measurement-heavy path (column widths are recomputed from scratch every render, so cost tracks cells rather than rows), which is exactly where the cost of the width primitive shows up, and there was no scenario covering them. A fourth scenario compares char and pixel width modes; it is feature-detected and does nothing until the switch exists, so the same file still runs on builds without it (#121).

Changed

  • Table rendering measures each cell once per render instead of two or three times. The sizing pass already renders every vnode cell into a temp buffer to measure it; the row pass then rendered it again to get the same text for width and truncation, and measured that text twice (once as content, once as display content, the same string when nothing is truncated). The sizing pass now records what it measured per row and the row pass reuses it (vui--cell-measure), the untruncated display width is the content width, the cell padding string is built once per row, and the two propertized separator strings are memoized instead of rebuilt per row (they were 8 percent of a large render on their own). Per 1000x5 re-render on the compiled table benchmarks: pixel mode 18.4ms to 14.6ms, char 14.7ms to 12.7ms, and a table of vnode cells 18.2ms to 11.8ms (one temp-buffer render per cell fewer). Pixel mode now costs about 1.15x char on that table; the remainder is one cache lookup per cell against a C string-width. Whether pixel becomes the default is not a performance question any more but a compatibility one: it changes buffer-string (display spacers in padding), which anyone parsing or snapshotting vui output would notice, so char stays (#121).
  • The vui-stream-update-last extend fast path checks the prefix without copying it. The property-aware check from #125 did (substring n 0 (length o)) on every call just to compare it with equal-including-properties, which allocates the whole prefix per streamed chunk - O(n^2) garbage over a message’s lifetime, on the hot path of streamed output (docs/examples/14-claude-chat.el). The check now compares characters with compare-strings and walks the text-property intervals of both strings in lockstep (vui--string-prefix-equal-including-properties-p), allocating nothing: streaming a 2000-chunk message drops from ~12M allocated string chars to ~12K (just the suffixes). Plain-text chunks (the realistic streaming payload) run at time parity; content with many property intervals in the prefix trades some comparison speed (the interval walk is Lisp, the old one was C) for the allocation win. A new benchmark scenario (vui-bench-stream-update-last-extend) measures both payloads (#127).

Fixed

  • Bordered tables line up in a proportional font under pixel width mode. A font’s box-drawing glyphs rarely share a width: Helvetica on macOS draws and at 12px but falls back to Arial for the junctions , , at 9px, and ASCII + - | are 9, 4 and 5px, so a border row built by counting whole fill glyphs could not match the data rows (rows came out 105 to 137px wide for a 132px table in a GUI frame). Border rows are now laid out by position: each junction is centred on the separator it belongs to, computed from the separator’s centre in half-pixels and absolute from the row start so rounding never accumulates, and the rule between two junctions is whole glyphs plus a spacer for the sub-glyph remainder (vui--border-segment). A left corner narrower than the separator starts a little in; a right corner narrower than it is padded out, so every row measures the same. In a monospace font all of this is zero and the output is byte-identical. Measured in Helvetica: unicode borders, every row 168px and every junction within half a pixel of its (a 9px glyph cannot sit dead centre on a 12px one, half a pixel is the optimum); ASCII borders, every interior + exactly centred on its |, the corners overhanging by the 2px their glyph is wider. The layout guide no longer says to use :border nil there (#121).
  • Whole-buffer renders no longer record themselves in the undo list. Every vui-mount render and re-render erased and rebuilt the buffer with undo enabled, so Emacs copied the whole erased text, text properties included, into buffer-undo-list (and then every re-inserted piece), and widget-setup threw it all away at the end of the render, as it always has. On a 1000x5 table that copy was a third of the garbage a re-render made. The render now binds buffer-undo-list to t like inline mounts already did, and clears the list afterwards so nothing from before the render can be undone against text that no longer exists (that is what widget-setup did, and it still holds). Padding strings are also shared now: a run of N spaces is inserted or concatenated, both of which copy, so vui--spaces hands out one string per length instead of a fresh make-string per pad, and pixel mode skips its concat when a pad has no sub-space remainder. Per re-render of a 1000x5 table, in both modes: strings 32k to 2k, string chars 130k to 6k, intervals halved. At Emacs’s stock gc-cons-threshold that is 4.0 to 1.9 GCs and 84ms to 47ms per pixel-mode re-render (63ms to 47ms in char mode), and pixel mode now allocates exactly what char mode does. This was the “GC cliff” on large pixel tables reported during #121: mostly not pixel-specific at all (#121).
  • pixel width mode measures faced text as faced text. Table headers were measured plain and rendered bold, and the pixel cache keyed on string content only, so a bold header and a plain cell with the same text shared one width; in a monospace font that is invisible, in a proportional font (variable-pitch-mode) the header row came out wider than its column (measured 75px against 70px data rows in a GUI frame). Headers are now measured wearing their header face, and the cache keys on the string’s characters plus its face and display properties (vui--width-key), and nothing else: rendered buttons carry keymaps and action closures that do not affect width and hold references back into the component tree, so keying on every property would be both slow and unsafe. A property-free string is its own key, so the common case allocates nothing. The space width, which more than half of all lookups on a table render ask for, gets a one-entry memo in front of the cache (vui--space-pixel-width), cleared with it on font change. Net cost on the compiled table benchmark is about 5 percent on the pixel path (#121).
  • pixel width mode now survives text-scale-mode and measures correctly under variable-pitch-mode. Measured in a GUI frame: a pixel-aligned table drifted after C-x C-+ and stayed off even after a re-render. Two causes. Padding spacers were (space :width (N)), an absolute pixel count that stays put while the text around it scales; they are now (space :relative-width F), a fraction of the space they sit on, which follows the buffer’s face remapping the way a real space does (Emacs rounds F times the width, so every sub-space remainder is exact). And containers measure content by rendering it into a temp buffer, which has no face remapping, so a table in a scaled or proportional buffer was measured in the frame’s default font; measurement now happens in the render target’s face context (vui--measure-buffer, using string-pixel-width’s BUFFER argument on Emacs 31 and later), and the pixel cache is keyed by that context (face-remapping-alist) so a scaled buffer never reuses plain widths. Bordered tables also normalize the padded column width to a multiple of the fill glyph, not just the content width, since a fill glyph from a fallback font need not be as wide as the space. Result in a GUI frame: exact after a re-render at any scale, within a pixel before one; boxes, flex rows and borderless tables exact under variable-pitch-mode. Byte-identical in char mode (#121).
  • Three more pixel width mode unit bugs, all invisible in batch (a pixel is a column there) and found by a new mocked-font test suite (test/vui-width-mode-test.el, 37 specs, which also pins that pure ASCII renders byte-identically in both modes). vui-vstack :indent was inserted as N pixels instead of N columns, so an indented vstack in pixel mode indented by a sliver. vui-hstack converted its inherited :indent to pixels and then handed the pixel count down to nested vstacks as their character :indent, so continuation lines under an indented hstack indented about seven times too far. And vui-flex distributed the leftover in raw pixels: :space-between inserted a literal space for each pixel of remainder (a 20-column row came out 6px wide of its edge), and growers and :center got fractional-column shares that char mode never produces, so enabling pixel mode changed pure-ASCII flex rows by a column and sprinkled display spacers into monospace layouts for no gain. Leftover is now handed out in whole columns with the sub-column pixel remainder carried to one place (the last grower or the last gap), and :center padding in tables and boxes splits at column granularity too (vui--split-padding). Net effect: byte parity with char mode on ASCII, and rows still end on the exact pixel with wide glyphs (#121).
  • vui-flex growers in pixel width mode were scaled twice. The leftover is carved up in mode units (pixels), but a grower’s share was then handed to a character-width box (which converts again) and to the width-receiving callback whose contract is characters, so a :grow vnode padded to roughly seven times its share and (lambda (width) (vui-field :size width)) got a pixel count. Callbacks now receive whole columns (vui--width-to-chars, rounding down) and vnode growers are padded directly in mode units, which also keeps the sub-column remainder so the row’s right edge lands exactly. Byte-identical in char mode. Invisible in batch, where a pixel is a column; the regression tests mock string-pixel-width (#121).
  • vui-stream-update-last (and the node-path vui-stream-update / vui-stream-append-to) patched the item region in place even when the replacement left the whole stream region zero-width, so the separator the container had emitted around the stream went stale: appending an item and then updating it to an empty text left a stray blank line (“\nnote”), while a fresh mount with the same items renders just “note” (a vstack drops a zero-output child). In-place updates now re-lay the tree once whenever they cross the empty boundary, in either direction: emptying the last item drops the stale separator, and refilling the emptied stream brings it back. This is the mirror of the empty -> non-empty transition on append and of the emptying vui-stream-remove-last.
  • The incremental patcher (vui-incremental-render) skipped a segment whose content string changed only in text properties: equal on vnodes ignores string properties, so a vui-text with the same characters but a different face (via propertize) kept the stale face in the buffer. Segments are now compared with a property-aware vnode equality (equal-including-properties on strings). The vui-stream-update-last extend fast path had the same blind spot: string-prefix-p compares characters only, so growing a propertized text whose prefix changed properties appended just the suffix and left the stale prefix; the prefix is now checked including properties before taking the fast path.
  • Clicking a button or checkbox with mouse-1 signaled widget-button--check-and-call-button: Wrong type argument: overlayp, nil. widget-keymap (a parent of vui-mode-map) binds mouse presses to widget-button-click, which treats any button char property as a widget button and fails on vui’s button.el text buttons. vui buffers now keep plain global mouse semantics (mouse-drag-region on the mouse-1 press, taps go to the touchscreen translator); activation still comes from button.el, so mouse-1 (via mouse-1-click-follows-link), mouse-2 and RET all work. Reported on the quickstart article.
  • :memo components and hook deps (vui-use-memo, vui-use-callback) ignored string text properties: comparing with plain equal meant a propertized string prop or dep with the same characters but a different face counted as unchanged, so the component kept a stale render (or the hook a stale cache), even with vui-incremental-render off. Same bug class as the incremental patcher fix above; these comparisons now use the same property-aware equality. Functions in props and deps keep plain equal semantics, so a closure rebuilt on every render with an equal captured environment still hits the cache. The cost on the hot bail-out path is negligible: identical values short-circuit on eq, and even fresh-but-equal strings add well under a microsecond per component (#126).

v1.3.0 - 2026-07-02

Added

  • A public element-at-point API so consumers stop depending on the rendering mechanism. vui-element-at returns the vui element at point (a button, checkbox, select or field) as an opaque handle; vui-element-get reads its vui properties (:vui-key, :vui-tag, :vui-path, …); vui-key-at is the convenience for the common “which keyed row is the cursor on” question; and vui-activate runs the element’s action (follow a link, toggle a checkbox, submit a field). All four are mechanism-agnostic: they hide whether vui rendered the element as a button.el text button or a widget.el field. When #109 swapped buttons from widgets to text buttons it silently broke consumers reaching for (widget-at (point)) and (widget-get ... :vui-key), which return nil for text buttons - the same lesson as the cursor-identity fix, which only survived because vui already abstracted the two internally (vui--elt-get). This extends that abstraction outward, so a future rendering refactor is no longer a breaking change for anyone downstream (#113).
  • vui-goto-key moves point onto the widget carrying a given reconciliation :key (matched with equal), returning its position or nil. Handy for steering point to a known row, e.g. parking it on a stable anchor before a refresh so cursor restoration has somewhere to return to.
  • vui-collapsible takes a rich header. :header-right puts a vnode (a count, a badge, a status) right-aligned in the header row opposite the toggle, and :header-width (anything vui-flex’s :width accepts, default fill-column) sets what it aligns to. I wanted a dashboard section that reads “▶ SchemaName ............ 3 invalid”, toggle on the left, count pinned to the window edge, without hand-rolling the header and losing the toggle’s cursor identity to do it. With :header-right the header becomes a space-between vui-flex; without it the header is exactly the toggle as before, byte for byte, so nothing that does not ask for a right side changes. The right content is adornment: always shown in the header row (not a body child that hides when collapsed) and presentational (the toggle stays the only clickable part). The toggle keeps the stable :key from #103 so point rides it across expand and collapse, and a nested collapsible’s header subtracts its own indent so the right edge still lands at :header-width however deep it sits.
  • vui-stream - an imperative, append-only region for unbounded streaming content (chat transcripts, build logs), the one shape the declarative “re-render from state” model is too slow for: it rebuilds all N items on every append, so a stream of N is O(N^2). Get a handle with vui-use-stream (or vui-make-stream), place it in the tree with (vui-stream handle) so it gets a managed region, and append with (vui-stream-append handle vnode). An append writes exactly one region in O(1) - it never re-renders or walks the existing items - and declarative content below the region shifts down with it; a full re-render re-emits the items, so the buffer stays byte-identical to what a plain list would render. The empty -> non-empty transition does one re-render (a container drops an empty child and its separator); every later append is O(1). vui-stream-update-last rewrites just the last item’s region, for the in-progress message in a streaming agent UI (grow it as tokens arrive): its cost depends on that one message, not on the transcript length. Measured in batch: both appending into and growing the last item of a 4000-item stream are ~0.02ms and flat regardless of size, vs ~21ms and rising for the declarative rebuild (~950x at N=4000, the gap widening with size). Items are content vnodes (text/fragment, drawn however you like per item) for now (#82).
  • A vui-stream item may be a vui-component, mounted as a stateful ROW: it gets its own region and re-renders only that region when its state changes - a collapsible tool card, a copy button - independent of how many items are above it (~0.15ms whether 200 or 4000 items precede it). vui-stream-append mounts a component vnode inline at the tail (its region-end is shared as the stream’s, so the row growing on its own re-render keeps the box below correct); vui-stream-update-last on a row updates its props in place, preserving the row’s state. Content and rows interleave. A row needs the stream live and non-empty first (a component appended to an empty stream falls back to a plain child render), and relies on the always-on stream-tail patch to survive box updates; rows unmount with the buffer (#82).
  • Random-access vui-stream nodes. vui-stream-update-last can only touch the most recent item; vui-stream-open instead appends an item and returns a stable ref (a vui-stream-node) you keep, so you can edit it later no matter how many items land below it - the streaming-agent case update-last could not cover (a tool card appears while the reply is still streaming, a follow-up arrives mid-thinking). vui-stream-append-to grows a node in O(delta) (only the new tokens are inserted and redrawn), vui-stream-update rewrites its whole region, and vui-stream-finalize freezes it to static text. Out-of-order edits stay byte-identical to a wholesale render, and the model is kept in sync so a full re-render re-emits the streamed content. The load-bearing piece is finalize: a live node costs one region’s markers and Emacs walks every marker on each insert, so keeping the live set bounded by concurrency (finalize a message when its turn ends) keeps append flat for an unbounded transcript - measured ~1.9us from N=2000 to N=80000, vs O(N) (146us at N=80000) if nothing is ever finalized. vui-stream-before / vui-stream-after insert a new live node directly above / below an existing one (out-of-order: a card that belongs above an item already on screen), and vui-stream-remove deletes one - separators stay single, and emptying the stream re-lays like the empty -> non-empty transition. vui-stream-update-last is now sugar over the node API: when the last item is a live node it updates that node in place (keeping its markers valid for further appends), otherwise it rewrites the last region as before. vui-stream-open also accepts a vui-component, mounting it as a stateful inline ROW addressable by ref: vui-stream-update refreshes its props OUT OF ORDER (state preserved) wherever it has drifted to - the random-access counterpart of vui-stream-update-last on a row - and finalize stops tracking it while the row stays interactive. See docs/design/vui-stream-nodes.org (#82).
  • A re-render driven by a sibling’s state change (the “box” below a stream
    • status, queue, an input field) no longer re-emits the whole transcript.

    When the root is a flat container whose first child is a live stream, the stream’s region is left untouched (appends keep it current) and only the content after it is re-rendered, so a box update is O(box) instead of O(N): ~0.18ms flat regardless of stream size, vs ~5.7ms and rising at N=4000 (32x). Anything else (content before the stream, more than one stream, an indented or faced container) falls back to a wholesale rebuild. This is always on (like the whole-tree skip, it only ever reproduces what a wholesale rebuild would, byte-identical), independent of the experimental vui-incremental-render flag. (#82)

  • :memo keyword for vui-defcomponent: :memo t is a shorthand for the most common :should-update - skip the re-render while props are shallow-equal (equal on each value) and state is unchanged, like React.memo. An explicit :should-update takes precedence. Works on the normal render path (it skips the component’s vnode production) and, with vui-incremental-render on, lets the component bail out of re-rendering entirely. Note that :children counts as a prop, so a memo component wrapping nested content re-renders whenever its parent does.

Fixed

  • Shift+Tab navigates reliably no matter how the platform encodes it. Shift+Tab reaches Emacs as different events (<backtab>, S-TAB, <S-tab>, <S-iso-lefttab>…); the ones vui had not bound leaked through widget-keymap to widget-backward, which jumps to point-max when there are no widgets and left point stranded there. Instead of chasing every representation, vui now remaps widget-forward=/=widget-backward and forward-button=/=backward-button to its own navigation, so any key that would invoke them cycles through vui’s elements the same way.
  • vui’s TAB/S-TAB bindings are installed by a top-level form, not only inside the vui-mode-map defvar, so reloading vui.el into a running session re-applies them. defvar does not re-evaluate a keymap that is already bound, so before this a reload left vui-mode-map without the navigation keys: TAB still worked on a button (which carries its own keymap) but fell through to indent-for-tab-command on plain text. Fresh installs were unaffected; this bit reload-driven development.
  • vui-typed-field now signals recovery: its :on-error callback fires with nil when input becomes valid again, not only with a message on failure. Listeners that track validation errors - like the form examples, which gate their submit button on a (null errors) check - previously could never clear an error once a field had reported one, so a transient error (a half-typed email) stuck forever and the button stayed disabled. This is why the contact, registration and wizard examples could not be submitted. :on-error now reports the current error state after each input (nil = valid).
  • vui-set-state no longer mistakes a data symbol that happens to be a function for a functional update. It ran VALUE on the current state whenever (functionp value) was non-nil, but Emacs 31 made all a builtin, so (vui-set-state :filter 'all) called (all old-value) and signalled (wrong-number-of-arguments ...). A value is now treated as an updater only when it can accept one argument, so a data symbol like all (or cons) is stored literally, while #'1+ and (lambda (old) ...) still update as before. This bit any vui-set-state whose value was a bare symbol that was fboundp (the todo example’s all=/=active=/=completed filters).
  • Calling the low-level vui-render twice on the same buffer no longer crashes when that buffer holds a vui-field. The first render leaves the field’s widget-after-change hook on after-change-functions and the field itself in widget-field-list; the second render’s erase-buffer then fired that hook against the just-deleted field and signalled (number-or-marker-p nil). It was pre-existing and specific to reusing vui-render directly - vui-mount’s re-render path and fresh with-temp-buffer renders were fine, because the component re-render path already clears the field lists before erasing. vui-render now does the same, which also stops dead field widgets from piling up in widget-field-list across renders.
  • Rendering many buttons (or checkboxes and selects) is linear again, not O(n^2). Every widget widget-create builds leaves two live markers behind (its :from=/:to= bounds), and Emacs adjusts every live marker on every insert, so a buffer of N widgets cost O(N^2) to build: a ~6000-row schema dashboard took ~8.5s, and a 100k-row one crashed Emacs outright (#107). Plain text and flex layout stayed linear because overlays (Emacs 29+) carry no such cost, only explicit markers do. vui now detaches those bounds markers right after creating each button, checkbox and select, and reads their bounds from the button overlay instead (which tracks position for free), so navigation and cursor tracking are unchanged. That same 6000-row dashboard now builds in ~0.1s, and buttons render at a flat ~8us/row through 16000. Editable fields keep their markers: they rely on their own field machinery and are never numerous enough to matter.
  • Re-render recovers point semantically when the row it was on is removed, instead of dropping to the top of the buffer. Cursor restoration first looks for the saved widget by tree path and by =:key=/label; when both miss because the widget is genuinely gone (a list item deleted, a fix that clears its own row), it recovers to the surviving widget nearest it in the component tree, not by raw buffer position: the sibling that slid into its slot, the previous sibling when it was the last one in its container, or an ancestor. Nearness is a longer shared :vui-path prefix first (so recovery stays inside the same container), then the nearest sibling at the first differing step. So point keeps to a related row in the same container rather than jumping home or drifting across a boundary into an unrelated group, and every collapsible dashboard or list gets this without hand-rolling it.
  • vui-collapsible now keeps point on its header toggle across expand and collapse, out of the box. The toggle bakes the ▶/▼ indicator into its label, so the label flips on every toggle, and the button carried no :key, which left its label as the only cursor identity. On its own that was masked by the position fallback, but the moment a re-render also shifts the toggle (a sibling row appearing above it, another section expanding), the path, the index and the now-changed label all miss at once and point jumped off, to point-min in the worst case. It hit everything built on the primitive, collapsible sidebars especially, and the :key a caller passed was only used for reconciliation, never handed down to the toggle, so there was no fixing it from outside either. The toggle now takes a stable key (the caller’s :key, or the title when none is given) so vui--widget-identity re-finds it after the label flips. One honest gap: two collapsibles that share a title and set no :key still have ambiguous toggles and fall back to the old position-based behavior, no worse than before; a unique title or an explicit :key now tracks correctly.
  • Full-buffer re-renders no longer drop point onto the wrong widget (and scroll the window to chase it) when content shifts above the cursor. A jump I kept hitting in a dashboard of buttons and collapsible sections: expanding a section re-renders the whole root, and my cursor would land a row or two off, sometimes off-screen so Emacs re-scrolled to find it. I traced it to cursor restoration matching widgets by :vui-path (with an ordinal index fallback), both of which are just positions in the tree; vui-vstack drops nil children, so a conditional row turning on shifts every following sibling’s path and index by one, and restore parked point on the neighbour that had slid into the old slot. Restore now also records a stable identity (a field’s :key, otherwise the button or select label) and, when the saved path resolves to a widget that no longer matches, re-finds the original by that identity so point tracks the same logical row. A row with no stable identity still falls back to its position, as before. While I was there I fixed the matching viewport bug: window-start was restored to its old absolute line, which is wrong once the number of lines above it changed, so the window holding point now restores window-start relative to point and the cursor’s row stays where it visually was. Only that window follows the cursor; other windows showing the buffer keep their own scroll, so a background re-render does not yank a window the cursor does not live in. Both live in the full-rebuild path and help every consumer, independent of the experimental vui-incremental-render flag.
  • Cursor tracking across a re-render now follows a widget’s :key, not just its label, which covers the cases the first pass left on the table. vui-button, vui-checkbox and vui-select already accept a :key for reconciliation; that key now also rides on the widget, and vui--widget-identity prefers it over the label. So a checkbox (no label to match on) holds the cursor when rows shift above it, two buttons sharing a label are told apart by their keys, and a keyed button or select whose text changes in the same re-render (a counter, a select showing its current choice) is still found by its key instead of drifting off. The key wins because it is the stable identity, and since keys are only unique among siblings (two lists can reuse one), restoration pairs the key with the saved label to break ties, so a shared key no longer drifts the cursor onto a same-key row in another list. Unkeyed widgets are unchanged, matched by label and then position.
  • A vui-stream’s first appended item no longer vanishes when the stream was re-rendered while still empty. An empty live stream was wrongly treated as eligible for the stream-tail patch, so a re-render of the empty stream (for instance a sibling input field clearing its draft on submit) recorded the root as patchable; the first vui-stream-append then re-rendered through that patch, which leaves the stream region untouched, and the item - though recorded - was never emitted. An empty stream now re-renders wholesale until it has content.
  • :should-update and :on-update now actually bind the raw props and state plists, as the documentation has always promised. Previously only the named prop/state variables, prev-props, and prev-state were bound, so a form referencing props or state (including the documented example) raised void-variable.
  • vui-unmount no longer signals when tearing down a buffer that contains a vui-field. It erased the buffer without inhibiting modification hooks, so the field’s widget-after-change ran against the half-removed field and raised (number-or-marker-p nil). Teardown now inhibits modification hooks while erasing, as the render path already does.

Changed

  • Buttons, checkboxes and selects now render as button.el text buttons instead of widget.el widgets. This finishes what #108 started for #107: widget push-buttons each left two live markers in the buffer, and even after #108 detached them the widget machinery still cost ~8us per button. Text buttons carry no markers at all and render at a flat ~3us/row (the ~6000-row, 12000-button dashboard from #107 now builds in ~44ms). Editable fields stay on widget.el, which has no text-input equivalent, and widget-setup still runs so the buffer stays read-only outside fields. Navigation is now vui’s own: TAB and S-TAB (vui-forward / vui-backward) step across both text buttons and fields in buffer order, replacing widget-forward; a button’s RET and mouse activation come from button-map, and vui-mode keeps widget-keymap underneath for field editing. vui’s buttons rebind TAB and S-TAB on themselves too, so button.el’s button-buffer-map (which button-map inherits, binding TAB to forward-button) cannot hijack navigation into a button-only walk that skips fields. Cursor tracking, :key reconciliation, :tab-order, a custom :keymap and vui-goto-key all work as before across the mixed set. The one externally visible change: a button’s binding for RET is push-button (button.el) rather than widget-button-press.
  • The whole-tree skip (commit-skip) is now always on, independent of the experimental vui-incremental-render flag. When a re-render produces the exact same vtree object as the previous commit - which should-update returning nil and memoized (eq) vnodes both produce - the commit does nothing instead of erasing and rebuilding the buffer, so that re-render is O(1) regardless of buffer size. It only ever skips provably-unchanged work (a single eq check, no bookkeeping), so it carries no risk and needs no opt-in.
  • Reconciling a list of children is now O(n) instead of O(n^2). vui--find-matching-child used to linear-scan the existing children for every child on every render, so a flat list of S components cost O(S^2) to reconcile (and streaming N of them O(N^3)). It now builds a per-render lookup once - a hash keyed by (type . key) for keyed children, a vector for positional reuse - so each match is O(1). Reuse semantics are unchanged (keyed children by key, unkeyed by position, first match wins). Measured in batch: one re-render of a 2000-item keyed list drops from ~1660ms to ~51ms (32x), and the gap widens with size since this is an asymptotic change, not a constant factor.

Internal

  • Added a benchmark suite (benchmarks/vui-bench.el, run with eldev emacs --batch -l benchmarks/vui-bench.el -f vui-bench-run) that characterizes render performance: scaling with content, cost of a small update in a large UI, streaming append into a growing buffer, raw render throughput, and widget-heavy re-renders. Not part of eldev test (#83).
  • Experimental incremental rendering behind vui-incremental-render (default off, #82). Two flag-gated parts (the whole-tree skip that used to be the third is now unconditional - see Changed): (1) a segment patch for flat content containers (fragment or unindented vstack of text) that rewrites only changed lines; (2) a component-list bailout - when the root renders a flat vstack/fragment of component children, a re-render skips each child that opted in with :should-update / :memo and reports no change (and whose consumed context values are unchanged), leaving its buffer region, widgets, and state in place; changed children patch in place, reorder reuses instances by key. This breaks the per-instance commit floor: a localized one-item change in a 2000-component list drops from ~1760ms to ~50ms (a 36x speedup, measured in batch; see vui-bench-compare-run). The bailout’s per-instance bookkeeping (consumed-context tracking and rendered-region lengths) is computed only when the flag is on, so with it off the renderer keeps master’s performance (a 2000-component mount and a wholesale re-render both measure the same as before this work). The whole suite passes with the flag on as well as off (full parity); the flag stays off pending broader coverage (nested containers, providers above a list - see #82).

Documentation

  • Added a runnable vui-flex example (docs/examples/12-flex-layout.el): a form whose fields stretch to fill the window, the four :justify modes, and proportional :grow panels.
  • Added two vui-stream examples. docs/examples/13-agent-chat.el is a scripted agent chat (a transcript streaming above a persistent input box, messages typed out token by token, collapsible tool-call rows). docs/examples/14-claude-chat.el is a real, working chat against the claude -p CLI: it parses Claude Code’s streaming JSON into a live vui-stream transcript - the reply is a live node grown token by token (vui-stream-open + vui-stream-append-to, finalized with vui-stream-finalize), reasoning and per-turn details render as collapsible component rows (vui-stream-append of a component), the input box stays editable throughout, and --resume keeps conversation context across turns. Requires the claude CLI on PATH.
  • Added a real GitHub Actions dashboard example (docs/examples/15-ci-dashboard.el) backed by the gh CLI - not a simulation. It shows a live, auto-refreshing table of recent runs color-coded by status, drills into a run’s jobs and steps, triggers workflow_dispatch runs from inside Emacs, and streams a run’s log into the buffer with vui-stream. Every GitHub call is an async gh subprocess whose JSON is parsed on exit, so the UI never blocks while it polls. Point it at the companion d12frosted/vui-ci-demo repo or any repo reachable with gh. Requires the gh CLI on PATH, authenticated.

v1.2.0 - 2026-06-24

Added

  • Inline mounting (#8): vui-mount-inline renders a component into a managed region at point (or a given position) inside an existing buffer, without taking the buffer over - the major mode and surrounding content are untouched, and a buffer can host any number of inline instances. State updates rewrite only the instance’s region; region mutations are kept out of the undo history. vui-unmount now also accepts an instance and removes an inline instance’s region while running the full teardown lifecycle (which also runs on buffer kill, and when vui-mount takes over a buffer that hosts inline instances). vui-inline-instance-at returns the inline instance at a position. Intended for ephemeral, disposable forms in interactive documents.
  • vui-flex - horizontal layout that distributes a total width among children (#3). Children render at natural width; wrap one in vui-flex-item to give it a proportional share of the leftover (:grow weights). A function child receives its allotted width, which lets fields actually fill remaining space: (vui-flex-item :grow 1 (lambda (w) (vui-field :size w))). :width accepts a number, fill-column (default), window, or a function resolved at render time; :justify (:start / :center / :end / :space-between) places leftover width when nothing grows. Flex rows subtract inherited vstack indentation from their available width, accept :face / :keymap like other containers, and degrade to natural widths when content overflows the total. Single-row by design; multi-line children are measured by their widest line.
  • vui-region - styling wrapper that applies :face and :keymap to its children’s whole extent (#41). The face is layered beneath the children’s own faces, so child faces win while separators, padding, and indentation inserted by nested layout containers get styled too. The keymap cascades: nested regions’ and buttons’ own keymaps win for keys they define, unbound keys fall through to the region’s map, and from there to the buffer’s usual keymaps; buttons keep their default RET / TAB behavior and input fields are untouched.
  • Layout containers accept :face and :keymap: vui-hstack, vui-vstack, vui-box, and vui-list apply them to their whole rendered extent with the same cascading semantics as vui-region, so separator spacing, padding, and indentation inserted by the container itself can finally be styled, and rows can carry row-level keybindings (#41).
  • Table header and border faces are themable (#41): headers render in the new vui-table-header face (inherits bold; previously hardcoded) and border characters in vui-table-border (no attributes by default), customizable globally or per table via :header-face / :border-face.
  • vui-typed-field (and the typed shortcuts like vui-integer-field) accept :placeholder, passed through to the underlying field and shown while it is empty.
  • vui-rerender-on-resize - opt-in, buffer-local hook that re-renders a buffer’s VUI instances (root and inline) when its window size changes, coalescing resize bursts through the normal deferred scheduling. Pairs with vui-flex widths that depend on the window. vui-cancel-rerender-on-resize undoes it.
  • The component inspector (vui-inspect) and state viewer (vui-inspect-state) now cover instances mounted via vui-mount-inline: with no explicit instance they list the buffer’s root and every inline instance, labelled with its region.
  • Development-time warnings for two silent footguns: vui-set-state with a key that no component in scope declares (typos used to silently create new state), and sibling vnodes sharing a reconciliation key (they silently reconcile to one instance and share state). Both warn under the vui warning type, so they can be suppressed via warning-suppress-types.

Changed

  • The previous-props/state snapshots kept for :on-update / :should-update are now shallow copies instead of deep copy-tree copies, eliminating per-render copying (and the associated GC) of large state values. State and props values are treated as immutable: replace them (e.g. with functional vui-set-state updates) rather than mutating them in place - in-place mutation of a value was unreliable for change detection before and is invisible to it now.
  • Cursor preservation no longer walks the buffer character by character to enumerate widgets on every render; widgets are collected from button overlays and the field list instead. Collection time depends on the number of widgets rather than the buffer size (about 6x faster at 300 widgets / 15k characters, with the gap growing with buffer size).

Fixed

  • vui-use-async no longer lets a superseded load clobber the current one. When the key changed, the killed process’s sentinel (or a late async result) could write the old entry back over the new pending one, surfacing a stale error or stale data and re-triggering the load on the next render. Resolve/reject callbacks from superseded loads are now ignored.
  • The Pomodoro example (docs/examples/10-pomodoro.el) now keeps time from the wall clock instead of counting timer ticks. run-with-timer fires late when Emacs is busy and drops missed repeats, so decrementing the remaining seconds once per tick drifted and silently lost time. It now stores a deadline and recomputes the remaining seconds on each tick, so the countdown stays accurate regardless of timer jitter; the timer only drives the refresh rate.

Documentation

  • Documented that :face accepts anonymous face specs, not just face symbols: a plist like (:inherit error :weight ultra-bold) or (:foreground "red") can style text inline without a defface (#77). Calls out the two gotchas that made this confusing - colors must be strings (:foreground "red", not red), and there is no bare-symbol shorthand ((error :weight ...) does not work; use (:inherit error :weight ...)). Covered in the Primitives guide, the layout container styling section, and the vui-text docstring.

v1.1.0 - 2026-06-12

Added

  • vui-use-context - Namespace-clean way to consume a context: (vui-use-context NAME-context) is equivalent to the use-NAME function that vui-defcontext generates. Packages should prefer it, since the unprefixed use-NAME hook pollutes the namespace; use-NAME remains generated for convenience in applications and personal configs. vui itself now consumes its collapsible-indent context this way.
  • vui-unmount - Explicitly unmount the instance mounted in a buffer. Runs the full teardown lifecycle for the whole tree (:on-unmount hooks, effect cleanups, :on-mount cleanup functions, async process and render timer cancellation) and erases the buffer. Previously there was no way to tear a mounted UI down, so timers and processes held by components leaked.
  • Async callbacks created via vui-with-async-context and vui-async-callback now also become no-ops when their component tree has been unmounted. Previously they only checked that the buffer was still alive, so a stale timer could re-render an unmounted tree into the buffer.
  • vui-get-instance - Return the root instance mounted in a buffer (defaults to the current buffer). Lets applications drive a mounted UI via vui-rerender / vui-update / vui-update-props without maintaining their own buffer-to-instance storage (#36).
  • Instance update API: New public functions for re-rendering mounted instances:
    • vui-rerender - Re-render an instance preserving component state and memos. Useful for forcing a redraw.
    • vui-update - Update instance props, invalidate all memos, and re-render. Useful when new data arrives and computed values need to refresh while preserving UI state (like collapsed sections).
    • vui-update-props - Update instance props and re-render, preserving memos. Memoized values only recompute if their dependencies change. Useful for periodic refreshes where data may not have changed.
  • vui-typed-field component: New component in vui-components.el for typed input with parsing and validation. Uses component state to preserve raw input (e.g., users can see “123f” they typed while error is displayed).
    • Supported types: integer, natnum, float, number, file, directory, symbol, sexp
    • Path types (file, directory) automatically expand ~ and relative paths via expand-file-name
    • :must-exist constraint for file and directory types (validates path existence)
    • :extensions constraint for file type (e.g., :extensions '("el" "org"))
    • :on-change and :on-submit receive typed values only when input is valid
    • :on-error callback receives (error-msg raw-input) on invalid input
    • Numeric constraints via :min and :max
    • Custom validation via :validate (receives typed value)
    • Error display via :show-error (t or 'below for below field, 'inline for same line)
    • :required constraint for non-empty validation
  • Typed field shortcuts in vui-components.el (wrappers around vui-typed-field):
    • vui-integer-field, vui-natnum-field, vui-float-field, vui-number-field for numeric input
    • vui-file-field, vui-directory-field for path input
    • vui-symbol-field, vui-sexp-field for Lisp input
  • vui-quit command bound to q: Quits window when outside widget fields, self-inserts when inside (so you can type “q” in text inputs).
  • vui-refresh command bound to g: Re-renders the UI with current state when outside widget fields, self-inserts when inside. Applications needing custom refresh logic (e.g., re-fetching data) can override in their derived mode.
  • vui-button now accepts :tab-order and :keymap props. Use :tab-order -1 to make a button non-tabbable, and :keymap to define custom key bindings active when point is on the button.
  • vui-field now accepts :face and :placeholder props. Use :face to style the field text, and :placeholder for placeholder text when the field is empty.

Changed

  • vui-components.el: New component library with higher-level components built on vui primitives.
  • vui-collapsible: Togglable section that expands/collapses content. Supports both uncontrolled (manages own state) and controlled (:expanded prop) modes. Features customizable indicators, title face, indentation, and proper nested indentation via context propagation.
  • Semantic text components: Thin wrappers around vui-text with customizable faces that inherit from standard Emacs faces:
    • vui-heading with :level (1-8) and vui-heading-1 through vui-heading-8 (inherit from outline-1 through outline-8)
    • vui-strong (inherit from bold), vui-italic (inherit from italic)
    • vui-muted (inherit from shadow), vui-code (inherit from fixed-pitch)
    • vui-error (inherit from error), vui-warning (inherit from warning), vui-success (inherit from success)

Fixed

  • vui-select now shows option labels: for (value . label) options, the button text and the completion candidates display the labels, and :on-change receives the corresponding value (any Lisp object, e.g. a symbol or number). Previously the raw alist went to completing-read, so users completed on the values, labels were never shown, and :on-change received whatever string completion returned.
  • vui-field :placeholder is now actually rendered: while the field is empty, the placeholder is shown in the new vui-field-placeholder face (inherits shadow), padded or truncated to the field’s :size, and disappears the moment the field is modified. The prop was previously accepted and documented but never displayed.
  • vui-list no longer requires an explicit nil key-fn before keyword options: (vui-list items render-fn :indent 2) now works. Previously the keyword was consumed as the key function and the call failed with a confusing “Keyword argument 2 not one of …” error. Positional key-fn calls are unchanged, and unknown options still signal an error.
  • vui-component no longer drops props that appear after :children in the argument plist. Previously parsing stopped at :children, silently discarding everything after it.
  • Layout measurement no longer has side effects. Tables and vui-box render their content twice (a measure pass, then the real pass); the measure pass used to instantiate real component instances, so :on-mount fired up to three times per table cell per render, effects registered by cell components re-ran on every render, vui-use-async loaders started once per measure pass, and a component inside vui-box was reconciled twice per render (duplicating child entries and shifting indexes for keyless siblings). Measure passes now skip lifecycle hooks, discard effect registrations, and do not start async loaders.
  • Re-render requests that arrive while a render is in progress (e.g. vui-set-state from :on-update or an effect with vui-render-delay set to nil) are now queued and run after the in-progress render commits. Previously they started a nested render that erased the buffer mid-walk, leaving duplicated content. State-update cycles that never settle now signal a clear error instead of overflowing the stack.
  • Error boundary state is now scoped to the mounted tree (or to the buffer for static vui-render trees) instead of a single global table, and boundaries without an explicit :id derive a stable identity from their position in the tree. This fixes three problems: auto-generated boundary IDs changed on every render, leaking one error entry per render and re-firing :on-error on each one; error state for a given :id survived buffer kills, so a fresh mount could render the fallback even though its children were healthy; and two apps using the same :id shared error state. vui-reset-error-boundary’s ID argument is now optional - calling it without arguments resets all boundaries in the current tree.
  • vui-use-callback* and vui-use-memo* were unusable as documented: plain defmacro does not support &key, so the documented :compare eq syntax signalled void-variable, and omitting :compare failed to macro-expand with wrong-number-of-arguments. Only the accidental :compare 'eq form worked. Both macros now parse :compare from the body; bare eq / equal, quoted symbols, and comparison functions all work, and :compare may be omitted.
  • Killing a VUI buffer now runs the full unmount lifecycle for the mounted tree. Previously :on-unmount hooks, effect cleanups, and :on-mount cleanup functions never ran on buffer kill, so timers and processes held by components leaked.
  • vui-mount now unmounts a previously mounted tree before mounting into the same buffer. Previously the old tree stayed live without any teardown: its cleanups never ran and a timer created by it could re-render the old UI over the new mount.
  • Deferred re-renders now use a per-instance timer instead of a single global one. Previously, state updates in two VUI buffers within the same vui-render-delay window cancelled each other’s pending render, silently leaving the first buffer stale. vui-flush-sync likewise no longer cancels pending renders that belong to other buffers.
  • Disabled buttons now use widget-inactive face instead of the regular button face.
  • Button :tab-order and :keymap properties are now preserved when buttons are truncated in table cells.
  • Keymap hierarchy: special-mode-map bindings (like h for help) were inaccessible because set-keymap-parent overwrote the parent set by define-derived-mode. Now uses make-composed-keymap to include both widget-keymap and special-mode-map.
  • Table cursor preservation: Widgets inside table cells now have unique paths based on row and column indices. Previously, all widgets in a table shared the same render path, causing cursor restoration to potentially jump to the wrong widget (e.g., from row 2’s field to row 1’s field) after re-render.

v1.0.0 - 2025-12-28

Breaking Changes

  • Symbol Prefix Rename: All public macros now use the vui- prefix to comply with MELPA naming conventions:
    • defcomponentvui-defcomponent
    • defcontextvui-defcontext
    • use-effectvui-use-effect
    • use-refvui-use-ref
    • use-callbackvui-use-callback
    • use-callback*vui-use-callback*
    • use-memovui-use-memo
    • use-memo*vui-use-memo*
    • use-asyncvui-use-async

    See the README section “Using Shorter Names (Shorthands)” for how to use aliases or read-symbol-shorthands if you prefer the shorter names.

v1.0.0-rc.3 - 2025-12-20

Added

  • vui-mode major mode for VUI buffers. Provides vui-mode-map that users and packages can extend (e.g., for ace-link). Packages using VUI can derive their own modes from vui-mode to add custom keybindings while preserving VUI and widget functionality.
  • Documentation of Emacs 29 single-widget TAB navigation limitation (upstream bug#70594, fixed in Emacs 30).

Fixed

  • vui-table now respects :indent from parent vui-vstack. Previously, only the first line of a table was indented; now all lines are properly indented.
  • Cursor preservation now uses path-based widget tracking instead of index-based. Cursor correctly follows widgets when other widgets are added or removed before them. Previously, adding a widget above the cursor position would cause the cursor to land on the wrong widget after re-render.

Changed

  • vui-mount and vui-render now enable vui-mode instead of using kill-all-local-variables. This preserves derived modes and their keybindings across re-renders.

v1.0.0-rc.2 - 2025-12-12

Changed

  • vui-list now returns vstack (for vertical, default) or hstack (for horizontal) instead of a fragment. This ensures proper indent propagation when nested inside layout containers.

Added

  • :indent and :spacing options for vui-list.
  • Optional docstring support in vui-defcomponent macro.
  • :no-decoration option for vui-button to render without brackets.
  • :help-echo option for vui-button to control tooltip. Setting to nil disables tooltip generation, providing ~2x faster button creation for bulk rendering.

Fixed

  • Propagate indent through hstack to nested vstack. When a vstack was nested inside an hstack, the indent context from parent vstacks was lost, causing subsequent lines to render without proper indentation.
  • Accumulate indent correctly in nested vstacks.
  • Components that render to nil no longer affect vstack / hstack spacing. Previously, a component returning nil (e.g., (when condition (vui-text "..."))) would still cause separator newlines/spaces to be inserted, resulting in extra blank lines or spaces in the output.

v1.0.0-rc.1 - 2025-12-03

Initial release candidate.

Added

  • Core rendering engine with virtual DOM diffing and reconciliation.
  • Component system with vui-defcomponent macro.
  • Hooks: vui-use-effect, vui-use-memo, vui-use-ref, vui-use-async.
  • Layout primitives: vui-vstack, vui-hstack, vui-box, vui-table.
  • Basic elements: vui-text, vui-button, vui-link, vui-input, vui-checkbox, vui-select.
  • Utility components: vui-fragment, vui-newline, vui-space, vui-list, vui-error-boundary.
  • Async utilities: vui-async-callback, vui-with-async-context.
  • Debug mode with vui-debug-mode.
  • Comprehensive documentation and examples.