From 843cf8df19d8d7587ca85c299088909847920206 Mon Sep 17 00:00:00 2001 From: xintaofei Date: Sun, 12 Apr 2026 18:11:40 +0800 Subject: [PATCH] feat(macos): set traffic-light position via Tauri builder API and sync with zoom Use Tauri's native `traffic_light_position()` builder method to position macOS window controls instead of runtime objc2 calls. A global AtomicU32 tracks the current zoom level so newly created windows reflect the latest zoom. The frontend syncs zoom changes to the backend via a new `update_traffic_light_position` command. - Add `traffic_light_position()` to `apply_platform_window_style` builder - Add `CURRENT_ZOOM` atomic and `traffic_light_position()` helper - Register `update_traffic_light_position` Tauri command - Add `syncTrafficLightPosition` in appearance-provider to sync on zoom change, mount, and cross-tab storage events - Consolidate `ensure_windows_undecorated` calls into `post_window_setup` - Remove dead `on_window_resized` no-op and its Resized event listener Co-Authored-By: Claude Opus 4.6 (1M context) --- src-tauri/src/commands/windows.rs | 65 ++++++++++++++++++++------ src-tauri/src/lib.rs | 5 +- src/components/appearance-provider.tsx | 16 +++++++ src/lib/tauri.ts | 6 +++ 4 files changed, 77 insertions(+), 15 deletions(-) diff --git a/src-tauri/src/commands/windows.rs b/src-tauri/src/commands/windows.rs index b89d4b3..5da043e 100644 --- a/src-tauri/src/commands/windows.rs +++ b/src-tauri/src/commands/windows.rs @@ -1,5 +1,7 @@ use std::collections::HashMap; use std::sync::Mutex; +#[cfg(target_os = "macos")] +use std::sync::atomic::{AtomicU32, Ordering}; use tauri::{AppHandle, Manager, WebviewUrl, WebviewWindowBuilder}; @@ -7,6 +9,23 @@ use crate::app_error::AppCommandError; use crate::db::AppDatabase; use crate::models::FolderHistoryEntry; +/// Base traffic-light position (logical px) at 100 % zoom. +#[cfg(target_os = "macos")] +const TRAFFIC_LIGHT_X: f64 = 12.0; +#[cfg(target_os = "macos")] +const TRAFFIC_LIGHT_Y: f64 = 18.0; + +#[cfg(target_os = "macos")] +static CURRENT_ZOOM: AtomicU32 = AtomicU32::new(100); + +#[cfg(target_os = "macos")] +fn traffic_light_position() -> tauri::LogicalPosition { + let zoom = CURRENT_ZOOM.load(Ordering::Relaxed) as f64; + // Only Y scales with zoom: overlay content shifts vertically with + // font-size changes, but the horizontal inset remains constant. + tauri::LogicalPosition::new(TRAFFIC_LIGHT_X, TRAFFIC_LIGHT_Y * zoom / 100.0) +} + pub struct SettingsWindowState { owner_window_label: Mutex>, } @@ -31,6 +50,7 @@ where builder .hidden_title(true) .title_bar_style(tauri::TitleBarStyle::Overlay) + .traffic_light_position(traffic_light_position()) } #[cfg(target_os = "windows")] @@ -52,6 +72,11 @@ fn ensure_windows_undecorated(window: &tauri::WebviewWindow) { #[cfg(not(target_os = "windows"))] fn ensure_windows_undecorated(_window: &tauri::WebviewWindow) {} +/// Apply platform-specific post-creation setup. +pub(crate) fn post_window_setup(window: &tauri::WebviewWindow) { + ensure_windows_undecorated(window); +} + impl SettingsWindowState { pub fn new() -> Self { Self { @@ -202,7 +227,7 @@ pub async fn open_folder_window( let label = folder_window_label(entry.id); if let Some(existing) = app.get_webview_window(&label) { - ensure_windows_undecorated(&existing); + post_window_setup(&existing); let _ = existing.unminimize(); existing .set_focus() @@ -224,7 +249,7 @@ pub async fn open_folder_window( let folder_window = apply_platform_window_style(builder) .build() .map_err(|e| AppCommandError::window("Failed to open folder window", e.to_string()))?; - ensure_windows_undecorated(&folder_window); + post_window_setup(&folder_window); // Close welcome and project-boot windows if let Some(w) = app.get_webview_window("welcome") { @@ -280,7 +305,7 @@ pub async fn open_commit_window( let commit_window = apply_platform_window_style(builder) .build() .map_err(|e| AppCommandError::window("Failed to open commit window", e.to_string()))?; - ensure_windows_undecorated(&commit_window); + post_window_setup(&commit_window); if let Some(owner_window) = app.get_webview_window(&owner_label) { if let Err(err) = owner_window.set_enabled(false) { let _ = commit_window.close(); @@ -309,7 +334,7 @@ pub async fn open_settings_window( ) -> Result<(), AppCommandError> { let target_route = resolve_settings_target(section.as_deref(), agent_type.as_deref()); if let Some(existing) = app.get_webview_window("settings") { - ensure_windows_undecorated(&existing); + post_window_setup(&existing); if section.is_some() || agent_type.is_some() { let target_path = format!("/{target_route}"); let target_json = serde_json::to_string(&target_path).map_err(|e| { @@ -337,7 +362,7 @@ pub async fn open_settings_window( let settings_window = apply_platform_window_style(builder) .build() .map_err(|e| AppCommandError::window("Failed to open settings window", e.to_string()))?; - ensure_windows_undecorated(&settings_window); + post_window_setup(&settings_window); state.set_owner(owner_label); settings_window @@ -442,7 +467,7 @@ pub async fn open_merge_window( let merge_window = apply_platform_window_style(builder) .build() .map_err(|e| AppCommandError::window("Failed to open merge window", e.to_string()))?; - ensure_windows_undecorated(&merge_window); + post_window_setup(&merge_window); if let Some(owner_window) = app.get_webview_window(&owner_label) { if let Err(err) = owner_window.set_enabled(false) { let _ = merge_window.close(); @@ -522,7 +547,7 @@ pub async fn cleanup_dangling_merge(app: &AppHandle, merge_window_label: &str) { pub fn open_welcome_window(app: &AppHandle) -> Result<(), AppCommandError> { if let Some(existing) = app.get_webview_window("welcome") { - ensure_windows_undecorated(&existing); + post_window_setup(&existing); return Ok(()); } let url = WebviewUrl::App("welcome".into()); @@ -534,7 +559,7 @@ pub fn open_welcome_window(app: &AppHandle) -> Result<(), AppCommandError> { let welcome_window = apply_platform_window_style(builder) .build() .map_err(|e| AppCommandError::window("Failed to open welcome window", e.to_string()))?; - ensure_windows_undecorated(&welcome_window); + post_window_setup(&welcome_window); Ok(()) } @@ -548,7 +573,7 @@ pub async fn open_stash_window( let label = format!("stash-{folder_id}"); if let Some(existing) = app.get_webview_window(&label) { - ensure_windows_undecorated(&existing); + post_window_setup(&existing); let _ = existing.unminimize(); existing .set_focus() @@ -573,7 +598,7 @@ pub async fn open_stash_window( let stash_window = apply_platform_window_style(builder) .build() .map_err(|e| AppCommandError::window("Failed to open stash window", e.to_string()))?; - ensure_windows_undecorated(&stash_window); + post_window_setup(&stash_window); Ok(()) } @@ -588,7 +613,7 @@ pub async fn open_push_window( let label = format!("push-{folder_id}"); if let Some(existing) = app.get_webview_window(&label) { - ensure_windows_undecorated(&existing); + post_window_setup(&existing); let _ = existing.unminimize(); existing .set_focus() @@ -613,7 +638,7 @@ pub async fn open_push_window( let push_window = apply_platform_window_style(builder) .build() .map_err(|e| AppCommandError::window("Failed to open push window", e.to_string()))?; - ensure_windows_undecorated(&push_window); + post_window_setup(&push_window); Ok(()) } @@ -625,7 +650,7 @@ pub async fn open_project_boot_window( source: Option, ) -> Result<(), AppCommandError> { if let Some(existing) = app.get_webview_window("project-boot") { - ensure_windows_undecorated(&existing); + post_window_setup(&existing); let _ = existing.unminimize(); existing.set_focus().map_err(|e| { AppCommandError::window("Failed to focus project boot window", e.to_string()) @@ -650,7 +675,7 @@ pub async fn open_project_boot_window( .map_err(|e| { AppCommandError::window("Failed to open project boot window", e.to_string()) })?; - ensure_windows_undecorated(&window); + post_window_setup(&window); // Close welcome if opened from welcome if source.as_deref() == Some("welcome") { @@ -661,3 +686,15 @@ pub async fn open_project_boot_window( Ok(()) } + +/// Store the current zoom level so that newly created windows use the correct +/// traffic-light position. Existing windows are NOT repositioned at runtime. +#[cfg(feature = "tauri-runtime")] +#[cfg_attr(feature = "tauri-runtime", tauri::command)] +pub async fn update_traffic_light_position(app: AppHandle, zoom: f64) -> Result<(), AppCommandError> { + #[cfg(target_os = "macos")] + CURRENT_ZOOM.store(zoom.clamp(50.0, 300.0) as u32, Ordering::Relaxed); + let _ = (app, zoom); + Ok(()) +} + diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index e580e0a..ddb3686 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -136,7 +136,9 @@ mod tauri_app { .title(&entry.name) .inner_size(1260.0, 860.0) .min_inner_size(900.0, 600.0); - let _ = windows::apply_platform_window_style(builder).build(); + if let Ok(w) = windows::apply_platform_window_style(builder).build() { + windows::post_window_setup(&w); + } } } @@ -334,6 +336,7 @@ mod tauri_app { windows::open_stash_window, windows::open_push_window, windows::open_project_boot_window, + windows::update_traffic_light_position, project_boot::detect_package_manager, project_boot::create_shadcn_project, system_settings::get_system_proxy_settings, diff --git a/src/components/appearance-provider.tsx b/src/components/appearance-provider.tsx index b4f9626..bd0e2cb 100644 --- a/src/components/appearance-provider.tsx +++ b/src/components/appearance-provider.tsx @@ -14,6 +14,14 @@ import { STORAGE_KEY_ZOOM_LEVEL, } from "@/lib/appearance-script" +function syncTrafficLightPosition(zoom: number) { + if (typeof window === "undefined" || !("__TAURI_INTERNALS__" in window)) + return + import("@/lib/tauri").then((t) => + t.updateTrafficLightPosition(zoom).catch(() => {}) + ) +} + type AppearanceContextValue = { themeColor: ThemeColor setThemeColor: (color: ThemeColor) => void @@ -73,6 +81,7 @@ export function AppearanceProvider({ const setZoomLevel = useCallback((zoom: ZoomLevel) => { setZoomLevelState(zoom) document.documentElement.style.fontSize = `${(16 * zoom) / 100}px` + syncTrafficLightPosition(zoom) try { localStorage.setItem(STORAGE_KEY_ZOOM_LEVEL, String(zoom)) } catch { @@ -80,6 +89,12 @@ export function AppearanceProvider({ } }, []) + // Sync traffic-light position on mount (initial zoom) + useEffect(() => { + syncTrafficLightPosition(zoomLevel) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + // 跨标签页同步:用户在另一个窗口改了设置时,本窗口实时跟进 useEffect(() => { const onStorage = (e: StorageEvent) => { @@ -95,6 +110,7 @@ export function AppearanceProvider({ if ((ZOOM_LEVELS as readonly number[]).includes(zoom)) { setZoomLevelState(zoom) document.documentElement.style.fontSize = `${(16 * zoom) / 100}px` + syncTrafficLightPosition(zoom) } } } diff --git a/src/lib/tauri.ts b/src/lib/tauri.ts index 4ebd9ec..e7cb080 100644 --- a/src/lib/tauri.ts +++ b/src/lib/tauri.ts @@ -442,6 +442,12 @@ export async function mcpRemoveServer( }) } +// Appearance / window chrome + +export async function updateTrafficLightPosition(zoom: number): Promise { + return invoke("update_traffic_light_position", { zoom: zoom as number }) +} + // Folder history commands export async function loadFolderHistory(): Promise {