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}