fix(macos): fix traffic lights position on macOS 26 (#4247)

* fix(macos): place traffic lights via Tauri trafficLightPosition

Replaces the cocoa private-API positioning that drove traffic light placement through IPC with Tauri's supported trafficLightPosition window option, which routes through wry's macOS API and stays correct across versions including macOS 26 (Tahoe).

Position is now declared once at window creation: WebviewWindowBuilder.traffic_light_position in src-tauri/src/lib.rs for the initial main window, and trafficLightPosition on new WebviewWindow(...) in utils/nav.ts for reader windows and the recreated main window. The reader path mattered — those windows used to rely on the cocoa hack to place buttons after the on_window_ready hook fired, so any path that bypassed it left the buttons in AppKit's overlay default position (off-screen on macOS 26 until a resize).

The IPC surface narrows accordingly. set_traffic_lights now takes only visible: position is no longer a parameter and the WINDOW_CONTROL_PAD_X/Y static muts go away; setTrafficLightVisibility drops its position arg in trafficLightStore; useTrafficLight and HeaderBar drop their hard-coded { x: 10, y: 20 } magic numbers. position_traffic_lights stops touching the per-button NSWindowButton frames entirely and only collapses or restores the title-bar container view to hide / show buttons during reader chrome auto-hide. A short-circuit on the no-op transition keeps the cocoa setFrame from racing AppKit's own traffic-light tracking on every IPC call.

useTrafficLight stays — it still owns full-screen visibility synchronisation, the auto-hide visibility toggle, and feeds isTrafficLightVisible to the self-drawn <WindowButtons /> in the auth, library, OPDS, reader-sidebar, and user headers. None of those have an equivalent in the new declarative API. Only its 'where do the buttons sit' responsibility was moved out.

A single named constant TRAFFIC_LIGHT_RESTORE_Y_INSET is left behind in traffic_light.rs, used solely by the visible: false → true restore path to recompute the title-bar container height. It must agree with the y component of the two declarative trafficLightPosition values; a doc comment makes that contract explicit. Caching each window's natural title-bar height before the first collapse would let us delete the constant entirely, but the per-window state machine that requires is not worth the win for a single number.

y is tuned by eye to 24 to vertically center the buttons inside readest's ~48px header bar on macOS 26.1.

* fix(macos): center traffic lights from live AppKit offset, no version check

Restores the pre-PR cocoa-driven positioning that worked on macOS 15
while keeping the macOS 26 fix this PR was originally about: the
plugin owns `position_traffic_lights`, which now sizes the title-bar
container *and* sets each window button's frame.origin on every
on_window_ready / resize / theme-change / full-screen-exit event. Tao's
runtime `inset_traffic_lights` never fires (we never declare
`trafficLightPosition` or call `set_traffic_light_position`), so there
is no second code path fighting us on drawRect.

The y inset that visually centers the close button is computed at
runtime as

    y = (header_height - button_height) / 2 + button_origin_y

where `button_origin_y` is the close button's natural rest position
inside the title-bar container. Apple shifted that rest position by
~2pt on macOS Tahoe (26), so the same formula yields y=22 on macOS 15.6
and y=24 on macOS 26.1 with a 48px header — no `NSProcessInfo` lookup
and no hardcoded per-OS offset. The natural origin.y is read once and
cached via `OnceLock` so any post-resize autoresize that AppKit might
apply doesn't feed back into the centering math.

Frontend plumbing: `set_traffic_lights` IPC now carries `headerHeight`;
the zustand store remembers it across visibility toggles; the
`useTrafficLight` hook accepts a header ref, mirrors `ref.current`
into local state (so the effect re-runs when LibraryHeader's
conditional render flips the ref from null to the live node), measures
the border-box height on mount, and observes via ResizeObserver to
re-push on responsive breakpoint / safe-area changes. LibraryHeader,
sidebar Header, OPDS Navigation, and the reader HeaderBar each pass
their own ref so y is computed against the chrome each page actually
renders.

Library header is normalised to h-[44px] desktop to match the reader's
h-11 and drops the `-2px` macOS marginTop workaround, since the runtime
centering removes the need for it.

---------

Co-authored-by: Huang Xin <chrox.huang@gmail.com>
This commit is contained in:
loveheaven
2026-05-21 17:52:43 +08:00
committed by GitHub
parent 1a2e43e659
commit dabdcdcc53
8 changed files with 226 additions and 63 deletions
@@ -6,9 +6,27 @@ use tauri::{
Emitter, Runtime, Window,
};
static mut WINDOW_CONTROL_PAD_X: f64 = 10.0;
static mut WINDOW_CONTROL_PAD_Y: f64 = 22.0;
// Tracks visibility + last-known header height for this app so resize /
// fullscreen-exit callbacks can re-apply the same layout without an
// extra IPC round-trip from the frontend.
static mut TRAFFIC_LIGHTS_VISIBLE: bool = true;
static mut TRAFFIC_LIGHT_HEADER_HEIGHT: f64 = DEFAULT_HEADER_HEIGHT;
/// AppKit's natural rest position for `NSWindowButton.origin.y` inside
/// the title-bar container. This is the per-OS offset Apple shifted in
/// Tahoe (~5pt on macOS 15, ~7pt on macOS 26). Captured on the first
/// read so subsequent reads — which may pick up a post-resize
/// autoresized value rather than the natural one — don't feed back
/// into the centering formula and cause it to drift.
static NATURAL_BUTTON_ORIGIN_Y: std::sync::OnceLock<f64> = std::sync::OnceLock::new();
/// Fallback header height (logical px) when the frontend has not yet
/// reported one. Matches readest's standard `h-11` header so the
/// initial paint before React mounts is close to correct.
const DEFAULT_HEADER_HEIGHT: f64 = 44.0;
/// Horizontal inset for the leftmost close button.
const TRAFFIC_LIGHT_X_INSET: f64 = 10.0;
struct UnsafeWindowHandle(*mut std::ffi::c_void);
unsafe impl Send for UnsafeWindowHandle {}
@@ -31,52 +49,120 @@ pub fn init<R: Runtime>() -> TauriPlugin<R> {
}
#[command]
pub fn set_traffic_lights(window: Window, visible: bool, x: f64, y: f64) {
pub fn set_traffic_lights(window: Window, visible: bool, header_height: f64) {
unsafe {
TRAFFIC_LIGHTS_VISIBLE = visible;
WINDOW_CONTROL_PAD_X = x;
WINDOW_CONTROL_PAD_Y = y;
if header_height > 0.0 {
TRAFFIC_LIGHT_HEADER_HEIGHT = header_height;
}
position_traffic_lights(
UnsafeWindowHandle(window.ns_window().expect("Failed to create window handle")),
TRAFFIC_LIGHTS_VISIBLE,
WINDOW_CONTROL_PAD_X,
WINDOW_CONTROL_PAD_Y,
);
let ns_window = match window.ns_window() {
Ok(handle) => handle,
Err(_) => return,
};
position_traffic_lights(UnsafeWindowHandle(ns_window), visible);
}
}
fn position_traffic_lights(ns_window_handle: UnsafeWindowHandle, visible: bool, x: f64, y: f64) {
/// Centers the close button vertically inside `header_height`.
///
/// `y` (the value tao forwards to `[NSWindowButton setFrameOrigin:]`)
/// is the distance from the title-bar container's top to the button's
/// top. After tao applies it, the button's final position in window-
/// top coords is `y - button_origin_y_in_container`, because tao does
/// not touch `origin.y` — it preserves AppKit's natural rest position.
///
/// Apple shifted that rest position on macOS 26 (Tahoe): the close
/// button sits ~2pt higher in the container than it did on macOS 15
/// (`button.origin.y` reads ~7 on Tahoe vs ~5 on Sonoma/Sequoia). By
/// reading `button.origin.y` live and adding it to the centering math,
/// the formula self-corrects without an `NSProcessInfo` lookup.
fn compute_traffic_light_y(header_height: f64, button_height: f64, button_origin_y: f64) -> f64 {
((header_height - button_height) / 2.0 + button_origin_y).max(0.0)
}
/// Reads the close button's current frame.height and (on the first
/// call only) caches its origin.y as the natural AppKit rest position.
/// Returns `(14, 5)` if the window has no standard buttons (e.g.
/// decorationless utility webviews never call this in practice).
///
/// We **must not** re-read origin.y on every invocation. Some macOS
/// versions appear to autoresize the button's frame inside the
/// title-bar container when the container is resized — feeding that
/// value back into the centering formula produces a runaway where
/// each push grows y, the container grows, AppKit re-pins the button,
/// and the next push grows y again. Caching on first read makes the
/// formula a fixed-point in the natural offset, which is what we want.
unsafe fn measure_close_button(ns_window: *mut std::ffi::c_void) -> (f64, f64) {
use cocoa::appkit::{NSWindow, NSWindowButton};
use cocoa::foundation::NSRect;
let ns_window = ns_window as cocoa::base::id;
let close = ns_window.standardWindowButton_(NSWindowButton::NSWindowCloseButton);
if close.is_null() {
return (14.0, *NATURAL_BUTTON_ORIGIN_Y.get().unwrap_or(&5.0));
}
let frame: NSRect = msg_send![close, frame];
let cached_origin_y = *NATURAL_BUTTON_ORIGIN_Y.get_or_init(|| frame.origin.y);
(frame.size.height, cached_origin_y)
}
/// Owns both the title-bar container size and the standard window
/// buttons' frame origins. We don't call Tauri's
/// `set_traffic_light_position` (which would have routed through tao's
/// `inset_traffic_lights` and ping-ponged with this code on every
/// drawRect), so this is the sole authority for traffic-light layout.
/// The plugin's resize / theme-change / full-screen-exit callbacks
/// re-invoke this so AppKit can't leave the buttons stale.
fn position_traffic_lights(ns_window_handle: UnsafeWindowHandle, visible: bool) {
use cocoa::appkit::{NSView, NSWindow, NSWindowButton};
use cocoa::foundation::NSRect;
let ns_window = ns_window_handle.0 as cocoa::base::id;
unsafe {
let close = ns_window.standardWindowButton_(NSWindowButton::NSWindowCloseButton);
if close.is_null() {
return;
}
let miniaturize =
ns_window.standardWindowButton_(NSWindowButton::NSWindowMiniaturizeButton);
let zoom = ns_window.standardWindowButton_(NSWindowButton::NSWindowZoomButton);
let title_bar_container_view = close.superview().superview();
let close_rect: NSRect = msg_send![close, frame];
let button_height = close_rect.size.height;
let mut title_bar_frame_height = button_height + y;
if !visible {
title_bar_frame_height = 0.0;
}
let title_bar_frame_height = if visible {
let (button_height, button_origin_y) = measure_close_button(ns_window_handle.0);
let y = compute_traffic_light_y(
TRAFFIC_LIGHT_HEADER_HEIGHT,
button_height,
button_origin_y,
);
button_height + y
} else {
0.0
};
let mut title_bar_rect = NSView::frame(title_bar_container_view);
title_bar_rect.size.height = title_bar_frame_height;
title_bar_rect.origin.y = NSView::frame(ns_window).size.height - title_bar_frame_height;
let _: () = msg_send![title_bar_container_view, setFrame: title_bar_rect];
let window_buttons = vec![close, miniaturize, zoom];
let space_between = NSView::frame(miniaturize).origin.x - NSView::frame(close).origin.x;
if !visible || miniaturize.is_null() || zoom.is_null() {
return;
}
for (i, button) in window_buttons.into_iter().enumerate() {
let mut rect: NSRect = NSView::frame(button);
rect.origin.x = x + (i as f64 * space_between);
button.setFrameOrigin(rect.origin);
// Restore each button's frame.origin: x from the configured
// inset, y from the cached natural offset captured by
// measure_close_button so AppKit's autoresize on a container
// change can't drift us. Keeping origin.y stable means the
// centering formula stays a fixed point across resize / theme /
// full-screen events without per-OS tuning.
let cached_origin_y = *NATURAL_BUTTON_ORIGIN_Y.get().unwrap_or(&5.0);
let close_rect: NSRect = msg_send![close, frame];
let miniaturize_rect: NSRect = msg_send![miniaturize, frame];
let space_between = miniaturize_rect.origin.x - close_rect.origin.x;
for (i, button) in [close, miniaturize, zoom].iter().enumerate() {
let origin = cocoa::foundation::NSPoint::new(
TRAFFIC_LIGHT_X_INSET + (i as f64 * space_between),
cached_origin_y,
);
let _: () = msg_send![*button, setFrameOrigin: origin];
}
}
}
@@ -104,13 +190,16 @@ pub fn setup_traffic_light_positioner<R: Runtime>(window: Window<R>) {
return;
}
// Do the initial positioning
// Initial positioning. `position_traffic_lights` owns both the
// container size and the per-button frame origins, so the moment
// `on_window_ready` fires the buttons are centered against the
// current `TRAFFIC_LIGHT_HEADER_HEIGHT` — which defaults to the
// app's standard h-11 (44px) until React reports the active
// page's real height through the `set_traffic_lights` IPC.
unsafe {
position_traffic_lights(
UnsafeWindowHandle(window.ns_window().expect("Failed to create window handle")),
TRAFFIC_LIGHTS_VISIBLE,
WINDOW_CONTROL_PAD_X,
WINDOW_CONTROL_PAD_Y,
);
}
@@ -160,8 +249,6 @@ pub fn setup_traffic_light_positioner<R: Runtime>(window: Window<R>) {
position_traffic_lights(
UnsafeWindowHandle(id as *mut std::ffi::c_void),
TRAFFIC_LIGHTS_VISIBLE,
WINDOW_CONTROL_PAD_X,
WINDOW_CONTROL_PAD_Y,
);
}
});
@@ -295,8 +382,6 @@ pub fn setup_traffic_light_positioner<R: Runtime>(window: Window<R>) {
position_traffic_lights(
UnsafeWindowHandle(id as *mut std::ffi::c_void),
TRAFFIC_LIGHTS_VISIBLE,
WINDOW_CONTROL_PAD_X,
WINDOW_CONTROL_PAD_Y,
);
}
});
@@ -42,6 +42,7 @@ describe('trafficLightStore', () => {
isTrafficLightVisible: false,
shouldShowTrafficLight: false,
trafficLightInFullscreen: false,
headerHeight: 44,
unlistenEnterFullScreen: undefined,
unlistenExitFullScreen: undefined,
});
@@ -119,27 +120,41 @@ describe('trafficLightStore', () => {
expect(state.shouldShowTrafficLight).toBe(false);
});
test('invokes set_traffic_lights with default position', async () => {
test('invokes set_traffic_lights with default header height when none provided', async () => {
// y is computed Rust-side from headerHeight + the live close-button
// frame, so the IPC payload carries height-in-logical-px rather
// than a precomputed inset. Without an explicit value we fall
// back to readest's standard `h-11` (44px).
mockIsFullscreen.mockResolvedValue(false);
await useTrafficLightStore.getState().setTrafficLightVisibility(true);
expect(invoke).toHaveBeenCalledWith('set_traffic_lights', {
visible: true,
x: 10.0,
y: 22.0,
headerHeight: 44,
});
});
test('invokes set_traffic_lights with custom position', async () => {
test('invokes set_traffic_lights with caller-supplied header height', async () => {
mockIsFullscreen.mockResolvedValue(false);
await useTrafficLightStore.getState().setTrafficLightVisibility(true, { x: 20, y: 30 });
await useTrafficLightStore.getState().setTrafficLightVisibility(true, 56);
expect(invoke).toHaveBeenCalledWith('set_traffic_lights', {
visible: true,
x: 20,
y: 30,
headerHeight: 56,
});
});
test('remembers the last header height across visibility toggles', async () => {
mockIsFullscreen.mockResolvedValue(false);
await useTrafficLightStore.getState().setTrafficLightVisibility(true, 56);
await useTrafficLightStore.getState().setTrafficLightVisibility(false);
expect(invoke).toHaveBeenLastCalledWith('set_traffic_lights', {
visible: false,
headerHeight: 56,
});
});
});
@@ -53,10 +53,10 @@ const LibraryHeader: React.FC<LibraryHeaderProps> = ({
const { appService } = useEnv();
const { systemUIVisible, statusBarHeight } = useThemeStore();
const { currentBookshelf } = useLibraryStore();
const { isTrafficLightVisible } = useTrafficLight();
const [searchQuery, setSearchQuery] = useState(searchParams?.get('q') ?? '');
const headerRef = useRef<HTMLDivElement>(null);
const { isTrafficLightVisible } = useTrafficLight(headerRef);
const iconSize18 = useResponsiveSize(18);
const { safeAreaInsets: insets } = useThemeStore();
@@ -98,16 +98,14 @@ const LibraryHeader: React.FC<LibraryHeaderProps> = ({
<div
ref={headerRef}
className={clsx(
'titlebar z-10 flex h-[52px] w-full items-center py-2 pr-4 sm:h-[48px]',
'titlebar z-10 flex h-[52px] w-full items-center py-2 pr-4 sm:h-[44px]',
windowButtonVisible ? 'sm:pr-4' : 'sm:pr-6',
isTrafficLightVisible ? 'pl-16' : 'pl-0 sm:pl-2',
)}
style={{
marginTop: appService?.hasSafeAreaInset
? `max(${insets.top}px, ${systemUIVisible ? statusBarHeight : 0}px)`
: appService?.hasTrafficLight
? '-2px'
: '0px',
: '0px',
}}
>
<div className='flex w-full items-center justify-between space-x-6 sm:space-x-12'>
@@ -43,8 +43,9 @@ export function Navigation({
const viewSettings = settings.globalViewSettings;
const inputRef = useRef<HTMLInputElement>(null);
const headerRef = useRef<HTMLElement>(null);
const [searchQuery, setSearchQuery] = useState('');
const { isTrafficLightVisible } = useTrafficLight();
const { isTrafficLightVisible } = useTrafficLight(headerRef);
useEffect(() => {
setSearchQuery(searchTerm || '');
@@ -78,6 +79,7 @@ export function Navigation({
return (
<header
ref={headerRef}
className={clsx(
'navbar min-h-0 px-2',
'flex h-[48px] w-full items-center',
@@ -58,7 +58,8 @@ const HeaderBar: React.FC<HeaderBarProps> = ({
const _ = useTranslation();
const { envConfig, appService } = useEnv();
const { settings } = useSettingsStore();
const { isTrafficLightVisible } = useTrafficLight();
const headerRef = useRef<HTMLDivElement>(null);
const { isTrafficLightVisible } = useTrafficLight(headerRef);
const { trafficLightInFullscreen, setTrafficLightVisibility } = useTrafficLightStore();
const { bookKeys, hoveredBookKey } = useReaderStore();
const { isDarkMode, systemUIVisible, statusBarHeight } = useThemeStore();
@@ -77,7 +78,6 @@ const HeaderBar: React.FC<HeaderBarProps> = ({
const view = getView(bookKey);
const iconSize16 = useResponsiveSize(16);
const iconSize18 = useResponsiveSize(18);
const headerRef = useRef<HTMLDivElement>(null);
const docs = view?.renderer.getContents() ?? [];
const pointerInDoc = docs.some(({ doc }) => doc?.body?.style.cursor === 'pointer');
@@ -108,7 +108,7 @@ const HeaderBar: React.FC<HeaderBarProps> = ({
if (!appService?.hasTrafficLight) return;
if (hoveredBookKey === bookKey && isTopLeft) {
setTrafficLightVisibility(true, { x: 10, y: 20 });
setTrafficLightVisibility(true);
} else if (!hoveredBookKey) {
setTimeout(() => {
if (!getIsSideBarVisible()) {
@@ -1,5 +1,5 @@
import clsx from 'clsx';
import React from 'react';
import React, { useRef } from 'react';
import { FiSearch } from 'react-icons/fi';
import { MdOutlineMenu, MdOutlinePushPin, MdPushPin } from 'react-icons/md';
import { MdArrowBackIosNew } from 'react-icons/md';
@@ -19,13 +19,15 @@ const SidebarHeader: React.FC<{
onToggleSearchBar: () => void;
}> = ({ bookKey, isPinned, isSearchBarVisible, onClose, onTogglePin, onToggleSearchBar }) => {
const _ = useTranslation();
const { isTrafficLightVisible } = useTrafficLight();
const headerRef = useRef<HTMLDivElement>(null);
const { isTrafficLightVisible } = useTrafficLight(headerRef);
const iconSize14 = useResponsiveSize(14);
const iconSize18 = useResponsiveSize(18);
const iconSize22 = useResponsiveSize(22);
return (
<div
ref={headerRef}
className={clsx(
'sidebar-header flex h-11 items-center justify-between pe-2',
isTrafficLightVisible ? 'ps-1.5 sm:ps-20' : 'ps-1.5',
+56 -3
View File
@@ -1,9 +1,26 @@
import { useEffect } from 'react';
import { useEffect, useState } from 'react';
import { useEnv } from '@/context/EnvContext';
import { useTrafficLightStore } from '@/store/trafficLightStore';
export const useTrafficLight = () => {
/**
* Initializes the traffic-light store for the current window and (when
* a header ref is provided) keeps Rust's centering calculation in sync
* with the page's actual header height. Without a ref, the store's
* h-11 (44px) fallback is used — fine for chrome that matches; pages
* whose chrome is taller (library's h-[44px] / h-[52px], OPDS's
* h-[48px], etc.) must pass their own ref so the inset is computed
* from the rendered height instead of the default.
*
* Callers that conditionally render the header element (LibraryHeader
* delays mounting until `insets` resolves) need this hook to react
* when the ref's `.current` flips from null to the live node. We can't
* depend on the ref object itself — `useRef` returns the same handle
* every render — so we mirror `ref.current` into local state and use
* that as the effect dependency.
*/
export const useTrafficLight = (headerRef?: React.RefObject<HTMLElement | null>) => {
const { appService } = useEnv();
const [headerEl, setHeaderEl] = useState<HTMLElement | null>(() => headerRef?.current ?? null);
const {
isTrafficLightVisible,
@@ -13,17 +30,53 @@ export const useTrafficLight = () => {
cleanupTrafficLightListeners,
} = useTrafficLightStore();
// Sync ref.current → state on every render. The guard inside
// `setHeaderEl` is a no-op when the value hasn't changed, so this
// doesn't trigger a re-render storm.
useEffect(() => {
const current = headerRef?.current ?? null;
setHeaderEl((prev) => (prev === current ? prev : current));
});
useEffect(() => {
if (!appService?.hasTrafficLight) return;
initializeTrafficLightStore(appService);
initializeTrafficLightListeners();
setTrafficLightVisibility(true, { x: 10, y: 20 });
// The ResizeObserver below fires once immediately on `observe()`
// with the current border-box size, so if `headerEl` is already
// mounted we leave the initial height to that callback; otherwise
// we fall back to the store's standard `h-11` (44px) until the
// element appears and the observer kicks in.
setTrafficLightVisibility(true, headerEl?.getBoundingClientRect().height);
return () => {
cleanupTrafficLightListeners();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [appService?.hasTrafficLight]);
// Track header size so chrome that resizes (responsive breakpoint,
// font scale, safe-area inset shift) recenters the buttons without a
// per-component push. Read the border-box height — `entry.contentRect`
// returns content-box, which excludes padding. Library's `h-[48px]`
// with `py-2` measures 32 via contentRect (off by 16), pushing y too
// small and dropping the buttons toward the top of the chrome.
useEffect(() => {
if (!appService?.hasTrafficLight || !headerEl) return;
let lastHeight = -1;
const observer = new ResizeObserver(([entry]) => {
if (!entry) return;
const height =
entry.borderBoxSize?.[0]?.blockSize ?? entry.target.getBoundingClientRect().height;
if (height <= 0 || height === lastHeight) return;
lastHeight = height;
const { shouldShowTrafficLight } = useTrafficLightStore.getState();
setTrafficLightVisibility(shouldShowTrafficLight, height);
});
observer.observe(headerEl);
return () => observer.disconnect();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [appService?.hasTrafficLight, headerEl]);
return { isTrafficLightVisible };
};
@@ -2,16 +2,20 @@ import { create } from 'zustand';
import { invoke } from '@tauri-apps/api/core';
import { AppService } from '@/types/system';
const WINDOW_CONTROL_PAD_X = 10.0;
const WINDOW_CONTROL_PAD_Y = 22.0;
// Matches readest's standard `h-11` header (44px). Used as a fallback
// when a caller flips visibility without supplying its own measured
// height — e.g. the initial `useTrafficLight()` mount on pages whose
// header height is fixed.
const DEFAULT_HEADER_HEIGHT = 44;
interface TrafficLightState {
appService?: AppService;
isTrafficLightVisible: boolean;
shouldShowTrafficLight: boolean;
trafficLightInFullscreen: boolean;
headerHeight: number;
initializeTrafficLightStore: (appService: AppService) => void;
setTrafficLightVisibility: (visible: boolean, position?: { x: number; y: number }) => void;
setTrafficLightVisibility: (visible: boolean, headerHeight?: number) => void;
initializeTrafficLightListeners: () => Promise<void>;
cleanupTrafficLightListeners: () => void;
unlistenEnterFullScreen?: () => void;
@@ -24,6 +28,7 @@ export const useTrafficLightStore = create<TrafficLightState>((set, get) => {
isTrafficLightVisible: false,
shouldShowTrafficLight: false,
trafficLightInFullscreen: false,
headerHeight: DEFAULT_HEADER_HEIGHT,
initializeTrafficLightStore: (appService: AppService) => {
set({
@@ -33,20 +38,23 @@ export const useTrafficLightStore = create<TrafficLightState>((set, get) => {
});
},
setTrafficLightVisibility: async (visible: boolean, position?: { x: number; y: number }) => {
setTrafficLightVisibility: async (visible: boolean, headerHeight?: number) => {
const { getCurrentWindow } = await import('@tauri-apps/api/window');
const currentWindow = getCurrentWindow();
const isFullscreen = await currentWindow.isFullscreen();
const nextHeight = headerHeight ?? get().headerHeight;
set({
isTrafficLightVisible: !isFullscreen && visible,
shouldShowTrafficLight: visible,
trafficLightInFullscreen: isFullscreen,
headerHeight: nextHeight,
});
invoke('set_traffic_lights', {
visible: visible,
x: position?.x ?? WINDOW_CONTROL_PAD_X,
y: position?.y ?? WINDOW_CONTROL_PAD_Y,
});
// Rust reads the close button's natural rest position from cocoa
// and combines it with `headerHeight` to compute the y that
// visually centers the buttons. The formula self-adjusts across
// macOS versions because Apple's per-version offset is encoded
// in the button's frame.origin.y, not in our code.
invoke('set_traffic_lights', { visible, headerHeight: nextHeight });
},
initializeTrafficLightListeners: async () => {