From dd7c9ed6feca547007a4fc74db4792baecd2b377 Mon Sep 17 00:00:00 2001 From: hayyaksi <193020925+hayaksi1@users.noreply.github.com> Date: Fri, 17 Jul 2026 19:25:14 +0300 Subject: [PATCH] Widen the macOS title-bar drag strips so the window is easy to move (#33991) * Widen the macOS title-bar drag strips so the window is easy to move With titleBarStyle: "hidden" there is no native title bar, so the macOS window can only be moved via the -webkit-app-region: drag strips above the room and left-panel headers. Those strips were only ~13-24px tall and too thin to reliably grab, making the window awkward to move. Raise the drag strips above the room, left-panel and space headers to 32px (matching the traffic-light offset already used elsewhere in the file) for a comfortable grab zone; interactive controls keep -webkit-app-region: no-drag so they stay clickable. The injected CSS is also extracted into a pure buildTitleBarCss() helper so the string contract can be unit-tested. * Address review: use author copyright, cover setupMacosTitleBar, drop dead selector Use my own copyright on the new test file rather than New Vector's. Add tests for setupMacosTitleBar() itself: the platform guard and the enter-full-screen / leave-full-screen / did-finish-load lifecycle were previously never executed, leaving the diff below the 80% coverage gate. Drop the .mx_LeftPanel_newRoomList::before rule: #34040 removed that class from the product, so the rule matched nothing and the border-right it carried was silently lost from the left panel. Fold it back into .mx_LeftPanel::before, with a regression test covering the separator. --------- Co-authored-by: Michael Telatynski <7t3chguy@gmail.com> --- apps/desktop/src/macos-titlebar.test.ts | 248 +++++++++++++++++++----- apps/desktop/src/macos-titlebar.ts | 43 ++-- 2 files changed, 231 insertions(+), 60 deletions(-) diff --git a/apps/desktop/src/macos-titlebar.test.ts b/apps/desktop/src/macos-titlebar.test.ts index 7ff00572a6..0d2862962d 100644 --- a/apps/desktop/src/macos-titlebar.test.ts +++ b/apps/desktop/src/macos-titlebar.test.ts @@ -1,70 +1,222 @@ /* Copyright 2026 Spencer Poisseroux +Copyright 2026 hayaksi1 SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial Please see LICENSE files in the repository root for full details. */ -import { expect, describe, it, beforeEach, afterEach, vi } from "vitest"; +import { afterEach, describe, expect, it, vi, type Mock } from "vitest"; import type { BrowserWindow } from "electron"; -import { setupMacosTitleBar } from "./macos-titlebar.js"; +import { buildTitleBarCss, setupMacosTitleBar } from "./macos-titlebar.js"; -function createFakeWindow(): { - window: BrowserWindow; - emitDidFinishLoad: () => void; - insertCSS: ReturnType; -} { - const listeners: Record void> = {}; - const insertCSS = vi.fn().mockResolvedValue("css-key"); - const window = { - isFullScreen: vi.fn().mockReturnValue(false), - on: vi.fn(), - webContents: { - insertCSS, - removeInsertedCSS: vi.fn(), - on: vi.fn((event: string, listener: () => void) => { - listeners[event] = listener; - }), - }, - } as unknown as BrowserWindow; - - return { - window, - insertCSS, - emitDidFinishLoad: () => listeners["did-finish-load"]?.(), - }; +/** + * Extract the `height` (in px) declared for the given selector. + * + * A selector may appear in more than one rule block (e.g. `.mx_SpaceRoomView::before` is both grouped with + * `.mx_RoomView::before` for the drag declaration and given its own block for the height). We scan every + * block whose selector list contains the target and return the height from the first block that declares one. + */ +function dragStripHeightPx(css: string, selector: string): number { + const escaped = selector.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const blockRegex = new RegExp(`([^{}]*${escaped}[^{}]*)\\{([^}]*)\\}`, "g"); + let match: RegExpExecArray | null; + let foundBlock = false; + while ((match = blockRegex.exec(css)) !== null) { + foundBlock = true; + const heightMatch = /height:\s*(\d+(?:\.\d+)?)px/.exec(match[2]); + if (heightMatch) { + return Number.parseFloat(heightMatch[1]); + } + } + expect(foundBlock, `expected a rule block for "${selector}"`).toBe(true); + throw new Error(`expected a px height declared for "${selector}"`); } +describe("buildTitleBarCss", () => { + const css = buildTitleBarCss(); + + it("returns a non-empty CSS string", () => { + expect(typeof css).toBe("string"); + expect(css.length).toBeGreaterThan(0); + }); + + it.each([".mx_RoomView::before", ".mx_LeftPanel::before", ".mx_SpaceRoomView::before"])( + "marks %s as a drag handle", + (selector) => { + const escaped = selector.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const blockMatch = new RegExp(`${escaped}[^}]*\\{([^}]*)\\}`).exec(css); + expect(blockMatch, `expected a rule block for "${selector}"`).not.toBeNull(); + expect(blockMatch![1]).toMatch(/-webkit-app-region:\s*drag/); + }, + ); + + // Regression guard for #32018: the drag strips above the headers were ~13px and too small to grab. + it("gives .mx_RoomView::before a drag strip at least 28px tall (regression #32018)", () => { + expect(dragStripHeightPx(css, ".mx_RoomView::before")).toBeGreaterThanOrEqual(28); + }); + + it("gives .mx_LeftPanel::before a drag strip at least 28px tall (regression #32018)", () => { + expect(dragStripHeightPx(css, ".mx_LeftPanel::before")).toBeGreaterThanOrEqual(28); + }); + + it("gives .mx_SpaceRoomView::before a drag strip at least 28px tall (regression #32018)", () => { + expect(dragStripHeightPx(css, ".mx_SpaceRoomView::before")).toBeGreaterThanOrEqual(28); + }); + + it("keeps the left panel's separator on its drag strip", () => { + // The strip carries the panel's right-hand border up through the title bar band; widening it must not drop it. + expect(css).toMatch( + /\.mx_LeftPanel::before\s*\{[^}]*border-right:\s*1px\s+solid\s+var\(--cpd-color-bg-subtle-primary\)/, + ); + }); + + // Regression guard for #34243: against the default 68px rail the collapsed space panel's right-hand + // separator crowds the green traffic light, so the panel is widened to clear it. + it("widens the collapsed space panel so the separator clears the traffic lights", () => { + expect(css).toMatch(/\.mx_SpacePanel\.collapsed\s*\{[^}]*width:\s*76px\s*!important/); + }); + + it("keeps interactive elements excluded from the drag region (no-drag)", () => { + // The UserMenu buttons must remain clickable, not act as a drag handle. + expect(css).toMatch(/\.mx_UserMenu\s*>\s*\*\s*\{[^}]*-webkit-app-region:\s*no-drag/); + }); + + it("keeps iframes excluded from the drag region (no-drag)", () => { + // iframes (e.g. recaptcha, widgets) must remain interactive. + expect(css).toMatch(/iframe\s*\{[^}]*-webkit-app-region:\s*no-drag/); + }); + + it("does not turn the traffic-light offset into a no-drag handle on .mx_UserMenu itself", () => { + // The UserMenu container itself stays a drag handle (only its children are no-drag). + expect(css).toMatch(/\.mx_UserMenu\s*\{[^}]*-webkit-app-region:\s*drag/); + }); +}); + describe("setupMacosTitleBar", () => { + /** Minimal `BrowserWindow` stand-in: the module only ever touches these members. */ + function mockWindow(): { + window: BrowserWindow; + windowHandlers: Map void>; + webContentsHandlers: Map void>; + insertCSS: Mock; + removeInsertedCSS: Mock; + isFullScreen: Mock; + } { + const windowHandlers = new Map void>(); + const webContentsHandlers = new Map void>(); + const insertCSS = vi.fn<(css: string) => Promise>().mockResolvedValue("css-key-1"); + const removeInsertedCSS = vi.fn<(key: string) => Promise>().mockResolvedValue(undefined); + const isFullScreen = vi.fn<() => boolean>().mockReturnValue(false); + + const window = { + on: vi.fn((event: string, handler: () => void) => { + windowHandlers.set(event, handler); + }), + isFullScreen, + webContents: { + on: vi.fn((event: string, handler: () => void) => { + webContentsHandlers.set(event, handler); + }), + insertCSS, + removeInsertedCSS, + }, + } as unknown as BrowserWindow; + + return { window, windowHandlers, webContentsHandlers, insertCSS, removeInsertedCSS, isFullScreen }; + } + + /** + * The listeners are `() => void` and start `applyStyling()` without awaiting it, so awaiting a handler's + * own return value would prove nothing. Yield to the macrotask queue instead, which drains the pending + * microtasks and lets that fire-and-forget promise settle before we assert on its effects. + */ + function flushStyling(): Promise { + return new Promise((resolve) => setTimeout(resolve, 0)); + } + afterEach(() => { vi.restoreAllMocks(); }); - describe("on macOS", () => { - beforeEach(() => { - vi.spyOn(process, "platform", "get").mockReturnValue("darwin"); - }); - - it("widens the collapsed space panel so the separator clears the traffic lights", async () => { - const { window, insertCSS, emitDidFinishLoad } = createFakeWindow(); - setupMacosTitleBar(window); - emitDidFinishLoad(); - // insertCSS is async; wait for the microtask queue to flush - await Promise.resolve(); - - expect(insertCSS).toHaveBeenCalledTimes(1); - const css = insertCSS.mock.calls[0][0] as string; - expect(css).toContain(".mx_SpacePanel.collapsed"); - expect(css).toContain("width: 76px !important;"); - }); - }); - - it("does nothing on non-macOS platforms", () => { + it("does nothing on non-darwin platforms", () => { vi.spyOn(process, "platform", "get").mockReturnValue("win32"); - const { window, insertCSS, emitDidFinishLoad } = createFakeWindow(); + const { window, insertCSS } = mockWindow(); + setupMacosTitleBar(window); - emitDidFinishLoad(); + + expect(window.on).not.toHaveBeenCalled(); + expect(window.webContents.on).not.toHaveBeenCalled(); expect(insertCSS).not.toHaveBeenCalled(); }); + + it("registers the full-screen and load listeners on darwin", () => { + vi.spyOn(process, "platform", "get").mockReturnValue("darwin"); + const { window } = mockWindow(); + + setupMacosTitleBar(window); + + expect(window.on).toHaveBeenCalledWith("enter-full-screen", expect.any(Function)); + expect(window.on).toHaveBeenCalledWith("leave-full-screen", expect.any(Function)); + expect(window.webContents.on).toHaveBeenCalledWith("did-finish-load", expect.any(Function)); + }); + + it("injects the title bar CSS once the page has loaded", async () => { + vi.spyOn(process, "platform", "get").mockReturnValue("darwin"); + const { window, webContentsHandlers, insertCSS } = mockWindow(); + + setupMacosTitleBar(window); + webContentsHandlers.get("did-finish-load")!(); + await flushStyling(); + + expect(insertCSS).toHaveBeenCalledOnce(); + expect(insertCSS).toHaveBeenCalledWith(buildTitleBarCss()); + }); + + it("does not inject the CSS if the window loads while already full screen", async () => { + vi.spyOn(process, "platform", "get").mockReturnValue("darwin"); + const { window, webContentsHandlers, insertCSS, isFullScreen } = mockWindow(); + isFullScreen.mockReturnValue(true); + + setupMacosTitleBar(window); + webContentsHandlers.get("did-finish-load")!(); + await flushStyling(); + + expect(insertCSS).not.toHaveBeenCalled(); + }); + + it("removes the injected CSS when entering full screen", async () => { + vi.spyOn(process, "platform", "get").mockReturnValue("darwin"); + const { window, windowHandlers, webContentsHandlers, removeInsertedCSS } = mockWindow(); + + setupMacosTitleBar(window); + webContentsHandlers.get("did-finish-load")!(); + await flushStyling(); + windowHandlers.get("enter-full-screen")!(); + + expect(removeInsertedCSS).toHaveBeenCalledWith("css-key-1"); + }); + + it("does not attempt to remove the CSS if none was ever injected", async () => { + vi.spyOn(process, "platform", "get").mockReturnValue("darwin"); + const { window, windowHandlers, removeInsertedCSS } = mockWindow(); + + setupMacosTitleBar(window); + windowHandlers.get("enter-full-screen")!(); + await flushStyling(); + + expect(removeInsertedCSS).not.toHaveBeenCalled(); + }); + + it("re-injects the CSS when leaving full screen", async () => { + vi.spyOn(process, "platform", "get").mockReturnValue("darwin"); + const { window, windowHandlers, insertCSS } = mockWindow(); + + setupMacosTitleBar(window); + windowHandlers.get("leave-full-screen")!(); + await flushStyling(); + + expect(insertCSS).toHaveBeenCalledWith(buildTitleBarCss()); + }); }); diff --git a/apps/desktop/src/macos-titlebar.ts b/apps/desktop/src/macos-titlebar.ts index 717a853c91..11dae63fe7 100644 --- a/apps/desktop/src/macos-titlebar.ts +++ b/apps/desktop/src/macos-titlebar.ts @@ -7,13 +7,20 @@ Please see LICENSE files in the repository root for full details. import type { BrowserWindow } from "electron"; -export function setupMacosTitleBar(window: BrowserWindow): void { - if (process.platform !== "darwin") return; - - let cssKey: string | undefined; - - async function applyStyling(): Promise { - cssKey = await window.webContents.insertCSS(` +/** + * Build the CSS injected into the renderer to make the (native-title-bar-less) macOS window draggable. + * + * Because `electron-main.ts` uses `titleBarStyle: "hidden"` there is no native title bar, so the only way + * to drag the window is via `-webkit-app-region: drag` strips. The `::before` strips above the room and + * left-panel headers were previously ~13px tall and too small to reliably grab (#32018); they are raised + * to match the 32px traffic-light offset used elsewhere in this file. Interactive controls keep + * `-webkit-app-region: no-drag` so they remain clickable (an element must never be both clickable and a + * drag handle). + * + * Extracted as a pure helper so the string contract can be unit-tested (see macos-titlebar.test.ts). + */ +export function buildTitleBarCss(): string { + return ` /* Create margin of space for the traffic light buttons */ .mx_UserMenu { /* We zero the margin and use padding as we want to use it as a drag handle */ @@ -124,7 +131,8 @@ export function setupMacosTitleBar(window: BrowserWindow): void { .mx_LeftPanel::before { content: ""; - height: 13px; + /* Aligned with the 32px traffic-light offset so the empty top band is grabbable (#32018) */ + height: 32px; border-right: 1px solid var(--cpd-color-bg-subtle-primary); -webkit-app-region: drag; } @@ -134,16 +142,27 @@ export function setupMacosTitleBar(window: BrowserWindow): void { content: ""; -webkit-app-region: drag; } - + .mx_SpaceRoomView::before { display: block; - height: 24px; + /* Enlarged to match the traffic-light offset for a comfortable drag zone (#32018) */ + height: 32px; } .mx_RoomView::before { - height: 13px; + /* Enlarged from 13px to cover the empty band above the 64px room header (#32018) */ + height: 32px; } - `); + `; +} + +export function setupMacosTitleBar(window: BrowserWindow): void { + if (process.platform !== "darwin") return; + + let cssKey: string | undefined; + + async function applyStyling(): Promise { + cssKey = await window.webContents.insertCSS(buildTitleBarCss()); } window.on("enter-full-screen", () => {