Skip to main content

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}