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.
This commit is contained in:
l0ng-ai authored and GitHub committed 2026-11-07 23:08:25 +08:00
1 parent 257fb0f50f
commit 429b8f0efe
16 files changed
+205 -7

No files matched your search

+2
View File
@@ -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,
+1
View File
@@ -431,6 +431,7 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
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,
+14
View File
@@ -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 {
+1
View File
@@ -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::<crate::state::AppState>().sink.emit(ide_core::IdeEvent::WindowFullscreenChanged);
})
}
+12 -6
View File
@@ -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<Fullscreen>,
_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::<crate::state::AppState>().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::<NSWindow>();
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));
+8 -1
View File
@@ -903,6 +903,11 @@ export const commands = {
windowFlashTaskbar: (flash: boolean) => typedError<null, IdeError>(__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<null, IdeError>(__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<boolean>("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`. */
+1
View File
@@ -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}}",
+1
View File
@@ -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 {在文件管理器中显示日志}}",
+10
View File
@@ -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
+3
View File
@@ -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",
@@ -0,0 +1,4 @@
<!-- OpenIDEA: macOS window button glyph (close), drawn in the full-screen title bar. -->
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4.5 4.5L11.5 11.5M11.5 4.5L4.5 11.5" stroke="#6C707E" stroke-width="2" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 299 B

@@ -0,0 +1,5 @@
<!-- OpenIDEA: macOS window button glyph (exit full screen), drawn in the full-screen title bar. -->
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M8.75 2.5V7.25H13.5L8.75 2.5Z" fill="#6C707E"/>
<path d="M7.25 13.5V8.75H2.5L7.25 13.5Z" fill="#6C707E"/>
</svg>

After

Width:  |  Height:  |  Size: 319 B

@@ -0,0 +1,4 @@
<!-- OpenIDEA: macOS window button glyph (minimize), drawn in the full-screen title bar. -->
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M3.5 8H12.5" stroke="#6C707E" stroke-width="2" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 277 B

+87
View File
@@ -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 (
<div
className={clsx(styles.macButtons, !active && styles.macButtonsInactive)}
onMouseDown={(e) => e.preventDefault()}
>
<button
type="button"
tabIndex={-1}
className={clsx(styles.macButton, styles.macClose)}
aria-label={t("window.caption.close")}
onClick={closeWindow}
>
<Icon name="mac-window-close" size={8} />
</button>
<button
type="button"
tabIndex={-1}
className={clsx(styles.macButton, styles.macMinimize)}
aria-label={t("window.caption.minimize")}
disabled
>
<Icon name="mac-window-minimize" size={8} />
</button>
<button
type="button"
tabIndex={-1}
className={clsx(styles.macButton, styles.macZoom)}
aria-label={t("window.caption.exitFullScreen")}
onClick={() => void commands.windowToggleFullscreen()}
>
<Icon name="mac-window-exit-full-screen" size={8} />
</button>
</div>
);
}
+49
View File
@@ -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);
}
+3
View File
@@ -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 (
<header ref={setBar} className={className} {...windowDragRegion} data-titlebar="bare">
{overlay && <MacWindowButtons />}
{frameless && (
<>
<MainMenu />
@@ -323,6 +325,7 @@ export function TitleBar({
showContextMenu(e, menu);
}}
>
{overlay && <MacWindowButtons />}
{frameless && !minimal && <MainMenu onExpandedChange={setMenuExpanded} />}
{title && (
<span className={styles.windowTitle} {...windowDragRegion}>