diff --git a/apps/desktop/main/hybrid-overlay.ts b/apps/desktop/main/hybrid-overlay.ts index efedd61a..c1ceb48b 100644 --- a/apps/desktop/main/hybrid-overlay.ts +++ b/apps/desktop/main/hybrid-overlay.ts @@ -736,8 +736,11 @@ function retarget(id: number | null): void { dispatchWindowEvent({ type: 'OVERLAY_ATTACH_REQUESTED', id: overlayWmId, hostId, frame }); // Shadow-feed the kernel (window-kernel-shadow.ts) — additive, authoritative // over nothing; see that module's doc for why this can never change what - // actually happens on screen. - shadowKernelAttachOverlay(overlayWmId, hostId, getWindowStateSnapshot()); + // actually happens on screen. The SAME `frame` computed above for the old + // machine's event rides this feed too (`window-kernel.ts + // WindowRecord.overlayFrame`'s doc), so the kernel's attach/retarget + // reframes exactly as the old machine's does. + shadowKernelAttachOverlay(overlayWmId, hostId, getWindowStateSnapshot(), frame); // Tell the overlay renderer which window is now active + a state snapshot so // it fully swaps its navbar for the new window (instant paint, no flash). diff --git a/apps/desktop/main/window-kernel-shadow.ts b/apps/desktop/main/window-kernel-shadow.ts index a96739b5..a7a2be83 100644 --- a/apps/desktop/main/window-kernel-shadow.ts +++ b/apps/desktop/main/window-kernel-shadow.ts @@ -668,12 +668,19 @@ export function shadowKernelRaiseWindow(id: WindowId, oldSnapshot: StateSnapshot * `shadowExecute` (a rejected stale `expectedVersion`, or a `hostId`/ * `overlayId` `decide` doesn't recognize, is logged, never thrown into the * caller), ending with the same `compareAfterFeed` tail. + * + * `frame` is the freshly expanded overlay frame for `hostId`, computed at the + * caller's dispatch site the same way `OVERLAY_ATTACH_REQUESTED.frame` is + * (`hybrid-overlay.ts retarget()`) — see `window-kernel.ts + * WindowRecord.overlayFrame`'s doc for why the kernel takes this as data + * rather than computing it. Omitted on a detach. */ -export function shadowKernelAttachOverlay(overlayId: WindowId, hostId: WindowId | null, oldSnapshot: StateSnapshot): void { +export function shadowKernelAttachOverlay(overlayId: WindowId, hostId: WindowId | null, oldSnapshot: StateSnapshot, frame?: Rectangle): void { shadowExecute({ type: 'AttachOverlay', overlayId, hostId, + ...(frame !== undefined ? { frame } : {}), expectedVersion: getShadowKernelExecutor().getState().version, }); compareAfterFeed(oldSnapshot); @@ -879,9 +886,17 @@ export function shadowKernelSetFullscreen(id: WindowId, desired: boolean, oldSna * The old machine's `BOUNDS_CHANGED.bounds` and the kernel's own `Rectangle` * are the SAME type (`window-state.ts` imports `Rectangle` from * `window-kernel.ts`), so no conversion is needed at this boundary. + * + * `overlayFrame` mirrors `BOUNDS_CHANGED.overlayFrame` — the freshly + * expanded overlay frame for `id`, when `id` is hosting the shared chrome + * overlay (`electron-window-backend.ts attachNativeListeners`'s `emitBounds` + * closure already computes this for every page-host's bounds report for the + * old machine's own dispatch; it needs threading through to THIS call too). + * See `window-kernel.ts WindowRecord.overlayFrame`'s doc for why the frame + * rides here as data rather than being computed inside the kernel. */ -export function shadowKernelBoundsChanged(id: WindowId, bounds: Rectangle, oldSnapshot: StateSnapshot): void { - shadowDispatch({ type: 'BoundsChanged', id, bounds }); +export function shadowKernelBoundsChanged(id: WindowId, bounds: Rectangle, oldSnapshot: StateSnapshot, overlayFrame?: Rectangle): void { + shadowDispatch({ type: 'BoundsChanged', id, bounds, ...(overlayFrame !== undefined ? { overlayFrame } : {}) }); compareAfterFeed(oldSnapshot); } diff --git a/apps/desktop/main/window-kernel.test.ts b/apps/desktop/main/window-kernel.test.ts index b8d40e6a..7c0658ec 100644 --- a/apps/desktop/main/window-kernel.test.ts +++ b/apps/desktop/main/window-kernel.test.ts @@ -1892,31 +1892,52 @@ describe('window-kernel: overlay attachment', () => { assert.strictEqual(overlayForHost(s, hostB), null); }); - it('an attached overlay follows its host BoundsChanged; a detached one does not', () => { + it('an attached overlay follows its host to the freshly EXPANDED overlay frame, never the raw host rect; a detached one does not follow at all', () => { const registered = fold(registerHostsAndOverlay(), initialState()); const attached = fold( [{ type: 'OverlayAttached', overlayId, hostId: hostA }], registered, ); + // `overlayFrame` stands in for `expandToOverlayFrame(bounds, workArea)` — + // deliberately NOT equal to `bounds`, so the assertion below can tell the + // two apart. The dispatch site (`electron-window-backend.ts emitBounds`) + // computes it; the kernel only stores what it is given. const bounds: Rectangle = { x: 10, y: 20, width: 300, height: 400 }; - const boundsEv: Event = { type: 'BoundsChanged', id: hostA, bounds }; + const overlayFrame: Rectangle = { x: -8, y: -44, width: 316, height: 452 }; + const boundsEv: Event = { type: 'BoundsChanged', id: hostA, bounds, overlayFrame }; const nextAttached = evolve(attached, boundsEv); const attachedEffects = react(attached, boundsEv, nextAttached); assert.ok( - attachedEffects.some((e) => e.type === 'SetBounds' && e.id === overlayId && JSON.stringify(e.bounds) === JSON.stringify(bounds)), - 'an attached overlay must follow its host bounds change', + attachedEffects.some((e) => e.type === 'SetBounds' && e.id === overlayId && JSON.stringify(e.bounds) === JSON.stringify(overlayFrame)), + 'an attached overlay must follow its host to the EXPANDED overlay frame', + ); + assert.ok( + !attachedEffects.some((e) => e.type === 'SetBounds' && e.id === overlayId && JSON.stringify(e.bounds) === JSON.stringify(bounds)), + 'the raw host rect must never be handed to the overlay directly — that was the gutter/trigger-zone-losing bug', + ); + + // A BoundsChanged naming no `overlayFrame` — the dispatch site could not + // read a work area, or this window has no overlay attached — reframes + // nothing: there is no new frame to follow to. + const noFrameEv: Event = { type: 'BoundsChanged', id: hostA, bounds }; + const noFrameNext = evolve(attached, noFrameEv); + const noFrameEffects = react(attached, noFrameEv, noFrameNext); + assert.ok( + !noFrameEffects.some((e) => e.type === 'SetBounds' && e.id === overlayId), + 'no overlayFrame on the event means nothing new to reframe to', ); - // Detach, then move hostA's bounds again — no follow this time. + // Detach, then move hostA's bounds again, WITH a frame — still no follow. const detached = evolve(attached, { type: 'OverlayAttached', overlayId, hostId: null }); const bounds2: Rectangle = { x: 1, y: 2, width: 3, height: 4 }; - const boundsEv2: Event = { type: 'BoundsChanged', id: hostA, bounds: bounds2 }; + const overlayFrame2: Rectangle = { x: -7, y: -42, width: 19, height: 46 }; + const boundsEv2: Event = { type: 'BoundsChanged', id: hostA, bounds: bounds2, overlayFrame: overlayFrame2 }; const nextDetached = evolve(detached, boundsEv2); const detachedEffects = react(detached, boundsEv2, nextDetached); assert.ok( !detachedEffects.some((e) => e.type === 'SetBounds' && e.id === overlayId), - 'a detached overlay must not follow bounds changes', + 'a detached overlay must not follow bounds changes even when a frame is given', ); }); @@ -1982,6 +2003,181 @@ describe('window-kernel: overlay attachment', () => { }); }); +// ── Overlay follow: visibility and frame ──────────────────────────────────── +// +// Ports `window-state.ts applyOverlayFollow`'s full rule set (via +// `overlayFollowPatch`, `window-kernel.ts`): a non-normal host always hides +// its attached overlay; a normal host takes the given frame and reveals the +// overlay while the app is active. `WindowShown` is fired for neither host in +// these scenes on purpose — a hand-written scene omitting it would leave +// `visible` false and silently exclude the window from content predicates, +// but the overlay-follow rule reads a host's `fsMode`/registration facts, not +// its own visibility, so the hosts here are deliberately left unshown to keep +// each scene to the minimum that exercises the rule under test. + +describe('window-kernel: overlay follow — visibility and frame', () => { + const registerHostsAndOverlay = (): Event[] => [ + { type: 'WindowRegistered', id: wid(1), kind: 'page-host', layer: 0, canBecomeKey: true, acceptsInput: true, class: 'primary' }, + { type: 'WindowRegistered', id: wid(2), kind: 'page-host', layer: 0, canBecomeKey: true, acceptsInput: true, class: 'primary' }, + { type: 'WindowRegistered', id: wid(3), kind: 'browser', layer: 1, canBecomeKey: false, acceptsInput: false, class: 'overlay' }, + ]; + const overlayId = wid(3); + const hostA = wid(1); + const hostB = wid(2); + /** Stand-ins for `expandToOverlayFrame(bounds, workArea)` — deliberately NOT + * equal to any raw host bounds used alongside them, so an assertion can + * tell "the expanded frame" apart from "the raw host rect". */ + const frameA: Rectangle = { x: -8, y: -44, width: 316, height: 452 }; + const frameB: Rectangle = { x: 92, y: 56, width: 336, height: 468 }; + + /** The app active, both hosts + the overlay registered, nothing attached yet. */ + const active = (): State => fold([...registerHostsAndOverlay(), { type: 'AppActivated' }], initialState()); + + it('attach shows the overlay when the host is normal and the app is active — the frame lands before the reveal', () => { + const state = active(); + assert.strictEqual(state.order.find((w) => w.id === overlayId)?.visible, false, 'precondition: not yet shown'); + + const ev: Event = { type: 'OverlayAttached', overlayId, hostId: hostA, frame: frameA }; + const next = evolve(state, ev); + const effects = react(state, ev, next); + + const overlay = next.order.find((w) => w.id === overlayId)!; + assert.strictEqual(overlay.visible, true); + assert.strictEqual(overlay.overlayFor, hostA); + assert.deepStrictEqual(overlay.overlayFrame, frameA); + assert.ok(next.opSeqCounter > state.opSeqCounter, 'a genuine show mints a token'); + assert.strictEqual(overlay.lastOpSeq, next.opSeqCounter); + + const boundsIdx = effects.findIndex( + (e) => e.type === 'SetBounds' && e.id === overlayId && JSON.stringify(e.bounds) === JSON.stringify(frameA), + ); + const showIdx = effects.findIndex((e) => e.type === 'ShowWindow' && e.id === overlayId && e.opSeq === next.opSeqCounter); + assert.notStrictEqual(boundsIdx, -1, 'the overlay is framed'); + assert.notStrictEqual(showIdx, -1, 'the overlay is shown, carrying the op token evolve stamped'); + assert.ok(boundsIdx < showIdx, 'the frame lands before the reveal, so it never paints at a stale rect'); + }); + + it('retarget re-frames the ALREADY-visible overlay onto the new host — no second ShowWindow, no new op token', () => { + const attached = fold([{ type: 'OverlayAttached', overlayId, hostId: hostA, frame: frameA }], active()); + assert.strictEqual(attached.order.find((w) => w.id === overlayId)?.visible, true, 'precondition: shown on hostA'); + + const ev: Event = { type: 'OverlayAttached', overlayId, hostId: hostB, frame: frameB }; + const next = evolve(attached, ev); + const effects = react(attached, ev, next); + + const overlay = next.order.find((w) => w.id === overlayId)!; + assert.strictEqual(overlay.overlayFor, hostB); + assert.deepStrictEqual(overlay.overlayFrame, frameB); + assert.strictEqual(overlay.visible, true, 'still shown — a retarget is not a fresh reveal'); + assert.strictEqual(next.opSeqCounter, attached.opSeqCounter, 'no focus-family effect — nothing to stamp a token for'); + + assert.ok( + effects.some((e) => e.type === 'SetBounds' && e.id === overlayId && JSON.stringify(e.bounds) === JSON.stringify(frameB)), + 'the overlay follows onto the new host at its frame', + ); + assert.ok(!effects.some((e) => e.type === 'ShowWindow' && e.id === overlayId), 'already visible — no re-show'); + }); + + it('detach hides the overlay', () => { + const attached = fold([{ type: 'OverlayAttached', overlayId, hostId: hostA, frame: frameA }], active()); + assert.strictEqual(attached.order.find((w) => w.id === overlayId)?.visible, true, 'precondition: shown'); + + const ev: Event = { type: 'OverlayAttached', overlayId, hostId: null }; + const next = evolve(attached, ev); + const effects = react(attached, ev, next); + + assert.strictEqual(next.order.find((w) => w.id === overlayId)?.overlayFor, undefined); + assert.ok(effects.some((e) => e.type === 'HideWindow' && e.id === overlayId)); + }); + + it('a host entering fullscreen hides its attached overlay, without severing the attachment', () => { + const attached = fold([{ type: 'OverlayAttached', overlayId, hostId: hostA, frame: frameA }], active()); + assert.strictEqual(attached.order.find((w) => w.id === overlayId)?.visible, true, 'precondition: shown'); + + const ev: Event = { type: 'OsFullscreenEntered', id: hostA }; + const next = evolve(attached, ev); + const effects = react(attached, ev, next); + + const overlay = next.order.find((w) => w.id === overlayId)!; + assert.strictEqual(overlay.visible, false); + assert.strictEqual(overlay.overlayFor, hostA, 'still attached — only visibility moved, the same shape a fullscreen-hidden overlay had before this rule existed'); + assert.ok(effects.some((e) => e.type === 'HideWindow' && e.id === overlayId)); + assert.ok(!effects.some((e) => e.type === 'SetBounds' && e.id === overlayId), 'a fullscreen hide never touches the frame'); + assert.strictEqual(next.opSeqCounter, attached.opSeqCounter, 'a hide is never a focus-family effect'); + }); + + it('a host bounds change re-frames its attached, ALREADY-visible overlay — no extra ShowWindow', () => { + const attached = fold([{ type: 'OverlayAttached', overlayId, hostId: hostA, frame: frameA }], active()); + assert.strictEqual(attached.order.find((w) => w.id === overlayId)?.visible, true, 'precondition: shown'); + + const movedBounds: Rectangle = { x: 50, y: 60, width: 300, height: 400 }; + const movedFrame: Rectangle = { x: 42, y: 16, width: 316, height: 452 }; + const ev: Event = { type: 'BoundsChanged', id: hostA, bounds: movedBounds, overlayFrame: movedFrame }; + const next = evolve(attached, ev); + const effects = react(attached, ev, next); + + const overlay = next.order.find((w) => w.id === overlayId)!; + assert.deepStrictEqual(overlay.overlayFrame, movedFrame); + assert.strictEqual(overlay.visible, true, 'still visible — a reframe is not a re-show'); + assert.ok( + effects.some((e) => e.type === 'SetBounds' && e.id === overlayId && JSON.stringify(e.bounds) === JSON.stringify(movedFrame)), + 'the overlay follows to the freshly expanded frame', + ); + assert.ok(!effects.some((e) => e.type === 'ShowWindow' && e.id === overlayId), 'already visible — no re-show'); + assert.strictEqual(next.opSeqCounter, attached.opSeqCounter, 'a reframe alone mints no op token'); + }); + + it('leaving fullscreen re-reveals the overlay at its LAST frame — no SetBounds, only a ShowWindow carrying a fresh op token', () => { + const hidden = fold( + [ + { type: 'OverlayAttached', overlayId, hostId: hostA, frame: frameA }, + { type: 'OsFullscreenEntered', id: hostA }, + ], + active(), + ); + assert.strictEqual(hidden.order.find((w) => w.id === overlayId)?.visible, false, 'precondition: hidden by fullscreen'); + + const ev: Event = { type: 'OsFullscreenLeft', id: hostA }; + const next = evolve(hidden, ev); + const effects = react(hidden, ev, next); + + const overlay = next.order.find((w) => w.id === overlayId)!; + assert.strictEqual(overlay.visible, true); + assert.deepStrictEqual(overlay.overlayFrame, frameA, 'never moved while hidden — the same frame as before the hide'); + assert.ok(next.opSeqCounter > hidden.opSeqCounter, 'a genuine show mints a token'); + assert.strictEqual(overlay.lastOpSeq, next.opSeqCounter); + + assert.ok( + effects.some((e) => e.type === 'ShowWindow' && e.id === overlayId && e.opSeq === next.opSeqCounter), + 'the overlay is re-shown, carrying the op token evolve stamped', + ); + assert.ok(!effects.some((e) => e.type === 'SetBounds' && e.id === overlayId), 'no rect write — the overlay was never moved while hidden'); + }); + + it('a non-normal host at attach time hides rather than reveals, even though a frame and an active app are both given', () => { + // The mid-transition case: a retarget landing while the new host is + // already `entering`/`leaving`/`fullscreen` must not paint chrome over + // it, regardless of `frame`/`appActive` — ported from + // `applyOverlayFollow`'s early return before its `SET_BOUNDS` line. + const midTransition = fold( + [...registerHostsAndOverlay(), { type: 'AppActivated' }, { type: 'OsFullscreenEntered', id: hostA }], + initialState(), + ); + assert.notStrictEqual(midTransition.order.find((w) => w.id === hostA)?.fsMode.name, 'normal', 'precondition: hostA is fullscreen'); + + const ev: Event = { type: 'OverlayAttached', overlayId, hostId: hostA, frame: frameA }; + const next = evolve(midTransition, ev); + const effects = react(midTransition, ev, next); + + const overlay = next.order.find((w) => w.id === overlayId)!; + assert.strictEqual(overlay.overlayFor, hostA, 'the attachment is still recorded'); + assert.strictEqual(overlay.visible, false); + assert.strictEqual(overlay.overlayFrame, undefined, 'no frame is stored — the early return never reaches the frame write'); + assert.ok(!effects.some((e) => e.type === 'ShowWindow' && e.id === overlayId)); + assert.ok(!effects.some((e) => e.type === 'SetBounds' && e.id === overlayId)); + }); +}); + // ── Focus provenance: cause, and isStaleSelfEcho ──────────────────────────── describe('window-kernel: focus provenance (cause) and isStaleSelfEcho', () => { @@ -3574,21 +3770,23 @@ describe('window-kernel: topmostBaseLayerPageHost', () => { { type: 'WindowContentOrderable', id }, ]; - it('a floating quick-view above a base-layer page host does not shadow it — the chrome frames the page, not the peek', () => { + it('a topmost quick-view IS the attach target — the chrome frames the peek, not the base-layer page beneath it', () => { const base = wid(1); const quickView = wid(2); const state = fold([ registerPageHost(base), ...shown(base), - // Registered later, in the floating band — a peek opened over the page. - registerPageHost(quickView, { layer: FLOATING, role: 'quick-view' }), + // Registered later, at the SAME base layer — real peeks/slides declare + // no `alwaysOnTop` (`izui-roles.ts deriveWindowLayer`), so they land at + // `BASE_LAYER` like any ordinary page, never in a floating band. + registerPageHost(quickView, { role: 'quick-view' }), ...shown(quickView), ]); assert.deepStrictEqual(state.order.map((w) => w.id), [base, quickView], 'the quick-view sits above `base` in `order`'); assert.strictEqual( topmostBaseLayerPageHost(state)?.id, - base, - 'kind alone admits the peek too — the role clause is what excludes it', + quickView, + 'a frontmost peek is the window the user is working in — peeks/slides use the shared overlay for their address bar (task ff3b18ba) and ship no navbar of their own', ); }); @@ -3598,6 +3796,12 @@ describe('window-kernel: topmostBaseLayerPageHost', () => { assert.strictEqual(topmostBaseLayerPageHost(state), null, 'array position alone would have found it — the layer clause is what excludes it'); }); + it('the layer clause still excludes a quick-view outside the base layer, even though the role is now admitted', () => { + const floatingQuickView = wid(1); + const state = fold([registerPageHost(floatingQuickView, { layer: FLOATING, role: 'quick-view' }), ...shown(floatingQuickView)]); + assert.strictEqual(topmostBaseLayerPageHost(state), null, 'admitting the role did not also drop the layer requirement'); + }); + it('no eligible page-host reads null, even with other windows on screen', () => { const browserWin = wid(1); const utilityHost = wid(2); diff --git a/apps/desktop/main/window-kernel.ts b/apps/desktop/main/window-kernel.ts index 20c5db03..042eed02 100644 --- a/apps/desktop/main/window-kernel.ts +++ b/apps/desktop/main/window-kernel.ts @@ -248,6 +248,31 @@ export interface WindowRecord { */ overlayFor?: WindowId; + /** + * ATTACHED-OVERLAY TARGET FRAME. Set on an OVERLAY record alongside + * `overlayFor`: the frame the overlay should currently occupy, taken + * verbatim from whichever call supplied one (`AttachOverlay.frame`, + * `BoundsChanged.overlayFrame`) — the kernel never computes this itself. + * `expandToOverlayFrame` (`overlay-frame.ts`) needs a work-area read + * (`screen.getDisplayNearestPoint`), which is platform data this + * zero-value-import file cannot ask for, so the frame rides on the + * command/event as data, computed at the dispatch site that already knows + * the work area — the same design `window-state.ts + * OVERLAY_ATTACH_REQUESTED.frame` / `BOUNDS_CHANGED.overlayFrame` used. + * + * DISTINCT FROM `bounds`, deliberately: `bounds` is a window's own + * OS-mirrored rect, written only by a genuine `BoundsChanged` naming THAT + * window. `overlayFrame` is a TARGET this window (the overlay) has not + * necessarily reached on screen yet — which is exactly what makes it a + * STORED FACT `react` can diff into a `SetBounds` effect (see `react`'s + * "Overlay follow — frame" rule) without reading `ev.type`: the fact + * exists so `react` never has to ask which event produced it, only + * whether it moved. `undefined` until the first frame is ever given, and + * untouched by a hide (a fullscreen-hidden overlay keeps its last frame, + * so re-revealing it needs no new `SetBounds`). + */ + overlayFrame?: Rectangle; + /** * The newest `opSeq` stamped on a focus-family effect (`ShowWindow` / * `RaiseWindow`) issued for this window — what `isStaleSelfEcho` compares @@ -731,8 +756,20 @@ export type Event = * `Maximize` / `Unmaximize` below reuse this same event shape rather than * inventing a second bounds-writing fact, under the dispatch-is-synchronous * contract described on the field. + * + * `overlayFrame` is the freshly expanded overlay frame for THIS window, + * when it is hosting the shared chrome overlay — ported from + * `window-state.ts BOUNDS_CHANGED.overlayFrame`. Computed at the dispatch + * site the same way `AttachOverlay.frame` is (see + * `WindowRecord.overlayFrame`'s doc for why): the backend already computes + * it for every page-host's bounds report and only needs to stop dropping + * it at the kernel's edge (`window-kernel-shadow.ts + * shadowKernelBoundsChanged`). Absent when this window has no overlay + * attached, or the dispatch site could not read a work area — `evolve` + * treats an absent frame as "nothing new to reframe to", same as + * `AttachOverlay` with no `frame`. */ - | { type: 'BoundsChanged'; id: WindowId; bounds: Rectangle } + | { type: 'BoundsChanged'; id: WindowId; bounds: Rectangle; overlayFrame?: Rectangle } /** * The display layout changed (ported from `window-state.ts * DISPLAY_CHANGED`) — one already-computed target rect per affected @@ -783,11 +820,20 @@ export type Event = * As an event, it is a fact already decided elsewhere and can never be * rejected here: `evolve` normalizes a `hostId` that isn't registered, or * that names the overlay itself, into a detach rather than storing it — - * see `evolve`'s `OverlayAttached` case. The follow itself is `react`'s - * job, driven off the host's next `BoundsChanged`, never a live query at - * this event's own dispatch time. + * see `evolve`'s `OverlayAttached` case. The follow (reveal, hide, reframe) + * is decided IN `evolve`, from the host's CURRENT `fsMode` and + * `state.appActive` — never a live query at some later point, and never a + * branch `react` has to make: `evolve` writes the result onto the overlay's + * `visible`/`overlayFrame` fields, and `react` only diffs them. + * + * `frame` is the freshly expanded overlay frame for the NEW host, computed + * at the dispatch site the same way `BoundsChanged.overlayFrame` is (see + * `WindowRecord.overlayFrame`'s doc) — ported from `window-state.ts + * OVERLAY_ATTACH_REQUESTED.frame`. Absent on a detach, or when the + * dispatch site could not read the host's bounds between the retarget + * signal and this call. */ - | { type: 'OverlayAttached'; overlayId: WindowId; hostId: WindowId | null } + | { type: 'OverlayAttached'; overlayId: WindowId; hostId: WindowId | null; frame?: Rectangle } /** * A persisted bottom -> top stack order was imported (`decide`'s translation * of `ImportStackOrder`; ported from `window-state.ts @@ -942,8 +988,13 @@ export type Command = * is detach. One transition, no separate detach command (see the doc on * `WindowRecord.overlayFor`). `decide` rejects if either named window is * absent from `order`. + * + * `frame` carries the newly expanded overlay frame for `hostId`, computed + * at the caller's dispatch site — see `WindowRecord.overlayFrame`'s doc for + * why the kernel takes this as data rather than computing it. Meaningless + * on a detach (`hostId: null`), and safely ignored there. */ - | { type: 'AttachOverlay'; overlayId: WindowId; hostId: WindowId | null; expectedVersion: number } + | { type: 'AttachOverlay'; overlayId: WindowId; hostId: WindowId | null; frame?: Rectangle; expectedVersion: number } /** * Restore a persisted bottom -> top stack order (ported from * `window-state.ts STACK_ORDER_IMPORTED`) — the session-restore ordering @@ -1289,6 +1340,40 @@ function withPendingProjectionFlushed(record: WindowRecord): WindowRecord { return { ...record, bounds: record.pendingProjectedBounds, pendingProjectedBounds: null }; } +// ───────────────────────────────────────────────────────────────────────────── +// Overlay follow (§ WindowRecord.overlayFor/overlayFrame) — the decision half +// of `window-state.ts applyOverlayFollow`, ported WITHOUT its effects: it +// returns the record patch an evolve arm merges onto an attached overlay, +// rather than pushing effects itself (effects are `react`'s job, from the +// diff those patches leave behind). Shared by every evolve arm that can move +// an attached overlay's visibility or frame (`OverlayAttached`, +// `BoundsChanged`, `OsFullscreenEntered`, `OsFullscreenLeft`) so the rule — +// a non-normal host always hides; a normal host takes the given frame and +// reveals while the app is active — lives in exactly one place. Total and +// reads only the values it is given, same as every other evolve helper. +// ───────────────────────────────────────────────────────────────────────────── + +/** The record patch `overlayFollowPatch` can produce — see that function. */ +type OverlayFollowPatch = Partial>; + +function overlayFollowPatch(overlay: WindowRecord, hostNormal: boolean, appActive: boolean, frame: Rectangle | undefined): OverlayFollowPatch { + if (!hostNormal) { + // Ported verbatim: a non-normal host hides and returns — no frame write, + // even when one was given, and no reveal. + return overlay.visible ? { visible: false } : {}; + } + const patch: OverlayFollowPatch = {}; + if (frame !== undefined) patch.overlayFrame = frame; + if (appActive && !overlay.visible) { + patch.visible = true; + // `hiddenByAppBlur` can never be true on a visible window (checked by + // `window-kernel-properties.test.ts checkInvariants`) — cleared here the + // same way `WindowShown`'s arm clears it on any genuine show. + if (overlay.hiddenByAppBlur === true) patch.hiddenByAppBlur = false; + } + return patch; +} + // ───────────────────────────────────────────────────────────────────────────── // evolve — TOTAL. No guards. Cannot throw. Reads nothing but its two // arguments. Every event bumps `version`. @@ -1666,8 +1751,9 @@ export function evolve(state: State, ev: Event): State { // overlays outright: an attached overlay's visibility belongs to its // attachment, so it is driven here and never dismissed. An already-hidden // overlay is left alone, which is what keeps a host in fullscreen (whose - // overlay `react`'s follow rule already drove off screen) from having its - // chrome revealed by the next activation. + // overlay the `OsFullscreenEntered` evolve arm already drove off screen, + // via `overlayFollowPatch`) from having its chrome revealed by the next + // activation. // // No `opSeq` is stamped, exactly as the `AppResignSettled` sweep stamps // none: `lastOpSeq` tracks focus-family ops and `HideWindow` carries no @@ -1886,9 +1972,21 @@ export function evolve(state: State, ev: Event): State { } case 'OsFullscreenEntered': { + // OVERLAY FOLLOW (ported from `window-state.ts applyOverlayFollow`'s + // fullscreen branch): `nextFsModeForOsEntered` never settles a host + // back to `normal` (every one of its arms lands on `entering`, + // `leaving`, or `fullscreen` — see that function), so a host entering + // fullscreen always takes its attached overlay off screen. No opSeq + // stamp: a hide is never a focus-family effect. + const overlay = overlayForHost(state, ev.id); + const patch = overlay !== null ? overlayFollowPatch(overlay, false, state.appActive, undefined) : null; return { ...state, - order: state.order.map((w) => (w.id === ev.id ? { ...w, fsMode: nextFsModeForOsEntered(w.fsMode) } : w)), + order: state.order.map((w) => { + if (w.id === ev.id) return { ...w, fsMode: nextFsModeForOsEntered(w.fsMode) }; + if (patch !== null && overlay !== null && w.id === overlay.id) return { ...w, ...patch }; + return w; + }), version: state.version + 1, }; } @@ -1896,11 +1994,31 @@ export function evolve(state: State, ev: Event): State { case 'OsFullscreenLeft': { // Can newly clear the fsMode axis of a held DisplayChanged projection // (several of its arms settle onto 'normal') — flush if visible too. + // + // OVERLAY FOLLOW: the reverse of `OsFullscreenEntered` above — settling + // back to `normal` re-reveals the attached overlay while the app is + // active, at its LAST frame (no `frame` is given here, mirroring + // `window-state.ts applyOverlayFollow`'s "never moved while hidden, so + // no rect needed" case). `settlesNormal` reads the host's PRIOR + // (`state`) record, since this arm computes the settle itself rather + // than reading it back off `next` — `evolve` may read only its own two + // arguments. + const hostBefore = state.order.find((w) => w.id === ev.id); + const settlesNormal = hostBefore !== undefined && nextFsModeForOsLeft(hostBefore.fsMode).name === 'normal'; + const overlay = overlayForHost(state, ev.id); + const patch = overlay !== null ? overlayFollowPatch(overlay, settlesNormal, state.appActive, undefined) : null; + const issuesShow = patch !== null && patch.visible === true; + const opSeq = issuesShow ? state.opSeqCounter + 1 : state.opSeqCounter; return { ...state, - order: state.order.map((w) => - w.id === ev.id ? withPendingProjectionFlushed({ ...w, fsMode: nextFsModeForOsLeft(w.fsMode) }) : w, - ), + order: state.order.map((w) => { + if (w.id === ev.id) return withPendingProjectionFlushed({ ...w, fsMode: nextFsModeForOsLeft(w.fsMode) }); + if (patch !== null && overlay !== null && w.id === overlay.id) { + return { ...w, ...patch, ...(issuesShow ? { lastOpSeq: opSeq } : {}) }; + } + return w; + }), + opSeqCounter: opSeq, version: state.version + 1, }; } @@ -1939,9 +2057,28 @@ export function evolve(state: State, ev: Event): State { // `decide`'s translation of SetBounds/Maximize/Unmaximize (see the // event doc). Mirror only — no other field, no effects computed here // (effects are `react`'s job, from the diff). + // + // OVERLAY FOLLOW (ported from `window-state.ts applyOverlayFollow`, + // §5 step 9's "follow driven by boundsChanged"): the host's own bounds + // write above is untouched by this — only its ATTACHED overlay (if + // any) reframes, off `ev.overlayFrame` (the dispatch site's freshly + // expanded frame, computed alongside the raw bounds — see the event's + // own doc), and reveals when the host is normal and the app is active. + const host = state.order.find((w) => w.id === ev.id); + const overlay = overlayForHost(state, ev.id); + const patch = overlay !== null && host !== undefined ? overlayFollowPatch(overlay, host.fsMode.name === 'normal', state.appActive, ev.overlayFrame) : null; + const issuesShow = patch !== null && patch.visible === true; + const opSeq = issuesShow ? state.opSeqCounter + 1 : state.opSeqCounter; return { ...state, - order: state.order.map((w) => (w.id === ev.id ? { ...w, bounds: ev.bounds } : w)), + order: state.order.map((w) => { + if (w.id === ev.id) return { ...w, bounds: ev.bounds }; + if (patch !== null && overlay !== null && w.id === overlay.id) { + return { ...w, ...patch, ...(issuesShow ? { lastOpSeq: opSeq } : {}) }; + } + return w; + }), + opSeqCounter: opSeq, version: state.version + 1, }; } @@ -2023,9 +2160,24 @@ export function evolve(state: State, ev: Event): State { // a valid host. const resolvedHostId: WindowId | undefined = ev.hostId !== null && ev.hostId !== ev.overlayId && state.order.some((w) => w.id === ev.hostId) ? ev.hostId : undefined; + // OVERLAY FOLLOW (ported from `window-state.ts applyOverlayFollow`, via + // `overlayFollowPatch`): attach and retarget drive the SAME + // reveal/reframe decision the bounds- and fullscreen-driven follow + // rules elsewhere in this switch drive. Detach (`host === undefined`) + // touches only the relation above; `react`'s structural `overlayFor` + // diff (its "Overlay detach" rule) is what drives it off screen, same + // as before this change. + const host = resolvedHostId !== undefined ? state.order.find((w) => w.id === resolvedHostId) : undefined; + const overlay = state.order.find((w) => w.id === ev.overlayId); + const patch = host !== undefined && overlay !== undefined ? overlayFollowPatch(overlay, host.fsMode.name === 'normal', state.appActive, ev.frame) : {}; + const issuesShow = patch.visible === true; + const opSeq = issuesShow ? state.opSeqCounter + 1 : state.opSeqCounter; return { ...state, - order: state.order.map((w) => (w.id === ev.overlayId ? { ...w, overlayFor: resolvedHostId } : w)), + order: state.order.map((w) => + w.id === ev.overlayId ? { ...w, overlayFor: resolvedHostId, ...patch, ...(issuesShow ? { lastOpSeq: opSeq } : {}) } : w, + ), + opSeqCounter: opSeq, version: state.version + 1, }; } @@ -2220,6 +2372,21 @@ export function evolve(state: State, ev: Event): State { export function react(prev: State, ev: Event, next: State): Effect[] { const effects: Effect[] = []; + // Overlay follow — FRAME (§ WindowRecord.overlayFrame): a structural diff + // of the stored target frame `evolve` writes (`OverlayAttached`, + // `BoundsChanged`, `OsFullscreenLeft` — see `overlayFollowPatch`), reading + // only `prev`/`next` and never `ev.type`. PLACED FIRST, ahead of the + // visibility loop just below, so a reveal never paints the overlay at a + // stale frame — the kernel's structural-diff form of `window-state.ts + // applyOverlayFollow`'s `SET_BOUNDS`-before-`SHOW_WINDOW` ordering. + for (const nextRecord of next.order) { + const prevRecord = prev.order.find((w) => w.id === nextRecord.id); + if (prevRecord === undefined) continue; + if (nextRecord.overlayFrame !== undefined && !boundsEqual(prevRecord.overlayFrame, nextRecord.overlayFrame)) { + effects.push({ type: 'SetBounds', id: nextRecord.id, bounds: nextRecord.overlayFrame }); + } + } + // Visibility: a record present in both states whose `visible` flipped. // (A closed window vanishes from `next.order` entirely — no HideWindow; // its dismissal is the CloseWindow diff below instead.) @@ -2375,20 +2542,6 @@ export function react(prev: State, ev: Event, next: State): Effect[] { } } - // Overlay follow (§ WindowRecord.overlayFor): an attached overlay's bounds - // effect mirrors its HOST's bounds diff — never a live query, never a - // branch on `ev.type`. A detached overlay (`overlayFor` undefined) has no - // host to diff against and is skipped. - for (const nextRecord of next.order) { - if (nextRecord.overlayFor === undefined) continue; - const prevHost = prev.order.find((w) => w.id === nextRecord.overlayFor); - const nextHost = next.order.find((w) => w.id === nextRecord.overlayFor); - if (nextHost === undefined) continue; - if (nextHost.bounds !== undefined && !boundsEqual(prevHost?.bounds, nextHost.bounds)) { - effects.push({ type: 'SetBounds', id: nextRecord.id, bounds: nextHost.bounds }); - } - } - // Overlay detach (an AttachOverlay retarget/detach, or the host closing): // a structural diff of `overlayFor` going from set -> unset. `evolve` // never flips `visible` for this, so a still-visible overlay must be @@ -2693,7 +2846,9 @@ export function decide(state: State, cmd: Command): { events: Event[] } | { reje if (cmd.hostId !== null && !state.order.some((w) => w.id === cmd.hostId)) { return { rejected: 'no-such-window' }; } - return { events: [{ type: 'OverlayAttached', overlayId: cmd.overlayId, hostId: cmd.hostId }] }; + return { + events: [{ type: 'OverlayAttached', overlayId: cmd.overlayId, hostId: cmd.hostId, ...(cmd.frame !== undefined ? { frame: cmd.frame } : {}) }], + }; } // ImportStackOrder carries `order`, not `id` — handled here, before the @@ -3098,25 +3253,45 @@ export function isFrontEligibleWindow(record: WindowRecord): boolean { * `layer` (see `topmost`'s own doc); this predicate holds that contract with * two clauses, neither of which is optional: * - * - a role in `CONTENT_ROLES` `kind: 'page-host'` alone is not enough to - * mean "a base-layer page". `ipc.ts - * assembleHybridPageHost` registers EVERY - * hybrid page host with `kind: 'page-host'`, - * including peeks and slides, which register - * `role: 'quick-view'`. Without this clause - * the predicate admits a floating quick-view, - * and the overlay frames a peek while the - * base-layer page underneath it sits - * chrome-less. - * - `layer === BASE_LAYER` a page-host is not confined to the base - * layer — one can sit in a floating band - * behind the cmd panel, a peek, or a slide, - * alongside base-layer page-hosts. `order` is - * insertion- and movement-ordered, never - * layer-sorted, so without this clause array - * position and visual stacking diverge with - * no error path — the topmost `order` entry - * need not be the topmost on screen. + * - a role in `CONTENT_ROLES`, + * or `'quick-view'` `kind: 'page-host'` alone is not enough to + * mean "a base-layer page" — `ipc.ts + * assembleHybridPageHost` registers EVERY + * hybrid page host with `kind: 'page-host'`, + * including peeks and slides, which + * register `role: 'quick-view'`. + * + * `'quick-view'` is ADMITTED, not excluded. + * Peeks and slides are explicitly built to + * use the shared overlay's address bar — + * `features/peeks/background.js` and + * `features/slides/background.js` both open + * with `pageHost: true` under the comment + * "page-host shell with address bar (task + * ff3b18ba)", and neither ships a navbar of + * its own. Excluding the role left a + * frontmost peek framed by nothing: with + * another page underneath, the chrome + * painted around that page instead — the + * window the user is NOT working in — and + * with the peek alone on screen, there was + * no address bar at all. A frontmost peek IS + * the window the user is working in, so this + * predicate has to name it, not whatever + * ordinary page happens to sit behind it. + * - `layer === BASE_LAYER` a page-host is not confined to the base + * layer — one can sit in a floating band + * behind the cmd panel, a peek, or a slide, + * alongside base-layer page-hosts. `order` + * is insertion- and movement-ordered, never + * layer-sorted, so without this clause array + * position and visual stacking diverge with + * no error path — the topmost `order` entry + * need not be the topmost on screen. Real + * peeks/slides land at `BASE_LAYER` too + * (`izui-roles.ts deriveWindowLayer`: they + * declare no `alwaysOnTop`), so this clause + * never excludes them on its own. * * `visible` and `orderable` are carried over from the same reasoning * `isContentWindow` and the `SetStacking` emission (`react`) already apply: a @@ -3126,7 +3301,7 @@ export function isFrontEligibleWindow(record: WindowRecord): boolean { export function topmostBaseLayerPageHost(state: State): WindowRecord | null { return topmost( state, - (r) => r.kind === 'page-host' && r.visible && r.orderable && r.layer === BASE_LAYER && roleIn(CONTENT_ROLES, r.role), + (r) => r.kind === 'page-host' && r.visible && r.orderable && r.layer === BASE_LAYER && (roleIn(CONTENT_ROLES, r.role) || r.role === 'quick-view'), ); }