docs/internal/widget-framework-v2-review.md
Purpose: audit the widget framework in fresh-core / crates/fresh-editor/src/widgets/
and the UI chrome built around it; explain structurally why focus, event routing,
wheel targeting, and text input have been a recurring bug source rather than a run of
unrelated defects; compare the design against the state of the art (Dear ImGui, the
CSS/flexbox model, Tailwind, htmx, TanStack); and propose the shape of a v2 that makes
Fresh a genuinely good plugin-authoring target.
Convention: this is an ANALYSIS + PLANNED doc. Claims about current behaviour are cited to source; where this doc and the source disagree, the source wins. Nothing here is implemented.
The widget framework's central design decision — a widget tree renders to a flat
vector of styled text rows (TextPropertyEntry) which the host writes as a virtual
buffer's content — is the root of the recurring bug classes. That representation has
no vertical axis, no z-order, no clipping, and no coordinate space. Every UI need that
requires one of those four has been met by punching a side channel out of the
renderer to the host (overlays, dropdown_popup, embeds, scroll_regions) or by
pushing the work back onto the plugin author (visibleRows, hand-counted chrome rows,
blank padding rows).
Concretely, in crates/fresh-editor/src/widgets/render.rs:
$ grep -c "panel_height\|avail_height\|available_rows" widgets/render.rs
0
The layout engine takes panel_width and threads it down
(allocate_row_child_widths). It never learns how tall it is. Every scrollable
widget therefore carries a plugin-supplied visibleRows, and every plugin that
hosts a list re-derives the editor's own layout arithmetic in TypeScript.
That single gap explains most of the reported symptoms:
| Reported symptom | Structural cause |
|---|---|
| Scroll goes to the wrong element | No scroll containers — only a hand-ordered ladder of surfaces in mouse_input.rs and per-widget visibleRows |
| Focus management fights us | Three unrelated focus models (widget focus_key, FocusManager<T>, per-component ad-hoc rings) + a 15-variant hardcoded layer enum |
| Mouse/keyboard propagation | No capture/bubble, no hit-test tree; 72 hand-written dispatch functions in one file and 29 hand-rolled *Layout hit caches |
| Text input misbehaves | Three text-editing engines, and typed characters take a different, slower path than selection keys |
| Reimplementing cursor ops | TextEdit is not reachable from a plugin; plugins mirror {value, cursor} and feed cursorByte back every frame |
| Dropdowns are painful | Popups are not a layer; Dropdown is special-cased as a singleton field on RenderOutput with a hardcoded 8-row window |
Event handling has a second, orthogonal axis (§2.6): even inside the widget
framework, per-kind behaviour is a match on WidgetSpec at 280+ sites across four
files, and the key path is not a dispatch table at all but a ladder of per-kind
short-circuits. Routing and encapsulation are separate problems; a hit-tree fixes the
first and nothing about the second.
The recommendation is not "rewrite the widget framework." It is: give the existing
declarative tree a real box/constraint layout pass with a vertical axis, a real
layer + hit-test tree, one focus/input path, and one WidgetImpl trait in
place of the per-kind matches — then delete the four side channels and the parallel
control library that exist only because those things are missing.
| Layer | Where | Shape |
|---|---|---|
| Widget vocabulary | fresh-core/src/api.rs (WidgetSpec) | 17-variant tagged enum, Serialize/TS-derived |
| Reconciler / renderer | fresh-editor/src/widgets/render.rs (10,440 lines) | spec + prev instance state → rows, hits, focus |
| Registry | fresh-editor/src/widgets/registry.rs | (plugin, panel_id) → spec, hits, instance state |
| Host runtime | fresh-editor/src/app/widget_runtime.rs (4,220 lines) | key/mouse dispatch, floating & dock panels |
| Plugin front-end | plugins/lib/widgets.ts (1,255 lines) + generated fresh.d.ts | builder functions |
| Semantic projection | fresh-editor/src/view/scene.rs | Scene view structs shared with web/GUI frontends |
The good parts are genuinely good and should survive any v2:
ButtonKind::{Normal, Primary, Danger} rather than
colours; TreeNode rather than glyph strings. Plugins describe intent; the host owns
theme. This is the right boundary and is what makes theme switching free.Scene feeding the TUI, the wgpu/winit
GUI (fresh-gui — which paints the same ratatui cell grid, so it rides the TUI
renderer rather than being an independent one), and the web bridge, with a Rust
parity test that fails on divergence, is stronger than what most terminal editors
have. Any v2 must preserve it. Two caveats the rest of this doc has to respect:
there are exactly two independent widget renderers — the row renderer
(TUI+GUI) and web-ui/js/65-widgets.js, ~1,000 lines of hand-written JS that walks
raw WidgetSpec with its own CSS flex layout and must be hand-mirrored for every
vocabulary change — and the parity test (tests/scene_parity.rs) covers tabs,
status bar, and menus but zero widget-panel surfaces today.render_spec walks the tree and returns RenderOutput:
pub struct RenderOutput {
pub entries: Vec<TextPropertyEntry>, // the rows
pub hits: Vec<HitArea>, // (row, byte_start..byte_end)
pub instance_states: HashMap<String, WidgetInstanceState>,
pub focus_key: String,
pub tabbable: Vec<String>,
pub focus_cursor: Option<FocusCursor>,
pub embeds: Vec<EmbedRect>, // side channel 1
pub overlays: Vec<OverlayRow>, // side channel 2
pub scroll_regions: Vec<ScrollRegion>, // side channel 3
pub dropdown_popup: Option<DropdownPopup>, // side channel 4
}
The first five fields are the model. The last four are each an admission that the model cannot express something:
embeds — a widget cannot contain a non-text surface, so WindowEmbed returns a
rect for the host to paint into afterwards.overlays — a widget cannot be drawn over another, so Overlay children are
extracted into a second list the host paints in a later pass. HitArea grew an
overlay: bool flag and the registry grew a second hit-test entry point
(WidgetRegistry::overlay_hit_test) to keep the two coordinate spaces apart.scroll_regions — a widget cannot clip its children, so an overflowing List
exports its geometry for the host to paint and drag a scrollbar over.dropdown_popup — Overlay rows are clipped to the panel, so a dropdown near a
modal's bottom edge would be cut off. Rather than fix clipping, the open dropdown is
hoisted to a singleton Option on the output and drawn at screen coordinates. Its
window is a constant: DROPDOWN_VISIBLE_OPTIONS: usize = 8. Only one dropdown can
be open per panel — a limitation, though a milder one than an app-wide singleton.That is four escape hatches in one struct, all tracing to the same missing feature: the renderer has no coordinate space, only a row list.
These are ordered by how much pain per line of code they cause.
render_spec threads panel_width down and nothing else. Col stacks children and
returns however many rows they happened to produce. There is no "you have 24 rows,
distribute them," no flex-grow on the vertical axis, no intrinsic-vs-available
height distinction.
The consequence is that the row budget is a plugin responsibility, and it shows:
// plugins/search_replace.ts
// Tree visible rows = panel viewport height minus the chrome
// (line 1 + options row + separator + footer = 4 rows)
const fixedRows = 5;
const visibleRows = Math.max(3, getViewportHeight() - fixedRows);
Note the comment says 4 and the constant is 5. That is exactly the failure mode of hand-counted layout: the arithmetic and its rationale drift, silently, and the only symptom is a row of content clipped or a dead row at the bottom.
The orchestrator goes further and reimplements justify-content: space-between:
// plugins/orchestrator.ts
// The host tree renders only its actual content rows (it does not pad itself out to
// `visibleRows`), so with few sessions the hints used to sit directly under the last
// card with dead space *below* them. Measure the rows the visible tree content
// occupies and fill the gap with blank, non-interactive rows...
const padRows = bottomRows > 0 ? Math.max(0, listRows - treeRows) : 0;
const bottomPad = padRows > 0 ? [raw(Array.from({length: padRows}, () => ({text: ""})))] : [];
A plugin is emitting blank text rows to push a footer to the bottom of a panel.
That is the terminal-UI equivalent of spacers, and it is the framework's fault,
not the plugin's.
The settings port hit the same wall from the host side. Per
settings-widget-unification-plan.md §5.3.1, list_of sets the embedded List's
visible_rows = rows.len() — i.e. "no viewport" — which pushed the [+] Add new row
off-screen on tall maps and hung two e2e tests. The root cause is recorded there as a
composite-visibility problem; it is more precisely the absence of a height constraint
to propagate.
Assumption to reject: "widgets are text, and text has no height, so the plugin knows best." The plugin knows least — it cannot see the dock drag width, the terminal resize, or the sibling panel. The host has all of it.
Editor::handle_vertical_scroll (app/mouse_input.rs) is a single else if
chain over every scrollable surface in the application, in priority order, written by
hand:
SHIFT → horizontal
overlay prompt (live grep) → preview pane or result list
bottom-anchored prompt dropdown
file browser
any popup
floating widget panel (modal: consumes even on a miss)
dock
mounted split widget panel
else → wheel_surface_at(col,row) → pane / explorer / tab strip / nothing
Every branch carries an issue number in its comment (#2119, #2969) because every branch was added after a bug where the wheel reached the wrong surface. That is the signature of a missing abstraction: the ordering is data being expressed as control flow, so it can only be extended by someone who holds the whole ladder in their head.
What is missing is what every GUI toolkit has: a scrollable-container hit test. "Find the topmost element under the pointer; walk up its ancestors; the first one with overflow consumes the delta; if it is already at its bound, keep walking (that is scroll chaining)." That is ~20 lines against a proper tree, and it makes the modal "consume even on a miss" rule fall out of the layer's own opacity rather than being special-cased.
The click path is the same disease at larger scale: mouse_input.rs is 4,713 lines and
72 functions — 16 of them handle_click_* alone (menu bar, file explorer, scrollbar,
horizontal scrollbar, status bar, search options, split separator, split controls, tab
bar, suggestions, prompt scrollbar, popup scrollbar, global popups, buffer popups,
context menus, …) — each hit-testing against its own cached rect struct. There are
29 distinct *Layout structs across view/ and app/ whose only job is "remember
where I painted things so I can hit-test them next frame."
docs/internal/rendering-and-layout.md §11 and
input-keybindings-actions.md §8 both list a unified HitArea + z-index as PLANNED.
It is the single highest-value unshipped item in the codebase.
There are three independent implementations of "a caret in a string":
model buffer + Cursors + Event — the real editor. Correct: graphemes,
multi-cursor, undo as a transaction log, plugin hooks.primitives/text_edit.rs::TextEdit (819 lines) — the widget framework's engine.
Correct-ish: graphemes, word nav, selection. No undo.view/prompt.rs::Prompt — the command palette / file finder /
search input, with its own input: String, cursor_pos: usize,
selection_anchor: Option<usize>, and its own undo_stack/redo_stack.Three engines means three sets of grapheme bugs, three different behaviours for Ctrl+Left at a word boundary, three answers to "does Ctrl+Z work here."
Worse, within the widget framework the same field is driven by two paths of
different latency. In app/input.rs:
ModeKeyDisposition::TextInput(ch) => {
// ... Deliberately still an async plugin action: a mode's other bindings
// (Space, Backspace, …) edit the same field through the same queue, and taking
// a host-side shortcut here would let plain characters overtake them and
// scramble the typed text.
let action_name = match view.effective_mode.as_deref() {
Some(mode) => format!("mode_text_input@{}:{}", mode, ch),
None => format!("mode_text_input:{}", ch),
};
Some(self.handle_action(Action::PluginAction(action_name)))
}
ModeKeyDisposition::WidgetSelection(mv) => {
// ... host-side, synchronous, straight into the focused TextEdit
self.with_focused_text_editor(&panel_id, |e| e.move_word_left_selecting())
}
A typed a is encoded into an action-name string, dispatched as a
PluginAction, and marshalled across the QuickJS channel; the plugin's handler then
issues a textInputChar widget command back to the host, whose TextEdit applies the
edit (the plugin does not perform the edit itself, and on the fast path deliberately
does not re-emit its spec — see search_replace.ts's mode_text_input handler). So
the round trip costs latency and ordering coupling, not a full spec rebuild.
Shift+Left, by contrast, is applied synchronously, host-side, to the same TextEdit
with no round trip at all. The comment is honest about the hazard the split creates
and chooses the queued path for every mutating key to keep ordering — which is the
right local call and the wrong global architecture.
The string encoding deserves its own note. "mode_text_input@orchestrator:a" is a
keystroke serialised into the Action enum's plugin-action namespace, then parsed back
out. The @mode qualifier exists only because the unqualified name was a global
handler name that multiple plugins fought over — the comment above it says so. This is
a character-level input event travelling through a command channel.
And because the host's TextEdit is not addressable from TypeScript, every plugin
form field carries a mirror:
// plugins/orchestrator.ts — 18 of these in one plugin
sshHost: { value: string; cursor: number };
projectPath: { value: string; cursor: number };
filter: { value: string; cursor: number };
with cursorByte: openDialog.filter.cursor fed back into the spec on every render.
To be precise about where the defect is: the host does enforce the seed-only rule at
runtime — for a keyed Text widget with instance state, the renderer takes the value
and caret from the retained TextEdit and ignores the spec's value/cursor_byte
(see the keyed-Text arm in widgets/render.rs). The re-fed cursorByte is therefore
dead weight, not a live race. The defect is that nothing in the type says so:
cursorByte/value look like live inputs in widgets.ts, so every plugin dutifully
maintains a mirror the host will never read after frame one. The fix is expressing the
rule in the API (initialValue/initialCursor), not adding enforcement that already
exists.
Three focus models coexist:
RenderOutput::{focus_key, tabbable} — the widget framework's, a String key plus a
declaration-ordered tabbable list recomputed on every render.view/ui/focus.rs::FocusManager<T> — a generic Vec<T> + current: usize ring, used
by Settings.Prompt demonstrates the collision. It is a hand-rolled composite widget (text field +
suggestion list + title + footer) that had a real WidgetSpec bolted onto it:
// view/prompt.rs
pub toolbar_widget: Option<fresh_core::api::WidgetSpec>,
/// Overlay focus ring position: `None` = the query input is focused ...
/// `Some(key)` = that toolbar control is focused ... Tab/Shift+Tab cycle
/// input → toggles → input.
pub toolbar_focus: Option<String>,
Two focus rings in one component, manually interleaved by Tab handling, because the prompt hosts widgets but is not made of them.
Above all of this sits app/overlay.rs, introduced (correctly) to stop the keyboard
and mouse precedence ladders from drifting apart. But look at what it is: a 15-variant
enum naming every specific UI surface in the application —
pub(crate) enum LayerKind {
EventDebug, Settings, KeybindingEditor, CalibrationWizard, WorkspaceTrust,
Menu, Prompt, Popup, NewTabMenu, TabContextMenu, FileExplorerContextMenu,
CloseSplitMenu, FloatingModal, Dock, Editor,
}
— rebuilt from scratch every time it is queried, by reading fifteen different Option
fields off Editor. Four of those variants (NewTabMenu, TabContextMenu,
FileExplorerContextMenu, CloseSplitMenu) are the same widget: an anchored list of
labels with a highlighted row. They are four separate structs in
app/types/context_menu.rs with four separate key handlers, in a codebase that already
has PanelPlacement::Anchored floating widget panels.
This unified the precedence while leaving the composition unsolved. Layers do not
nest, are not data, and cannot be created by a plugin. Adding any new modal surface
means: a new field on Editor, a new enum variant, a new arm in overlay_layers(),
and a new arm in each of the four dispatchers that match on kind.
This is the "where we perhaps shouldn't be using widgets" case — or more precisely, where a half-migration is worse than either end state.
Per settings-widget-unification-plan.md, the render leg is done: every Settings
control paints through widgets::render_spec, and view/controls/*/render.rs is
deleted. But view/controls/ still holds 6,203 lines, including seven
input.rs modules — the per-control key/mouse state machines. And the bridge between
the two is view/settings/widget_map.rs (1,303 lines), which each frame projects the
full live control state — edit buffers, carets, selections, dropdown-open flags,
focused-row hints — into a fresh WidgetSpec. Its own header says it plainly:
Editing still runs through the settings input path — this migrates the view.
So a settings text field today is: TextInputState (domain model) → embedded
TextEdit (engine) → widget_map projection → WidgetSpec::Text → render_spec →
rows, with input going settings/input.rs → TextInputState, entirely bypassing the
widget runtime's own handle_widget_key. Two models, one render path, one input path
that doesn't match the render path.
The plan document is candid about the cost: the first swap regressed 25 e2e tests and
introduced a hang, fixed by making the projection more faithful — i.e. by making the
mirror more complete rather than removing the mirror. And Settings still runs a third
scrolling model on top: view/ui/scroll_panel.rs's ScrollablePanel (self-described
as "inspired by Flutter Sliver, WPF ScrollViewer, Qt QAbstractScrollArea"), used by
nothing else.
There is a real question underneath this, and it is worth asking rather than assuming: should Settings be a widget panel at all? A modal form with a categories sidebar, search, per-field reset/inherit affordances, and a nested entry-editor stack is not obviously the same thing as a plugin's side panel. The honest answer is that it should be — but only once the framework has vertical layout, real scroll containers, and a layer stack. Migrating the render leg onto a framework that lacks all three is what produced the 1,303-line projection layer. The migration was sequenced before its prerequisites.
The five items above are all about routing — which surface receives an event. There is an orthogonal axis: encapsulation — where the behaviour for a given element type lives. A hit-tree with capture/bubble fixes the first and does nothing for the second, and it is worth being explicit that these are separate problems.
Today the widget framework is a closed enum WidgetSpec plus a match on that enum at
every site that needs per-kind behaviour:
| Site | WidgetSpec:: match sites |
|---|---|
widgets/render.rs | 142 |
app/widget_runtime.rs | 75 |
widgets/actions.rs | 33 |
view/settings/widget_map.rs | 32 |
Plus WidgetInstanceState (a parallel enum in registry.rs), the widgets.ts
builder, and the generated fresh.d.ts. Adding one widget kind touches seven
files.
The render side is the healthy case: render_collected is essentially one dispatch
match calling per-kind collect_* helpers, so the behaviour is at least located.
The input side is the unhealthy case. handle_widget_key is not a dispatch table at
all — it is a ladder of short-circuits, each keyed on the focused widget's kind, each
placed ahead of the general path:
// completion-popup short-circuit — intercepts Tab/Up/Down/Enter/Esc
let completions_open = matches!(key, "Tab"|"Up"|"Down"|"Enter"|"Escape")
&& self.focused_text_completions_open(panel_key);
if completions_open { /* ... */ }
// "Dropdown-popup short-circuit: ... Mirrors the completions short-circuit above."
let dropdown_open = matches!(key, "Up"|"Down"|"Enter"|"Space"|"Escape")
&& self.focused_dropdown_open(panel_key);
if dropdown_open { /* ... */ }
That is structurally the same defect as handle_vertical_scroll (§2.2), reproduced
inside the widget framework: a priority ordering expressed as control flow, extended
by prepending a branch. The comment on the second block — "mirrors the short-circuit
above" — is the tell.
The remaining kind checks in widget_runtime.rs are worse than either: clustering
their line numbers shows roughly 40 separate one-or-two-line matches!(spec, WidgetSpec::X {..}) probes scattered through the key-handling code, rather than one
dispatch point. Behaviour for a kind is neither centralised nor encapsulated — it is
smeared.
Why both short-circuits exist at all: the completion popup and the dropdown option list are not child boxes. They are side channels (§1.2), so they are not deeper in the tree than the field they belong to, so ordinary innermost-first dispatch cannot give them the key. The short-circuits are hand-simulated depth. Fix the tree (§4.5) and route by depth (§4.2), and both blocks delete themselves — not because they were refactored, but because the condition they encode becomes structurally true.
That still leaves the vtable question, which §4.2 does not answer: after bubbling picks the right box, the box has to do something, and today "doing something" is a match arm in a central function rather than code the widget owns. See §4.3.
The usual framing ("immediate vs retained") is the least interesting axis, and Fresh
already sits in the more defensible spot: WidgetSpec is retained-declarative with
keyed instance state, which is strictly better than ImGui's ID-stack for a system where
the UI author lives across an async IPC boundary. Rebuilding the tree per frame in
TypeScript and reconciling host-side is right, and should not change.
The things ImGui gets right that Fresh does not:
GetContentRegionAvail() is the primitive Fresh lacks entirely; every
visibleRows in a plugin is a manual reimplementation of it.BeginChild gives clipping, scrolling, and a scrollbar in one call. Fresh's
equivalent is List.visible_rows + the ScrollRegion side channel + host paint —
three pieces the plugin author must assemble.io.WantCaptureMouse / WantCaptureKeyboard is a
single boolean answering "did the UI eat this event." Fresh answers it with
overlay_layers() plus four dispatchers matching on LayerKind.What Fresh should not copy: ImGui's ID stack (positional identity is fragile —
Fresh's explicit key is better), and its lack of accessibility/semantic model
(Fresh's Scene projection is a genuine advantage over ImGui and over every other TUI
toolkit).
The browser box model is the thing Fresh is missing, minus the parts nobody wants:
flex-grow is exactly what spacer(flex: true) is for
horizontally — and there is no vertical equivalent, which is why the orchestrator
pads with blank rows. justify-content: space-between on a Col deletes that hack.overflow: scroll as a container property, not a widget property. This is the
single change that fixes wheel routing: any box can be a scroll container, wheel
events walk the ancestor chain, scroll chaining is the natural default. It also
deletes visibleRows from the plugin API — the container's height is whatever the
layout gave it.What to steal explicitly: the box model, flex on both axes, overflow, stacking
contexts, and position: absolute-with-anchor. What to leave: the cascade, inheritance,
selectors, and anything resembling a stylesheet language.
Tailwind is worth naming for one narrow reason, and it is not utility classes. Its
real contribution is the constrained design-token scale: you cannot pick an
arbitrary spacing value, so a large codebase stays visually coherent without a design
review. Fresh already has the mechanism (theme keys, ButtonKind intents) but has not
extended it to spacing and density. A Density::{Compact, Comfortable} token plus a
small fixed spacing scale on Row/Col would let the dock, settings, and every plugin
agree on padding without any of them hardcoding spacer(1) — which they all currently
do. plugins.md §7.3 already lists "full role-based theming (only button intent
ships)" as PLANNED; this is the same item, extended to layout.
htmx's insight is that the server owns state and the client sends semantic
intents, avoiding a duplicated client-side model. Fresh's widget framework is already
htmx-shaped and more correct than htmx here: the plugin sends a spec, the host owns
interaction state, events come back semantic (activate/change/select) with no
coordinates. That is the right architecture and the review should say so clearly.
The idea worth taking: htmx's swap granularity (hx-swap, out-of-band swaps). Fresh
has WidgetMutation fast paths, but the default path is "re-emit the whole tree." For
the orchestrator dock — thousands of lines building a full spec on every keystroke,
crossing the QuickJS boundary — a targeted "replace the subtree under key X" swap
would cut both latency and the surface area for reconciliation bugs.
The idea to reject: htmx's string-templated, stringly-typed contract. Fresh's
ts-rs-generated fresh.d.ts is a strictly better boundary and must be kept. (And
"mode_text_input@mode:char" is exactly the stringly-typed failure htmx invites — it
should be a typed hook argument, not a synthesised action name.)
List/Tree/TableTanStack's discipline is headless logic: Table, Virtual, and Query ship state
machines with zero rendering, so the same sorting/paging/virtualisation logic serves
any renderer. Fresh has the raw material — WidgetInstanceState already holds
scroll_offset, selected_index, item_height, expanded_keys — but it is welded
into a 10,440-line render.rs rather than being an addressable, testable core.
Two concrete implications:
List/Tree. Its virtualiser takes
count, getScrollElement, estimateSize and returns the visible window. Fresh's
List takes visibleRows from the plugin, which is the same computation performed
in the wrong process. Inverting this (host computes the window from the container
height it just assigned, plugin supplies a row-count and a row-builder) removes the
entire visibleRows family of bugs — including the settings list_of hang.inFlight / lastError fields (the orchestrator has both). A
WidgetSpec::Async { key, placeholder } that the host renders as a skeleton until
the plugin resolves it would delete a lot of duplicated plugin state and make
loading states consistent across the app.| Dimension | Fresh today | State of the art | Verdict |
|---|---|---|---|
| Declarative + keyed reconciliation | ✅ | React/TanStack | Ahead of most TUI toolkits |
| Semantic (non-visual) plugin API | ✅ | — | Ahead of ImGui, Ratatui, Bubble Tea |
Multi-renderer semantic projection (Scene) | ✅ + parity test | — | Genuinely rare; keep |
| Sandboxed plugin UI with host-owned hit-test | ✅ | — | Strong security posture |
| Layout: horizontal | partial (flex spacer, width_pct) | flexbox | Behind |
| Layout: vertical | ❌ none | flexbox | Critical gap |
| Scroll containers / clipping | ❌ per-widget visibleRows | overflow, TanStack Virtual | Critical gap |
| Z-order / stacking | ❌ 4 side channels, 1 dropdown max | stacking contexts | Critical gap |
| Focus model | 3 competing | 1 tree-derived ring | Behind |
| Event propagation | hand-ordered ladders | capture/bubble on a hit tree | Behind |
| Text input | 3 engines, 2 latencies | 1 engine | Behind |
| Design tokens | theme keys only | Tailwind scale | Minor gap |
Five changes. They are ordered so that each is independently shippable and each makes
the next one smaller. Nothing here requires abandoning WidgetSpec, the TS builders, or
the reconciler — the vocabulary is fine; the engine underneath it is what changes.
Split the current single-pass render_spec into:
spec ──measure(available: Constraints) ──► intrinsic sizes
──arrange(final: Rect) ────────────► LayoutBox tree (rects, clip, z, scroll)
──paint(LayoutBox) ────────────────► cells / TextPropertyEntry / DOM
Constraints { min_w, max_w, min_h, max_h } — the Flutter/WPF two-phase model, which is
the right one for a system that must also drive a GPU window and a browser.
Immediate consequences:
Col gets flex, justify, and align. The orchestrator's blank-row padding and
search_replace's getViewportHeight() - fixedRows both delete.visibleRows disappears from the public API. A List inside a box of height 12 shows
12 rows because that is what arrange gave it. The settings list_of hang cannot be
expressed.LayoutBox tree is the hit-test tree (§4.2). No more *Layout cache structs.Keep the row-based paint back end. Widgets should still render as styled text rows in the terminal; the change is that layout no longer happens in the row domain.
With a LayoutBox tree carrying rect, clip, z, and scroll, mouse dispatch
becomes:
hit(point) -> Vec<&LayoutBox> — deepest-first path from root, respecting clip
and z.This replaces the handle_vertical_scroll ladder, the 16 handle_click_* arms,
and the 29 *Layout caches. It also makes LayerKind mostly
unnecessary: a modal is a box with z above its siblings and pointer_opaque: true,
not an enum variant plus four dispatcher arms.
Retain overlay_layers() for the handful of genuinely application-global precedence
rules (event-debug intercepts everything; PTY routing gates) — but shrink it from
fifteen surface-specific variants to two or three semantic ones.
§4.2 answers which box gets an event. It does not answer what the box does with it — that is still a match arm in a central function. Fix it by separating the wire type from the behaviour:
WidgetSpec stays a closed, Serialize/Deserialize/TS-derived tagged enum. It
crosses a sandbox boundary and feeds three renderers plus the Scene parity test;
it must remain plain data.trait WidgetImpl {
fn measure(&self, spec: &WidgetSpec, st: &InstanceState, c: Constraints) -> Size;
fn arrange(&self, spec: &WidgetSpec, st: &mut InstanceState, r: Rect) -> Vec<LayoutBox>;
fn paint(&self, spec: &WidgetSpec, st: &InstanceState, b: &LayoutBox, s: &mut dyn Surface);
fn on_event(&self, spec: &WidgetSpec, st: &mut InstanceState,
ev: &Event, cx: &mut EventCtx) -> Handled;
}
fn behavior(spec: &WidgetSpec) -> &'static dyn WidgetImpl { /* the ONE match */ }
Exactly one match on WidgetSpec survives, in one file. The 142 + 75 + 33 match sites
of §2.6 become impl bodies that only ever see their own variant. Adding a widget kind
becomes: one enum variant, one impl WidgetImpl, one TS builder — instead of seven
files.
render_collected's per-kind collect_* helpers are already ~80% of measure +
arrange + paint; the extraction is mechanical. The genuinely new work is
on_event, which is what dissolves handle_widget_key's short-circuit ladder:
Dropdown::on_event claims Up/Down when its own open state is set and returns
Handled::No otherwise, letting the key bubble. No central function needs to know
that dropdowns exist.
EventCtx is the second half of self-containment. A widget must be able to act
without reaching into Editor — the current handlers cannot, which is why they live
on Editor in the first place. EventCtx gives them a scoped capability set:
request focus, request a repaint, start/stop a pointer grab, emit a semantic event to
the owning plugin, push a child popup. This is the same shape as
DeferredAction/InputContext in input-keybindings-actions.md §3.1, which already
solved this problem for modal components — generalise that, don't invent a second one.
The extensibility fork, stated explicitly. "Self-contained component" can mean two different things and they have very different costs:
Closed kind set, host-side WidgetImpl | Open kind set, plugin-defined behaviour | |
|---|---|---|
| Who adds a kind | host PR, one impl | any plugin |
| Event cost | synchronous, in-process | one IPC round-trip per event |
| Text input | fixed by §4.5 | reintroduces the mode_text_input latency for every component |
Scene parity / web + GUI renderers | intact | broken — they cannot paint an unknown kind |
| Sandbox | intact | plugin code runs inside layout |
Recommendation: the closed set. Open kinds re-create the exact defect §4.5 exists
to remove, and would silently break the multi-frontend parity that is one of Fresh's
real advantages (the web widget renderer cannot paint a kind it doesn't know). Plugins get composition instead, which is 90% of what they actually
want: a WidgetSpec::Component { key, child } node that is a focus scope and an
event scope — Tab cycles within it, events bubble to its boundary and can be
stopped there. Plugins already build subtrees with TypeScript functions; this makes
such a subtree behave as a unit rather than as inlined nodes, and gives it a stable
identity for keyed reconciliation and targeted swaps (§4.7 phase 7).
Delete FocusManager<T> and every component-local ring. Focus becomes:
FocusId = the LayoutBox's stable key path.focus_trap: bool on any box (modals set it; the prompt's toolbar problem in §2.4
becomes "the prompt is a focus trap containing an input and a toolbar" and the
interleaving code disappears).focused: Option<FocusId> on the editor. Editor.focused_widget,
Prompt.toolbar_focus, SettingsState's ring, and dock focus all collapse into it.Keyboard dispatch then mirrors mouse: capture from root to the focused box, bubble back.
The is_terminal_ui_action / allows_normal_fallthrough whitelists in
input/keybindings.rs §5.2 become "the event bubbled past every UI box without being
consumed, so the editor gets it" — which is what those whitelists are approximating by
hand.
TextEdit to fresh-core as the single-field editing engine, with undo.Prompt's inline engine; make the prompt's query field a Text box.view/controls/*/input.rs state machines; route Settings through
handle_widget_key.mode_text_input@<mode>:<char> with a typed key fast lane through the
same ordered queue. An earlier draft proposed making printables synchronous
host-side; adversarial review against the source showed that would create the
interleaving hazard, not remove it — plugins bind Backspace/Space/Enter in their
defineMode keymaps as async plugin actions (search_replace.ts binds
["Backspace", "search_replace_backspace"]), and the mode keymap resolves before
the printable fallthrough, so "x, Backspace, a" would apply as a-then-Backspace.
Making all editing keys synchronous requires pre-empting the mode keymap — an
audited, plugin-visible precedence break that also has to carve out getNextKey
capture (flash, vi-mode) and keymap-level interception (search history's Up/Down
proxy). So the sequenced fix is: step 1, keep one ordered queue but make the
keystroke a typed event (WidgetKeyInput { mode, key } or a WidgetAction fast
lane) instead of a synthesised PluginAction string — this fixes the stringly
encoding and the mode-collision namespace now, with zero plugin breakage;
step 2, only after the widget runtime owns all editing keys for focused text
boxes (post-§4.3 on_event), move the whole editing-key family host-side in one
audited change, never printables alone.cursorByte/value from the plugin-facing spec as apparent live inputs. The
host already enforces seed-only at runtime (§2.3); rename to
initialValue/initialCursor so the type says what the runtime does, and delete
the 18 {value, cursor} mirrors in orchestrator.ts.This is the change that most directly answers "text input not working correctly" and "having to reimplement cursor operations."
Add WidgetSpec::Popup { anchor: Key, placement, child }. It participates in layout
like anything else, in a higher stacking context, clipped to the screen rather than
to its panel.
Then delete: RenderOutput::dropdown_popup, RenderOutput::overlays,
HitArea::overlay, WidgetRegistry::overlay_hit_test, DROPDOWN_VISIBLE_OPTIONS, and
the one-dropdown-at-a-time restriction. ScrollRegion and EmbedRect also fold in:
a scroll container's scrollbar is painted from its own LayoutBox, and WindowEmbed
becomes a box with a foreign paint back end.
Fold the four context-menu structs in app/types/context_menu.rs into a single
anchored-popup widget, and let plugins mount context menus — which they currently
cannot.
Re-ordered after adversarial review against the source (which found that the original "layout first" phase 1 was not a true increment — it changed the plugin API, the web renderer, and e2e visual assertions all at once). Each phase ships independently and pays for itself:
| Phase | Work | Unblocks |
|---|---|---|
| 0 | Test net: widget surfaces in scene_parity.rs; a headless render_spec snapshot harness; first unit tests around handle_widget_key (today: zero) | Every later phase gets a tripwire |
| 1 | WidgetImpl trait + EventCtx; extract the per-kind matches into impls (behaviour-preserving; covered by the existing ~150 render.rs unit tests) | Collapses 280+ match sites to one; adding a kind touches 1 file, not 7; shrinks every later phase |
| 2 | Height, minimally: avail_height on RenderContext, populated at the three render call sites from floating/dock/split geometry; List/Tree default their window from it when the spec omits visibleRows (explicit values keep winning → zero plugins break); extend the resize-rerender loop to split-mounted panels; migrate one plugin (search_replace.ts) as proof | Kills the visibleRows bug class without new abstractions |
| 3 | Vertical flex/justify as a render-internal two-pass over collected row-counts, still emitting rows; then the real Constraints/LayoutBox tree, kept internal to the renderer (still flattening to rows + HitAreas, because rows/bytes are the wire format for click delivery, the web bridge's hit-index protocol, and the focus cursor) | Deletes the padding hacks; geometry substrate for 4 |
| 4 | Hit tree with capture/bubble, panel-local first: replace the two handle_widget_key short-circuits and overlay_hit_test; only then grow outward to the app-level wheel/click ladders as non-widget chrome gains boxes | Deletes the short-circuits, then the ladder and *Layout caches |
| 5 | Unified focus ring + focus traps | Deletes FocusManager, toolbar_focus, dock-focus special cases |
| 6 | Single TextEdit; typed key fast lane (§4.5 step 1); host-side editing keys only as the later audited step 2 | Fixes the stringly encoding now, the latency later |
| 7 | Popup in a stacking context; Component focus/event scope | Deletes 4 side channels + 4 context-menu structs |
| 8 | Finish Settings onto the framework; delete view/controls/ and widget_map.rs | ~7,500 lines deleted |
| 9 | Design tokens (density/spacing scale), Async subtree, targeted swaps | Consistency + latency |
Phases 1 and 4 are the two halves of the event problem and are independent of each other: 4 fixes routing (which box), 1 fixes encapsulation (what the box does). Phase 1 goes first because it is a mechanical extraction with no behaviour change and removes the "touch seven files" tax from everything after it.
Phase 8 is deliberately last. Per §2.5, attempting it before the phases above is what produced the projection layer; the plan document's own §5.3.1 is a record of paying that cost.
Implementation status. Shipped on this plan's first PR: phase 1 in
full (17 kinds behind WidgetImpl, one dispatch); phase 2 in full
(avail_height, auto-sized List/Tree, search_replace.ts
migrated); phase 3's substrate — the internal LayoutBox tree
(widgets/layout_box.rs: rects, z, clip-order, parent links, built by
collection and shifted through every container path alongside the
embed/scroll-region channels) — with vertical flex/justify still open;
phase 4 panel-local — wheel dispatch bubbles the hit path with scroll
chaining, and both handle_widget_key short-circuits are deleted in
favour of kind-owned on_key (KeyDisposition::PassAfter carrying the
dismiss-then-act contract); phase 5 panel-local — the published Tab
ring derives from the box tree (spec-walk ring retained pre-collection
only, pinned equal by debug assert) and focus_trap scoping is live;
phase 6 step 1 (typed key fast lane); and phase 7's tree half — the
completion popup and dropdown pop-over are boxes (z=1 opaque /
screen-space z=2) parented to their owning field, which is the
structural fact the deleted short-circuits used to hand-simulate.
The outward growth has since begun: WidgetRegistry::overlay_hit_test
is deleted — which surface the pointer is on (base rows vs a covering
popup) is the box tree's call, passed as a parameter to the one
byte-ranged resolver, and the right-click path now shares the same
probe as left-click/hover (fixing its overlay blind spot). The two
big precedence ladders are data: handle_vertical_scroll's else-if
chain is WHEEL_SURFACES and handle_mouse_click's 18-step chain is
CLICK_SURFACES — const tables walked with a uniform consumed/pass
contract, mid-chain guards included as explicit entries. Adding a
surface is a variant + a table position + a dispatch arm; the
ordering no longer lives in control flow. What the tables are not
yet: a real hit-test — containment still lives inside each surface's
handler because non-widget chrome has no boxes. Since then: the
ScrollRegion channel is deleted — the scroll payload rides the
scrollable widget's own box (one object, one shift path; the
scrollbar painters and drag read the box tree) — and vertical flex
shipped: Spacer { flex } inside a height-budgeted Col absorbs the
leftover rows (the single-fill pass takes precedence), so
bottom-pinned chrome needs no plugin row arithmetic. Chrome boxes have since begun in earnest: BOTH the wheel and the
click chains dispatch over z-ordered boxes built per event from the
cached chrome geometry (wheel_chrome_boxes / click_chrome_boxes)
— containment is a rectangle wherever the paint caches record one, a
full-frame box + handler-decline where geometry is derived, and the
two mid-chain click guards ride at their old positions as boxes. The
WHEEL_SURFACES/CLICK_SURFACES tables and their enums are deleted.
ALL FIVE pointer gestures now walk chrome boxes: right-click and
double-click converted their short chains (rclick_chrome_boxes /
dblclick_chrome_boxes, guards as declining boxes), and hover — the
last hand-ordered ladder — became the same walk in query form
(hover_chrome_boxes + hover_chrome_target: 13 z-ordered surfaces,
handlers decline where geometry is finer than their rect). The
surface-selection if/else chains are gone; what survives ahead of
each walk is a small set of pre-walk short-circuits (the LayerKind
overlay ladder, dock capture, modal gating) that decide whether
chrome dispatch runs at all — those are the app-level keyboard/layer
model's territory and fall with it, not with the pointer arc.
Key ownership is now complete on the same model: every kind's whole
keyboard vocabulary lives in its on_key (DualList's column/cursor
ops, List/Tree selection + paging + activate, Text's caret motion,
chords, clipboard, and multi-line Enter), with a KeyFx effects
channel (focus_advance, flash_scrollbar, clipboard_copy) so
kinds request host actions without reaching into the Editor.
handle_widget_key's match holds only genuine cross-widget panel
policy: Tab ring cycling, picker forwarding to a sibling scrollable,
and the single-line-Enter submit/advance fallback.
The app-level focus unification (§4.4) has begun where it isn't
gated on the big migrations: the overlay prompt's toolbar is fully
on the shared model — ring derived from its layout-box tree
(focus_ring, document order; any focusable kind joins), clicks
hit-testing the same tree (prompt_toolbar_hits deleted), and
activation dispatched through the kind's own on_key (the
Toggle/Button match deleted). Riding on that, the Row paths now
shift boxes/embeds by display width rather than byte length, so
Row-hosted box geometry is hit-test-correct for non-ASCII labels.
What remains of toolbar_focus is one Option<String> naming the
focused control — collapsing it (and Settings' FocusManager, and
the dock's focused flag) into the one focused: Option<FocusId>
awaits the prompt-as-widgets migration (§4.5) and Settings (phase
8) respectively.
§4.5 step 1 has begun at the engine level: the prompt's private
editing implementations are deleted — every mutation and
selection-move runs through the shared TextEdit via a sync shim
(Prompt::apply_edit), so boundary walks, drains, and anchor
bookkeeping exist once. TextEdit grew delete_to_start (Ctrl+U)
and a grapheme-correct forward-delete in the process, improving
widget Text fields too. Deliberately retained prompt-side: the
undo/redo stacks (engine undo is the recorded §4.5 follow-up), the
word-motion policy (buffer-style next-word-start /
select-to-word-end, which differs from the engine's word hops), and
the word-motion policy only. Since then the collapse completed:
the prompt_input.rs editing key arms ride the shared text-key
table (twelve per-KeyCode arms → two, through
Prompt::handle_text_key), every external reader moved onto an
accessor layer (input_str/cursor_byte/set_input_selected/
set_input_plain/set_cursor_byte/select_range), and the
input/cursor_pos/selection_anchor fields are DELETED — the
prompt's text state IS an embedded single-line TextEdit; the
sync shim reduced to a direct engine call. One text engine, one
selection model, one key table across prompt, Settings fields, and
widget Text. Engine undo has since landed too:
TextEdit owns a bounded, deduped (value, cursor) history —
every mutating edit checkpoints, set_value deliberately never
does (programmatic nav-syncs must not flood history; hosts
checkpoint explicitly for deliberate replacements) — so the
prompt's host-side undo stacks are deleted and widget Text fields
gained C-z/C-y for free, with change firing on restore. §4.5
step 1 including "with undo" is complete. Still prompt-side: the
buffer-style word-motion policy, computed over the engine's value.
The chrome-geometry arc (phase 4's last piece) has begun: ONE
chrome_boxes() surface tree — every routable chrome surface
once, surface-named kinds, one z-order — now serves hover,
right-click, and double-click, whose per-gesture builders are
deleted; each gesture's dispatch declines surfaces it has no
handler for. Since then the arc advanced: wheel and left-click joined
(all five gestures, one tree, four more builders deleted — though
only hover/right-click/double-click sort by the tree's z; wheel and
click order the same boxes by their own WHEEL_ORDER/CLICK_ORDER
precedence, so "one z-order" holds for the tree, not yet for every
walk), and proxy retirement covered the geometric bulk — menu
bar/dropdowns via MenuLayout, context menus via
ContextMenuCore::rect, tabs via TabLayout.bar_area, search options,
file explorer, per-split/per-item editor scrollbars, separators,
buttons, status bar all carry real rects — and, closing the last
two proxies the milestone review caught, chrome:prompt_scrollbar
and chrome:popup_scrollbar now ride their painted rects
(suggestions_scrollbar_rect, per-popup scrollbar_rect; no box
when none was painted). chrome:prompt_suggestions stays
full-frame ON PURPOSE: it is the wheel's position-blind capture of
an open prompt's suggestion list (scroll anywhere scrolls the
list), not missing geometry — the box now says so. The remaining
full-frame surfaces are named guards whose semantics ARE
full-screen (menu/context-menu close guards, popup absorb/dismiss,
overlay-prompt modal, transient guard, and the file browser's
no-recorded-layout fallback, which is a modal dialog absorbing
strays). The dropdown pop-over's windowing moved into
the widget renderer — the host paints handed-down rows + indices and
no longer windows — but the window itself (the
DROPDOWN_VISIBLE_OPTIONS = 8 constant) lives on in the renderer;
it moved, it did not disappear. The per-gesture
WHEEL_ORDER/CLICK_ORDER arrays were evaluated for collapse: with
rects disjoint they are mostly order-agnostic, and what remains in
them is the irreducibly per-gesture slotting of guard/capture
surfaces — documented as semantics, not debt. The tree is built per
event from the recorded layouts (cheap, always state-fresh);
persisting it per render was evaluated and rejected as staleness
risk without measured cost.
Phase 7's popup channel has since generalized: the singleton
DropdownPopup side channel is renamed PanelPopup (fields
popup/popups/popup_hits/popup_rect) because it is no longer
dropdown-specific — a plugin Popup { screen_space: true } node now
renders its child subtree and projects the fully-rendered rows
through the same channel (empty row_indices: generic rows are not
option cells, so the host records no select hits and the box absorbs
clicks), with anchor_absolute marking explicit anchors so
container merges don't drift them. The overlays channel and
HitArea::overlay were then AUDITED for deletion and the honest
answer is: they are not event side channels anymore. The
covering-surface decision is box-driven (hit_path +
pointer_opaque names the surface; z alone deliberately does not),
and HitArea::overlay records only which row text a hit's byte
range was measured against — a coordinate-space fact
hit_test_row_aware needs, not dispatch. What remains of
overlays is the PAINT wire for panel-clipped popup rows (Overlay,
panel-clipped Popup, the Text completion list) — the same standing
entries has for base rows. Migrating the completion popup to the
screen-space channel would change visuals (escape panel clipping;
e2e assertion churn) and is recorded as an optional follow-up, not
debt. Phase 7's task closes on that disposition.
The chrome registration arc (chrome-event-model-plan.md) has since
executed its dispatch half — slices 0 through 4: chrome surfaces
REGISTER (a ChromeComponent per surface, one components()
registry — the app-level kinds::behavior()), every per-gesture
handler arm lives with its component (the central
hover_chrome_target/rclick_chrome_dispatch/
click_surface_dispatch/wheel_chrome_scroll matches are deleted),
and ALL FIVE pointer gestures scan ONE tree in ONE order via
hit_stack (effective-z bands, children above parents, document
order within a band; Consumed/PassAfter/Pass dispositions
preserve the act-then-continue guards). The per-gesture
WHEEL_ORDER/CLICK_ORDER arrays are DELETED: their slots became
stacking structure (z bands ×10; transient dismiss at 175; the
overlay prompt's wheel/click asymmetry as two thin boxes — wheel
modal 160 over suggestion capture 155, click scrim 15), so "one
master z-order" is now true of every walk, not just three. The tree
is still rebuilt per event from live state (the freshness ruling).
Since then the modal and keyboard slices landed too: the
dispatch_modal_mouse LayerKind ladder is DELETED — each modal
(Settings, keybinding editor, calibration wizard, workspace trust,
floating modal) owns its whole-channel mouse capture as a registered
component (capture_mouse, first-active in registry order, ranked
as the layer stack ranks them); the hardcoded context-menu keyboard
rung is a registry loop (on_key — the keyboard analogue of
capture); and overlay_layers() — the single source of truth for
keyboard focus, PTY gating, and modal precedence — is DERIVED from
per-component layers() declarations with explicit ranks, so a new
overlay surface registers a component and appears in every consumer
for free. The four context-menu LayerKind variants collapsed to
one (no consumer matched them individually). The chrome plan has since executed to COMPLETION: the dock
click/right-click captures moved into the walk with the formal
pointer-grab slot (PointerGrab derived from live drag state; the
two precedence caveats resolved deliberately and documented), the
scan gained its opacity gate (popups/suggestions absorb as a tree
property — pointer_opaque, with the band rule that specific
targets outrank their opaque backdrop), and geometry-from-layout
(slice 7) retired every derivable *Layout cache:
search_options_layout (7a), the status bar's
clickable/plugin_token_areas (7b — with_status_bar_ctx
shares the painter's own input gathering; ChromeComponent::hover
takes &mut Editor since live geometry can lazily load buffer
chunks), and menu_layout (7c — the renderer's walk runs
frame-free; chrome_rows_now() derives the five chrome rows via
the actual Layout split). Each retirement is pinned by
debug-build parity oracles asserting event-time derivation ==
paint walk. The remaining popup/suggestion rects
(popup_areas, global_popup_areas, suggestions_*,
prompt_results/preview_area) are RULED paint-recorded (7d):
they anchor to paint-produced text layout (cursor screen position
via the wrap maps, split content_rect), so an event-time
derivation would still resolve against paint recordings — the
screen_space class — and their handlers re-check live stacks
before acting. Geometry is now produced by layout or recorded by
ruling; never recorded by accident. Per-gesture decomposition of
the modal handlers stays design-intermediate by ruling (modal
interiors are bespoke until Settings migrates).
A staff-level review pass then audited the arc against this doc's
goal (verdict: the model is real; gap list in the PR ledger), and
the REMEDIATION WAVES closed it out: the two confirmed bugs fixed
with regression tests (TextEdit undo trailing-newline, prompt
Ctrl+Home/End); the theme inspector registered as a component;
hover reactions moved behind on_hover_change; horizontal scroll
and triple-click routed through the one tree (on_hwheel,
PointerPress::Triple — the WheelSurface ladder and the
post-walk split scan are deleted); PointerGrab extended to all
13 press-to-release drag classes with grab-keyed drag dispatch and
complete terminal-forward suppression; FloatingModal/Dock keyboard
claims moved onto the on_key registry (one precedence source);
the handle_click_* bodies moved into their component files
(mouse_input.rs 4,100 → 2,550 lines — the walk engines, grab
routing, and shared geometry remain); Tree's focus↔selection sync
moved into the kind behind a new WidgetImpl::on_focus_change
hook; the capability-as-kind-string gates replaced by
kind-declared capabilities (HitArea.row_target /
.context_click, BoxMeta.picker_scroll_target); and the
clipboard's host shells rerouted through the kind's own C-c/C-x/
C-a vocabulary (one owner). Remaining acknowledged limitations,
now documented at their sites: mounted (split-buffer) panels drop
the overlay/popup channels (a renderer-compositing arc of its
own — prefer floating slots for popup-bearing UI); the router's
orchestrator-shaped dock branch awaits the focus-unification arc;
panel hover runs beside (not inside) the chrome hover walk.
Also open, in overall plan order: the rest of the app-level focus
unification — FocusManager, Prompt.toolbar_focus, dock focus
(5), host-side editing keys (6 step 2), completion-popup migration +
overlays/overlay_hit_test deletion (7 — plugin-visible wire
change, budget the web renderer mirror), Settings (8), and
tokens/Async (9).
Two standing costs every phase must budget for, found in review: (a) the web widget
renderer (web-ui/js/65-widgets.js) hand-mirrors the spec vocabulary and must be
updated for every kind/field change; (b) the e2e suites assert on screen strings,
so any phase that intentionally changes visual output (2, 3, 7, 8) pays an assertion-
churn tax — which is exactly why phase 0 exists.
WidgetImpl per kind (§4.3), not plugin-authored behaviour running
per event. The latter puts an IPC round-trip on every keystroke and breaks the
web/GUI renderers, which cannot paint a kind they don't know.Scene projections. Multiple frontends from one semantic
model, guarded by a parity test, is a strategic asset; layout v2 must sit below
it — and phase 0 must first extend that parity test to widgets, which it does not
cover today.Rough, source-counted, assuming all phases land:
| Deleted | Lines |
|---|---|
view/controls/ (7 input state machines + domain models) | ~6,200 |
view/settings/widget_map.rs | ~1,300 |
view/ui/scroll_panel.rs | ~770 |
Wheel/click ladders + *Layout caches in mouse_input.rs | ~1,500 (of 4,713) |
Prompt's inline text engine + toolbar_focus interleaving | ~400 |
| Context-menu structs collapsed into one anchored popup | ~600 |
RenderOutput side channels + overlay_hit_test | ~500 |
| Total | ~11,000 |
Against roughly 3,000–4,000 lines of new layout/hit-test/focus engine. The net is not the point — the point is that ~11,000 lines currently exist because those 3,000 do not.