Skip to main content

teksilo_core/
window_chrome.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! Platform abstraction for custom window chrome (title bars).
5//!
6//! `PlatformTitleBarHost` is the seam between a platform-agnostic `TitleBar`
7//! widget (in `teksilo-widgets`) and the per-OS implementations that own the
8//! window handle (in `teksilo-platform`). The trait is intentionally `!Send +
9//! !Sync`: every implementation lives on the UI thread alongside the widget
10//! tree.
11
12use std::any::Any;
13use std::fmt;
14use std::rc::Rc;
15
16use teksilo_canvas::{Point, Rect, Size};
17
18use crate::widget_id::WidgetId;
19use crate::window::TeksiloWindowId;
20
21/// Capabilities the title bar widget needs from the windowing layer.
22pub trait PlatformTitleBarHost {
23    /// Logical-pixel area on the leading edge that the widget must leave
24    /// blank because the OS draws there. macOS reserves space for the traffic
25    /// lights; Windows / Wayland return `Size::ZERO`.
26    fn reserved_leading_inset(&self) -> Size;
27
28    /// Logical-pixel area on the trailing edge reserved by the OS. Currently
29    /// always `Size::ZERO`; reserved for future use.
30    fn reserved_trailing_inset(&self) -> Size;
31
32    /// Whether the widget should render its own minimize / maximize / close
33    /// buttons. `true` on Windows and Wayland; `false` on macOS where the OS
34    /// draws the traffic lights.
35    fn renders_custom_controls(&self) -> bool;
36
37    /// Whether the application should install a `WindowFrame`-style overlay
38    /// with invisible edge / corner resize strips. `true` on Windows and
39    /// Wayland where the client draws the entire frame; `false` on macOS
40    /// where the native `NSWindow` frame still services edge resize.
41    fn needs_custom_resize_handles(&self) -> bool;
42
43    /// Begin an interactive window move. Called on left-press inside a drag
44    /// region. The OS takes over until the user releases the button.
45    fn begin_drag(&self) -> Result<(), PlatformError>;
46
47    /// Begin an interactive resize from the given edge. Called on left-press
48    /// inside a resize border widget.
49    fn begin_resize(&self, edge: ResizeEdge) -> Result<(), PlatformError>;
50
51    /// Show the system window menu at the given client-area position. Wayland
52    /// only; other platforms return `Ok(())` and do nothing.
53    ///
54    /// Only meaningful when [`Self::has_window_menu`] is `true`.
55    fn show_window_menu(&self, at: Point) -> Result<(), PlatformError>;
56
57    /// Whether the platform can show a system window menu at all.
58    ///
59    /// `false` means [`Self::show_window_menu`] has nothing to call and the
60    /// title bar should build its **own** menu instead, so right-clicking the
61    /// bar is not simply dead. X11 is the case that motivates this: winit's
62    /// `show_window_menu` is an empty stub there, and `_GTK_SHOW_WINDOW_MENU`
63    /// — the only cross-desktop request for it — is not implemented by KWin
64    /// (KDE bug 454756), so there is no OS menu to ask for.
65    ///
66    /// Defaults to `true`, which is correct for every platform that has one.
67    fn has_window_menu(&self) -> bool {
68        true
69    }
70
71    /// Publish the current rectangles of the title bar's interactive
72    /// sub-regions. The widget tree publishes them in **logical**
73    /// pixels; backends that need physical pixels (Windows) convert
74    /// internally. Wayland and macOS ignore the payload.
75    ///
76    /// Called once per frame from
77    /// [`Widget::after_paint`](crate::widget::Widget::after_paint) on
78    /// the [`crate::Widget`]-implementing title bar root.
79    fn update_hit_regions(&self, regions: &HitRegions);
80
81    /// Resolve a control-button target back to the `WidgetId` of the
82    /// `ControlButton` that the widget tree last reported for it.
83    /// Used by the Windows backend's synthetic-tap forwarding when
84    /// `WM_NCLBUTTONUP` fires on `HTMINBUTTON`/`HTMAXBUTTON`/`HTCLOSE`
85    /// — the OS owns the click area, so the proc looks up the
86    /// matching widget id and the app routes a synthetic tap into it.
87    ///
88    /// Default: `None`. Backends that don't intercept non-client
89    /// button presses (Wayland, macOS) have no synthetic-tap path.
90    fn title_bar_widget_id(&self, _target: ControlTarget) -> Option<WidgetId> {
91        None
92    }
93
94    /// Inject a synthetic hover entered/leave event for the given
95    /// control button. Used by the Windows backend's `WM_NCMOUSEMOVE`
96    /// / `WM_NCMOUSELEAVE` path: the OS handles non-client hover, so
97    /// widget-side hover events never fire over button rects.
98    ///
99    /// Default: no-op. The Windows host stores the matching
100    /// `Signal<bool>` (registered by `WindowControls` at build time)
101    /// and writes it; macOS / Wayland never produce these so the
102    /// no-op suffices.
103    fn set_button_hover(&self, _target: ControlTarget, _entered: bool) {}
104
105    /// Register the per-button hover signal that the host writes
106    /// when the OS reports a non-client hover for the matching
107    /// `target`. Called by `WindowControls` at build time for each
108    /// of the three buttons.
109    ///
110    /// Default: no-op. macOS / Wayland never need this — they get
111    /// hover events through the widget tree's pointer pipeline.
112    fn register_hover_signal(&self, _target: ControlTarget, _signal: crate::signal::Signal<bool>) {}
113}
114
115/// Target a synthetic title-bar tap or hover at a specific button.
116/// The Windows backend posts these as part of
117/// [`TitleBarSyntheticEvent`] / [`TitleBarHoverEvent`] payloads; the
118/// teksilo-app dispatcher then looks up the matching widget id via
119/// [`PlatformTitleBarHost::title_bar_widget_id`].
120#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
121pub enum ControlTarget {
122    Minimize,
123    Maximize,
124    Close,
125}
126
127/// Synthetic primary-button tap on a custom title bar's control
128/// button. Posted by the Windows backend's wndproc subclass on
129/// `WM_NCLBUTTONUP` over `HTMINBUTTON` / `HTMAXBUTTON` / `HTCLOSE` —
130/// the OS owned the click area (returned non-`HTCLIENT` from
131/// `WM_NCHITTEST`) so widget land never saw it. The teksilo-app
132/// dispatcher resolves the right `ControlButton` via
133/// [`PlatformTitleBarHost::title_bar_widget_id`] and calls
134/// `WidgetTree::synthesise_tap` on it. Wayland and macOS never
135/// produce these.
136#[derive(Debug, Clone, Copy)]
137pub struct TitleBarSyntheticEvent {
138    pub teksilo_id: TeksiloWindowId,
139    pub target: ControlTarget,
140}
141
142/// Hover entered/leave for a custom title-bar control button. Posted
143/// by the Windows backend's `WM_NCMOUSEMOVE` / `WM_NCMOUSELEAVE`
144/// handlers for the same reason as [`TitleBarSyntheticEvent`]: the
145/// OS owns hover events over non-client areas.
146#[derive(Debug, Clone, Copy)]
147pub struct TitleBarHoverEvent {
148    pub teksilo_id: TeksiloWindowId,
149    pub target: ControlTarget,
150    pub entered: bool,
151}
152
153/// Callbacks the window manager hands to a platform host at
154/// construction time. Hosts invoke these for operations that must go
155/// through the event loop:
156///
157/// - `request_close`: winit 0.30 has no synchronous
158///   `Window::request_close`, so the host posts a `CloseWindowRequest`
159///   and `teksilo-app` routes it to `WindowManager::queue_close`.
160/// - `post_external`: the Windows backend forwards `WM_NCLBUTTONUP` /
161///   `WM_NCMOUSEMOVE` over its custom title-bar buttons as
162///   `TitleBarSyntheticEvent` / `TitleBarHoverEvent` payloads through
163///   the same `AppEvent::External` arm. The closure abstracts the
164///   posting mechanism (a winit `EventLoopProxy` in production) so
165///   teksilo-core stays winit-free.
166/// - `teksilo_id`: the host's window id, copied into the synthetic
167///   payloads so the dispatcher knows which window to address.
168#[derive(Clone)]
169pub struct TitleBarHostCallbacks {
170    pub request_close: Rc<dyn Fn()>,
171    /// Post a `Box<dyn Any + Send>` payload back to the application
172    /// event loop. Currently used by the Windows backend for
173    /// `TitleBarSyntheticEvent` and `TitleBarHoverEvent`. Wayland and
174    /// macOS construct hosts that never call this.
175    pub post_external: Rc<dyn Fn(Box<dyn Any + Send>)>,
176    /// teksilo-side window id. The Windows backend stamps this into
177    /// every synthetic payload it posts so the app dispatcher can
178    /// route into the right `WidgetTree`.
179    pub teksilo_id: TeksiloWindowId,
180}
181
182impl TitleBarHostCallbacks {
183    /// Callbacks that do nothing. Useful for tests and for platform stubs
184    /// that never construct a host (a headless build, or X11 with no
185    /// EWMH-capable window manager).
186    pub fn noop() -> Self {
187        Self {
188            request_close: Rc::new(|| {}),
189            post_external: Rc::new(|_| {}),
190            teksilo_id: TeksiloWindowId::new(0),
191        }
192    }
193}
194
195impl fmt::Debug for TitleBarHostCallbacks {
196    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
197        f.debug_struct("TitleBarHostCallbacks")
198            .finish_non_exhaustive()
199    }
200}
201
202/// Set of rectangles inside the window client area that the title bar
203/// widget cares about. The widget tree publishes them in **logical**
204/// pixels (its native coordinate system); platform backends that need
205/// physical pixels (Windows) convert internally before storing.
206/// Coordinates are relative to the window client origin (top-left).
207#[derive(Debug, Default, Clone)]
208pub struct HitRegions {
209    pub minimize: Option<Rect>,
210    pub maximize: Option<Rect>,
211    pub close: Option<Rect>,
212    /// Widget id of the minimize button, when one is present in the
213    /// tree. Companions to the rect above; the Windows backend uses
214    /// these to route `WM_NCLBUTTONUP` on `HTMINBUTTON` back into the
215    /// widget tree as a synthetic tap.
216    pub minimize_id: Option<WidgetId>,
217    pub maximize_id: Option<WidgetId>,
218    pub close_id: Option<WidgetId>,
219    /// One or more drag-region rectangles. Multiple rects allow non-rectangular
220    /// drag surfaces (e.g. drag region split around a centred search bar).
221    pub drag: Vec<Rect>,
222    /// Holes punched in [`drag`](Self::drag): sub-rectangles that must **not**
223    /// be treated as caption. Backends that hand the drag region to the OS
224    /// (Windows: `WM_NCHITTEST` -> `HTCAPTION`) must test these **before**
225    /// `drag` and answer `HTCLIENT`, or the OS owns those pixels and the widget
226    /// underneath never sees a click, a hover, or a cursor change.
227    ///
228    /// Published by `TitleBar::after_paint` from two sources. First, the
229    /// [`gesture_dead_zone`](crate::arena::WidgetNode::gesture_dead_zone) nodes
230    /// inside its drag region — the same declaration that already stops an
231    /// ancestor drag from arming in widget land. One concept, both layers:
232    /// wrap an interactive title-bar control in a `DeadZone` and it becomes
233    /// clickable on Windows *and* immune to jitter-drag everywhere else.
234    /// Teksilo's counterpart of Electron's `-webkit-app-region: no-drag`.
235    ///
236    /// Second, every interactive overlay's intersection with the title bar.
237    /// Overlay content floats above the chrome in widget land, so its pixels
238    /// must return to the client area no matter which published rect lies
239    /// beneath — which is why a backend must test these holes before the
240    /// control-button rects as well as before `drag`. The shipped bug: a
241    /// hamburger `MenuBar`'s revealed bar is an overlay anchored outside the
242    /// drag region, so no dead zone covered it, and on Windows every menu
243    /// title over the caption dragged the window instead of opening.
244    pub no_drag: Vec<Rect>,
245    pub resize_borders: ResizeBorders,
246}
247
248impl HitRegions {
249    pub fn new() -> Self {
250        Self::default()
251    }
252}
253
254/// Physical-pixel widths of the eight resize edges. Zero means "no resize
255/// border on this side". The Windows backend uses these to translate
256/// `WM_NCHITTEST` cursor positions into `HTLEFT`/`HTTOPRIGHT`/etc.
257#[derive(Debug, Default, Clone, Copy)]
258pub struct ResizeBorders {
259    pub top: f32,
260    pub right: f32,
261    pub bottom: f32,
262    pub left: f32,
263}
264
265impl ResizeBorders {
266    pub const fn uniform(thickness: f32) -> Self {
267        Self {
268            top: thickness,
269            right: thickness,
270            bottom: thickness,
271            left: thickness,
272        }
273    }
274}
275
276#[derive(Debug, Clone, Copy, PartialEq, Eq)]
277pub enum ResizeEdge {
278    Top,
279    TopLeft,
280    TopRight,
281    Left,
282    Right,
283    Bottom,
284    BottomLeft,
285    BottomRight,
286}
287
288#[derive(Debug, thiserror::Error)]
289pub enum PlatformError {
290    /// The current platform / window system does not support custom chrome
291    /// at all (e.g. X11 without an EWMH window manager) or does not support a
292    /// specific operation (e.g.
293    /// `begin_resize` on macOS, where winit lacks `drag_resize_window`).
294    #[error("operation not supported on this platform")]
295    Unsupported,
296    /// An OS-level call failed. The string is intended for logging, not
297    /// programmatic inspection.
298    #[error("platform error: {0}")]
299    Os(String),
300}