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}