Skip to main content

frust_widgets/
icon_button.rs

1//! The `IconButton` interactive widget: a pressable **vector** icon — `mark`
2//! centered in a square slot, labelled `label` for accessibility, firing
3//! `on_press` on a release *inside* its bounds.
4//!
5//! [`icon_button`] is the declarative view-fn; it produces an
6//! [`IconButtonView`] carrying the icon, the accessible label, and a typed
7//! `on_press` closure, which materialises into a retained
8//! [`IconButtonWidget`]. The interaction is the same **fire-on-up-inside**
9//! contract every interactive widget in this crate follows (see
10//! `docs/CODE_STANDARDS.md`'s Interaction Semantics, and [`crate::button`]'s
11//! own doc comment): a `Down` inside captures the pointer and paints the
12//! pressed wash; `Move` only updates the pressed visual (cursor-inside); the
13//! callback fires on `Up` *only if the release lands inside*; a `Cancel`
14//! (platform gesture steal) clears the pressed state without firing.
15//!
16//! # Transparent at rest, pressed wash on press
17//!
18//! Unlike [`crate::button`]'s [`crate::ButtonStyle::Icon`] variant (a
19//! square, filled/outlined affordance built for a labelled action row), this
20//! widget paints **nothing** at rest — the mark is the whole resting
21//! appearance, matching a toolbar/app-bar icon affordance (Material's
22//! "standard icon button", the muxr topbar's trailing action slot). A `Down`
23//! inside washes the slot in the mark's own resolved ink at
24//! [`crate::authoring::PRESSED_OPACITY`], so a light-on-dark slot and a
25//! dark-on-light one both darken correctly rather than one of them glowing
26//! against its own background.
27//!
28//! # Slot and glyph sizing
29//!
30//! [`IconButtonView::icon_size`] sets the painted glyph's side length
31//! (default [`DEFAULT_ICON_SIZE`], the same 24px default [`crate::icon`]
32//! itself uses). [`IconButtonView::slot_size`] sets an explicit square tap
33//! target; left unset, the slot is derived the same way
34//! [`crate::ButtonStyle::Icon`] derives its own square box — pad the glyph by
35//! [`PAD_X`]/[`PAD_Y`] on each axis and take the larger side — which lands a
36//! default 24px glyph inside a 48×48 slot, matching Material's minimum
37//! touch-target guidance (source:
38//! <https://m3.material.io/foundations/designing/structure#touch-target>,
39//! retrieved 2026-08-19) without hardcoding that number directly.
40//!
41//! # Color resolution
42//!
43//! The mark's ink follows [`crate::icon`]'s own **explicit builder value >
44//! theme > fallback** precedence (see [`IconButtonView::ink`]): an explicit
45//! override always wins; otherwise the default resolves `on_surface` from
46//! the threaded [`Theme`], falling back to [`UNTHEMED_INK`] (matching
47//! `icon.rs`'s own unthemed default) when no theme is threaded. The pressed
48//! wash reuses this same resolved ink rather than a separate color, so it
49//! never needs a resolution of its own.
50
51use std::rc::Rc;
52
53use frust_core::accesskit::{Action, Role};
54use frust_core::{
55    AnyView, BoxConstraints, BuildCtx, ChangeFlags, ChildPod, EventCtx, EventResult, InputEvent,
56    LayoutCtx, PaintCtx, PaintScene, PointerPhase, SemanticsCtx, View, Widget, any,
57};
58use frust_theme::Theme;
59use kurbo::{Point, Size};
60use peniko::Color;
61
62use crate::authoring::{ErasedCallback, PRESSED_OPACITY, erase_callback, presses};
63use crate::icon;
64use crate::icon::IconData;
65
66/// Default glyph side length, in logical px — the same default
67/// [`crate::icon`] itself uses.
68const DEFAULT_ICON_SIZE: f64 = 24.0;
69
70/// Horizontal padding around the glyph used to derive the default square
71/// slot when no explicit [`IconButtonView::slot_size`] is given — mirrors
72/// [`crate::button`]'s `ButtonStyle::Icon` padding constants (same values,
73/// for a consistent icon-affordance footprint across the crate).
74const PAD_X: f64 = 12.0;
75/// Vertical padding around the glyph — see [`PAD_X`].
76const PAD_Y: f64 = 8.0;
77
78/// Corner radius of the pressed wash, in logical px (the unthemed fallback;
79/// a theme resolves this from `shape.small`, mirroring
80/// [`crate::button`]'s `ButtonWidget::resolve_radius`).
81const RADIUS: f64 = 6.0;
82
83/// Unthemed default icon color — matches `icon.rs`'s own unthemed
84/// `DEFAULT_COLOR` (the M3 light `on_surface` role), duplicated here since
85/// that constant is private to its module; a theme resolves this from
86/// `scheme().on_surface` instead (see the [module docs](self)).
87const UNTHEMED_INK: Color = Color::from_rgb8(0x1D, 0x1B, 0x20);
88
89/// Return `color` with its alpha channel replaced by `alpha` (mirrors
90/// [`crate::button`]'s identically-named helper).
91fn with_alpha(color: Color, alpha: f32) -> Color {
92    let c = color.components;
93    Color::new([c[0], c[1], c[2], alpha])
94}
95
96/// A view-held, typed press callback (erased to [`ErasedCallback`] on build).
97type OnPress<State> = Rc<dyn Fn(&mut State)>;
98
99/// Whether widget-local `pos` lies within a `size`-sized box anchored at the
100/// origin (the button's own bounds) — mirrors [`crate::button`]'s
101/// identically-named helper.
102fn inside(pos: Point, size: Size) -> bool {
103    pos.x >= 0.0 && pos.y >= 0.0 && pos.x < size.width && pos.y < size.height
104}
105
106/// Build the type-erased mark child view: [`crate::icon`] over `mark` at
107/// `icon_size`, with `ink` applied as an explicit color override when set.
108/// Shared by build/rebuild/teardown so the resolved child stays consistent
109/// across the widget's whole lifecycle.
110fn mark_view<State: 'static>(mark: IconData, icon_size: f64, ink: Option<Color>) -> AnyView<State> {
111    let mut view = icon(mark).size(icon_size);
112    if let Some(ink) = ink {
113        view = view.color(ink);
114    }
115    any::<State, _>(view)
116}
117
118/// A declarative pressable vector icon. See the [module docs](self).
119pub struct IconButtonView<State: 'static> {
120    mark: IconData,
121    label: String,
122    /// `None` lets [`crate::icon`] resolve `on_surface` from the threaded
123    /// theme (its own documented default) — see [`IconButtonView::ink`].
124    ink: Option<Color>,
125    icon_size: f64,
126    /// `None` = the padding-derived square default — see the [module
127    /// docs](self)'s "Slot and glyph sizing" section.
128    slot_size: Option<f64>,
129    on_press: OnPress<State>,
130}
131
132/// Create a pressable vector icon painting `mark`, labelled `label` for
133/// accessibility, that runs `on_press` against the app state when released
134/// inside its bounds. See the [module docs](self).
135pub fn icon_button<State: 'static, F: Fn(&mut State) + 'static>(
136    mark: impl Into<IconData>,
137    label: impl Into<String>,
138    on_press: F,
139) -> IconButtonView<State> {
140    IconButtonView {
141        mark: mark.into(),
142        label: label.into(),
143        ink: None,
144        icon_size: DEFAULT_ICON_SIZE,
145        slot_size: None,
146        on_press: Rc::new(on_press),
147    }
148}
149
150/// PascalCase alias for [`icon_button`], matching the container view-fn
151/// vocabulary (see [`crate::Icon`]/[`crate::Button`]'s own aliases).
152#[allow(non_snake_case)]
153pub fn IconButton<State: 'static, F: Fn(&mut State) + 'static>(
154    mark: impl Into<IconData>,
155    label: impl Into<String>,
156    on_press: F,
157) -> IconButtonView<State> {
158    icon_button(mark, label, on_press)
159}
160
161impl<State: 'static> IconButtonView<State> {
162    /// Ink the mark explicitly, overriding the theme's `on_surface` default
163    /// (explicit builder value wins — see the [module docs](self)).
164    pub fn ink(mut self, ink: Color) -> Self {
165        self.ink = Some(ink);
166        self
167    }
168
169    /// Draw the glyph at `size` logical px (default [`DEFAULT_ICON_SIZE`]).
170    pub fn icon_size(mut self, size: f64) -> Self {
171        self.icon_size = size;
172        self
173    }
174
175    /// Give the button an explicit square tap target, overriding the
176    /// padding-derived default — see the [module docs](self)'s "Slot and
177    /// glyph sizing" section.
178    pub fn slot_size(mut self, size: f64) -> Self {
179        self.slot_size = Some(size);
180        self
181    }
182}
183
184/// The retained widget for an [`IconButtonView`]. The mark is a nested
185/// [`crate::IconWidget`] owned as a [`ChildPod`].
186pub struct IconButtonWidget {
187    mark: ChildPod,
188    /// The accessible label (see [`IconButtonView`]'s field of the same
189    /// name); the semantics node reads its name from here.
190    label: String,
191    ink: Option<Color>,
192    icon_size: f64,
193    slot_size: Option<f64>,
194    /// The pressed *visual* state (the wash paints). Follows the cursor
195    /// in/out while captured, and is purely cosmetic.
196    pressed: bool,
197    /// Armed by a `Down` (alongside `capture_pointer`), cleared on
198    /// `Up`/`Cancel`. Gates all `Move`/`Up` handling so a hover `Move`
199    /// (dispatched by the desktop shell on every cursor motion) never
200    /// latches `pressed` or fires the callback without a preceding press.
201    captured: bool,
202    on_press: ErasedCallback,
203}
204
205impl IconButtonWidget {
206    /// The pressed-wash corner radius: themed `shape.small`, resolved
207    /// against the box so it never exceeds a pill (mirrors
208    /// [`crate::button`]'s `ButtonWidget::resolve_radius`). Unthemed: the
209    /// [`RADIUS`] constant exactly.
210    fn resolve_radius(theme: Option<&Theme>, size: Size) -> f64 {
211        match theme {
212            Some(theme) => {
213                frust_theme::ShapeScale::resolve(theme.shape.small, size.width, size.height)
214            }
215            None => RADIUS,
216        }
217    }
218
219    /// The resolved ink color — mirrors `icon.rs`'s own `resolve_default_color`
220    /// (duplicated locally since that helper is private to its module): an
221    /// explicit [`IconButtonView::ink`] override wins; otherwise `on_surface`
222    /// from the threaded theme, falling back to [`UNTHEMED_INK`].
223    fn resolve_ink(&self, theme: Option<&Theme>) -> Color {
224        if let Some(ink) = self.ink {
225            return ink;
226        }
227        match theme {
228            Some(theme) => theme.scheme().on_surface,
229            None => UNTHEMED_INK,
230        }
231    }
232}
233
234impl<State: 'static> View<State> for IconButtonView<State> {
235    type Element = IconButtonWidget;
236
237    fn build(&self, ctx: &mut BuildCtx<'_>) -> IconButtonWidget {
238        let mark_view = mark_view::<State>(self.mark.clone(), self.icon_size, self.ink);
239        IconButtonWidget {
240            mark: crate::authoring::build_child(&mark_view, ctx),
241            label: self.label.clone(),
242            ink: self.ink,
243            icon_size: self.icon_size,
244            slot_size: self.slot_size,
245            pressed: false,
246            captured: false,
247            on_press: erase_callback(&self.on_press),
248        }
249    }
250
251    fn rebuild(
252        &self,
253        prev: &Self,
254        element: &mut IconButtonWidget,
255        ctx: &mut BuildCtx<'_>,
256    ) -> ChangeFlags {
257        // Closures are not comparable — always reinstall the adapter.
258        element.on_press = erase_callback(&self.on_press);
259        let prev_view = mark_view::<State>(prev.mark.clone(), prev.icon_size, prev.ink);
260        let next_view = mark_view::<State>(self.mark.clone(), self.icon_size, self.ink);
261        let mut flags =
262            crate::authoring::rebuild_child(&prev_view, &next_view, &mut element.mark, ctx);
263        if prev.icon_size != self.icon_size || prev.slot_size != self.slot_size {
264            element.icon_size = self.icon_size;
265            element.slot_size = self.slot_size;
266            flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
267        }
268        if prev.ink != self.ink {
269            element.ink = self.ink;
270            flags |= ChangeFlags::PAINT;
271        }
272        if prev.label != self.label {
273            element.label = self.label.clone();
274            // Semantics are recomputed off the paint pass, so a label change
275            // republishes through PAINT (mirrors the muxr topbar's
276            // `IconButtonWidget::rebuild` precedent).
277            flags |= ChangeFlags::PAINT;
278        }
279        flags
280    }
281
282    fn teardown(&self, element: &mut IconButtonWidget, ctx: &mut BuildCtx<'_>) {
283        let mark_view = mark_view::<State>(self.mark.clone(), self.icon_size, self.ink);
284        crate::authoring::teardown_child(&mark_view, &mut element.mark, ctx);
285    }
286}
287
288impl Widget for IconButtonWidget {
289    fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
290        // A tight constraint always wins (mirrors `IconWidget::layout`), so
291        // `mark_size` is exactly `(icon_size, icon_size)`.
292        let icon_bc = BoxConstraints::tight(Size::new(self.icon_size, self.icon_size));
293        let mark_size = self.mark.layout_child(ctx, &icon_bc);
294        let side = self.slot_size.unwrap_or_else(|| {
295            let width = mark_size.width + PAD_X * 2.0;
296            let height = mark_size.height + PAD_Y * 2.0;
297            width.max(height)
298        });
299        let size = bc.constrain(Size::new(side, side));
300        self.mark.set_origin(Point::new(
301            (size.width - mark_size.width) / 2.0,
302            (size.height - mark_size.height) / 2.0,
303        ));
304        size
305    }
306
307    fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
308        let theme = Theme::from_paint_ctx(ctx);
309        let ink = self.resolve_ink(theme);
310        let radius = Self::resolve_radius(theme, ctx.size());
311        self.mark.paint_child(ctx, scene);
312        if self.pressed {
313            // Transparent at rest (module docs): only a pressed wash paints,
314            // in the mark's own resolved ink so a light-on-dark slot and a
315            // dark-on-light one both darken correctly.
316            scene.fill_rounded_rect(
317                ctx.origin(),
318                ctx.size(),
319                radius,
320                with_alpha(ink, PRESSED_OPACITY),
321            );
322        }
323    }
324
325    fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
326        let InputEvent::Pointer(p) = event else {
327            return EventResult::Ignored;
328        };
329        match p.phase {
330            PointerPhase::Down => {
331                if !presses(p) {
332                    return EventResult::Ignored;
333                }
334                self.pressed = true;
335                self.captured = true;
336                ctx.capture_pointer();
337                ctx.request_redraw();
338                EventResult::Handled
339            }
340            PointerPhase::Move => {
341                // Only a press we armed on `Down` tracks the cursor; a hover
342                // `Move` (no prior press) is not ours.
343                if !self.captured {
344                    return EventResult::Ignored;
345                }
346                self.pressed = inside(p.position, ctx.size());
347                ctx.request_redraw();
348                EventResult::Handled
349            }
350            PointerPhase::Up => {
351                if !self.captured {
352                    return EventResult::Ignored;
353                }
354                // Fire on up-inside only.
355                if inside(p.position, ctx.size()) {
356                    (self.on_press)(ctx);
357                }
358                self.pressed = false;
359                self.captured = false;
360                ctx.request_redraw();
361                EventResult::Handled
362            }
363            PointerPhase::Cancel => {
364                if !self.captured {
365                    return EventResult::Ignored;
366                }
367                self.pressed = false;
368                self.captured = false;
369                ctx.request_redraw();
370                EventResult::Handled
371            }
372        }
373    }
374
375    fn semantics(&self, ctx: &mut SemanticsCtx) {
376        // A single a11y node (Role::Button) labelled by the accessible name —
377        // the mark itself contributes nothing separately (an icon is
378        // decorative on its own; the *name* of the control is what a screen
379        // reader needs, never the shape of its glyph).
380        ctx.push_node(Role::Button, |node| {
381            node.set_label(self.label.as_str());
382            node.add_action(Action::Click);
383        });
384    }
385
386    crate::authoring::visit_children!(mark);
387}
388
389#[cfg(test)]
390mod tests {
391    use super::*;
392    use std::any::Any;
393
394    #[derive(Default)]
395    struct Counter {
396        presses: u32,
397    }
398
399    /// Build an icon-button widget over `Counter` state, with a known
400    /// 48×48 size (the default slot for a 24px glyph) for the inside/outside
401    /// geometry.
402    fn widget() -> IconButtonWidget {
403        let view = icon_button::<Counter, _>(crate::icons::CLOSE, "Close", |s: &mut Counter| {
404            s.presses += 1
405        });
406        let mut counter = 0u64;
407        View::<Counter>::build(&view, &mut BuildCtx::new(&mut counter))
408    }
409
410    fn ev(phase: PointerPhase, x: f64, y: f64) -> InputEvent {
411        InputEvent::Pointer(frust_core::PointerEvent {
412            phase,
413            position: Point::new(x, y),
414            button: frust_core::PointerButton::Primary,
415        })
416    }
417
418    fn secondary_ev(phase: PointerPhase, x: f64, y: f64) -> InputEvent {
419        InputEvent::Pointer(frust_core::PointerEvent {
420            phase,
421            position: Point::new(x, y),
422            button: frust_core::PointerButton::Secondary,
423        })
424    }
425
426    fn dispatch(w: &mut IconButtonWidget, state: &mut Counter, event: &InputEvent) {
427        let state_any: &mut dyn Any = state;
428        let mut ctx = EventCtx::new(state_any, Point::ZERO, Size::new(48.0, 48.0));
429        w.event(&mut ctx, event);
430    }
431
432    #[test]
433    fn down_then_up_inside_fires_once() {
434        let mut w = widget();
435        let mut state = Counter::default();
436        dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 10.0, 10.0));
437        assert!(w.pressed);
438        dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 12.0, 12.0));
439        assert_eq!(state.presses, 1);
440        assert!(!w.pressed);
441    }
442
443    #[test]
444    fn down_inside_move_out_up_outside_does_not_fire() {
445        let mut w = widget();
446        let mut state = Counter::default();
447        dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 10.0, 10.0));
448        dispatch(&mut w, &mut state, &ev(PointerPhase::Move, 200.0, 10.0));
449        assert!(!w.pressed, "moving out clears the pressed wash");
450        dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 200.0, 10.0));
451        assert_eq!(state.presses, 0, "up outside must not fire");
452    }
453
454    #[test]
455    fn cancel_clears_pressed_without_firing() {
456        let mut w = widget();
457        let mut state = Counter::default();
458        dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 10.0, 10.0));
459        dispatch(&mut w, &mut state, &ev(PointerPhase::Cancel, 10.0, 10.0));
460        assert!(!w.pressed);
461        assert_eq!(state.presses, 0);
462    }
463
464    #[test]
465    fn move_back_inside_then_up_fires() {
466        let mut w = widget();
467        let mut state = Counter::default();
468        dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 10.0, 10.0));
469        dispatch(&mut w, &mut state, &ev(PointerPhase::Move, 200.0, 10.0));
470        dispatch(&mut w, &mut state, &ev(PointerPhase::Move, 20.0, 10.0));
471        assert!(w.pressed);
472        dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 20.0, 10.0));
473        assert_eq!(state.presses, 1);
474    }
475
476    #[test]
477    fn up_without_down_does_not_fire() {
478        let mut w = widget();
479        let mut state = Counter::default();
480        let state_any: &mut dyn Any = &mut state;
481        let mut ctx = EventCtx::new(state_any, Point::ZERO, Size::new(48.0, 48.0));
482        let result = w.event(&mut ctx, &ev(PointerPhase::Up, 20.0, 20.0));
483        assert!(matches!(result, EventResult::Ignored));
484        assert_eq!(state.presses, 0, "an unarmed Up must never fire");
485    }
486
487    #[test]
488    fn hover_move_without_down_is_ignored_noop() {
489        let mut w = widget();
490        let mut state = Counter::default();
491        let state_any: &mut dyn Any = &mut state;
492        let mut ctx = EventCtx::new(state_any, Point::ZERO, Size::new(48.0, 48.0));
493        let result = w.event(&mut ctx, &ev(PointerPhase::Move, 20.0, 20.0));
494        assert!(matches!(result, EventResult::Ignored));
495        assert!(!w.pressed, "hover must not press");
496        assert!(!ctx.needs_redraw(), "hover must not request a redraw");
497        assert_eq!(state.presses, 0);
498    }
499
500    #[test]
501    fn a_secondary_press_neither_presses_nor_captures_nor_fires() {
502        let mut w = widget();
503        let mut state = Counter::default();
504        dispatch(
505            &mut w,
506            &mut state,
507            &secondary_ev(PointerPhase::Down, 10.0, 10.0),
508        );
509        assert!(!w.pressed, "no pressed wash on a right-click");
510        assert!(!w.captured, "and no capture for the shell to wedge on");
511        dispatch(
512            &mut w,
513            &mut state,
514            &secondary_ev(PointerPhase::Up, 10.0, 10.0),
515        );
516        assert_eq!(state.presses, 0);
517
518        // The primary gesture is untouched by the guard.
519        dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 10.0, 10.0));
520        assert!(w.pressed);
521        dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 10.0, 10.0));
522        assert_eq!(state.presses, 1);
523    }
524
525    #[test]
526    fn semantics_publishes_a_single_button_node_named_by_the_label() {
527        // Drives a real `RenderRoot` (the radio/checkbox precedent) rather
528        // than constructing a `SemanticsCtx` directly — its constructor
529        // isn't public, and the semantics tree is only meaningful once
530        // published through a root.
531        fn logic(_s: &mut ()) -> IconButtonView<()> {
532            icon_button::<(), _>(crate::icons::CLOSE, "Close", |_: &mut ()| {})
533        }
534        let mut root: frust_core::RenderRoot<(), IconButtonView<()>> =
535            frust_core::RenderRoot::new();
536        let mut state = ();
537        root.rebuild(&mut logic, &mut state);
538        let mut tcx = frust_text::TextContext::new();
539        root.layout_with_text(Size::new(200.0, 200.0), &mut tcx as &mut dyn Any);
540        let update = root.semantics();
541        let matches: Vec<_> = update
542            .nodes
543            .iter()
544            .filter(|(_, n)| n.role() == Role::Button)
545            .collect();
546        assert_eq!(matches.len(), 1, "one a11y node, not one per glyph path");
547        let (_, node) = matches[0];
548        assert_eq!(node.label(), Some("Close"));
549        assert!(node.supports_action(Action::Click));
550    }
551
552    // --- layout: slot/glyph sizing ------------------------------------------
553
554    #[test]
555    fn default_slot_squares_a_24px_glyph_to_48px() {
556        let mut w = widget();
557        let mut lctx = LayoutCtx::new();
558        let size = w.layout(&mut lctx, &BoxConstraints::loose(Size::new(500.0, 500.0)));
559        assert_eq!(size, Size::new(48.0, 48.0));
560    }
561
562    #[test]
563    fn explicit_slot_size_overrides_the_padding_derived_default() {
564        let view = icon_button::<Counter, _>(crate::icons::CLOSE, "Close", |_: &mut Counter| {})
565            .slot_size(30.0);
566        let mut counter = 0u64;
567        let mut w = View::<Counter>::build(&view, &mut BuildCtx::new(&mut counter));
568        let mut lctx = LayoutCtx::new();
569        let size = w.layout(&mut lctx, &BoxConstraints::loose(Size::new(500.0, 500.0)));
570        assert_eq!(size, Size::new(30.0, 30.0));
571    }
572
573    #[test]
574    fn explicit_icon_size_grows_the_default_slot() {
575        let view = icon_button::<Counter, _>(crate::icons::CLOSE, "Close", |_: &mut Counter| {})
576            .icon_size(32.0);
577        let mut counter = 0u64;
578        let mut w = View::<Counter>::build(&view, &mut BuildCtx::new(&mut counter));
579        let mut lctx = LayoutCtx::new();
580        let size = w.layout(&mut lctx, &BoxConstraints::loose(Size::new(500.0, 500.0)));
581        // 32 + 2*PAD_X = 56, 32 + 2*PAD_Y = 48 -> the wider axis wins.
582        assert_eq!(size, Size::new(56.0, 56.0));
583    }
584}