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