Lua TUI framework design and widget sizing
The user is asking about architecture design for a Lua TUI framework. They want to understand whether widgets should be fully defined in Lua (with layout and render methods) versus the current approach where Zig handles layout and rendering.
Let me first understand the current architecture by reading the relevant files.
Current architecture:
- Lua: Owns state + tree structure, returns widget descriptions as tables
- Zig: Parses tables →
Widgetstructs, does layout + rendering
The mouse resize problem: Your resize_pane() in Lua adjusts flex values, but it has no knowledge of the actual pixel/cell positions of widgets after layout. So you can't detect "is the mouse on a split border?"
Options to consider:
Pass layout results back to Lua - After Zig does layout, send computed
{x, y, width, height}back to Lua. Then Lua can do hit-testing for resize handles.Zig handles resize hit-testing - Zig detects drag on borders and sends a new event like
{type="resize_drag", split_id=..., delta=...}to Lua.Full Lua layout - Move
layout()to Lua. Widget tables include alayout(constraints)method that Lua calls. Zig just renders from the positioned tree.Hybrid: Zig layout with metadata - Widgets can have optional
on_resize_dragcallbacks. Zig does layout, detects resize zones, and invokes callbacks.
My recommendation: Option 2 is simplest - Zig already knows positions after layout. Add "resize handle" hit regions alongside your existing HitRegion for PTYs. When mouse drags on a handle, Zig tells Lua which split to resize.
Want me to explore any of these in more detail?
The user is asking for the best solution, not just the simplest. This requires deeper architectural thinking about what makes a TUI framework good - extensibility, composability, performance, developer ergonomics, etc.
Let me think through the trade-offs more carefully and consult the oracle for a thorough analysis of the design options.
- TL;DR
Use Zig as the authoritative layout + rendering + hit‑testing engine, and keep Lua as a pure “state + view(tree) + behavior” layer. Mouse resizing (and all low‑level hit tests) should live in Zig, which then sends semantic events to Lua (e.g. “resize this pane by N cells”), not raw geometry. Add just enough optional geometry querying if needed, but avoid mirroring the entire layout tree into Lua every frame.
- Recommended approach (simple path, but long‑term solid)
High‑level picture:
- Lua: owns logical UI state (pane tree, focus, routing, commands), and exposes
view()which builds a declarative widget tree using primitives (Row,Column,Terminal,Text, …). - Zig: owns the concrete widget structs, runs layout (
Widget.layout), renders via vaxis, does hit-testing for mouse, maps raw input -> higher-level events, and pushes those events into Lua’supdate(event).
Concretely, evolve toward this:
2.1. Make Zig the canonical layout + hit-test engine
You already have:
Widgetwithx, y, width, height, flex, kind.- A flex‑ish layout for
rowandcolumn. HitRegionto represent clickable regions, currently tied topty_id.
Extend this in Zig:
Attach logical IDs to Widgets
- Allow Lua descriptors to carry an optional
id(string or integer) on any widget:id = "pane:123",id = "statusbar",id = "split:abc", etc. - Propagate this through
widget.parseWidgetintoWidget(e.g.Widget.id: ?u32or?[]const u8depending on what’s convenient). - For terminals you already have
pty_id; you can use that as part of the ID scheme.
- Allow Lua descriptors to carry an optional
Build hit regions during layout/render
- After
layout, you know each child’sx/y/width/height. - For each interactive element (pane area, border between
rowchildren, border betweencolumnchildren, scrollbars when you have them), create aHitRegion:kind:.pane,.split_handle,.scrollbar, etc.target_id: the logical ID of the widget or split.x, y, width, height: in cell coordinates.
This can be done either in
layoutor right before rendering, stored in a simpleArrayList(HitRegion)per frame.- After
Central mouse dispatcher in Zig
- On mouse event
(x, y, button, action)from vaxis:- Walk the
HitRegionarray to find the topmost region containing(x,y). - Decide whether this is:
- A click on a pane (focus / send mouse to PTY).
- A drag on a split handle (start/continue/end a resize interaction).
- Something else (future scrollbars, UI buttons, etc.).
- Walk the
- Maintain minimal per-interaction state in Zig (e.g.
drag_state = { kind = .split_resize, split_id, axis, start_mouse_x, start_mouse_y }).
- On mouse event
2.2. Express mouse resize as semantic events to Lua
Instead of exposing all geometry to Lua, give Lua a high‑level description that fits nicely with your existing Lua code:
Define semantic resize events (from Zig to Lua):
-- Example shape event = { type = "mouse_resize", data = { target = <pane_id or split_id>, axis = "width" | "height", delta_cells = <signed integer>, -- horizontal/vertical delta in cells side = "before" | "after", -- which side of the split was dragged, if useful } }Compute
delta_cellsin Zig- Use the difference in mouse cell coordinates between press and current move on the relevant axis.
- Convert directly to cell count; TUI coordinates already are cell units.
- Decide sign convention (e.g. dragging right on a vertical split means positive
delta_cellsfor the left pane).
Reuse Lua’s existing resize logic
- Your
default.luaalready hasresize_pane("width"/"height", delta_chars)that:- Walks the logical tree to find the right split.
- Adjusts
flexvalues of siblings.
- Wire the mouse event handler to call into the same function (or a split-specific variant):
- Map
target(pane id or split info) to the starting node inresize_pane. - Use
delta_cellsasdelta_chars.
- Map
This keeps the model of resizing (which pane to grow/shrink, how flex is redistributed) in Lua where your layout state lives, while Zig only turns pixels into deltas.
- Your
2.3. Keep layout in Zig, view in Lua
Do not move layout into Lua; keep this pattern:
- Zig calls
ui.view()→ Lua builds a fresh widget tree of simple tables (no coordinates). widget.parseWidgetturns it intoWidgetstructs.Widget.layout(Zig) computes positions and sizes; you can expand this system over time (padding, margins, constraints, scroll, etc.).
For extensibility:
- Custom widgets in Lua are functions that return trees of core Zig primitives (
Row,Column,Terminal,Text, laterScroll,Box, etc.). - If a future custom widget needs a custom layout algorithm that cannot be expressed via composition, that widget should become a new Zig primitive; Lua uses it declaratively.
2.4. Optional: on-demand geometry querying, not full mirror
Only when needed, add a very small API from Lua to Zig:
prise.get_layout(id) -> { x, y, width, height } | nil
Implementation sketch:
- After layout, build a map
id -> {x,y,w,h}for elements that opted into layout querying. - Expose a C/Lua function that:
- Looks up the ID.
- Returns a tiny table with geometry.
This gives power users some geometry without paying the cost of:
- Sending the whole layout tree back to Lua each frame.
- Keeping two sources of truth.
You probably won’t need this for pane resizing; it’s more for advanced custom widgets or status bars that want to draw something proportional to space.
- Rationale and trade-offs
Why this (Zig layout + Zig hit test + Lua semantics) is the best long‑term architecture:
Extensibility
- Lua authors define arbitrary widget compositions without touching Zig.
- When a new layout behavior is really needed (scroll, grid, overlay), you introduce a new Zig primitive once, giving better performance and correctness than ad‑hoc Lua layout.
- Hit‑testing stays coherent because it’s driven by the same engine that computes layout and rendering.
Performance
- Layout and hit‑testing stay in Zig, with predictable cost and no per-node callbacks into Lua.
- You already marshal the tree from Lua → Zig; you avoid doing the reverse every frame.
- Mouse interactions don’t require Lua queries to find “what is under the cursor” – Zig already has that in memory.
Composability
- Nested
Row/Column/Terminal/Text/ futureScrolletc. stay easy to reason about in Lua; they’re just nested tables. - Hit regions can be composited across nested layouts; Lua doesn’t need to know the nesting layout math to respond to events.
- Nested
Developer ergonomics
- Lua authors think in terms of high‑level events (
mouse_resize,click,scroll,key_press) and logical IDs, not coordinates. - The model matches Elm/React:
update(event)mutates state,view()re‑renders a declarative tree. - No requirement to implement/understand layout algorithms in Lua.
- Lua authors think in terms of high‑level events (
Future features
- Scrollable containers: Zig implements a
Scrollwidget that:- Lays out its child into “content space”.
- Clips based on
scroll_x/scroll_yfrom Lua props. - Emits
scrollevents with deltas / wheel input.
Lua just updates scroll offsets in state.
- Focus management:
- Zig can maintain a list of focusable regions per frame; keyboard navigation events (
focus_next, etc.) are generated in Zig and passed to Lua with target IDs. - Lua decides how to change focus (e.g., custom focus rules), but doesn’t have to do geometry checks.
- Zig can maintain a list of focusable regions per frame; keyboard navigation events (
- Animations:
- Timers already live in Zig and call back into Lua (
set_timeout). - For animations, Lua owns animated state (e.g.,
progress) and updates it inupdate. - Zig simply re-runs
view()andlayouton each frame; no change to the core architecture.
- Timers already live in Zig and call back into Lua (
- Scrollable containers: Zig implements a
Why not the other options as primary:
Option 1: Pass full layout results back to Lua every frame
- High FFI overhead: for each node, you now marshal coordinates + flags in addition to what you already send Lua → Zig.
- Lua now has a second copy of layout state; risk of people wanting to mutate positions directly instead of going through flex/constraints, which complicates the model.
- You almost never need the full geometry tree for typical app logic; only a few events need local geometry.
Option 3: Full Lua layout
- Performance: layout every frame in Lua is slower and more jitter‑prone than Zig, especially for nested containers and future features like scroll + virtualized lists.
- Complexity: you’d need to expose measurement APIs (for text, terminal content, etc.), and then rebuild a layout engine in Lua you already have in Zig.
- Harder to keep hit‑testing and rendering correct and in sync.
Option 4 as standalone: hybrid callbacks for layout or resize
- Layout callbacks into Lua during
Widget.layoutwould be expensive and easy to misuse (e.g., layout running arbitrary user code). - Resize‑drag callbacks from Zig → Lua are good, but they should be semantic events on top of Zig hit‑testing and layout, not “Lua decides hit testing.”
- Layout callbacks into Lua during
So the recommended architecture is really: Option 2 (Zig internal resize hit testing + events) plus a clean event API to Lua, optionally with a tiny bit of Option 1 (on‑demand geometry query) when truly needed.
- Risks and guardrails
Risk: IDs get messy or inconsistent
- Guardrail: enforce a simple ID convention (e.g. panes use
pty:id(), non‑pane widgets use explicitidfields). - Consider validating IDs in
parseWidget(e.g. log if two widgets in the same frame share the same ID).
- Guardrail: enforce a simple ID convention (e.g. panes use
Risk: overloading Lua with too many event types
- Guardrail: keep a small, orthogonal event vocabulary. For mouse resizing, expose one consistent
mouse_resizeshape; reuse for both panes and future resizable UI elements.
- Guardrail: keep a small, orthogonal event vocabulary. For mouse resizing, expose one consistent
Risk: advanced widgets want more geometry than your minimal APIs expose
- Guardrail: start with semantic events; add
get_layout(id)only when a real need appears. Don’t prematurely dump the entire layout tree into Lua.
- Guardrail: start with semantic events; add
Risk: large UIs may make the Lua → Zig tree construction a bottleneck
- Guardrail: if/when this shows up (profiling), introduce incremental/diffed trees or keyed widgets; but that’s an optimization step, not a different architecture.
Effort estimate to add mouse resize with this architecture: M (1–3h) for a basic implementation (assuming hit-region plumbing exists or is trivial), more like L (1–2d) to polish events, IDs, and edge cases.
- When to consider the advanced path
Revisit the design only if you see:
- Layout tree size grows very large and
view()→parseWidget()becomes a bottleneck (FPS drops even when nothing is animating). - You need truly custom layout algorithms per widget type that can’t be expressed as compositions of core Zig primitives, and there are many such widgets created by users.
- Lua authors frequently ask for complex geometry info to implement custom interactions (drag‑drop, custom selection rectangles, cross‑widget snapping, etc.), and your on‑demand
get_layout(id)isn’t enough.
- Optional advanced path (outline only)
If those triggers happen, a more advanced architecture could be:
- Maintain a retained scene graph in Zig, keyed by stable widget IDs, and have Lua’s
view()return a “virtual tree” that’s diffed against the existing graph (React-style reconciliation). - Support a richer layout language in Zig (constraints, grid, absolute positioning in subtrees).
- Expose a read‑only layout snapshot API to Lua for introspection (
prise.layout_snapshot()returning a pruned tree withid+ geometry) but not per frame, only on demand. - Let advanced widgets register custom layout delegates implemented in Zig, declared from Lua (e.g. “custom_grid” layout implemented as a Zig plugin).
But you don’t need that now. For a tmux‑like terminal multiplexer with Lua customization, the cleanest long‑term path is:
Declarative Lua trees → Zig layout/render/hit‑test → semantic events → Lua state update.
The oracle's analysis is thorough. Here's the distilled recommendation:
Best architecture: Zig owns layout + hit-testing, Lua owns state + semantics
Lua: state → view() → widget tree (tables with optional IDs)
↓
Zig: parseWidget → layout() → hit regions → render
↓
mouse/keyboard → semantic events → Lua update()
Key changes to implement:
- Add widget IDs - Let Lua attach
idto any widget; propagate through parsing - Build hit regions for split handles - During layout, detect borders between Row/Column children
- Emit semantic resize events -
{type="mouse_resize", target=split_id, axis="width", delta_cells=3} - Lua handles the logic - Your existing
resize_pane()just receives cell deltas instead of computing them
Why this is best long-term:
- Extensibility: Custom Lua widgets = compositions of primitives; truly new layouts become Zig primitives
- Performance: Layout/hit-test stay in Zig, no FFI overhead per frame
- Future-proof: Scrollable containers, focus management, animations all fit this model cleanly
- Ergonomics: Lua authors think in events and IDs, not coordinates
Avoid: Mirroring full layout back to Lua every frame, or moving layout into Lua (slower, duplicate state, harder to keep in sync).
Want me to start implementing this - beginning with widget IDs and split-handle hit regions?
Thread
Reference Thread
T-02bda434-0e58-425a-be98-7baaf505cae5