From e7d6d481df5fb2b867c161e41741555ea511503f Mon Sep 17 00:00:00 2001 From: Giovanny Aranda Date: Tue, 20 Oct 2026 18:14:54 +0200 Subject: [PATCH] feat: themed title bar for the desktop app (#1690) --- electron/main.cjs | 69 +++++++++++ electron/preload.js | 13 ++ packages/plugin-sdk/src/desktop.ts | 11 ++ src/main.tsx | 25 +++- src/ui/index.css | 25 ++++ src/ui/onboarding/OnboardingStage.tsx | 2 + src/ui/shell/TitleBar.tsx | 168 ++++++++++++++++++++++++++ src/ui/tests/shell/TitleBar.test.tsx | 124 +++++++++++++++++++ 8 files changed, 435 insertions(+), 2 deletions(-) create mode 100644 src/ui/shell/TitleBar.tsx create mode 100644 src/ui/tests/shell/TitleBar.test.tsx diff --git a/electron/main.cjs b/electron/main.cjs index 34c744719..ac39f468f 100644 --- a/electron/main.cjs +++ b/electron/main.cjs @@ -31,6 +31,10 @@ const { quitApp } = require("./app-quit.cjs"); const { selectLinuxPasswordStore } = require("./linux-password-store.cjs"); // Saved by the file manager after the user picks an editor in a file dialog. const EXTERNAL_EDITOR_SETTING = "fileManager.externalEditorPath"; + +// The height of the title bar the app draws itself. The renderer uses the +// same number, so the bar and the window controls always line up. +const TITLE_BAR_HEIGHT = 40; const { isExternalLoginUrl, isCallbackPort } = require("./external-login.cjs"); // The main process's Node.js networking (the global `fetch`) only @@ -1327,12 +1331,27 @@ function createWindow() { ? "macOS" : "Linux"; + const isMac = process.platform === "darwin"; + mainWindow = new BrowserWindow({ width: 1200, height: 800, minWidth: 800, minHeight: 600, title: "Termix", + // The app draws its own title bar so it follows the active theme. macOS + // keeps the traffic lights on top of it, Windows and Linux get the + // native controls as an overlay whose colors the renderer keeps in sync. + titleBarStyle: isMac ? "hiddenInset" : "hidden", + ...(isMac + ? { trafficLightPosition: { x: 14, y: 16 } } + : { + titleBarOverlay: { + color: "#0f0f14", + symbolColor: "#e6e6ea", + height: TITLE_BAR_HEIGHT, + }, + }), icon: path.join( appRoot, "public", @@ -1545,6 +1564,16 @@ function createWindow() { } }); + const sendTitleBarVisible = () => { + if (!mainWindow || mainWindow.isDestroyed()) return; + mainWindow.webContents.send( + "title-bar-visible-changed", + titleBarVisible(mainWindow), + ); + }; + mainWindow.on("enter-full-screen", sendTitleBarVisible); + mainWindow.on("leave-full-screen", sendTitleBarVisible); + mainWindow.on("closed", () => { embeddedViews.destroyAll(); mainWindow = null; @@ -1574,10 +1603,50 @@ ipcMain.handle("get-app-version", () => { return app.getVersion(); }); +/** The window an IPC call came from, so the main window and the extra + * windows (isolated views, sign-in) can each answer for themselves. */ +function windowFor(event) { + const win = BrowserWindow.fromWebContents(event.sender); + if (win && !win.isDestroyed()) return win; + if (mainWindow && !mainWindow.isDestroyed()) return mainWindow; + return null; +} + ipcMain.handle("get-platform", () => { return process.platform; }); +// Whether the app-drawn title bar shows in the window that asks. Only the +// main window hides the native bar, and in full screen macOS hides the +// traffic lights, so the bar goes away there too. +function titleBarVisible(win) { + return !!win && win === mainWindow && !win.isFullScreen(); +} + +ipcMain.handle("get-title-bar-visible", (event) => { + return titleBarVisible(windowFor(event)); +}); + +// The renderer sends the colors of the active theme so the window controls +// the system draws (Windows and Linux) match the app. +ipcMain.handle("set-title-bar-overlay", (event, colors) => { + const win = windowFor(event); + if (!win || process.platform === "darwin") return; + const options = {}; + if (typeof colors?.color === "string" && colors.color) { + options.color = colors.color; + } + if (typeof colors?.symbolColor === "string" && colors.symbolColor) { + options.symbolColor = colors.symbolColor; + } + if (Object.keys(options).length === 0) return; + try { + win.setTitleBarOverlay(options); + } catch (error) { + logToFile("setTitleBarOverlay failed:", error); + } +}); + ipcMain.handle("get-embedded-server-status", () => { return { running: diff --git a/electron/preload.js b/electron/preload.js index 8bc56bc56..4ceb8fb2b 100644 --- a/electron/preload.js +++ b/electron/preload.js @@ -32,6 +32,19 @@ contextBridge.exposeInMainWorld("electronAPI", { webFrame.setZoomFactor(Math.min(2, Math.max(0.5, value))); }, getPlatform: () => ipcRenderer.invoke("get-platform"), + + // Whether the app-drawn title bar shows in this window, and when it changes + // (full screen hides it). + getTitleBarVisible: () => ipcRenderer.invoke("get-title-bar-visible"), + onTitleBarVisibleChange: (callback) => { + const listener = (_event, visible) => callback(visible); + ipcRenderer.on("title-bar-visible-changed", listener); + return () => + ipcRenderer.removeListener("title-bar-visible-changed", listener); + }, + // Keeps the window controls the system draws in the colors of the theme. + setTitleBarOverlay: (colors) => + ipcRenderer.invoke("set-title-bar-overlay", colors), getEmbeddedServerStatus: () => ipcRenderer.invoke("get-embedded-server-status"), diff --git a/packages/plugin-sdk/src/desktop.ts b/packages/plugin-sdk/src/desktop.ts index 19346342a..811bee573 100644 --- a/packages/plugin-sdk/src/desktop.ts +++ b/packages/plugin-sdk/src/desktop.ts @@ -35,6 +35,17 @@ export interface ElectronAPI { getAppVersion: () => Promise; setZoomFactor?: (factor: number) => void; getPlatform: () => Promise; + /** Whether the app-drawn title bar shows in this window. */ + getTitleBarVisible?: () => Promise; + /** Calls back when the title bar shows or hides; returns an unsubscribe. */ + onTitleBarVisibleChange?: ( + callback: (visible: boolean) => void, + ) => () => void; + /** Keeps the window controls the system draws in the colors of the theme. */ + setTitleBarOverlay?: (colors: { + color?: string; + symbolColor?: string; + }) => Promise; getSetting?: (key: string) => Promise; setSetting?: (key: string, value: string) => Promise; diff --git a/src/main.tsx b/src/main.tsx index 2c4e3517e..76b2f4f14 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,5 +1,10 @@ /* eslint-disable react-refresh/only-export-components */ import { prepareClientCacheVersion } from "@/lib/client-cache-version"; +import { + TitleBar, + TITLE_BAR_OFFSET, + useTitleBarOverlayColors, +} from "@/shell/TitleBar"; import { StrictMode, Suspense, lazy, useState, useRef, useEffect } from "react"; import { createRoot } from "react-dom/client"; import "./ui/index.css"; @@ -160,6 +165,8 @@ function FullscreenAppGate() { } function App() { + // Keeps the window controls the system draws in the colors of the theme. + useTitleBarOverlayColors(); const stored = getStoredAuth(); const [phase, setPhase] = useState( stored?.loggedIn ? "verifying" : "idle-auth", @@ -370,10 +377,17 @@ function App() { const { t } = useTranslation(); const isTransitioning = phase === "fading-in" || phase === "fading-out"; + // The desktop app draws a title bar on top of the window, so everything + // below it starts lower. The bar sets the offset only while it shows. + const titleBarOffset = TITLE_BAR_OFFSET; if (phase === "verifying" || phase === "loading-app") { return ( -
+
+

{t("common.loading")}

@@ -384,8 +398,13 @@ function App() { return ( <> + + {isTransitioning && ( -
+

@@ -416,6 +435,7 @@ function App() { style={{ opacity: appOpacity, pointerEvents: phase === "idle-app" ? "auto" : "none", + paddingTop: titleBarOffset, }} > @@ -432,6 +452,7 @@ function App() { style={{ opacity: authOpacity, pointerEvents: phase === "idle-auth" ? "auto" : "none", + paddingTop: titleBarOffset, }} > diff --git a/src/ui/index.css b/src/ui/index.css index 9be5e16a9..12f52bf24 100644 --- a/src/ui/index.css +++ b/src/ui/index.css @@ -555,3 +555,28 @@ html.fs-xl { background-color: color-mix(in oklab, var(--accent-brand) 35%, transparent); color: inherit; } + +/* + * The title bar the desktop app draws on top of its window. The window has + * no native frame, so this strip is what moves the window around: the whole + * bar drags, everything inside it that takes clicks does not. + */ +.termix-titlebar { + -webkit-app-region: drag; + user-select: none; +} + +.termix-titlebar button, +.termix-titlebar a, +.termix-titlebar input, +.termix-titlebar [role="button"] { + -webkit-app-region: no-drag; +} + +/* + * Layouts size themselves to the viewport with h-dvh. Under the app-drawn + * title bar the usable height is shorter, so trim it while the bar shows. + */ +.h-dvh { + height: calc(100dvh - var(--termix-titlebar-height, 0px)); +} diff --git a/src/ui/onboarding/OnboardingStage.tsx b/src/ui/onboarding/OnboardingStage.tsx index 470ebc856..e80c9182d 100644 --- a/src/ui/onboarding/OnboardingStage.tsx +++ b/src/ui/onboarding/OnboardingStage.tsx @@ -6,6 +6,7 @@ import { SurfaceScope } from "@/components/surface/surface-scope"; import { useUiPreferencesContext } from "@/contexts/UiPreferencesContext"; import { usePermissions } from "@/hooks/use-permissions"; import { isElectron } from "@/lib/electron"; +import { TITLE_BAR_OFFSET } from "@/shell/TitleBar"; import { getOnboardingPlugins, type OnboardingPluginList, @@ -236,6 +237,7 @@ export function OnboardingStage({

(null); + const [visible, setVisible] = useState(false); + + useEffect(() => { + if (!isElectron()) return; + let cancelled = false; + const api = window.electronAPI; + if (typeof api?.getPlatform === "function") { + void api + .getPlatform() + .then((value: string) => { + if (!cancelled) setPlatform(value as Platform); + }) + .catch(() => {}); + } else { + // Without the bridge, fall back to what the user agent says. + setPlatform(/mac/i.test(navigator.userAgent) ? "darwin" : "other"); + } + + // Older builds without the visibility bridge always show the bar. + if (typeof api?.getTitleBarVisible === "function") { + void api + .getTitleBarVisible() + .then((value) => { + if (!cancelled) setVisible(value); + }) + .catch(() => {}); + } else { + setVisible(true); + } + const unsubscribe = api?.onTitleBarVisibleChange?.(setVisible); + return () => { + cancelled = true; + unsubscribe?.(); + }; + }, []); + + const shown = isElectron() && platform !== null && visible; + + useEffect(() => { + if (!shown) return; + const root = document.documentElement; + root.style.setProperty("--termix-titlebar-height", `${TITLE_BAR_HEIGHT}px`); + return () => { + root.style.removeProperty("--termix-titlebar-height"); + }; + }, [shown]); + + if (!shown) return null; + + const isMac = platform === "darwin"; + const hasOverlayControls = platform === "win32" || platform === "linux"; + + return ( +
+ {isMac && ( +