From 607d01160fe158f8ecb5c37ae250785122a3e649 Mon Sep 17 00:00:00 2001 From: ylhan Date: Mon, 21 Sep 2026 23:33:28 +0800 Subject: [PATCH] Draw the window's own frame: no menu bar, no title bar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The File/Edit/View/Window menu is a browser's menu, and it brought accelerators that fought the app's own shortcuts. The OS title bar is gone too, with minimize/maximize/close drawn in the header beside the language toggle. Two things a frameless window has to be told explicitly, both of which fail as a window that cannot be moved rather than as an error: the header has to declare itself a drag region, and interactive children have to opt out of it. Maximise state is read from the window rather than tracked from the last button press, because the window manager can maximise on its own — a double-click on the drag region, a shortcut, or snapping to an edge. --- README.md | 15 ++++ README.zh-CN.md | 13 ++++ src/main/index.ts | 21 +++++- src/main/ipc/registry.ts | 24 ++++++- src/preload/index.ts | 19 +++++ src/renderer/src/App.tsx | 14 +++- .../src/components/WindowControls.tsx | 69 +++++++++++++++++++ src/renderer/src/i18n/en-US.json | 6 ++ src/renderer/src/i18n/zh-CN.json | 6 ++ src/renderer/src/styles.css | 47 ++++++++++++- src/shared/api.ts | 16 +++++ src/shared/ipc-contract.ts | 8 +++ src/shared/ipc-schemas.ts | 4 ++ 13 files changed, 258 insertions(+), 4 deletions(-) create mode 100644 src/renderer/src/components/WindowControls.tsx diff --git a/README.md b/README.md index 827f526..03917cd 100644 --- a/README.md +++ b/README.md @@ -343,6 +343,21 @@ Bytes cross as `Uint8Array`, never as a string. TCP splits UTF-8 characters across chunks routinely, and decoding either half alone corrupts them; the bug appears only with CJK or emoji output, which makes it easy to miss. +### The window frame + +There is no OS title bar and no application menu: the app draws its own +controls in the header and nothing else. Two consequences that are easy to miss: + +- **The header is the only drag region.** A window manager will not move a + window whose client area never claims `-webkit-app-region: drag`, and the + symptom is a window that simply cannot be moved — not an error. +- **Interactive children must opt out** with `no-drag`, or they become + undraggable and, on some platforms, unclickable. + +Maximise state is read from the window rather than tracked from the last button +press, because the window manager can maximise on its own — double-clicking the +drag region, a keyboard shortcut, or snapping to a screen edge. + ### Terminal font The terminal uses **the desktop's own monospace font**, read from diff --git a/README.zh-CN.md b/README.zh-CN.md index e3049cc..3205175 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -241,6 +241,19 @@ preload contextBridge,窄白名单 —— 这个文件**就是** 字节以 `Uint8Array` 跨界,绝不用字符串。TCP 经常把 UTF-8 字符切断在 chunk 之间,单独解码任一半都会损坏它;这个 bug 只在中文或 emoji 输出时出现,因此极易漏掉。 +### 窗口边框 + +没有系统标题栏,也没有应用菜单:应用在头部自绘窗口按钮,除此之外什么都没有。 +两个容易漏掉的连带后果: + +- **头部是唯一的拖动区域。** 如果客户区从不声明 `-webkit-app-region: drag`, + 窗口管理器就不会移动它——症状是窗口**完全拖不动**,而不是报错。 +- **可交互的子元素必须用 `no-drag` 显式退出**,否则它们既拖不动、在某些平台上 + 也点不动。 + +最大化状态是从窗口**读取**的,而不是根据上一次按钮点击推断的——因为窗口管理器 +可以自己最大化:双击拖动区、键盘快捷键、或贴边吸附。 + ### 终端字体 终端使用**桌面自己的等宽字体**——启动时从 `org.gnome.desktop.interface diff --git a/src/main/index.ts b/src/main/index.ts index 420deae..532c058 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1,5 +1,6 @@ -import { app, BrowserWindow, session, shell } from 'electron' +import { app, BrowserWindow, Menu, session, shell } from 'electron' import { join } from 'node:path' +import { CH } from '@shared/ipc-contract' import { createAppContext, type AppContext } from './context' import { registerIpcHandlers } from './ipc/registry' @@ -42,6 +43,10 @@ function createWindow(): BrowserWindow { show: false, backgroundColor: '#14161a', title: 'NexusTerm', + // No OS title bar. The app draws its own controls in the header, so the + // header also has to declare itself a drag region in CSS — without that + // the window cannot be moved at all. + frame: false, webPreferences: { preload: join(__dirname, '../preload/index.js'), // These three are the security boundary. The renderer runs untrusted @@ -57,6 +62,16 @@ function createWindow(): BrowserWindow { // Avoid the white flash before React has painted. win.once('ready-to-show', () => win.show()) + // The maximise button has to render differently when the window is already + // maximised, and the window can also be maximised by the window manager — + // double-clicking the drag region, a keyboard shortcut, snapping — so the + // state is pushed rather than inferred from the last button press. + const pushMaximized = (maximized: boolean): void => { + if (!win.isDestroyed()) win.webContents.send(CH.WINDOW_STATE, { maximized }) + } + win.on('maximize', () => pushMaximized(true)) + win.on('unmaximize', () => pushMaximized(false)) + // Anything that tries to open a new window or navigate away goes to the // system browser instead. A terminal emulator has no business hosting // arbitrary web content in-process. @@ -114,6 +129,10 @@ if (!gotTheLock) { }) void app.whenReady().then(() => { + // File/Edit/View/Window is a browser's menu, not a terminal's. It also + // brought its own accelerators that fought with the app's own shortcuts. + Menu.setApplicationMenu(null) + session.defaultSession.webRequest.onHeadersReceived((details, callback) => { callback({ responseHeaders: { diff --git a/src/main/ipc/registry.ts b/src/main/ipc/registry.ts index af454af..df96cd6 100644 --- a/src/main/ipc/registry.ts +++ b/src/main/ipc/registry.ts @@ -1,4 +1,4 @@ -import { app, ipcMain } from 'electron' +import { app, BrowserWindow, ipcMain, type IpcMainInvokeEvent } from 'electron' import { CH } from '@shared/ipc-contract' import { appInfoSchema, @@ -137,4 +137,26 @@ export function registerIpcHandlers(context: AppContext): void { const { hostId, kind } = credentialRefInputSchema.parse(raw) return hasCredential(db, hostId, kind) }) + + // ---------------------------------------------------------------- window + // + // The window is frameless, so these stand in for the title bar. Each one + // resolves the window from the sender rather than holding a reference, so a + // second window (if one ever exists) cannot be closed by the first. + + const windowOf = (event: IpcMainInvokeEvent): BrowserWindow | null => + BrowserWindow.fromWebContents(event.sender) + + ipcMain.handle(CH.WINDOW_MINIMIZE, (event) => windowOf(event)?.minimize()) + + ipcMain.handle(CH.WINDOW_TOGGLE_MAXIMIZE, (event) => { + const win = windowOf(event) + if (!win) return + if (win.isMaximized()) win.unmaximize() + else win.maximize() + }) + + ipcMain.handle(CH.WINDOW_CLOSE, (event) => windowOf(event)?.close()) + + ipcMain.handle(CH.WINDOW_IS_MAXIMIZED, (event) => windowOf(event)?.isMaximized() ?? false) } diff --git a/src/preload/index.ts b/src/preload/index.ts index 554c983..0b5139c 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1,6 +1,7 @@ import { contextBridge, ipcRenderer } from 'electron' import { CH } from '@shared/ipc-contract' import { SESSION_PORT_CHANNEL, type PortHandshake } from '@shared/session-protocol' +import type { WindowState } from '@shared/ipc-schemas' import type { NexusTermApi } from '@shared/api' /** @@ -44,6 +45,24 @@ const api: NexusTermApi = { remove: (input) => ipcRenderer.invoke(CH.CREDENTIAL_DELETE, input), has: (hostId, kind) => ipcRenderer.invoke(CH.CREDENTIAL_HAS, { hostId, kind }), }, + + window: { + minimize: () => ipcRenderer.invoke(CH.WINDOW_MINIMIZE), + toggleMaximize: () => ipcRenderer.invoke(CH.WINDOW_TOGGLE_MAXIMIZE), + close: () => ipcRenderer.invoke(CH.WINDOW_CLOSE), + isMaximized: () => ipcRenderer.invoke(CH.WINDOW_IS_MAXIMIZED), + + onMaximizedChange: (listener) => { + const handler = (_event: unknown, state: WindowState): void => listener(state.maximized) + ipcRenderer.on(CH.WINDOW_STATE, handler) + // Returned rather than left implicit: React's development-mode effects + // run twice, and without a way to unsubscribe each of those would add + // another listener for the lifetime of the window. + return () => { + ipcRenderer.removeListener(CH.WINDOW_STATE, handler) + } + }, + }, } /** diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 89f2627..2410d83 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -8,6 +8,7 @@ import TabBar from './components/Tabs/TabBar' import TerminalHost from './components/Terminal/TerminalHost' import HostKeyDialog from './components/Session/HostKeyDialog' import PasswordDialog from './components/Session/PasswordDialog' +import WindowControls from './components/WindowControls' import { useSessionStore } from './stores/sessionStore' import { useSettingsStore } from './stores/settingsStore' import { SUPPORTED_LOCALES, type Locale } from './i18n' @@ -54,7 +55,16 @@ export default function App() { return (
-
+
{ + if ((e.target as HTMLElement).closest('button')) return + void window.nexusterm.window.toggleMaximize() + }} + > ❯_ {t('app.name')} {t('app.tagline')} @@ -69,6 +79,8 @@ export default function App() { > + +
diff --git a/src/renderer/src/components/WindowControls.tsx b/src/renderer/src/components/WindowControls.tsx new file mode 100644 index 0000000..2605789 --- /dev/null +++ b/src/renderer/src/components/WindowControls.tsx @@ -0,0 +1,69 @@ +import { useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { Copy, Minus, Square, X } from 'lucide-react' + +/** + * Minimise / maximise / close, drawn by the app. + * + * The window is frameless, so the title bar that normally carries these is + * gone. Two consequences worth knowing: + * + * - The header has to declare itself a drag region (see `.header` in the + * stylesheet), or there is nothing left to drag the window by. + * - Maximise state is *read* rather than tracked from the last button press, + * because the window manager can maximise on its own — double-clicking the + * drag region, a keyboard shortcut, snapping to an edge. + */ +export default function WindowControls() { + const { t } = useTranslation() + const [maximized, setMaximized] = useState(false) + + useEffect(() => { + let cancelled = false + + void window.nexusterm.window.isMaximized().then((value) => { + if (!cancelled) setMaximized(value) + }) + + const unsubscribe = window.nexusterm.window.onMaximizedChange(setMaximized) + + return () => { + cancelled = true + unsubscribe() + } + }, []) + + return ( +
+ + + + + +
+ ) +} diff --git a/src/renderer/src/i18n/en-US.json b/src/renderer/src/i18n/en-US.json index 1d260e7..3b58f48 100644 --- a/src/renderer/src/i18n/en-US.json +++ b/src/renderer/src/i18n/en-US.json @@ -113,5 +113,11 @@ "reconnecting": "--- connection lost; reconnecting (attempt {{attempt}}, in {{seconds}}s) ---", "reconnected": "--- reconnected on attempt {{attempt}} ---\n--- note: this is a new shell; the previous session ended and its working directory is not restored ---", "disconnected": "--- disconnected: {{message}} ---" + }, + "window": { + "minimize": "Minimize", + "maximize": "Maximize", + "restore": "Restore", + "close": "Close window" } } diff --git a/src/renderer/src/i18n/zh-CN.json b/src/renderer/src/i18n/zh-CN.json index da5b21c..1ebb4a5 100644 --- a/src/renderer/src/i18n/zh-CN.json +++ b/src/renderer/src/i18n/zh-CN.json @@ -113,5 +113,11 @@ "reconnecting": "--- 连接断开,正在重连(第 {{attempt}} 次,{{seconds}} 秒后)---", "reconnected": "--- 已重连(第 {{attempt}} 次尝试成功)---\n--- 注意:这是一个新的 shell,上一个会话已结束,工作目录不会恢复 ---", "disconnected": "--- 连接已断开:{{message}} ---" + }, + "window": { + "minimize": "最小化", + "maximize": "最大化", + "restore": "还原", + "close": "关闭窗口" } } diff --git a/src/renderer/src/styles.css b/src/renderer/src/styles.css index 1ee930d..a42c3c7 100644 --- a/src/renderer/src/styles.css +++ b/src/renderer/src/styles.css @@ -72,9 +72,22 @@ body { display: flex; align-items: center; gap: 10px; - padding: 0 12px; + /* No left padding on the right edge: the window controls run to the corner, + as they do on every other window. */ + padding: 0 0 0 12px; background: var(--bg-raised); border-bottom: 1px solid var(--border); + /* The window is frameless, so this is the only thing left to drag it by. + Interactive children opt out below, or they would be undraggable and + unclickable. */ + -webkit-app-region: drag; +} + +.header button, +.header input, +.header select, +.header .banner { + -webkit-app-region: no-drag; } .header__mark { @@ -97,6 +110,38 @@ body { flex: 1; } +/* ---------- window controls ---------- */ + +.wincontrols { + display: flex; + align-self: stretch; + margin-left: 2px; + -webkit-app-region: no-drag; +} + +.wincontrols__button { + display: inline-flex; + align-items: center; + justify-content: center; + width: 40px; + border: 0; + background: none; + color: var(--text-dim); + cursor: default; +} + +.wincontrols__button:hover { + background: rgba(255, 255, 255, 0.09); + color: var(--text); +} + +/* Red, as every window's close button is — the one control that should need + no reading to identify. */ +.wincontrols__button--close:hover { + background: #c42b1c; + color: #fff; +} + /* ---------- security banner ---------- */ .banner { diff --git a/src/shared/api.ts b/src/shared/api.ts index dc4b0f0..1ecd6e0 100644 --- a/src/shared/api.ts +++ b/src/shared/api.ts @@ -82,4 +82,20 @@ export interface NexusTermApi { remove(input: CredentialDeleteInput): Promise has(hostId: number, kind: CredentialKind): Promise } + + /** + * Window controls for a frameless window. + * + * The title bar that normally carries these is gone, so the app draws its + * own — which also means the drag region has to be declared in CSS, since + * there is no longer a title bar to drag. + */ + window: { + minimize(): Promise + toggleMaximize(): Promise + close(): Promise + isMaximized(): Promise + /** Returns a function that removes the listener. */ + onMaximizedChange(listener: (maximized: boolean) => void): () => void + } } diff --git a/src/shared/ipc-contract.ts b/src/shared/ipc-contract.ts index afcf58e..720f1ec 100644 --- a/src/shared/ipc-contract.ts +++ b/src/shared/ipc-contract.ts @@ -36,6 +36,14 @@ export const CH = { CREDENTIAL_SET: 'credential:set', CREDENTIAL_DELETE: 'credential:delete', CREDENTIAL_HAS: 'credential:has', + + // The window is frameless, so these replace what the title bar used to do. + WINDOW_MINIMIZE: 'window:minimize', + WINDOW_TOGGLE_MAXIMIZE: 'window:toggleMaximize', + WINDOW_CLOSE: 'window:close', + WINDOW_IS_MAXIMIZED: 'window:isMaximized', + /** Pushed from main when the window is maximised or restored. */ + WINDOW_STATE: 'window:state', } as const export type Channel = (typeof CH)[keyof typeof CH] diff --git a/src/shared/ipc-schemas.ts b/src/shared/ipc-schemas.ts index 3337eed..ae15c43 100644 --- a/src/shared/ipc-schemas.ts +++ b/src/shared/ipc-schemas.ts @@ -187,6 +187,10 @@ export const sessionCloseInputSchema = z.object({ sessionId: z.string().min(1).max(80), }) +/** Pushed from main when the window is maximised or restored. */ +export const windowStateSchema = z.object({ maximized: z.boolean() }) +export type WindowState = z.infer + /** A local shell either starts or it does not; there is no prompt to answer. */ export const localSessionResultSchema = z.discriminatedUnion('ok', [ z.object({ ok: z.literal(true), sessionId: z.string() }),