From 429b8f0efef3c17b6a2b549b511893f1a6da5f2a Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Sat, 7 Nov 2026 23:08:25 +0800 Subject: [PATCH] fix(window): show the window buttons in full screen on macOS (#2015) Native full screen moves a window's buttons into the bar revealed with the menu bar, and a project tab shown in its group's full-screen Space has none, so the title bar's slot for them stayed empty. The title bar now draws them there while the window fills the screen: close closes that project's tab, the green button leaves full screen, and minimize is disabled. --- crates/ide-core/src/event.rs | 2 + src-tauri/src/commands/mod.rs | 1 + src-tauri/src/commands/window.rs | 14 +++ src-tauri/src/macos.rs | 1 + src-tauri/src/titlebar.rs | 18 ++-- src/bindings.ts | 9 +- src/platform/locales/en.json | 1 + src/platform/locales/zh.json | 1 + src/styles/tokens.css | 10 +++ src/ui/icons.tsx | 3 + src/ui/openidea/general/macWindowClose.svg | 4 + .../general/macWindowExitFullScreen.svg | 5 ++ src/ui/openidea/general/macWindowMinimize.svg | 4 + src/workbench/MacWindowButtons.tsx | 87 +++++++++++++++++++ src/workbench/TitleBar.module.css | 49 +++++++++++ src/workbench/TitleBar.tsx | 3 + 16 files changed, 205 insertions(+), 7 deletions(-) create mode 100644 src/ui/openidea/general/macWindowClose.svg create mode 100644 src/ui/openidea/general/macWindowExitFullScreen.svg create mode 100644 src/ui/openidea/general/macWindowMinimize.svg create mode 100644 src/workbench/MacWindowButtons.tsx diff --git a/crates/ide-core/src/event.rs b/crates/ide-core/src/event.rs index 298547f89..a80657afb 100644 --- a/crates/ide-core/src/event.rs +++ b/crates/ide-core/src/event.rs @@ -15,6 +15,8 @@ use std::sync::Arc; pub enum IdeEvent { /// Project window membership or selection changed. WindowGroupsChanged, + /// A window entered or left full screen, itself or as a member of a full-screen window group. + WindowFullscreenChanged, ProjectTrustChanged { root: crate::IpcPath, trusted: bool, diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 15bb3d3d4..335e9f52d 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -431,6 +431,7 @@ pub fn builder() -> tauri_specta::Builder { window::window_toggle_fullscreen, window::window_flash_taskbar, window::window_set_titlebar_height, + window::window_is_fullscreen, window_drag::window_start_drag, window::window_take_launch, window::window_close_context, diff --git a/src-tauri/src/commands/window.rs b/src-tauri/src/commands/window.rs index fb1534d85..a93262ec3 100644 --- a/src-tauri/src/commands/window.rs +++ b/src-tauri/src/commands/window.rs @@ -193,6 +193,20 @@ pub fn window_open_projects( .collect()) } +/// Whether this window fills the screen: in native full screen, or (macOS) shown in its window +/// group's full-screen Space. +#[tauri::command] +#[specta::specta] +pub fn window_is_fullscreen(window: WebviewWindow) -> bool { + #[cfg(target_os = "macos")] + { + use tauri::Manager; + crate::window_groups::in_fullscreen(window.app_handle(), &window) + } + #[cfg(not(target_os = "macos"))] + window.is_fullscreen().unwrap_or(false) +} + #[derive(Serialize, Deserialize, Type)] #[serde(rename_all = "camelCase")] pub struct CloseContext { diff --git a/src-tauri/src/macos.rs b/src-tauri/src/macos.rs index 7b6a63add..752fe29a9 100644 --- a/src-tauri/src/macos.rs +++ b/src-tauri/src/macos.rs @@ -135,6 +135,7 @@ pub fn set_fullscreen_auxiliary(window: &WebviewWindow, host: Option<&WebviewWin if let Some(frame) = frame { native.setFrame_display(frame, true); } + target.state::().sink.emit(ide_core::IdeEvent::WindowFullscreenChanged); }) } diff --git a/src-tauri/src/titlebar.rs b/src-tauri/src/titlebar.rs index bf32bbac3..eabe726ec 100644 --- a/src-tauri/src/titlebar.rs +++ b/src-tauri/src/titlebar.rs @@ -12,11 +12,12 @@ use objc2::{ runtime::ProtocolObject, }; use objc2_app_kit::{ - NSToolbar, NSWindow, NSWindowDidExitFullScreenNotification, NSWindowStyleMask, NSWindowToolbarStyle, - NSWindowWillEnterFullScreenNotification, + NSToolbar, NSWindow, NSWindowDidEnterFullScreenNotification, NSWindowDidExitFullScreenNotification, + NSWindowStyleMask, NSWindowToolbarStyle, NSWindowWillEnterFullScreenNotification, }; use objc2_foundation::{NSNotificationCenter, NSObjectProtocol}; use std::{cell::Cell, cell::RefCell, collections::HashMap, rc::Rc}; +use tauri::Manager; /// Unified compact (38pt) and unified (52pt) toolbars; pick the one closer to the web bar. const COMPACT_MAX_HEIGHT: f64 = 45.0; @@ -55,7 +56,7 @@ impl Drop for Observer { struct Tracked { state: Rc, - _observers: [Observer; 2], + _observers: [Observer; 3], } thread_local! { @@ -71,7 +72,7 @@ fn apply(window: &NSWindow, state: &Fullscreen) { } } -fn track(window: &NSWindow) -> Tracked { +fn track(window: &NSWindow, app: &tauri::AppHandle) -> Tracked { let state = Rc::new(Fullscreen::default()); if window.styleMask().contains(NSWindowStyleMask::FullScreen) { state.will_enter(); @@ -79,11 +80,14 @@ fn track(window: &NSWindow) -> Tracked { let observe = |name, change: fn(&Fullscreen)| { let weak = Weak::new(window); let state = state.clone(); + let app = app.clone(); let block = block2::RcBlock::new(move |_| { change(&state); if let Some(window) = weak.load() { apply(&window, &state); } + // The web title bar shows the window buttons itself in full screen. + app.state::().sink.emit(ide_core::IdeEvent::WindowFullscreenChanged); }); let observer = unsafe { NSNotificationCenter::defaultCenter().addObserverForName_object_queue_usingBlock( @@ -98,6 +102,7 @@ fn track(window: &NSWindow) -> Tracked { let observers = unsafe { [ observe(NSWindowWillEnterFullScreenNotification, Fullscreen::will_enter), + observe(NSWindowDidEnterFullScreenNotification, Fullscreen::will_enter), observe(NSWindowDidExitFullScreenNotification, Fullscreen::did_exit), ] }; @@ -106,12 +111,13 @@ fn track(window: &NSWindow) -> Tracked { pub fn set_height(window: &tauri::WebviewWindow, height: f64) -> tauri::Result<()> { let label = window.label().to_owned(); + let app = window.app_handle().clone(); // with_webview runs on the AppKit thread. window.with_webview(move |webview| unsafe { let Some(mtm) = MainThreadMarker::new() else { return }; let native = &*webview.ns_window().cast::(); - let state = - WINDOWS.with_borrow_mut(|windows| windows.entry(label).or_insert_with(|| track(native)).state.clone()); + let state = WINDOWS + .with_borrow_mut(|windows| windows.entry(label).or_insert_with(|| track(native, &app)).state.clone()); if native.toolbar().is_none() { let toolbar = NSToolbar::new(mtm); native.setToolbar(Some(&toolbar)); diff --git a/src/bindings.ts b/src/bindings.ts index 4abe6b450..a763377ba 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -903,6 +903,11 @@ export const commands = { windowFlashTaskbar: (flash: boolean) => typedError(__TAURI_INVOKE("window_flash_taskbar", { flash })), /** Height of the web title bar in native logical pixels (CSS height × IDE zoom). */ windowSetTitlebarHeight: (height: number | null) => typedError(__TAURI_INVOKE("window_set_titlebar_height", { height })), + /** + * Whether this window fills the screen: in native full screen, or (macOS) shown in its window + * group's full-screen Space. + */ + windowIsFullscreen: () => __TAURI_INVOKE("window_is_fullscreen"), /** * Starts dragging `window` while the primary mouse button is still down; returns whether a drag began. * @@ -4479,7 +4484,9 @@ export type IdeError = { export type IdeEvent = /** Project window membership or selection changed. */ -{ type: "windowGroupsChanged" } | { type: "projectTrustChanged"; root: IpcPath; trusted: boolean } | +{ type: "windowGroupsChanged" } | +/** A window entered or left full screen, itself or as a member of a full-screen window group. */ +{ type: "windowFullscreenChanged" } | { type: "projectTrustChanged"; root: IpcPath; trusted: boolean } | /** Files or directories changed on disk (after debouncing, ignore rules applied). */ { type: "fsChanged"; created: IpcPath[]; modified: IpcPath[]; removed: IpcPath[] } | /** Excluded or ignored folders whose contents changed; they stay out of `FsChanged`. */ diff --git a/src/platform/locales/en.json b/src/platform/locales/en.json index 493b0cf13..0dce59ac9 100644 --- a/src/platform/locales/en.json +++ b/src/platform/locales/en.json @@ -196,6 +196,7 @@ "window.caption.minimize": "Minimize", "window.caption.maximize": "Maximize", "window.caption.restore": "Restore Down", + "window.caption.exitFullScreen": "Exit Full Screen", "window.caption.close": "Close", "menu.help.keymapReference": "Keymap Reference", "menu.help.showLog": "{os, select, macos {Show Log in Finder} windows {Show Log in Explorer} other {Show Log in Files}}", diff --git a/src/platform/locales/zh.json b/src/platform/locales/zh.json index 9db8c6437..d93abbf75 100644 --- a/src/platform/locales/zh.json +++ b/src/platform/locales/zh.json @@ -196,6 +196,7 @@ "window.caption.minimize": "最小化", "window.caption.maximize": "最大化", "window.caption.restore": "向下还原", + "window.caption.exitFullScreen": "退出全屏", "window.caption.close": "关闭", "menu.help.keymapReference": "快捷键参考", "menu.help.showLog": "{os, select, macos {在访达中显示日志} windows {在资源管理器中显示日志} other {在文件管理器中显示日志}}", diff --git a/src/styles/tokens.css b/src/styles/tokens.css index 1476e486b..ea1a96da6 100644 --- a/src/styles/tokens.css +++ b/src/styles/tokens.css @@ -436,6 +436,16 @@ html { --fg-caption-close-hover: #ffffff; } +/* macOS window buttons drawn by the web title bar in full screen: the system colours in every theme. */ +:root { + --mac-window-close: #ff5f57; + --mac-window-minimize: #febc2e; + --mac-window-zoom: #28c840; + --mac-window-glyph: rgb(0 0 0 / 55%); + --mac-window-edge: rgb(0 0 0 / 12%); + --mac-window-inactive: color-mix(in srgb, var(--fg-strong) 22%, transparent); +} + /* * Islands Light. Every colour token of :root is redefined here. Values are * PyCharm's own; its secondary greys, comment / metadata colours, line numbers diff --git a/src/ui/icons.tsx b/src/ui/icons.tsx index 8368918a5..f857b06df 100644 --- a/src/ui/icons.tsx +++ b/src/ui/icons.tsx @@ -248,6 +248,9 @@ const ICONS = { "window-maximize": "openidea/general/windowMaximize", "window-restore": "openidea/general/windowRestore", "window-close": "openidea/general/windowClose", + "mac-window-close": "openidea/general/macWindowClose", + "mac-window-minimize": "openidea/general/macWindowMinimize", + "mac-window-exit-full-screen": "openidea/general/macWindowExitFullScreen", hide: "general/hide", pin: "general/pin", eye: "general/show", diff --git a/src/ui/openidea/general/macWindowClose.svg b/src/ui/openidea/general/macWindowClose.svg new file mode 100644 index 000000000..dcf91cfbd --- /dev/null +++ b/src/ui/openidea/general/macWindowClose.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/ui/openidea/general/macWindowExitFullScreen.svg b/src/ui/openidea/general/macWindowExitFullScreen.svg new file mode 100644 index 000000000..4c0b528a9 --- /dev/null +++ b/src/ui/openidea/general/macWindowExitFullScreen.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/ui/openidea/general/macWindowMinimize.svg b/src/ui/openidea/general/macWindowMinimize.svg new file mode 100644 index 000000000..ab44ef431 --- /dev/null +++ b/src/ui/openidea/general/macWindowMinimize.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/workbench/MacWindowButtons.tsx b/src/workbench/MacWindowButtons.tsx new file mode 100644 index 000000000..fe4113345 --- /dev/null +++ b/src/workbench/MacWindowButtons.tsx @@ -0,0 +1,87 @@ +/** + * macOS window buttons in the title bar's slot while the window fills the screen. Native full + * screen moves the system's buttons into the bar revealed with the menu bar, and a project tab + * shown in its group's full-screen Space has none, so the slot kept for them would be empty. + * Close closes this project's tab, the green button leaves full screen, minimize is disabled. + * Like the system's, they take no keyboard focus. + */ +import { useEffect, useState } from "react"; +import clsx from "clsx"; +import { t } from "@/platform/i18n"; +import { commands, onIdeEvent } from "@/platform/ipc"; +import { Icon } from "@/ui"; +import styles from "./TitleBar.module.css"; + +type AppWindow = import("@tauri-apps/api/window").Window; + +function useFullscreenWindow(): { fullscreen: boolean; active: boolean } { + const [fullscreen, setFullscreen] = useState(false); + const [active, setActive] = useState(() => document.hasFocus()); + useEffect(() => { + let disposed = false; + const offs: (() => void)[] = []; + const sync = () => void commands.windowIsFullscreen().then((on) => !disposed && setFullscreen(on)); + sync(); + offs.push(onIdeEvent("windowFullscreenChanged", sync)); + void import("@tauri-apps/api/window").then(async ({ getCurrentWindow }) => { + const w: AppWindow = getCurrentWindow(); + const listeners = await Promise.all([ + w.onResized(sync), + w.onFocusChanged(({ payload }) => { + setActive(payload); + sync(); + }), + ]); + if (disposed) listeners.forEach((off) => off()); + else offs.push(...listeners); + }); + return () => { + disposed = true; + offs.forEach((off) => off()); + }; + }, []); + return { fullscreen, active }; +} + +function closeWindow() { + void import("@tauri-apps/api/window").then(({ getCurrentWindow }) => getCurrentWindow().close()); +} + +export function MacWindowButtons() { + const { fullscreen, active } = useFullscreenWindow(); + if (!fullscreen) return null; + return ( +
e.preventDefault()} + > + + + +
+ ); +} diff --git a/src/workbench/TitleBar.module.css b/src/workbench/TitleBar.module.css index 2c4443521..de70f2b38 100644 --- a/src/workbench/TitleBar.module.css +++ b/src/workbench/TitleBar.module.css @@ -195,3 +195,52 @@ background: var(--caption-close-pressed); color: var(--fg-caption-close-hover); } + +/* macOS window buttons drawn in the traffic-light slot while the window fills the screen. */ +.macButtons { + position: fixed; + top: 0; + left: 0; + height: var(--h-titlebar); + display: flex; + align-items: center; + gap: 8px; + padding-left: 12px; + zoom: calc(1 / var(--titlebar-zoom, 1)); + z-index: 1; +} +.macButton { + width: 12px; + height: 12px; + padding: 0; + border: none; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + color: var(--mac-window-glyph); + box-shadow: inset 0 0 0 0.5px var(--mac-window-edge); +} +.macButton > * { + opacity: 0; +} +.macButtons:hover .macButton:not(:disabled) > * { + opacity: 1; +} +.macClose { + background: var(--mac-window-close); +} +.macMinimize { + background: var(--mac-window-minimize); +} +.macZoom { + background: var(--mac-window-zoom); +} +.macButton:active:not(:disabled) { + filter: brightness(0.8); +} +/* Disabled in full screen, and every button of an inactive window, are grey as AppKit draws them. */ +.macButton:disabled, +.macButtonsInactive:not(:hover) .macButton { + background: var(--mac-window-inactive); +} diff --git a/src/workbench/TitleBar.tsx b/src/workbench/TitleBar.tsx index 806068a97..aeedf51c0 100644 --- a/src/workbench/TitleBar.tsx +++ b/src/workbench/TitleBar.tsx @@ -21,6 +21,7 @@ import { widgetTitle, } from "./mainToolbar"; import { WindowControls } from "./WindowControls"; +import { MacWindowButtons } from "./MacWindowButtons"; import { usePlatform } from "@/platform/platformInfo"; import { projectInitials } from "./projectColor"; import { keymapItems, themeItems, themePreview, type ThemePreview, VIEW_MODE_ITEMS } from "./quickSwitch"; @@ -301,6 +302,7 @@ export function TitleBar({ if (bare) return (
+ {overlay && } {frameless && ( <> @@ -323,6 +325,7 @@ export function TitleBar({ showContextMenu(e, menu); }} > + {overlay && } {frameless && !minimal && } {title && (