refactor: migrate to VS Code titlebar drag region pattern
This commit is contained in:
@@ -13,7 +13,7 @@ import {
|
||||
import { closeAllTransportSessions } from "./daemon/local-transport.js";
|
||||
import {
|
||||
registerWindowManager,
|
||||
getTitleBarOverlayOptions,
|
||||
getMainWindowChromeOptions,
|
||||
resolveSystemWindowTheme,
|
||||
setupWindowResizeEvents,
|
||||
setupDragDropPrevention,
|
||||
@@ -86,21 +86,18 @@ function applyAppIcon(): void {
|
||||
}
|
||||
|
||||
async function createMainWindow(): Promise<void> {
|
||||
const isMac = process.platform === "darwin";
|
||||
const iconPath = getWindowIconPath();
|
||||
const systemTheme = resolveSystemWindowTheme();
|
||||
|
||||
const mainWindow = new BrowserWindow({
|
||||
width: 1200,
|
||||
height: 800,
|
||||
show: false,
|
||||
...(iconPath ? { icon: iconPath } : {}),
|
||||
titleBarStyle: "hidden",
|
||||
...(isMac
|
||||
? { trafficLightPosition: { x: 16, y: 14 } }
|
||||
: {
|
||||
titleBarOverlay: getTitleBarOverlayOptions(resolveSystemWindowTheme()),
|
||||
autoHideMenuBar: true,
|
||||
}),
|
||||
...getMainWindowChromeOptions({
|
||||
platform: process.platform,
|
||||
theme: systemTheme,
|
||||
}),
|
||||
webPreferences: {
|
||||
preload: getPreloadPath(),
|
||||
contextIsolation: true,
|
||||
|
||||
@@ -19,15 +19,13 @@ contextBridge.exposeInMainWorld("paseoDesktop", {
|
||||
},
|
||||
window: {
|
||||
getCurrentWindow: () => ({
|
||||
startMove: (screenX: number, screenY: number) =>
|
||||
ipcRenderer.send("paseo:window:startMove", { screenX, screenY }),
|
||||
moving: (screenX: number, screenY: number) =>
|
||||
ipcRenderer.send("paseo:window:moving", { screenX, screenY }),
|
||||
endMove: () => ipcRenderer.send("paseo:window:endMove"),
|
||||
toggleMaximize: () => ipcRenderer.invoke("paseo:window:toggleMaximize"),
|
||||
isFullscreen: () => ipcRenderer.invoke("paseo:window:isFullscreen"),
|
||||
setTitleBarTheme: (theme: "light" | "dark") =>
|
||||
ipcRenderer.invoke("paseo:window:setTitleBarTheme", theme),
|
||||
updateWindowControls: (update: {
|
||||
height?: number;
|
||||
backgroundColor?: string;
|
||||
foregroundColor?: string;
|
||||
}) => ipcRenderer.invoke("paseo:window:updateWindowControls", update),
|
||||
onResized: (handler: EventHandler): (() => void) => {
|
||||
const listener = (_ipcEvent: Electron.IpcRendererEvent, payload: unknown) => {
|
||||
handler(payload);
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
createWindowMoveState,
|
||||
readWindowMovePayload,
|
||||
resolveWindowMovePosition,
|
||||
} from "./window-drag-coordinates";
|
||||
|
||||
describe("window-drag-coordinates", () => {
|
||||
describe("readWindowMovePayload", () => {
|
||||
it("returns finite screen coordinates", () => {
|
||||
expect(readWindowMovePayload({ screenX: 1500, screenY: 900 })).toEqual({
|
||||
screenX: 1500,
|
||||
screenY: 900,
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects non-finite screen coordinates", () => {
|
||||
expect(readWindowMovePayload({ screenX: Number.NaN, screenY: 900 })).toBeNull();
|
||||
expect(
|
||||
readWindowMovePayload({ screenX: 1500, screenY: Number.POSITIVE_INFINITY }),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("createWindowMoveState", () => {
|
||||
it("derives finite offsets from the window position", () => {
|
||||
expect(
|
||||
createWindowMoveState({
|
||||
payload: { screenX: 1500, screenY: 900 },
|
||||
windowX: 1200,
|
||||
windowY: 700,
|
||||
}),
|
||||
).toEqual({
|
||||
offsetX: 300,
|
||||
offsetY: 200,
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects non-finite offsets", () => {
|
||||
expect(
|
||||
createWindowMoveState({
|
||||
payload: { screenX: 1500, screenY: 900 },
|
||||
windowX: Number.NaN,
|
||||
windowY: 700,
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveWindowMovePosition", () => {
|
||||
it("rounds the next window position", () => {
|
||||
expect(
|
||||
resolveWindowMovePosition({
|
||||
payload: { screenX: 1501.4, screenY: 902.6 },
|
||||
state: { offsetX: 300, offsetY: 200 },
|
||||
}),
|
||||
).toEqual({
|
||||
x: 1201,
|
||||
y: 703,
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects non-finite next positions", () => {
|
||||
expect(
|
||||
resolveWindowMovePosition({
|
||||
payload: { screenX: 1500, screenY: 900 },
|
||||
state: { offsetX: Number.NaN, offsetY: 200 },
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,64 +0,0 @@
|
||||
export type WindowMovePayload = {
|
||||
screenX: number;
|
||||
screenY: number;
|
||||
};
|
||||
|
||||
export type WindowMoveState = {
|
||||
offsetX: number;
|
||||
offsetY: number;
|
||||
};
|
||||
|
||||
export type WindowPosition = {
|
||||
x: number;
|
||||
y: number;
|
||||
};
|
||||
|
||||
type WindowMovePayloadInput =
|
||||
| {
|
||||
screenX?: unknown;
|
||||
screenY?: unknown;
|
||||
}
|
||||
| null
|
||||
| undefined;
|
||||
|
||||
function isFiniteCoordinate(value: unknown): value is number {
|
||||
return typeof value === "number" && Number.isFinite(value);
|
||||
}
|
||||
|
||||
export function readWindowMovePayload(input: WindowMovePayloadInput): WindowMovePayload | null {
|
||||
if (!isFiniteCoordinate(input?.screenX) || !isFiniteCoordinate(input?.screenY)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
screenX: input.screenX,
|
||||
screenY: input.screenY,
|
||||
};
|
||||
}
|
||||
|
||||
export function createWindowMoveState(input: {
|
||||
payload: WindowMovePayload;
|
||||
windowX: number;
|
||||
windowY: number;
|
||||
}): WindowMoveState | null {
|
||||
const offsetX = input.payload.screenX - input.windowX;
|
||||
const offsetY = input.payload.screenY - input.windowY;
|
||||
if (!Number.isFinite(offsetX) || !Number.isFinite(offsetY)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { offsetX, offsetY };
|
||||
}
|
||||
|
||||
export function resolveWindowMovePosition(input: {
|
||||
payload: WindowMovePayload;
|
||||
state: WindowMoveState;
|
||||
}): WindowPosition | null {
|
||||
const x = Math.round(input.payload.screenX - input.state.offsetX);
|
||||
const y = Math.round(input.payload.screenY - input.state.offsetY);
|
||||
if (!Number.isFinite(x) || !Number.isFinite(y)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { x, y };
|
||||
}
|
||||
@@ -1,6 +1,15 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { getTitleBarOverlayOptions, readBadgeCount, readWindowTheme } from "./window-manager";
|
||||
import {
|
||||
applyWindowControlsOverlayUpdate,
|
||||
createWindowControlsOverlayState,
|
||||
getMainWindowChromeOptions,
|
||||
getTitleBarOverlayOptions,
|
||||
readBadgeCount,
|
||||
readWindowControlsOverlayUpdate,
|
||||
readWindowTheme,
|
||||
resolveRuntimeTitleBarOverlayOptions,
|
||||
} from "./window-manager";
|
||||
|
||||
describe("window-manager", () => {
|
||||
describe("readBadgeCount", () => {
|
||||
@@ -39,7 +48,7 @@ describe("window-manager", () => {
|
||||
expect(getTitleBarOverlayOptions("light")).toEqual({
|
||||
color: "#ffffff",
|
||||
symbolColor: "#09090b",
|
||||
height: 48,
|
||||
height: 29,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -47,7 +56,133 @@ describe("window-manager", () => {
|
||||
expect(getTitleBarOverlayOptions("dark")).toEqual({
|
||||
color: "#18181c",
|
||||
symbolColor: "#e4e4e7",
|
||||
height: 29,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("readWindowControlsOverlayUpdate", () => {
|
||||
it("accepts partial runtime overlay updates", () => {
|
||||
expect(
|
||||
readWindowControlsOverlayUpdate({
|
||||
height: 48,
|
||||
backgroundColor: "#18181c",
|
||||
}),
|
||||
).toEqual({
|
||||
height: 48,
|
||||
backgroundColor: "#18181c",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects empty and invalid payloads", () => {
|
||||
expect(readWindowControlsOverlayUpdate(undefined)).toBeNull();
|
||||
expect(readWindowControlsOverlayUpdate({})).toBeNull();
|
||||
expect(readWindowControlsOverlayUpdate({ height: 0 })).toBeNull();
|
||||
expect(readWindowControlsOverlayUpdate({ backgroundColor: 12 })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveRuntimeTitleBarOverlayOptions", () => {
|
||||
it("applies the VS Code height minus border adjustment", () => {
|
||||
expect(
|
||||
resolveRuntimeTitleBarOverlayOptions({
|
||||
height: 48,
|
||||
backgroundColor: "#ffffff",
|
||||
foregroundColor: "#09090b",
|
||||
}),
|
||||
).toEqual({
|
||||
color: "#ffffff",
|
||||
symbolColor: "#09090b",
|
||||
height: 47,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyWindowControlsOverlayUpdate", () => {
|
||||
it("merges cached colors with later runtime height updates", () => {
|
||||
const setTitleBarOverlay = vi.fn();
|
||||
let state = createWindowControlsOverlayState("dark");
|
||||
|
||||
state = applyWindowControlsOverlayUpdate({
|
||||
win: { setTitleBarOverlay },
|
||||
current: state,
|
||||
update: {
|
||||
backgroundColor: "#18181c",
|
||||
foregroundColor: "#e4e4e7",
|
||||
},
|
||||
});
|
||||
|
||||
state = applyWindowControlsOverlayUpdate({
|
||||
win: { setTitleBarOverlay },
|
||||
current: state,
|
||||
update: { height: 48 },
|
||||
});
|
||||
|
||||
expect(state).toEqual({
|
||||
height: 48,
|
||||
backgroundColor: "#18181c",
|
||||
foregroundColor: "#e4e4e7",
|
||||
});
|
||||
expect(setTitleBarOverlay).toHaveBeenNthCalledWith(1, {
|
||||
color: "#18181c",
|
||||
symbolColor: "#e4e4e7",
|
||||
height: 28,
|
||||
});
|
||||
expect(setTitleBarOverlay).toHaveBeenNthCalledWith(2, {
|
||||
color: "#18181c",
|
||||
symbolColor: "#e4e4e7",
|
||||
height: 47,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("getMainWindowChromeOptions", () => {
|
||||
it("uses frameless hidden title bars with overlay on windows", () => {
|
||||
expect(
|
||||
getMainWindowChromeOptions({
|
||||
platform: "win32",
|
||||
theme: "dark",
|
||||
}),
|
||||
).toEqual({
|
||||
titleBarStyle: "hidden",
|
||||
frame: false,
|
||||
autoHideMenuBar: true,
|
||||
titleBarOverlay: {
|
||||
color: "#18181c",
|
||||
symbolColor: "#e4e4e7",
|
||||
height: 29,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("uses frameless hidden title bars with overlay on linux", () => {
|
||||
expect(
|
||||
getMainWindowChromeOptions({
|
||||
platform: "linux",
|
||||
theme: "light",
|
||||
}),
|
||||
).toEqual({
|
||||
titleBarStyle: "hidden",
|
||||
frame: false,
|
||||
autoHideMenuBar: true,
|
||||
titleBarOverlay: {
|
||||
color: "#ffffff",
|
||||
symbolColor: "#09090b",
|
||||
height: 29,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the mac traffic-light path separate", () => {
|
||||
expect(
|
||||
getMainWindowChromeOptions({
|
||||
platform: "darwin",
|
||||
theme: "dark",
|
||||
}),
|
||||
).toEqual({
|
||||
titleBarStyle: "hidden",
|
||||
titleBarOverlay: true,
|
||||
trafficLightPosition: { x: 16, y: 14 },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,10 +1,4 @@
|
||||
import { app, BrowserWindow, ipcMain, nativeTheme } from "electron";
|
||||
import {
|
||||
createWindowMoveState,
|
||||
readWindowMovePayload,
|
||||
resolveWindowMovePosition,
|
||||
type WindowMoveState,
|
||||
} from "./window-drag-coordinates.js";
|
||||
|
||||
export function readBadgeCount(input: unknown): number {
|
||||
if (typeof input !== "number" || !Number.isSafeInteger(input) || input < 0) {
|
||||
@@ -15,6 +9,17 @@ export function readBadgeCount(input: unknown): number {
|
||||
}
|
||||
|
||||
export type WindowTheme = "light" | "dark";
|
||||
export type WindowControlsOverlayUpdate = {
|
||||
height?: number;
|
||||
backgroundColor?: string;
|
||||
foregroundColor?: string;
|
||||
};
|
||||
|
||||
export type WindowControlsOverlayState = {
|
||||
height: number;
|
||||
backgroundColor?: string;
|
||||
foregroundColor?: string;
|
||||
};
|
||||
|
||||
export function readWindowTheme(input: unknown): WindowTheme | null {
|
||||
if (input === "light" || input === "dark") {
|
||||
@@ -28,71 +33,111 @@ export function resolveSystemWindowTheme(): WindowTheme {
|
||||
return nativeTheme.shouldUseDarkColors ? "dark" : "light";
|
||||
}
|
||||
|
||||
export function createWindowControlsOverlayState(theme: WindowTheme): WindowControlsOverlayState {
|
||||
const overlay = getTitleBarOverlayOptions(theme);
|
||||
return {
|
||||
height: overlay.height ?? 29,
|
||||
backgroundColor: overlay.color,
|
||||
foregroundColor: overlay.symbolColor,
|
||||
};
|
||||
}
|
||||
|
||||
export function getTitleBarOverlayOptions(theme: WindowTheme): Electron.TitleBarOverlayOptions {
|
||||
if (theme === "dark") {
|
||||
return { color: "#18181c", symbolColor: "#e4e4e7", height: 48 };
|
||||
return { color: "#18181c", symbolColor: "#e4e4e7", height: 29 };
|
||||
}
|
||||
|
||||
return { color: "#ffffff", symbolColor: "#09090b", height: 48 };
|
||||
return { color: "#ffffff", symbolColor: "#09090b", height: 29 };
|
||||
}
|
||||
|
||||
export function getMainWindowChromeOptions(input: {
|
||||
platform: NodeJS.Platform;
|
||||
theme: WindowTheme;
|
||||
}): Pick<
|
||||
Electron.BrowserWindowConstructorOptions,
|
||||
"titleBarStyle" | "trafficLightPosition" | "frame" | "titleBarOverlay" | "autoHideMenuBar"
|
||||
> {
|
||||
if (input.platform === "darwin") {
|
||||
return {
|
||||
titleBarStyle: "hidden",
|
||||
titleBarOverlay: true,
|
||||
trafficLightPosition: { x: 16, y: 14 },
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
titleBarStyle: "hidden",
|
||||
frame: false,
|
||||
titleBarOverlay: getTitleBarOverlayOptions(input.theme),
|
||||
autoHideMenuBar: true,
|
||||
};
|
||||
}
|
||||
|
||||
function readFiniteOverlayHeight(input: unknown): number | null {
|
||||
if (typeof input !== "number" || !Number.isFinite(input)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const rounded = Math.round(input);
|
||||
return rounded >= 1 ? rounded : null;
|
||||
}
|
||||
|
||||
function readOverlayColor(input: unknown): string | null {
|
||||
if (typeof input !== "string") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return input;
|
||||
}
|
||||
|
||||
export function readWindowControlsOverlayUpdate(input: unknown): WindowControlsOverlayUpdate | null {
|
||||
if (!input || typeof input !== "object") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const candidate = input as Record<string, unknown>;
|
||||
const height = readFiniteOverlayHeight(candidate.height);
|
||||
const backgroundColor = readOverlayColor(candidate.backgroundColor);
|
||||
const foregroundColor = readOverlayColor(candidate.foregroundColor);
|
||||
|
||||
if (height === null && backgroundColor === null && foregroundColor === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
...(height !== null ? { height } : {}),
|
||||
...(backgroundColor !== null ? { backgroundColor } : {}),
|
||||
...(foregroundColor !== null ? { foregroundColor } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveRuntimeTitleBarOverlayOptions(
|
||||
state: WindowControlsOverlayState,
|
||||
): Electron.TitleBarOverlayOptions {
|
||||
return {
|
||||
color: state.backgroundColor?.trim() === "" ? undefined : state.backgroundColor,
|
||||
symbolColor: state.foregroundColor?.trim() === "" ? undefined : state.foregroundColor,
|
||||
height: Math.max(0, state.height - 1),
|
||||
};
|
||||
}
|
||||
|
||||
export function applyWindowControlsOverlayUpdate(input: {
|
||||
win: Pick<BrowserWindow, "setTitleBarOverlay">;
|
||||
current: WindowControlsOverlayState;
|
||||
update: WindowControlsOverlayUpdate;
|
||||
}): WindowControlsOverlayState {
|
||||
const next: WindowControlsOverlayState = {
|
||||
height: input.update.height ?? input.current.height,
|
||||
backgroundColor: input.update.backgroundColor ?? input.current.backgroundColor,
|
||||
foregroundColor: input.update.foregroundColor ?? input.current.foregroundColor,
|
||||
};
|
||||
|
||||
input.win.setTitleBarOverlay(resolveRuntimeTitleBarOverlayOptions(next));
|
||||
return next;
|
||||
}
|
||||
|
||||
export function registerWindowManager(): void {
|
||||
// ---------------------------------------------------------------------------
|
||||
// Manual window dragging (replaces flaky CSS -webkit-app-region: drag).
|
||||
// State is keyed per window id to support multiple windows.
|
||||
// ---------------------------------------------------------------------------
|
||||
const moveStates = new Map<number, WindowMoveState>();
|
||||
|
||||
ipcMain.on("paseo:window:startMove", (event, payload: { screenX: number; screenY: number }) => {
|
||||
const win = BrowserWindow.fromWebContents(event.sender);
|
||||
if (!win) return;
|
||||
const movePayload = readWindowMovePayload(payload);
|
||||
if (!movePayload) {
|
||||
moveStates.delete(win.id);
|
||||
return;
|
||||
}
|
||||
const [winX, winY] = win.getPosition();
|
||||
const moveState = createWindowMoveState({
|
||||
payload: movePayload,
|
||||
windowX: winX,
|
||||
windowY: winY,
|
||||
});
|
||||
if (!moveState) {
|
||||
moveStates.delete(win.id);
|
||||
return;
|
||||
}
|
||||
moveStates.set(win.id, moveState);
|
||||
});
|
||||
|
||||
ipcMain.on("paseo:window:moving", (event, payload: { screenX: number; screenY: number }) => {
|
||||
const win = BrowserWindow.fromWebContents(event.sender);
|
||||
if (!win) return;
|
||||
const state = moveStates.get(win.id);
|
||||
if (!state) return;
|
||||
const movePayload = readWindowMovePayload(payload);
|
||||
if (!movePayload) {
|
||||
moveStates.delete(win.id);
|
||||
return;
|
||||
}
|
||||
const nextPosition = resolveWindowMovePosition({
|
||||
payload: movePayload,
|
||||
state,
|
||||
});
|
||||
if (!nextPosition) {
|
||||
moveStates.delete(win.id);
|
||||
return;
|
||||
}
|
||||
win.setPosition(nextPosition.x, nextPosition.y);
|
||||
});
|
||||
|
||||
ipcMain.on("paseo:window:endMove", (event) => {
|
||||
const win = BrowserWindow.fromWebContents(event.sender);
|
||||
if (win) moveStates.delete(win.id);
|
||||
});
|
||||
|
||||
app.on("browser-window-created", (_event, win) => {
|
||||
win.on("closed", () => moveStates.delete(win.id));
|
||||
});
|
||||
const overlayStateByWindow = new WeakMap<BrowserWindow, WindowControlsOverlayState>();
|
||||
|
||||
ipcMain.handle("paseo:window:toggleMaximize", (event) => {
|
||||
const win = BrowserWindow.fromWebContents(event.sender);
|
||||
@@ -124,18 +169,25 @@ export function registerWindowManager(): void {
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle("paseo:window:setTitleBarTheme", (event, theme?: unknown) => {
|
||||
ipcMain.handle("paseo:window:updateWindowControls", (event, update?: unknown) => {
|
||||
const win = BrowserWindow.fromWebContents(event.sender);
|
||||
if (!win || process.platform === "darwin") {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextTheme = readWindowTheme(theme);
|
||||
if (!nextTheme) {
|
||||
const nextUpdate = readWindowControlsOverlayUpdate(update);
|
||||
if (!nextUpdate) {
|
||||
return;
|
||||
}
|
||||
|
||||
win.setTitleBarOverlay(getTitleBarOverlayOptions(nextTheme));
|
||||
const current =
|
||||
overlayStateByWindow.get(win) ?? createWindowControlsOverlayState(resolveSystemWindowTheme());
|
||||
const nextState = applyWindowControlsOverlayUpdate({
|
||||
win,
|
||||
current,
|
||||
update: nextUpdate,
|
||||
});
|
||||
overlayStateByWindow.set(win, nextState);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user