From d32affc7e20849a80043b9698e8396fdf7c7597f Mon Sep 17 00:00:00 2001 From: Siya <57148521+siyabendoezdemir@users.noreply.github.com> Date: Sun, 11 Oct 2026 15:34:11 +0000 Subject: [PATCH 01/26] fix(split): an open tab chosen for a pane goes into it in the update that closes the bar (SV-02, SV-03, SV-07, SV-15) The command bar asks the pane session first (a new barChoice hook): the chosen tab moves, is copied or swaps into the pane in the same store update that closes the bar, so no frame selects it outside the split. The shell's pane engine hands the replaced pane's card to the new tab, so it no longer regrows from a sliver. feel-fix.spec.ts samples the page views in main from Enter until the split settles: without the fix the chosen tab covered the split for 12 samples (45 ms); without the engine hand-over its view grew for 250 ms. Co-Authored-By: Claude Opus 5.5 --- FEATURES.md | 2 +- docs/feature-status.json | 2 +- src/main/commandbar/search.ts | 17 +- src/main/context.ts | 8 +- src/main/split/index.ts | 58 ++++- src/renderer/src/shell/split/engine.ts | 19 ++ tests/e2e/feel-fix.spec.ts | 313 +++++++++++++++++++++++++ 7 files changed, 407 insertions(+), 12 deletions(-) create mode 100644 tests/e2e/feel-fix.spec.ts diff --git a/FEATURES.md b/FEATURES.md index bb613f15..87e7cbfc 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -196,7 +196,7 @@ Spec: `spec/split-view.md` | ID | Feature | P | Built | Tested | Feels right | Notes | |---|---|---|---|---|---|---| | SV-01 | What a split view is | P0 | ✅ | ✅ e2e+unit | ✅ | 2-4 live panes shown together; the split is one sidebar item | -| SV-02 | Create with the keyboard shortcut | P0 | ✅ | ✅ e2e+unit | – | Ctrl+Shift+= adds an empty pane and opens the command bar; a URL or open tab fills it, Escape removes it; refused in an empty window | +| SV-02 | Create with the keyboard shortcut | P0 | ✅ | ✅ e2e+unit | – | Ctrl+Shift+= adds an empty pane and opens the command bar; a URL or open tab fills it (an open tab goes into the pane in the update that closes the bar, never shown alone), Escape removes it; refused in an empty window | | SV-03 | Create from the command bar | P0 | ✅ | ✅ e2e | – | Add Split Right/Left/Above/Below and New Split View actions; the command bar stays open for the new pane | | SV-04 | Create by dragging a tab onto the page | P0 | ✅ | ✅ e2e+unit | ✅ | drag a tab row over the page: theme-coloured drop indicator, the page makes room, release adds the pane | | SV-05 | Create by dropping a tab onto another tab row | P0 | ✅ | ✅ e2e+unit | ✅ | resting a dragged tab on a tab or split row's middle band (~400 ms) highlights that half; dropping makes or extends a split there, undoable with Cmd/Ctrl+Z; quick drags reorder as usual | diff --git a/docs/feature-status.json b/docs/feature-status.json index e98ed3a7..8c7f8e0b 100644 --- a/docs/feature-status.json +++ b/docs/feature-status.json @@ -89,7 +89,7 @@ "FV-08": {"built": "yes", "note": "Favorites sleep only after 4x the sleep period"}, "FV-10": {"built": "yes", "note": "see TB-04"}, "SV-01": {"built": "yes", "feel": true, "note": "2-4 live panes shown together; the split is one sidebar item"}, - "SV-02": {"built": "yes", "note": "Ctrl+Shift+= adds an empty pane and opens the command bar; a URL or open tab fills it, Escape removes it; refused in an empty window"}, + "SV-02": {"built": "yes", "note": "Ctrl+Shift+= adds an empty pane and opens the command bar; a URL or open tab fills it (an open tab goes into the pane in the update that closes the bar, never shown alone), Escape removes it; refused in an empty window"}, "SV-03": {"built": "yes", "note": "Add Split Right/Left/Above/Below and New Split View actions; the command bar stays open for the new pane"}, "SV-04": {"built": "yes", "feel": true, "note": "drag a tab row over the page: theme-coloured drop indicator, the page makes room, release adds the pane"}, "SV-05": {"built": "yes", "feel": true, "note": "resting a dragged tab on a tab or split row's middle band (~400 ms) highlights that half; dropping makes or extends a split there, undoable with Cmd/Ctrl+Z; quick drags reorder as usual"}, diff --git a/src/main/commandbar/search.ts b/src/main/commandbar/search.ts index d97a1292..0497f910 100644 --- a/src/main/commandbar/search.ts +++ b/src/main/commandbar/search.ts @@ -12,6 +12,12 @@ import { normKey, pastSearchOf, type RankInput, rank } from './rank' import { SourceCache } from './sources' import { parseSuggestions } from './suggest' +/** Closes a window's command bar (in a store update's draft). */ +const closeIn = (windowId: WindowId) => (d: OrbitState) => { + const win = d.windows[windowId] + if (win && win.overlay.kind === 'commandbar') win.overlay = { kind: 'none' } +} + /** * The command bar's main-process side: ranks open tabs, history, archived * tabs, Spaces, actions and the typed URL/search per keystroke (no state @@ -27,10 +33,7 @@ export function registerSearch(ctx: MainContext, history: HistoryService): void const closeBar = (windowId: WindowId | undefined) => { if (!windowId) return - store.update((d) => { - const win = d.windows[windowId] - if (win && win.overlay.kind === 'commandbar') win.overlay = { kind: 'none' } - }) + store.update(closeIn(windowId)) } /** Profile of the window's current Space, and whether it is private (non-persistent session). */ @@ -211,6 +214,12 @@ export function registerSearch(ctx: MainContext, history: HistoryService): void commands.register('search.open', async ({ result, mode, disposition, topResult }, c) => { const windowId = windowOf(c) if (windowId && store.state.windows[windowId]?.kind === 'mini') return openInMini(windowId, result, topResult) + // An open tab chosen for a pane goes into it in the update that closes the bar (SV-02). + const tabId = result.kind === 'tab' && disposition !== 'new-tab' ? result.tabId : undefined + if (windowId && tabId && store.state.tabs[tabId] && barOpen(windowId)) { + const close = closeIn(windowId) + if ((ctx.hooks.barChoice ?? []).some((take) => take(windowId, tabId, close))) return + } closeBar(windowId) const state = store.state const current = windowId ? activeTabOf(state, windowId) : null diff --git a/src/main/context.ts b/src/main/context.ts index 06912b93..5f62d6da 100644 --- a/src/main/context.ts +++ b/src/main/context.ts @@ -6,7 +6,7 @@ import type { WebFrameMain, WindowOpenHandlerResponse, } from 'electron' -import type { TabId } from '../shared/state' +import type { OrbitState, TabId, WindowId } from '../shared/state' import type { Perf } from './perf' import type { ActionRegistry, CommandRegistry, EventRegistry } from './registry' import type { Sessions } from './sessions' @@ -31,6 +31,12 @@ export interface Hooks { * goes to the page once these have settled. */ screenShare?: ((tabId: TabId, contents: WebContents, frame: WebFrameMain) => Promise)[] + /** + * The command bar chose an open tab to switch to. A hook that takes the choice (a pane being filled, + * SV-02) carries it out in one store update that also runs `closeBar`, and returns true; the bar + * then does nothing more. Otherwise the bar closes and selects the tab. + */ + barChoice?: ((windowId: WindowId, tabId: TabId, closeBar: (d: OrbitState) => void) => boolean)[] } /** Everything a feature module needs. Passed to each module's register(). */ diff --git a/src/main/split/index.ts b/src/main/split/index.ts index 3b829a64..643a14af 100644 --- a/src/main/split/index.ts +++ b/src/main/split/index.ts @@ -1,6 +1,7 @@ import { clipboard, Menu, type MenuItemConstructorOptions, nativeTheme } from 'electron' import { FAVORITES_FULL_MESSAGE } from '../../shared/defaults' import { newId } from '../../shared/ids' +import { windowMayShow } from '../../shared/private' import { equalizeLayout, flipLayout, @@ -23,6 +24,7 @@ import { isAwayFromPinned } from '../../shared/tabs' import { paletteFor } from '../../shared/theme' import { insertItem, locate, sectionOf, splitOfTab } from '../../shared/tree' import type { MainContext } from '../context' +import { isUnplacedImportTab } from '../import/unplaced' import { SPLIT_PANE_MENU_KEYS, withKeyHints } from '../keys/menu-hints' import type { CallContext } from '../registry' import { makeTab } from '../tabs/model' @@ -80,6 +82,21 @@ interface PaneSession { /** How long an empty pane waits for the command bar's choice before it is removed. */ const SETTLE_GRACE_MS = 120 +/** An open tab the command bar chose, with the change that closes the bar. */ +interface BarChoice { + tabId: TabId + close: (d: OrbitState) => void +} + +/** Selecting `tabId` keeps the window in its Space (as `activateInDraft` decides), and the window may show it. */ +function selectsInPlace(state: OrbitState, windowId: WindowId, tabId: TabId): boolean { + const win = state.windows[windowId] + const tab = state.tabs[tabId] + if (!win || !tab || !windowMayShow(state, windowId, tabId) || isUnplacedImportTab(tabId)) return false + if (tab.spaceId === win.spaceId) return true + return sectionOf(state, tabId) === 'favorite' && state.spaces[win.spaceId]?.profileId === tab.profileId +} + /** Split feature (main process). Owned by the split lane; see docs/ARCHITECTURE.md. */ export function registerSplit(ctx: MainContext): void { const { store, commands, actions, windows } = ctx @@ -257,8 +274,12 @@ export function registerSplit(ctx: MainContext): void { else settle(session, { cancel: true }) } - /** Ends a pane session once its command bar closed: keep, fill, swap in or remove the pane. */ - const settle = (session: PaneSession, opts: { cancel?: boolean } = {}) => { + /** + * Ends a pane session once its command bar closed: keep, fill, swap in or remove the pane. With a + * `choice`, the bar is still open and has just chosen that open tab: the bar closes in the same + * store update that carries the choice out, so no frame shows the tab outside the split (SV-02). + */ + const settle = (session: PaneSession, opts: { cancel?: boolean; choice?: BarChoice } = {}) => { const windowId = session.windowId if (sessions.get(windowId) !== session) return sessions.delete(windowId) @@ -267,17 +288,23 @@ export function registerSplit(ctx: MainContext): void { const tab = state.tabs[session.tabId] const splitId = splitOfTab(state, session.tabId) if (!tab || !splitId) return - const chosen = opts.cancel ? null : (session.chosen ?? null) + const close = opts.choice?.close ?? (() => {}) + const chosen = opts.cancel ? null : (opts.choice?.tabId ?? session.chosen ?? null) // A tab that existed when the bar closed was picked from its results; a tab the choice // created (e.g. "open in new tab") was not, and is left alone. - const picked = !!chosen && !!session.tabsAtClose?.[chosen] + const picked = !!chosen && (!!opts.choice || !!session.tabsAtClose?.[chosen]) const chosenIsToday = picked && sectionOf(state, chosen) === 'today' && !splitOfTab(state, chosen) if (!session.blank) { // ⌘L on a pane, then an open Today tab was chosen: it takes the pane's place (SV-15). if (!chosen || !chosenIsToday) return updateMoving([chosen], (d) => { - if (!swapIntoPaneInDraft(d, session.tabId, chosen)) return + close(d) + if (!swapIntoPaneInDraft(d, session.tabId, chosen)) { + // The bar left the choice to this session: it is still made. + if (opts.choice) focusInDraft(d, windowId, chosen) + return + } restoreOtherSpaces(d, session, chosen) focusInDraft(d, windowId, chosen) }) @@ -287,6 +314,7 @@ export function registerSplit(ctx: MainContext): void { if (!opts.cancel && !isBlankPane(tab)) { // Filled with a URL or search: a pinned split remembers it as the pane's pinned URL. store.update((d) => { + close(d) const split = d.splits[splitId] if (split?.pending === session.tabId) delete split.pending adoptPlacement(d, [session.tabId], locate(d, splitId)?.container) @@ -297,6 +325,7 @@ export function registerSplit(ctx: MainContext): void { if (chosen && chosenIsToday) { // An open tab was chosen: it moves into the new pane (SV-02). updateMoving([chosen], (d) => { + close(d) detachTab(d, chosen) const split = d.splits[splitId] if (!split) return @@ -315,6 +344,7 @@ export function registerSplit(ctx: MainContext): void { const url = state.tabs[chosen]?.url if (url && !isBlankPane({ url })) { store.update((d) => { + close(d) const blank = d.tabs[session.tabId] const split = d.splits[splitId] if (split?.pending === session.tabId) delete split.pending @@ -333,6 +363,7 @@ export function registerSplit(ctx: MainContext): void { // Nothing chosen (Escape, click outside) or a new tab was opened instead: the empty pane // goes away. Focus returns to the previous pane unless something else was selected since. store.update((d) => { + close(d) const stillOnBlank = activeTabOf(d, windowId) === session.tabId detachTab(d, session.tabId) delete d.tabs[session.tabId] @@ -353,6 +384,23 @@ export function registerSplit(ctx: MainContext): void { } } + // The bar chose an open tab while it fills a pane (SV-02, SV-03, SV-07) or changes one (SV-15): the + // session carries the choice out itself. A choice that takes the window to another Space, or that + // the window can't show, goes the usual way: the bar closes, selects it, and the session settles. + ctx.hooks.barChoice ??= [] + ctx.hooks.barChoice.push((windowId, chosen, close) => { + const session = sessions.get(windowId) + const state = store.state + const overlay = state.windows[windowId]?.overlay + if (!session || session.settling || overlay?.kind !== 'commandbar' || overlay.nonce !== session.nonce) return false + const tab = state.tabs[session.tabId] + if (!tab || !splitOfTab(state, session.tabId) || !selectsInPlace(state, windowId, chosen)) return false + const today = sectionOf(state, chosen) === 'today' && !splitOfTab(state, chosen) + if (session.blank ? !isBlankPane(tab) : !today) return false + settle(session, { choice: { tabId: chosen, close } }) + return true + }) + // Watch command bar sessions: open on a pane → track; closed → settle right after the choice ran. store.subscribe((next) => { for (const session of [...sessions.values()]) { diff --git a/src/renderer/src/shell/split/engine.ts b/src/renderer/src/shell/split/engine.ts index 88264ae9..e36e4573 100644 --- a/src/renderer/src/shell/split/engine.ts +++ b/src/renderer/src/shell/split/engine.ts @@ -15,6 +15,7 @@ import { type PaneSide, paneDropZone, removePane, + replacePane, type SplitAxis, sameLayout, setSizesAt, @@ -230,11 +231,29 @@ export class PaneEngine { if (!sameLayout(this.model.layout, model.layout)) this.preview = null } else if (this.preview && after.includes(this.preview.tabId)) this.preview = null else if (this.preview?.link && after.some((id) => !before.includes(id))) this.preview = null + if (related) this.handOver(this.model.layout, model.layout, before, after) this.model = model this.ready = true this.relayout(related && !prefersReducedMotion()) } + /** + * A pane whose tab was replaced where it is (an open tab chosen for an empty pane, SV-02; ⌘L on a + * pane, SV-15): the new tab takes over its card as it is, instead of growing in from a sliver + * while the old card fades. + */ + private handOver(old: SplitLayout | null, next: SplitLayout | null, before: TabId[], after: TabId[]): void { + const gone = before.filter((id) => !after.includes(id)) + const added = after.filter((id) => !before.includes(id)) + const from = gone[0] + const to = added[0] + if (!old || !next || gone.length !== 1 || added.length !== 1 || !from || !to) return + const anim = this.anims.get(from) + if (!anim || this.anims.has(to) || !sameLayout(replacePane(old, from, to), next)) return + this.anims.delete(from) + this.anims.set(to, anim) + } + /** Drop indicator for a tab dragged over the frame (SV-04), or null to clear it. */ setPreview(preview: DropPreview | null): void { const same = diff --git a/tests/e2e/feel-fix.spec.ts b/tests/e2e/feel-fix.spec.ts new file mode 100644 index 00000000..f3ec34a3 --- /dev/null +++ b/tests/e2e/feel-fix.spec.ts @@ -0,0 +1,313 @@ +/** + * Frames the "feels right" sign-off held back (reviews/feel-signoff-1.md), each checked where the + * gap showed: in main, where the page views are, or in the renderers, frame by frame. Each test logs + * its numbers (`[feel]` lines) beside their targets. + */ +import { type ElectronApplication, expect, type Page, test } from '@playwright/test' +import { note } from './frames' +import { action, type Fixtures, launchOrbit, MOD, type Orbit, run, startFixtures, state, viewPage } from './helpers' + +let fx: Fixtures +let orbit: Orbit + +test.describe.configure({ timeout: 120_000 }) + +test.beforeAll(async () => { + fx = await startFixtures() +}) +test.afterAll(async () => { + await fx.close() +}) +test.beforeEach(async () => { + orbit = await launchOrbit() +}) +test.afterEach(async () => { + await orbit.app.close() +}) + +async function openTab(name: string, activate = true): Promise { + return (await run(orbit.app, 'tabs.create', { url: fx.url(`/page/${name}`), activate })) as string +} + +type Box = { x: number; y: number; width: number; height: number } + +const near = (a: Box | undefined | null, b: Box | undefined | null, tol = 1.5) => + !!a && + !!b && + Math.abs(a.x - b.x) <= tol && + Math.abs(a.y - b.y) <= tol && + Math.abs(a.width - b.width) <= tol && + Math.abs(a.height - b.height) <= tol + +// ---- Page views, sampled in main ---- + +interface ViewState { + bounds: Box + visible: boolean + /** A child of the window's content view (it can draw). */ + attached: boolean +} +interface ViewSample { + t: number + views: Record +} + +/** + * Records, in main, every tab view of the first window: where it is, whether it is shown, whether it + * is attached. A sample is taken once the code that moved, showed, hid, attached or removed a view + * has run through (what the next frame draws), and every 4 ms besides. Returns a reader that stops it. + */ +async function watchViews(app: ElectronApplication): Promise<() => Promise> { + await app.evaluate(() => { + type View = Electron.WebContentsView & Record + type Orbit = { + windowIds: () => string[] + state: () => { tabs: Record } + ctx: { + tabs: { get: (id: string) => View | undefined } + windows: { get: (id: string) => Electron.BrowserWindow | undefined } + } + } + const g = globalThis as unknown as { + __orbit: Orbit + __feelFixViews?: { samples: ViewSample[]; stop: () => void } + } + g.__feelFixViews?.stop() + const o = g.__orbit + const win = o.ctx.windows.get(o.windowIds()[0] as string) + if (!win) throw new Error('no window') + const samples: ViewSample[] = [] + let queued = false + const snap = () => { + queued = false + if (win.isDestroyed()) return + const children = win.contentView.children + const views: Record = {} + for (const id of Object.keys(o.state().tabs)) { + const view = o.ctx.tabs.get(id) + if (!view) continue + views[id] = { bounds: view.getBounds(), visible: view.getVisible(), attached: children.includes(view) } + } + samples.push({ t: performance.now(), views }) + } + const later = () => { + if (queued) return + queued = true + queueMicrotask(snap) + } + const undo: (() => void)[] = [] + const wrap = (target: object, name: string) => { + const host = target as Record + const original = host[name] as (...args: unknown[]) => unknown + host[name] = function (this: unknown, ...args: unknown[]) { + const result = original.apply(this, args) + later() + return result + } + undo.push(() => { + host[name] = original + }) + } + wrap(win.contentView, 'addChildView') + wrap(win.contentView, 'removeChildView') + for (const id of Object.keys(o.state().tabs)) { + const view = o.ctx.tabs.get(id) + if (!view) continue + wrap(view, 'setBounds') + wrap(view, 'setVisible') + } + const timer = setInterval(snap, 4) + g.__feelFixViews = { + samples, + stop: () => { + clearInterval(timer) + for (const fn of undo.splice(0)) fn() + }, + } + snap() + }) + return () => + app.evaluate(() => { + const g = globalThis as unknown as { __feelFixViews?: { samples: ViewSample[]; stop: () => void } } + g.__feelFixViews?.stop() + const samples = g.__feelFixViews?.samples ?? [] + g.__feelFixViews = undefined + return samples + }) +} + +const shown = (v: ViewState | undefined): v is ViewState => !!v && v.visible && v.attached + +function union(boxes: Box[]): Box { + const x = Math.min(...boxes.map((b) => b.x)) + const y = Math.min(...boxes.map((b) => b.y)) + const right = Math.max(...boxes.map((b) => b.x + b.width)) + const bottom = Math.max(...boxes.map((b) => b.y + b.height)) + return { x, y, width: right - x, height: bottom - y } +} + +/** The space between two boxes: positive when they are apart, negative when they overlap. */ +function gapBetween(a: Box, b: Box): number { + const across = Math.max(b.x - (a.x + a.width), a.x - (b.x + b.width)) + const down = Math.max(b.y - (a.y + a.height), a.y - (b.y + b.height)) + return Math.max(across, down) +} + +const inside = (a: Box, area: Box, tol = 1) => + a.x >= area.x - tol && + a.y >= area.y - tol && + a.x + a.width <= area.x + area.width + tol && + a.y + a.height <= area.y + area.height + tol + +// ---- SV-02, SV-03, SV-07, SV-15: an open tab chosen for a pane ---- + +/** The tabs of the split that holds `tabId`, in layout order. */ +async function splitTabs(app: ElectronApplication, tabId: string): Promise { + const s = await state(app) + for (const split of Object.values(s.splits)) { + const ids: string[] = JSON.stringify(split.layout).match(/t-[a-z0-9]+/g) ?? [] + if (ids.includes(tabId)) return ids + } + return [] +} + +/** Waits until each tab's page view is shown over its pane card and the cards hold still. */ +async function settledOver(o: Orbit, tabIds: string[]): Promise { + await expect + .poll( + async () => { + const cards = await o.shell.evaluate(async (ids) => { + const boxes = () => + ids.map((id) => { + const el = document.querySelector(`[data-testid="split-pane"][data-tab-id="${id}"]`) + const r = el?.getBoundingClientRect() + return r ? { x: r.x, y: r.y, width: r.width, height: r.height } : null + }) + const before = boxes() + await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r))) + const after = boxes() + return JSON.stringify(before) === JSON.stringify(after) ? after : null + }, tabIds) + if (!cards) return false + const views = await o.app.evaluate((_e, ids) => { + const hooks = ( + globalThis as unknown as { + __orbit: { tabView: (id: string) => { bounds: Box; visible: boolean } | null } + } + ).__orbit + return ids.map((id) => hooks.tabView(id)) + }, tabIds) + return tabIds.every((_, i) => views[i]?.visible === true && near(views[i]?.bounds, cards[i])) + }, + { message: `views of ${tabIds.join(', ')} over their panes` }, + ) + .toBe(true) +} + +/** + * Chooses an open tab in the command bar (already open for a pane) with Enter, sampling the page + * views in main from just before Enter until the split has settled. Returns what was seen. + */ +async function chooseOpenTab(overlay: Page, text: string, chosen: string, panes: string[]) { + const input = overlay.getByTestId('command-input') + await expect(input).toBeFocused() + await input.fill(text) + const first = overlay.getByTestId('command-result').first() + await expect(first).toHaveAttribute('data-kind', 'tab') + await expect(first).toContainText(text) + const read = await watchViews(orbit.app) + await input.press('Enter') + await expect.poll(() => splitTabs(orbit.app, panes[0] as string)).toEqual(expect.arrayContaining(panes)) + await settledOver(orbit, panes) + const samples = await read() + const end = samples.at(-1) + const area = union(panes.map((id) => end?.views[id]?.bounds).filter((b): b is Box => !!b)) + const others = panes.filter((id) => id !== chosen) + // Each sample: the chosen tab's view never covers the split's whole area; every shown pane's view + // stays inside it, FL-18's 8 px gutter apart; the other panes stay attached and shown. + const alone = samples.filter((s) => { + const v = s.views[chosen] + return shown(v) && v.bounds.width >= area.width - 8 && v.bounds.height >= area.height - 8 + }) + const outside = samples.filter((s) => panes.some((id) => shown(s.views[id]) && !inside(s.views[id].bounds, area))) + const crowded = samples.filter((s) => { + const live = panes.map((id) => s.views[id]).filter(shown) + return live.some((a, i) => live.some((b, j) => j > i && gapBetween(a.bounds, b.bounds) < 7)) + }) + const dropped = samples.filter((s) => others.some((id) => !shown(s.views[id]))) + // Once shown, the chosen tab's view keeps the pane's place: it takes the empty pane's card as it is. + const firstShown = samples.findIndex((s) => shown(s.views[chosen])) + const resized = samples + .slice(Math.max(0, firstShown)) + .filter((s) => shown(s.views[chosen]) && !near(s.views[chosen]?.bounds, end?.views[chosen]?.bounds, 1)) + return { samples: samples.length, alone, outside, crowded, dropped, resized, area } +} + +test('[SV-02][SV-03][SV-07][SV-15] an open tab chosen for a pane goes straight into it: no frame shows it alone', async () => { + const metrics = await openTab('Metrics') + const standup = await openTab('Standup') + const design = await openTab('Design') + const notes = await openTab('Notes') + const base = await openTab('Changelog') + await settledOver(orbit, [base]) + const overlay = await viewPage(orbit.app, 'overlay') + const input = overlay.getByTestId('command-input') + const check = (round: string, seen: Awaited>) => { + const span = (list: { t: number }[]) => ((list.at(-1)?.t ?? 0) - (list[0]?.t ?? 0)) as number + note(round, 'samples with the chosen tab covering the split', seen.alone.length, '0', '') + note(round, 'chosen tab shown alone for', seen.alone.length ? span(seen.alone) : 0, '0 ms') + expect(seen.samples, round).toBeGreaterThan(5) + expect( + seen.alone.map((s) => s.t), + `${round}: the chosen tab covers the split`, + ).toEqual([]) + expect( + seen.outside.map((s) => s.t), + `${round}: a pane's view leaves the split`, + ).toEqual([]) + expect( + seen.crowded.map((s) => s.t), + `${round}: two panes' views closer than the gutter`, + ).toEqual([]) + expect( + seen.dropped.map((s) => s.t), + `${round}: another pane's view went away`, + ).toEqual([]) + expect( + seen.resized.map((s) => s.t), + `${round}: the chosen tab's view changed size once shown`, + ).toEqual([]) + } + + // SV-02: ⌃⇧= adds an empty pane on the right with the command bar; Enter on an open tab fills it. + await orbit.shell.keyboard.press('Control+Shift+Equal') + await expect(input).toBeFocused() + await expect.poll(async () => (await splitTabs(orbit.app, base)).length).toBe(2) + await settledOver(orbit, [base]) + check('SV-02', await chooseOpenTab(overlay, 'Metrics', metrics, [base, metrics])) + + // SV-03: Add Split Below from the command bar puts the next empty pane under the focused one. + await orbit.shell.keyboard.press(`${MOD}+T`) + await expect(input).toBeFocused() + await input.fill('split below') + await expect(overlay.getByTestId('command-result').first()).toContainText('Add Split Below') + await input.press('Enter') + await expect(overlay.getByTestId('command-bar')).toHaveAttribute('data-mode', 'edit-url') + await expect.poll(async () => (await splitTabs(orbit.app, base)).length).toBe(3) + await settledOver(orbit, [base, metrics]) + check('SV-03', await chooseOpenTab(overlay, 'Standup', standup, [base, metrics, standup])) + + // SV-07: the "+" menu's New Split View runs the same action as ⌃⇧=. + await action(orbit.app, 'split.add') + await expect(input).toBeFocused() + await expect.poll(async () => (await splitTabs(orbit.app, base)).length).toBe(4) + await settledOver(orbit, [base, metrics, standup]) + check('SV-07', await chooseOpenTab(overlay, 'Design', design, [base, metrics, standup, design])) + + // SV-15: ⌘L on a pane, then an open tab: it takes that pane's place. + await orbit.shell.keyboard.press(`${MOD}+L`) + await expect(overlay.getByTestId('command-bar')).toHaveAttribute('data-mode', 'edit-url') + check('SV-15', await chooseOpenTab(overlay, 'Notes', notes, [base, metrics, standup, notes])) + expect(await splitTabs(orbit.app, base)).not.toContain(design) + expect(Object.keys((await state(orbit.app)).tabs)).toContain(design) +}) From b26b63d89602b0b097fc6e944d77879678ce0c08 Mon Sep 17 00:00:00 2001 From: Siya <57148521+siyabendoezdemir@users.noreply.github.com> Date: Sun, 11 Oct 2026 16:42:01 +0000 Subject: [PATCH 02/26] fix(media): back from the player, the page shows in its window only once it has drawn there (MD-01, MD-07, MD-16) Selecting the video's tab again, or Back to tab, attached the tab's view to its window at once. The view still held the frame it drew in the player, so the page area showed the player layout (black, the video small at its top-left) for 2-3 frames. The window manager can now hold a view that comes back into a window out of sight, at its place under the pages the window showed, until a feature says it is ready (holdArrivals, at most 250 ms). The player asks for it: the page's agent resolves once the page has drawn a frame with its video back in it. The pages shown before stay until then. Tests: media.spec.ts checks every frame of the return in main (the view is never shown before the page drew its own layout) and, where an ffmpeg with x11grab exists, a grab of the screen (no frame of the player layout in the page area, no empty player). MD-16's note says what Back to tab does now. Co-Authored-By: Claude Opus 5.5 --- FEATURES.md | 4 +- docs/feature-status.json | 4 +- src/main/media/agent.ts | 15 ++ src/main/media/manager.ts | 2 + src/main/media/player.ts | 20 ++- src/main/windows.ts | 87 ++++++++++- tests/e2e/media.spec.ts | 316 +++++++++++++++++++++++++++++++++++++- 7 files changed, 434 insertions(+), 14 deletions(-) diff --git a/FEATURES.md b/FEATURES.md index 87e7cbfc..2ab0bd66 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -305,7 +305,7 @@ Spec: `spec/media.md` | ID | Feature | P | Built | Tested | Feels right | Notes | |---|---|---|---|---|---|---| -| MD-01 | Automatic picture-in-picture when leaving a video tab | P0 | ✅ | ✅ e2e+unit | – | Orbit's own floating player: leaving a tab whose main video plays (tab, Space or split change; the largest playing video, in the page or in any of its frames, cross-site ones included) moves it there within ~300 ms, still playing; not when paused, muted or turned off (MD-08); selecting the tab again puts it back in the page (or its frame) with no blank frame, at any load (a frame's page does it from its own size, before its first frame back is drawn); moving between a split's panes doesn't float it. A frame inside a shadow tree can't be found from its page: its video plays on in the tab and in the sidebar controller instead. Known limit (D71): a page's own picture-in-picture button inside a frame returns to the tab without raising the window; the tab is selected only when its window already has focus, in the Space it shows, and once for each frame document (D74). Known limit (D74): a page can end its own picture-in-picture once with exitPictureInPicture() and bring the window forward, as the engine's Back to tab button does; moving it to another of its videos brings nothing forward. Known limit (D78): while a frame's video is in the player, the frame's page can see a hidden div the size of its viewport under , which the frame's script watches, with the root element, to put the video back before the frame is drawn; when the frame's own script, not main, puts a same-site frame's video back, the page may get one "ResizeObserver loop completed with undelivered notifications" error event, and then its own observers of or get the final size a frame later, as they did before | +| MD-01 | Automatic picture-in-picture when leaving a video tab | P0 | ✅ | ✅ e2e+unit | – | Orbit's own floating player: leaving a tab whose main video plays (tab, Space or split change; the largest playing video, in the page or in any of its frames, cross-site ones included) moves it there within ~300 ms, still playing; not when paused, muted or turned off (MD-08); selecting the tab again puts it back in the page (or its frame) with no blank frame, at any load (a frame's page does it from its own size, before its first frame back is drawn), and the page shows in its window only once it has drawn there, so no frame of its player layout reaches the screen (meanwhile, for at most 250 ms, the page shown before stays); moving between a split's panes doesn't float it. A frame inside a shadow tree can't be found from its page: its video plays on in the tab and in the sidebar controller instead. Known limit (D71): a page's own picture-in-picture button inside a frame returns to the tab without raising the window; the tab is selected only when its window already has focus, in the Space it shows, and once for each frame document (D74). Known limit (D74): a page can end its own picture-in-picture once with exitPictureInPicture() and bring the window forward, as the engine's Back to tab button does; moving it to another of its videos brings nothing forward. Known limit (D78): while a frame's video is in the player, the frame's page can see a hidden div the size of its viewport under , which the frame's script watches, with the root element, to put the video back before the frame is drawn; when the frame's own script, not main, puts a same-site frame's video back, the page may get one "ResizeObserver loop completed with undelivered notifications" error event, and then its own observers of or get the final size a frame later, as they did before | | MD-02 | Open picture-in-picture manually | P1 | 🟡 partial | ✅ e2e | – | Picture in Picture in a video's page right-click menu, the Picture in Picture action and the tray's PiP button; while the tab stays selected the engine's own PiP window shows the video (Orbit's player needs the tab's view), so its controls are the engine's until the tab is left and Orbit's player takes over | | MD-03 | The floating player's look and placement | P0 | ✅ | ✅ e2e+unit | ✅ | frameless, always-on-top window, 320x180 for 16:9 (keeps the video's shape), bottom-right 16 px inside the work area; rounded corners (a window shape where the system has none) and shadow; video only until hovered, then controls over a dark gradient; the same player on every platform | | MD-04 | Floating player controls | P0 | ✅ | ✅ e2e+unit | ✅ | on hover: play/pause, +/-15 s, progress with elapsed and total time (seekable; LIVE badge for streams), Back to tab (selects it in its window and Space), x (keeps playing in the sidebar), ... menu (playback speed, turn off today / always for the site), previous/next for Media Session pages; double-click goes back to the tab | @@ -320,7 +320,7 @@ Spec: `spec/media.md` | MD-13 | Video calls: automatic call picture-in-picture | P1 | 🟡 partial | ✅ e2e | – | leaving a call tab floats the largest participant video (never the self view) in Orbit's player with microphone/camera toggles and Back to call tab; moves and resizes like any video (a pinch zooms, as for any video). Not built: leave call (the site's Media Session hang-up handler lives in the page's own script world, which Orbit reaches only through a page patch, a lead decision under D40), a chat toggle (no web API exposes a site's chat), and the site's own document picture-in-picture view (also only through the page's Media Session) | | MD-14 | Call controls in the sidebar | P1 | 🟡 partial | ✅ e2e+unit | – | a background call tab (or a closed call window) gets a sidebar call controller: microphone and camera toggles (track.enabled on the tracks the page shows) and jump back. Not built: a call is seen only when its page shows its own camera (a self view); an audio-only call isn't, because the browser process can't see what a page captures and watching getUserMedia takes a page patch (a lead decision under D40) | | MD-15 | Switch which tab is being screen-shared | P2 | – | – | – | not possible as specified: Orbit's screen chooser shares a screen or a window, never a single tab, and the engine can't change what a running share captures (switching the shared tab is the Chrome browser's own feature, not in Electron); swapping the call page's video track would take a page patch, a lead decision under D40 | -| MD-16 | Feel: entering and leaving picture-in-picture | P1 | ✅ | ✅ e2e+unit | – | enter: the video shrinks from its place in the page to the corner (~300 ms, standard curve); Back to tab/double-click: the tab is selected at once and a still of the last frame flies back into the page; release glides to the corner; Back to PiP grows from the controller; tray slides with the snappy spring; with reduced motion (FL-28) the player appears and goes without motion and a release settles with a 120 ms ease-out. Not possible: a haptic tick on macOS (Electron has no haptics; it would take native code), and fading in the engine's own PiP window, which shows only while the tab stays selected (MD-02). Only a person can check playback for stutter | +| MD-16 | Feel: entering and leaving picture-in-picture | P1 | ✅ | ✅ e2e+unit | – | enter: the video shrinks from its place in the page to the corner (~300 ms, standard curve); Back to tab/double-click: the tab is selected at once, and the player's window, showing a still of the last frame, flies to the video's place in the page (220 ms, over the window: always on top) and hides; under Xvfb, with no window manager, the selected window comes over the player, so the flight can't be seen there; release glides to the corner; Back to PiP grows from the controller; tray slides with the snappy spring; with reduced motion (FL-28) the player appears and goes without motion and a release settles with a 120 ms ease-out. Not possible: a haptic tick on macOS (Electron has no haptics; it would take native code), and fading in the engine's own PiP window, which shows only while the tab stays selected (MD-02). Only a person can check playback for stutter | ## Archive, recently closed, history and sidebar backups (prefix AR) diff --git a/docs/feature-status.json b/docs/feature-status.json index 8c7f8e0b..79743136 100644 --- a/docs/feature-status.json +++ b/docs/feature-status.json @@ -216,7 +216,7 @@ "FL-02": {"built": "yes", "note": "260 ms standard curve drives the sidebar column and the card margins together; interruptible; reduced motion snaps; starts a frame along, so the frame that draws ⌘S already moves"}, "FL-03": {"built": "yes", "note": "0 ms dwell; card in 200 ms (snappy), out 160 ms (quick); hysteresis band; re-entering while it leaves brings it back"}, "AP-05": {"built": "yes", "note": "private windows are near-black whatever the theme (frame, sidebar, window background) with hover and selection tuned for black; Private label with icon in the sidebar, not over the page; no Space switcher or Favorites"}, - "MD-01": {"built": "yes", "note": "Orbit's own floating player: leaving a tab whose main video plays (tab, Space or split change; the largest playing video, in the page or in any of its frames, cross-site ones included) moves it there within ~300 ms, still playing; not when paused, muted or turned off (MD-08); selecting the tab again puts it back in the page (or its frame) with no blank frame, at any load (a frame's page does it from its own size, before its first frame back is drawn); moving between a split's panes doesn't float it. A frame inside a shadow tree can't be found from its page: its video plays on in the tab and in the sidebar controller instead. Known limit (D71): a page's own picture-in-picture button inside a frame returns to the tab without raising the window; the tab is selected only when its window already has focus, in the Space it shows, and once for each frame document (D74). Known limit (D74): a page can end its own picture-in-picture once with exitPictureInPicture() and bring the window forward, as the engine's Back to tab button does; moving it to another of its videos brings nothing forward. Known limit (D78): while a frame's video is in the player, the frame's page can see a hidden div the size of its viewport under , which the frame's script watches, with the root element, to put the video back before the frame is drawn; when the frame's own script, not main, puts a same-site frame's video back, the page may get one \"ResizeObserver loop completed with undelivered notifications\" error event, and then its own observers of or get the final size a frame later, as they did before"}, + "MD-01": {"built": "yes", "note": "Orbit's own floating player: leaving a tab whose main video plays (tab, Space or split change; the largest playing video, in the page or in any of its frames, cross-site ones included) moves it there within ~300 ms, still playing; not when paused, muted or turned off (MD-08); selecting the tab again puts it back in the page (or its frame) with no blank frame, at any load (a frame's page does it from its own size, before its first frame back is drawn), and the page shows in its window only once it has drawn there, so no frame of its player layout reaches the screen (meanwhile, for at most 250 ms, the page shown before stays); moving between a split's panes doesn't float it. A frame inside a shadow tree can't be found from its page: its video plays on in the tab and in the sidebar controller instead. Known limit (D71): a page's own picture-in-picture button inside a frame returns to the tab without raising the window; the tab is selected only when its window already has focus, in the Space it shows, and once for each frame document (D74). Known limit (D74): a page can end its own picture-in-picture once with exitPictureInPicture() and bring the window forward, as the engine's Back to tab button does; moving it to another of its videos brings nothing forward. Known limit (D78): while a frame's video is in the player, the frame's page can see a hidden div the size of its viewport under , which the frame's script watches, with the root element, to put the video back before the frame is drawn; when the frame's own script, not main, puts a same-site frame's video back, the page may get one \"ResizeObserver loop completed with undelivered notifications\" error event, and then its own observers of or get the final size a frame later, as they did before"}, "MD-02": {"built": "partial", "note": "Picture in Picture in a video's page right-click menu, the Picture in Picture action and the tray's PiP button; while the tab stays selected the engine's own PiP window shows the video (Orbit's player needs the tab's view), so its controls are the engine's until the tab is left and Orbit's player takes over"}, "MD-03": {"built": "yes", "feel": true, "note": "frameless, always-on-top window, 320x180 for 16:9 (keeps the video's shape), bottom-right 16 px inside the work area; rounded corners (a window shape where the system has none) and shadow; video only until hovered, then controls over a dark gradient; the same player on every platform"}, "MD-04": {"built": "yes", "feel": true, "note": "on hover: play/pause, +/-15 s, progress with elapsed and total time (seekable; LIVE badge for streams), Back to tab (selects it in its window and Space), x (keeps playing in the sidebar), ... menu (playback speed, turn off today / always for the site), previous/next for Media Session pages; double-click goes back to the tab"}, @@ -231,7 +231,7 @@ "MD-13": {"built": "partial", "note": "leaving a call tab floats the largest participant video (never the self view) in Orbit's player with microphone/camera toggles and Back to call tab; moves and resizes like any video (a pinch zooms, as for any video). Not built: leave call (the site's Media Session hang-up handler lives in the page's own script world, which Orbit reaches only through a page patch, a lead decision under D40), a chat toggle (no web API exposes a site's chat), and the site's own document picture-in-picture view (also only through the page's Media Session)"}, "MD-14": {"built": "partial", "note": "a background call tab (or a closed call window) gets a sidebar call controller: microphone and camera toggles (track.enabled on the tracks the page shows) and jump back. Not built: a call is seen only when its page shows its own camera (a self view); an audio-only call isn't, because the browser process can't see what a page captures and watching getUserMedia takes a page patch (a lead decision under D40)"}, "MD-15": {"built": "not started", "note": "not possible as specified: Orbit's screen chooser shares a screen or a window, never a single tab, and the engine can't change what a running share captures (switching the shared tab is the Chrome browser's own feature, not in Electron); swapping the call page's video track would take a page patch, a lead decision under D40"}, - "MD-16": {"built": "yes", "note": "enter: the video shrinks from its place in the page to the corner (~300 ms, standard curve); Back to tab/double-click: the tab is selected at once and a still of the last frame flies back into the page; release glides to the corner; Back to PiP grows from the controller; tray slides with the snappy spring; with reduced motion (FL-28) the player appears and goes without motion and a release settles with a 120 ms ease-out. Not possible: a haptic tick on macOS (Electron has no haptics; it would take native code), and fading in the engine's own PiP window, which shows only while the tab stays selected (MD-02). Only a person can check playback for stutter"}, + "MD-16": {"built": "yes", "note": "enter: the video shrinks from its place in the page to the corner (~300 ms, standard curve); Back to tab/double-click: the tab is selected at once, and the player's window, showing a still of the last frame, flies to the video's place in the page (220 ms, over the window: always on top) and hides; under Xvfb, with no window manager, the selected window comes over the player, so the flight can't be seen there; release glides to the corner; Back to PiP grows from the controller; tray slides with the snappy spring; with reduced motion (FL-28) the player appears and goes without motion and a release settles with a 120 ms ease-out. Not possible: a haptic tick on macOS (Electron has no haptics; it would take native code), and fading in the engine's own PiP window, which shows only while the tab stays selected (MD-02). Only a person can check playback for stutter"}, "FL-32": {"built": "yes", "note": "corner resize follows the pointer, resists past its limits (rubber band) and springs back on release; a fling heads for the corner in its direction and settles with the spring; tray slides up with the snappy timing; Back to tab flies to the video's place; pinch zooms (MD-06) rather than resizes"}, "KS-11": {"built": "partial", "note": "Space and arrows in the focused player, Cmd/Ctrl+scroll and pinch zoom (tested), hardware media keys via Chromium (untested), Mute Tab action assignable; the calendar meeting shortcut (P2) belongs to TB-21/FV-09"}, "KS-12": {"built": "partial", "note": "⌘Y / Ctrl+H View History (AR-10); ⌘⇧L Open Library on macOS, the command bar elsewhere; View Archive with no key, assignable; ⌘, / Ctrl+, Settings, which cannot be remapped, and again brings the same Settings window to the front; ⌘⇧⌫ / Ctrl+Shift+Delete Clear Browsing Data…; Shortcut Settings (command bar, assignable); the shortcut sheet from Help → Keyboard Shortcuts (macOS) and the command bar (OB-12); Open File… (macOS File menu, the command bar) and ⌘O / Ctrl+O wherever no Peek or Mini Window is open: the files chosen open in new tabs, the last one selected (Settings → Shortcuts says so under Open File…, which can also get keys of its own); with no Orbit window shown, the macOS menu bar takes ⌘O itself, so Open File… still opens (F6); Alt+F stays free for the Windows/Linux app menu (SB-03, SB-26, not built). Not built: Open Downloads (DL-14, downloads lane), pinned extensions (EX-06)"}, diff --git a/src/main/media/agent.ts b/src/main/media/agent.ts index b8a76b8c..a9f1d045 100644 --- a/src/main/media/agent.ts +++ b/src/main/media/agent.ts @@ -619,6 +619,20 @@ const agentScript = (handle: string, define: string, frame: boolean) => `(() => return true } + // Back in its window (MD-01): resolves once the page has drawn a frame with its video back in it. + // The main process shows the view only then: until the page draws at its new size, the view + // shows the last frame it drew, in the player's layout. (rAF, then a task: after that frame.) + function drawn() { + return new Promise((resolve) => { + let n = 0 + const tick = () => { + if (lift && lift.shown && ++n < 60) requestAnimationFrame(tick) + else requestAnimationFrame(() => setTimeout(() => resolve(!lift || !lift.shown), 0)) + } + requestAnimationFrame(tick) + }) + } + // A video removed from the page leaves the top layer at once: the player has to close (MD-07). function watchRemoval(v) { if (removal) removal.disconnect() @@ -765,6 +779,7 @@ const agentScript = (handle: string, define: string, frame: boolean) => `(() => size: sizeVideo, zoom: zoomVideo, drop, + drawn, lifted: () => !!lift, index, frameBox, diff --git a/src/main/media/manager.ts b/src/main/media/manager.ts index 97ba9353..3222bb8e 100644 --- a/src/main/media/manager.ts +++ b/src/main/media/manager.ts @@ -792,6 +792,8 @@ export class MediaManager { const target = agents[agents.length - 1] if (!target) return undefined if (fn === 'lift' && chain && agents.length > 1) return (await this.liftChain(tabId, chain, args[0])) as T + // Back in its window: every document on the way has drawn with the video back in it (MD-01). + if (fn === 'drawn') return (await Promise.all(agents.map((a) => this.exec(a, 'drawn')))) as T if (fn === 'drop') { const done = await Promise.all(agents.map((a) => this.exec(a, 'drop'))) return done[done.length - 1] as T diff --git a/src/main/media/player.ts b/src/main/media/player.ts index 21662537..c4a404e3 100644 --- a/src/main/media/player.ts +++ b/src/main/media/player.ts @@ -6,9 +6,11 @@ * page's top layer at the player's size at the top-left of the page; the window shows just that * corner. Orbit's controls (renderer view "pip") sit in a transparent view on top. * - * Returning to the tab needs nothing from here: the window manager attaches the tab's view to its - * window again (taking it out of this one), and the page puts the video back on that very frame - * (see agent.ts). The player then just hides. Closing the player (×) hides it and leaves the view + * Returning to the tab: the window manager attaches the tab's view to its window again (taking it + * out of this one), and the page puts the video back on that very frame (see agent.ts). Until the + * page has drawn that layout, the view stays out of sight under the pages its window showed + * (arriving(), WindowManager.holdArrivals): never a frame of the player's layout in the page area + * (MD-01). The player then just hides. Closing the player (×) hides it and leaves the view * detached, like any background tab, so the media plays on as audio (MD-07). */ import { @@ -218,6 +220,7 @@ export class PipPlayer { screen.on('display-removed', () => this.refit()) screen.on('display-metrics-changed', () => this.refit()) + ctx.windows.holdArrivals((tabId, view) => this.arriving(tabId, view)) } /** The tab whose video is in the player. */ @@ -372,6 +375,17 @@ export class PipPlayer { return true } + /** + * The video's view goes back into a window (its tab was selected again, or Back to tab): it stays + * out of sight there until its page has put the video back and drawn at its size (MD-01). Until + * then the view would show the last frame the page drew: black, with the video small at its top-left. + */ + private arriving(tabId: TabId, view: WebContentsView): Promise | undefined { + const s = this.session + if (s?.tabId !== tabId || s.view !== view) return undefined + return this.host.call(tabId, 'drawn') + } + private isShown(tabId: TabId): boolean { return displayedTabs(this.ctx.store.state).has(tabId) } diff --git a/src/main/windows.ts b/src/main/windows.ts index b0ecad1e..ce0232f7 100644 --- a/src/main/windows.ts +++ b/src/main/windows.ts @@ -106,6 +106,11 @@ interface WindowRecord { * the prompt and the small views above stay under it, with the page (setPageCover). */ cover?: WebContentsView + /** + * Pages no longer wanted that stay shown, where they were, over a view held out of sight until + * its page has drawn (holdArrivals); they hide once it shows. + */ + lingering: Set } /** A tab view placed in a window. */ @@ -118,6 +123,8 @@ interface Attachment { /** How long the overlay view stays up after closing so its exit animation can play (FL-16). */ const OVERLAY_EXIT_MS = 110 +/** The longest a view coming back into a window stays out of sight, waiting for its page to draw (holdArrivals). */ +const HOLD_MS = 250 /** The page tools' renderer starts this long after the window shows (NV-05: ⌘F finds the bar ready). */ const TOOLS_PREWARM_MS = 1500 @@ -223,6 +230,9 @@ export class WindowManager { /** Tabs whose page closed its own web contents (window.close()): not reopened until selected again. */ private selfClosed = new Set() private floatingOwners: ((tabId: TabId) => WindowId | undefined)[] = [] + private arrivalHolds: ((tabId: TabId, view: WebContentsView) => Promise | undefined)[] = [] + /** Views held out of sight under their window's pages (holdArrivals). */ + private held = new Map }>() /** * Views that stay above the page tools wherever either goes: a page's own dialog or print dialog * over its page, in a tab or a Peek (pages lane, NV-24, NV-07; keepAboveTools()). @@ -293,6 +303,17 @@ export class WindowManager { this.floatingOwners.push(owner) } + /** + * A feature that lends a tab's view to a window of its own (the floating player) can hold it out + * of sight when it comes back into a window: until the promise it returns settles, or HOLD_MS. + * A view whose size changed shows the last frame its page drew until the page draws at the new + * size, so meanwhile it sits at its place under the pages the window showed, which stay (MD-01: + * never a frame of the player's layout in the page area). + */ + holdArrivals(hold: (tabId: TabId, view: WebContentsView) => Promise | undefined): void { + this.arrivalHolds.push(hold) + } + private floatingWindowOf(tabId: TabId): WindowId | undefined { for (const owner of this.floatingOwners) { const windowId = owner(tabId) @@ -423,6 +444,7 @@ export class WindowManager { promptShown: false, promptReady: false, uiViews: new Set(), + lingering: new Set(), } this.records.set(id, rec) // macOS: the traffic lights live in the sidebar's top row (SB-20). @@ -507,6 +529,7 @@ export class WindowManager { viewContents(rec.prompt)?.close() for (const view of rec.uiViews) viewContents(view)?.close() rec.uiViews.clear() + for (const [tabId, hold] of [...this.held]) if (hold.windowId === id) this.dropHold(tabId) this.records.delete(id) for (const [tabId, entry] of [...this.attached]) { if (entry.windowId === id) { @@ -674,12 +697,7 @@ export class WindowManager { // Native full screen has no frame: square corners (SB-19). const corner = this.ctx.store.state.windows[windowId]?.fullscreen ? 0 : radius const wanted = new Set(panes.map((p) => p.tabId)) - for (const tabId of rec.visible) { - if (wanted.has(tabId)) continue - // Only hide what this window holds: the view may have moved to another window since. - const entry = this.attached.get(tabId) - if (entry?.windowId === rec.id) entry.view.setVisible(false) - } + for (const [tabId, hold] of [...this.held]) if (hold.windowId === rec.id && !wanted.has(tabId)) this.dropHold(tabId) const shownBefore = rec.visible for (const pane of panes) { // A page that closed itself stays closed until the tab is selected again (onState). @@ -694,6 +712,18 @@ export class WindowManager { // A tab whose page crashed while in the background shows its crash screen when it is shown // again, until the user reloads it (NV-14: the pages lane's screen over the page). } + // While a view coming back is held out of sight (holdArrivals), the pages shown before stay over it. + const holding = [...this.held.values()].filter((h) => h.windowId === rec.id) + for (const tabId of new Set([...rec.visible, ...rec.lingering])) { + rec.lingering.delete(tabId) + if (wanted.has(tabId)) continue + // Only hide what this window holds: the view may have moved to another window since. + const entry = this.attached.get(tabId) + if (entry?.windowId !== rec.id) continue + if (holding.length > 0) rec.lingering.add(tabId) + else entry.view.setVisible(false) + } + for (const hold of holding) this.placeUnder(rec, hold.view) rec.visible = wanted if (panes.length === 1) rec.lastSingleRect = panes[0]?.rect if (rec.htmlFullscreen) this.layoutHtmlFullscreen(rec, wanted) @@ -1097,6 +1127,7 @@ export class WindowManager { /** Removes a (sleeping, closing or replaced) tab view from whatever window holds it. Safe for destroyed views. */ detachView(tabId: TabId, view: WebContentsView): void { + if (this.held.get(tabId)?.view === view) this.dropHold(tabId) const entry = this.attached.get(tabId) if (entry?.view === view) { this.attached.delete(tabId) @@ -1173,6 +1204,7 @@ export class WindowManager { const current = this.attached.get(tabId) const children = rec.win.contentView.children if (current?.view === view && current.windowId === rec.id && children.includes(view)) return + const arriving = !children.includes(view) let loser: WindowRecord | undefined if (current && current.view !== view) { this.removeEverywhere(current.view) @@ -1186,6 +1218,7 @@ export class WindowManager { this.placeOnTop(rec, view) this.attached.set(tabId, { view, windowId: rec.id, contentsId: view.webContents.id }) this.selfClosed.delete(tabId) + if (arriving) this.holdIfAsked(rec, tabId, view) if (loser && !loser.win.isDestroyed()) { this.applyPanes( loser.id, @@ -1252,6 +1285,48 @@ export class WindowManager { } } + /** A view just came into the window: held out of sight if a feature asks (holdArrivals). */ + private holdIfAsked(rec: WindowRecord, tabId: TabId, view: WebContentsView): void { + for (const ask of this.arrivalHolds) { + const ready = ask(tabId, view) + if (!ready) continue + this.dropHold(tabId) + const shown = () => this.unhold(tabId, view) + this.held.set(tabId, { view, windowId: rec.id, timer: setTimeout(shown, HOLD_MS) }) + ready.then(shown, shown) + return + } + } + + /** The held view's page has drawn (or HOLD_MS passed): its window lays out again and shows it. */ + private unhold(tabId: TabId, view: WebContentsView): void { + const hold = this.held.get(tabId) + if (hold?.view !== view) return + this.dropHold(tabId) + const rec = this.records.get(hold.windowId) + if (rec && !rec.win.isDestroyed()) this.applyPanes(rec.id, rec.panes, rec.radius) + } + + private dropHold(tabId: TabId): void { + clearTimeout(this.held.get(tabId)?.timer) + this.held.delete(tabId) + } + + /** Puts a held view under the window's other shown pages, if it isn't already. */ + private placeUnder(rec: WindowRecord, view: WebContentsView): void { + const parent = rec.win.contentView + const children = parent.children + const at = children.indexOf(view) + let floor = -1 + for (const entry of this.attached.values()) { + if (entry.windowId !== rec.id || entry.view === view || !entry.view.getVisible()) continue + const i = children.indexOf(entry.view) + if (i >= 0 && (floor < 0 || i < floor)) floor = i + } + if (at < 0 || floor < 0 || at < floor) return + parent.addChildView(view, floor) + } + /** Follows a tab's live web contents: fullscreen requests, and pages that close themselves. */ private watchTab(tabId: TabId, contents: WebContents): void { const contentsId = contents.id diff --git a/tests/e2e/media.spec.ts b/tests/e2e/media.spec.ts index 7e02b946..f85eae4d 100644 --- a/tests/e2e/media.spec.ts +++ b/tests/e2e/media.spec.ts @@ -1,3 +1,4 @@ +import { execFileSync, spawn } from 'node:child_process' import { createServer, type Server } from 'node:http' import type { AddressInfo } from 'node:net' import { crc32, deflateSync } from 'node:zlib' @@ -174,6 +175,13 @@ const FORGE = ` * A page that makes Orbit's script believe its own exit from picture-in-picture failed, then exits * anyway: the engine's leave then looks like its Back to tab (D74). Goes before the page's media. */ +/** + * A page slow to lay out at a new size, like a heavy page or one in a busy process: each resize + * keeps its main thread busy for `ms`, so its first frame at the new size comes that much later. + */ +const SLOW_RESIZE = (ms: number) => + `` + const HOLD = `