diff --git a/electron/windows.ts b/electron/windows.ts index 8db7d9cd4a..5113840f5b 100644 --- a/electron/windows.ts +++ b/electron/windows.ts @@ -165,6 +165,21 @@ function hudBounds(win: BrowserWindow): Electron.Rectangle { return { x, y, ...hudWindowSize }; } +/** + * Applies `setBounds` to the HUD window. On Linux/X11, window managers such as + * Mutter ignore programmatic size changes when `resizable: false` locks + * `WM_NORMAL_HINTS` min/max dimensions, so `resizable` is toggled around the call. + */ +function setHudWindowBounds(win: BrowserWindow, next: Electron.Rectangle) { + if (process.platform === "linux") { + win.setResizable(true); + win.setBounds(next, false); + win.setResizable(false); + } else { + win.setBounds(next, false); + } +} + // Origin the current drag gesture started from. The renderer sends the pointer's // *total* travel since pointerdown rather than per-frame deltas, so every move is // an absolute `origin + delta` — no rounding to accumulate, and a dropped message @@ -196,9 +211,11 @@ function hudWorkAreaFor(bounds: Electron.Rectangle): Electron.Rectangle { return screen.getDisplayMatching(reference).workArea; } -// Re-clamps a HUD nobody is dragging: a drag owns the position frame by frame, and the -// renderer re-measures on release. Skipped while minimized, where moving the window is -// platform-defined; "restore" runs it instead. +/** + * Re-clamps a HUD nobody is dragging: a drag owns the position frame by frame, and the + * renderer re-measures on release. Skipped while minimized, where moving the window is + * platform-defined; "restore" runs it instead. + */ function reclampHud() { const win = hudOverlayWindow; if (!win || win.isDestroyed() || win.isMinimized() || hudDragOrigin) return; @@ -217,7 +234,7 @@ function reclampHud() { }) : clampHudBoundsToWorkArea(bounds, hudContentRect, workArea); if (!sameRect(next, bounds)) { - win.setBounds(next, false); + setHudWindowBounds(win, next); } } @@ -435,7 +452,7 @@ ipcMain.on("hud-overlay-set-size", (_event, width: number, height: number, conte hudWindowSize = { width: next.width, height: next.height }; if (!sameRect(next, bounds)) { - hudOverlayWindow.setBounds(next, false); + setHudWindowBounds(hudOverlayWindow, next); } reassertAlwaysOnTop(hudOverlayWindow); }); @@ -460,9 +477,11 @@ export function createHudOverlayWindow(): BrowserWindow { // Close to what the renderer asks for on its very first measurement (bar plus // the reserved space above it), so the HUD doesn't visibly resize itself the - // instant it becomes visible. See src/components/launch/hudGeometry.ts. - const windowWidth = 820; - const windowHeight = 560; + // instant it becomes visible. On Linux the HUD is not click-through, so start + // at the compact bar+tooltip size until a popover opens. + const isLinux = process.platform === "linux"; + const windowWidth = isLinux ? 760 : 820; + const windowHeight = isLinux ? 190 : 560; const x = Math.floor(workArea.x + (workArea.width - windowWidth) / 2); const y = Math.floor(workArea.y + workArea.height - windowHeight - 5); diff --git a/src/components/launch/HudControls.tsx b/src/components/launch/HudControls.tsx index c417b754c2..215ee8a6e9 100644 --- a/src/components/launch/HudControls.tsx +++ b/src/components/launch/HudControls.tsx @@ -45,8 +45,8 @@ const hudLockedClasses = // The bar's orientation decides which side its tooltips open on. A horizontal bar has all the // reserve of the transparent window above it, so its tooltips go up. In a vertical bar "up" is // over the controls above the one under the pointer, the ones the user reaches for next, so the -// tooltips go beside it: the bar is centred in a window about 650px wide or more (hudGeometry.ts), -// so the 260px maximum fits on either side. +// tooltips go beside it. On Linux the collapsed window is only as wide as a notice, so a vertical +// tooltip wraps to the room Radix reports beside the bar instead of being cut by the window. const HudVerticalContext = createContext(false); export const HudLayoutProvider = HudVerticalContext.Provider; @@ -60,20 +60,31 @@ const HUD_TOOLTIP_EDGE_PADDING = 8; // right in the render that shows it, not one effect later) and added to the primitive's gap. function HudTooltip({ content, children }: { content: string; children: ReactElement }) { const vertical = useContext(HudVerticalContext); - const side = vertical ? "right" : "top"; const triggerRef = useRef(null); const [open, setOpen] = useState(false); const [clearance, setClearance] = useState(0); + const [side, setSide] = useState<"top" | "right" | "left">(vertical ? "right" : "top"); const handleOpenChange = (next: boolean) => { const trigger = triggerRef.current; const bar = trigger?.closest("[data-tray-layout]"); if (next && trigger && bar) { + let currentSide: "top" | "right" | "left" = "top"; + if (vertical) { + const barRect = bar.getBoundingClientRect(); + const screenLeft = (window.screenX || window.screenLeft || 0) + barRect.right; + const screen = window.screen as Screen & { availLeft?: number }; + const screenRight = (screen.availLeft ?? 0) + (screen.availWidth || screen.width || 1920); + const requiredRoom = 260 + TOOLTIP_GAP_PX + HUD_TOOLTIP_EDGE_PADDING; + // When HUD is near the right edge of its display, flip tooltip to the left of the bar + currentSide = screenLeft + requiredRoom > screenRight ? "left" : "right"; + } + setSide(currentSide); setClearance( computeHudTooltipClearance( trigger.getBoundingClientRect(), bar.getBoundingClientRect(), - side, + currentSide, ), ); } @@ -84,9 +95,12 @@ function HudTooltip({ content, children }: { content: string; children: ReactEle diff --git a/src/components/launch/LaunchWindow.tsx b/src/components/launch/LaunchWindow.tsx index 50df3e6450..884a37776c 100644 --- a/src/components/launch/LaunchWindow.tsx +++ b/src/components/launch/LaunchWindow.tsx @@ -158,7 +158,7 @@ export function LaunchWindow() { // False until the platform, and on macOS and Linux the native helper, have answered: the // cursor toggle is only shown once it is known to change something. const [supportsCursorModeToggle, setSupportsCursorModeToggle] = useState(false); - const [isLinuxHud, setIsLinuxHud] = useState(false); + const [isLinuxHud, setIsLinuxHud] = useState(() => getPlatform() === "linux"); // The running version, and whether this copy may offer an update check at all — a // Store/Flathub/Snap/Nix install is kept current by its package manager and is offered // nothing (electron/install-channel.ts). Asked once: neither answer changes while the app @@ -397,7 +397,13 @@ export function LaunchWindow() { // screen.availHeight (which a window resize can't change) and are pushed // down as CSS custom properties. // --------------------------------------------------------------------------- - const hudAllocatedSizeRef = useRef({ width: 0, height: 0, orientation: trayLayout }); + const hudAllocatedSizeRef = useRef({ + width: 0, + height: 0, + orientation: trayLayout, + popoverOpen: isPopoverOpen, + toastsHeight: 0, + }); // Last stack rect sent to the main process, so a measurement that changed nothing // costs no IPC. const lastSentHudContentRef = useRef<{ @@ -434,32 +440,49 @@ export function LaunchWindow() { ? noticeEl.getBoundingClientRect().height || noticeEl.scrollHeight : 0; - // The two floating surfaces get their own CSS caps (a 470px-tall language - // list would look absurd), but the *window* always reserves room for the - // taller of them. Sizing the reserve to whichever happens to be open would - // mean the window grows when the panel opens — and since the stack is - // bottom-anchored, growing upward moves every bit of content down in window - // coordinates, which the renderer repaints a frame or two after the native - // resize lands. That gap is visible as exactly the position judder this - // whole architecture exists to remove. So: reserve the maximum, always. const popoverMaxHeight = computeHudPopoverMaxHeight(barHeight, availableHeight); const modalMaxHeight = computeHudModalMaxHeight(barHeight, availableHeight); const anchorEl = hudAnchorRef.current; anchorEl?.style.setProperty("--hud-popover-max-h", `${popoverMaxHeight}px`); anchorEl?.style.setProperty("--hud-modal-max-h", `${modalMaxHeight}px`); - const { required, granted } = computeHudWindowSize({ + // On non-Linux platforms, the window always reserves the maximum popover height + // to prevent bottom-anchored position judder when panels open/close. + // On Linux, the HUD window is never click-through (setHudMouseEventsEnabled), + // so reserving 500+ px of empty height blocks desktop mouse clicks across + // the center of the screen. We make the trade-off here: accept the transient + // resize jump when opening popovers so the collapsed bar stays compact + // (reserving tooltip clearance: 86 px stack -> ~190 px total window height, + // growing for visible toasts and shrinking when they disappear). + const isLinux = isLinuxHud; + const collapsedLinux = isLinux && !isPopoverOpen; + const computed = computeHudWindowSize({ barWidth, barHeight, noticeHeight, availableHeight, - stackMaxHeight: Math.max(popoverMaxHeight, modalMaxHeight), + stackMaxHeight: collapsedLinux + ? Math.max(86, hudToastsHeightRef.current) + : Math.max(popoverMaxHeight, modalMaxHeight), }); + const required = computed.required; + const granted = collapsedLinux + ? { + // The vertical bar is narrow: the widest thing beside it is a notice or a toast, + // already in `required`. Its tooltips wrap to the room left (HudTooltip). + width: + trayLayout === "horizontal" ? Math.max(760, computed.granted.width) : required.width, + height: Math.max(190, required.height), + } + : computed.granted; const allocated = hudAllocatedSizeRef.current; // A different orientation is a different shape entirely (wide-short vs // narrow-tall), so the previous allocation says nothing useful. - const orientationChanged = allocated.orientation !== trayLayout; + const orientationChanged = + allocated.orientation !== trayLayout || + (isLinux && allocated.popoverOpen !== isPopoverOpen) || + (collapsedLinux && allocated.toastsHeight !== hudToastsHeightRef.current); // Grow the moment the content stops fitting. Shrink only once the content // has fallen a whole reserve below what was granted — that asymmetry is the // hysteresis: the bar can grow into its reserve (recording controls, a @@ -521,11 +544,13 @@ export function LaunchWindow() { } allocated.orientation = trayLayout; + allocated.popoverOpen = isPopoverOpen; + allocated.toastsHeight = hudToastsHeightRef.current; allocated.width = granted.width; allocated.height = granted.height; lastSentHudContentRef.current = grantedContent; window.electronAPI.setHudOverlaySize(granted.width, granted.height, grantedContent); - }, [trayLayout]); + }, [trayLayout, isPopoverOpen, isLinuxHud]); // One persistent observer; elements wire themselves up via callback refs as // they mount/unmount. The bar and the notice column size the window; the anchor diff --git a/src/components/launch/hudGeometry.ts b/src/components/launch/hudGeometry.ts index 5887343f6e..2cfdc0a7ad 100644 --- a/src/components/launch/hudGeometry.ts +++ b/src/components/launch/hudGeometry.ts @@ -233,10 +233,15 @@ export function addHudToastsToStack(stack: HudSize, toastsHeight: number): HudSi * tooltip opens, it holds for either orientation and either state. */ export function computeHudTooltipClearance( - trigger: { top: number; right: number }, - bar: { top: number; right: number }, - side: "top" | "right", + trigger: { top: number; right: number; left?: number }, + bar: { top: number; right: number; left?: number }, + side: "top" | "right" | "left", ): number { - const inset = side === "top" ? trigger.top - bar.top : bar.right - trigger.right; + const inset = + side === "top" + ? trigger.top - bar.top + : side === "left" + ? (trigger.left ?? 0) - (bar.left ?? 0) + : bar.right - trigger.right; return Math.max(0, Math.round(inset)); } diff --git a/src/components/ui/tooltip.tsx b/src/components/ui/tooltip.tsx index 6defa0101b..ad1ba3ca29 100644 --- a/src/components/ui/tooltip.tsx +++ b/src/components/ui/tooltip.tsx @@ -53,6 +53,39 @@ const TooltipTrigger = React.forwardRef< )); TooltipTrigger.displayName = "TooltipTrigger"; +// A box capped by `max-width` stays as wide as the cap once its text wraps: CSS sizes it +// before the lines are broken, so balanced lines leave an empty strip on the inline-end +// side. Once the lines are laid out, shrink the box to its longest line. The wrapping +// itself is untouched — only the empty strip goes. +function hugWrappedText(node: HTMLElement | null) { + if (!node) return; + node.style.width = ""; + // Radix also renders a visually hidden copy of the text (role="tooltip") for screen + // readers: measure only what is on screen. + const lines: DOMRect[] = []; + for (const child of Array.from(node.childNodes)) { + if (child instanceof Element && child.getAttribute("role") === "tooltip") continue; + const range = document.createRange(); + range.selectNode(child); + if (typeof range.getClientRects !== "function") return; + lines.push(...Array.from(range.getClientRects())); + } + if (lines.length < 2) return; // one line already fits its box + const box = node.getBoundingClientRect(); + if (box.width === 0) return; + // The open animation scales the box (zoom-in-95): measure in its unscaled pixels. + const scale = node.offsetWidth / box.width; + const longestLine = Math.max(...lines.map((line) => line.width)) * scale; + const style = window.getComputedStyle(node); + const chrome = + parseFloat(style.paddingLeft) + + parseFloat(style.paddingRight) + + parseFloat(style.borderLeftWidth) + + parseFloat(style.borderRightWidth); + const hugged = Math.ceil(longestLine + chrome); + if (hugged < node.offsetWidth) node.style.width = `${hugged}px`; +} + function TooltipContent({ className, sideOffset = TOOLTIP_GAP_PX, @@ -65,6 +98,7 @@ function TooltipContent({ // Follows the text's own direction, so Arabic starts at its own edge. dir="auto" sideOffset={sideOffset} + ref={hugWrappedText} className={cn( // Popper sizes its wrapper to `max-content`, so without a cap a long tooltip is one // long line. 260px is about 40 characters a line at this size. `text-balance` evens the