teksilo_widgets/title_bar/controls.rs
1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! The minimize / maximize / close button cluster on the trailing edge of
5//! a `TitleBar`. Rendered only when
6//! [`PlatformTitleBarHost::renders_custom_controls`] is `true`
7//! (Windows + Wayland; never on macOS).
8//!
9//! These are deliberately NOT built on top of the regular `Button` widget:
10//! `Button` carries a 72 dp minimum width, themed padding, focus ring and
11//! border, none of which are appropriate for a flush-fitting Win11-style
12//! window control. Instead, each control is a small composing widget
13//! [`ControlButton`] built from primitives (FixedSize + ZStack +
14//! RectWidget + Center + TextWidget) so we inherit centering, theming and
15//! reactive hover for free.
16//!
17//! For M2 the maximize/restore swap is *not* implemented — the maximize
18//! button always shows the `□` glyph. M3+ will add a `Signal<bool>`-driven
19//! glyph swap once the host can update it from `WindowEvent::Resized`.
20
21use std::cell::Cell;
22use std::rc::Rc;
23use teksilo_i18n::lit;
24
25use teksilo_canvas::{Rect, Size, SizeProposal};
26use teksilo_core::PlatformTitleBarHost;
27use teksilo_core::accessibility::AccessNodeBuilder;
28use teksilo_core::color_prop::ColorProp;
29use teksilo_core::event::EventResponse;
30use teksilo_core::signal::Signal;
31use teksilo_core::widget::{
32 CursorIcon, EventContext, LayoutContext, PaintContext, Widget, WidgetPlacement,
33};
34use teksilo_core::widget_builder::HandlerSet;
35use teksilo_core::widget_id::WidgetId;
36use teksilo_tokens::{SurfaceRole, TextRole, TextStyleRole};
37
38use crate::primitives::{Center, FixedSize, HStack, RectWidget, Switcher, TextWidget, ZStack};
39use crate::title_bar::CloseAction;
40
41/// Layout snapshot that [`WindowControls`] exports to its parent `TitleBar`
42/// so the `after_paint` aggregator can read the per-button [`WidgetId`]s.
43/// Populated during `WindowControls::build`.
44///
45/// The maximize slot is the **Switcher** that wraps the two glyph
46/// buttons (`□` / `❐`), not either child directly: the inactive
47/// Switcher child is dormant and has `Rect::ZERO` bounds, but the
48/// Switcher container itself is always laid out by the parent
49/// HStack and has valid bounds. A synthetic tap dispatched at the
50/// Switcher's bounds-center routes through hit-testing to whichever
51/// child is currently visible.
52#[derive(Debug, Clone)]
53pub struct WindowControlsLayout {
54 pub minimize_id: WidgetId,
55 pub maximize_id: WidgetId,
56 pub close_id: WidgetId,
57}
58
59/// Action invoked when a [`ControlButton`] is tapped.
60pub type ControlAction = Rc<dyn Fn(&mut EventContext)>;
61
62/// A compact, flush-fitting window-control button.
63///
64/// Composes existing primitives — a `FixedSize` cell wrapping a `ZStack`
65/// of (hover background, centred glyph). Hover state is tracked in a
66/// `Signal<bool>` that drives a derived `Signal<SurfaceRole>` background,
67/// so a hover change repaints with no relayout. Both the glyph color
68/// (`fg`) and the hover surface are stored as *roles* (`ColorProp` /
69/// `SurfaceRole`) that resolve against the current theme at paint time —
70/// so the cluster retints live across `ctx.set_theme(...)` without a
71/// rebuild.
72pub struct ControlButton {
73 glyph: &'static str,
74 width: f32,
75 height: f32,
76 fg: ColorProp,
77 /// Surface role painted over the title bar when the cursor is
78 /// inside the cell. `SurfaceRole::Transparent` keeps the cell flat.
79 hover_role: SurfaceRole,
80 action: Option<ControlAction>,
81 /// Accessible name exposed to AT. Reactive so `WindowControls` can
82 /// flip it between "Maximize" and "Restore" without rebuilding.
83 a11y_name: Signal<String>,
84 /// External hover input — the Windows host writes this when the
85 /// OS reports `WM_NCMOUSEMOVE` over the button rect (the OS owns
86 /// non-client hover events, so the widget's own `on_hover`
87 /// handler never fires for those pixels). Wired through an effect
88 /// that drives `bg_signal` so the visual hover state is identical
89 /// to widget-tree-driven hover. `None` means no external feed —
90 /// only the widget's internal hover handler runs.
91 external_hover: Option<Signal<bool>>,
92 root_child_id: Option<WidgetId>,
93}
94
95impl std::fmt::Debug for ControlButton {
96 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
97 f.debug_struct("ControlButton")
98 .field("glyph", &self.glyph)
99 .field("width", &self.width)
100 .field("height", &self.height)
101 .finish_non_exhaustive()
102 }
103}
104
105impl ControlButton {
106 /// Create a control button with the given Unicode glyph, fixed cell dimensions, and
107 /// foreground color role. The hover background defaults to transparent until overridden
108 /// via [`hover_background`](ControlButton::hover_background).
109 pub fn new(glyph: &'static str, width: f32, height: f32, fg: impl Into<ColorProp>) -> Self {
110 Self {
111 glyph,
112 width,
113 height,
114 fg: fg.into(),
115 hover_role: SurfaceRole::Transparent,
116 action: None,
117 a11y_name: Signal::new(String::new()),
118 external_hover: None,
119 root_child_id: None,
120 }
121 }
122
123 /// Bind an external boolean hover input. The Windows backend
124 /// writes this signal on `WM_NCMOUSEMOVE` / `WM_NCMOUSELEAVE`
125 /// over the button rect, since those events never reach the
126 /// widget tree (the OS treats the area as non-client). `build`
127 /// installs an effect that maps the bool to the `bg_signal`
128 /// colour identically to the internal hover handler.
129 pub(crate) fn external_hover(mut self, signal: Signal<bool>) -> Self {
130 self.external_hover = Some(signal);
131 self
132 }
133
134 /// Set the surface role painted over the title bar background while the pointer is inside
135 /// the button cell. The default is `SurfaceRole::Transparent` (flat).
136 pub fn hover_background(mut self, role: SurfaceRole) -> Self {
137 self.hover_role = role;
138 self
139 }
140
141 /// Register the callback invoked when the user taps this button.
142 pub fn on_tap(mut self, action: impl Fn(&mut EventContext) + 'static) -> Self {
143 self.action = Some(Rc::new(action));
144 self
145 }
146
147 fn with_action(mut self, action: ControlAction) -> Self {
148 self.action = Some(action);
149 self
150 }
151
152 /// Bind the accessible name read by AT when this button's a11y node
153 /// is queried. The glyph text drawn in the cell is purely visual and
154 /// is hidden from AT — assistive users get this name instead.
155 pub(crate) fn set_a11y_name(mut self, name: Signal<String>) -> Self {
156 self.a11y_name = name;
157 self
158 }
159}
160
161impl Widget for ControlButton {
162 fn build(&mut self, ctx: &mut teksilo_core::build_context::BuildContext) -> Vec<WidgetId> {
163 // Reactive hover background: a `Signal<bool>` tracks whether the
164 // pointer is inside the cell, and a derived `Signal<SurfaceRole>`
165 // maps it to the hover role (while inside) or
166 // `SurfaceRole::Transparent` (flat). Driving the RectWidget with a
167 // *role* signal — rather than a resolved `Color` — means the hover
168 // fill resolves against the live theme at paint time, so it
169 // retints across `set_theme` as well as repainting on hover.
170 let hovered = ctx.signal(false);
171 let hover_role = self.hover_role;
172 let bg_role = hovered.map(move |inside| {
173 if *inside {
174 hover_role
175 } else {
176 SurfaceRole::Transparent
177 }
178 });
179
180 let bg_rect = ctx.add(RectWidget::new().background(bg_role));
181
182 let glyph_text = TextWidget::new(lit!(self.glyph))
183 .style(TextStyleRole::Body)
184 .color(self.fg.clone())
185 .single_line()
186 .a11y_hidden();
187 let centred_glyph = ctx.add(Center::new().child(glyph_text));
188
189 let stack = ctx.add(ZStack::new().add_child(bg_rect).add_child(centred_glyph));
190 let sized = ctx.add(
191 FixedSize::new()
192 .width(self.width)
193 .height(self.height)
194 .child_id(stack),
195 );
196
197 // Self handlers: tap fires the action, hover drives the `hovered`
198 // bool (which the derived role signal above reacts to).
199 let hovered_handler = hovered.clone();
200 let mut handlers =
201 HandlerSet::new()
202 .cursor(CursorIcon::Pointer)
203 .on_hover(move |entered, _ctx| {
204 hovered_handler.set(entered);
205 });
206
207 if let Some(action) = self.action.take() {
208 // `accessibility` advertises `Action::Click`, and on macOS
209 // VoiceOver only offers a press at all when the node claims
210 // that action (`is_clickable` == `supports_action(Click)`).
211 // The dispatcher never synthesizes a tap from it, so without
212 // this handler the window controls are advertised to AT and
213 // then do nothing when invoked. `ControlAction` is an `Rc`
214 // closure — pointer and AT share the one action.
215 let access_action = action.clone();
216 handlers = handlers
217 .on_tap(move |_pos, ctx| action(ctx))
218 .on_access_action(move |a, ctx: &mut EventContext| {
219 if a == teksilo_core::accesskit::Action::Click {
220 access_action(ctx);
221 EventResponse::Handled
222 } else {
223 EventResponse::Ignored
224 }
225 });
226 }
227
228 ctx.apply_self_handlers(handlers);
229
230 // External hover feed (Windows non-client hover): write the same
231 // `hovered` bool the internal handler writes, so OS-driven hover
232 // renders identically to widget-tree-driven hover. The effect
233 // handle is owned by the BuildContext so it lives as long as the
234 // widget node.
235 if let Some(ext) = self.external_hover.take() {
236 let hovered_ext = hovered.clone();
237 ctx.effect(&ext, move |entered| {
238 hovered_ext.set(*entered);
239 });
240 }
241
242 // Refresh the a11y node whenever the name signal changes
243 // (maximize ⇄ restore toggle).
244 let self_id = ctx.self_id();
245 self.a11y_name.bind_to(
246 self_id,
247 ctx.binding_registry(),
248 teksilo_core::binding::BindingLevel::AccessibilityOnly,
249 );
250
251 self.root_child_id = Some(sized);
252 vec![sized]
253 }
254
255 fn layout_response(
256 &self,
257 _proposal: SizeProposal,
258 _ctx: &LayoutContext,
259 ) -> teksilo_core::widget::LayoutResponse {
260 // Always exactly the configured cell. Returning the proposal here
261 // would let an HStack stretch us to the leftover width.
262 Size::new(self.width, self.height).into()
263 }
264
265 fn place_children(
266 &self,
267 bounds: Rect,
268 _proposal: SizeProposal,
269 children: &mut [WidgetPlacement],
270 _ctx: &LayoutContext,
271 ) {
272 for child in children.iter_mut() {
273 child.origin = bounds.origin();
274 child.size = bounds.size();
275 }
276 }
277
278 fn paint(&self, _bounds: Rect, _canvas: &mut teksilo_canvas::Canvas, _ctx: &PaintContext) {}
279
280 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
281 builder.set_role(teksilo_core::accesskit::Role::Button);
282 let name = self.a11y_name.get();
283 if !name.is_empty() {
284 builder.set_name(name);
285 }
286 builder.add_action(teksilo_core::accesskit::Action::Click);
287 }
288
289 fn children(&self) -> Vec<WidgetId> {
290 self.root_child_id.into_iter().collect()
291 }
292}
293
294/// The minimize / maximize / close cluster, laid out as an HStack of
295/// [`ControlButton`]s. Each cell forwards taps to the supplied host.
296pub struct WindowControls {
297 host: Rc<dyn PlatformTitleBarHost>,
298 show_restore: Signal<bool>,
299 /// User-supplied override for the close action — see
300 /// [`crate::title_bar::TitleBar::close_action`].
301 close_action: Option<CloseAction>,
302 root_child_id: Option<WidgetId>,
303 /// Sink the parent `TitleBar` shares with us so its
304 /// `after_paint` aggregator can read our per-button `WidgetId`s.
305 /// `None` when the controls are used standalone (tests / docs).
306 layout_sink: Option<Rc<Cell<Option<WindowControlsLayout>>>>,
307}
308
309impl std::fmt::Debug for WindowControls {
310 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
311 f.debug_struct("WindowControls").finish_non_exhaustive()
312 }
313}
314
315impl WindowControls {
316 /// Build the minimize / maximize / close cluster for the given platform host.
317 ///
318 /// `show_restore` drives the maximize ↔ restore swap: `true` renders the
319 /// **Restore** affordance (a11y name and action), `false` the **Maximize**
320 /// one. It is deliberately not called `is_maximized`: a window is also
321 /// restorable — and must not offer "maximize" — while it is
322 /// [`WindowPlacement::Fullscreen`](teksilo_core::WindowPlacement::Fullscreen),
323 /// which `WindowPlacement::is_maximized` reports as `false`. See
324 /// [`crate::title_bar::TitleBar`]'s own derivation.
325 ///
326 /// `close_action` overrides the default `ctx.close_window()` behaviour (e.g.
327 /// to show a "save before closing?" dialog).
328 pub fn new(
329 host: Rc<dyn PlatformTitleBarHost>,
330 show_restore: Signal<bool>,
331 close_action: Option<CloseAction>,
332 ) -> Self {
333 Self {
334 host,
335 show_restore,
336 close_action,
337 root_child_id: None,
338 layout_sink: None,
339 }
340 }
341
342 /// Wire a sink the parent `TitleBar` will read from in its
343 /// `after_paint` hook. The sink receives a [`WindowControlsLayout`]
344 /// snapshot during this widget's `build` pass.
345 pub(crate) fn layout_sink(mut self, sink: Rc<Cell<Option<WindowControlsLayout>>>) -> Self {
346 self.layout_sink = Some(sink);
347 self
348 }
349}
350
351impl Widget for WindowControls {
352 fn build(&mut self, ctx: &mut teksilo_core::build_context::BuildContext) -> Vec<WidgetId> {
353 // Hand the buttons *roles*, not a frozen `theme.colors.*` snapshot.
354 // A resolved `Color` is a `ColorProp::Static` that `mark_all_dirty`
355 // re-resolves to the same value, so a build-time snapshot would
356 // freeze the glyph/hover colors at whatever theme was active when
357 // the tree was built — they would not retint on `set_theme`. Roles
358 // resolve against the current theme at paint time, so the cluster
359 // follows light ↔ dark live without a rebuild.
360 let fg = TextRole::Primary;
361 let hover_bg = SurfaceRole::Hover;
362 let close_hover = SurfaceRole::StatusError;
363
364 // Win11-style cell: 46 dp wide × 32 dp tall fits comfortably into a
365 // 40 dp title bar. Height here is the cell's natural size; the
366 // final placed height is driven by the parent HStack's bounds.
367 let cell_w = 46.0;
368 let cell_h = 32.0;
369
370 let close_override = self.close_action.clone();
371
372 // All three controls write through `WindowState::placement` and
373 // `WindowState::close` now. The signal flip fires the state's
374 // observer which queues a `WindowCommand`; the app-level manager
375 // translates that into the appropriate winit call on the next
376 // tick. OS-initiated state changes (green-light zoom, drag-to-
377 // top-snap) come back through `set_placement_from_os`, keeping
378 // the button glyph in sync without echoing back out.
379 let minimize_action: ControlAction = Rc::new(move |ctx| {
380 if let Some(w) = ctx.window() {
381 w.placement().set(teksilo_core::WindowPlacement::Minimized);
382 }
383 });
384 let maximize_action: ControlAction = Rc::new(move |ctx| {
385 if let Some(w) = ctx.window() {
386 use teksilo_core::WindowPlacement as P;
387 // Fullscreen restores, it does not maximize. Reading only
388 // `is_maximized()` here used to send a fullscreen window to
389 // `Maximized` — a state no command asked for, and one that
390 // silently drops fullscreen while an app-level "hide the
391 // chrome" mode keyed off it stays collapsed.
392 //
393 // Restoring to `Floating` (rather than to whatever the window
394 // was before it went fullscreen) is the framework's honest
395 // answer: `WindowState` keeps no pre-fullscreen memory. An app
396 // that wants "back to exactly where I was" owns that memory
397 // itself and should drive the transition through its own
398 // command rather than this button.
399 let next = match w.placement().get() {
400 P::Maximized | P::Fullscreen => P::Floating,
401 P::Floating | P::Minimized => P::Maximized,
402 };
403 w.placement().set(next);
404 }
405 });
406 let close_action: ControlAction = match close_override {
407 Some(user_action) => user_action,
408 None => Rc::new(move |ctx| ctx.close_window()),
409 };
410
411 // `to_signal()` observes the i18n manager so the name updates
412 // when `tree.set_locale(...)` is called; `resolve_now()` would
413 // freeze the English string at build time.
414 let minimize_name = teksilo_i18n::tr_widget!(a11y_window_minimize_name()).to_signal();
415 let close_name = teksilo_i18n::tr_widget!(a11y_window_close_name()).to_signal();
416 let maximize_name = teksilo_i18n::tr_widget!(a11y_window_maximize_name()).to_signal();
417 let restore_name = teksilo_i18n::tr_widget!(a11y_window_restore_name()).to_signal();
418
419 // Per-button hover signals for the Windows custom-chrome
420 // path. The host writes them on `WM_NCMOUSEMOVE` /
421 // `WM_NCMOUSELEAVE` over the matching button rect; the
422 // button's effect maps the bool to its visual `bg_signal`.
423 // On Wayland and macOS the host's `register_hover_signal` is
424 // a no-op, so these are never written from outside — the
425 // buttons fall back to their internal `on_hover` handler.
426 let minimize_hover = Signal::new(false);
427 let maximize_hover = Signal::new(false);
428 let close_hover_signal = Signal::new(false);
429 self.host.register_hover_signal(
430 teksilo_core::ControlTarget::Minimize,
431 minimize_hover.clone(),
432 );
433 self.host.register_hover_signal(
434 teksilo_core::ControlTarget::Maximize,
435 maximize_hover.clone(),
436 );
437 self.host.register_hover_signal(
438 teksilo_core::ControlTarget::Close,
439 close_hover_signal.clone(),
440 );
441
442 let minimize = ControlButton::new("\u{2014}", cell_w, cell_h, fg)
443 .hover_background(hover_bg)
444 .with_action(minimize_action)
445 .set_a11y_name(minimize_name)
446 .external_hover(minimize_hover);
447 let minimize_id = ctx.add(minimize);
448
449 // Maximize/restore: both states use `□` (U+25A1). The
450 // semantically nicer "two stacked squares" glyphs (`❐` U+2750,
451 // `⧉` U+29C9, `🗗` U+1F5D7) and even neighbouring Geometric
452 // Shapes glyphs like `▭` U+25AD all render as missing on
453 // Windows because text-typeset's font fallback chain only
454 // reliably hits `□` from Segoe UI's basic geometric coverage
455 // (same root cause as the close button using U+00D7 instead
456 // of U+2715). State differentiation is still carried by:
457 // - the OS itself (window is or isn't maximized);
458 // - the reactive a11y name (Maximize / Restore — both
459 // Switcher children carry their own static name and the
460 // hidden child's a11y node doesn't reach AT);
461 // - the action (toggles correctly via `WindowState::placement`).
462 // A future pass can swap the glyph for custom rect-primitive
463 // icons to restore the visual delta.
464 let switcher_idx = self.show_restore.map(|b| if *b { 1usize } else { 0usize });
465 let maximize_action_restore = maximize_action.clone();
466 // Both Switcher children share the same external_hover
467 // signal: only one is visible at a time, and the host
468 // doesn't distinguish between "maximize-normal" and
469 // "maximize-zoomed" — the OS just reports a hit on
470 // `HTMAXBUTTON`, which both buttons occupy.
471 let maximize_normal = ControlButton::new("\u{25A1}", cell_w, cell_h, fg)
472 .hover_background(hover_bg)
473 .with_action(maximize_action)
474 .set_a11y_name(maximize_name)
475 .external_hover(maximize_hover.clone());
476 let maximize_zoomed = ControlButton::new("\u{25A1}", cell_w, cell_h, fg)
477 .hover_background(hover_bg)
478 .with_action(maximize_action_restore)
479 .set_a11y_name(restore_name)
480 .external_hover(maximize_hover);
481 let max_normal_id = ctx.add(maximize_normal);
482 let max_zoomed_id = ctx.add(maximize_zoomed);
483 let maximize_switcher = Switcher::new(switcher_idx)
484 .child_id(max_normal_id)
485 .child_id(max_zoomed_id);
486 let switcher_id = ctx.add(maximize_switcher);
487
488 // U+00D7 (Latin-1 ×) instead of U+2715 (Dingbats ✕): the latter
489 // is missing from many default Linux sans-serif fonts, leaving the
490 // close cell unlabelled. The Latin-1 multiplication sign is in
491 // basically every font.
492 let close = ControlButton::new("\u{00D7}", cell_w, cell_h, fg)
493 .hover_background(close_hover)
494 .with_action(close_action)
495 .set_a11y_name(close_name)
496 .external_hover(close_hover_signal);
497 let close_id = ctx.add(close);
498
499 let row = HStack::new()
500 .spacing(0.0)
501 .add_child(minimize_id)
502 .add_child(switcher_id)
503 .add_child(close_id);
504
505 let root = ctx.add(row);
506 self.root_child_id = Some(root);
507
508 // Publish the layout snapshot for the parent `TitleBar`'s
509 // `after_paint` aggregator. The sink is `None` when the
510 // controls are used standalone (e.g. in tests that don't go
511 // through `TitleBar`); the publish call is a no-op then.
512 //
513 // The maximize slot is the Switcher's id, not either glyph
514 // button: the inactive Switcher child is dormant and reports
515 // `Rect::ZERO`, but the Switcher container itself is laid out
516 // by the parent HStack and has stable bounds across the
517 // floating ↔ maximized transition.
518 if let Some(sink) = &self.layout_sink {
519 sink.set(Some(WindowControlsLayout {
520 minimize_id,
521 maximize_id: switcher_id,
522 close_id,
523 }));
524 }
525
526 vec![root]
527 }
528
529 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
530 builder.set_role(teksilo_core::accesskit::Role::Group);
531 builder.set_name(teksilo_i18n::tr_widget!(a11y_window_controls_name()).resolve_now());
532 }
533
534 fn layout_response(
535 &self,
536 proposal: SizeProposal,
537 ctx: &LayoutContext,
538 ) -> teksilo_core::widget::LayoutResponse {
539 match self.root_child_id {
540 Some(root_id) => ctx
541 .child_size(root_id, proposal)
542 .unwrap_or_else(|| proposal.resolve(0.0, 0.0)),
543 None => proposal.resolve(0.0, 0.0),
544 }
545 .into()
546 }
547
548 fn place_children(
549 &self,
550 bounds: Rect,
551 _proposal: SizeProposal,
552 children: &mut [WidgetPlacement],
553 _ctx: &LayoutContext,
554 ) {
555 for child in children.iter_mut() {
556 child.origin = bounds.origin();
557 child.size = bounds.size();
558 }
559 }
560
561 fn paint(&self, _bounds: Rect, _canvas: &mut teksilo_canvas::Canvas, _ctx: &PaintContext) {}
562
563 fn children(&self) -> Vec<WidgetId> {
564 self.root_child_id.into_iter().collect()
565 }
566}