Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 27 additions & 8 deletions electron/windows.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand All @@ -217,7 +234,7 @@ function reclampHud() {
})
: clampHudBoundsToWorkArea(bounds, hudContentRect, workArea);
if (!sameRect(next, bounds)) {
win.setBounds(next, false);
setHudWindowBounds(win, next);
}
}

Expand Down Expand Up @@ -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);
});
Expand All @@ -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);
Expand Down
24 changes: 19 additions & 5 deletions src/components/launch/HudControls.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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<HTMLButtonElement | null>(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,
),
);
}
Expand All @@ -84,9 +95,12 @@ function HudTooltip({ content, children }: { content: string; children: ReactEle
<Tooltip
ref={triggerRef}
content={content}
side={side}
side={vertical ? side : "top"}
sideOffset={TOOLTIP_GAP_PX + clearance}
collisionPadding={HUD_TOOLTIP_EDGE_PADDING}
className={
vertical ? "max-w-[min(260px,var(--radix-tooltip-content-available-width))]" : undefined
}
open={open}
onOpenChange={handleOpenChange}
>
Expand Down
53 changes: 39 additions & 14 deletions src/components/launch/LaunchWindow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<{
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
13 changes: 9 additions & 4 deletions src/components/launch/hudGeometry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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));
}
34 changes: 34 additions & 0 deletions src/components/ui/tooltip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand Down
Loading