diff --git a/electron/hudWindowBounds.test.ts b/electron/hudWindowBounds.test.ts index a60a993aa..c5c719394 100644 --- a/electron/hudWindowBounds.test.ts +++ b/electron/hudWindowBounds.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { clampHudBoundsToWorkArea, clampHudContentToWorkArea, + hudBarAnchor, hudDragDestination, hudResizeBounds, parseHudContentRect, @@ -220,6 +221,69 @@ describe("hud resize bounds", () => { }); }); +describe("hud bar home (#1136)", () => { + /** The stack with a popover of `height` open above the bar, centred and bottom-anchored. */ + const stackWith = (height: number) => { + const stackHeight = BAR.height + 8 + height; + return { + x: BAR.x, + y: WINDOW.height - BAR_BOTTOM_INSET - stackHeight, + width: BAR.width, + height: stackHeight, + }; + }; + /** A re-clamp as windows.ts runs it once the bar has a home: same size, placed from home. */ + const placeFromHome = ( + bounds: typeof WINDOW, + content: typeof BAR, + home: { x: number; y: number }, + ) => + hudResizeBounds({ + bounds, + previousContent: content, + anchor: home, + width: bounds.width, + height: bounds.height, + nextContent: content, + workArea: WORK_AREA, + }); + + it("gives the same point for the bar and for a stack with a popover above it", () => { + expect(hudBarAnchor(WINDOW, stackWith(320))).toEqual(hudBarAnchor(WINDOW, BAR)); + expect(hudBarAnchor(WINDOW, BAR)).toEqual({ + x: WINDOW.x + BAR.x + BAR.width / 2, + y: WINDOW.y + BAR.y + BAR.height, + }); + }); + + it("puts the bar back where it was dropped once a popover that pushed it closes", () => { + // The report: the bar dropped near the top of the screen, its top at y 200. + const dropped = dragTo(0, 200 - BAR.y - WINDOW.y); + expect(barOnScreen(dropped).top).toBe(200); + const home = hudBarAnchor(dropped, BAR); + + // The language menu (320 px) has no room above the bar: the window moves down to fit it… + const language = placeFromHome(dropped, stackWith(320), home); + expect(language.y + stackWith(320).y).toBe(WORK_AREA.y); + expect(barOnScreen(language).top).toBeGreaterThan(200); + // …and back once it closes. It used to stay down, at 268 in the report. + const closed = placeFromHome(language, BAR, home); + expect(barOnScreen(closed).top).toBe(200); + + // Device settings (470 px) pushes from home, not from where the menu left it, and + // returns there too. + const device = placeFromHome(closed, stackWith(470), home); + expect(device.y + stackWith(470).y).toBe(WORK_AREA.y); + expect(barOnScreen(placeFromHome(device, BAR, home)).top).toBe(200); + }); + + it("leaves a bar with room above it alone when a popover opens", () => { + const atBottom = dragTo(0, 4000); + const home = hudBarAnchor(atBottom, BAR); + expect(placeFromHome(atBottom, stackWith(470), home)).toEqual(atBottom); + }); +}); + describe("hud content rect parsing", () => { it("accepts a measured rect", () => { expect(parseHudContentRect({ x: 185.5, y: 612, width: 560, height: 64 })).toEqual({ diff --git a/electron/hudWindowBounds.ts b/electron/hudWindowBounds.ts index 25ecd3a39..071c89a10 100644 --- a/electron/hudWindowBounds.ts +++ b/electron/hudWindowBounds.ts @@ -89,6 +89,20 @@ export function clampHudBoundsToWorkArea( : clampRectToWorkArea(bounds, workArea, HUD_WINDOW_MIN); } +/** + * Screen point of the bar's bottom-centre. The stack is centred on the bar and ends on + * its bottom edge, so any stack rect gives the same point, a popover open above it or + * not. Without a rect, the window's bottom-centre. + */ +export function hudBarAnchor( + bounds: EditorWindowRect, + content: HudContentRect | null, +): { x: number; y: number } { + return content + ? { x: bounds.x + content.x + content.width / 2, y: bounds.y + content.y + content.height } + : { x: bounds.x + bounds.width / 2, y: bounds.y + bounds.height }; +} + /** * Where a resize re-anchors the window: the bar's bottom-centre stays under * where it was, so switching between the horizontal bar and the tall vertical @@ -98,11 +112,18 @@ export function clampHudBoundsToWorkArea( * (LaunchWindow.module.css), so the post-resize rect is computed, not guessed. * The result keeps the new bar fully inside the work area, so a layout flip * cannot strand the HUD off screen. + * + * `anchor`, when given, is where the bar belongs rather than where it is: the + * point the user left it at. A popover opening near the top of the screen pushes + * the window down so the popover fits, and placing from the anchor instead of the + * pushed window brings the bar back once it closes (#1136). */ export function hudResizeBounds(input: { bounds: EditorWindowRect; /** Bar rect in the window as it currently is; null before the first report. */ previousContent: HudContentRect | null; + /** The bar's home bottom-centre; null to keep it where it is now. */ + anchor?: { x: number; y: number } | null; width: number; height: number; /** Bar rect in the requested size; null for a malformed message. */ @@ -114,12 +135,8 @@ export function hudResizeBounds(input: { const width = Math.max(1, Math.round(input.width)); const height = Math.max(1, Math.round(input.height)); - const anchorCenterX = input.previousContent - ? input.bounds.x + input.previousContent.x + input.previousContent.width / 2 - : input.bounds.x + input.bounds.width / 2; - const anchorBottomY = input.previousContent - ? input.bounds.y + input.previousContent.y + input.previousContent.height - : input.bounds.y + input.bounds.height; + const { x: anchorCenterX, y: anchorBottomY } = + input.anchor ?? hudBarAnchor(input.bounds, input.previousContent); const x = input.nextContent ? anchorCenterX - (input.nextContent.x + input.nextContent.width / 2) diff --git a/electron/windows.ts b/electron/windows.ts index 74c7f215d..df71910b1 100644 --- a/electron/windows.ts +++ b/electron/windows.ts @@ -12,6 +12,7 @@ import { clampHudBoundsToWorkArea, HUD_WINDOW_MIN, type HudContentRect, + hudBarAnchor, hudContentScreenRect, hudDragDestination, hudResizeBounds, @@ -174,6 +175,13 @@ let hudDragOrigin: { x: number; y: number } | null = null; // renderer's first measurement. let hudContentRect: HudContentRect | null = null; +// Where the user left the bar: its bottom-centre on screen, set by a drag and by the first +// resize. A re-clamp places the bar from here, never from where the window was last pushed: +// a popover opening near the top of the screen pushes the window down so the popover fits, +// and starting from the pushed window left the bar there once the popover closed, a little +// further down with each popover (#1136). Null until the first resize. +let hudBarHome: { x: number; y: number } | null = null; + // Linux never makes the HUD click-through (setHudMouseEventsEnabled in LaunchWindow), so // an overhanging reserve would swallow clicks on the panels and docks under it. There the // content rect is ignored and the whole window stays inside the work area. @@ -193,7 +201,18 @@ function reclampHud() { if (!win || win.isDestroyed() || win.isMinimized() || hudDragOrigin) return; const bounds = hudBounds(win); - const next = clampHudBoundsToWorkArea(bounds, hudContentRect, hudWorkAreaFor(bounds)); + const workArea = hudWorkAreaFor(bounds); + const next = hudBarHome + ? hudResizeBounds({ + bounds, + previousContent: hudContentRect, + anchor: hudBarHome, + width: bounds.width, + height: bounds.height, + nextContent: hudContentRect, + workArea, + }) + : clampHudBoundsToWorkArea(bounds, hudContentRect, workArea); if (!sameRect(next, bounds)) { win.setBounds(next, false); } @@ -334,6 +353,7 @@ ipcMain.on("hud-overlay-drag-to", (_event, deltaX: number, deltaY: number) => { // setBounds with the HUD's own size, not setPosition, which re-applies the size read // back from the window and grows it every frame under fractional scaling. hudOverlayWindow.setBounds(next, false); + hudBarHome = hudBarAnchor(next, hudContentRect); }); ipcMain.on("hud-overlay-drag-end", () => { @@ -376,9 +396,11 @@ ipcMain.on("hud-overlay-set-size", (_event, width: number, height: number, conte const bounds = hudBounds(hudOverlayWindow); const nextContent = HUD_CLAMPS_CONTENT ? parseHudContentRect(content) : null; + hudBarHome ??= hudBarAnchor(bounds, hudContentRect); const next = hudResizeBounds({ bounds, previousContent: hudContentRect, + anchor: hudBarHome, width, height, nextContent, @@ -496,6 +518,7 @@ export function createHudOverlayWindow(): BrowserWindow { hudDragOrigin = null; // The next HUD is a new window: a rect measured in this one would misplace it. hudContentRect = null; + hudBarHome = null; stopHudCursorPoll(); } });