diff --git a/apps/readest-app/src-tauri/src/macos/traffic_light.rs b/apps/readest-app/src-tauri/src/macos/traffic_light.rs index fae1fc5a..ae5841bb 100644 --- a/apps/readest-app/src-tauri/src/macos/traffic_light.rs +++ b/apps/readest-app/src-tauri/src/macos/traffic_light.rs @@ -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 = 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() -> TauriPlugin { } #[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(window: Window) { 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(window: Window) { 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(window: Window) { position_traffic_lights( UnsafeWindowHandle(id as *mut std::ffi::c_void), TRAFFIC_LIGHTS_VISIBLE, - WINDOW_CONTROL_PAD_X, - WINDOW_CONTROL_PAD_Y, ); } }); diff --git a/apps/readest-app/src/__tests__/store/traffic-light-store.test.ts b/apps/readest-app/src/__tests__/store/traffic-light-store.test.ts index 49beb543..a0f33a1a 100644 --- a/apps/readest-app/src/__tests__/store/traffic-light-store.test.ts +++ b/apps/readest-app/src/__tests__/store/traffic-light-store.test.ts @@ -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, }); }); }); diff --git a/apps/readest-app/src/app/library/components/LibraryHeader.tsx b/apps/readest-app/src/app/library/components/LibraryHeader.tsx index 0932b724..f96db345 100644 --- a/apps/readest-app/src/app/library/components/LibraryHeader.tsx +++ b/apps/readest-app/src/app/library/components/LibraryHeader.tsx @@ -53,10 +53,10 @@ const LibraryHeader: React.FC = ({ const { appService } = useEnv(); const { systemUIVisible, statusBarHeight } = useThemeStore(); const { currentBookshelf } = useLibraryStore(); - const { isTrafficLightVisible } = useTrafficLight(); const [searchQuery, setSearchQuery] = useState(searchParams?.get('q') ?? ''); const headerRef = useRef(null); + const { isTrafficLightVisible } = useTrafficLight(headerRef); const iconSize18 = useResponsiveSize(18); const { safeAreaInsets: insets } = useThemeStore(); @@ -98,16 +98,14 @@ const LibraryHeader: React.FC = ({
diff --git a/apps/readest-app/src/app/opds/components/Navigation.tsx b/apps/readest-app/src/app/opds/components/Navigation.tsx index 0bd473a3..0b4740d8 100644 --- a/apps/readest-app/src/app/opds/components/Navigation.tsx +++ b/apps/readest-app/src/app/opds/components/Navigation.tsx @@ -43,8 +43,9 @@ export function Navigation({ const viewSettings = settings.globalViewSettings; const inputRef = useRef(null); + const headerRef = useRef(null); const [searchQuery, setSearchQuery] = useState(''); - const { isTrafficLightVisible } = useTrafficLight(); + const { isTrafficLightVisible } = useTrafficLight(headerRef); useEffect(() => { setSearchQuery(searchTerm || ''); @@ -78,6 +79,7 @@ export function Navigation({ return (
= ({ const _ = useTranslation(); const { envConfig, appService } = useEnv(); const { settings } = useSettingsStore(); - const { isTrafficLightVisible } = useTrafficLight(); + const headerRef = useRef(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 = ({ const view = getView(bookKey); const iconSize16 = useResponsiveSize(16); const iconSize18 = useResponsiveSize(18); - const headerRef = useRef(null); const docs = view?.renderer.getContents() ?? []; const pointerInDoc = docs.some(({ doc }) => doc?.body?.style.cursor === 'pointer'); @@ -108,7 +108,7 @@ const HeaderBar: React.FC = ({ if (!appService?.hasTrafficLight) return; if (hoveredBookKey === bookKey && isTopLeft) { - setTrafficLightVisibility(true, { x: 10, y: 20 }); + setTrafficLightVisibility(true); } else if (!hoveredBookKey) { setTimeout(() => { if (!getIsSideBarVisible()) { diff --git a/apps/readest-app/src/app/reader/components/sidebar/Header.tsx b/apps/readest-app/src/app/reader/components/sidebar/Header.tsx index ac6d2904..a4f22b2f 100644 --- a/apps/readest-app/src/app/reader/components/sidebar/Header.tsx +++ b/apps/readest-app/src/app/reader/components/sidebar/Header.tsx @@ -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(null); + const { isTrafficLightVisible } = useTrafficLight(headerRef); const iconSize14 = useResponsiveSize(14); const iconSize18 = useResponsiveSize(18); const iconSize22 = useResponsiveSize(22); return (
{ +/** + * 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) => { const { appService } = useEnv(); + const [headerEl, setHeaderEl] = useState(() => 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 }; }; diff --git a/apps/readest-app/src/store/trafficLightStore.ts b/apps/readest-app/src/store/trafficLightStore.ts index 7987df7a..25c81e1c 100644 --- a/apps/readest-app/src/store/trafficLightStore.ts +++ b/apps/readest-app/src/store/trafficLightStore.ts @@ -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; cleanupTrafficLightListeners: () => void; unlistenEnterFullScreen?: () => void; @@ -24,6 +28,7 @@ export const useTrafficLightStore = create((set, get) => { isTrafficLightVisible: false, shouldShowTrafficLight: false, trafficLightInFullscreen: false, + headerHeight: DEFAULT_HEADER_HEIGHT, initializeTrafficLightStore: (appService: AppService) => { set({ @@ -33,20 +38,23 @@ export const useTrafficLightStore = create((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 () => {