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.
- Mounted buffers reflow on window resize by default:
vui-mountandvui-mount-inlinenow installvui-rerender-on-resizethemselves, sovui-flexandvui-gridlayouts 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. Setvui-rerender-on-resize-defaultto nil for the previous opt-in behavior; plainvui-renderbuffers still need the explicit call (#134). vui-renderunmounts 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.
:width 'windownow measures the window showing the buffer being rendered, not whichever window happens to be selected. Avui-flexorvui-gridin 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 wayfill-columnis 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-flexcomputed 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-updatesays skip measures as its cached vtree, exactly what the real render commits; avui-streamregion is never re-bound while measuring, so live handles keep their real buffer; and staticvui-flex-itemchildren 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-columninto 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-tablelines up with the table body whendisplay-line-numbers-modeis on.:align-to 0is the start of the text area, but Emacs reserves room fordisplay-line-numbersthere, so the pinned header now aligns to(line-number-display-width 'columns)instead. Contributed by ginqi7 (#148).
- 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 avui-with-async-contextclosure from the component, or updating props from outside withvui-get-instanceplusvui-update- and when each fits (#151). - Documented that props do not re-seed state. A component whose
:stateis initialized from a prop keeps the value it captured at mount, sovui-updateappears to do nothing: the props change and the buffer does not, with no error or warning. Now called out in thevui-updateandvui-update-propsdocstrings and in the API reference, with the two fixes (read the prop in:render, or sync it in:on-update) (#151). - Documented in
vui-defcomponentthat a function returned from:on-mountbecomes the unmount cleanup, which silently captures a:on-mountform ending in asetqof a lambda (#151).
vui-grid: a responsive grid of equal-width tracks (#134). Cells fill rows in source order; the column count starts from:columnsand falls while tracks of at least:min-column-widthstop 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-spacinginserts blank lines between rows, and an empty cell keeps its track so columns stay aligned. Identity followsvui-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-flexaccepts: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 invui-layout.el: growers grow into their row’s leftover, and the newvui-flex-item:min-widthlets 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.:justifyis not applied under:wrap. Works in bothcharandpixelwidth 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,:rigidfloors, 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.
vui-width-mode: choose how layout measures and pads text. The default,char, is the existing behaviour (string-widthcolumns). Setting it topixelswitches every layout primitive (vui-table,vui-box,vui-flex,vui-hstack/vui-vstackindent,vui-space, button:max-width, field placeholders) to measure withstring-pixel-widthand 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 widthstring-widthdoes 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 onafter-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 becausecharis 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 fortruncate-string-pixelwise. Contributed by ginqi7 (#120, #121).vui-tableaccepts: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 inheader-line-format, so long tables keep their column labels in view. This is the sticky-scroll model, not thetabulated-list-modeone: 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:evalheader-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 previousheader-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
:memoscenarios in the benchmark suite (benchmarks/vui-bench.el): the:memobail-out now compares state with the property-awarevui--vnode-equal(a Lisp walk) instead of C-levelequal, 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 butequalstructure, 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 refreshprev-stateso the worst-case cell cannot silently degrade to theeqfast 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 comparescharandpixelwidth modes; it is feature-detected and does nothing until the switch exists, so the same file still runs on builds without it (#121).
- 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 Cstring-width. Whether pixel becomes the default is not a performance question any more but a compatibility one: it changesbuffer-string(display spacers in padding), which anyone parsing or snapshotting vui output would notice, socharstays (#121). - The
vui-stream-update-lastextend 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 withequal-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 withcompare-stringsand 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).
- Bordered tables line up in a proportional font under
pixelwidth 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 nilthere (#121). - Whole-buffer renders no longer record themselves in the undo list. Every
vui-mountrender and re-render erased and rebuilt the buffer with undo enabled, so Emacs copied the whole erased text, text properties included, intobuffer-undo-list(and then every re-inserted piece), andwidget-setupthrew 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 bindsbuffer-undo-listtotlike 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 whatwidget-setupdid, and it still holds). Padding strings are also shared now: a run of N spaces is inserted or concatenated, both of which copy, sovui--spaceshands out one string per length instead of a freshmake-stringper 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 stockgc-cons-thresholdthat 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). pixelwidth 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 itsfaceanddisplayproperties (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).pixelwidth mode now survivestext-scale-modeand measures correctly undervariable-pitch-mode. Measured in a GUI frame: a pixel-aligned table drifted afterC-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, usingstring-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 undervariable-pitch-mode. Byte-identical in char mode (#121).- Three more
pixelwidth 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:indentwas inserted as N pixels instead of N columns, so an indented vstack in pixel mode indented by a sliver.vui-hstackconverted its inherited:indentto 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. Andvui-flexdistributed the leftover in raw pixels::space-betweeninserted a literal space for each pixel of remainder (a 20-column row came out 6px wide of its edge), and growers and:centergot 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:centerpadding 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-flexgrowers inpixelwidth 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:growvnode 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 incharmode. Invisible in batch, where a pixel is a column; the regression tests mockstring-pixel-width(#121).vui-stream-update-last(and the node-pathvui-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 emptyingvui-stream-remove-last.- The incremental patcher (
vui-incremental-render) skipped a segment whose content string changed only in text properties:equalon vnodes ignores string properties, so avui-textwith the same characters but a different face (viapropertize) kept the stale face in the buffer. Segments are now compared with a property-aware vnode equality (equal-including-propertieson strings). Thevui-stream-update-lastextend fast path had the same blind spot:string-prefix-pcompares 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 ofvui-mode-map) binds mouse presses towidget-button-click, which treats anybuttonchar property as a widget button and fails on vui’s button.el text buttons. vui buffers now keep plain global mouse semantics (mouse-drag-regionon the mouse-1 press, taps go to the touchscreen translator); activation still comes from button.el, so mouse-1 (viamouse-1-click-follows-link), mouse-2 and RET all work. Reported on the quickstart article. :memocomponents and hook deps (vui-use-memo,vui-use-callback) ignored string text properties: comparing with plainequalmeant 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 withvui-incremental-renderoff. Same bug class as the incremental patcher fix above; these comparisons now use the same property-aware equality. Functions in props and deps keep plainequalsemantics, 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 oneq, and even fresh-but-equal strings add well under a microsecond per component (#126).
- A public element-at-point API so consumers stop depending on the rendering
mechanism.
vui-element-atreturns the vui element at point (a button, checkbox, select or field) as an opaque handle;vui-element-getreads its vui properties (:vui-key,:vui-tag,:vui-path, …);vui-key-atis the convenience for the common “which keyed row is the cursor on” question; andvui-activateruns 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 abutton.eltext button or awidget.elfield. 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-keymoves point onto the widget carrying a given reconciliation:key(matched withequal), 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-collapsibletakes a rich header.:header-rightputs a vnode (a count, a badge, a status) right-aligned in the header row opposite the toggle, and:header-width(anythingvui-flex’s:widthaccepts, defaultfill-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-rightthe header becomes a space-betweenvui-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:keyfrom #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-widthhowever 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 withvui-use-stream(orvui-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-lastrewrites 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-streamitem may be avui-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-appendmounts 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-laston 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-streamnodes.vui-stream-update-lastcan only touch the most recent item;vui-stream-openinstead appends an item and returns a stable ref (avui-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-togrows a node in O(delta) (only the new tokens are inserted and redrawn),vui-stream-updaterewrites its whole region, andvui-stream-finalizefreezes 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 isfinalize: 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-afterinsert a new live node directly above / below an existing one (out-of-order: a card that belongs above an item already on screen), andvui-stream-removedeletes one - separators stay single, and emptying the stream re-lays like the empty -> non-empty transition.vui-stream-update-lastis 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-openalso accepts avui-component, mounting it as a stateful inline ROW addressable by ref:vui-stream-updaterefreshes its props OUT OF ORDER (state preserved) wherever it has drifted to - the random-access counterpart ofvui-stream-update-laston a row - andfinalizestops 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-renderflag. (#82) :memokeyword forvui-defcomponent::memo tis a shorthand for the most common:should-update- skip the re-render while props are shallow-equal (equalon each value) and state is unchanged, likeReact.memo. An explicit:should-updatetakes precedence. Works on the normal render path (it skips the component’s vnode production) and, withvui-incremental-renderon, lets the component bail out of re-rendering entirely. Note that:childrencounts as a prop, so a memo component wrapping nested content re-renders whenever its parent does.
- 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 throughwidget-keymaptowidget-backward, which jumps to point-max when there are no widgets and left point stranded there. Instead of chasing every representation, vui now remapswidget-forward=/=widget-backwardandforward-button=/=backward-buttonto 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-mapdefvar, so reloading vui.el into a running session re-applies them.defvardoes not re-evaluate a keymap that is already bound, so before this a reload leftvui-mode-mapwithout the navigation keys: TAB still worked on a button (which carries its own keymap) but fell through toindent-for-tab-commandon plain text. Fresh installs were unaffected; this bit reload-driven development. vui-typed-fieldnow signals recovery: its:on-errorcallback 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-errornow reports the current error state after each input (nil = valid).vui-set-stateno 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 madealla 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 likeall(orcons) is stored literally, while#'1+and(lambda (old) ...)still update as before. This bit anyvui-set-statewhose value was a bare symbol that wasfboundp(the todo example’sall=/=active=/=completedfilters).- Calling the low-level
vui-rendertwice on the same buffer no longer crashes when that buffer holds avui-field. The first render leaves the field’swidget-after-changehook onafter-change-functionsand the field itself inwidget-field-list; the second render’serase-bufferthen fired that hook against the just-deleted field and signalled(number-or-marker-p nil). It was pre-existing and specific to reusingvui-renderdirectly -vui-mount’s re-render path and freshwith-temp-bufferrenders were fine, because the component re-render path already clears the field lists before erasing.vui-rendernow does the same, which also stops dead field widgets from piling up inwidget-field-listacross renders. - Rendering many buttons (or checkboxes and selects) is linear again, not
O(n^2). Every widget
widget-createbuilds 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-pathprefix 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-collapsiblenow 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, topoint-minin the worst case. It hit everything built on the primitive, collapsible sidebars especially, and the:keya 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) sovui--widget-identityre-finds it after the label flips. One honest gap: two collapsibles that share a title and set no:keystill have ambiguous toggles and fall back to the old position-based behavior, no worse than before; a unique title or an explicit:keynow 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-vstackdrops 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-startwas restored to its old absolute line, which is wrong once the number of lines above it changed, so the window holding point now restoreswindow-startrelative 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 experimentalvui-incremental-renderflag. - 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-checkboxandvui-selectalready accept a:keyfor reconciliation; that key now also rides on the widget, andvui--widget-identityprefers 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 firstvui-stream-appendthen 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-updateand:on-updatenow actually bind the rawpropsandstateplists, as the documentation has always promised. Previously only the named prop/state variables,prev-props, andprev-statewere bound, so a form referencingpropsorstate(including the documented example) raisedvoid-variable.vui-unmountno longer signals when tearing down a buffer that contains avui-field. It erased the buffer without inhibiting modification hooks, so the field’swidget-after-changeran 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.
- Buttons, checkboxes and selects now render as
button.eltext buttons instead ofwidget.elwidgets. 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 onwidget.el, which has no text-input equivalent, andwidget-setupstill 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, replacingwidget-forward; a button’s RET and mouse activation come frombutton-map, andvui-modekeepswidget-keymapunderneath for field editing. vui’s buttons rebind TAB and S-TAB on themselves too, sobutton.el’sbutton-buffer-map(whichbutton-mapinherits, binding TAB toforward-button) cannot hijack navigation into a button-only walk that skips fields. Cursor tracking,:keyreconciliation,:tab-order, a custom:keymapandvui-goto-keyall work as before across the mixed set. The one externally visible change: a button’s binding for RET ispush-button(button.el) rather thanwidget-button-press. - The whole-tree skip (commit-skip) is now always on, independent of the
experimental
vui-incremental-renderflag. When a re-render produces the exact same vtree object as the previous commit - whichshould-updatereturning 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 singleeqcheck, 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-childused 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.
- Added a benchmark suite (
benchmarks/vui-bench.el, run witheldev 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 ofeldev 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/:memoand 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; seevui-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).
- Added a runnable
vui-flexexample (docs/examples/12-flex-layout.el): a form whose fields stretch to fill the window, the four:justifymodes, and proportional:growpanels. - Added two
vui-streamexamples.docs/examples/13-agent-chat.elis 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.elis a real, working chat against theclaude -pCLI: it parses Claude Code’s streaming JSON into a livevui-streamtranscript - the reply is a live node grown token by token (vui-stream-open+vui-stream-append-to, finalized withvui-stream-finalize), reasoning and per-turn details render as collapsible component rows (vui-stream-appendof a component), the input box stays editable throughout, and--resumekeeps conversation context across turns. Requires theclaudeCLI on PATH. - Added a real GitHub Actions dashboard example
(
docs/examples/15-ci-dashboard.el) backed by theghCLI - 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, triggersworkflow_dispatchruns from inside Emacs, and streams a run’s log into the buffer withvui-stream. Every GitHub call is an asyncghsubprocess whose JSON is parsed on exit, so the UI never blocks while it polls. Point it at the companiond12frosted/vui-ci-demorepo or any repo reachable withgh. Requires theghCLI on PATH, authenticated.
- Inline mounting (#8):
vui-mount-inlinerenders 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-unmountnow also accepts an instance and removes an inline instance’s region while running the full teardown lifecycle (which also runs on buffer kill, and whenvui-mounttakes over a buffer that hosts inline instances).vui-inline-instance-atreturns 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 invui-flex-itemto give it a proportional share of the leftover (:growweights). 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))).:widthaccepts 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/:keymaplike 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:faceand:keymapto 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 defaultRET/TABbehavior and input fields are untouched.- Layout containers accept
:faceand:keymap:vui-hstack,vui-vstack,vui-box, andvui-listapply them to their whole rendered extent with the same cascading semantics asvui-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-headerface (inheritsbold; previously hardcoded) and border characters invui-table-border(no attributes by default), customizable globally or per table via:header-face/:border-face. vui-typed-field(and the typed shortcuts likevui-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 withvui-flexwidths that depend on the window.vui-cancel-rerender-on-resizeundoes it.- The component inspector (
vui-inspect) and state viewer (vui-inspect-state) now cover instances mounted viavui-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-statewith 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 thevuiwarning type, so they can be suppressed viawarning-suppress-types.
- The previous-props/state snapshots kept for
:on-update/:should-updateare now shallow copies instead of deepcopy-treecopies, 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 functionalvui-set-stateupdates) 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).
vui-use-asyncno 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-timerfires 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.
- Documented that
:faceaccepts anonymous face specs, not just face symbols: a plist like(:inherit error :weight ultra-bold)or(:foreground "red")can style text inline without adefface(#77). Calls out the two gotchas that made this confusing - colors must be strings (:foreground "red", notred), 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 thevui-textdocstring.
vui-use-context- Namespace-clean way to consume a context:(vui-use-context NAME-context)is equivalent to theuse-NAMEfunction thatvui-defcontextgenerates. Packages should prefer it, since the unprefixeduse-NAMEhook pollutes the namespace;use-NAMEremains 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-unmounthooks, effect cleanups,:on-mountcleanup 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-contextandvui-async-callbacknow 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 viavui-rerender/vui-update/vui-update-propswithout 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.elfor 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 viaexpand-file-name :must-existconstraint forfileanddirectorytypes (validates path existence):extensionsconstraint forfiletype (e.g.,:extensions '("el" "org")):on-changeand:on-submitreceive typed values only when input is valid:on-errorcallback receives(error-msg raw-input)on invalid input- Numeric constraints via
:minand:max - Custom validation via
:validate(receives typed value) - Error display via
:show-error(tor'belowfor below field,'inlinefor same line) :requiredconstraint for non-empty validation
- Supported types:
- Typed field shortcuts in
vui-components.el(wrappers aroundvui-typed-field):vui-integer-field,vui-natnum-field,vui-float-field,vui-number-fieldfor numeric inputvui-file-field,vui-directory-fieldfor path inputvui-symbol-field,vui-sexp-fieldfor Lisp input
vui-quitcommand bound toq: Quits window when outside widget fields, self-inserts when inside (so you can type “q” in text inputs).vui-refreshcommand bound tog: 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-buttonnow accepts:tab-orderand:keymapprops. Use:tab-order -1to make a button non-tabbable, and:keymapto define custom key bindings active when point is on the button.vui-fieldnow accepts:faceand:placeholderprops. Use:faceto style the field text, and:placeholderfor placeholder text when the field is empty.
- 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 (:expandedprop) modes. Features customizable indicators, title face, indentation, and proper nested indentation via context propagation.- Semantic text components: Thin wrappers around
vui-textwith customizable faces that inherit from standard Emacs faces:vui-headingwith:level(1-8) andvui-heading-1throughvui-heading-8(inherit fromoutline-1throughoutline-8)vui-strong(inherit frombold),vui-italic(inherit fromitalic)vui-muted(inherit fromshadow),vui-code(inherit fromfixed-pitch)vui-error(inherit fromerror),vui-warning(inherit fromwarning),vui-success(inherit fromsuccess)
vui-selectnow shows option labels: for(value . label)options, the button text and the completion candidates display the labels, and:on-changereceives the corresponding value (any Lisp object, e.g. a symbol or number). Previously the raw alist went tocompleting-read, so users completed on the values, labels were never shown, and:on-changereceived whatever string completion returned.vui-field:placeholderis now actually rendered: while the field is empty, the placeholder is shown in the newvui-field-placeholderface (inheritsshadow), 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-listno 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-componentno longer drops props that appear after:childrenin the argument plist. Previously parsing stopped at:children, silently discarding everything after it.- Layout measurement no longer has side effects. Tables and
vui-boxrender their content twice (a measure pass, then the real pass); the measure pass used to instantiate real component instances, so:on-mountfired up to three times per table cell per render, effects registered by cell components re-ran on every render,vui-use-asyncloaders started once per measure pass, and a component insidevui-boxwas 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-statefrom:on-updateor an effect withvui-render-delayset 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-rendertrees) instead of a single global table, and boundaries without an explicit:idderive 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-erroron each one; error state for a given:idsurvived buffer kills, so a fresh mount could render the fallback even though its children were healthy; and two apps using the same:idshared 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*andvui-use-memo*were unusable as documented: plaindefmacrodoes not support&key, so the documented:compare eqsyntax signalledvoid-variable, and omitting:comparefailed to macro-expand with wrong-number-of-arguments. Only the accidental:compare 'eqform worked. Both macros now parse:comparefrom the body; bareeq/equal, quoted symbols, and comparison functions all work, and:comparemay be omitted.- Killing a VUI buffer now runs the full unmount lifecycle for the mounted tree. Previously
:on-unmounthooks, effect cleanups, and:on-mountcleanup functions never ran on buffer kill, so timers and processes held by components leaked. vui-mountnow 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-delaywindow cancelled each other’s pending render, silently leaving the first buffer stale.vui-flush-synclikewise no longer cancels pending renders that belong to other buffers. - Disabled buttons now use
widget-inactiveface instead of the regular button face. - Button
:tab-orderand:keymapproperties are now preserved when buttons are truncated in table cells. - Keymap hierarchy:
special-mode-mapbindings (likehfor help) were inaccessible becauseset-keymap-parentoverwrote the parent set bydefine-derived-mode. Now usesmake-composed-keymapto include bothwidget-keymapandspecial-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.
- Symbol Prefix Rename: All public macros now use the
vui-prefix to comply with MELPA naming conventions:defcomponent→vui-defcomponentdefcontext→vui-defcontextuse-effect→vui-use-effectuse-ref→vui-use-refuse-callback→vui-use-callbackuse-callback*→vui-use-callback*use-memo→vui-use-memouse-memo*→vui-use-memo*use-async→vui-use-async
See the README section “Using Shorter Names (Shorthands)” for how to use aliases or
read-symbol-shorthandsif you prefer the shorter names.
vui-modemajor mode for VUI buffers. Providesvui-mode-mapthat users and packages can extend (e.g., forace-link). Packages using VUI can derive their own modes fromvui-modeto 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).
vui-tablenow respects:indentfrom parentvui-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.
vui-mountandvui-rendernow enablevui-modeinstead of usingkill-all-local-variables. This preserves derived modes and their keybindings across re-renders.
vui-listnow returnsvstack(for vertical, default) orhstack(for horizontal) instead of a fragment. This ensures proper indent propagation when nested inside layout containers.
:indentand:spacingoptions forvui-list.- Optional docstring support in
vui-defcomponentmacro. :no-decorationoption forvui-buttonto render without brackets.:help-echooption forvui-buttonto control tooltip. Setting tonildisables tooltip generation, providing ~2x faster button creation for bulk rendering.
- Propagate indent through
hstackto nestedvstack. When avstackwas nested inside anhstack, the indent context from parentvstackswas lost, causing subsequent lines to render without proper indentation. - Accumulate indent correctly in nested
vstacks. - Components that render to
nilno longer affectvstack/hstackspacing. Previously, a component returningnil(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.
Initial release candidate.
- Core rendering engine with virtual DOM diffing and reconciliation.
- Component system with
vui-defcomponentmacro. - 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.