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}