feat(mac-window): 支持切换 macOS 原生窗口控制与原生全屏行为 (#288)

## 背景
当前 GoNavi 使用自定义无边框标题栏与右上角窗口按钮,在 macOS 下与系统原生窗口交互习惯存在明显差异:
- 缺少左上角原生红黄绿窗口控制按钮
- 绿色按钮不具备 macOS 原生全屏 / Space 语义
- 标题栏交互和系统应用不够一致

这个 PR 为 macOS 增加了可切换的原生窗口控制模式,在尽量不影响现有跨平台行为的前提下,补齐 macOS 用户更熟悉的窗口体验。

## 变更内容
- 在 `主题 -> 外观参数` 中新增 `使用 macOS 原生窗口控制` 开关
- 启用后:
  - 显示 macOS 左上角原生红黄绿按钮
  - 隐藏现有右上角自定义窗口按钮
  - 为标题栏内容预留原生按钮安全区域
  - 优先使用 macOS 原生全屏行为
  - 支持 `Control + Command + F` 切换全屏
- 修复原生全屏下按 `Esc` 会意外退出全屏的问题
- 补充窗口行为、边界条件和相关工具函数单元测试

## 影响范围
- 仅影响 macOS 下启用该开关时的窗口样式与交互
- Windows/Linux 默认行为不变
- Windows 构建已验证通过

## 验证结果
已完成以下验证:
- [x] `npm run test`
- [x] `npm run build`
- [x] `go test ./...`
- [x] macOS 手工验证通过
- [x] Windows 构建验证通过

### macOS 手工验证项
- [x] 设置页可见 `使用 macOS 原生窗口控制`
- [x] 开关关闭时,保留当前自定义标题栏与右上角按钮
- [x] 开关开启时,右上角自定义按钮隐藏
- [x] 开启后显示左上角原生红黄绿按钮
- [x] 绿色按钮进入原生全屏
- [x] 原生全屏进入独立 Space
- [x] `Control + Command + F` 可切换全屏
- [x] 原生全屏下按 `Esc` 不再意外退出全屏
- [x] 浅色 / 深色主题下显示正常
- [x] 模糊与透明效果在普通窗口和全屏下均可正常工作
- [x] 最小化行为正常

## 截图 / 演示
### 历史窗口样式
- `MAC_历史版本窗口.png`
<img width="1920" height="1080" alt="MAC_历史版本窗口"
src="https://github.com/user-attachments/assets/4bd9176f-9d7e-43d1-9e1a-c7a6bfc0e28c"
/>

### 设置项与菜单
- `MAC_菜单控制.png`
<img width="1278" height="909" alt="MAC_菜单控制"
src="https://github.com/user-attachments/assets/520da1b5-af59-4f1a-ba5d-36abdc03ef60"
/>

- `MAC_菜单控制_Dark.png`
<img width="1119" height="861" alt="MAC_菜单控制_Dark"
src="https://github.com/user-attachments/assets/b21af50e-b583-4895-b316-cc21b7498a51"
/>

- `MAC_恢复默认.png`
<img width="1526" height="922" alt="MAC_恢复默认"
src="https://github.com/user-attachments/assets/0129f69d-b2ca-45eb-847a-6b6cb37ca576"
/>

### 原生窗口控制效果
- `MAC_窗口组件原生控制.png`
<img width="1236" height="849" alt="MAC_窗口组件原生控制"
src="https://github.com/user-attachments/assets/003dba09-d0a8-4999-8241-f2d1db078d1b"
/>

- `MAC_窗口组件原生控制2.png`
<img width="1920" height="834" alt="MAC_窗口组件原生控制2"
src="https://github.com/user-attachments/assets/241c94a6-955f-41f8-9b1d-b9a40d0a5251"
/>

- `MAC_切换后变化.png`
<img width="1920" height="1080" alt="MAC_切换后变化"
src="https://github.com/user-attachments/assets/52d8977b-9c64-4413-85d9-f94bdcdc0e53"
/>

### 全屏、快捷键与 Space 行为
- `MAC_快捷键.png`
<img width="1227" height="846" alt="MAC_快捷键"
src="https://github.com/user-attachments/assets/2972cee3-3621-42f1-bd05-1e24eaded5ef"
/>

- `MAC_支持SPACE切换.png`
<img width="353" height="251" alt="MAC_支持SPACE切换"
src="https://github.com/user-attachments/assets/044974a6-64c4-4d0c-8ba9-3445af77f8e4"
/>

- `MAC_最大化.png`
<img width="1920" height="1079" alt="MAC_最大化"
src="https://github.com/user-attachments/assets/dbdf4cd4-0abd-4142-9c81-08c8c23af73b"
/>

### 模糊与透明效果
- `MAC_模糊与透明.png`
<img width="1267" height="954" alt="MAC_模糊与透明"
src="https://github.com/user-attachments/assets/f5a3a377-805e-4d5f-a3f0-fa588d77d9f7"
/>

- `MAC_模糊与透明_全屏.png`
<img width="1920" height="1080" alt="MAC_模糊与透明_全屏"
src="https://github.com/user-attachments/assets/e20642ba-b828-47d0-a154-c23a7b15643d"
/>

### 其他窗口行为
- `MAC_窗口最小化.png`
<img width="276" height="129" alt="MAC_窗口最小化"
src="https://github.com/user-attachments/assets/d7f758a0-072e-4c47-95e6-9539075f1d71"
/>

- `MAC_设置启动全屏-重新打开.png`
<img width="1920" height="1080" alt="MAC_设置启动全屏-重新打开"
src="https://github.com/user-attachments/assets/b033d102-5062-46cb-9c41-c6fe330df007"
/>

### Windows 回归验证
- `WINDOWS_菜单.png`
<img width="1920" height="1040" alt="WINDOWS_菜单"
src="https://github.com/user-attachments/assets/3a295470-c1c6-42f5-a265-2cd38e9224cf"
/>


- `WINDOWS_全屏.png`
<img width="1920" height="1040" alt="WINDOWS_全屏"
src="https://github.com/user-attachments/assets/b254dc81-0c42-4024-9f91-3e029bfe29a0"
/>

## 说明
- 本次实现优先保证 macOS 原生窗口交互一致性,而不是模拟系统按钮视觉
- 当前方案对非 macOS 平台保持兼容
- 如果窗口样式在切换当次未完全刷新,重启应用后可获得稳定表现
This commit is contained in:
Syngnat
2026-03-20 21:18:43 +08:00
committed by GitHub
19 changed files with 1013 additions and 263 deletions

View File

@@ -68,6 +68,12 @@ func (a *App) SetWindowTranslucency(opacity float64, blur float64) {
setMacWindowTranslucency(opacity, blur)
}
// SetMacNativeWindowControls toggles macOS native traffic-light window controls.
// On non-macOS platforms this is a no-op.
func (a *App) SetMacNativeWindowControls(enabled bool) {
setMacNativeWindowControls(enabled)
}
// Shutdown is called when the app terminates
func (a *App) Shutdown(ctx context.Context) {
logger.Infof("应用开始关闭,准备释放资源")

View File

@@ -0,0 +1,70 @@
//go:build darwin
package app
/*
#cgo CFLAGS: -x objective-c -fblocks
#cgo LDFLAGS: -framework Cocoa
#import <Cocoa/Cocoa.h>
#import <dispatch/dispatch.h>
static void gonaviSetWindowButtonsVisible(NSWindow *window, BOOL visible) {
if (window == nil) {
return;
}
for (NSWindowButton buttonType = NSWindowCloseButton; buttonType <= NSWindowZoomButton; buttonType++) {
NSButton *button = [window standardWindowButton:buttonType];
if (button != nil) {
[button setHidden:!visible];
[button setEnabled:visible];
}
}
}
static void gonaviApplyMacWindowStyle(BOOL enabled) {
dispatch_async(dispatch_get_main_queue(), ^{
for (NSWindow *window in [NSApp windows]) {
if (window == nil) {
continue;
}
NSUInteger styleMask = [window styleMask];
styleMask |= NSWindowStyleMaskClosable;
styleMask |= NSWindowStyleMaskMiniaturizable;
styleMask |= NSWindowStyleMaskResizable;
if (enabled) {
styleMask |= NSWindowStyleMaskTitled;
styleMask |= NSWindowStyleMaskFullSizeContentView;
[window setStyleMask:styleMask];
[window setTitleVisibility:NSWindowTitleHidden];
[window setTitlebarAppearsTransparent:YES];
[window setMovableByWindowBackground:YES];
[window setCollectionBehavior:[window collectionBehavior] | NSWindowCollectionBehaviorFullScreenPrimary];
gonaviSetWindowButtonsVisible(window, YES);
} else {
styleMask &= ~NSWindowStyleMaskTitled;
styleMask &= ~NSWindowStyleMaskFullSizeContentView;
[window setStyleMask:styleMask];
[window setTitleVisibility:NSWindowTitleVisible];
[window setTitlebarAppearsTransparent:NO];
[window setMovableByWindowBackground:YES];
gonaviSetWindowButtonsVisible(window, NO);
}
[[window contentView] setNeedsDisplay:YES];
[window invalidateShadow];
}
});
}
*/
import "C"
func setMacNativeWindowControls(enabled bool) {
state := resolveMacNativeWindowControlState(enabled)
flag := C.BOOL(false)
if state.ShowNativeButtons {
flag = C.BOOL(true)
}
C.gonaviApplyMacWindowStyle(flag)
}

View File

@@ -0,0 +1,32 @@
package app
type macNativeWindowControlState struct {
ShowNativeButtons bool
UseTitledWindow bool
UseFullSizeContent bool
HideWindowTitle bool
TransparentTitlebar bool
AllowNativeFullscreen bool
}
func resolveMacNativeWindowControlState(enabled bool) macNativeWindowControlState {
if enabled {
return macNativeWindowControlState{
ShowNativeButtons: true,
UseTitledWindow: true,
UseFullSizeContent: true,
HideWindowTitle: true,
TransparentTitlebar: true,
AllowNativeFullscreen: true,
}
}
return macNativeWindowControlState{
ShowNativeButtons: false,
UseTitledWindow: false,
UseFullSizeContent: false,
HideWindowTitle: false,
TransparentTitlebar: false,
AllowNativeFullscreen: false,
}
}

View File

@@ -0,0 +1,37 @@
package app
import "testing"
func TestResolveMacNativeWindowControlStateEnabled(t *testing.T) {
state := resolveMacNativeWindowControlState(true)
if !state.ShowNativeButtons {
t.Fatal("expected native buttons to be visible when enabled")
}
if !state.UseTitledWindow || !state.UseFullSizeContent {
t.Fatal("expected enabled state to request titled full-size content window")
}
if !state.HideWindowTitle || !state.TransparentTitlebar {
t.Fatal("expected enabled state to hide title and use transparent titlebar")
}
if !state.AllowNativeFullscreen {
t.Fatal("expected enabled state to allow native fullscreen")
}
}
func TestResolveMacNativeWindowControlStateDisabled(t *testing.T) {
state := resolveMacNativeWindowControlState(false)
if state.ShowNativeButtons {
t.Fatal("expected native buttons to be hidden when disabled")
}
if state.UseTitledWindow || state.UseFullSizeContent {
t.Fatal("expected disabled state to avoid titled/full-size content window")
}
if state.HideWindowTitle || state.TransparentTitlebar {
t.Fatal("expected disabled state to keep title visibility and opaque titlebar")
}
if state.AllowNativeFullscreen {
t.Fatal("expected disabled state to avoid native fullscreen behavior")
}
}

View File

@@ -0,0 +1,5 @@
//go:build !darwin
package app
func setMacNativeWindowControls(enabled bool) {}