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
31 changes: 31 additions & 0 deletions apps/desktop/src/contextMenuPosition.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { describe, expect, it } from "vite-plus/test";

import { scaleContextMenuPositionForElectron } from "./contextMenuPosition.ts";

describe("scaleContextMenuPositionForElectron", () => {
it("scales CSS pixel positions by the renderer zoom factor", () => {
expect(scaleContextMenuPositionForElectron({ x: 120, y: 80 }, 0.8)).toEqual({
x: 96,
y: 64,
});
expect(scaleContextMenuPositionForElectron({ x: 120, y: 80 }, 1.25)).toEqual({
x: 150,
y: 100,
});
});

it("leaves missing positions missing", () => {
expect(scaleContextMenuPositionForElectron(undefined, 0.8)).toBeUndefined();
});

it("falls back to unscaled coordinates for invalid zoom factors", () => {
expect(scaleContextMenuPositionForElectron({ x: 120, y: 80 }, 0)).toEqual({
x: 120,
y: 80,
});
expect(scaleContextMenuPositionForElectron({ x: 120, y: 80 }, Number.NaN)).toEqual({
x: 120,
y: 80,
});
});
});
19 changes: 19 additions & 0 deletions apps/desktop/src/contextMenuPosition.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
export interface ContextMenuPosition {
readonly x: number;
readonly y: number;
}

export function scaleContextMenuPositionForElectron(
position: ContextMenuPosition | undefined,
zoomFactor: number,
): ContextMenuPosition | undefined {
if (position === undefined) {
return undefined;
}

const normalizedZoomFactor = Number.isFinite(zoomFactor) && zoomFactor > 0 ? zoomFactor : 1;
return {
x: position.x * normalizedZoomFactor,
y: position.y * normalizedZoomFactor,
};
}
39 changes: 39 additions & 0 deletions apps/desktop/src/desktopZoom.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { describe, expect, it } from "vite-plus/test";

import { normalizeDesktopZoomFactor, resolveDesktopZoomCssVariables } from "./desktopZoom.ts";

describe("desktopZoom", () => {
it("gradually reduces zoomed-out titlebar leading spacing", () => {
expect(resolveDesktopZoomCssVariables(0.8)).toEqual({
titlebarHeight: "65px",
titlebarLeadingOffset: "106.5px",
});
expect(resolveDesktopZoomCssVariables(0.6)).toEqual({
titlebarHeight: "86.66666666666667px",
titlebarLeadingOffset: "136.66666666666669px",
});
});

it("keeps a minimum leading offset clear of the traffic lights", () => {
expect(resolveDesktopZoomCssVariables(0.4)).toEqual({
titlebarHeight: "130px",
titlebarLeadingOffset: "205px",
});
});

it("compensates titlebar spacing when zoomed in", () => {
expect(resolveDesktopZoomCssVariables(1.25)).toEqual({
titlebarHeight: "41.6px",
titlebarLeadingOffset: "72px",
});
});

it("normalizes invalid zoom factors", () => {
expect(normalizeDesktopZoomFactor(0)).toBe(1);
expect(normalizeDesktopZoomFactor(Number.NaN)).toBe(1);
expect(resolveDesktopZoomCssVariables(0)).toEqual({
titlebarHeight: "52px",
titlebarLeadingOffset: "90px",
});
});
});
30 changes: 30 additions & 0 deletions apps/desktop/src/desktopZoom.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
export interface DesktopZoomCssVariables {
readonly titlebarHeight: string;
readonly titlebarLeadingOffset: string;
}

export const DESKTOP_TITLEBAR_HEIGHT_PX = 52;
export const DESKTOP_TITLEBAR_LEADING_OFFSET_PX = 90;
export const DESKTOP_TITLEBAR_MIN_SCREEN_LEADING_OFFSET_PX = 82;
export const DESKTOP_TITLEBAR_ZOOM_OUT_SCREEN_OFFSET_STEP_PX = 24;

export function normalizeDesktopZoomFactor(zoomFactor: number): number {
return Number.isFinite(zoomFactor) && zoomFactor > 0 ? zoomFactor : 1;
}

export function resolveDesktopZoomCssVariables(zoomFactor: number): DesktopZoomCssVariables {
const normalizedZoomFactor = normalizeDesktopZoomFactor(zoomFactor);
const screenLeadingOffset =
normalizedZoomFactor >= 1
? DESKTOP_TITLEBAR_LEADING_OFFSET_PX
: Math.max(
DESKTOP_TITLEBAR_MIN_SCREEN_LEADING_OFFSET_PX,
DESKTOP_TITLEBAR_LEADING_OFFSET_PX -
(1 - normalizedZoomFactor) * DESKTOP_TITLEBAR_ZOOM_OUT_SCREEN_OFFSET_STEP_PX,
);

return {
titlebarHeight: `${DESKTOP_TITLEBAR_HEIGHT_PX / normalizedZoomFactor}px`,
titlebarLeadingOffset: `${screenLeadingOffset / normalizedZoomFactor}px`,
};
}
1 change: 1 addition & 0 deletions apps/desktop/src/ipc/channels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export const CLEAR_CLOUD_AUTH_TOKEN_CHANNEL = "desktop:clear-cloud-auth-token";
export const FETCH_CLOUD_AUTH_CHANNEL = "desktop:fetch-cloud-auth";
export const CLOUD_AUTH_CALLBACK_CHANNEL = "desktop:cloud-auth-callback";
export const MENU_ACTION_CHANNEL = "desktop:menu-action";
export const ZOOM_FACTOR_CHANGED_CHANNEL = "desktop:zoom-factor-changed";
export const UPDATE_STATE_CHANNEL = "desktop:update-state";
export const UPDATE_GET_STATE_CHANNEL = "desktop:update-get-state";
export const UPDATE_SET_CHANNEL_CHANNEL = "desktop:update-set-channel";
Expand Down
54 changes: 52 additions & 2 deletions apps/desktop/src/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,57 @@ import type {
DesktopPreviewRecordingFrame,
DesktopPreviewTabState,
} from "@t3tools/contracts";
import { contextBridge, ipcRenderer } from "electron";
import { contextBridge, ipcRenderer, webFrame } from "electron";

import { normalizeDesktopZoomFactor, resolveDesktopZoomCssVariables } from "./desktopZoom.ts";
import * as IpcChannels from "./ipc/channels.ts";
import { scaleContextMenuPositionForElectron } from "./contextMenuPosition.ts";

let lastAppliedDesktopZoomFactor: number | undefined;
let pendingDesktopZoomSyncFrame: number | null = null;

function applyDesktopZoomCssVariables(zoomFactor: number) {
const normalizedZoomFactor = normalizeDesktopZoomFactor(zoomFactor);
if (lastAppliedDesktopZoomFactor === normalizedZoomFactor) {
return;
}
lastAppliedDesktopZoomFactor = normalizedZoomFactor;

const variables = resolveDesktopZoomCssVariables(normalizedZoomFactor);
document.documentElement.style.setProperty("--desktop-zoom-factor", String(normalizedZoomFactor));
document.documentElement.style.setProperty("--desktop-titlebar-height", variables.titlebarHeight);
document.documentElement.style.setProperty(
"--desktop-titlebar-leading-offset",
variables.titlebarLeadingOffset,
);
}

function syncDesktopZoomCssVariables() {
applyDesktopZoomCssVariables(webFrame.getZoomFactor());
}

function scheduleDesktopZoomCssVariableSync() {
if (pendingDesktopZoomSyncFrame !== null) {
return;
}
pendingDesktopZoomSyncFrame = window.requestAnimationFrame(() => {
pendingDesktopZoomSyncFrame = null;
syncDesktopZoomCssVariables();
});
}

if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", syncDesktopZoomCssVariables, { once: true });
} else {
syncDesktopZoomCssVariables();
}
window.addEventListener("resize", scheduleDesktopZoomCssVariableSync);
window.visualViewport?.addEventListener("resize", scheduleDesktopZoomCssVariableSync);

ipcRenderer.on(IpcChannels.ZOOM_FACTOR_CHANGED_CHANNEL, (_event, zoomFactor: unknown) => {
if (typeof zoomFactor !== "number") return;
applyDesktopZoomCssVariables(zoomFactor);
});

function unwrapEnsureSshEnvironmentResult(result: unknown) {
if (
Expand Down Expand Up @@ -98,7 +146,9 @@ contextBridge.exposeInMainWorld("desktopBridge", {
showContextMenu: (items, position) =>
ipcRenderer.invoke(IpcChannels.CONTEXT_MENU_CHANNEL, {
items,
...(position === undefined ? {} : { position }),
...(position === undefined
? {}
: { position: scaleContextMenuPositionForElectron(position, webFrame.getZoomFactor()) }),
}),
openExternal: (url: string) => ipcRenderer.invoke(IpcChannels.OPEN_EXTERNAL_CHANNEL, url),
createCloudAuthRequest: () => ipcRenderer.invoke(IpcChannels.CREATE_CLOUD_AUTH_REQUEST_CHANNEL),
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/window/DesktopWindow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ function makeFakeBrowserWindow() {
openDevTools: vi.fn(),
replaceMisspelling: vi.fn(),
send: vi.fn(),
getZoomFactor: vi.fn(() => 1),
setWindowOpenHandler: vi.fn(),
};

Expand Down
10 changes: 10 additions & 0 deletions apps/desktop/src/window/DesktopWindow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -274,6 +274,16 @@ const make = Effect.gen(function* () {
void runPromise(electronMenu.popupTemplate({ window, template: menuTemplate }));
});

const syncZoomCssVariables = () => {
if (window.isDestroyed()) return;
window.webContents.send(
IpcChannels.ZOOM_FACTOR_CHANGED_CHANNEL,
window.webContents.getZoomFactor(),
);
};
window.webContents.on("zoom-changed", syncZoomCssVariables);
window.webContents.on("did-finish-load", syncZoomCssVariables);

window.webContents.setWindowOpenHandler(({ url }) => {
if (Option.isSome(ElectronShell.parseSafeExternalUrl(url))) {
void runPromise(electronShell.openExternal(url));
Expand Down
79 changes: 76 additions & 3 deletions apps/web/src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,15 @@ import {
} from "./ThreadStatusIndicators";
import { ProjectFavicon } from "./ProjectFavicon";
import { autoAnimate } from "@formkit/auto-animate";
import React, { useCallback, useEffect, memo, useMemo, useRef, useState } from "react";
import React, {
useCallback,
useEffect,
useLayoutEffect,
memo,
useMemo,
useRef,
useState,
} from "react";
import { useShallow } from "zustand/react/shallow";
import {
DndContext,
Expand Down Expand Up @@ -2555,8 +2563,70 @@ const SidebarChromeHeader = memo(function SidebarChromeHeader({
}: {
isElectron: boolean;
}) {
const headerRef = useRef<HTMLDivElement | null>(null);
const wordmarkRef = useRef<HTMLDivElement | null>(null);
const [isWordmarkVisible, setIsWordmarkVisible] = useState(true);

useLayoutEffect(() => {
if (!isElectron) {
setIsWordmarkVisible(true);
return;
}

const headerElement = headerRef.current;
const wordmarkElement = wordmarkRef.current;
if (!headerElement || !wordmarkElement) {
return;
}

let frame: number | null = null;
const updateWordmarkVisibility = () => {
frame = null;
const style = window.getComputedStyle(headerElement);
const availableWidth =
headerElement.clientWidth -
(Number.parseFloat(style.paddingLeft) || 0) -
(Number.parseFloat(style.paddingRight) || 0);
const requiredWidth = wordmarkElement.getBoundingClientRect().width;
setIsWordmarkVisible(requiredWidth <= availableWidth + 1);
};

const scheduleUpdate = () => {
if (frame !== null) {
return;
}
frame = window.requestAnimationFrame(updateWordmarkVisibility);
};

scheduleUpdate();
const resizeObserver =
typeof ResizeObserver === "undefined"
? null
: new ResizeObserver(() => {
scheduleUpdate();
});
resizeObserver?.observe(headerElement);
resizeObserver?.observe(wordmarkElement);
window.addEventListener("resize", scheduleUpdate);
window.visualViewport?.addEventListener("resize", scheduleUpdate);

return () => {
if (frame !== null) {
window.cancelAnimationFrame(frame);
}
resizeObserver?.disconnect();
window.removeEventListener("resize", scheduleUpdate);
window.visualViewport?.removeEventListener("resize", scheduleUpdate);
};
}, [isElectron]);

const wordmark = (
<div className="flex items-center gap-2">
<div
ref={wordmarkRef}
className={`flex items-center gap-2 ${
isWordmarkVisible ? "" : "invisible pointer-events-none"
}`}
>
<SidebarTrigger className="shrink-0 md:hidden" />
<Tooltip>
<TooltipTrigger
Expand Down Expand Up @@ -2584,7 +2654,10 @@ const SidebarChromeHeader = memo(function SidebarChromeHeader({
);

return isElectron ? (
<SidebarHeader className="drag-region h-[52px] flex-row items-center gap-2 px-4 py-0 pl-[90px] wco:h-[env(titlebar-area-height)] wco:pl-[calc(env(titlebar-area-x)+1em)]">
<SidebarHeader
ref={headerRef}
className="drag-region h-[var(--desktop-titlebar-height,52px)] flex-row items-center gap-2 px-4 py-0 pl-[var(--desktop-titlebar-leading-offset,90px)] wco:h-[env(titlebar-area-height)] wco:pl-[calc(env(titlebar-area-x)+1em)]"
>
{wordmark}
</SidebarHeader>
) : (
Expand Down
Loading