Document fresh-ui migration plan for editor UI - #3028
Open
sinelaw wants to merge 11 commits into
Open
Conversation
sinelaw
force-pushed
the
claude/fresh-editor-fresh-ui-migration-glu9af
branch
from
August 19, 2026 06:10
e445ddb to
579655e
Compare
sinelaw
force-pushed
the
claude/fresh-editor-fresh-ui-migration-glu9af
branch
from
August 19, 2026 06:17
579655e to
df32b38
Compare
sinelaw
force-pushed
the
claude/fresh-editor-fresh-ui-migration-glu9af
branch
from
August 19, 2026 06:49
dfb5d4b to
e606eb0
Compare
sinelaw
force-pushed
the
claude/fresh-editor-fresh-ui-migration-glu9af
branch
from
August 19, 2026 10:12
e606eb0 to
8c600da
Compare
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
force-pushed
the
claude/fresh-editor-fresh-ui-migration-glu9af
branch
from
August 19, 2026 11:21
8c600da to
ed5b96a
Compare
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
This PR adds comprehensive documentation for migrating the Fresh editor's UI onto the
fresh-uilibrary. 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:SplitRenderer::render_content(text pipeline stays, chrome moves to fresh-ui)Updated
docs/internal/README.md: Added reference to the new migration document in the documentation indexNotable Details
ChromeComponentregistry andSceneprojection as proto-retained-widget structures that the migration completes rather than replacesSplitRenderer::render_contentfunction signature as the single hard boundary between kept (text pipeline) and migrated (chrome) codeDraw::Host(HostId)items in the LayoutSpec callback into the editor to render text contentThis is a planning/documentation-only change with no code modifications to the editor or library itself.
https://claude.ai/code/session_01Ws2GtzEBQRFZMv8qF61Jzr