docs/internal/settings-widget-unification-plan.md
Forward-looking plan, not a description of what ships today. Fresh currently has two independent UI-control implementations. This document records why, what each provides, and a phased plan to collapse them into the single declarative widget framework that is already exposed to plugins.
Convention note: this is a PLANNED doc. The status tables below label what is IMPLEMENTED versus PLANNED; where a claim disagrees with the source, the source wins.
Number,
Dropdown, and DualList shipped as WidgetSpec kinds with host-owned
instance state, keyboard + mouse dispatch, Set* mutations, TS builders,
and unit tests. The kinds then grew the affordances the Settings dialog
actually needs (and plugins get for free): Toggle has label_first
(form layout label: [v], chip-only hit), label_width alignment, and
indeterminate ([-] for inherited values, issue #2345); Number
renders a label: [ 42 ] value cell with an in-place edit mode
(edit_text/edit_cursor/edit_sel_*: live buffer, selection bg,
REVERSED block caret); Dropdown renders label: [value ▼] with an
inline option list when open (windowed to 8 rows, scroll_offset);
Text has block_caret (REVERSED caret cell for modal surfaces);
OverlayOptions gained reversed.overlay.rs +
FloatingWidgetState already provide the layer stack, modal/dock/anchored
panels, and on-top popups. The Dropdown's option list renders inline (it
grows the control, like the historical Settings control) rather than as an
overlay. A small floating-panel stack for nested modals is the only
net-new host piece, deferred until the entry-editor surfaces need it.WidgetSpec) — DONE; settings e2e suite GREEN.
Every Settings control renders through widgets::render_spec (Toggle,
Number, Dropdown, Text, TextList, DualList, Map, ObjectArray, the multiline
JSON editor, Complex), with click geometry derived from the reconciler's
real hit areas (hit_rect: toggle chip, number value cell, dropdown
button + option rows). The first swap replaced only the render leg with
approximate visuals/geometry and regressed 25 settings e2e tests + 1 hang;
the fix was faithful projection: the mapping (widget_map.rs) projects
the full control state — edit buffers, carets, selections, dropdown-open,
focused-row hints, [x] delete buttons, the three-state TextList add row,
the bordered JSON box — into the spec each frame, and the upgraded widget
kinds render them. Root causes worth remembering: the ObjectArray rows
looked up display_field with its / prefix (every LSP server row showed
(no action)); an empty List still pads one blank row (its viewport
is min 1 tall), which control_height doesn't count — that off-by-one
pushed [+] Add new out of the control band and made the mouse-driven
add test hang; and collect_dropdown gated the spec's open on widget
focus, which a stateless surface never sets. All 25 + the hang are fixed:
the settings e2e filter passes 141/141 (4 ignored), orchestrator 78/78,
widget 7/7, lib 2967/2967. The settings input path still drives the
control-state model (SettingControl); routing input through
handle_widget_key and retiring the per-control *State structs is the
remaining (behavior-preserving) step — see §5.3.1.view/controls ratatui
render modules (toggle / number_input / dropdown / text_input / dual_list /
text_list / map_input / keybinding_list / button render.rs) are
deleted — the widget framework is the only paint path. And there is now
exactly one text-editing engine: TextInputState rides an embedded
[TextEdit] (its ~150 lines of hand-rolled byte-cursor mechanics and its
dead immediate-mode key/mouse handler are deleted), joining
NumberInputState and JsonEditState, which already did — the same engine
the widget runtime drives for its Text widgets, selection included. What
remains of view/controls is the domain model: *State structs as
config-value carriers with domain behavior (validation, add/remove
semantics, replace-on-type), plus two thin add-input line buffers
(TextListState.new_item_text, MapState.new_key_text) that are domain
fields rather than editing engines. Full *State retirement (runtime
instance-state as the model, widget_event → pending_changes) rides the
entry-dialog-panel milestone (§5.3.1 steps 6–7). Selection is unified
too: the Text spec carries a seed selection (sel_start/sel_end,
mirroring Number), the mapping projects the editor's live selection,
and Shift+Left/Right, Ctrl+A, Ctrl+C/V work in every settings text
field — main panel and entry dialog — through the same TextEdit
engine and the same key arms the JSON editor always had.plugins.md
§7.1 lists the new kinds; widgets.ts + fresh.d.ts carry the new options
(labelFirst/labelWidth/indeterminate, number edit fields, dropdown
open/scrollOffset, blockCaret, reversed overlays); the dead
view/controls render path is deleted. Remaining: input routing +
*State retirement (§5.3.1 steps 6–7 and the behavior-preserving input
inversion).Fresh ships two mature-but-separate control systems. The plugin widget
runtime (WidgetSpec) is a declarative, host-reconciled tree that plugins
author in TypeScript — it owns layout, focus, hit-testing, scroll, and cursor,
and is the framework we expose and want to grow. The Settings control
library (view/controls/) is a host-only, immediate-mode (ratatui) set of
richer form controls that the Settings dialog drives directly. The two share no
code: Settings never touches WidgetSpec, the widget runtime never calls
view/controls/, and the controls each system implements only partly overlap.
The goal is one framework — the plugin-facing widget tree — that Settings and
plugins both render through.
WidgetSpecfresh_core::api::WidgetSpec (tagged enum), plus
WidgetAction, WidgetMutation, HintEntry, TextPropertyEntry.crates/fresh-editor/src/widgets/ — a registry keyed by
(plugin, panel id), a reconciler that renders a spec to
text-property entries + hit areas + host-owned instance state, and pure
action/mutation helpers.crates/fresh-editor/plugins/lib/widgets.ts builders,
surfaced in the generated fresh.d.ts.widget_event hook (activate/toggle/change/submit/select/expand/…);
targeted updates flow forward through WidgetMutation fast paths.TextPropertyEntry rows written as a
buffer's virtual content (plus floating/dock and overlay variants).Kinds that ship: Row, Col, Spacer, Divider, HintBar, Toggle,
Button, Text (unified single-line / multi-line by row count), List,
Tree, LabeledSection, WindowEmbed, Overlay, Raw (escape hatch).
Kinds it lacks (the gap with Settings): no Number, no Dropdown, no
Map, no DualList, no KeybindingList.
view/controls/crates/fresh-editor/src/view/controls/ — host-only, no plugin
exposure, no TypeScript bindings.*State (data + behavior), a *Colors
(from_theme), a *Layout (hit geometry), and a render_* that paints into
a ratatui Frame and returns the *Layout for hit-testing.Toggle, NumberInput, Dropdown, TextInput, TextList,
MapInput, DualList, KeybindingList, Button.view/settings/) maps each schema field to
a SettingControl variant — Toggle, Number, Dropdown, Text,
TextList, DualList, Map, ObjectArray (keybinding list), Json
(multiline editor for object/complex values), Complex (uneditable) — and
drives the render_* calls plus its own SettingsLayout aggregation, focus
order, and modal entry-dialog stack.| Control | view/controls/ (Settings) | WidgetSpec (plugins) |
|---|---|---|
| Toggle / checkbox | ✅ | ✅ |
| Button | ✅ | ✅ |
| Single-line text | ✅ (TextInput) | ✅ (Text, rows=1) |
| Multi-line text | ✅ (Json editor) | ✅ (Text, rows≥2) |
| List of strings | ✅ (TextList) | ~ (List is read/select, not add/remove-edit) |
| Number | ✅ (NumberInput) | ❌ |
| Dropdown / select | ✅ (Dropdown) | ❌ |
| Map / dict | ✅ (MapInput) | ❌ |
| Ordered-subset picker | ✅ (DualList) | ❌ |
| Keybinding-list editor | ✅ (KeybindingList) | ❌ |
| Tree | ❌ | ✅ |
| Row/Col layout, flex | ❌ (hand-rolled SettingsLayout) | ✅ |
Four controls (Toggle, Button, single- and multi-line Text) are implemented twice. Five rich inputs live only in Settings; layout/flex and Tree live only in the widget runtime.
Two competing design notes (now in git history, removed in the docs consolidation) proposed opposite directions:
UNIFIED_UI_FRAMEWORK_PLAN.md — keep the Rust view/controls/ library as
the source of truth and hand-write TypeScript mirrors so plugins get the
same controls.plugin-widget-library-design.md — a Rust-resident declarative
WidgetSpec tree with a thin TS front-end; the host reconciles. Its appendix
explicitly rejected the TS-mirror approach.The WidgetSpec design won and shipped for plugins. The TS-mirror plan did not.
This plan therefore commits to a single direction, consistent with what already
ships: WidgetSpec is the union framework; Settings migrates onto it. In
the shipped design's own roadmap, "Settings adoption" was listed as remaining,
unstarted work — that is the gap this plan closes.
The retired design note framed Settings adoption as moving renderers to a shared location — "pure refactoring, no new behavior." That undersells one fact: the two systems paint to different surfaces.
view/controls/render_* draws immediate-mode into a ratatui Frame and
returns a *Layout.widgets/render.rs produces TextPropertyEntry rows (virtual buffer
content) plus HitAreas, under the spec/instance-state model.A Toggle renderer cannot be literally shared because one writes frame cells and
the other emits text-property rows. Unification therefore has to separate
control logic (state transitions, value formatting, hit geometry, focus
rules — all surface-independent) from paint (the surface-specific step).
Three options:
view/controls/
directly and instead emits a WidgetSpec tree rendered by the widget runtime
into a floating/overlay panel — exactly like a plugin. This is the true
end-state and matches the stated goal ("switch the Settings UI to the widget
framework").Recommendation: target (A) as the end-state, use (C) as the
mechanism to get there without duplicating control behavior in flight. Add the
missing kinds to WidgetSpec first (§5.1), which is pure additive value even if
the Settings port lands later.
WidgetSpec (additive, non-breaking)Add the five missing kinds so the widget framework becomes a superset of what
Settings needs. Each is a new WidgetSpec variant, a widgets/render.rs
renderer emitting text-property rows + hit areas, instance-state and
mutation/action plumbing consistent with the existing kinds, and a
plugins/lib/widgets.ts builder + fresh.d.ts entry.
Number — value + min/max/step, increment/decrement hit areas, integer and
float-as-percent modes (mirror NumberInputState).Dropdown — options + selected index; opening a dropdown needs the compositor
(§5.4) for the popup list, so ship a closed/inline-cycling form first and the
popup form after Layer lands.Map — key→value entries with expand/collapse and add/remove; reuse the Tree
disclosure machinery for the expandable rows.DualList — two-column ordered-subset picker with cross-exclusion.KeybindingList — the ObjectArray editor for keybinding rows.Deliverable: plugins can build number/dropdown/map/dual-list/keybinding inputs. Independent of any Settings change; ship and test on their own.
For the controls that exist on both sides (Toggle, Button, Text, and the Phase-1 additions), factor out the state machine, value formatting, and hit geometry into one place, leaving two thin paint adapters:
view/controls/render_* working for Settings
unchanged in behavior, andwidgets/render.rs.This removes the double implementation of Toggle/Button/Text behavior and makes the two surfaces provably consistent. No user-visible change; regression-tested against existing Settings and widget tests.
WidgetSpec tree (option A)Replace the hand-rolled SettingsLayout / SettingControl render loop with a
function that maps each schema field to a WidgetSpec node (LabeledSection +
the matching control kind) inside Row/Col layout, and mount it as a
floating/overlay widget panel. Focus order, scroll, and hit-testing come from
the widget runtime instead of SettingsLayout. The schema→control mapping
(items.rs) stays; only its output changes from SettingControl to
WidgetSpec.
Sequencing: do the flat settings page first; the modal entry dialog for Map/ObjectArray entries is a nested modal stack and depends on the compositor (§5.4).
The full "one WidgetSpec tree for the whole dialog" rewrite (above) would also
regress the ~120 passing settings tests (categories sidebar, search, scroll,
footer, per-field label alignment, entry-dialog modal stack) — so it is done
incrementally, per control, keeping the settings chrome as-is and moving one
control's interaction into the runtime at a time. The mapped seams:
SettingsState::widget_states: HashMap<String, WidgetInstanceState> (added; keyed by field JSON pointer,
cleared in show()). This is the runtime-owned interaction model — TextEdit
cursor+selection, Number edit buffer, Dropdown-open, DualList cursors. It is
threaded read-only into render via RenderContext<'a>.widget_states (a borrow
disjoint from state.scroll_panel, which render holds mutably) and passed to
render_control → render_control_via_widget as the prev instance-state,
replacing the old &HashMap::new().render_setting_item_pure is a pure
function (state → cells) with no mutable interaction state. Keep it pure: the
store is mutated only by input, and render only reads it as prev. So
input handlers own evolution, render reflects it — exactly the mounted-panel
contract, without making render &mut.view/settings/input.rs routes the
key through the runtime handlers (App::handle_widget_key /
handle_widget_text_key in app/widget_runtime.rs, which already implement
Dropdown popup open/cycle/commit, TextEdit selection+clipboard, List/Tree/Dual
navigation) against state.widget_states, then translates the resulting
WidgetMutation (SetNumber/SetDropdown/SetValue/SetDualIncluded/…) into
a pending_changes config write. These handlers are currently App methods
oriented around a plugin PanelKey+buffer_id; factor the pure core so
Settings can drive it without a plugin buffer.out.hits
(byte-range + widget_kind + payload) instead of the approximate/Default
rects the stateless swap emits — this is what fixes the toggle-chip click
mismatch. (A first correctness step landed: the Map hit rects now track the
display_field header-row offset, commit 5520038.)wait_until(contains "[+] Add new") before it
ever clicks — the row simply never scrolls into view. list_of sets the
embedded List's visible_rows = rows.len(), so the List emits every entry
and the [+] Add new raw_row sits at the bottom of a tall control; after a
search-jump (skip_rows = 0, top of the control) it is off-screen for any map
with more entries than the viewport, and wait_until never scrolls. The old
renderer kept the add-row reachable (fixed-height inner viewport / jump
scrolled to it). Fix direction: either give the settings-embedded composite an
inner viewport that always shows the add-row, or make the search-jump / focus
scroll the add-row into view. Affects test_map_add_new_button_clickable_with_mouse
and test_languages_map_has_add_new_button (both tall maps; the LSP map is
short, which is why it passes). NOTE: commit 5520038's message wrongly
credited the header-offset hit fix with resolving the hang — the hang is this
separate visibility issue; the hit fix is still a valid correctness step.Number needs an edit mode (Enter →
type-to-replace via TextEdit → Enter commit / Esc revert) to match
NumberInputState; the e2e suite asserts this directly
(test_number_input_enter_editing_mode, …_escape_cancels_editing,
…_enter_selects_all_text, …_cursor_navigation).Increment order (each a compiling commit): (1) persistent store + render threading [done — behavior-neutral, empty
store]; (2) Toggle end-to-end through the runtime (real hits + SetChecked→config)
— fixes the 4 toggle tests and proves the click path; (3) Number edit-mode + route
Number input — fixes the 5 number tests; (4) Dropdown route (popup already exists)
— fixes the 2 dropdown tests; (5) Text/JSON cursor+selection from the store — fixes
cursor-visibility + selection-indicator; (6) Map/TextList/DualList add-remove +
inherit buttons; (7) entry-dialog stack gets its own store. Acceptance gate — PASSED: the 25 failing
settings e2e tests are green without regressing the passing ones (141/141,
4 ignored); the render/hit legs are done via faithful state projection + real
hit geometry, and steps (2)–(5) collapsed into the widget-kind parity work
described in §0. The remaining increments are input routing through
handle_widget_key (behavior-preserving; retires the *State structs) and
the entry-dialog stack's own store (steps 6–7).
Layer: reuse, don't buildOriginal framing: build a new compositor. On inspection this is unnecessary — the editor already has one, and the remaining modal surfaces should reuse it:
overlay.rs is a first-class, ordered overlay-layer stack
(Editor::overlay_layers()) that already unifies keyboard focus, mouse
early-capture, terminal-input gating, and z-order across every overlay —
Settings, Menu, Prompt, Popup, FloatingModal, Dock. Precedence lives in one
place.FloatingWidgetState already renders a WidgetSpec as a centered
modal, a left dock, or an anchored context-menu popup
(PanelPlacement::{Centered, LeftDock, Anchored}) — with input capture,
click-outside dismiss, scrollbars, and WindowEmbed. It rides the
FloatingModal layer.OverlayRow / WidgetSpec::Overlay paint rows on top of a panel at a
given buffer row — the mechanism behind Text completion popups, rendered by
both the buffer and floating-panel paint paths.So the modal surfaces map onto existing infra with no new subsystem:
| Surface | Reuses |
|---|---|
| Dropdown option popup | OverlayRow (IMPLEMENTED — see emit_dropdown_overlays) |
| Settings entry-dialog (Map / ObjectArray entry editing) | a floating widget panel via MountFloatingWidget (centered modal) — the widget-tree replacement for EntryDialogState |
| Confirm prompts / tooltips / context menus | PanelPlacement::{Centered, Anchored} + the FloatingModal overlay layer |
The one genuine gap is stacking: today only one floating_widget_panel is
mounted at a time (MountFloatingWidget replaces any existing one), whereas a
nested entry-dialog-over-modal needs a small stack. Settings already models this
with its own entry_dialog_stack: Vec<EntryDialogState>; generalizing the
floating-panel slot into a short stack (top-of-stack captures input; Esc pops
one) is the only net-new host work — and it's a localized change to the
floating-panel accessors, not a new compositor.
Status: the Dropdown popup ships on OverlayRow today, proving the reuse path.
The floating-panel stack + entry-dialog-on-WidgetSpec is the remaining piece,
sequenced before §5.3's modal entry-dialog surface.
With Settings and plugins both on WidgetSpec, finalize the TypeScript builder
surface for the Phase-1 kinds, regenerate fresh.d.ts, and update
plugins.md §7 and config-themes-settings.md §4 to describe one framework.
Retire the now-dead SettingsLayout/SettingControl render path.
Phase 1 (rich kinds) ─────────────┐ independent, ship first
├─► Phase 3 (Settings → WidgetSpec, flat page)
Phase 2 (shared logic core) ──────┘
Phase 4 (compositor/Layer) ──────► Phase 3 modal surfaces (entry dialog, dropdown popup)
└─► Phase 5 (expose + docs + delete dead path)
Phases 1 and 2 are safe, non-breaking, and independently valuable. Phase 3's flat page can land before the compositor; its modal surfaces cannot. Phase 4 is the long pole and is already on the widget roadmap for reasons unrelated to Settings — Settings should be a co-driver of it, not a separate effort.
[Reset]/[Inherit]/[Clear] buttons, and the layer-source band are
Settings concepts, not generic widget concepts. Decide whether these become
generic widget decorations or stay Settings-side chrome wrapped around the
widget tree. Recommendation: keep them Settings-side initially; generalize
only if a plugin wants them.Layer unifies popups/tooltips/modals across the
whole editor; scoping it to what Settings + dropdowns need first avoids
blocking on the full design.List vs TextList. The widget List is select/scroll; Settings'
TextList is add/remove/edit. Phase 1 must decide whether to extend List
with an editable mode or keep TextList a distinct kind. Recommendation: a
distinct editable-list kind, since the editing affordances differ materially.One declarative widget framework, authored the same way by plugins and by the
built-in Settings dialog: WidgetSpec carries every control Settings needs,
each control has a single surface-agnostic implementation, Settings renders by
mounting a widget panel rather than driving view/controls/ directly, and the
old SettingsLayout/SettingControl render path is deleted.