Skip to main content

rightkit_tauri_shell/
frameless.rs

1//! Frameless window chrome. macOS keeps the native traffic lights over an overlay title bar;
2//! Windows/Linux drop decorations and the app draws its own title bar (the `@rightkit/react-shell`
3//! TitleBar) using the `rk_shell_window` controls and `data-tauri-drag-region`.
4//!
5//! Donor: ViewRight `traffic_lights` (capture the macOS-default button origins once, apply a
6//! horizontal/vertical offset, re-assert after resize/focus because AppKit resets them).
7
8#[cfg(all(feature = "qa-probes", debug_assertions))]
9use serde::Serialize;
10use tauri::{WebviewWindow, WebviewWindowBuilder, Wry};
11
12#[cfg(all(feature = "qa-probes", debug_assertions))]
13use crate::error::ShellError;
14
15/// Style a builder for the frameless look. No-op when `frameless` is false.
16pub fn style<'a>(
17    b: WebviewWindowBuilder<'a, Wry, tauri::AppHandle<Wry>>,
18    frameless: bool,
19) -> WebviewWindowBuilder<'a, Wry, tauri::AppHandle<Wry>> {
20    if !frameless {
21        return b;
22    }
23    #[cfg(target_os = "macos")]
24    {
25        b.title_bar_style(tauri::TitleBarStyle::Overlay)
26            .hidden_title(true)
27    }
28    #[cfg(not(target_os = "macos"))]
29    {
30        b.decorations(false)
31    }
32}
33
34/// The macOS traffic-light offset, usable on its own.
35///
36/// A plain library type: it needs neither the shell plugin nor the managed `ShellState`. An app that
37/// keeps its own lifecycle manages one itself and forwards window events to it:
38///
39/// ```text
40/// app.manage(rightkit_tauri_shell::frameless::TrafficLights::default());
41/// // in `on_window_event`:
42/// if let Some(t) = app.try_state::<TrafficLights>() { t.handle_window_event(&webview, event); }
43/// // in a command: `t.set_offset(&window, dx, dy)`
44/// ```
45///
46/// With the shell plugin installed the calls run through the shell's main-thread dispatch (which
47/// rejects work during shutdown). Without it they run inline when already on the main thread and are
48/// queued to it otherwise (fire and forget), so no lifecycle hook or single-instance claim is needed.
49/// Cloning shares the captured default origins and the last offset.
50#[derive(Default, Clone)]
51pub struct TrafficLights {
52    #[cfg(target_os = "macos")]
53    inner: std::sync::Arc<macos::Inner>,
54}
55
56impl std::fmt::Debug for TrafficLights {
57    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
58        f.debug_struct("TrafficLights").finish_non_exhaustive()
59    }
60}
61
62impl TrafficLights {
63    /// A fresh instance with no offset applied.
64    pub fn new() -> Self {
65        Self::default()
66    }
67
68    /// Offset the close/minimise/zoom buttons from their macOS default origin (x right, y down).
69    /// No-op off macOS.
70    pub fn set_offset(&self, window: &WebviewWindow<Wry>, dx: f64, dy: f64) {
71        #[cfg(target_os = "macos")]
72        {
73            let inner = std::sync::Arc::clone(&self.inner);
74            macos::dispatch(window, move |window| macos::apply(window, dx, dy, &inner));
75        }
76        #[cfg(not(target_os = "macos"))]
77        let _ = (window, dx, dy);
78    }
79    /// Place the close / minimise / zoom buttons at an absolute inset instead of a relative offset: the
80    /// close button's top-left corner sits `x` points from the window's left edge and `y` points from its
81    /// top (y down), and the other two keep the macOS spacing. Replaces the last `set_offset` (and the
82    /// reverse), and is re-asserted by [`TrafficLights::reapply`]. No-op off macOS.
83    pub fn set_inset(&self, window: &WebviewWindow<Wry>, x: f64, y: f64) {
84        #[cfg(target_os = "macos")]
85        {
86            let inner = std::sync::Arc::clone(&self.inner);
87            macos::dispatch(window, move |window| {
88                macos::apply_inset(window, x, y, &inner)
89            });
90        }
91        #[cfg(not(target_os = "macos"))]
92        let _ = (window, x, y);
93    }
94    /// Re-assert the last offset (call from resize / focus / fullscreen events).
95    pub fn reapply(&self, window: &WebviewWindow<Wry>) {
96        #[cfg(target_os = "macos")]
97        {
98            let inner = std::sync::Arc::clone(&self.inner);
99            macos::dispatch(window, move |window| macos::reapply(window, &inner));
100        }
101        #[cfg(not(target_os = "macos"))]
102        let _ = window;
103    }
104    /// Re-assert the offset when `event` is one AppKit answers by resetting the buttons: a resize or
105    /// a focus gain (fullscreen exit refocuses). Other events are ignored. No-op off macOS.
106    pub fn handle_window_event(&self, window: &WebviewWindow<Wry>, event: &tauri::WindowEvent) {
107        if matches!(
108            event,
109            tauri::WindowEvent::Resized(_)
110                | tauri::WindowEvent::Focused(true)
111                | tauri::WindowEvent::ScaleFactorChanged { .. }
112        ) {
113            self.reapply(window);
114        }
115    }
116}
117
118/// One standard window button's frame in its superview's coordinates (AppKit points, origin at the
119/// bottom-left) and whether it is hidden. QA probe: compiled only with the `qa-probes` feature in a debug-assertions build.
120#[cfg(all(feature = "qa-probes", debug_assertions))]
121#[derive(Debug, Clone, Copy, PartialEq, Serialize)]
122pub struct ButtonFrame {
123    pub x: f64,
124    pub y: f64,
125    pub width: f64,
126    pub height: f64,
127    pub hidden: bool,
128}
129
130#[cfg(all(feature = "qa-probes", debug_assertions))]
131impl ButtonFrame {
132    /// Map an AppKit frame (`origin`, `size`) and hidden flag to the typed struct. Pure.
133    pub fn new(origin: (f64, f64), size: (f64, f64), hidden: bool) -> Self {
134        Self {
135            x: origin.0,
136            y: origin.1,
137            width: size.0,
138            height: size.1,
139            hidden,
140        }
141    }
142}
143
144/// The three standard window buttons, read without changing anything. See [`button_frames`].
145/// QA probe: compiled only with the `qa-probes` feature in a debug-assertions build.
146#[cfg(all(feature = "qa-probes", debug_assertions))]
147#[derive(Debug, Clone, Copy, PartialEq)]
148pub struct ButtonFrames {
149    pub close: ButtonFrame,
150    pub miniaturize: ButtonFrame,
151    pub zoom: ButtonFrame,
152    /// `NSWindow.isVisible` at the time of the read.
153    pub window_visible: bool,
154}
155
156#[cfg(all(feature = "qa-probes", debug_assertions))]
157impl ButtonFrames {
158    /// `[close, miniaturize, zoom]`.
159    pub fn buttons(&self) -> [ButtonFrame; 3] {
160        [self.close, self.miniaturize, self.zoom]
161    }
162
163    /// The QA control-command shape ViewRight shipped:
164    /// `{"buttons": [{"x","y","width","height","hidden"} x3 in close, miniaturize, zoom order], "windowVisible": bool}`.
165    pub fn to_json(&self) -> String {
166        serde_json::json!({ "buttons": self.buttons(), "windowVisible": self.window_visible })
167            .to_string()
168    }
169}
170
171/// Read-only probe of the macOS close / minimise / zoom buttons: each frame, hidden state, and
172/// whether the window is visible. It never moves a button, orders the window in or activates the
173/// app, so a QA scenario can assert where AppKit left the traffic lights (for example that an
174/// overlay title bar kept them at the macOS default spot).
175///
176/// AppKit views are read on the main thread only: with the shell plugin installed the read runs
177/// through the shell's main-thread dispatch (refused during shutdown); without it, inline when
178/// already on the main thread and otherwise queued to it with a 5 s wait. Errors: `Other` when the
179/// window has no NSWindow or a button, `Timeout` when the main thread does not answer. On every
180/// platform other than macOS it returns [`ShellError::Unsupported`].
181///
182/// QA probe: compiled only with the `qa-probes` feature in a debug-assertions build (a release build refuses the feature).
183#[cfg(all(feature = "qa-probes", debug_assertions))]
184pub fn button_frames(window: &WebviewWindow<Wry>) -> Result<ButtonFrames, ShellError> {
185    #[cfg(target_os = "macos")]
186    {
187        macos::frames(window)
188    }
189    #[cfg(not(target_os = "macos"))]
190    {
191        let _ = window;
192        Err(ShellError::Unsupported(
193            "window button frames exist only on macOS".into(),
194        ))
195    }
196}
197
198#[cfg(target_os = "macos")]
199mod macos {
200    use std::sync::Mutex;
201
202    use objc2_app_kit::{NSWindow, NSWindowButton};
203    use objc2_foundation::NSPoint;
204    use tauri::{WebviewWindow, Wry};
205
206    #[cfg(all(feature = "qa-probes", debug_assertions))]
207    use crate::error::ShellError;
208
209    const KINDS: [NSWindowButton; 3] = [
210        NSWindowButton::CloseButton,
211        NSWindowButton::MiniaturizeButton,
212        NSWindowButton::ZoomButton,
213    ];
214
215    #[derive(Default)]
216    pub struct Inner {
217        defaults: Mutex<Option<[NSPoint; 3]>>,
218        offset: Mutex<(f64, f64)>,
219        /// The last absolute inset (`set_inset`); `None` while a relative offset is in force.
220        inset: Mutex<Option<(f64, f64)>>,
221    }
222
223    extern "C" {
224        /// libSystem: 1 on the process main thread, 0 elsewhere.
225        fn pthread_main_np() -> std::os::raw::c_int;
226    }
227
228    /// Run `f` on the main thread. Through the shell's dispatch when `ShellState` is managed;
229    /// otherwise inline on the main thread, or queued to it (no result is awaited).
230    pub fn dispatch<F>(window: &WebviewWindow<Wry>, f: F)
231    where
232        F: FnOnce(&WebviewWindow<Wry>) + Send + 'static,
233    {
234        use tauri::Manager;
235        if window
236            .app_handle()
237            .try_state::<crate::ShellState>()
238            .is_some()
239        {
240            let _ = crate::windows::dispatch_native(window, move |w| {
241                f(w);
242                Ok(())
243            });
244        } else if unsafe { pthread_main_np() } == 1 {
245            f(window);
246        } else {
247            let w = window.clone();
248            let _ = window.run_on_main_thread(move || f(&w));
249        }
250    }
251
252    fn ns_window(window: &WebviewWindow<Wry>) -> Option<&NSWindow> {
253        let ptr = window.ns_window().ok()?;
254        if ptr.is_null() {
255            return None;
256        }
257        // SAFETY: Tauri returns the live NSWindow of this window; used synchronously.
258        Some(unsafe { &*(ptr as *const NSWindow) })
259    }
260
261    /// Read the three buttons on the main thread (see [`super::button_frames`]).
262    #[cfg(all(feature = "qa-probes", debug_assertions))]
263    pub fn frames(window: &WebviewWindow<Wry>) -> Result<super::ButtonFrames, ShellError> {
264        use tauri::Manager;
265        if window
266            .app_handle()
267            .try_state::<crate::ShellState>()
268            .is_some()
269        {
270            return crate::windows::dispatch_native(window, read).map_err(ShellError::Other);
271        }
272        if unsafe { pthread_main_np() } == 1 {
273            return read(window).map_err(ShellError::Other);
274        }
275        let (tx, rx) = std::sync::mpsc::channel();
276        let target = window.clone();
277        window
278            .run_on_main_thread(move || {
279                let _ = tx.send(read(&target));
280            })
281            .map_err(|e| ShellError::Other(format!("main thread unavailable: {e}")))?;
282        rx.recv_timeout(std::time::Duration::from_secs(5))
283            .map_err(|_| ShellError::Timeout("main thread did not answer within 5 s".into()))?
284            .map_err(ShellError::Other)
285    }
286
287    #[cfg(all(feature = "qa-probes", debug_assertions))]
288    fn read(window: &WebviewWindow<Wry>) -> Result<super::ButtonFrames, String> {
289        let win = ns_window(window).ok_or("no NSWindow for this window")?;
290        let mut out = [super::ButtonFrame::new((0.0, 0.0), (0.0, 0.0), false); 3];
291        for (slot, (name, kind)) in out.iter_mut().zip([
292            ("close", KINDS[0]),
293            ("miniaturize", KINDS[1]),
294            ("zoom", KINDS[2]),
295        ]) {
296            let button = win
297                .standardWindowButton(kind)
298                .ok_or_else(|| format!("window has no {name} button"))?;
299            let frame = button.frame();
300            *slot = super::ButtonFrame::new(
301                (frame.origin.x, frame.origin.y),
302                (frame.size.width, frame.size.height),
303                button.isHidden(),
304            );
305        }
306        Ok(super::ButtonFrames {
307            close: out[0],
308            miniaturize: out[1],
309            zoom: out[2],
310            window_visible: win.isVisible(),
311        })
312    }
313
314    pub fn apply(window: &WebviewWindow<Wry>, dx: f64, dy: f64, inner: &Inner) {
315        let Some(win) = ns_window(window) else { return };
316        let mut buttons = Vec::with_capacity(3);
317        for k in KINDS {
318            match win.standardWindowButton(k) {
319                Some(b) => buttons.push(b),
320                None => return,
321            }
322        }
323        let base = {
324            let mut d = inner.defaults.lock().unwrap_or_else(|e| e.into_inner());
325            *d.get_or_insert_with(|| {
326                let mut o = [NSPoint { x: 0.0, y: 0.0 }; 3];
327                for (i, b) in buttons.iter().enumerate() {
328                    o[i] = b.frame().origin;
329                }
330                o
331            })
332        };
333        for (i, b) in buttons.iter().enumerate() {
334            // AppKit's origin is bottom-left, so a downward offset subtracts.
335            b.setFrameOrigin(NSPoint {
336                x: base[i].x + dx,
337                y: base[i].y - dy,
338            });
339        }
340        *inner.offset.lock().unwrap_or_else(|e| e.into_inner()) = (dx, dy);
341        *inner.inset.lock().unwrap_or_else(|e| e.into_inner()) = None;
342    }
343
344    /// Absolute placement: the close button's top-left corner sits `x` points from the window's left
345    /// edge and `y` points from its top (y down); the other two keep the macOS spacing.
346    pub fn apply_inset(window: &WebviewWindow<Wry>, x: f64, y: f64, inner: &Inner) {
347        let Some(win) = ns_window(window) else { return };
348        let mut buttons = Vec::with_capacity(3);
349        for k in KINDS {
350            match win.standardWindowButton(k) {
351                Some(b) => buttons.push(b),
352                None => return,
353            }
354        }
355        let base = {
356            let mut d = inner.defaults.lock().unwrap_or_else(|e| e.into_inner());
357            *d.get_or_insert_with(|| {
358                let mut o = [NSPoint { x: 0.0, y: 0.0 }; 3];
359                for (i, b) in buttons.iter().enumerate() {
360                    o[i] = b.frame().origin;
361                }
362                o
363            })
364        };
365        for (i, b) in buttons.iter().enumerate() {
366            // SAFETY: `b` is a live NSButton owned by the window's titlebar view; AppKit
367            // keeps its superview alive for the duration of this main-thread call.
368            let Some(container) = (unsafe { b.superview() }) else {
369                return;
370            };
371            let container_height = container.frame().size.height;
372            let button_height = b.frame().size.height;
373            // AppKit's origin is bottom-left, so the top-down inset is converted here.
374            b.setFrameOrigin(NSPoint {
375                x: x + (base[i].x - base[0].x),
376                y: container_height - y - button_height,
377            });
378        }
379        *inner.inset.lock().unwrap_or_else(|e| e.into_inner()) = Some((x, y));
380    }
381
382    pub fn reapply(window: &WebviewWindow<Wry>, inner: &Inner) {
383        if inner
384            .defaults
385            .lock()
386            .unwrap_or_else(|e| e.into_inner())
387            .is_some()
388        {
389            let inset = *inner.inset.lock().unwrap_or_else(|e| e.into_inner());
390            match inset {
391                Some((x, y)) => apply_inset(window, x, y, inner),
392                None => {
393                    let (dx, dy) = *inner.offset.lock().unwrap_or_else(|e| e.into_inner());
394                    apply(window, dx, dy, inner);
395                }
396            }
397        }
398    }
399}
400
401#[cfg(all(test, feature = "qa-probes", debug_assertions))]
402mod tests {
403    use super::*;
404
405    #[test]
406    fn button_frame_maps_an_appkit_frame() {
407        let f = ButtonFrame::new((7.0, 6.5), (14.0, 16.0), true);
408        assert_eq!(
409            f,
410            ButtonFrame {
411                x: 7.0,
412                y: 6.5,
413                width: 14.0,
414                height: 16.0,
415                hidden: true,
416            }
417        );
418    }
419
420    #[test]
421    fn button_frames_json_keeps_the_viewright_shape_and_order() {
422        let frames = ButtonFrames {
423            close: ButtonFrame::new((7.0, 6.0), (14.0, 16.0), false),
424            miniaturize: ButtonFrame::new((27.0, 6.0), (14.0, 16.0), false),
425            zoom: ButtonFrame::new((47.0, 6.0), (14.0, 16.0), true),
426            window_visible: true,
427        };
428        let v: serde_json::Value = serde_json::from_str(&frames.to_json()).unwrap();
429        assert_eq!(v["windowVisible"], true);
430        let b = v["buttons"].as_array().unwrap();
431        assert_eq!(b.len(), 3);
432        assert_eq!(b[0]["x"], 7.0);
433        assert_eq!(b[1]["x"], 27.0);
434        assert_eq!(b[2]["x"], 47.0);
435        assert_eq!(b[0]["hidden"], false);
436        assert_eq!(b[2]["hidden"], true);
437        for button in b {
438            for key in ["x", "y", "width", "height", "hidden"] {
439                assert!(button.get(key).is_some(), "{key}");
440            }
441        }
442        assert_eq!(frames.buttons()[1], frames.miniaturize);
443    }
444}