From ffd4c255ec5d655a6081a7a00bb9dc9481a70638 Mon Sep 17 00:00:00 2001 From: Finesssee <90105158+Finesssee@users.noreply.github.com> Date: Sun, 9 Aug 2026 13:27:38 +0700 Subject: [PATCH 1/2] fix(tray): stop fractional-DPI resize loop with explicit two-state cycle detection (#261) At 125% DPI the anchored tray flyout can flap between two physical heights 379<->386 px (2-4 Hz): measure setSize layout feedback keeps disagreeing with the native integer snap. Replace the 2logical deadband with a pure decision (lib/traySizing.decideTrayHeight) that commits ANY real change (even +5 physical px) but detects a bounded physical A->B->A pair (span <=8, span 7 observed), then retains the larger member and suppresses in-pair flips. Lock clears on out-of-pair changes, width/min-max/zoom/DPI changes. Applied-frame reconciliation adopts the candidate as committed when its physical frame is already on screen. surface maxHeight now follows the RETAINED height (post-decision), so DOM constraint and window never diverge. No timers, no observer gating, no broad deadband. --- .../hooks/useTrayPanelLayout.sizing.test.tsx | 228 +++++++++++++++ .../src/hooks/useTrayPanelLayout.ts | 56 +++- apps/desktop-tauri/src/lib/traySizing.test.ts | 228 +++++++++++++++ apps/desktop-tauri/src/lib/traySizing.ts | 272 ++++++++++++++++++ 4 files changed, 769 insertions(+), 15 deletions(-) create mode 100644 apps/desktop-tauri/src/hooks/useTrayPanelLayout.sizing.test.tsx create mode 100644 apps/desktop-tauri/src/lib/traySizing.test.ts create mode 100644 apps/desktop-tauri/src/lib/traySizing.ts diff --git a/apps/desktop-tauri/src/hooks/useTrayPanelLayout.sizing.test.tsx b/apps/desktop-tauri/src/hooks/useTrayPanelLayout.sizing.test.tsx new file mode 100644 index 0000000000..e2a881d0d0 --- /dev/null +++ b/apps/desktop-tauri/src/hooks/useTrayPanelLayout.sizing.test.tsx @@ -0,0 +1,228 @@ +import { renderHook, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { TrayPanelLayoutOptions } from "./useTrayPanelLayout"; + +// #261 hook-level proof of the two-state cycle detection: +// - stable one-way +5-physical-px changes COMMIT (no blanket deadband); +// - a bounded 679↔686-physical pair (the reporter's 7-px amplitude) is +// detected once, converges to the LARGER member, and stops committing; +// - during suppression the surface's max-height tracks the RETAINED window +// target (never the freshly measured, smaller candidate → no clipping); +// - genuine growth/shrink outside the pair clears and commits; +// - anchors fire exactly on real commits (bottom-anchored flow intact). +const SCALE = 1.25; + +const tauriMocks = vi.hoisted(() => ({ + getWorkAreaRect: vi + .fn() + .mockResolvedValue({ x: 0, y: 0, width: 1280, height: 900 }), + reanchorTrayPanel: vi.fn().mockResolvedValue(undefined), + revealTrayPanelWindow: vi.fn().mockResolvedValue(undefined), +})); + +const windowMocks = vi.hoisted(() => ({ + setSize: vi.fn().mockResolvedValue(undefined), + innerSize: vi.fn().mockResolvedValue({ width: 328, height: 420 }), + getCurrentWindow: vi.fn(), + LogicalSize: vi.fn((width: number, height: number) => ({ width, height })), + PhysicalSize: vi.fn((width: number, height: number) => ({ width, height })), +})); + +vi.mock("../lib/tauri", () => tauriMocks); +vi.mock("@tauri-apps/api/window", () => windowMocks); + +import { useTrayPanelLayout } from "./useTrayPanelLayout"; + +let surface: HTMLElement; + +function mountSurface(): void { + document.body.innerHTML = [ + '", + ].join(""); + surface = document.querySelector(".menu-surface--tray")!; +} + +/** Drive the auto-fit measure: jsdom rects are 0, so scrollHeight dominates → + * contentHeight = scrollHeight + 4 (measure pipeline, zoom=1). */ +function setScrollHeight(px: number): void { + Object.defineProperty(surface, "scrollHeight", { + configurable: true, + get: () => px, + }); +} + +function hookProps(overrides: Partial = {}): TrayPanelLayoutOptions { + return { + canMeasure: true, + denseOverview: false, + detailMode: true, + layoutKey: "sizing", + ...overrides, + }; +} + +function lastResize(): { width: number; height: number } { + const calls = windowMocks.setSize.mock.calls; + return calls[calls.length - 1][0]; +} + +describe("useTrayPanelLayout two-state cycle detection (#261)", () => { + beforeEach(() => { + vi.clearAllMocks(); + mountSurface(); + Object.defineProperty(window, "devicePixelRatio", { + configurable: true, + value: SCALE, + }); + // Win32 applies integer-physical sizes: readback = round(logical * 1.25). + windowMocks.setSize.mockImplementation( + async (size: { width: number; height: number }) => { + windowMocks.innerSize.mockResolvedValue({ + width: Math.round(size.width * SCALE), + height: Math.round(size.height * SCALE), + }); + }, + ); + windowMocks.getCurrentWindow.mockReturnValue({ + setSize: windowMocks.setSize, + close: vi.fn().mockResolvedValue(undefined), + scaleFactor: vi.fn().mockResolvedValue(SCALE), + onResized: vi.fn().mockResolvedValue(() => {}), + innerSize: windowMocks.innerSize, + } as never); + }); + + afterEach(() => { + document.body.innerHTML = ""; + }); + + /** Nudge a pass and wait for its reveal — the deterministic per-pass + * completion signal (no sleeps). */ + async function nudgePass( + result: { current: { requestLayout: () => void } }, + sh: number, + revealCount: number, + ): Promise { + setScrollHeight(sh); + result.current.requestLayout(); + await waitFor( + () => + expect(tauriMocks.revealTrayPanelWindow.mock.calls.length).toBeGreaterThan( + revealCount, + ), + { timeout: 1000 }, + ); + return tauriMocks.revealTrayPanelWindow.mock.calls.length; + } + + it("commits stable small changes, locks the reporter pair on the larger member, tracks retained height in the DOM", async () => { + setScrollHeight(535); // → 539 logical → 674 physical + const { result } = renderHook(() => useTrayPanelLayout(hookProps())); + await waitFor(() => expect(result.current.layoutReady).toBe(true), { + timeout: 3000, + }); + expect( + windowMocks.setSize.mock.calls.some( + (call) => (call[0] as { height: number }).height === 539, + ), + ).toBe(true); + let revealCount = tauriMocks.revealTrayPanelWindow.mock.calls.length; + + // (1) Stable one-way +5-physical change COMMITS (no blanket deadband). + revealCount = await nudgePass(result, 539, revealCount); // → 543 → 679 phys + expect(lastResize()).toEqual({ width: 328, height: 543 }); + expect(surface.style.maxHeight).toBe("543px"); + + // (2) Reporter-class alternation: one commit to 549 (686 phys), then the + // 543↔549 pair (679↔686 phys, span 7) is detected on the flip-down. + revealCount = await nudgePass(result, 545, revealCount); // → 549 → 686 phys + expect(lastResize()).toEqual({ width: 328, height: 549 }); + expect(surface.style.maxHeight).toBe("549px"); + const lockedResizes = windowMocks.setSize.mock.calls.length; + const lockedAnchors = tauriMocks.reanchorTrayPanel.mock.calls.length; + + // Flip-down evidence (measure 539→543): detected, suppressed, and the DOM + // constraint stays the RETAINED height — never the smaller candidate. + revealCount = await nudgePass(result, 539, revealCount); + expect(windowMocks.setSize.mock.calls.length).toBe(lockedResizes); + expect(tauriMocks.reanchorTrayPanel.mock.calls.length).toBe(lockedAnchors); + expect(surface.style.maxHeight).toBe("549px"); + + // (3) Repeated flips stay suppressed; surface stays at the retained 549. + revealCount = await nudgePass(result, 545, revealCount); + revealCount = await nudgePass(result, 539, revealCount); + expect(windowMocks.setSize.mock.calls.length).toBe(lockedResizes); + expect(tauriMocks.reanchorTrayPanel.mock.calls.length).toBe(lockedAnchors); + expect(surface.style.maxHeight).toBe("549px"); + // Retained window (549 logical) fully contains BOTH measured sides + // (543 and 549 candidates) — no clipping by construction. + expect(lastResize()).toEqual({ width: 328, height: 549 }); + + // (4) Real growth outside the pair clears the lock and commits once. + revealCount = await nudgePass(result, 700, revealCount); // → 704 → 880 phys + expect(lastResize()).toEqual({ width: 328, height: 704 }); + expect(surface.style.maxHeight).toBe("704px"); + + // Real shrink commits once. + revealCount = await nudgePass(result, 410, revealCount); // → clamp 420 → 525 phys + expect(lastResize()).toEqual({ width: 328, height: 420 }); + expect(surface.style.maxHeight).toBe("420px"); + + // (5) No blanket absorption: a stable 1-physical-px change still commits. + revealCount = await nudgePass(result, 417, revealCount); // → 421 → 526 phys + expect(lastResize()).toEqual({ width: 328, height: 421 }); + expect(surface.style.maxHeight).toBe("421px"); + }); + + it("reconciles to the applied physical frame after an OS snap (no churn, no cycle)", async () => { + // Deliberate 5-physical snap: requesting 539 logical (→674 phys) yields an + // innerSize readback of 669. + windowMocks.setSize.mockImplementation( + async (size: { width: number; height: number }) => { + windowMocks.innerSize.mockResolvedValue({ + width: Math.round(size.width * SCALE), + height: size.height === 539 ? 669 : Math.round(size.height * SCALE), + }); + }, + ); + + setScrollHeight(535); // → 539 target (674 phys requested) → applied 669 + const { result } = renderHook(() => useTrayPanelLayout(hookProps())); + await waitFor(() => expect(result.current.layoutReady).toBe(true), { + timeout: 3000, + }); + expect( + windowMocks.setSize.mock.calls.some( + (call) => (call[0] as { height: number }).height === 539, + ), + ).toBe(true); + const snappedResizes = windowMocks.setSize.mock.calls.length; + const snappedAnchors = tauriMocks.reanchorTrayPanel.mock.calls.length; + let revealCount = tauriMocks.revealTrayPanelWindow.mock.calls.length; + + // Candidate 535 (→669 phys) equals the APPLIED frame while the recorded + // target says 674: suppress (no setSize/reanchor), adopt the candidate. + // The prior frame is 420 (525 phys), 144 px away from 669 — the A↔B + // detector cannot fire here. + revealCount = await nudgePass(result, 531, revealCount); // → 535 → 669 phys + expect(windowMocks.setSize.mock.calls.length).toBe(snappedResizes); + expect(tauriMocks.reanchorTrayPanel.mock.calls.length).toBe(snappedAnchors); + expect(surface.style.maxHeight).toBe("535px"); + + // Identical next pass: now exact same-frame stable — still zero churn. + revealCount = await nudgePass(result, 531, revealCount); + expect(windowMocks.setSize.mock.calls.length).toBe(snappedResizes); + expect(tauriMocks.reanchorTrayPanel.mock.calls.length).toBe(snappedAnchors); + expect(surface.style.maxHeight).toBe("535px"); + + // Recovery: a real +5-physical change from the reconciled frame commits. + revealCount = await nudgePass(result, 535, revealCount); // → 539 → 674 phys + expect(lastResize()).toEqual({ width: 328, height: 539 }); + expect(surface.style.maxHeight).toBe("539px"); + }); +}); diff --git a/apps/desktop-tauri/src/hooks/useTrayPanelLayout.ts b/apps/desktop-tauri/src/hooks/useTrayPanelLayout.ts index 65f265e206..7120cf16f7 100644 --- a/apps/desktop-tauri/src/hooks/useTrayPanelLayout.ts +++ b/apps/desktop-tauri/src/hooks/useTrayPanelLayout.ts @@ -9,6 +9,12 @@ import { reanchorTrayPanel, revealTrayPanelWindow, } from "../lib/tauri"; +import { + decideTrayHeight, + EMPTY_AUTOFIT_STATE, + recordAutoFitCommit, + type TrayAutoFitState, +} from "../lib/traySizing"; const TRAY_WIDTH = 328; const TRAY_MAX_MEASURE_HEIGHT = 920; @@ -77,11 +83,10 @@ export function useTrayPanelLayout({ // compounded a per-open size growth. const lastSizeRef = useRef<{ width: number; height: number } | null>(null); const programmaticInFlightRef = useRef(0); - // Auto-fit tracks its last LOGICAL target separately (lastSizeRef is physical) - // so its "did the content size change?" check stays in content pixels. - const autoFitLogicalRef = useRef<{ width: number; height: number } | null>( - null, - ); + // Auto-fit sizing decision state (committed frame + one-frame history + + // learned oscillation pair) — all frame logic lives in lib/traySizing so + // the #261 cycle detection stays pure and directly testable. + const sizingStateRef = useRef(EMPTY_AUTOFIT_STATE); const fixedSizeRef = useRef(fixedSize); useEffect(() => { fixedSizeRef.current = fixedSize; @@ -268,7 +273,12 @@ export function useTrayPanelLayout({ programmaticInFlightRef.current += 1; try { if (!layoutReadyRef.current) { - autoFitLogicalRef.current = { width: TRAY_WIDTH, height: minHeight }; + sizingStateRef.current = recordAutoFitCommit( + sizingStateRef.current, + TRAY_WIDTH, + minHeight, + window.devicePixelRatio, + ); await applySize(new LogicalSize(TRAY_WIDTH, minHeight)); } @@ -308,17 +318,33 @@ export function useTrayPanelLayout({ contentHeight = Math.ceil(maxBottom - surfaceRect.top) + 4; const height = Math.min(Math.max(contentHeight, minHeight), maxHeight); - surface.style.maxHeight = `${height}px`; + + // #261: two-state cycle detection on physical targets. Normal rule + // commits ANY real change (even +5 physical px); only exact same- + // frame equality is a no-op, and a bounded A→B→A pair (span ≤8 + // physical, the reporter's 7) locks onto its larger member. The DOM + // constraint is set AFTER the decision from the RETAINED height, so + // surface and window never diverge. + const decision = decideTrayHeight( + { + measuredHeight: height, + expectedWidth: TRAY_WIDTH, + minHeight, + maxHeight, + // WebView layout px ↔ Win32 physical px ratio; CSS zoom does not + // affect it (zoom is already in `height` via scaledContentHeight). + scaleFactor: window.devicePixelRatio, + zoom, + lastAppliedPhysicalHeight: lastSizeRef.current?.height ?? null, + }, + sizingStateRef.current, + ); + sizingStateRef.current = decision.state; + surface.style.maxHeight = `${decision.height}px`; committedHeight = true; - const previousSize = autoFitLogicalRef.current; - const shouldResize = - previousSize === null || - previousSize.width !== TRAY_WIDTH || - Math.abs(previousSize.height - height) > 2; - if (shouldResize) { - autoFitLogicalRef.current = { width: TRAY_WIDTH, height }; - await applySize(new LogicalSize(TRAY_WIDTH, height)); + if (decision.commit) { + await applySize(new LogicalSize(TRAY_WIDTH, decision.height)); await Promise.resolve(reanchorTrayPanel()).catch(() => {}); } diff --git a/apps/desktop-tauri/src/lib/traySizing.test.ts b/apps/desktop-tauri/src/lib/traySizing.test.ts new file mode 100644 index 0000000000..ff990acef3 --- /dev/null +++ b/apps/desktop-tauri/src/lib/traySizing.test.ts @@ -0,0 +1,228 @@ +import { describe, expect, it } from "vitest"; +import { + decideTrayHeight, + EMPTY_AUTOFIT_STATE, + recordAutoFitCommit, + TRAY_CYCLE_SPAN_MAX_PHYSICAL_PX, + type TrayAutoFitState, + type TraySizingInput, +} from "./traySizing"; + +const TRAY_WIDTH = 328; +const MIN = 420; +const MAX = 920; + +function input(overrides: Partial = {}): TraySizingInput { + return { + measuredHeight: 500, + expectedWidth: TRAY_WIDTH, + minHeight: MIN, + maxHeight: MAX, + scaleFactor: 1, + zoom: 1, + lastAppliedPhysicalHeight: null, + ...overrides, + }; +} + +interface ReplayStep { + measured: number; + reason: string; + commit: boolean; + height: number; +} + +/** Replay a candidate stream the way the hook does: a commit updates the + * returned state AND the applied-physical readback (Win32 applies + * round(height*sf)); a suppression changes neither. */ +function replay( + scaleFactor: number, + candidates: number[], + overrides: Partial = {}, +): { steps: ReplayStep[]; state: TrayAutoFitState } { + let state = EMPTY_AUTOFIT_STATE; + let applied: number | null = null; + const steps: ReplayStep[] = []; + for (const measured of candidates) { + const d = decideTrayHeight( + input({ measuredHeight: measured, scaleFactor, lastAppliedPhysicalHeight: applied, ...overrides }), + state, + ); + state = d.state; + if (d.commit) applied = Math.round(d.height * scaleFactor); + steps.push({ measured, reason: d.reason, commit: d.commit, height: d.height }); + } + return { steps, state }; +} + +function commitIndexes(steps: ReplayStep[]): number[] { + return steps.flatMap((s, i) => (s.commit ? [i] : [])); +} + +describe("decideTrayHeight (#261 two-state cycle detection)", () => { + it("commits the initial fit and clamps the measured height", () => { + const d = decideTrayHeight(input({ measuredHeight: 539 }), EMPTY_AUTOFIT_STATE); + expect(d).toMatchObject({ commit: true, reason: "initial", height: 539 }); + expect(decideTrayHeight(input({ measuredHeight: 50 }), EMPTY_AUTOFIT_STATE).height).toBe(MIN); + expect(decideTrayHeight(input({ measuredHeight: 5000 }), EMPTY_AUTOFIT_STATE).height).toBe(MAX); + }); + + it("normal rule: every stable ONE-WAY small step commits (+5 physical each)", () => { + // 480→484→488→492 logical @1.25 = 600→605→610→615 physical. NO blanket + // deadband may absorb these: each is a real, stable change. + const { steps } = replay(1.25, [480, 484, 488, 492]); + expect(commitIndexes(steps)).toEqual([0, 1, 2, 3]); + expect(steps.every((s) => s.reason === "initial" || s.reason === "commit")).toBe(true); + }); + + it("suppresses only exact same quantized frame (no neighborhood)", () => { + const { steps } = replay(1.25, [480, 480, 481]); + // 481→601.25≠600: even +1 physical px commits. + expect(commitIndexes(steps)).toEqual([0, 2]); + expect(steps[1]).toMatchObject({ commit: false, reason: "same-frame", height: 480 }); + }); + + it("reporter pair: 379↔386 physical alternation detects once, converges to the LARGER member", () => { + // 303.2↔308.8 logical @1.25 = exactly 379↔386 physical (reporter, issue #261). + expect(Math.round(303.2 * 1.25)).toBe(379); + expect(Math.round(308.8 * 1.25)).toBe(386); + const { steps } = replay(1.25, [303.2, 308.8, 303.2, 303.2, 308.8, 303.2], { minHeight: 200 }); + expect(commitIndexes(steps)).toEqual([0, 1]); + expect(steps[2]).toMatchObject({ commit: false, reason: "cycle-converge", height: 308.8 }); + for (const s of steps.slice(2)) { + expect(s.commit).toBe(false); + expect(s.reason).toMatch(/cycle/); + // Retained height is ALWAYS the larger member — the window fully + // contains the surface from either measured side (308.8 ≥ 303.2). + expect(s.height).toBe(308.8); + } + }); + + it("cycle detected below current position commits ONCE upward, then locks", () => { + // A=500(625) → B=495(619) → A: committed is the lo member, so the detector + // commits once to the larger (safe) member, then suppresses all flips. + const { steps } = replay(1.25, [500, 495, 500, 495, 500, 495]); + expect(commitIndexes(steps)).toEqual([0, 1, 2]); + expect(steps[2]).toMatchObject({ commit: true, reason: "cycle-converge", height: 500 }); + expect(steps[3]).toMatchObject({ commit: false, reason: "cycle-suppress", height: 500 }); + expect(steps[4]).toMatchObject({ commit: false, reason: "cycle-suppress", height: 500 }); + }); + + it("does NOT classify a lone A→B small change as oscillation", () => { + const { steps, state } = replay(1.25, [303.2, 308.8, 308.8, 308.8], { minHeight: 200 }); + expect(commitIndexes(steps)).toEqual([0, 1]); + expect(steps[2].reason).toBe("same-frame"); + expect(state.cycle).toBeNull(); + }); + + it("does NOT cycle-detect pairs above the physical span cap", () => { + expect(TRAY_CYCLE_SPAN_MAX_PHYSICAL_PX).toBe(8); + // A=300→300, B=310→310 at sf=1: span 10 > 8 → genuine large flip, commits back. + const { steps, state } = replay(1, [300, 310, 300], { minHeight: 200 }); + expect(commitIndexes(steps)).toEqual([0, 1, 2]); + expect(state.cycle).toBeNull(); + }); + + it("clears the lock and commits the moment a candidate lands OUTSIDE the learned pair", () => { + const { steps, state } = replay(1.25, [303.2, 308.8, 303.2, 330], { minHeight: 200 }); + expect(commitIndexes(steps)).toEqual([0, 1, 3]); + expect(steps[3]).toMatchObject({ commit: true, height: 330 }); + expect(state.cycle).toBeNull(); + // And values near—but not equal to—the old pair never stay suppressed. + const { steps: near } = replay(1.25, [303.2, 308.8, 303.2, 306, 306], { minHeight: 200 }); + expect(commitIndexes(near)).toEqual([0, 1, 3]); + }); + + it("clears the lock on zoom change, DPI change, layout-class (min/max) change, and width change", () => { + const base: number[] = [303.2, 308.8, 303.2]; + const locked = replay(1.25, base, { minHeight: 200 }); + expect(locked.state.cycle).not.toBeNull(); + + // zoom 1 → 1.5 with an in-pair candidate: lock invalidated. + const zoomed = decideTrayHeight( + input({ measuredHeight: 308.8, scaleFactor: 1.25, zoom: 1.5, minHeight: 200 }), + locked.state, + ); + expect(zoomed.state.cycle).toBeNull(); + + // DPI 1.25 → 1.0: physical frames incomparable → full reset, fresh fit. + const dpi = decideTrayHeight(input({ measuredHeight: 308.8, scaleFactor: 1, minHeight: 200 }), locked.state); + expect(dpi).toMatchObject({ commit: true, reason: "initial" }); + expect(dpi.state.cycle).toBeNull(); + + // Layout class: min 200 → 420 with an outside-pair candidate → cleared + commit. + const klass = decideTrayHeight(input({ measuredHeight: 500, scaleFactor: 1.25, minHeight: 420 }), locked.state); + expect(klass.state.cycle).toBeNull(); + expect(klass.commit).toBe(true); + + // Width class change commits and clears. + const wide = decideTrayHeight(input({ measuredHeight: 308.8, scaleFactor: 1.25, minHeight: 200, expectedWidth: 400 }), locked.state); + expect(wide).toMatchObject({ commit: true, reason: "width" }); + expect(wide.state.cycle).toBeNull(); + }); + + it("re-learns a pair only on fresh A→B→A evidence after a clear", () => { + // Lock {379,386}, then a genuine stable move to 500 (lock cleared); a NEW + // 500↔505 flip forms its own pair and converges again. + const seq = replay(1.25, [303.2, 308.8, 303.2, 500, 505, 500, 500, 505], { minHeight: 200 }); + expect(commitIndexes(seq.steps)).toEqual([0, 1, 3, 4]); + expect(seq.steps[5]).toMatchObject({ reason: "cycle-converge", height: 505 }); + expect(seq.steps[7]).toMatchObject({ commit: false, reason: "cycle-suppress", height: 505 }); + }); + + it("reconciles committed state to the EXACT physical frame Win32 applied (readback)", () => { + // History: min fit 420 (→525), then a real 480 commit (→600 target) whose + // apply the OS snapped to applied=595. A 476 candidate (→595) equals the + // on-screen frame: no setSize, and the committed frame is REPLACED by the + // candidate's own {476,595} — the DOM constraint and future comparisons + // now describe reality — while `prior` is left untouched (no manufactured + // transition feeding the cycle detector). + let state = recordAutoFitCommit(EMPTY_AUTOFIT_STATE, TRAY_WIDTH, 420, 1.25); + state = recordAutoFitCommit(state, TRAY_WIDTH, 480, 1.25); + expect(state.prior).toMatchObject({ height: 420, physical: 525 }); + + const d = decideTrayHeight( + input({ measuredHeight: 476, scaleFactor: 1.25, lastAppliedPhysicalHeight: 595 }), // 476→595 + state, + ); + expect(d).toMatchObject({ commit: false, reason: "applied-frame", height: 476 }); + expect(d.state.committed).toMatchObject({ + width: TRAY_WIDTH, + height: 476, + physical: 595, + }); + expect(d.state.prior).toMatchObject({ height: 420, physical: 525 }); + expect(d.state.cycle).toBeNull(); + + // The very next identical candidate is now an exact same-frame no-op. + const again = decideTrayHeight( + input({ measuredHeight: 476, scaleFactor: 1.25, lastAppliedPhysicalHeight: 595 }), + d.state, + ); + expect(again).toMatchObject({ commit: false, reason: "same-frame", height: 476 }); + + // …but only for EXACT equality: 596 physical is a real +1 change. + const d2 = decideTrayHeight( + input({ measuredHeight: 476.8, scaleFactor: 1.25, lastAppliedPhysicalHeight: 595, minHeight: 200 }), + state, + ); + expect(Math.round(476.8 * 1.25)).toBe(596); + expect(d2.commit).toBe(true); + }); + + it("recordAutoFitCommit shifts history for hook-driven fits (min-fit seed)", () => { + let s = recordAutoFitCommit(EMPTY_AUTOFIT_STATE, TRAY_WIDTH, 420, 1.25); + expect(s.committed).toMatchObject({ width: TRAY_WIDTH, height: 420, physical: 525 }); + expect(s.prior).toBeNull(); + s = recordAutoFitCommit(s, TRAY_WIDTH, 200, 1.25); + expect(s.committed).toMatchObject({ height: 200, physical: 250 }); + expect(s.prior).toMatchObject({ height: 420, physical: 525 }); + }); + + it("zoom is measure-space: alternation around a zoom-scaled size still locks", () => { + // content 500 × zoom 1.5 = 750 measured; pair 750↔754 (Δ5) still detects. + const { steps } = replay(1, [750, 754, 750, 754], { zoom: 1.5 }); + expect(commitIndexes(steps)).toEqual([0, 1]); + expect(steps[3]).toMatchObject({ commit: false, reason: "cycle-suppress", height: 754 }); + }); +}); diff --git a/apps/desktop-tauri/src/lib/traySizing.ts b/apps/desktop-tauri/src/lib/traySizing.ts new file mode 100644 index 0000000000..10a357535e --- /dev/null +++ b/apps/desktop-tauri/src/lib/traySizing.ts @@ -0,0 +1,272 @@ +/** + * Pure auto-fit sizing decision for the anchored tray flyout (#261). + * + * Problem: on fractional-DPI setups (reporter: Win11 25H2 at 125% scale, + * AppliedDPI 120) the transparent borderless flyout alternated natively + * between 379 and 386 physical px (≈5.6 logical px) at 2–4 Hz: a bounded + * two-position measure↔setSize↔layout feedback loop. The exact OS driver + * is unconfirmed (a same-layout Chromium sandbox at deviceScaleFactor 1.25 + * converges), so this targets the demonstrated FEEDBACK CLASS, not a guess + * at the OS cause: explicit two-state cycle detection on physical targets. + * + * Policy (narrow): + * - NORMAL RULE: any candidate whose quantized PHYSICAL height differs + * from the committed frame commits — including a stable one-way +5 px. + * Suppression happens ONLY for exact same-frame equality (a genuine + * no-op) — there is deliberately NO neighborhood deadband, so small real + * deltas are never absorbed. + * - CYCLE DETECTION: history of the last two committed physical targets. + * A candidate returning to the previous-previous frame (A→B→A) with pair + * span ≤ TRAY_CYCLE_SPAN_MAX_PHYSICAL_PX (reporter observed 7; cap 8) + * is an oscillation pair. A lone A→B small change is NOT classified as + * oscillation — it commits normally. + * - Once a pair is learned, the LARGER member is retained (window fully + * contains the surface, no clipping) and candidates equal to EITHER pair + * member are suppressed until something outside the pair arrives. + * - The lock clears on: a candidate outside the pair (real change), a + * width change, a min/max (layout-class) change, a zoom change, or a DPI + * (scaleFactor) change — so unrelated nearby values are never suppressed + * indefinitely. + * + * No timers, no debounce, no observer gating: measurement always runs; + * only setSize/re-anchor commits are decided here. + */ + +/** Max span (physical px) of a recognized two-state feedback pair. Reporter + * evidence: 7 physical px at 125% scale; capped at 8. */ +export const TRAY_CYCLE_SPAN_MAX_PHYSICAL_PX = 8; + +export interface TrayFrame { + /** Logical-px height committed/applied to the window. */ + height: number; + /** Quantized physical height: round(height * scaleFactor) at commit. */ + physical: number; +} + +interface CycleLock { + lo: TrayFrame; + hi: TrayFrame; + scaleFactor: number; + zoom: number; + minHeight: number; + maxHeight: number; +} + +/** Full decision state; owned by the hook in a ref, threaded pure. */ +export interface TrayAutoFitState { + /** Last committed frame (null before the first fit). */ + committed: (TrayFrame & { width: number }) | null; + /** The frame committed BEFORE `committed` (the A in A→B→A). */ + prior: TrayFrame | null; + /** Learned oscillation pair after two-state detection. */ + cycle: CycleLock | null; + /** Scale factor all physical values above were computed with. */ + scaleFactor: number | null; +} + +export const EMPTY_AUTOFIT_STATE: TrayAutoFitState = { + committed: null, + prior: null, + cycle: null, + scaleFactor: null, +}; + +export interface TraySizingInput { + /** Measured content height, logical px, post zoom-scale, pre clamp. */ + measuredHeight: number; + /** Logical content width the flyout always carries (TRAY_WIDTH). */ + expectedWidth: number; + minHeight: number; + maxHeight: number; + /** Win32 DPI ratio (window.devicePixelRatio), e.g. 1.25 at 125%. */ + scaleFactor: number; + /** Active tray zoom (CSS zoom factor already applied to the measure). */ + zoom: number; + /** Physical height Win32 ACTUALLY applied after our last resize + * (`innerSize()` readback); used only for exact-frame equality. */ + lastAppliedPhysicalHeight: number | null; +} + +export type TrayDecisionReason = + | "initial" + | "width" + | "commit" + | "same-frame" + | "applied-frame" + | "cycle-converge" + | "cycle-suppress"; + +export interface TrayAutoFitDecision { + /** Logical height the DOM constraint AND any window apply must use. + * On suppression this is ALWAYS the retained committed height, never + * the freshly measured candidate. */ + height: number; + commit: boolean; + reason: TrayDecisionReason; + state: TrayAutoFitState; +} + +/** Record a commit the decision function did not order (initial min fit). */ +export function recordAutoFitCommit( + state: TrayAutoFitState, + width: number, + height: number, + scaleFactor: number, +): TrayAutoFitState { + const frame = { height, physical: Math.round(height * scaleFactor) }; + return { + committed: { ...frame, width }, + prior: state.committed + ? { height: state.committed.height, physical: state.committed.physical } + : state.prior, + cycle: state.cycle, + scaleFactor, + }; +} + +export function decideTrayHeight( + input: TraySizingInput, + prevState: TrayAutoFitState, +): TrayAutoFitDecision { + const sf = + Number.isFinite(input.scaleFactor) && input.scaleFactor > 0 + ? input.scaleFactor + : 1; + const zoom = Number.isFinite(input.zoom) && input.zoom > 0 ? input.zoom : 1; + const height = Math.min( + Math.max(input.measuredHeight, input.minHeight), + input.maxHeight, + ); + const candidatePhysical = Math.round(height * sf); + + // DPI changed: physical frames are incomparable — reset all history. + let state = + prevState.scaleFactor !== null && prevState.scaleFactor !== sf + ? { ...EMPTY_AUTOFIT_STATE, scaleFactor: sf } + : { ...prevState, scaleFactor: sf }; + + // Validate or drop the learned cycle before anything consults it. + if (state.cycle) { + const lock = state.cycle; + const contextUnchanged = + lock.scaleFactor === sf && + lock.zoom === zoom && + lock.minHeight === input.minHeight && + lock.maxHeight === input.maxHeight && + state.committed !== null && + state.committed.width === input.expectedWidth; + const inPair = + candidatePhysical === lock.lo.physical || + candidatePhysical === lock.hi.physical; + if (!contextUnchanged) { + state = { ...state, cycle: null }; + } else if (inPair) { + // Still flipping inside the learned pair: retain the LARGER member — + // the window fully contains the surface in either measure state. + return { height: lock.hi.height, commit: false, reason: "cycle-suppress", state }; + } else { + // Real change outside the pair: unlock and fall through to the + // normal rule with history intact. + state = { ...state, cycle: null }; + } + } + + if (state.committed === null) { + return { + height, + commit: true, + reason: "initial", + state: { + committed: { height, physical: candidatePhysical, width: input.expectedWidth }, + prior: null, + cycle: null, + scaleFactor: sf, + }, + }; + } + + const committed = state.committed; + const pushCommit = (): TrayAutoFitState => ({ + committed: { height, physical: candidatePhysical, width: input.expectedWidth }, + prior: { height: committed.height, physical: committed.physical }, + cycle: null, + scaleFactor: sf, + }); + + if (committed.width !== input.expectedWidth) { + return { height, commit: true, reason: "width", state: pushCommit() }; + } + + // Exact same quantized frame as committed: a genuine no-op (for integer + // logical measures at sf ≥ 1, physical equality ⇔ logical equality). + if (candidatePhysical === committed.physical) { + return { height: committed.height, commit: false, reason: "same-frame", state }; + } + + // A→B→A bounded two-state detection: candidate returns to the frame we + // committed before `committed`, at a span within the observed feedback + // amplitude. A lone A→B never reaches this branch. + const prior = state.prior; + if ( + prior !== null && + candidatePhysical === prior.physical && + Math.abs(candidatePhysical - committed.physical) <= TRAY_CYCLE_SPAN_MAX_PHYSICAL_PX + ) { + const loFrame = prior.physical <= committed.physical ? prior : committed; + const hiFrame = prior.physical <= committed.physical ? committed : prior; + const cycle: CycleLock = { + lo: loFrame, + hi: hiFrame, + scaleFactor: sf, + zoom, + minHeight: input.minHeight, + maxHeight: input.maxHeight, + }; + if (committed.physical === hiFrame.physical) { + // Already at the larger member: hold it, apply nothing. + return { + height: hiFrame.height, + commit: false, + reason: "cycle-converge", + state: { ...state, cycle }, + }; + } + // Retain the LARGER member with one convergence commit, then lock. + return { + height, + commit: true, + reason: "cycle-converge", + state: { + committed: { height, physical: candidatePhysical, width: input.expectedWidth }, + prior: { height: committed.height, physical: committed.physical }, + cycle, + scaleFactor: sf, + }, + }; + } + + // Idempotence anchored in reality: the candidate's physical frame IS what + // Win32 currently shows (snap/clamp made the applied size differ from the + // recorded target). Re-applying would be a rect no-op, so suppress the + // setSize — and reconcile state to reality: the committed frame becomes + // the candidate's (logical height + physical), so the DOM constraint and + // any later comparisons use what is actually on screen. `prior` is left + // untouched: this is not a committed transition, so it must not feed the + // cycle detector's A→B→A evidence. + if ( + input.lastAppliedPhysicalHeight !== null && + candidatePhysical === input.lastAppliedPhysicalHeight + ) { + return { + height, + commit: false, + reason: "applied-frame", + state: { + ...state, + committed: { height, physical: candidatePhysical, width: input.expectedWidth }, + }, + }; + } + + return { height, commit: true, reason: "commit", state: pushCommit() }; +} From 25e6fec4a6ca161106fbd7a6d0041986c6496394 Mon Sep 17 00:00:00 2001 From: Finesssee <90105158+Finesssee@users.noreply.github.com> Date: Sun, 9 Aug 2026 13:35:26 +0700 Subject: [PATCH 2/2] test(tray): harden #261 convergence-test sequencing with per-pass markers The CI runner's scheduling can misalign a nudge with its pass (reveal count increments are not pass-unique). Wait on the surface maxHeight marker each pass sets instead; identical markers fall back to reveal+1. --- .../hooks/useTrayPanelLayout.sizing.test.tsx | 60 +++++++++++-------- 1 file changed, 36 insertions(+), 24 deletions(-) diff --git a/apps/desktop-tauri/src/hooks/useTrayPanelLayout.sizing.test.tsx b/apps/desktop-tauri/src/hooks/useTrayPanelLayout.sizing.test.tsx index e2a881d0d0..8542eb6a11 100644 --- a/apps/desktop-tauri/src/hooks/useTrayPanelLayout.sizing.test.tsx +++ b/apps/desktop-tauri/src/hooks/useTrayPanelLayout.sizing.test.tsx @@ -10,6 +10,10 @@ import type { TrayPanelLayoutOptions } from "./useTrayPanelLayout"; // target (never the freshly measured, smaller candidate → no clipping); // - genuine growth/shrink outside the pair clears and commits; // - anchors fire exactly on real commits (bottom-anchored flow intact). +// +// Sequencing instead of sleeps: every completed pass marks the surface's +// max-height (it is set on EVERY pass from the decision, suppressed or not), +// so each nudge waits for its own marker value. const SCALE = 1.25; const tauriMocks = vi.hoisted(() => ({ @@ -71,6 +75,10 @@ function lastResize(): { width: number; height: number } { return calls[calls.length - 1][0]; } +function hookResult(result: unknown): { current: { requestLayout: () => void } } { + return result as { current: { requestLayout: () => void } }; +} + describe("useTrayPanelLayout two-state cycle detection (#261)", () => { beforeEach(() => { vi.clearAllMocks(); @@ -101,23 +109,29 @@ describe("useTrayPanelLayout two-state cycle detection (#261)", () => { document.body.innerHTML = ""; }); - /** Nudge a pass and wait for its reveal — the deterministic per-pass - * completion signal (no sleeps). */ + /** Nudge a pass and wait until IT marks the surface with `expectedMarker` + * (maxHeight is assigned on every pass → deterministic per-pass signal, + * immune to nudge-vs-pass alignment races). Returns nothing; callers assert + * setSize/anchor deltas against counts they snapshotted before the nudge. */ async function nudgePass( - result: { current: { requestLayout: () => void } }, + result: unknown, sh: number, - revealCount: number, - ): Promise { + expectedMarker: string, + ): Promise { + const r = hookResult(result); + const alreadyMarked = surface.style.maxHeight === expectedMarker; + const revealsAtNudge = tauriMocks.revealTrayPanelWindow.mock.calls.length; setScrollHeight(sh); - result.current.requestLayout(); + r.current.requestLayout(); await waitFor( () => - expect(tauriMocks.revealTrayPanelWindow.mock.calls.length).toBeGreaterThan( - revealCount, - ), - { timeout: 1000 }, + alreadyMarked + ? expect( + tauriMocks.revealTrayPanelWindow.mock.calls.length, + ).toBeGreaterThan(revealsAtNudge) + : expect(surface.style.maxHeight).toBe(expectedMarker), + { timeout: 3000 }, ); - return tauriMocks.revealTrayPanelWindow.mock.calls.length; } it("commits stable small changes, locks the reporter pair on the larger member, tracks retained height in the DOM", async () => { @@ -131,16 +145,15 @@ describe("useTrayPanelLayout two-state cycle detection (#261)", () => { (call) => (call[0] as { height: number }).height === 539, ), ).toBe(true); - let revealCount = tauriMocks.revealTrayPanelWindow.mock.calls.length; // (1) Stable one-way +5-physical change COMMITS (no blanket deadband). - revealCount = await nudgePass(result, 539, revealCount); // → 543 → 679 phys + await nudgePass(result, 539, "543px"); // → 543 → 679 phys expect(lastResize()).toEqual({ width: 328, height: 543 }); expect(surface.style.maxHeight).toBe("543px"); // (2) Reporter-class alternation: one commit to 549 (686 phys), then the // 543↔549 pair (679↔686 phys, span 7) is detected on the flip-down. - revealCount = await nudgePass(result, 545, revealCount); // → 549 → 686 phys + await nudgePass(result, 545, "549px"); // → 549 → 686 phys expect(lastResize()).toEqual({ width: 328, height: 549 }); expect(surface.style.maxHeight).toBe("549px"); const lockedResizes = windowMocks.setSize.mock.calls.length; @@ -148,14 +161,14 @@ describe("useTrayPanelLayout two-state cycle detection (#261)", () => { // Flip-down evidence (measure 539→543): detected, suppressed, and the DOM // constraint stays the RETAINED height — never the smaller candidate. - revealCount = await nudgePass(result, 539, revealCount); + await nudgePass(result, 539, "549px"); expect(windowMocks.setSize.mock.calls.length).toBe(lockedResizes); expect(tauriMocks.reanchorTrayPanel.mock.calls.length).toBe(lockedAnchors); expect(surface.style.maxHeight).toBe("549px"); // (3) Repeated flips stay suppressed; surface stays at the retained 549. - revealCount = await nudgePass(result, 545, revealCount); - revealCount = await nudgePass(result, 539, revealCount); + await nudgePass(result, 545, "549px"); + await nudgePass(result, 539, "549px"); expect(windowMocks.setSize.mock.calls.length).toBe(lockedResizes); expect(tauriMocks.reanchorTrayPanel.mock.calls.length).toBe(lockedAnchors); expect(surface.style.maxHeight).toBe("549px"); @@ -164,17 +177,17 @@ describe("useTrayPanelLayout two-state cycle detection (#261)", () => { expect(lastResize()).toEqual({ width: 328, height: 549 }); // (4) Real growth outside the pair clears the lock and commits once. - revealCount = await nudgePass(result, 700, revealCount); // → 704 → 880 phys + await nudgePass(result, 700, "704px"); // → 704 → 880 phys expect(lastResize()).toEqual({ width: 328, height: 704 }); expect(surface.style.maxHeight).toBe("704px"); // Real shrink commits once. - revealCount = await nudgePass(result, 410, revealCount); // → clamp 420 → 525 phys + await nudgePass(result, 410, "420px"); // → clamp 420 → 525 phys expect(lastResize()).toEqual({ width: 328, height: 420 }); expect(surface.style.maxHeight).toBe("420px"); // (5) No blanket absorption: a stable 1-physical-px change still commits. - revealCount = await nudgePass(result, 417, revealCount); // → 421 → 526 phys + await nudgePass(result, 417, "421px"); // → 421 → 526 phys expect(lastResize()).toEqual({ width: 328, height: 421 }); expect(surface.style.maxHeight).toBe("421px"); }); @@ -203,25 +216,24 @@ describe("useTrayPanelLayout two-state cycle detection (#261)", () => { ).toBe(true); const snappedResizes = windowMocks.setSize.mock.calls.length; const snappedAnchors = tauriMocks.reanchorTrayPanel.mock.calls.length; - let revealCount = tauriMocks.revealTrayPanelWindow.mock.calls.length; // Candidate 535 (→669 phys) equals the APPLIED frame while the recorded // target says 674: suppress (no setSize/reanchor), adopt the candidate. // The prior frame is 420 (525 phys), 144 px away from 669 — the A↔B // detector cannot fire here. - revealCount = await nudgePass(result, 531, revealCount); // → 535 → 669 phys + await nudgePass(result, 531, "535px"); // → 535 → 669 phys expect(windowMocks.setSize.mock.calls.length).toBe(snappedResizes); expect(tauriMocks.reanchorTrayPanel.mock.calls.length).toBe(snappedAnchors); expect(surface.style.maxHeight).toBe("535px"); // Identical next pass: now exact same-frame stable — still zero churn. - revealCount = await nudgePass(result, 531, revealCount); + await nudgePass(result, 531, "535px"); expect(windowMocks.setSize.mock.calls.length).toBe(snappedResizes); expect(tauriMocks.reanchorTrayPanel.mock.calls.length).toBe(snappedAnchors); expect(surface.style.maxHeight).toBe("535px"); // Recovery: a real +5-physical change from the reconciled frame commits. - revealCount = await nudgePass(result, 535, revealCount); // → 539 → 674 phys + await nudgePass(result, 535, "539px"); // → 539 → 674 phys expect(lastResize()).toEqual({ width: 328, height: 539 }); expect(surface.style.maxHeight).toBe("539px"); });