From 10f049c3719f409e81256ceb97ed3caffa5a1469 Mon Sep 17 00:00:00 2001 From: EtienneLescot Date: Mon, 12 Oct 2026 20:10:08 +0200 Subject: [PATCH] fix(hud): put the bar back where it was dropped once a popover closes A popover opening near the top of the screen pushes the HUD window down so it fits. Every re-clamp then started from the pushed window, so the bar stayed down after the popover closed, and moved further with each taller popover. The main process now keeps the bar's home, its bottom-centre where a drag or the first resize left it, and places the window from there. A popover still pushes the bar while it is open; closing it brings the bar back. Refs #1136. --- electron/hudWindowBounds.test.ts | 64 ++++++++++++++++++++++++++++++++ electron/hudWindowBounds.ts | 29 ++++++++++++--- electron/windows.ts | 25 ++++++++++++- 3 files changed, 111 insertions(+), 7 deletions(-) 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(); } });