diff --git a/apps/desktop/src/components/layout/AppToolbar.vue b/apps/desktop/src/components/layout/AppToolbar.vue index 9272ba865c..75694056c5 100644 --- a/apps/desktop/src/components/layout/AppToolbar.vue +++ b/apps/desktop/src/components/layout/AppToolbar.vue @@ -1,13 +1,14 @@ - + - - {{ t("toolbar.newConnection") }} + + + {{ t("toolbar.newConnection") }} + diff --git a/apps/desktop/src/composables/__tests__/useWindowControls.spec.ts b/apps/desktop/src/composables/__tests__/useWindowControls.spec.ts index 6cd19c2da3..e1c0068d56 100644 --- a/apps/desktop/src/composables/__tests__/useWindowControls.spec.ts +++ b/apps/desktop/src/composables/__tests__/useWindowControls.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { shouldDrawDesktopWindowFrame, shouldReserveMacTrafficLightInset, shouldShowWindowControls } from "@/composables/useWindowControls"; +import { macTrafficLightInsetPaddingForScale, macTrafficLightPositionForScale, shouldDrawDesktopWindowFrame, shouldReserveMacTrafficLightInset, shouldShowWindowControls } from "@/composables/useWindowControls"; describe("window controls", () => { it("shows custom controls for non-macOS desktop windows", () => { @@ -26,4 +26,17 @@ describe("window controls", () => { expect(shouldReserveMacTrafficLightInset(false, false, true)).toBe(false); expect(shouldReserveMacTrafficLightInset(true, false, false)).toBe(false); }); + + it("keeps the macOS traffic light reserve visually stable across UI scales", () => { + expect(macTrafficLightInsetPaddingForScale(1)).toBe("70px"); + expect(macTrafficLightInsetPaddingForScale(0.9)).toBe("78px"); + expect(macTrafficLightInsetPaddingForScale(1.25)).toBe("56px"); + }); + + it("adjusts macOS traffic lights vertically with the UI scale", () => { + expect(macTrafficLightPositionForScale(1)).toEqual({ x: 16, y: 18 }); + expect(macTrafficLightPositionForScale(0.9)).toEqual({ x: 16, y: 16 }); + expect(macTrafficLightPositionForScale(1.1)).toEqual({ x: 16, y: 20 }); + expect(macTrafficLightPositionForScale(1.25)).toEqual({ x: 16, y: 23 }); + }); }); diff --git a/apps/desktop/src/composables/useWindowControls.ts b/apps/desktop/src/composables/useWindowControls.ts index 1b5bfbd690..4a8be83dd2 100644 --- a/apps/desktop/src/composables/useWindowControls.ts +++ b/apps/desktop/src/composables/useWindowControls.ts @@ -3,6 +3,30 @@ import { isTauriRuntime } from "@/lib/backend/tauriRuntime"; import { isMacOS } from "@/lib/backend/platform"; import * as api from "@/lib/backend/api"; +const MIN_UI_SCALE = 0.75; +const MAX_UI_SCALE = 2; +export const MAC_TRAFFIC_LIGHT_X = 16; +export const MAC_TRAFFIC_LIGHT_BASE_Y = 18; +const MAC_TRAFFIC_LIGHT_SCALE_DELTA_Y = 20; +const MAC_TRAFFIC_LIGHT_RESERVED_INSET = 70; + +function normalizeTrafficLightUiScale(scale: number): number { + return Number.isFinite(scale) ? Math.min(MAX_UI_SCALE, Math.max(MIN_UI_SCALE, scale)) : 1; +} + +export function macTrafficLightPositionForScale(scale: number): { x: number; y: number } { + const normalizedScale = normalizeTrafficLightUiScale(scale); + return { + x: MAC_TRAFFIC_LIGHT_X, + y: Math.round(MAC_TRAFFIC_LIGHT_BASE_Y + (normalizedScale - 1) * MAC_TRAFFIC_LIGHT_SCALE_DELTA_Y), + }; +} + +export function macTrafficLightInsetPaddingForScale(scale: number): string { + const normalizedScale = normalizeTrafficLightUiScale(scale); + return `${Math.ceil(MAC_TRAFFIC_LIGHT_RESERVED_INSET / normalizedScale)}px`; +} + export function shouldReserveMacTrafficLightInset(isMac: boolean, isFullscreen: boolean, isDesktop = true): boolean { return isDesktop && isMac && !isFullscreen; } diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 488604a486..aea9d1d05d 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -64,7 +64,7 @@ tauri-plugin-clipboard-manager = "2.3.2" [target.'cfg(target_os = "macos")'.dependencies] objc2 = { version = "0.6.4", default-features = false, features = ["std"] } -objc2-app-kit = { version = "0.3.2", default-features = false, features = ["NSApplication", "NSImage"] } +objc2-app-kit = { version = "0.3.2", default-features = false, features = ["NSApplication", "NSButton", "NSControl", "NSImage", "NSResponder", "NSView", "NSWindow"] } objc2-foundation = { version = "0.3.2", default-features = false, features = ["NSData"] } [target.'cfg(target_os = "linux")'.dependencies] diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index a424906e52..a219fb31c6 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -45,5 +45,6 @@ pub mod text_export; pub mod transfer; pub mod tunnel_profiles; pub mod update; +pub mod window_controls; pub mod xlsx_export; pub mod zookeeper_cmd; diff --git a/src-tauri/src/commands/window_controls.rs b/src-tauri/src/commands/window_controls.rs new file mode 100644 index 0000000000..0c60076e62 --- /dev/null +++ b/src-tauri/src/commands/window_controls.rs @@ -0,0 +1,133 @@ +#[cfg(target_os = "macos")] +async fn set_macos_traffic_light_position_inner( + window: tauri::Window, + target_x: f64, + target_center_y: f64, + ui_scale: f64, +) -> Result { + let ns_window = window.ns_window().map_err(|err| err.to_string())? as usize; + let (tx, rx) = tokio::sync::oneshot::channel(); + window + .run_on_main_thread(move || unsafe { + use objc2_app_kit::{NSWindow, NSWindowButton}; + + let ns_window = &*(ns_window as *mut NSWindow); + let Some(close) = ns_window.standardWindowButton(NSWindowButton::CloseButton) else { + let _ = tx.send(None); + return; + }; + let Some(miniaturize) = ns_window.standardWindowButton(NSWindowButton::MiniaturizeButton) else { + let _ = tx.send(None); + return; + }; + let zoom = ns_window.standardWindowButton(NSWindowButton::ZoomButton); + + let Some(title_bar_container_view) = close.superview().and_then(|view| view.superview()) else { + let _ = tx.send(None); + return; + }; + + let close_rect = close.frame(); + let window_height = ns_window.frame().size.height; + let target_window_center_y = target_center_y * ui_scale; + let target_y = (target_window_center_y - close_rect.size.height / 2.0).max(0.0); + let title_bar_frame_height = close_rect.size.height + target_y; + let mut title_bar_rect = title_bar_container_view.frame(); + title_bar_rect.size.height = title_bar_frame_height; + title_bar_rect.origin.y = window_height - title_bar_frame_height; + title_bar_container_view.setFrame(title_bar_rect); + + let close_rect = close.frame(); + let current_window_rect = + close.superview().map(|view| view.convertRect_toView(close_rect, None)).unwrap_or(close_rect); + let current_center_y = + window_height - (current_window_rect.origin.y + current_window_rect.size.height / 2.0); + let center_delta_y = target_window_center_y - current_center_y; + let space_between = miniaturize.frame().origin.x - close_rect.origin.x; + let mut buttons = vec![close, miniaturize]; + if let Some(zoom) = zoom { + buttons.push(zoom); + } + for (index, button) in buttons.into_iter().enumerate() { + let mut rect = button.frame(); + rect.origin.x = target_x + (index as f64 * space_between); + rect.origin.y -= center_delta_y; + button.setFrameOrigin(rect.origin); + } + + let Some(close) = ns_window.standardWindowButton(NSWindowButton::CloseButton) else { + let _ = tx.send(None); + return; + }; + let zoom = ns_window.standardWindowButton(NSWindowButton::ZoomButton); + let close_rect = close.frame(); + let close_window_rect = + close.superview().map(|view| view.convertRect_toView(close_rect, None)).unwrap_or(close_rect); + let reserved_button_rect = zoom + .as_ref() + .and_then(|button| button.superview().map(|view| view.convertRect_toView(button.frame(), None))) + .unwrap_or(close_window_rect); + let button_center_y = window_height - (close_window_rect.origin.y + close_window_rect.size.height / 2.0); + let _ = tx.send(Some(MacosTrafficLightLayout { + x: close_window_rect.origin.x, + y: target_y, + center_y: button_center_y, + previous_center_y: current_center_y, + reserved_inset: reserved_button_rect.origin.x + reserved_button_rect.size.width + 8.0, + })); + }) + .map_err(|err| err.to_string())?; + rx.await.map_err(|err| err.to_string())?.ok_or_else(|| "Unable to locate macOS traffic light buttons".to_string()) +} + +#[cfg(not(target_os = "macos"))] +async fn set_macos_traffic_light_position_inner( + _window: tauri::Window, + x: f64, + target_center_y: f64, + ui_scale: f64, +) -> Result { + let center_y = target_center_y * ui_scale; + Ok(MacosTrafficLightLayout { x, y: center_y, center_y, previous_center_y: center_y, reserved_inset: 0.0 }) +} + +fn validate_macos_traffic_light_position(x: f64, y: f64, scale: f64) -> Result<(), String> { + if !x.is_finite() || !y.is_finite() || !scale.is_finite() || scale <= 0.0 { + return Err("Invalid traffic light position".to_string()); + } + Ok(()) +} + +#[derive(Debug, Clone, serde::Serialize)] +pub struct MacosTrafficLightLayout { + x: f64, + y: f64, + center_y: f64, + previous_center_y: f64, + reserved_inset: f64, +} + +#[tauri::command] +pub async fn set_macos_traffic_light_position( + window: tauri::Window, + x: f64, + y: f64, + scale: f64, +) -> Result { + validate_macos_traffic_light_position(x, y, scale)?; + // Await AppKit work asynchronously so the main thread can execute the scheduled closure. + set_macos_traffic_light_position_inner(window, x, y, scale).await +} + +#[cfg(test)] +mod tests { + use super::validate_macos_traffic_light_position; + + #[test] + fn validates_macos_traffic_light_position_inputs() { + assert!(validate_macos_traffic_light_position(16.0, 18.0, 1.0).is_ok()); + assert!(validate_macos_traffic_light_position(f64::NAN, 18.0, 1.0).is_err()); + assert!(validate_macos_traffic_light_position(16.0, f64::INFINITY, 1.0).is_err()); + assert!(validate_macos_traffic_light_position(16.0, 18.0, 0.0).is_err()); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 7cb454eb49..6fc6afb396 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -878,6 +878,7 @@ pub fn run() { commands::app_settings::save_desktop_settings, commands::app_settings::complete_app_close, commands::app_settings::request_app_close_from_window_controls, + commands::window_controls::set_macos_traffic_light_position, commands::app_settings::set_driver_store_dir, commands::app_settings::set_plugin_store_dir, commands::app_settings::set_agent_store_dir,