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