UI primitives
bemtvi gives plugins a small, layered set of primitives for building rich interfaces — a reactive component model, plugin-owned content surfaces, ready-made async widgets, and the floating windows underneath them. They share two properties that make plugin UIs short to write and consistent to use:
- The server owns every surface. You never write a render loop, an input grab, or frame-timing code — you hand the server data and a description of what to show, and react to results. (PUC Lua can’t do frame-time work anyway; ADR 0002.)
- One geometry vocabulary. Floats, views, pickers, and the bottom panel all place themselves with the same size / align / margin words (see Placing things).
A file tree, a dashboard, a modal dialog, a which-key popup, a fuzzy finder — each is a few lines on top of these. Reach for the highest-level one that fits:
| You want to… | Reach for | What it is |
|---|---|---|
| A stateful UI that redraws itself (file tree, dashboard, dialog) | btv.view.component / btv.component | A Vue-shaped reactive component: state + a pure render. |
| Plugin-owned content you update by hand (a list, a report) | btv.view | An inert buffer you set lines on and mount in a dock / split / float. |
| To ask the user something (text, choice, yes/no) | btv.ui.input / select / confirm | Async prompt widgets — return a promise. |
| To pop up read-only content (a hint, a tooltip) | btv.ui.float | A bordered content overlay, dismissed by the next key. |
| A real, editable window placed over the layout | nvim_open_win (float form, RPC) | The low-level escape hatch — a true floating window (see Floating windows directly). |
Components — reactive UIs
btv.component is a Vue-shaped component model: you write reactive state and
a pure render, and the framework re-runs the render automatically whenever the
state changes (coalesced to one redraw per tick). It owns the whole lifecycle —
waiting for the surface to be ready, batching state writes, tearing down on close —
so there’s no tick-dance, no manual re-render, no buffer-number juggling.
A component is a { setup, render } table:
setup(ctx, props)runs once on mount and owns every side effect — reactive state, derived state, event subscriptions, key binds, data fetches — and returns thestatevalue handed to render. It runs only after the surface is ready, so everything onctxis valid immediately.render(state)is pure: it maps state to what’s on screen and returns it. The framework re-runs it on every state change.
Both may be async — call btv.await(...) straight inside them (each runs in its
own btv.async coroutine), so a setup that loads from disk, or a render that
fetches to display, reads top-to-bottom.
local Counter = btv.view.component({
setup = function(ctx)
local s = ctx.reactive({ n = 0 }) -- writing s.n re-renders
ctx.keymap_set("n", "+", function() s.n = s.n + 1 end)
ctx.keymap_set("n", "q", ctx.close)
return s
end,
render = function(s)
return { lines = { "count: " .. s.n, "", "+ to increment · q to quit" } }
end,
})
Counter.mount({ float = { width = 30, height = 4, grab = true } })
ctx carries the reactivity and lifecycle:
ctx field | Purpose |
|---|---|
ctx.reactive(tbl) | A deep reactive proxy — writing any key schedules a re-render. (Iterate with ipairs / #, not pairs — PUC 5.4 has no __pairs.) |
ctx.computed(getter) | A cached derived value (read c() / c.value); re-evaluates only when a reactive input it read has changed. |
ctx.refresh() | Force a re-render. |
ctx.props | The opts.props passed to mount. |
ctx.on_close(fn) / ctx.close() | Register a teardown hook / close the instance. |
Two surfaces, one reactive core
Where a component renders is a pluggable backend, so the same reactive core drives two very different surfaces:
"view"(the default, and whatbtv.view.componentselects) — a focus-taking, navigablebtv.viewbuffer, mounted in a dock, split, or grabbing float.renderreturns{ lines, decor }. This is the file-tree / list / modal-dialog case;ctxgainskeymap_set/line/set_cursor/bufnr/winid/bo/wo."float"— a non-focus popup-content float (the which-key surface). It never steals focus and binds no keys;renderreturns{ lines, title?, relative?, border? }(rows may be styled chunks), and an empty render hides the float — so a component shows and hides purely by what it returns. Reach it withbtv.component{ surface = "float", … }.
-- A self-dismissing toast on the non-focus float surface.
local Toast = btv.component({
surface = "float",
setup = function(ctx)
local s = ctx.reactive({ text = ctx.props.text })
btv.timer(function() s.text = nil end, 1500) -- clear -> empty render -> hides
return s
end,
render = function(s)
if not s.text then return { lines = {} } end -- an empty render hides the float
return { lines = { { { s.text, "Comment" } } }, relative = "bottom" }
end,
})
Toast.mount({ props = { text = "saved ✓" } })
mount(opts) instantiates the component (returning the instance, with :close());
opts.props is passed to setup, and the rest configures the surface. Render and
setup errors are caught and surfaced through btv.notify rather than crashing the
editor.
Views — the content surface
btv.view is the surface components are built on, and is useful on its own. A view
is a plugin-owned, read-only buffer: its lines a plugin sets wholesale, the
editing grammar treats it as inert (navigation works, text-mutating keys don’t),
and <CR> dispatches to an on_select callback. It’s the generalization of the
bottom panel — the surface a file tree, a symbol list, or any line-oriented widget
mounts in a dock or a split.
local v = btv.view.create({ name = "files", filetype = "btvfiles" })
v:set_lines({ " init.lua", " README.md" })
v:set_userdata({ { path = "init.lua" }, { path = "README.md" } }) -- parallel to lines
v:on_select(function(line, data) btv.open(data.path, { where = "main" }) end)
v:mount({ dock = "left", size = 30 })
-- later: v:unmount() (keeps it alive) / v:close() (drops it)
| Method | Does |
|---|---|
:set_lines(lines) | Replace the content wholesale. |
:set_userdata(list) | Opaque per-line data (1-based, parallel to the lines); the selected line’s entry is handed to on_select. |
:on_select(fn) | fn(line, userdata) on <CR> / confirm. |
:set_decor(ns, marks) | Replace namespace ns’s extmark decoration — each mark { line, col, <extmark opts> } (0-based), so hl_group / virt_text / sign_text / … all apply. |
:mount(opts) | Show it — { dock = … } / { split = "vsplit"|"split" } / { float = … } / { tab = true }. |
:bufnr() / :winid() | The backing buffer / showing window (live, from the mirror). |
:line() / :set_cursor(n) | Read / move the 1-based cursor line. |
:place_in(win) | Adopt a reserved restore slot (used by btv.view.on_restore, below). |
Persisting a view across sessions
A view opts into the workspace session by passing persist — a stable, plugin-chosen
string id (instance-unique within your plugin) — to create. The editor records only the
(namespace, id) pair and the view’s slot in the layout, never its content: the plugin
owns what’s worth saving and stores it in its own
btv.shada.plugin() store, keyed
by the same id.
On restart the editor reopens the layout with each persisted view’s slot held by an empty
placeholder window, then — once your plugin has loaded — calls the restorer you registered
with btv.view.on_restore so you can rebuild the view and drop it into the reserved slot:
-- Save side: create with a persist id, and stash whatever you need to rebuild it.
local function open_tree(state)
local v = btv.view.create({ name = "Files", filetype = "btvfiles", persist = "main" })
v:set_lines(render(state))
v:on_select(...)
-- The plugin owns the content; persist just enough to rebuild it.
btv.shada.plugin():set("view:main", state)
-- Clean up the stored state if the user closes the view for good.
v:on_close(function() btv.shada.plugin():delete("view:main") end)
v:mount({ dock = "left", size = 30 })
return v
end
-- Restore side: register once at load. `id` is the persist string; `place(view)` drops a
-- freshly-built view into the reserved slot instead of opening a new window.
btv.view.on_restore(function(id, place)
local state = btv.shada.plugin():get("view:" .. id)
local v = btv.view.create({ name = "Files", filetype = "btvfiles", persist = id })
v:set_lines(render(state))
v:on_select(...)
place(v)
end)
The owning namespace is derived from your plugin’s location, exactly like
btv.shada.plugin() — so two plugins can both use persist = "main" without colliding, and
a persisted view whose plugin is no longer installed has its slot quietly collapsed on
restore. A lazy plugin does not have to be awake for this. A reserved slot names its
owner, so a restore that finds one belonging to a cmd/keys/event/ft-lazy plugin
loads that plugin — the slot is itself a trigger, on equal footing with the ones you
declared. You do not arm anything for it: keep bemtvi-tree behind cmd = "BtvTree" and the
sidebar you quit with still comes back, its config running (and its on_restore
registering) before the slot is given up on. A plugin that is enabled = false stays
unloaded and its slot collapses, exactly as if it had been removed. From a context that attributes to no plugin (a bare :lua, an RPC, a test), pass
an explicit namespace = "…" to both create and on_restore, the same escape hatch
btv.shada.plugin(namespace) takes. GC: the editor never deletes your stored state —
delete it yourself when the view is closed for good (the on_close line above). A view
created without persist is ephemeral: it does not ride the session. Session persistence is
a native-build feature (the web build does not restore layouts yet).
The high-level way: a persistent component. The create + on_restore + fresh-mount
dance above is what btv.view.component (see Components above)
automates. Pass persist = "<id>" to
mount and the framework resolves the namespace once, threads it through the backing view +
a per-component ctx.store, and on restart adopts the reserved slot or mounts fresh for you
— no on_restore handler, no VimEnter fallback:
local Files = btv.view.component({
setup = function(ctx)
local s = ctx.reactive({ tree = ctx.store:get("tree") or load_tree() })
ctx.on_close(function() ctx.store:delete("tree") end) -- GC is still yours
return s
end,
render = function(s) return { lines = render_tree(s.tree) } end,
})
Files.mount({ name = "Files", filetype = "btvfiles", persist = "main", dock = "left", size = 30 })
ctx.store is this component’s btv.shada.plugin() slice; mutate it on every change and the
sidebar comes back intact. A full runnable example is examples/view-persist/. Reach for the
raw create / on_restore pair only when you need a surface the component doesn’t model.
Widgets — ready-made prompts
The btv.ui widgets are the common interactions, prebuilt. The three input ones are
non-blocking and promise-only (ADR 0002): the call returns at once and you
react with :next(fn), or await it inside btv.async. (The vim.ui.*
muscle-memory aliases keep neovim’s callback shape.)
-- input: a one-line prompt. Resolves to the text, or nil on <Esc>.
btv.ui.input({ prompt = "Rename to: ", default = vim.fn.expand("%:t") })
:next(function(name) if name and name ~= "" then btv.notify(name) end end)
-- select: a floating chooser. Resolves to the chosen item, or nil on cancel.
btv.ui.select({ "apple", "banana", "cherry" }, { prompt = "Pick:" })
:next(function(item) if item then btv.notify("picked " .. item) end end)
-- confirm: a yes/no dialog. Resolves to a boolean.
btv.ui.confirm("Quit without saving?", { default = false })
:next(function(ok) if ok then btv.cmd("qa!") end end)
-
btv.ui.input(opts)—promptlabel +defaultprefill, over the command line.""on an empty<CR>,nilon<Esc>. -
btv.ui.select(items, opts)— a floating list.format_itemmaps an item to its label (defaulttostring); the original item round-trips back, so an arbitrary table survives even though only strings cross the bridge. Its keys are rebindableselect-mode maps (j/k/<C-n>/<C-p>/arrows nav,gg/Gfirst/last,<CR>confirm,<Esc>/qcancel):btv.keymap.set("select", "<C-j>", btv.ui.select_actions.next) -- actions: next, btv.keymap.set("select", "q", function() end) -- prev, first, last, ``` -- confirm, cancel -
btv.ui.menu(entries, opts)— a context menu on the same list. Each entry is{ label, detail?, enabled?, separator? }.detail(a shortcut, say) sits in an aligned column on the right, a row withenabled = falsegreys out, and{ separator = true }draws a rule. Keys and clicks skip the rows that can’t be chosen. The promise resolves to the chosen entry itself, so extra fields (anaction) come back with it.anchor = "mouse"opens it at the pointer (at the text cursor when a key came after the last click), where it acts like a GUI menu: one click chooses, hovering highlights, a click or right-click off it dismisses it. The right-click menu itself is built on it: to add rows there, register a group withbtv.contextmenurather than opening a second menu.btv.ui.menu({ { label = "Format", detail = "Shift+Alt+F", action = btv.lsp.format }, { separator = true }, { label = "Close", action = function() btv.cmd("bdelete") end }, }, { anchor = "mouse" }):next(function(e) if e then e.action() end end) -
btv.ui.confirm(message, opts)— yes/no.default = true(the default) makes<CR>Yes and shows[Y/n];falsemakes it decline and shows[y/N]. For a multi-choice menu useselect. -
btv.ui.open(uri)— hand a path/URL to the OS opener (open/explorer/xdg-open), off-tick; resolves to the run result{ code, stdout, stderr }(likebtv.run, it resolves rather than rejects — a missing opener iscode = -1).
Popup content
btv.ui.float(contents, opts) is the popup-content surface — a box of read-only
content (a rounded border by default; border = "none" for borderless) with no
list, no selection, no input grab. By default it is transient: the next key
dismisses it.
btv.ui.float("A hint.\nPress any key to dismiss.", { title = " info ", relative = "cursor" })
contents is a string (split on newlines), a list of line strings, or — for a
styled popup — a list where a row is a chunk list { {text, hl_group?}, … }
(neovim’s virt_text shape), so a row can colour its key one group and its
description another. With persist = true it survives keystrokes and returns a
handle (:update(contents, opts) / :close() / :is_open()) — the surface a
key-observer plugin like which-key refreshes as keys arrive. Options: border
("none"/"single"/"rounded"/"double"/"solid", default "rounded"),
title, relative ("cursor" / "editor" / "bottom").
The float surfaces
When something floats, it’s one of four distinct surfaces — worth knowing which, because they behave differently. The two axes: is it a real window? (a real window holds a buffer, so it can scroll and be focused) and does it carry a selectable list?
| Surface | What it is | Real window? | Grabs input? | Scrolls? |
|---|---|---|---|---|
| Floating window | A free, editable window over the layout — you manage its lifecycle | ✅ | optional (grab) | ✅ |
| Popup content | A lightweight display overlay (btv.ui.float) — not a window | ❌ | never | ❌ |
| Popup window | A transient, read-only real window over a scratch buffer — auto-dismissed but scrollable | ✅ | never | ✅ |
| List widget | The floating selectable list — picker, btv.ui.select, btv.ui.menu, completion | server widget | ✅ | ✅ (preview) |
The naming is a two-axis system: “popup” marks the transient, read-only family
(popup content, popup window); “window” marks a real backing window
(floating window, popup window). The popup window sits in the intersection —
it auto-dismisses like popup content, but because it’s a real window it scrolls
(a long hover scrolls past its box) and gets syntax highlighting for free. That’s
why LSP hover and signature help use it, not popup content; it has no direct Lua
constructor — btv.lsp.hover / signature_help open it internally.
Placing things
Every windowed surface — floating windows, btv.view, components, pickers, the
bottom panel — shares one placement vocabulary, so you learn it once:
| Field | Meaning |
|---|---|
width / height | Cells (40) or a viewport fraction string — "50vw" (50% of editor width), "30vh", "50%". A fraction re-resolves on every layout, so it reflows when the terminal resizes. |
align | A 9-grid word — "top-left", "top", "top-right", "left", "center", "right", "bottom-left", "bottom", "bottom-right". |
margin | Inset from the aligned corner. A number (the vertical gap; sides get ~2× to look even), or {vertical, horizontal} / {top, right, bottom, left}. |
relative | "editor" (the whole screen — dock bands included, so a centered float centers on the screen, not on whichever region has focus), "cursor" (anchored at the cursor, flipping for room), or "win". |
border | "none" / "single" / "rounded" / "double" / "solid" (plus "shadow" for btv.view floats). |
title | A string drawn on the top border. |
grab | true (default for a view/component float) locks focus to it — the modal-dialog shape; false is a non-modal panel focus can leave. |
For low-level parity there’s also anchor ("NW"/"NE"/"SW"/"SE") + row/col
offsets, the explicit form align + margin sugar over.
Floating windows directly
When you need a real, editable window over the layout — not a view or a widget —
the float form of nvim_open_win is the low-level primitive. It lives on the RPC
surface (the nvim_* methods a client, test, or remote tool drives), not in config
Lua: bemtvi’s Lua API deliberately omits the mutating vim.api.nvim_* entity surface
(ADR 0002), so a plugin opens its float through btv.view:mount{ float = … } or a
component instead — the same window with the lifecycle managed for you (pass
grab = false for a non-modal panel focus can leave).
-- over RPC (a client or test):
-- win = nvim_open_win(buf, true, { relative = "editor", width = 40, height = 3,
-- align = "center", border = "rounded", title = " scratch " })
-- nvim_win_set_config(win, { height = 6 }) -- move / resize live
-- nvim_win_close(win, true) -- dismiss
From Lua the float reads are first-class: nvim_win_get_config(win) (alias of
btv.win.config) reads a window’s float config back ({ relative = "" } for a tiled
window), and btv.win.gettype(win) returns "popup" for a float. A float is a real
window — it holds an editable buffer, splits are disallowed, and :q / focus /
:only treat it as an overlay rather than part of the tiled tree.
Try it
Runnable playgrounds ship under examples/:
| Example | Shows |
|---|---|
btvchecklist | A modal checkbox dialog written with btv.view.component (reactive state + pure render). |
btvview | A dockable btv.view content surface with <CR> → open-in-main. |
which-key | A real which-key as a surface = "float" component over the pending-key oracle. |
ui-prompt | btv.ui.input and btv.ui.confirm prompts. |
ui-select | The floating chooser, including items that carry data. |
ui-float | Popup content (\f / \F), and LSP hover (K) through the popup window. |
window-geometry | The shared size / align / margin vocabulary across every surface. |
BEMTVI_CONFIG=examples/btvchecklist cargo run -p bemtvi
How it works (in brief)
A floating window is a real window carried by the same WindowOp queue and View
projection as tiled windows — redraw() projects each with its rect, floating
flag, border, and zindex, and every client paints the tiled windows first, then
the floats in z-order. The two placement modes map onto existing
FloatRelative::{Cursor, Editor} variants with no new positioning code.
The widgets sit on one server-side float-list component — completion, the
picker, and btv.ui.select are thin engines over the same widget, differing only in
whether they carry a prompt and a preview; for each, only a display label and an
integer key cross the Lua↔Rust bridge. The component model is pure Lua: a Vue-3
reactivity core (dependency-tracked reactive / computed) over the btv.view
(and popup-content) surfaces, with the render coalesced to one redraw per tick and
async renders generation-gated so a slow one can’t clobber a newer one.
For the full design, see the floating-windows plan, the float-list widget spec, and the native plugin API spec.