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
62 changes: 60 additions & 2 deletions apps/desktop/src/window/DesktopApplicationMenu.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,10 @@ const desktopUpdatesLayer = Layer.succeed(DesktopUpdates.DesktopUpdates, {
install: Effect.die("unexpected install"),
} satisfies DesktopUpdates.DesktopUpdates["Service"]);

const makeDesktopWindowLayer = (selectedAction: Deferred.Deferred<string>) =>
const makeDesktopWindowLayer = (
selectedAction: Deferred.Deferred<string>,
appearanceSynced?: Deferred.Deferred<void>,
) =>
Layer.succeed(DesktopWindow.DesktopWindow, {
createMain: Effect.die("unexpected createMain"),
ensureMain: Effect.die("unexpected ensureMain"),
Expand All @@ -79,7 +82,9 @@ const makeDesktopWindowLayer = (selectedAction: Deferred.Deferred<string>) =>
handleBackendNotReady: Effect.void,
flushMainWindowBounds: Effect.void,
dispatchMenuAction: (action) => Deferred.succeed(selectedAction, action).pipe(Effect.asVoid),
syncAppearance: Effect.void,
syncAppearance: appearanceSynced
? Deferred.succeed(appearanceSynced, undefined).pipe(Effect.asVoid)
: Effect.void,
} satisfies DesktopWindow.DesktopWindow["Service"]);

const makeElectronMenuLayer = (
Expand Down Expand Up @@ -136,4 +141,57 @@ describe("DesktopApplicationMenu", () => {
assert.equal(yield* Deferred.await(selectedAction), "open-settings");
}),
);

it.effect("updates zoom and syncs window appearance from the native View menu", () =>
Effect.gen(function* () {
const selectedAction = yield* Deferred.make<string>();
const appearanceSynced = yield* Deferred.make<void>();
const applicationMenuTemplate =
yield* Deferred.make<readonly Electron.MenuItemConstructorOptions[]>();

yield* Effect.gen(function* () {
const menu = yield* DesktopApplicationMenu.DesktopApplicationMenu;
yield* menu.configure;
}).pipe(
Effect.provide(
DesktopApplicationMenu.layer.pipe(
Layer.provideMerge(makeElectronMenuLayer(applicationMenuTemplate)),
Layer.provideMerge(makeDesktopWindowLayer(selectedAction, appearanceSynced)),
Layer.provideMerge(desktopUpdatesLayer),
Layer.provideMerge(electronDialogLayer),
Layer.provideMerge(electronAppLayer),
Layer.provideMerge(
DesktopEnvironment.layer(environmentInput).pipe(
Layer.provide(Layer.mergeAll(NodeServices.layer, DesktopConfig.layerTest({}))),
),
),
),
),
);

const template = yield* Deferred.await(applicationMenuTemplate);
const viewMenu = template.find((item) => item.label === "View");
assert.isDefined(viewMenu);
if (!Array.isArray(viewMenu.submenu)) {
throw new Error("Expected View menu submenu to be an array.");
}
const zoomInItem = viewMenu.submenu.find(
(item) => item.label === "Zoom In" && item.visible !== false,
);
assert.isDefined(zoomInItem);
if (typeof zoomInItem.click !== "function") {
throw new Error("Expected Zoom In menu item to have a click handler.");
}

const webContents = { zoomLevel: 1 };
zoomInItem.click(
{} as Electron.MenuItem,
{ webContents } as Electron.BrowserWindow,
{} as KeyboardEvent,
);

yield* Deferred.await(appearanceSynced);
assert.equal(webContents.zoomLevel, 1.5);
}),
);
});
53 changes: 49 additions & 4 deletions apps/desktop/src/window/DesktopApplicationMenu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ const handleCheckForUpdatesMenuClick = Effect.gen(function* () {
export const make = Effect.gen(function* () {
const electronApp = yield* ElectronApp.ElectronApp;
const electronMenu = yield* ElectronMenu.ElectronMenu;
const desktopWindow = yield* DesktopWindow.DesktopWindow;
const environment = yield* DesktopEnvironment.DesktopEnvironment;
const appName = yield* electronApp.name;
const context = yield* Effect.context<DesktopApplicationMenuRuntimeServices>();
Expand All @@ -120,6 +121,53 @@ export const make = Effect.gen(function* () {
);
};

const zoomClick =
(
action: string,
updateZoomLevel: (zoomLevel: number) => number,
): NonNullable<Electron.MenuItemConstructorOptions["click"]> =>
(_menuItem, window) => {
if (!window || !("webContents" in window)) {
return;
}

const webContents = window.webContents as Electron.WebContents;
webContents.zoomLevel = updateZoomLevel(webContents.zoomLevel);
runMenuEffect(action, desktopWindow.syncAppearance);
};

const zoomMenuItems: Electron.MenuItemConstructorOptions[] =
environment.platform === "darwin"
? [
{ role: "resetZoom" },
{ role: "zoomIn", accelerator: "CmdOrCtrl+=" },
{ role: "zoomIn", accelerator: "CmdOrCtrl+Plus", visible: false },
{ role: "zoomOut" },
]
: [
{
label: "Actual Size",
accelerator: "CmdOrCtrl+0",
click: zoomClick("reset-zoom", () => 0),
},
{
label: "Zoom In",
accelerator: "CmdOrCtrl+=",
click: zoomClick("zoom-in", (zoomLevel) => zoomLevel + 0.5),
},
{
label: "Zoom In",
accelerator: "CmdOrCtrl+Plus",
visible: false,
click: zoomClick("zoom-in", (zoomLevel) => zoomLevel + 0.5),
},
{
label: "Zoom Out",
accelerator: "CmdOrCtrl+-",
click: zoomClick("zoom-out", (zoomLevel) => zoomLevel - 0.5),
},
];

const configure = Effect.gen(function* () {
const checkForUpdatesClick = () => {
runMenuEffect("check-for-updates", handleCheckForUpdatesMenuClick);
Expand Down Expand Up @@ -181,10 +229,7 @@ export const make = Effect.gen(function* () {
{ role: "forceReload" },
{ role: "toggleDevTools" },
{ type: "separator" },
{ role: "resetZoom" },
{ role: "zoomIn", accelerator: "CmdOrCtrl+=" },
{ role: "zoomIn", accelerator: "CmdOrCtrl+Plus", visible: false },
{ role: "zoomOut" },
...zoomMenuItems,
{ type: "separator" },
{ role: "togglefullscreen" },
],
Expand Down
89 changes: 76 additions & 13 deletions apps/desktop/src/window/DesktopWindow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,12 +58,13 @@ const environmentInput = {
runningUnderArm64Translation: false,
} satisfies DesktopEnvironment.MakeDesktopEnvironmentInput;

function makeFakeBrowserWindow() {
function makeFakeBrowserWindow(initialZoomFactor = 1) {
const windowListeners = new Map<string, (...args: readonly unknown[]) => void>();
const webContentsListeners = new Map<string, (...args: readonly unknown[]) => void>();
const webContents = {
copyImageAt: vi.fn(),
getURL: vi.fn(() => "t3code-dev://app/"),
getZoomFactor: vi.fn(() => initialZoomFactor),
isLoadingMainFrame: vi.fn(() => false),
on: vi.fn((eventName: string, listener: (...args: readonly unknown[]) => void) => {
webContentsListeners.set(eventName, listener);
Expand Down Expand Up @@ -107,6 +108,7 @@ function makeFakeBrowserWindow() {
window: window as unknown as Electron.BrowserWindow,
getBounds: window.getBounds,
getNormalBounds: window.getNormalBounds,
getZoomFactor: webContents.getZoomFactor,
isDestroyed: window.isDestroyed,
isFullScreen: window.isFullScreen,
isMaximized: window.isMaximized,
Expand All @@ -117,6 +119,7 @@ function makeFakeBrowserWindow() {
reload: webContents.reload,
send: webContents.send,
setAutoHideCursor: window.setAutoHideCursor,
setTitleBarOverlay: window.setTitleBarOverlay,
webContentsListeners,
windowListeners,
};
Expand Down Expand Up @@ -158,17 +161,18 @@ const electronThemeLayer = Layer.succeed(ElectronTheme.ElectronTheme, {
onUpdated: () => Effect.void,
} satisfies ElectronTheme.ElectronTheme["Service"]);

const desktopEnvironmentLayer = DesktopEnvironment.layer(environmentInput).pipe(
Layer.provide(
Layer.mergeAll(
NodeServices.layer,
DesktopConfig.layerTest({
T3CODE_PORT: "3773",
VITE_DEV_SERVER_URL: "http://127.0.0.1:5733",
}),
const makeDesktopEnvironmentLayer = (platform: NodeJS.Platform = environmentInput.platform) =>
DesktopEnvironment.layer({ ...environmentInput, platform }).pipe(
Layer.provide(
Layer.mergeAll(
NodeServices.layer,
DesktopConfig.layerTest({
T3CODE_PORT: "3773",
VITE_DEV_SERVER_URL: "http://127.0.0.1:5733",
}),
),
),
),
);
);

const desktopWindowBoundsEquivalence = Schema.toEquivalence(
DesktopAppSettings.DesktopWindowBoundsSchema,
Expand All @@ -186,6 +190,7 @@ function makeTestLayer(input: {
bounds: DesktopAppSettings.DesktopWindowBounds,
) => Effect.Effect<void>;
readonly openedExternalUrls?: unknown[];
readonly platform?: NodeJS.Platform;
}) {
let desktopSettings = input.desktopSettings ?? DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS;
const desktopAppSettingsLayer = Layer.succeed(DesktopAppSettings.DesktopAppSettings, {
Expand Down Expand Up @@ -244,7 +249,7 @@ function makeTestLayer(input: {
Layer.provide(
Layer.mergeAll(
desktopAssetsLayer,
desktopEnvironmentLayer,
makeDesktopEnvironmentLayer(input.platform),
desktopAppSettingsLayer,
desktopServerExposureLayer,
DesktopState.layer,
Expand Down Expand Up @@ -343,7 +348,7 @@ const makeSplashScenario = (createOutcomes: readonly (Electron.BrowserWindow | n
Layer.provide(
Layer.mergeAll(
desktopAssetsLayer,
desktopEnvironmentLayer,
makeDesktopEnvironmentLayer(),
DesktopAppSettings.layerTest(),
desktopServerExposureLayer,
electronMenuLayer,
Expand Down Expand Up @@ -405,6 +410,64 @@ describe("DesktopWindow", () => {
);
});

it("grows the title bar overlay with renderer zoom", () => {
assert.equal(DesktopWindow.resolveTitleBarOverlayHeight(0.8), 40);
assert.equal(DesktopWindow.resolveTitleBarOverlayHeight(1), 40);
assert.equal(DesktopWindow.resolveTitleBarOverlayHeight(1.5), 60);
assert.equal(DesktopWindow.resolveTitleBarOverlayHeight(Number.NaN), 40);
});

it.effect("keeps the Linux title bar overlay in sync with renderer zoom", () =>
Effect.gen(function* () {
const fakeWindow = makeFakeBrowserWindow(1.5);
const createCount = yield* Ref.make(0);
const mainWindow = yield* Ref.make<Option.Option<Electron.BrowserWindow>>(Option.none());
const layer = makeTestLayer({
window: fakeWindow.window,
createCount,
mainWindow,
platform: "linux",
});

yield* Effect.gen(function* () {
const desktopWindow = yield* DesktopWindow.DesktopWindow;
yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773"));

const didFinishLoad = fakeWindow.webContentsListeners.get("did-finish-load");
const beforeInputEvent = fakeWindow.webContentsListeners.get("before-input-event");
if (!didFinishLoad || !beforeInputEvent) {
return yield* Effect.die("renderer zoom listeners were not registered");
}

didFinishLoad();
assert.deepEqual(fakeWindow.setTitleBarOverlay.mock.calls.at(-1), [{ height: 60 }]);

yield* desktopWindow.syncAppearance;
assert.deepEqual(fakeWindow.setTitleBarOverlay.mock.calls.at(-1), [
{
color: "#01000000",
height: 60,
symbolColor: "#1f2937",
},
]);

fakeWindow.getZoomFactor.mockReturnValue(2);
beforeInputEvent(
{},
{
type: "keyDown",
control: true,
meta: false,
alt: false,
key: "+",
},
);
yield* Effect.promise(() => new Promise<void>((resolve) => setImmediate(resolve)));
assert.deepEqual(fakeWindow.setTitleBarOverlay.mock.calls.at(-1), [{ height: 80 }]);
}).pipe(Effect.provide(layer));
}),
);

it.effect("does not open a development window until the backend is ready", () =>
Effect.gen(function* () {
const fakeWindow = makeFakeBrowserWindow();
Expand Down
50 changes: 48 additions & 2 deletions apps/desktop/src/window/DesktopWindow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,7 @@ export function isRetryableDevelopmentRendererLoadFailure(input: {
function getWindowTitleBarOptions(
shouldUseDarkColors: boolean,
platform: NodeJS.Platform,
zoomFactor = 1,
): WindowTitleBarOptions {
if (platform === "darwin") {
return {
Expand All @@ -196,12 +197,41 @@ function getWindowTitleBarOptions(
titleBarStyle: "hidden",
titleBarOverlay: {
color: TITLEBAR_COLOR,
height: TITLEBAR_HEIGHT,
height: resolveTitleBarOverlayHeight(zoomFactor),
symbolColor: shouldUseDarkColors ? TITLEBAR_DARK_SYMBOL_COLOR : TITLEBAR_LIGHT_SYMBOL_COLOR,
},
};
}

export function resolveTitleBarOverlayHeight(zoomFactor: number): number {
if (!Number.isFinite(zoomFactor) || zoomFactor <= 1) {
return TITLEBAR_HEIGHT;
}
return Math.round(TITLEBAR_HEIGHT * zoomFactor);
}

function syncWindowTitleBarHeight(window: Electron.BrowserWindow, platform: NodeJS.Platform): void {
if (platform === "darwin" || window.isDestroyed()) {
return;
}
window.setTitleBarOverlay({
height: resolveTitleBarOverlayHeight(window.webContents.getZoomFactor()),
});
}

function isRendererZoomShortcut(
input: Pick<Electron.Input, "alt" | "control" | "key" | "meta" | "type">,
platform: NodeJS.Platform,
): boolean {
const modifierPressed = platform === "darwin" ? input.meta : input.control;
return (
input.type === "keyDown" &&
modifierPressed &&
!input.alt &&
["+", "=", "-", "_", "0", ")"].includes(input.key)
);
}

function syncWindowAppearance(
window: Electron.BrowserWindow,
shouldUseDarkColors: boolean,
Expand All @@ -213,7 +243,11 @@ function syncWindowAppearance(
}

window.setBackgroundColor(getInitialWindowBackgroundColor(shouldUseDarkColors));
const { titleBarOverlay } = getWindowTitleBarOptions(shouldUseDarkColors, platform);
const { titleBarOverlay } = getWindowTitleBarOptions(
shouldUseDarkColors,
platform,
window.webContents.getZoomFactor(),
);
if (typeof titleBarOverlay === "object") {
window.setTitleBarOverlay(titleBarOverlay);
}
Expand Down Expand Up @@ -590,7 +624,19 @@ export const make = Effect.gen(function* () {
clearDevelopmentLoadRetry();
developmentLoadRetryIndex = 0;
window.setTitle(environment.displayName);
syncWindowTitleBarHeight(window, environment.platform);
});
if (environment.platform !== "darwin") {
const syncTitleBarHeightAfterZoom = () => {
setImmediate(() => syncWindowTitleBarHeight(window, environment.platform));
};
window.webContents.on("zoom-changed", syncTitleBarHeightAfterZoom);
window.webContents.on("before-input-event", (_event, input) => {
if (isRendererZoomShortcut(input, environment.platform)) {
syncTitleBarHeightAfterZoom();
}
});
}
window.webContents.on(
"did-fail-load",
(_event, errorCode, errorDescription, validatedURL, isMainFrame) => {
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/components/sidebar/SidebarChrome.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ export const SidebarChromeHeader = memo(function SidebarChromeHeader({
return (
<SidebarHeader
className={cn(
"@container/sidebar-header relative h-[var(--workspace-topbar-height)] shrink-0 flex-row items-center px-3 py-0 md:px-0",
"@container/sidebar-header relative h-[var(--workspace-topbar-height)] shrink-0 flex-row items-center px-3 py-2 md:px-0",
isElectron && "drag-region",
)}
>
Expand Down
Loading