Skip to content

feat(canvas): focus mode — one node fills the window, chrome yields (⌘⇧F) - #267

Open
eneskirca wants to merge 5 commits into
mainfrom
feat/focus-mode
Open

feat(canvas): focus mode — one node fills the window, chrome yields (⌘⇧F)#267
eneskirca wants to merge 5 commits into
mainfrom
feat/focus-mode

Conversation

@eneskirca

Copy link
Copy Markdown
Owner

The #78 focus-mode feature, v1 in the exact shape agreed there (same-DOM reparent, Esc never exits, forced DOM/WebGL renderer for the focused node while the shared layer is on).

How it works

  • Same element, no respawn: the focused .term-node root is reparented imperatively (layout effect) into an always-mounted fixed surface OUTSIDE <ReactFlow> — the DOM move park/adopt already proves safe; every listener the terminal owns is bound to term.element or the host. Not a React portal: switching portal containers remounts the subtree and would blank the terminal. The effect cleanup restores the node before React could ever detach it (unfocus AND unmount/project switch).
  • Shared-GPU v1 (as agreed): focused joins the glyphOff MUST-BE-OPAQUE reasons — a reparented node is not where the shared layer paints — riding the same setup/teardown path collapse/⌘M/stacking/drag use; the focused id also joins the opaque set in the same render that commits the reparent. Teaching the shared layer an off-canvas plate stays the follow-up.
  • Mouse math: the node leaves the viewport transform, so patchTerminalScale gets a getter that answers 1 while focused; it reads the focus STORE keyed by node id, so a parked terminal's closure stays correct across park/adopt.
  • Keys: ⌘⇧F toggles (selected terminal node; palette "Focus node" too; row added to the shortcuts panel). Esc is never an exit key — it reaches the CLI in the pane; exit is the chord or the pill in the surface. Leaving lands framed on the node via goToNode (no bare fitView). Refused while the kanban board is up.
  • Chrome: nothing is display-toggled — the opaque surface (z 40) covers the canvas and its chrome; the Dock lifts above it (z 41) on pointer proximity (mousemove class, not an invisible hover band that would eat clicks on the shell's prompt row) or direct hover, opacity-faded only. fitView's obstacle math is untouched.

Needs a real-device pass before this counts as done

  • shared-GPU on → focus → the node paints via its own renderer, exit → returns to the shared layer with no blank plate
  • focus during an active drag/park cycle; project switch while focused (the restore path)
  • mouse selection accuracy inside the focused terminal at canvas zoom ≠ 100%
  • Server Edition in a browser tab (Ctrl+Shift+F is unreserved, but verify)

Refs #78

🤖 Generated with Claude Code

…⌘⇧F)

The #78 focus-mode feature, v1 in the shape agreed there:

- The focused .term-node root is REPARENTED (imperatively, in a layout effect)
  into an always-mounted fixed surface outside <ReactFlow> — the same DOM-move
  operation park/adopt already proves safe, so the SAME xterm element carries
  over with no respawn and no redraw. Not a React portal: switching portal
  containers remounts the subtree and would blank the terminal. The cleanup
  restores the node before React could ever detach it (unfocus AND unmount).
- Shared-GPU interplay is the agreed v1: `focused` joins the glyphOff
  MUST-BE-OPAQUE reasons (a portaled node is not where the shared layer
  paints), riding the exact setup/teardown path collapse/cmd-M/stacking/drag
  use, and the focused id joins the opaque set in the same render that
  commits the reparent. Mouse-coordinate math reads scale 1 while focused
  (the node left the viewport transform); the getter reads the store keyed by
  node id, so a parked terminal's closure stays correct across adopt.
- ⌘⇧F toggles (selected terminal; palette "Focus node" too). Esc is NEVER an
  exit key — it reaches the CLI — so exit is the same chord or the pill in
  the surface; leaving lands framed on the node via goToNode. Refused while
  the kanban board is up (its card modal is already a focused session view).
- Chrome isn't display-toggled: the opaque surface (z 40) simply covers it,
  and the dock lifts above (z 41) on pointer proximity or hover, faded by
  opacity only. fitView's obstacle math is untouched.

Needs a real-device pass (GPU + shared-renderer interaction) before this is
called done — flagged in the PR checklist.

Refs #78

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@eneskirca

Copy link
Copy Markdown
Owner Author

Review from the terminal-rendering side (I own the WebGL budget coordinator and the glyphgrid attachment paths). Two interactions with that machinery, plus a note on what this PR already gets right.

What's already right, so it doesn't get "fixed" later: adding focused to glyphOff is the correct call — a reparented node is off the React Flow viewport, and the shared layer positions glyphs from on-canvas world geometry, so a focused node must leave the shared canvas. Reading the focus state from the store rather than a component ref in patchTerminalScale is also right: a parked terminal's closure belongs to a dead instance. Neither needs changing.


1. On a busy canvas the fullscreen terminal can end up on the DOM renderer (the one thing focus mode most wants accelerated).

The focus surface is an opaque layer above the canvas (position: fixed; z-index: 40), so every other node stays in the layout. IntersectionObserver does not model occlusion — those nodes keep reporting visible, so they keep their WebGL contexts. The budget coordinator refuses to evict a visible holder (tryGrantlruHiddenHolder() returns null → the newcomer is not granted, src/renderer/terminal/webgl-budget.ts). So if the focused node was not already a context holder and the budget is full (16 on macOS, 24 elsewhere, 12 in a browser tab), the node filling the screen renders on xterm's DOM renderer while 16–24 fully-occluded terminals hold the GPU. Realistic on the canvases this feature is aimed at — the "135 sessions" case is exactly the one where you reach for ⌘⇧F.

Two ways out, either is small:

  • Hide the canvas while focuseddisplay: none (not just cover) on the React Flow viewport under .focus-surface.is-active. Every other node then reports hidden, becomes a reclaim candidate, and the focused node gets its slot on demand. This also stops the occluded terminals from rendering frames nobody can see.
  • Or give the coordinator a priority hook — a prioritizeWebglClient(id) that lets one visible holder be reclaimed for the focused node. More code, and it weakens an invariant that currently has no exceptions; I'd take the first.

2. Shared-renderer mode paints one blank frame per ⌘⇧F (and per exit).

The reparent is a useLayoutEffect (before paint); the glyph participation effect that tears the grid down is a passive useEffect (after paint), as is the setGlyphMounted(false) that drops term-node--glyphgrid. So for at least one painted frame the node is fullscreen while its grid is still registered at the old on-canvas position and the node still carries the glyph-mode styling — transparent body, .xterm-rows hidden. That frame shows no text. Same on exit.

Narrowest fix: in the focus layout effect, run glyphSyncRef.current?.(false) before surface.appendChild(root), so the terminal is back on its own renderer in the same commit that moves it. (Only affects the shared setting — since #180 that is no longer the default, which is why I'd call this a polish item rather than a blocker.)


Neither finding is a correctness bug in the focus feature itself; 1 is the one I'd want closed before merge, because it silently undercuts exactly the case the feature exists for.

eneskirca and others added 2 commits August 16, 2026 14:44
…ode blank frame

Review on #267 (terminal-rendering side) found two interactions with the
renderer machinery; both closed in the recommended shape:

1. Budget starvation: IntersectionObserver does not model occlusion, so
   covered-but-displayed terminals kept reporting visible, kept their WebGL
   contexts, and a full budget refused the one node filling the screen
   (tryGrant only evicts hidden holders). The flow pane now goes display:none
   while focused — covered nodes report hidden, the budget reclaims them, and
   occluded terminals stop rendering invisible frames. Two consequences are
   handled with it: covered terminals vote "not viewing" (null) through
   applyFit's new focus gate — the kanban board's exact semantics, re-run on
   every focus flip via a mount-stable subscription — and the exit-time
   goToNode is deferred past the class flip plus two frames, because fitView
   math against a 0-sized pane is the origin-jump bug.

2. Shared-mode blank frame: the reparent ran before paint (layout effect)
   while the grid teardown ran after (passive effect), so one fullscreen
   frame painted with the grid still registered on-canvas and the node still
   in glyph-mode styling. The focus layout effect now runs the glyph sync
   (off) in the same commit as the DOM move; the passive effect then re-runs
   with the same answer and no-ops.

Refs #78

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@eneskirca

Copy link
Copy Markdown
Owner Author

Both findings closed in 680c5b52, in the shapes you suggested:

1 (budget starvation) — took the first option: body.focus-mode .flow-wrap { display: none }. Covered nodes zero their geometry, report hidden, and become reclaim candidates, so the focused node gets its slot on demand — and the occluded terminals stop rendering invisible frames. Two consequences handled with it: covered terminals now vote null ("not viewing") through a focus gate in applyFit — the kanban board's exact semantics, re-evaluated on every focus flip via a mount-stable subscription — so a 0-sized container can never clamp a co-attached viewer; and the exit-time goToNode is deferred until after the class flips back plus two frames, because fitView math against a 0-sized pane is the origin-jump bug.

2 (shared-mode blank frame) — the narrow fix as suggested: the focus layout effect runs glyphSyncRef.current?.(false) in the same commit as the reparent, before paint; the passive participation effect then re-runs with the same answer and no-ops. Exit needs nothing extra — the node comes home already on its own renderer with painted rows.

Thanks for confirming the glyphOff term and the store-read scale getter — noted as load-bearing in the comments so they don't get "simplified" later.

eneskirca and others added 2 commits August 16, 2026 14:54
…ced two sections

The conflict resolution in 6baf545 reordered the two appended sections by
slicing the conflict hunk, but the hunk covered only the differing middle —
the focus-mode header ended up glued mid-keyframes with its comment never
terminated, which is exactly the 'Unterminated string' the tailwind pass
tripped on in CI. Rebuilt wholesale from the two clean sources (origin/main's
tail + the focus section from a8e7372, with the review-fix flow-wrap rule
re-applied). electron-vite build verified locally.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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.

1 participant