Skip to content

Document fresh-ui migration plan for editor UI - #3028

Open
sinelaw wants to merge 11 commits into
masterfrom
claude/fresh-editor-fresh-ui-migration-glu9af
Open

Document fresh-ui migration plan for editor UI#3028
sinelaw wants to merge 11 commits into
masterfrom
claude/fresh-editor-fresh-ui-migration-glu9af

Conversation

@sinelaw

@sinelaw sinelaw commented Aug 18, 2026

Copy link
Copy Markdown
Owner

Summary

This PR adds comprehensive documentation for migrating the Fresh editor's UI onto the fresh-ui library. The document grounds the abstract library design in the editor's current architecture, providing a concrete, file-level migration plan with specific mechanics for each surface.

Key Changes

  • Added docs/internal/fresh-editor-ui-migration.md (~900 lines): A detailed migration guide covering:

    • Current state survey of the editor's UI architecture (ChromeComponent registry, Scene projections, event dispatch, plugin widget system, settings)
    • The critical keep/migrate boundary at SplitRenderer::render_content (text pipeline stays, chrome moves to fresh-ui)
    • Target design showing how each UI surface maps to fresh-ui primitives (menus, context menus, prompts, splits, modals, settings, etc.)
    • State classification framework (app state, element state, framework state, session state)
    • The Host seam mechanism for integrating text rendering with the UI tree
    • Nine-wave migration plan (M0–M9) with concrete mechanics and exit criteria
    • Risk analysis, scoping decisions, and verification strategy
  • Updated docs/internal/README.md: Added reference to the new migration document in the documentation index

Notable Details

  • The document identifies the existing ChromeComponent registry and Scene projection as proto-retained-widget structures that the migration completes rather than replaces
  • Establishes the SplitRenderer::render_content function signature as the single hard boundary between kept (text pipeline) and migrated (chrome) code
  • Defines the Host seam mechanism where Draw::Host(HostId) items in the LayoutSpec callback into the editor to render text content
  • Provides concrete mappings of current UI surfaces to fresh-ui components (e.g., context menus → Layer + List, modals → Modality::Exclusive)
  • Includes M0 prototype gate requirements before any surface migration begins, with specific exit criteria
  • M2 identified as the go/no-go decision point for the overall approach

This is a planning/documentation-only change with no code modifications to the editor or library itself.

https://claude.ai/code/session_01Ws2GtzEBQRFZMv8qF61Jzr

@sinelaw
sinelaw force-pushed the claude/fresh-editor-fresh-ui-migration-glu9af branch from e445ddb to 579655e Compare August 19, 2026 06:10
@sinelaw
sinelaw changed the base branch from master to claude/fresh-ui-wheel-axis August 19, 2026 06:10
Base automatically changed from claude/fresh-ui-wheel-axis to master August 19, 2026 06:15
@sinelaw
sinelaw force-pushed the claude/fresh-editor-fresh-ui-migration-glu9af branch from 579655e to df32b38 Compare August 19, 2026 06:17
@sinelaw
sinelaw force-pushed the claude/fresh-editor-fresh-ui-migration-glu9af branch from dfb5d4b to e606eb0 Compare August 19, 2026 06:49
@sinelaw
sinelaw changed the base branch from master to claude/fresh-ui-text-spans August 19, 2026 06:49
@sinelaw
sinelaw force-pushed the claude/fresh-editor-fresh-ui-migration-glu9af branch from e606eb0 to 8c600da Compare August 19, 2026 10:12
Base automatically changed from claude/fresh-ui-text-spans to master August 19, 2026 11:16
claude added 9 commits August 19, 2026 11:17
Add docs/internal/fresh-editor-ui-migration.md, the editor-side companion
to widget-library-design.md and widget-library-implementation-plan.md.

It grounds the abstract Part-2 waves in the editor as built today:
- a survey of the current UI (ChromeComponent registry, the two decoupled
  precedence systems layer_rank + pointer z-bands, the Scene projection,
  paint-recorded vs live-derived geometry, the keyboard/pointer dispatch
  walks, the PointerGrab flag ladder, capture_mouse modals, the plugin
  WidgetSpec runtime, and the half-unified Settings controls);
- the keep/migrate boundary at SplitRenderer::render_content — file-backed
  buffers and the text pipeline stay put behind a Host leaf;
- a target design mapping every chrome surface onto fresh-ui primitives,
  with state-home classification and theme/plugin-boundary integration;
- the M0-M9 migration refined with file-level deletions and the added
  seam mechanics (caret arbitration, geometry-cache bridge).

Survey is as-built; the design and plan are forward-looking. Also index it
in docs/internal/README.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ws2GtzEBQRFZMv8qF61Jzr
An adversarial review of fresh-editor-ui-migration.md against the source
confirmed the current-state survey nearly claim-for-claim but found the
Host-seam mechanism (s4.4) described upside-down, plus several pre-wave
decisions the doc glossed over. Corrections:

- s1/s4.4: paint never touches cells — the leaf emits Draw::Host(id) and
  the editor-side backend fold (a per-HostId callback over &mut Editor,
  which does not exist yet) runs render_content and captures the caches;
  click->byte moves to a Gesture handler; the caret arrives at fold time,
  after LayoutSpec.cursor is fixed; note compute_content_layout as the
  pre-paint geometry path and the HostSpec::Leaf Rc-identity relayout
  footgun. Fix the render_content signature (associated fn) and the
  misattributed quote.
- s2.9/s4.1: Node<Action> does not work as the message type (Action has
  no positional variants by design); use a UiMsg wrapper, as the
  library's own demo does. Keybinding resolver survives as the root
  fallback handler.
- s1/s4.2/M9: name the tension between "render_content untouched" and
  the per-leaf split grid as an open scoping decision.
- s4.2: TextRun::markdown does not exist; add the inline-styled-text
  decision (styled spans vs node-per-span) before M3/M5.
- s4.7 (new): whole-root rebuild-per-frame inverts the library's cost
  model; require an M0 benchmark and Shared-hoisting practice rules.
- s5.0: re-scope M0 as a prototype gate (fold-callback API, real buffer
  cells under Draw::Host, caret-anchored popup, click-to-cursor).
- s5.1: coverage addenda for surfaces no wave named (tab-drag ghost,
  frame-buffer animations, warning domains, orchestrator previews,
  WindowEmbed, live-grep/quick-open overlays, fresh-gui native menus,
  scrollbar markers). s2.10: add the omitted WindowEmbed kind.
- s6: the five open questions are now answered (web endgame as a
  parallel track; WidgetMutation reinterpreted as host-side data-source
  edits; mutate-then-decline portable except cross-layer guards; hover
  popups from app state + geometry bridge; one Ui with WindowId-keyed
  subtrees) and replaced by a nine-item pre-wave decision list.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ws2GtzEBQRFZMv8qF61Jzr
The plan had the frame migrating last (M9), with earlier waves mounted
into rects carved by today's layout. That is backwards:

- Ui::frame(root, size) takes a whole-frame Size; there is no sub-rect
  mount. Inside-out has to invent one, build every wave on it, then
  delete it at M9. Outside-in uses the API as designed.
- A Layer's stacking, modality, dismissal and focus scoping come from
  being a child of the tree. Mounted into a rect, a migrated context
  menu still needs the old precedence machinery for everything outside
  it — so those mechanisms are half-simulated, then redone. Under the
  shell the first migrated overlay is real immediately, which is where
  the value of this work actually is.
- The hybrid rule already exists: Base is today's floor (z0,
  EDITOR_BASE 0). The shell's legacy Host leaf IS that floor, so no new
  precedence concept is needed during the transition.

Add tests/ui_shell_frame_parity.rs, which tests the claim the whole
shell rests on: that fresh-ui reproduces the editor's frame rectangles.
It builds the frame skeleton as a description, folds it to Draw::Host
items, and compares against the ratatui Layout calls render.rs makes,
over 192 visibility combinations x a size grid. Findings:

1. Parity holds wherever the visible fixed rows fit — exactly, dock and
   sidebar carves included, from 1 to 200 columns. The frame is five
   Length(n) rows and one Min(0); Sizing::Cells and flex(1) reproduce
   it without rounding.
2. One divergence, in the deep-squeeze band: when the fixed rows cannot
   all fit, ratatui's solver starves an interior row and keeps the
   last, while fresh-ui fills in order and starves the last. Pinned by
   a test. This is not a fresh-ui bug to fix — it says row visibility
   belongs in build() as a function of available height, which is
   better than either engine's accident. Recorded as a decision.

The PoC also shows the dock's bail-out rules are app logic keyed on the
frame width, which build() cannot read — resolved from state first.

Doc: new s5.0 (direction, PoC, five revised stages), M0 gains the frame
skeleton and drops the mount point, M9 reframed as dissolving into S1/S5,
and the squeeze question added to the decision list. fresh-ui is a
dev-dependency for now; the shell is not yet on the render path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ws2GtzEBQRFZMv8qF61Jzr
The fold was the migration's one untested mechanism: fresh-ui emits
Draw::Host for content the host draws itself, but neither of the
library's backends ever implemented it (both stub it as a fill), and
render_content takes ~28 parameters because it needs WindowBuffers'
disjoint split. Whether that could be assembled inside a fold callback
was open. It can.

- view/shell/frame.rs: the frame as a description, one Host region per
  area the old painters own. Shared with the parity test, which no
  longer carries its own copy.
- view/shell/fold.rs: LayoutSpec -> ratatui cells, with a HostPainter
  callback for Draw::Host. Host regions paint INLINE as their item is
  reached, so a chrome item later in the list lands on top of them —
  the popup-over-a-buffer case. A two-pass fold would invert it.
- app/shell_host.rs: impl HostPainter for Editor. paint_body assembles
  all ~28 render_content arguments from &mut Editor inside the
  callback — with_all_mut split, theme read-guard, config bundle —
  mirroring Editor::render's assembly one for one.

The constraint this revealed, now a compile-time assertion: the Ui
cannot live on the Editor. The fold reads the display list off the Ui
while calling back into &mut Editor; as a field those borrows conflict
and the callback cannot take the with_all_mut split. Ui and Editor are
siblings, as in the library's own tutorial.

The caret rule also falls out rather than being listed:
LayoutSpec.cursor (a focused native TextField) wins over a caret a host
wrote via pending_hardware_cursor, reproducing "an overlay's field takes
the caret from the buffer" without the suppression list.

Six unit tests cover paint order, clipping, both caret directions, and
the borrow shape; the parity test still passes. Not yet on the render
path: per-frame state is defaulted and BodyOutput is not published to
the geometry bridge — mechanical S1 work that does not touch the borrow.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ws2GtzEBQRFZMv8qF61Jzr
Puts fresh-ui on the render path for the first time, without a flag day
and without changing any painting.

The technique is the one this codebase already uses to hoist geometry:
when chrome surfaces moved from paint-recorded caches to live
derivations, the paint pass debug-asserted that paint == derivation
before the cache was deleted. Same here.

- frame.rs gains region_rects (frame description -> region rectangles)
  and assert_parity, which compares region by region and reports every
  disagreement at once.
- Editor::render builds the shell Frame from the visibility flags it
  already computes and asserts against the rectangles the ratatui
  Layout just produced.

Debug builds only, so release is untouched. In debug it runs on every
frame, which makes the e2e suite the verification that fresh-ui can
take the frame over: thousands of real frames at real sizes, with real
dock, explorer, prompt and suggestion states.

The assertion skips the squeeze band (frame shorter than its fixed
rows), where the two engines starve different rows by design — the
decision recorded in the migration doc, pinned separately by a test.

One wiring bug caught while writing it: the shell was first handed
chrome_area, but its tree carries the dock as a column of the whole
frame, so it needs `size` — chrome_area is already dock-excluded.

S1b, once this is quiet across the suite, deletes compute_dock_split,
the five-row Layout and split_file_explorer_area and takes the shell's
rects. The e2e verdict for this commit is still running; if it reports
a disagreement, that is a real finding and lands as a follow-up.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ws2GtzEBQRFZMv8qF61Jzr
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ws2GtzEBQRFZMv8qF61Jzr
The fold carries output across the seam; nothing carried input. This
adds view/shell/input.rs: the crossterm key and mouse events
Editor::handle_key_press and Editor::handle_mouse already receive,
translated into fresh_ui::Input.

The rule that makes S1's hybrid dispatch work is that translation is
lossy in one direction only. Everything the library understands passes
through faithfully; everything it does not returns None and stays on
the existing path — key releases and repeats, keys with no counterpart
(Insert, media keys), and horizontal wheel, which the library's Wheel
has no axis for and which is declined rather than reported as vertical
scroll. The shell takes what it understands, the legacy floor keeps the
rest.

Two translations encode decisions rather than mechanics:

- Drag is reported as a move. The library routes it by pointer capture,
  so the node that took the press keeps receiving motion without the
  backend distinguishing the two. That is the whole drag mechanism, and
  what replaces the PointerGrab flag ladder.
- The physical chord is reported, not the layout reading. KeyPress
  carries both and the editor's keymap decides which wins; that belongs
  to the keybinding resolver surviving at the root fallback, not to a
  widget.

Seven tests pin the contract, including the deliberate None cases —
those are the ones that would silently deliver phantom input if someone
later "fixed" them by approximating.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ws2GtzEBQRFZMv8qF61Jzr
Stacked on the fresh-ui wheel-axis change: the adapter no longer has to
decline horizontal scroll. ScrollLeft/ScrollRight now translate with
Axis::Horizontal instead of returning None, so a migrated surface can
scroll sideways rather than silently ignoring the wheel.

Also records in the doc how that gap was decided, since the judgement
recurs. Three things did not cross the seam; each was tested against
"is the concept backend-neutral" and "does the editor actually need it":

- Horizontal wheel: fixed in the library. The scroll model was already
  two-dimensional but no input could address the x axis — an internal
  asymmetry, and one the editor genuinely uses.
- Keys with no counterpart (Insert, media keys): left alone. Checked
  empirically by diffing every key token the shipped keymap binds
  against KeyCode — the gap is empty. KeyCode is an abstract
  vocabulary, not a mirror of crossterm.
- Key kinds (press/repeat/release): left alone, and declining is
  correct. A repeat should act as a press for every widget in the set,
  and no widget needs release.

The rule, for the library changes the plan already predicts: fix the
library when the gap is an internal asymmetry it half-expresses; leave
it when the gap is only "the backend has a concept the library chose
not to have".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ws2GtzEBQRFZMv8qF61Jzr
The first real surface expressed in fresh-ui rather than delegated to a
Host region. The status bar is the smallest one worth migrating first
and the best prepared: its geometry is already derived live rather than
recorded during paint, and it is already projected as a flat list of
segments each carrying a name and a side. Nothing about it needs the
frame to have inverted, so it is built and tested before the shell
drives rendering.

Segments take their natural width (a TextRun measures itself), the left
group packs from the left, and the gap between the groups is a flex
child — so the right group stays flush with the edge at any width,
rather than being positioned by a computed number of spaces.

It also answers the inline-styling question for this surface. A display
list item carries one theme key and a TextRun holds one unstyled
string, so a surface that styles WITHIN a run needs several nodes —
still an open decision for palette match highlights, mnemonics and
markdown. The status bar is not one of those: its colour boundaries are
exactly its segment boundaries, so one themed text node per segment is
a faithful rendering rather than a workaround.

Also makes the mouse translation exhaustive. Adding ScrollLeft and
ScrollRight covered every MouseEventKind, so the catch-all became
unreachable; dropping it means a new crossterm variant fails the build
instead of being silently declined.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ws2GtzEBQRFZMv8qF61Jzr
@sinelaw
sinelaw force-pushed the claude/fresh-editor-fresh-ui-migration-glu9af branch from 8c600da to ed5b96a Compare August 19, 2026 11:21
claude added 2 commits August 19, 2026 11:34
S1a asserted, on every debug frame, that the shell's frame layout
matched the ratatui one; that ran green across the whole e2e suite. So
the assertion has done its job and the swap can happen: the five-row
vertical Layout and the sidebar carve are gone from render, and every
region's rectangle now comes from one fresh-ui description laid out
once.

Three things this needed, each worth keeping in mind for later stages:

- Hidden regions still have positions. A zero-height prompt row is
  where the suggestions popup anchors, so the description carries every
  region — hidden ones at zero size, mirroring the Length(0)
  constraints the old layout used. The parity test now compares all
  seven regions, empty ones included, which is what makes taking these
  rects verbatim safe.
- Decisions and geometry separate cleanly. split_file_explorer_area
  both decided whether a sidebar shows and computed where it goes. It
  is now file_explorer_layout_request, answering only "is there one,
  how wide, which side"; the shell turns that into rectangles. That
  split is what let the geometry move without the policy moving too.
- Presence is app state, not geometry. A hidden sidebar has a
  zero-width rectangle like anything else, so callers keep telling the
  two apart by Option rather than by measuring.

The S1a assertion is removed along with the code it checked — with one
derivation left in render it would compare the shell against itself.
tests/ui_shell_frame_parity.rs keeps both derivations honest instead,
over 192 visibility combinations and a grid of sizes, which is far more
than a running editor reaches.

19 shell tests, both parity tests, and the 20 harness tests (which
render real frames and assert on screen contents) pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ws2GtzEBQRFZMv8qF61Jzr
Completes S1. Three pieces, each load-bearing and each changing nothing
on screen, which is exactly what a seam should do.

- Editor::shell_ui holds the Ui across frames, so element state, focus
  and the dirty set survive. It is an Option, moved out for the
  duration of a frame rather than borrowed from self: the display list
  is borrowed from the Ui while the fold calls back into &mut Editor,
  and as a plain field those borrows conflict. Same disjointness the
  sibling arrangement gives — either satisfies the constraint the
  earlier compile-time assertion records.
- fold_native walks the display list and skips Host items, so a region
  can move into the tree on its own while the ones around it keep their
  painters and their rectangles. When the last region is native this
  collapses into the general fold. A test pins the property the working
  state depends on: a frame of host regions paints nothing at all.
- shell_dispatch offers each translated event to the tree ahead of the
  legacy walk — stage two of three, with the modal-capture band still
  ahead of it and the existing walk still the floor. No node carries a
  handler yet, so it declines everything and every event lands where it
  always did.

UiMsg lands with it: Action for anything a user could bind, which goes
through handle_action unchanged, and a Ui variant for positional facts
that are applied and never serialized.

A surface can now stop being a Host leaf and immediately draw through
the fold, take its own input and keep its own state, with no further
plumbing — which is what makes the remaining stages independent swaps
rather than one flag day.

21 shell tests, both parity tests, and the 20 harness tests (which
render real frames and dispatch real input) pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ws2GtzEBQRFZMv8qF61Jzr
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants