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) <noreply@anthropic.com>
This commit is contained in:
xintaofei
2026-04-12 18:11:40 +08:00
parent 883fb64db0
commit 843cf8df19
4 changed files with 77 additions and 15 deletions

View File

@@ -1,5 +1,7 @@
use std::collections::HashMap; use std::collections::HashMap;
use std::sync::Mutex; use std::sync::Mutex;
#[cfg(target_os = "macos")]
use std::sync::atomic::{AtomicU32, Ordering};
use tauri::{AppHandle, Manager, WebviewUrl, WebviewWindowBuilder}; use tauri::{AppHandle, Manager, WebviewUrl, WebviewWindowBuilder};
@@ -7,6 +9,23 @@ use crate::app_error::AppCommandError;
use crate::db::AppDatabase; use crate::db::AppDatabase;
use crate::models::FolderHistoryEntry; 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<f64> {
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 { pub struct SettingsWindowState {
owner_window_label: Mutex<Option<String>>, owner_window_label: Mutex<Option<String>>,
} }
@@ -31,6 +50,7 @@ where
builder builder
.hidden_title(true) .hidden_title(true)
.title_bar_style(tauri::TitleBarStyle::Overlay) .title_bar_style(tauri::TitleBarStyle::Overlay)
.traffic_light_position(traffic_light_position())
} }
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
@@ -52,6 +72,11 @@ fn ensure_windows_undecorated(window: &tauri::WebviewWindow) {
#[cfg(not(target_os = "windows"))] #[cfg(not(target_os = "windows"))]
fn ensure_windows_undecorated(_window: &tauri::WebviewWindow) {} 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 { impl SettingsWindowState {
pub fn new() -> Self { pub fn new() -> Self {
Self { Self {
@@ -202,7 +227,7 @@ pub async fn open_folder_window(
let label = folder_window_label(entry.id); let label = folder_window_label(entry.id);
if let Some(existing) = app.get_webview_window(&label) { if let Some(existing) = app.get_webview_window(&label) {
ensure_windows_undecorated(&existing); post_window_setup(&existing);
let _ = existing.unminimize(); let _ = existing.unminimize();
existing existing
.set_focus() .set_focus()
@@ -224,7 +249,7 @@ pub async fn open_folder_window(
let folder_window = apply_platform_window_style(builder) let folder_window = apply_platform_window_style(builder)
.build() .build()
.map_err(|e| AppCommandError::window("Failed to open folder window", e.to_string()))?; .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 // Close welcome and project-boot windows
if let Some(w) = app.get_webview_window("welcome") { 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) let commit_window = apply_platform_window_style(builder)
.build() .build()
.map_err(|e| AppCommandError::window("Failed to open commit window", e.to_string()))?; .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 Some(owner_window) = app.get_webview_window(&owner_label) {
if let Err(err) = owner_window.set_enabled(false) { if let Err(err) = owner_window.set_enabled(false) {
let _ = commit_window.close(); let _ = commit_window.close();
@@ -309,7 +334,7 @@ pub async fn open_settings_window(
) -> Result<(), AppCommandError> { ) -> Result<(), AppCommandError> {
let target_route = resolve_settings_target(section.as_deref(), agent_type.as_deref()); let target_route = resolve_settings_target(section.as_deref(), agent_type.as_deref());
if let Some(existing) = app.get_webview_window("settings") { 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() { if section.is_some() || agent_type.is_some() {
let target_path = format!("/{target_route}"); let target_path = format!("/{target_route}");
let target_json = serde_json::to_string(&target_path).map_err(|e| { 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) let settings_window = apply_platform_window_style(builder)
.build() .build()
.map_err(|e| AppCommandError::window("Failed to open settings window", e.to_string()))?; .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); state.set_owner(owner_label);
settings_window settings_window
@@ -442,7 +467,7 @@ pub async fn open_merge_window(
let merge_window = apply_platform_window_style(builder) let merge_window = apply_platform_window_style(builder)
.build() .build()
.map_err(|e| AppCommandError::window("Failed to open merge window", e.to_string()))?; .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 Some(owner_window) = app.get_webview_window(&owner_label) {
if let Err(err) = owner_window.set_enabled(false) { if let Err(err) = owner_window.set_enabled(false) {
let _ = merge_window.close(); 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> { pub fn open_welcome_window(app: &AppHandle) -> Result<(), AppCommandError> {
if let Some(existing) = app.get_webview_window("welcome") { if let Some(existing) = app.get_webview_window("welcome") {
ensure_windows_undecorated(&existing); post_window_setup(&existing);
return Ok(()); return Ok(());
} }
let url = WebviewUrl::App("welcome".into()); 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) let welcome_window = apply_platform_window_style(builder)
.build() .build()
.map_err(|e| AppCommandError::window("Failed to open welcome window", e.to_string()))?; .map_err(|e| AppCommandError::window("Failed to open welcome window", e.to_string()))?;
ensure_windows_undecorated(&welcome_window); post_window_setup(&welcome_window);
Ok(()) Ok(())
} }
@@ -548,7 +573,7 @@ pub async fn open_stash_window(
let label = format!("stash-{folder_id}"); let label = format!("stash-{folder_id}");
if let Some(existing) = app.get_webview_window(&label) { if let Some(existing) = app.get_webview_window(&label) {
ensure_windows_undecorated(&existing); post_window_setup(&existing);
let _ = existing.unminimize(); let _ = existing.unminimize();
existing existing
.set_focus() .set_focus()
@@ -573,7 +598,7 @@ pub async fn open_stash_window(
let stash_window = apply_platform_window_style(builder) let stash_window = apply_platform_window_style(builder)
.build() .build()
.map_err(|e| AppCommandError::window("Failed to open stash window", e.to_string()))?; .map_err(|e| AppCommandError::window("Failed to open stash window", e.to_string()))?;
ensure_windows_undecorated(&stash_window); post_window_setup(&stash_window);
Ok(()) Ok(())
} }
@@ -588,7 +613,7 @@ pub async fn open_push_window(
let label = format!("push-{folder_id}"); let label = format!("push-{folder_id}");
if let Some(existing) = app.get_webview_window(&label) { if let Some(existing) = app.get_webview_window(&label) {
ensure_windows_undecorated(&existing); post_window_setup(&existing);
let _ = existing.unminimize(); let _ = existing.unminimize();
existing existing
.set_focus() .set_focus()
@@ -613,7 +638,7 @@ pub async fn open_push_window(
let push_window = apply_platform_window_style(builder) let push_window = apply_platform_window_style(builder)
.build() .build()
.map_err(|e| AppCommandError::window("Failed to open push window", e.to_string()))?; .map_err(|e| AppCommandError::window("Failed to open push window", e.to_string()))?;
ensure_windows_undecorated(&push_window); post_window_setup(&push_window);
Ok(()) Ok(())
} }
@@ -625,7 +650,7 @@ pub async fn open_project_boot_window(
source: Option<String>, source: Option<String>,
) -> Result<(), AppCommandError> { ) -> Result<(), AppCommandError> {
if let Some(existing) = app.get_webview_window("project-boot") { if let Some(existing) = app.get_webview_window("project-boot") {
ensure_windows_undecorated(&existing); post_window_setup(&existing);
let _ = existing.unminimize(); let _ = existing.unminimize();
existing.set_focus().map_err(|e| { existing.set_focus().map_err(|e| {
AppCommandError::window("Failed to focus project boot window", e.to_string()) 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| { .map_err(|e| {
AppCommandError::window("Failed to open project boot window", e.to_string()) 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 // Close welcome if opened from welcome
if source.as_deref() == Some("welcome") { if source.as_deref() == Some("welcome") {
@@ -661,3 +686,15 @@ pub async fn open_project_boot_window(
Ok(()) 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(())
}

View File

@@ -136,7 +136,9 @@ mod tauri_app {
.title(&entry.name) .title(&entry.name)
.inner_size(1260.0, 860.0) .inner_size(1260.0, 860.0)
.min_inner_size(900.0, 600.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_stash_window,
windows::open_push_window, windows::open_push_window,
windows::open_project_boot_window, windows::open_project_boot_window,
windows::update_traffic_light_position,
project_boot::detect_package_manager, project_boot::detect_package_manager,
project_boot::create_shadcn_project, project_boot::create_shadcn_project,
system_settings::get_system_proxy_settings, system_settings::get_system_proxy_settings,

View File

@@ -14,6 +14,14 @@ import {
STORAGE_KEY_ZOOM_LEVEL, STORAGE_KEY_ZOOM_LEVEL,
} from "@/lib/appearance-script" } 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 = { type AppearanceContextValue = {
themeColor: ThemeColor themeColor: ThemeColor
setThemeColor: (color: ThemeColor) => void setThemeColor: (color: ThemeColor) => void
@@ -73,6 +81,7 @@ export function AppearanceProvider({
const setZoomLevel = useCallback((zoom: ZoomLevel) => { const setZoomLevel = useCallback((zoom: ZoomLevel) => {
setZoomLevelState(zoom) setZoomLevelState(zoom)
document.documentElement.style.fontSize = `${(16 * zoom) / 100}px` document.documentElement.style.fontSize = `${(16 * zoom) / 100}px`
syncTrafficLightPosition(zoom)
try { try {
localStorage.setItem(STORAGE_KEY_ZOOM_LEVEL, String(zoom)) localStorage.setItem(STORAGE_KEY_ZOOM_LEVEL, String(zoom))
} catch { } 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(() => { useEffect(() => {
const onStorage = (e: StorageEvent) => { const onStorage = (e: StorageEvent) => {
@@ -95,6 +110,7 @@ export function AppearanceProvider({
if ((ZOOM_LEVELS as readonly number[]).includes(zoom)) { if ((ZOOM_LEVELS as readonly number[]).includes(zoom)) {
setZoomLevelState(zoom) setZoomLevelState(zoom)
document.documentElement.style.fontSize = `${(16 * zoom) / 100}px` document.documentElement.style.fontSize = `${(16 * zoom) / 100}px`
syncTrafficLightPosition(zoom)
} }
} }
} }

View File

@@ -442,6 +442,12 @@ export async function mcpRemoveServer(
}) })
} }
// Appearance / window chrome
export async function updateTrafficLightPosition(zoom: number): Promise<void> {
return invoke("update_traffic_light_position", { zoom: zoom as number })
}
// Folder history commands // Folder history commands
export async function loadFolderHistory(): Promise<FolderHistoryEntry[]> { export async function loadFolderHistory(): Promise<FolderHistoryEntry[]> {