Skip to main content

gpui_kit_material/components/
button.rs

1//! Material button presentation over GPUI Base activation and focus behavior.
2//! Reference priority is Figma kit > md3.io > m3e.kyralabs.dev, per value.
3//! Sizes, paddings, gaps, icon sizes, corners, outline widths and disabled
4//! colors come from the Figma Material 3 Design Kit's Buttons page (Button
5//! 57994:2227 and its text/elevated/outline/tonal/toggle sets; fixture
6//! `tests/fixtures/figma-button.json`). The kit has no motion, so
7//! state-layer, shadow, ripple and color-transition timing follow the md3.io
8//! captures of floor/material c910072f3fc5b660276731d8a935d7186f288364
9//! (`conformance/reference/button-*.json`). m3e's button tokens
10//! (`tests/fixtures/m3e-sys-tokens.json`) are outranked secondary evidence.
11//! Controlled toggle and loading presentation over Base behavior.
12#[cfg(test)]
13mod active_tests;
14mod control;
15mod playback;
16pub use playback::{ButtonTransitionInputs, ButtonTransitionPlayback};
17mod effects;
18mod focus_ring;
19mod form;
20pub(crate) use focus_ring::ButtonFocusRing;
21pub use form::{FabSize, IconButtonWidth};
22pub(crate) use form::{Form, Palette, Roles, Segment, segment_metrics};
23mod hit_target;
24#[cfg(test)]
25mod loading_tests;
26#[cfg(test)]
27mod preview_tests;
28mod ripple;
29pub(crate) use ripple::ButtonRipple;
30mod ripple_clip;
31mod shadow;
32#[cfg(test)]
33mod toggle_tests;
34
35use super::InteractionPreview;
36use control::BaseControl;
37use effects::CssColor;
38
39use crate::{
40    color::ColorRole,
41    icons::Icon,
42    motion::{self, MotionToken},
43    tokens::{
44        state,
45        typography::{TypeRole, rem},
46    },
47};
48use gpui::{prelude::*, *};
49
50/// Persistent progress/loading presentation for one logical button.
51///
52/// Keep this handle across virtual unmounts. Forward actual progress, loading,
53/// and disabled changes with [`Self::sync`] while unmounted; do not report a
54/// virtual unmount as hiding progress. Clones share playback, not interaction
55/// state. A fresh handle starts a new lifecycle.
56#[derive(Clone, Default)]
57pub struct ButtonProgressPlayback {
58    state: std::rc::Rc<std::cell::RefCell<ButtonProgressState>>,
59}
60
61#[derive(Default)]
62struct ButtonProgressState {
63    started: bool,
64    visible: bool,
65    generation: u64,
66    circular: super::progress::CircularProgressPlayback,
67    opacity: gpui_base::TransitionPlayback<f32>,
68}
69
70impl ButtonProgressPlayback {
71    /// Record real input changes at their event time without requesting frames.
72    /// `disabled` is the caller's independent flag; loading also disables.
73    /// First showing progress starts its circular clock. Hiding preserves that
74    /// clock, but the next show adopts the new opacity rather than resuming an
75    /// old hidden transition, matching the ordinary button API.
76    pub fn sync(&self, progress: bool, loading: bool, disabled: bool, cx: &App) {
77        self.sample_inputs(progress, loading, disabled, cx);
78    }
79
80    /// Forward a preference change after updating the app, including offscreen.
81    /// This does not start unused progress or restart a previously stopped loop.
82    /// Opacity retains its existing reduced-motion Continue policy.
83    pub fn observe_reduced_motion(&self, cx: &App) {
84        self.state.borrow().circular.observe_reduced_motion(cx);
85    }
86
87    /// Advance lifecycle and opacity without requesting frames. A started
88    /// clock is sampled even while hidden or unmounted, keeping its phase and
89    /// reduced-motion stop; only a painted indicator requests frames.
90    fn sample_inputs(
91        &self,
92        progress: bool,
93        loading: bool,
94        disabled: bool,
95        cx: &App,
96    ) -> gpui_base::MotionValue<f32> {
97        let mut state = self.state.borrow_mut();
98        let visible = progress || loading;
99        if visible && !state.visible {
100            state.generation += 1;
101            state.opacity = gpui_base::TransitionPlayback::default();
102        }
103        state.started |= visible;
104        state.visible = visible;
105        if state.started {
106            state.circular.sample(cx);
107        }
108        if visible {
109            state.opacity.sample(
110                if disabled || loading { 0.7 } else { 1. },
111                playback::opacity_policy(),
112                cx,
113            )
114        } else {
115            gpui_base::MotionValue {
116                value: 1.,
117                status: gpui_base::MotionStatus::Idle,
118            }
119        }
120    }
121}
122
123#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
124pub enum ButtonVariant {
125    #[default]
126    Filled,
127    Elevated,
128    Tonal,
129    Outlined,
130    Text,
131}
132impl ButtonVariant {
133    pub const ALL: [Self; 5] = [
134        Self::Filled,
135        Self::Elevated,
136        Self::Tonal,
137        Self::Outlined,
138        Self::Text,
139    ];
140    pub const fn name(self) -> &'static str {
141        match self {
142            Self::Filled => "filled",
143            Self::Elevated => "elevated",
144            Self::Tonal => "tonal",
145            Self::Outlined => "outlined",
146            Self::Text => "text",
147        }
148    }
149}
150
151#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
152pub enum ButtonSize {
153    ExtraSmall,
154    #[default]
155    Small,
156    Medium,
157    Large,
158    ExtraLarge,
159}
160#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
161pub enum ButtonShape {
162    #[default]
163    Round,
164    Square,
165}
166
167impl ButtonShape {
168    pub const ALL: [Self; 2] = [Self::Round, Self::Square];
169    pub const fn name(self) -> &'static str {
170        match self {
171            Self::Round => "round",
172            Self::Square => "square",
173        }
174    }
175}
176
177/// Logical placement of the optional icon, relative to the label.
178#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
179pub enum ButtonIconPosition {
180    #[default]
181    Leading,
182    Trailing,
183}
184/// Layout direction for logical icon placement and padding.
185#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
186pub enum ButtonDirection {
187    #[default]
188    Ltr,
189    Rtl,
190}
191
192#[derive(Clone, Copy, Debug)]
193struct Geometry {
194    height: f32,
195    padding: f32,
196    gap: f32,
197    outline: f32,
198    square: f32,
199    pressed: f32,
200    typography: TypeRole,
201}
202impl ButtonSize {
203    pub const ALL: [Self; 5] = [
204        Self::ExtraSmall,
205        Self::Small,
206        Self::Medium,
207        Self::Large,
208        Self::ExtraLarge,
209    ];
210    pub const fn name(self) -> &'static str {
211        match self {
212            Self::ExtraSmall => "xs",
213            Self::Small => "s",
214            Self::Medium => "m",
215            Self::Large => "l",
216            Self::ExtraLarge => "xl",
217        }
218    }
219    /// The container height in design px.
220    pub(crate) fn height(self) -> f32 {
221        self.geometry().height
222    }
223    fn icon_size(self) -> f32 {
224        match self {
225            Self::ExtraSmall | Self::Small => 20.,
226            Self::Medium => 24.,
227            Self::Large => 32.,
228            Self::ExtraLarge => 40.,
229        }
230    }
231    fn geometry(self) -> Geometry {
232        let (height, padding, gap, outline, square, pressed, typography) = match self {
233            Self::ExtraSmall => (32., 12., 4., 1., 12., 8., TypeRole::LabelLarge),
234            Self::Small => (40., 16., 8., 1., 12., 8., TypeRole::LabelLarge),
235            Self::Medium => (56., 24., 8., 1., 16., 12., TypeRole::TitleMedium),
236            Self::Large => (96., 48., 12., 2., 28., 16., TypeRole::HeadlineSmall),
237            Self::ExtraLarge => (136., 64., 16., 2., 28., 16., TypeRole::HeadlineLarge),
238        };
239        Geometry {
240            height,
241            padding,
242            gap,
243            outline,
244            square,
245            pressed,
246            typography,
247        }
248    }
249}
250
251/// Inline padding: the kit pads every variant, with or without an icon, by
252/// the size's State-layer padding (12/16/24/48/64), unlike md3.io's compact
253/// text and small-icon paddings. Both sides are equal, so logical direction
254/// does not change them.
255fn horizontal_padding(size: ButtonSize) -> (f32, f32) {
256    let padding = size.geometry().padding;
257    (padding, padding)
258}
259
260#[cfg(test)]
261fn alpha(mut color: Rgba, opacity: f32) -> Rgba {
262    color.a *= opacity;
263    color
264}
265/// Disabled colors follow the Figma kit (every Button*/Toggle button* set at
266/// State=Disabled): every variant, including text and outlined, fills its
267/// container with `State Layers/On Surface/Opacity-10` and draws its content
268/// in on-surface under a 0.38 layer; the outlined button keeps its full
269/// outline-variant outline. Fixture: `tests/fixtures/figma-button.json`.
270fn disabled_targets(outlined: bool) -> (CssColor, CssColor, CssColor) {
271    use ColorRole as C;
272    (
273        CssColor::alpha_role(C::ON_SURFACE, 0.10),
274        CssColor::alpha_role(C::ON_SURFACE, crate::tokens::state::DISABLED_CONTENT),
275        if outlined {
276            CssColor::role(C::OUTLINE_VARIANT)
277        } else {
278            CssColor::transparent()
279        },
280    )
281}
282fn color_targets(variant: ButtonVariant, disabled: bool) -> (CssColor, CssColor, CssColor) {
283    use ColorRole as C;
284    if disabled {
285        return disabled_targets(variant == ButtonVariant::Outlined);
286    }
287    let (bg, fg) = match variant {
288        ButtonVariant::Filled => (CssColor::role(C::PRIMARY), C::ON_PRIMARY),
289        ButtonVariant::Elevated => (CssColor::role(C::SURFACE_CONTAINER_LOW), C::PRIMARY),
290        ButtonVariant::Tonal => (
291            CssColor::role(C::SECONDARY_CONTAINER),
292            C::ON_SECONDARY_CONTAINER,
293        ),
294        ButtonVariant::Outlined => (CssColor::transparent(), C::ON_SURFACE_VARIANT),
295        ButtonVariant::Text => (CssColor::transparent(), C::PRIMARY),
296    };
297    (
298        bg,
299        CssColor::role(fg),
300        if variant == ButtonVariant::Outlined {
301            CssColor::role(C::OUTLINE_VARIANT)
302        } else {
303            CssColor::transparent()
304        },
305    )
306}
307fn resting_radius(size: ButtonSize, shape: ButtonShape, selected: bool) -> f32 {
308    let g = size.geometry();
309    if (shape == ButtonShape::Round) != selected {
310        g.height / 2.
311    } else {
312        g.square
313    }
314}
315
316fn toggle_color_targets(
317    variant: ButtonVariant,
318    disabled: bool,
319    toggle: bool,
320    selected: bool,
321) -> (CssColor, CssColor, CssColor) {
322    use ColorRole as C;
323    if !toggle {
324        return color_targets(variant, disabled);
325    }
326    if disabled {
327        // The kit's disabled toggles match the plain buttons, except that a
328        // selected outlined toggle (inverse surface when enabled) drops its
329        // outline.
330        return disabled_targets(variant == ButtonVariant::Outlined && !selected);
331    }
332    let roles = match (variant, selected) {
333        (ButtonVariant::Filled, false) => Some((C::SURFACE_CONTAINER, C::ON_SURFACE_VARIANT)),
334        (ButtonVariant::Filled | ButtonVariant::Elevated, true) => {
335            Some((C::PRIMARY, C::ON_PRIMARY))
336        }
337        (ButtonVariant::Tonal, true) => Some((C::SECONDARY, C::ON_SECONDARY)),
338        (ButtonVariant::Outlined, true) => Some((C::INVERSE_SURFACE, C::INVERSE_ON_SURFACE)),
339        _ => None,
340    };
341    match roles {
342        Some((bg, fg)) => (
343            CssColor::role(bg),
344            CssColor::role(fg),
345            CssColor::transparent(),
346        ),
347        None => color_targets(variant, false),
348    }
349}
350
351/// The resolved container and content of an enabled toggle button of
352/// `variant`, for composites (connected groups) that paint toggle colors
353/// on their own options.
354pub(crate) fn toggle_colors(variant: ButtonVariant, selected: bool) -> (Rgba, Rgba) {
355    let (bg, fg, _) = toggle_color_targets(variant, false, true, selected);
356    (bg.resolve(), fg.resolve())
357}
358
359#[cfg(test)]
360fn colors(variant: ButtonVariant, disabled: bool) -> (Rgba, Rgba, Rgba) {
361    let (bg, fg, border) = color_targets(variant, disabled);
362    (bg.resolve(), fg.resolve(), border.resolve())
363}
364
365/// Passive visual state. Base remains the only activation authority.
366struct PressState {
367    pointer: bool,
368    hovered: bool,
369    caller_hovered: bool,
370    native_hovered: bool,
371    outside_window: bool,
372    keyboard: bool,
373    focus: FocusHandle,
374    focus_out: Option<Subscription>,
375    _activation: Subscription,
376}
377fn clear_press(state: &mut PressState) {
378    state.pointer = false;
379    state.hovered = false;
380    state.outside_window = true;
381    state.keyboard = false;
382}
383fn press_state(
384    id: ElementId,
385    focus: &FocusHandle,
386    window: &mut Window,
387    cx: &mut App,
388) -> Entity<PressState> {
389    let state = window.use_keyed_state((id, "material-press"), cx, |window, cx| {
390        let activation =
391            cx.observe_window_activation(window, |state: &mut PressState, window, cx| {
392                if !window.is_window_active() {
393                    clear_press(state);
394                    cx.notify();
395                }
396            });
397        PressState {
398            pointer: false,
399            hovered: false,
400            caller_hovered: false,
401            native_hovered: false,
402            outside_window: false,
403            keyboard: false,
404            focus: focus.clone(),
405            focus_out: None,
406            _activation: activation,
407        }
408    });
409    if state.read(cx).focus_out.is_none() || state.read(cx).focus != *focus {
410        let weak = state.downgrade();
411        let subscription = window.on_focus_out(focus, cx, move |_, _, cx| {
412            let _ = weak.update(cx, |state, cx| {
413                state.keyboard = false;
414                cx.notify();
415            });
416        });
417        state.update(cx, |state, _| {
418            state.keyboard = false;
419            state.focus = focus.clone();
420            state.focus_out = Some(subscription);
421        });
422    }
423    state
424}
425fn observe_press(
426    mut base: BaseControl,
427    state: &Entity<PressState>,
428    focus: &FocusHandle,
429    disabled: bool,
430    focusable: bool,
431) -> BaseControl {
432    if disabled {
433        return base;
434    }
435    let down = state.clone();
436    base = base.on_mouse_down(MouseButton::Left, move |_, window, cx| {
437        if !window.default_prevented() {
438            down.update(cx, |state, cx| {
439                state.outside_window = false;
440                state.pointer = true;
441                state.hovered = !window.last_input_was_touch();
442                cx.notify();
443            });
444        }
445    });
446    let up = state.clone();
447    let out = state.clone();
448    base = base
449        .on_mouse_up(MouseButton::Left, move |_, window, cx| {
450            up.update(cx, |state, cx| {
451                state.pointer = false;
452                state.hovered = !window.last_input_was_touch();
453                cx.notify();
454            });
455        })
456        .on_mouse_up_out(MouseButton::Left, move |_, _, cx| {
457            out.update(cx, |state, cx| {
458                state.hovered = false;
459                state.pointer = false;
460                cx.notify();
461            });
462        });
463    if focusable {
464        let down = state.clone();
465        let up = state.clone();
466        let focus = focus.clone();
467        base = base
468            .on_key_down(move |event, window, cx| {
469                // Only activation keys change the pressed visual; an unrelated
470                // key (including a modifier) during a held Space keeps it.
471                let stroke = &event.keystroke;
472                if !matches!(stroke.key.as_str(), "enter" | "space") {
473                    return;
474                }
475                let pressed = !window.default_prevented()
476                    && focus.is_focused(window)
477                    && !stroke.modifiers.modified();
478                down.update(cx, |state, cx| {
479                    if state.keyboard != pressed {
480                        state.keyboard = pressed;
481                        cx.notify();
482                    }
483                });
484            })
485            .on_key_up(move |event, _, cx| {
486                if matches!(event.keystroke.key.as_str(), "enter" | "space") {
487                    up.update(cx, |state, cx| {
488                        if state.keyboard {
489                            state.keyboard = false;
490                            cx.notify();
491                        }
492                    });
493                }
494            });
495    }
496    base
497}
498
499type HoverCallback = std::rc::Rc<dyn Fn(&bool, &mut Window, &mut App)>;
500
501/// Sized, shaped Material presentation; activation, keyboard and focus stay in Base.
502#[derive(gpui::IntoElement)]
503pub struct MaterialButton {
504    #[cfg(test)]
505    progress_opacity_observer: Option<std::rc::Rc<std::cell::Cell<f32>>>,
506    #[cfg(test)]
507    radius_observer: Option<std::rc::Rc<std::cell::Cell<f32>>>,
508    #[cfg(test)]
509    colors_observer: Option<std::rc::Rc<std::cell::Cell<(Rgba, Rgba, Rgba)>>>,
510    hover_callback: Option<HoverCallback>,
511    caller_hover_mode: HoverListenerMode,
512    #[cfg(test)]
513    effects_observer: Option<std::rc::Rc<std::cell::RefCell<(f32, shadow::ShadowPair)>>>,
514    id: ElementId,
515    focus_handle: Option<FocusHandle>,
516    focusable: bool,
517    base: BaseControl,
518    children: Vec<AnyElement>,
519    icon: Option<Icon>,
520    icon_position: ButtonIconPosition,
521    direction: ButtonDirection,
522    variant: ButtonVariant,
523    size: ButtonSize,
524    shape: ButtonShape,
525    disabled: bool,
526    ripple: bool,
527    active_state: bool,
528    progress: Option<(bool, SharedString)>,
529    loading: Option<(bool, SharedString)>,
530    progress_playback: Option<ButtonProgressPlayback>,
531    transition_playback: Option<ButtonTransitionPlayback>,
532    preview: Option<InteractionPreview>,
533    form: Form,
534    palette: Option<Palette>,
535    open: bool,
536    press_probe: Option<std::rc::Rc<std::cell::Cell<bool>>>,
537    style: StyleRefinement,
538}
539impl MaterialButton {
540    pub fn new(id: impl Into<ElementId>) -> Self {
541        let id = id.into();
542        Self {
543            #[cfg(test)]
544            progress_opacity_observer: None,
545            #[cfg(test)]
546            radius_observer: None,
547            #[cfg(test)]
548            colors_observer: None,
549            hover_callback: None,
550            caller_hover_mode: HoverListenerMode::InputModalityAware,
551            #[cfg(test)]
552            effects_observer: None,
553            id: id.clone(),
554            focus_handle: None,
555            focusable: true,
556            base: BaseControl::new(id).block_mouse_except_scroll(),
557            children: vec![],
558            icon: None,
559            icon_position: ButtonIconPosition::Leading,
560            direction: ButtonDirection::Ltr,
561            variant: ButtonVariant::Filled,
562            size: ButtonSize::Small,
563            shape: ButtonShape::Round,
564            disabled: false,
565            ripple: true,
566            active_state: false,
567            progress: None,
568            loading: None,
569            progress_playback: None,
570            transition_playback: None,
571            preview: None,
572            form: Form::Label,
573            palette: None,
574            open: false,
575            press_probe: None,
576            style: StyleRefinement::default(),
577        }
578    }
579    /// Use a non-label form's geometry (icon button, FAB, split segment).
580    pub(crate) fn form(mut self, form: Form) -> Self {
581        self.form = form;
582        self
583    }
584    /// Paint with these roles instead of the variant's.
585    pub(crate) fn palette(mut self, palette: Palette) -> Self {
586        self.palette = Some(palette);
587        self
588    }
589    /// A trailing split segment whose menu is open: fully round.
590    /// Report whether this button is pressed (pointer, key or forced
591    /// preview) to a composite such as a button group. A change requests
592    /// one more frame so the composite reads it.
593    pub(crate) fn press_probe(mut self, probe: std::rc::Rc<std::cell::Cell<bool>>) -> Self {
594        self.press_probe = Some(probe);
595        self
596    }
597    pub(crate) fn open(mut self, open: bool) -> Self {
598        self.open = open;
599        self
600    }
601    fn form_geometry(&self) -> form::FormGeometry {
602        let geometry = self.form.geometry(self.size, self.direction);
603        if self.open && self.form == Form::Segment(Segment::Trailing) {
604            // The open, fully round trailing segment centers its chevron.
605            let side = (geometry.padding.0 + geometry.padding.1) / 2.;
606            form::FormGeometry {
607                padding: (side, side),
608                ..geometry
609            }
610        } else {
611            geometry
612        }
613    }
614    fn color_targets(&self) -> (CssColor, CssColor, CssColor) {
615        match &self.palette {
616            Some(palette) => palette.targets(self.disabled, self.base.toggle, self.base.selected),
617            None => toggle_color_targets(
618                self.variant,
619                self.disabled,
620                self.base.toggle,
621                self.base.selected,
622            ),
623        }
624    }
625    /// Enable controlled toggle semantics and Material selected presentation.
626    pub fn toggle(mut self, enabled: bool) -> Self {
627        self.base.toggle = enabled;
628        self
629    }
630    /// Set the application-owned selection. This does not emit a change request.
631    pub fn selected(mut self, selected: bool) -> Self {
632        self.base.selected = selected;
633        self
634    }
635    /// Allow activation to request a selection change (enabled by default).
636    /// Disable this when a containing group owns selection; `on_click` still runs.
637    pub fn toggle_on_click(mut self, enabled: bool) -> Self {
638        self.base.toggle_on_click = enabled;
639        self
640    }
641    /// Handle Base's request to change selection. The caller must update the
642    /// value supplied to `selected`; the button never stores selection itself.
643    pub fn on_change(
644        mut self,
645        callback: impl Fn(bool, &ClickEvent, &mut Window, &mut App) + 'static,
646    ) -> Self {
647        self.base.on_change = Some(std::rc::Rc::new(callback));
648        self
649    }
650    pub fn variant(mut self, variant: ButtonVariant) -> Self {
651        self.variant = variant;
652        self
653    }
654    /// Set a decorative icon; the button supplies its reference slot size.
655    pub fn icon(mut self, icon: impl Into<Icon>) -> Self {
656        self.icon = Some(icon.into());
657        self
658    }
659    pub fn icon_position(mut self, position: ButtonIconPosition) -> Self {
660        self.icon_position = position;
661        self
662    }
663    pub fn direction(mut self, direction: ButtonDirection) -> Self {
664        self.direction = direction;
665        self
666    }
667    pub fn button_size(mut self, size: ButtonSize) -> Self {
668        self.size = size;
669        self
670    }
671    pub fn shape(mut self, shape: ButtonShape) -> Self {
672        self.shape = shape;
673        self
674    }
675    pub fn disabled(mut self, disabled: bool) -> Self {
676        self.disabled = disabled;
677        self.base = self.base.disabled(disabled);
678        self
679    }
680    /// Show an indeterminate indicator without changing activation or busy state.
681    /// The label names the progress indicator for assistive technology. Keep this
682    /// configuration present while hidden to retain its animation phase.
683    /// When combined with `loading`, either true flag shows the indicator.
684    pub fn progress(mut self, visible: bool, label: impl Into<SharedString>) -> Self {
685        self.progress = Some((visible, label.into()));
686        self
687    }
688    /// Show progress, expose busy, and suppress activation while `loading` is true.
689    ///
690    /// This is controlled presentation: the caller owns the loading flag, label
691    /// children, and independent `disabled` flag. Update children to show a loading
692    /// label; no previous label or disabled state is saved or restored internally.
693    /// `loading(false, label)` retains progress configuration and its clock. An
694    /// explicitly disabled button stays disabled when loading ends. The label
695    /// names the indicator for assistive technology, not the button's visible text.
696    pub fn loading(mut self, loading: bool, label: impl Into<SharedString>) -> Self {
697        self.loading = Some((loading, label.into()));
698        self
699    }
700    /// Keep visual transition clocks across virtual unmounting. Forward real
701    /// offscreen controlled changes through the handle's `sync` method.
702    /// Interaction state and ripple remain mount-local.
703    pub fn transition_playback(mut self, playback: ButtonTransitionPlayback) -> Self {
704        self.transition_playback = Some(playback);
705        self
706    }
707
708    /// Retain progress lifecycle and opacity across virtual unmounting.
709    /// While unmounted, the owner must forward real inputs and reduced motion
710    /// to the handle. Hover, pressed, focus and ripple remain mount-local.
711    pub fn progress_playback(mut self, playback: ButtonProgressPlayback) -> Self {
712        self.progress_playback = Some(playback);
713        self
714    }
715    /// Keep the reference active appearance, for example while a menu is open.
716    /// This only changes presentation: it does not focus, select, activate, or
717    /// create a ripple. The caller owns this state independently of `disabled`.
718    /// The name leaves GPUI's `active(style)` customization available.
719    pub fn active_state(mut self, active: bool) -> Self {
720        self.active_state = active;
721        self
722    }
723    /// Render a static specimen in one forced interaction state, painting
724    /// exactly what the live state paints: its state-layer opacity, elevation,
725    /// pressed shape (with the press layer at rest instead of a pointer
726    /// ripple) and focus ring. The specimen leaves the tab order and ignores
727    /// live hover and press. Combine with `toggle`/`selected` for toggle
728    /// specimens. See [`InteractionPreview`].
729    pub fn preview_state(mut self, state: InteractionPreview) -> Self {
730        self.preview = Some(state);
731        self
732    }
733    /// Enable the pointer ripple (enabled by default).
734    pub fn ripple(mut self, enabled: bool) -> Self {
735        self.ripple = enabled;
736        self
737    }
738    pub fn accessibility_label(mut self, label: impl Into<SharedString>) -> Self {
739        self.base = self.base.accessibility_label(label);
740        self
741    }
742    pub fn on_click(
743        mut self,
744        callback: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
745    ) -> Self {
746        self.base = self.base.on_click(callback);
747        self
748    }
749    pub fn track_focus(mut self, handle: &FocusHandle) -> Self {
750        self.focus_handle = Some(handle.clone());
751        self.base = self.base.track_focus(handle);
752        self
753    }
754    pub fn tab_index(mut self, index: isize) -> Self {
755        self.base = self.base.tab_index(index);
756        self
757    }
758    pub fn tab_stop(mut self, stop: bool) -> Self {
759        self.base = self.base.tab_stop(stop);
760        self
761    }
762    pub fn focusable(mut self, focusable: bool) -> Self {
763        self.focusable = focusable;
764        self.base = self.base.focusable(focusable);
765        self
766    }
767}
768impl Styled for MaterialButton {
769    fn style(&mut self) -> &mut StyleRefinement {
770        &mut self.style
771    }
772}
773impl ParentElement for MaterialButton {
774    fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
775        self.children.extend(elements);
776    }
777}
778impl InteractiveElement for MaterialButton {
779    fn interactivity(&mut self) -> &mut Interactivity {
780        self.base.interactivity()
781    }
782}
783impl StatefulInteractiveElement for MaterialButton {
784    fn hover_listener_mode(mut self, mode: HoverListenerMode) -> Self {
785        self.caller_hover_mode = mode;
786        self
787    }
788    fn on_hover(mut self, listener: impl Fn(&bool, &mut Window, &mut App) + 'static) -> Self {
789        self.hover_callback = Some(std::rc::Rc::new(listener));
790        self
791    }
792}
793/// Transition-sampled visual channels for one render.
794struct SampledColors {
795    bg: Rgba,
796    fg: Rgba,
797    border: Rgba,
798    /// Disabled icon CSS is an explicit child color without a transition.
799    disabled_icon: Rgba,
800}
801
802/// Transient interaction state resolved from the passive press observer.
803struct Interaction {
804    focus: FocusHandle,
805    pressed: Entity<PressState>,
806    active: bool,
807    hovered: bool,
808    focused: bool,
809}
810
811/// State-layer opacity, elevation and shape sampled for one render.
812struct Effects {
813    opacity: f32,
814    shadows: shadow::ShadowPair,
815    radius: f32,
816}
817
818impl MaterialButton {
819    fn progress_visible(&self) -> bool {
820        self.progress.as_ref().is_some_and(|(visible, _)| *visible)
821    }
822
823    /// Keep the owner's no-op guard aligned with targets sampled by a
824    /// visible render, without injecting any resting interaction target.
825    fn record_transition_inputs(&self, cx: &App) {
826        if let Some(playback) = &self.transition_playback {
827            playback.record_inputs(
828                ButtonTransitionInputs {
829                    variant: self.variant,
830                    size: self.size,
831                    shape: self.shape,
832                    toggle: self.base.toggle,
833                    selected: self.base.selected,
834                    disabled: self.disabled,
835                    active: self.active_state,
836                    ripple: self.ripple,
837                },
838                cx,
839            );
840        }
841    }
842
843    /// One lifecycle implementation serves both caller-owned and keyed
844    /// playback. The keyed handle lives while the button renders each frame.
845    /// Returns the playback (when progress is configured) and its opacity.
846    fn sample_progress(
847        &self,
848        loading: bool,
849        window: &mut Window,
850        cx: &mut App,
851    ) -> (Option<ButtonProgressPlayback>, f32) {
852        if self.loading.is_none() && self.progress.is_none() {
853            return (None, 1.);
854        }
855        let playback = self.progress_playback.clone().unwrap_or_else(|| {
856            window
857                .use_keyed_state((self.id.clone(), "progress-playback"), cx, |_, _| {
858                    ButtonProgressPlayback::default()
859                })
860                .read(cx)
861                .clone()
862        });
863        let opacity = playback.sample_inputs(self.progress_visible(), loading, self.disabled, cx);
864        if matches!(
865            opacity.status,
866            gpui_base::MotionStatus::Running | gpui_base::MotionStatus::Delayed
867        ) {
868            window.request_animation_frame();
869        }
870        (Some(playback), opacity.value)
871    }
872
873    fn sample_colors(&self, window: &mut Window, cx: &mut App) -> SampledColors {
874        let (bg_target, fg_target, border_target) = self.color_targets();
875        let disabled_icon = fg_target.resolve();
876        let transitions = self.transition_playback.as_ref();
877        let bg = playback::token_sample(
878            transitions.map(|p| &p.background),
879            (self.id.clone(), "background-color"),
880            bg_target,
881            MotionToken::DefaultEffects,
882            window,
883            cx,
884        )
885        .resolve();
886        let fg = playback::token_sample(
887            transitions.map(|p| &p.foreground),
888            (self.id.clone(), "foreground-color"),
889            fg_target,
890            MotionToken::DefaultEffects,
891            window,
892            cx,
893        )
894        .resolve();
895        let border = playback::token_sample(
896            transitions.map(|p| &p.border),
897            (self.id.clone(), "border-color"),
898            border_target,
899            MotionToken::DefaultEffects,
900            window,
901            cx,
902        )
903        .resolve();
904        #[cfg(test)]
905        if let Some(observer) = &self.colors_observer {
906            observer.set((bg, fg, border));
907        }
908        SampledColors {
909            bg,
910            fg,
911            border,
912            disabled_icon,
913        }
914    }
915
916    fn resolve_interaction(&self, window: &mut Window, cx: &mut App) -> Interaction {
917        let focus = self.focus_handle.clone().unwrap_or_else(|| {
918            window
919                .use_keyed_state((self.id.clone(), "material-focus"), cx, |_, cx| {
920                    cx.focus_handle()
921                })
922                .read(cx)
923                .clone()
924        });
925        let pressed = press_state(self.id.clone(), &focus, window, cx);
926        if self.disabled || !self.focusable || !focus.is_focused(window) {
927            pressed.update(cx, |state, _| {
928                if self.disabled {
929                    state.pointer = false;
930                }
931                state.keyboard = false;
932            });
933        }
934        if let Some(preview) = self.preview {
935            return Interaction {
936                focus,
937                pressed,
938                active: self.active_state || preview.is_pressed(),
939                hovered: preview.is_hovered(),
940                focused: preview.is_focused(),
941            };
942        }
943        let press = pressed.read(cx);
944        let active = self.active_state || press.pointer || press.keyboard;
945        let hovered = press.hovered && !window.last_input_was_touch();
946        let focused =
947            self.focusable && focus.is_focused(window) && window.last_input_was_keyboard();
948        Interaction {
949            focus,
950            pressed,
951            active,
952            hovered,
953            focused,
954        }
955    }
956
957    fn sample_effects(
958        &self,
959        interaction: &Interaction,
960        window: &mut Window,
961        cx: &mut App,
962    ) -> Effects {
963        let Interaction {
964            active,
965            hovered,
966            focused,
967            ..
968        } = *interaction;
969        let transitions = self.transition_playback.as_ref();
970        let opacity = playback::opacity_sample(
971            transitions,
972            (self.id.clone(), "state-opacity"),
973            playback::state_opacity(
974                self.active_state,
975                self.disabled,
976                // A forced press shows the ripple at rest: the press layer
977                // covering the whole container.
978                self.ripple && self.preview.is_none(),
979                active,
980                focused,
981                hovered,
982            ),
983            playback::opacity_policy(),
984            window,
985            cx,
986        );
987        let level = match &self.palette {
988            Some(palette) => palette.shadow_level(self.disabled, hovered, active),
989            // Icon buttons never elevate.
990            None if matches!(self.form, Form::Icon(_)) => 0,
991            None => playback::shadow_level(
992                self.variant,
993                self.disabled,
994                self.base.toggle,
995                hovered,
996                active,
997            ),
998        };
999        let shadows = playback::token_sample(
1000            transitions.map(|p| &p.shadow),
1001            (self.id.clone(), "box-shadow"),
1002            shadow::ShadowPair::level(level),
1003            MotionToken::DefaultEffects,
1004            window,
1005            cx,
1006        );
1007        #[cfg(test)]
1008        if let Some(observer) = &self.effects_observer {
1009            *observer.borrow_mut() = (opacity, shadows.clone());
1010        }
1011        let target = match self.form {
1012            // Split segments morph their inner corners; outer ends stay round.
1013            Form::Segment(segment) => {
1014                let metrics = segment_metrics(self.size);
1015                if self.open && segment == Segment::Trailing {
1016                    self.size.geometry().height / 2.
1017                } else if active || hovered {
1018                    metrics.inner_active
1019                } else {
1020                    metrics.inner
1021                }
1022            }
1023            form if !form.sized_shape() => form.fixed_radius().unwrap_or(0.),
1024            _ if active => self.size.geometry().pressed,
1025            _ => resting_radius(
1026                self.size,
1027                self.shape,
1028                self.base.toggle && self.base.selected,
1029            ),
1030        };
1031        let radius = playback::token_sample(
1032            transitions.map(|p| &p.radius),
1033            (self.id.clone(), "pressed-radius"),
1034            target,
1035            MotionToken::FastSpatial,
1036            window,
1037            cx,
1038        );
1039        #[cfg(test)]
1040        if let Some(observer) = &self.radius_observer {
1041            observer.set(radius);
1042        }
1043        Effects {
1044            opacity,
1045            shadows,
1046            radius,
1047        }
1048    }
1049
1050    fn icon_slot_box(&self) -> Div {
1051        div()
1052            .flex()
1053            .items_center()
1054            .justify_center()
1055            .flex_shrink_0()
1056            .size(rem(self.form_geometry().icon))
1057    }
1058
1059    /// The leading/trailing slots: a visible progress indicator always takes
1060    /// the start slot and replaces the icon.
1061    fn slots(
1062        &mut self,
1063        loading: bool,
1064        progress: Option<(&ButtonProgressPlayback, f32)>,
1065        disabled_icon: Rgba,
1066        cx: &App,
1067    ) -> (Option<Div>, Option<Div>) {
1068        if let Some((playback, opacity)) = progress {
1069            let label = if loading {
1070                self.loading.as_ref()
1071            } else {
1072                self.progress.as_ref()
1073            }
1074            .expect("visible progress has caller configuration")
1075            .1
1076            .clone();
1077            // Frame requests stay limited to a painted, on-screen indicator.
1078            let indicator = super::progress::indeterminate_circular_progress_with_playback(
1079                (self.id.clone(), "button-progress-indicator"),
1080                label,
1081                size(rem(self.form_geometry().icon.min(24.)), rem(24.)),
1082                opacity,
1083                &playback.state.borrow().circular,
1084                cx,
1085            );
1086            let slot = self
1087                .icon_slot_box()
1088                .debug_selector(|| "material-button-progress-slot".into())
1089                .child(indicator);
1090            return (Some(slot), None);
1091        }
1092        let icon_size = self.form_geometry().icon;
1093        let slot = self.icon.take().map(|icon| {
1094            let icon = if self.disabled {
1095                icon.color(disabled_icon)
1096            } else {
1097                icon
1098            };
1099            self.icon_slot_box().child(icon.size(rem(icon_size)))
1100        });
1101        match self.icon_position {
1102            ButtonIconPosition::Leading => (slot, None),
1103            ButtonIconPosition::Trailing => (None, slot),
1104        }
1105    }
1106}
1107
1108/// Passive pointer/touch observer. A window exit must clear a held press even
1109/// after the pointer has left the button. Its non-occluding hitbox (the Base
1110/// border box captured in prepaint) only locates passive touch presses.
1111fn pointer_observer(
1112    pressed: &Entity<PressState>,
1113    touch_enabled: bool,
1114    target: hit_target::SharedTarget,
1115) -> impl IntoElement {
1116    let weak = pressed.downgrade();
1117    canvas(
1118        move |bounds, window, _| {
1119            target
1120                .get()
1121                .unwrap_or_else(|| hit_target::ResolvedTarget::new(bounds, None, window))
1122                .insert(window)
1123        },
1124        move |_, hitbox, window, _| {
1125            let moving_hitbox = hitbox.clone();
1126            let touch = weak.clone();
1127            window.observe_touch_event(move |event, window, cx| match event.phase {
1128                TouchPhase::Started
1129                    if touch_enabled && hitbox.is_hovered_at(event.position, window) =>
1130                {
1131                    let _ = touch.update(cx, |state, cx| {
1132                        state.pointer = true;
1133                        state.hovered = false;
1134                        state.outside_window = false;
1135                        cx.notify();
1136                    });
1137                }
1138                TouchPhase::Ended | TouchPhase::Cancelled => {
1139                    let _ = touch.update(cx, |state, cx| {
1140                        if state.pointer {
1141                            state.pointer = false;
1142                            state.hovered = false;
1143                            cx.notify();
1144                        }
1145                    });
1146                }
1147                _ => {}
1148            });
1149            let moving = weak.clone();
1150            window.on_mouse_event(move |event: &MouseMoveEvent, phase, window, cx| {
1151                if phase == DispatchPhase::Capture {
1152                    let _ = moving.update(cx, |state, cx| {
1153                        if state.pointer || state.outside_window {
1154                            state.outside_window = false;
1155                            let hovered = moving_hitbox.is_hovered_at(event.position, window)
1156                                && !window.last_input_was_touch();
1157                            if state.hovered != hovered {
1158                                state.hovered = hovered;
1159                                cx.notify();
1160                            }
1161                        }
1162                    });
1163                }
1164            });
1165            window.on_mouse_event(move |_: &MouseExitEvent, phase, _, cx| {
1166                if phase == DispatchPhase::Capture {
1167                    let _ = weak.update(cx, |state, cx| {
1168                        // A deferred native hover check can still see the last
1169                        // in-window mouse position after exit. Keep it suppressed
1170                        // until an actual pointer event enters the window again.
1171                        state.outside_window = true;
1172                        if state.pointer || state.hovered {
1173                            state.hovered = false;
1174                            state.pointer = false;
1175                            cx.notify();
1176                        }
1177                    });
1178                }
1179            });
1180        },
1181    )
1182    .absolute()
1183    .size_full()
1184}
1185
1186/// CSS :hover is independent of keyboard modality. Preserve the caller's
1187/// GPUI modality preference separately, sharing the one native listener.
1188fn bridge_hover(
1189    base: BaseControl,
1190    pressed: &Entity<PressState>,
1191    callback: Option<HoverCallback>,
1192    caller_mode: HoverListenerMode,
1193    window: &mut Window,
1194    cx: &mut App,
1195) -> BaseControl {
1196    if let Some(callback) = callback.clone() {
1197        let caller_hovered = pressed.read(cx).native_hovered
1198            && !pressed.read(cx).outside_window
1199            && (!window.last_input_was_keyboard()
1200                || matches!(caller_mode, HoverListenerMode::InputModalityIndependent));
1201        if caller_hovered != pressed.read(cx).caller_hovered {
1202            pressed.update(cx, |state, _| state.caller_hovered = caller_hovered);
1203            let weak = pressed.downgrade();
1204            window.defer(cx, move |window, cx| {
1205                if let Some(state) = weak.upgrade()
1206                    && state.read(cx).caller_hovered == caller_hovered
1207                {
1208                    callback(&caller_hovered, window, cx);
1209                }
1210            });
1211        }
1212    }
1213    let hover_state = pressed.clone();
1214    base.hover_listener_mode(HoverListenerMode::InputModalityIndependent)
1215        .on_hover(move |hovered, window, cx| {
1216            let caller_hovered = *hovered
1217                && !hover_state.read(cx).outside_window
1218                && (!window.last_input_was_keyboard()
1219                    || matches!(caller_mode, HoverListenerMode::InputModalityIndependent));
1220            let changed = hover_state.update(cx, |state, cx| {
1221                // GPUI reports hover=false during a held mouse down; the passive
1222                // move observer maintains pointer location for that interval.
1223                if !state.pointer {
1224                    let hovered =
1225                        *hovered && !state.outside_window && !window.last_input_was_touch();
1226                    if state.hovered != hovered {
1227                        state.hovered = hovered;
1228                        cx.notify();
1229                    }
1230                }
1231                state.native_hovered = *hovered;
1232                let changed = state.caller_hovered != caller_hovered;
1233                state.caller_hovered = caller_hovered;
1234                changed
1235            });
1236            if changed && let Some(callback) = &callback {
1237                callback(&caller_hovered, window, cx);
1238            }
1239        })
1240}
1241
1242/// The ripple's ink and visibility from the final (caller-refined) style.
1243/// Base's opacity scope ends after child.paint, so apply the same local
1244/// multiplier to the overlay (ancestor opacity remains in Window).
1245fn ripple_ink(painted: &Style, fg: Rgba) -> (Rgba, bool) {
1246    let mut ink = painted.text.color.map(Rgba::from).unwrap_or(fg);
1247    ink.a *= painted.opacity.unwrap_or(1.);
1248    let visible = painted.visibility == Visibility::Visible;
1249    if !visible {
1250        ink.a = 0.;
1251    }
1252    (ink, visible)
1253}
1254
1255impl RenderOnce for MaterialButton {
1256    fn render(mut self, window: &mut Window, cx: &mut App) -> impl IntoElement {
1257        use gpui_base::StyledExt;
1258        let loading = self.loading.as_ref().is_some_and(|(visible, _)| *visible);
1259        let show_progress = loading || self.progress_visible();
1260        self.disabled |= loading;
1261        if let Some(preview) = self.preview {
1262            self.disabled |= preview.is_disabled();
1263            self.base = self.base.tab_stop(false);
1264        }
1265        self.record_transition_inputs(cx);
1266        // `disabled()` already set Base; loading additionally disables it.
1267        self.base = self.base.disabled(self.disabled).busy(loading);
1268        let (progress_playback, progress_opacity) = self.sample_progress(loading, window, cx);
1269        #[cfg(test)]
1270        if let Some(observer) = &self.progress_opacity_observer {
1271            observer.set(progress_opacity);
1272        }
1273        let g = self.form_geometry();
1274        let ty = g.typography.scale();
1275        let colors = self.sample_colors(window, cx);
1276        let interaction = self.resolve_interaction(window, cx);
1277        if let Some(probe) = &self.press_probe
1278            && probe.get() != interaction.active
1279        {
1280            probe.set(interaction.active);
1281            window.request_animation_frame();
1282        }
1283        let effects = self.sample_effects(&interaction, window, cx);
1284        let (left_padding, right_padding) = g.padding;
1285        let progress = progress_playback
1286            .as_ref()
1287            .filter(|_| show_progress)
1288            .map(|playback| (playback, progress_opacity));
1289        let (leading, trailing) = self.slots(loading, progress, colors.disabled_icon, cx);
1290        let Interaction { focus, pressed, .. } = interaction;
1291        let expanded_target = matches!(self.size, ButtonSize::ExtraSmall | ButtonSize::Small)
1292            && matches!(self.form, Form::Label | Form::Icon(_) | Form::Segment(_));
1293        // Capture the actual Base border box during its prepaint. A positioned
1294        // child has padding-box geometry, so it cannot independently reconstruct
1295        // the target when callers supply asymmetric borders.
1296        let target_geometry = hit_target::SharedTarget::default();
1297        let target_writer = target_geometry.clone();
1298        let base = self
1299            .base
1300            .track_focus(&focus)
1301            .hitbox_extension(move |bounds, style, window| {
1302                let extension = expanded_target
1303                    .then(|| hit_target::extra_bounds(bounds, style, window))
1304                    .flatten();
1305                target_writer.set(Some(hit_target::ResolvedTarget::new(
1306                    bounds, extension, window,
1307                )));
1308                extension
1309            })
1310            .children(leading)
1311            .children(self.children)
1312            .children(trailing)
1313            .child(pointer_observer(
1314                &pressed,
1315                !self.disabled,
1316                target_geometry.clone(),
1317            ));
1318        let base = observe_press(base, &pressed, &focus, self.disabled, self.focusable);
1319        let base = bridge_hover(
1320            base,
1321            &pressed,
1322            self.hover_callback,
1323            self.caller_hover_mode,
1324            window,
1325            cx,
1326        );
1327        let SampledColors { bg, fg, border, .. } = colors;
1328        // A split segment: full outer ends, animated inner corners.
1329        let corners = match self.form {
1330            Form::Segment(segment) => {
1331                let rem_size = window.rem_size();
1332                let outer = rem(g.height / 2.).to_pixels(rem_size);
1333                let inner = rem(effects.radius).to_pixels(rem_size);
1334                let leading_side =
1335                    (segment == Segment::Leading) == (self.direction == ButtonDirection::Ltr);
1336                let (left, right) = if leading_side {
1337                    (outer, inner)
1338                } else {
1339                    (inner, outer)
1340                };
1341                Some(Corners {
1342                    top_left: left,
1343                    bottom_left: left,
1344                    top_right: right,
1345                    bottom_right: right,
1346                })
1347            }
1348            _ => None,
1349        };
1350        let mut base = base
1351            .flex()
1352            .flex_row()
1353            .when(self.direction == ButtonDirection::Rtl, |b| {
1354                b.flex_row_reverse()
1355            })
1356            .items_center()
1357            .justify_center()
1358            .flex_shrink_0()
1359            .min_w(rem(g.min_width))
1360            .when_some(g.width, |b, width| b.w(rem(width)))
1361            .h(rem(g.height))
1362            .pl(rem(left_padding))
1363            .pr(rem(right_padding))
1364            .gap(rem(g.gap))
1365            .map(|b| match corners {
1366                Some(corners) => b
1367                    .rounded_tl(corners.top_left)
1368                    .rounded_tr(corners.top_right)
1369                    .rounded_br(corners.bottom_right)
1370                    .rounded_bl(corners.bottom_left),
1371                None => b.rounded(rem(effects.radius)),
1372            })
1373            .bg(state::layer(bg, fg, effects.opacity))
1374            .text_color(fg)
1375            .font_family(ty.font_family)
1376            .text_size(ty.font_size)
1377            .line_height(ty.line_height)
1378            .font_weight(FontWeight(ty.font_weight as f32))
1379            .when(
1380                self.variant == ButtonVariant::Outlined && self.palette.is_none(),
1381                |b| b.border(rem(g.outline)).border_color(border),
1382            )
1383            .shadow(effects.shadows.into_shadows())
1384            .when(!self.disabled && self.preview.is_none(), |b| {
1385                b.cursor_pointer()
1386            })
1387            .refine_style(&self.style);
1388        let mut painted_style = Style::default();
1389        painted_style.refine(base.style());
1390        let (ripple_color, ripple_visible) = ripple_ink(&painted_style, fg);
1391        let base = ripple::ButtonRipple::new(
1392            base,
1393            self.id,
1394            self.ripple && self.preview.is_none(),
1395            !self.disabled && ripple_visible,
1396            painted_style.corner_radii,
1397            painted_style.border_widths,
1398            ripple_color,
1399            window,
1400            cx,
1401        )
1402        .hit_target(target_geometry);
1403        let forced_focus = self.preview == Some(InteractionPreview::Focused);
1404        let ring_enabled = !self.disabled && (self.focusable || forced_focus);
1405        match corners {
1406            Some(corners) => ButtonFocusRing::with_corners(base, focus, ring_enabled, corners),
1407            None => ButtonFocusRing::new(base, focus, ring_enabled, rem(effects.radius)),
1408        }
1409        .forced(forced_focus)
1410    }
1411}
1412/// Convenience constructor with caller-supplied content and activation.
1413pub fn material_button(id: impl Into<ElementId>, variant: ButtonVariant) -> MaterialButton {
1414    MaterialButton::new(id).variant(variant)
1415}
1416
1417#[cfg(test)]
1418mod tests {
1419    use super::{ButtonSize, ButtonVariant, MaterialButton, alpha, colors};
1420    use crate::tokens::state::layer;
1421    use crate::{color::ColorRole, theme::color};
1422    use gpui::{
1423        Context, IntoElement, Modifiers, Render, TestAppContext, VisualTestContext, Window, div,
1424        prelude::*, px, rgba,
1425    };
1426
1427    type PaintedColors = (gpui::Rgba, gpui::Rgba, gpui::Rgba);
1428
1429    struct EffectsHarness {
1430        disabled: bool,
1431        ripple: bool,
1432        sample: std::rc::Rc<std::cell::RefCell<(f32, super::shadow::ShadowPair)>>,
1433        focus: gpui::FocusHandle,
1434        hovers: Vec<bool>,
1435        hover_mode: gpui::HoverListenerMode,
1436        activations: usize,
1437    }
1438    impl Render for EffectsHarness {
1439        fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
1440            window.set_rem_size(px(14.));
1441            crate::motion::set_motion_scheme(crate::motion::MotionScheme::Expressive);
1442            let mut button = MaterialButton::new("effects")
1443                .ripple(self.ripple)
1444                .disabled(self.disabled)
1445                .track_focus(&self.focus)
1446                .debug_selector(|| "effects".into())
1447                .child("Effects")
1448                .on_click(cx.listener(|this, _, _, _| this.activations += 1));
1449            button = StatefulInteractiveElement::on_hover(
1450                button,
1451                cx.listener(|this, hovered, _, _| this.hovers.push(*hovered)),
1452            );
1453            button = StatefulInteractiveElement::hover_listener_mode(button, self.hover_mode);
1454            button.effects_observer = Some(self.sample.clone());
1455            div().p(px(30.)).child(button)
1456        }
1457    }
1458    #[gpui::test]
1459    fn default_ripple_suppresses_pressed_layer_but_retains_hover_and_keyboard_focus(
1460        cx: &mut TestAppContext,
1461    ) {
1462        use super::shadow::ShadowPair;
1463        use gpui::{MouseButton, MouseMoveEvent};
1464        use std::{cell::RefCell, rc::Rc, time::Duration};
1465        cx.update(gpui_base::init);
1466        assert!(MaterialButton::new("default").ripple);
1467        let sample = Rc::new(RefCell::new((0., ShadowPair::level(0))));
1468        let (_, cx) = cx.add_window_view({
1469            let sample = sample.clone();
1470            move |_, cx| EffectsHarness {
1471                disabled: false,
1472                ripple: true,
1473                sample,
1474                focus: cx.focus_handle(),
1475                hovers: vec![],
1476                hover_mode: gpui::HoverListenerMode::InputModalityAware,
1477                activations: 0,
1478            }
1479        });
1480        draw(cx);
1481        let center = cx.debug_bounds("effects").unwrap().center();
1482        cx.simulate_event(MouseMoveEvent {
1483            position: center,
1484            pressed_button: None,
1485            modifiers: Modifiers::default(),
1486        });
1487        cx.run_until_parked();
1488        draw(cx);
1489        cx.executor().advance_clock(Duration::from_millis(100));
1490        draw(cx);
1491        assert_eq!(sample.borrow().0, 0.08);
1492        cx.simulate_mouse_down(center, MouseButton::Left, Modifiers::default());
1493        draw(cx);
1494        cx.executor().advance_clock(Duration::from_millis(100));
1495        draw(cx);
1496        assert_eq!(
1497            sample.borrow().0,
1498            0.08,
1499            "a pointer wave coexists with hover, without adding the pressed state layer"
1500        );
1501        cx.simulate_mouse_up(center, MouseButton::Left, Modifiers::default());
1502        draw(cx);
1503        key(cx, "space", true);
1504        draw(cx);
1505        cx.executor().advance_clock(Duration::from_millis(100));
1506        draw(cx);
1507        assert_eq!(
1508            sample.borrow().0,
1509            0.1,
1510            "keyboard focus keeps its separate state layer"
1511        );
1512        key(cx, "space", false);
1513    }
1514    #[gpui::test]
1515    fn state_opacity_and_shadow_have_independent_event_clocks(cx: &mut TestAppContext) {
1516        use super::shadow::ShadowPair;
1517        use gpui::{MouseButton, MouseMoveEvent, point};
1518        use gpui_base::Interpolate;
1519        use std::{cell::RefCell, rc::Rc, time::Duration};
1520        cx.update(gpui_base::init);
1521        let sample = Rc::new(RefCell::new((0., ShadowPair::level(0))));
1522        let (view, cx) = cx.add_window_view({
1523            let sample = sample.clone();
1524            move |_, cx| EffectsHarness {
1525                ripple: false,
1526                disabled: false,
1527                sample,
1528                focus: cx.focus_handle(),
1529                hovers: vec![],
1530                hover_mode: gpui::HoverListenerMode::InputModalityAware,
1531                activations: 0,
1532            }
1533        });
1534        draw(cx);
1535        let center = cx.debug_bounds("effects").unwrap().center();
1536        cx.simulate_event(MouseMoveEvent {
1537            position: center,
1538            pressed_button: None,
1539            modifiers: Modifiers::default(),
1540        });
1541        draw(cx);
1542        cx.run_until_parked();
1543        draw(cx);
1544        assert_eq!(sample.borrow().0, 0.);
1545        cx.executor().advance_clock(Duration::from_millis(50));
1546        draw(cx);
1547        // Independently sampled from the CSS reference in Chromium.
1548        assert!((sample.borrow().0 - 0.0702267).abs() < 0.00001);
1549        cx.executor().advance_clock(Duration::from_millis(50));
1550        draw(cx);
1551        assert_eq!(sample.borrow().0, 0.08);
1552        let expected = ShadowPair::level(0).interpolate(
1553            &ShadowPair::level(1),
1554            crate::motion::MotionScheme::Expressive
1555                .resolve(crate::motion::MotionToken::DefaultEffects)
1556                .position(0.1),
1557        );
1558        assert_eq!(sample.borrow().1, expected);
1559        assert_ne!(sample.borrow().1, ShadowPair::level(1));
1560        cx.executor().advance_clock(Duration::from_millis(150));
1561        draw(cx);
1562        assert_eq!(sample.borrow().1, ShadowPair::level(1));
1563        // A stationary pointer stays hovered across disable/re-enable, even
1564        // though the native hover listener need not emit another true event.
1565        view.update(cx, |view, cx| {
1566            view.disabled = true;
1567            cx.notify();
1568        });
1569        draw(cx);
1570        cx.executor().advance_clock(Duration::from_millis(250));
1571        draw(cx);
1572        assert_eq!(*sample.borrow(), (0., ShadowPair::level(0)));
1573        view.update(cx, |view, cx| {
1574            view.disabled = false;
1575            cx.notify();
1576        });
1577        draw(cx);
1578        cx.executor().advance_clock(Duration::from_millis(250));
1579        draw(cx);
1580        assert_eq!(*sample.borrow(), (0.08, ShadowPair::level(1)));
1581        cx.update(|_, cx| cx.set_reduce_motion(true));
1582        cx.simulate_mouse_down(center, MouseButton::Left, Modifiers::default());
1583        draw(cx);
1584        cx.simulate_mouse_move(center, MouseButton::Left, Modifiers::default());
1585        draw(cx);
1586        cx.executor().advance_clock(Duration::from_millis(50));
1587        draw(cx);
1588        assert!(
1589            (sample.borrow().0 - 0.0975567).abs() < 0.00001,
1590            "reduced motion preserves CSS state fade"
1591        );
1592        assert_ne!(
1593            sample.borrow().1,
1594            ShadowPair::level(0),
1595            "reduced motion preserves CSS shadow transition"
1596        );
1597        cx.executor().advance_clock(Duration::from_millis(50));
1598        draw(cx);
1599        assert_eq!(sample.borrow().0, 0.10);
1600        cx.simulate_mouse_up(center, MouseButton::Left, Modifiers::default());
1601        draw(cx);
1602        cx.executor().advance_clock(Duration::from_millis(250));
1603        draw(cx);
1604        assert_eq!(
1605            sample.borrow().0,
1606            0.08,
1607            "held move's GPUI hover=false must not lose hover on release"
1608        );
1609        assert_eq!(sample.borrow().1, ShadowPair::level(1));
1610        cx.update(|_, cx| {
1611            assert!(!view.read(cx).hovers.is_empty());
1612            assert_eq!(view.read(cx).activations, 1);
1613        });
1614        cx.simulate_mouse_down(center, MouseButton::Left, Modifiers::default());
1615        draw(cx);
1616        cx.simulate_mouse_up(
1617            point(px(900.), px(800.)),
1618            MouseButton::Left,
1619            Modifiers::default(),
1620        );
1621        draw(cx);
1622        cx.executor().advance_clock(Duration::from_millis(250));
1623        draw(cx);
1624        assert_eq!(*sample.borrow(), (0., ShadowPair::level(0)));
1625        // Pointer focus becomes visible only when keyboard modality takes over.
1626        key(cx, "a", true);
1627        draw(cx);
1628        cx.executor().advance_clock(Duration::from_millis(100));
1629        draw(cx);
1630        assert_eq!(sample.borrow().0, 0.10);
1631        view.update(cx, |view, cx| {
1632            view.disabled = true;
1633            cx.notify();
1634        });
1635        draw(cx);
1636        cx.executor().advance_clock(Duration::from_millis(250));
1637        draw(cx);
1638        assert_eq!(*sample.borrow(), (0., ShadowPair::level(0)));
1639        view.update(cx, |view, cx| {
1640            view.disabled = false;
1641            cx.notify();
1642        });
1643        cx.update(|window, cx| {
1644            window.blur(cx);
1645            window.activate_window();
1646        });
1647        cx.run_until_parked();
1648        cx.simulate_event(MouseMoveEvent {
1649            position: center,
1650            pressed_button: None,
1651            modifiers: Modifiers::default(),
1652        });
1653        draw(cx);
1654        cx.run_until_parked();
1655        draw(cx);
1656        cx.executor().advance_clock(Duration::from_millis(250));
1657        draw(cx);
1658        assert_eq!(*sample.borrow(), (0.08, ShadowPair::level(1)));
1659        cx.simulate_mouse_down(center, MouseButton::Left, Modifiers::default());
1660        draw(cx);
1661        cx.executor().advance_clock(Duration::from_millis(100));
1662        draw(cx);
1663        assert_eq!(sample.borrow().0, 0.10);
1664        cx.deactivate_window();
1665        draw(cx);
1666        cx.executor().advance_clock(Duration::from_millis(250));
1667        for _ in 0..3 {
1668            cx.run_until_parked();
1669            draw(cx);
1670            assert_eq!(
1671                *sample.borrow(),
1672                (0., ShadowPair::level(0)),
1673                "deactivation must not revive hover from the cached pointer position"
1674            );
1675            cx.update(|_, cx| assert_eq!(view.read(cx).hovers.last(), Some(&false)));
1676        }
1677        cx.simulate_mouse_up(
1678            point(px(900.), px(800.)),
1679            MouseButton::Left,
1680            Modifiers::default(),
1681        );
1682        cx.update(|window, _| window.activate_window());
1683        cx.run_until_parked();
1684        cx.simulate_event(MouseMoveEvent {
1685            position: center,
1686            pressed_button: None,
1687            modifiers: Modifiers::default(),
1688        });
1689        draw(cx);
1690        cx.run_until_parked();
1691        draw(cx);
1692        cx.executor().advance_clock(Duration::from_millis(250));
1693        draw(cx);
1694        assert_eq!(
1695            *sample.borrow(),
1696            (0.08, ShadowPair::level(1)),
1697            "a real pointer move after activation restores hover"
1698        );
1699        cx.update(|_, cx| assert_eq!(view.read(cx).hovers.last(), Some(&true)));
1700    }
1701
1702    #[gpui::test]
1703    fn keyboard_hover_modes_preserve_caller_events_and_material_elevation(cx: &mut TestAppContext) {
1704        use super::shadow::ShadowPair;
1705        use gpui::{HoverListenerMode, MouseMoveEvent, point};
1706        use std::{cell::RefCell, rc::Rc, time::Duration};
1707        cx.update(gpui_base::init);
1708        let sample = Rc::new(RefCell::new((0., ShadowPair::level(0))));
1709        let (view, cx) = cx.add_window_view({
1710            let sample = sample.clone();
1711            move |_, cx| EffectsHarness {
1712                ripple: false,
1713                disabled: false,
1714                sample,
1715                focus: cx.focus_handle(),
1716                hovers: vec![],
1717                hover_mode: HoverListenerMode::InputModalityAware,
1718                activations: 0,
1719            }
1720        });
1721        draw(cx);
1722        let center = cx.debug_bounds("effects").unwrap().center();
1723        cx.simulate_event(MouseMoveEvent {
1724            position: center,
1725            pressed_button: None,
1726            modifiers: Modifiers::default(),
1727        });
1728        draw(cx);
1729        cx.run_until_parked();
1730        draw(cx);
1731        cx.executor().advance_clock(Duration::from_millis(250));
1732        draw(cx);
1733        assert_eq!(*sample.borrow(), (0.08, ShadowPair::level(1)));
1734        cx.update(|window, cx| {
1735            let focus = view.read(cx).focus.clone();
1736            window.focus(&focus, cx);
1737        });
1738        key(cx, "a", true);
1739        draw(cx);
1740        cx.run_until_parked();
1741        draw(cx);
1742        cx.executor().advance_clock(Duration::from_millis(100));
1743        draw(cx);
1744        assert_eq!(*sample.borrow(), (0.10, ShadowPair::level(1)));
1745        cx.update(|_, cx| assert_eq!(view.read(cx).hovers, [true, false]));
1746        view.update(cx, |view, cx| {
1747            view.hover_mode = HoverListenerMode::InputModalityIndependent;
1748            cx.notify();
1749        });
1750        draw(cx);
1751        cx.run_until_parked();
1752        draw(cx);
1753        cx.update(|_, cx| assert_eq!(view.read(cx).hovers, [true, false, true]));
1754        key(cx, "b", true);
1755        draw(cx);
1756        cx.run_until_parked();
1757        cx.update(|_, cx| assert_eq!(view.read(cx).hovers, [true, false, true]));
1758        cx.simulate_event(gpui::TouchEvent {
1759            id: gpui::TouchId(1),
1760            phase: gpui::TouchPhase::Started,
1761            position: center,
1762            ..Default::default()
1763        });
1764        draw(cx);
1765        cx.executor().advance_clock(Duration::from_millis(250));
1766        draw(cx);
1767        assert_eq!(
1768            *sample.borrow(),
1769            (0.1, ShadowPair::level(0)),
1770            "without ripple, held touch uses its pressed layer and drops hover elevation"
1771        );
1772        cx.simulate_event(gpui::TouchEvent {
1773            id: gpui::TouchId(1),
1774            phase: gpui::TouchPhase::Ended,
1775            position: center,
1776            ..Default::default()
1777        });
1778        cx.update(|window, cx| window.blur(cx));
1779        cx.simulate_event(gpui::MouseExitEvent {
1780            position: point(px(1200.), px(1000.)),
1781            pressed_button: None,
1782            modifiers: Modifiers::default(),
1783        });
1784        draw(cx);
1785        cx.executor().advance_clock(Duration::from_millis(250));
1786        draw(cx);
1787        assert_eq!(*sample.borrow(), (0., ShadowPair::level(0)));
1788        cx.run_until_parked();
1789        draw(cx);
1790        cx.run_until_parked();
1791        draw(cx);
1792        assert_eq!(*sample.borrow(), (0., ShadowPair::level(0)));
1793        cx.update(|_, cx| assert_eq!(view.read(cx).hovers, [true, false, true, false]));
1794    }
1795
1796    struct ColorsHarness {
1797        variant: ButtonVariant,
1798        disabled: bool,
1799        palette: crate::color::PaletteId,
1800        sample: std::rc::Rc<std::cell::Cell<PaintedColors>>,
1801    }
1802
1803    impl Render for ColorsHarness {
1804        fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
1805            crate::motion::set_motion_scheme(crate::motion::MotionScheme::Expressive);
1806            crate::theme::apply_scheme(
1807                crate::color::Scheme::from_palette(self.palette),
1808                window,
1809                cx,
1810            );
1811            let mut button = MaterialButton::new("colors-button")
1812                .variant(self.variant)
1813                .disabled(self.disabled)
1814                .child("Colors");
1815            button.colors_observer = Some(self.sample.clone());
1816            button
1817        }
1818    }
1819
1820    fn assert_colors_close(actual: PaintedColors, expected: PaintedColors) {
1821        for (actual, expected) in [
1822            (actual.0, expected.0),
1823            (actual.1, expected.1),
1824            (actual.2, expected.2),
1825        ] {
1826            for (actual, expected) in [
1827                (actual.r, expected.r),
1828                (actual.g, expected.g),
1829                (actual.b, expected.b),
1830                (actual.a, expected.a),
1831            ] {
1832                assert!(
1833                    (actual - expected).abs() < 0.00001,
1834                    "{actual} != {expected}"
1835                );
1836            }
1837        }
1838    }
1839
1840    #[gpui::test]
1841    fn rendered_button_colors_keep_state_timing_across_theme_changes(cx: &mut TestAppContext) {
1842        use crate::color::PaletteId;
1843        use std::{cell::Cell, rc::Rc, time::Duration};
1844        cx.update(gpui_base::init);
1845        let sample = Rc::new(Cell::new((rgba(0), rgba(0), rgba(0))));
1846        let (view, cx) = cx.add_window_view({
1847            let sample = sample.clone();
1848            move |_, _| ColorsHarness {
1849                variant: ButtonVariant::Filled,
1850                disabled: false,
1851                palette: PaletteId::Paper,
1852                sample,
1853            }
1854        });
1855        cx.update(|window, cx| {
1856            cx.set_reduce_motion(true);
1857            window.refresh();
1858            window.draw(cx).clear(cx);
1859            cx.set_reduce_motion(false);
1860        });
1861        let initial = sample.get();
1862        cx.update(|window, cx| {
1863            view.update(cx, |view, _| view.variant = ButtonVariant::Tonal);
1864            window.refresh();
1865            window.draw(cx).clear(cx);
1866        });
1867        assert_colors_close(sample.get(), initial);
1868        cx.executor().advance_clock(Duration::from_millis(125));
1869        draw(cx);
1870        let filled = colors(ButtonVariant::Filled, false);
1871        let tonal = colors(ButtonVariant::Tonal, false);
1872        let progress = crate::motion::MotionScheme::Expressive
1873            .resolve(crate::motion::MotionToken::DefaultEffects)
1874            .position(0.125);
1875        assert_colors_close(
1876            sample.get(),
1877            (
1878                crate::theme::blend(filled.0, tonal.0, progress),
1879                crate::theme::blend(filled.1, tonal.1, progress),
1880                tonal.2,
1881            ),
1882        );
1883        let midpoint = sample.get();
1884        cx.update(|window, cx| {
1885            view.update(cx, |view, _| view.palette = PaletteId::Ink);
1886            window.refresh();
1887            window.draw(cx).clear(cx);
1888        });
1889        assert_colors_close(sample.get(), midpoint);
1890        cx.executor().advance_clock(Duration::from_millis(125));
1891        draw(cx);
1892        assert_ne!(
1893            crate::theme::scheme(),
1894            crate::color::Scheme::from_palette(PaletteId::Paper)
1895        );
1896        assert_colors_close(sample.get(), colors(ButtonVariant::Tonal, false));
1897
1898        cx.update(|window, cx| {
1899            view.update(cx, |view, _| view.disabled = true);
1900            window.refresh();
1901            window.draw(cx).clear(cx);
1902        });
1903        cx.executor().advance_clock(Duration::from_millis(50));
1904        draw(cx);
1905        let disabling = sample.get();
1906        assert!(disabling.0.a > 0.10 && disabling.0.a < 1.);
1907        assert!(disabling.1.a > 0.38 && disabling.1.a < 1.);
1908        cx.update(|window, cx| {
1909            view.update(cx, |view, _| view.disabled = false);
1910            window.refresh();
1911            window.draw(cx).clear(cx);
1912        });
1913        assert_colors_close(sample.get(), disabling);
1914        cx.executor().advance_clock(Duration::from_millis(250));
1915        draw(cx);
1916        assert_colors_close(sample.get(), colors(ButtonVariant::Tonal, false));
1917        cx.update(|window, cx| {
1918            cx.set_reduce_motion(true);
1919            view.update(cx, |view, _| view.disabled = true);
1920            window.refresh();
1921            window.draw(cx).clear(cx);
1922        });
1923        assert_colors_close(sample.get(), colors(ButtonVariant::Tonal, false));
1924        cx.executor().advance_clock(Duration::from_millis(250));
1925        draw(cx);
1926        assert_colors_close(sample.get(), colors(ButtonVariant::Tonal, true));
1927    }
1928
1929    #[test]
1930    fn transparent_state_layers_preserve_content_rgb_and_opacity() {
1931        let content = rgba(0x6750a4ff);
1932        let hovered = layer(rgba(0), content, 0.08);
1933        for (a, b) in [
1934            (hovered.r, content.r),
1935            (hovered.g, content.g),
1936            (hovered.b, content.b),
1937        ] {
1938            assert!((a - b).abs() < 1e-6);
1939        }
1940        assert_eq!(hovered.a, 0.08);
1941        let filled = layer(rgba(0xffffffff), content, 0.10);
1942        assert_eq!(filled.a, 1.);
1943        assert!((filled.r - (1. * 0.9 + content.r * 0.1)).abs() < 0.00001);
1944    }
1945    fn figma_buttons() -> serde_json::Value {
1946        serde_json::from_str(include_str!("../../tests/fixtures/figma-button.json")).unwrap()
1947    }
1948
1949    /// A kit color variable name as the role (and opacity) it paints.
1950    pub(super) fn figma_role(variable: &str) -> (ColorRole, f32) {
1951        match variable {
1952            "Schemes/On Surface" => (ColorRole::ON_SURFACE, 1.),
1953            "Schemes/Outline Variant" => (ColorRole::OUTLINE_VARIANT, 1.),
1954            "State Layers/On Surface/Opacity-10" => (ColorRole::ON_SURFACE, 0.10),
1955            other => panic!("unexpected kit variable {other}"),
1956        }
1957    }
1958
1959    #[test]
1960    fn size_geometry_matches_the_figma_kit_buttons() {
1961        let fixture = figma_buttons();
1962        let px = |value: &serde_json::Value| value.as_f64().unwrap() as f32;
1963        for (size, name) in ButtonSize::ALL
1964            .into_iter()
1965            .zip(["XSmall", "Small", "Medium", "Large", "XLarge"])
1966        {
1967            let reference = &fixture["sizes"][name];
1968            let g = size.geometry();
1969            assert_eq!(g.height, px(&reference["container_height"]), "{name}");
1970            assert_eq!(size.icon_size(), px(&reference["icon_size"]), "{name}");
1971            assert_eq!(g.outline, px(&reference["outline_width"]), "{name}");
1972            assert_eq!(g.square, px(&reference["shape_square"]), "{name}");
1973            assert_eq!(g.pressed, px(&reference["shape_pressed"]), "{name}");
1974            assert_eq!(g.gap, px(&reference["icon_label_gap"]), "{name}");
1975            let padding = reference["padding"].as_array().unwrap();
1976            // [top, right, bottom, left]; the label's line box fills the rest.
1977            assert_eq!(padding[1], padding[3], "{name}");
1978            assert_eq!(
1979                super::horizontal_padding(size),
1980                (px(&padding[3]), px(&padding[1])),
1981                "{name}"
1982            );
1983            let style = reference["label_style"].as_str().unwrap();
1984            let role = g.typography.name();
1985            assert_eq!(
1986                style,
1987                format!("M3/{}", role.replacen('-', "/", 1)),
1988                "{name} label style"
1989            );
1990        }
1991    }
1992
1993    #[test]
1994    fn disabled_buttons_match_the_figma_kit() {
1995        let fixture = figma_buttons();
1996        let disabled = &fixture["disabled"];
1997        for variant in ButtonVariant::ALL {
1998            let (bg, fg, border) = colors(variant, true);
1999            let reference = &disabled[match variant {
2000                ButtonVariant::Filled => "filled",
2001                ButtonVariant::Elevated => "elevated",
2002                ButtonVariant::Tonal => "tonal",
2003                ButtonVariant::Outlined => "outlined",
2004                ButtonVariant::Text => "text",
2005            }];
2006            let (container, opacity) =
2007                figma_role(reference["container"]["variable"].as_str().unwrap());
2008            assert_eq!(
2009                bg,
2010                alpha(color(container), opacity),
2011                "{variant:?} container"
2012            );
2013            let (content, _) = figma_role(reference["content"]["variable"].as_str().unwrap());
2014            let content_opacity = reference["content"]["opacity"].as_f64().unwrap() as f32;
2015            assert_eq!(
2016                reference["content"]["icon_opacity"], reference["content"]["opacity"],
2017                "{variant:?}"
2018            );
2019            assert_eq!(fg, alpha(color(content), content_opacity), "{variant:?}");
2020            match reference["outline"].as_object() {
2021                Some(outline) => {
2022                    let (role, opacity) = figma_role(outline["variable"].as_str().unwrap());
2023                    assert_eq!(border, alpha(color(role), opacity), "{variant:?} outline");
2024                }
2025                None => assert_eq!(border.a, 0., "{variant:?} outline"),
2026            }
2027        }
2028        assert_eq!(
2029            colors(ButtonVariant::Text, false).1,
2030            color(ColorRole::PRIMARY)
2031        );
2032        assert_eq!(
2033            colors(ButtonVariant::Outlined, false).1,
2034            color(ColorRole::ON_SURFACE_VARIANT)
2035        );
2036        assert_eq!(
2037            colors(ButtonVariant::Outlined, false).2,
2038            color(ColorRole::OUTLINE_VARIANT)
2039        );
2040    }
2041
2042    struct Harness {
2043        disabled: bool,
2044        activations: usize,
2045    }
2046    impl Render for Harness {
2047        fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
2048            window.set_rem_size(px(14.));
2049            div().flex().flex_col().items_start().gap(px(10.)).children(
2050                ButtonSize::ALL.into_iter().map(|size| {
2051                    MaterialButton::new(size.name())
2052                        .button_size(size)
2053                        .debug_selector(move || size.name().into())
2054                        .disabled(self.disabled)
2055                        .child("Go")
2056                        .on_click(cx.listener(|this, _, _, cx| {
2057                            this.activations += 1;
2058                            cx.notify();
2059                        }))
2060                }),
2061            )
2062        }
2063    }
2064    fn draw(cx: &mut VisualTestContext) {
2065        cx.update(|w, cx| {
2066            w.refresh();
2067            w.draw(cx).clear(cx);
2068        });
2069    }
2070    #[gpui::test]
2071    fn all_sizes_keep_visual_height_and_base_keyboard_disabled_behavior(cx: &mut TestAppContext) {
2072        cx.update(|cx| {
2073            gpui_base::init(cx);
2074            cx.set_reduce_motion(true);
2075        });
2076        let (view, cx) = cx.add_window_view(|_, _| Harness {
2077            disabled: false,
2078            activations: 0,
2079        });
2080        draw(cx);
2081        for (name, height) in [
2082            ("xs", 32.),
2083            ("s", 40.),
2084            ("m", 56.),
2085            ("l", 96.),
2086            ("xl", 136.),
2087        ] {
2088            let bounds = cx.debug_bounds(name).unwrap();
2089            assert_eq!(bounds.size.height, px(height));
2090            assert!(bounds.size.width >= px(58.));
2091        }
2092        let location = cx.debug_bounds("s").unwrap().center();
2093        cx.simulate_click(location, Modifiers::default());
2094        draw(cx);
2095        for key in ["enter", "space"] {
2096            let keystroke = cx.update(|_, cx| {
2097                gpui::Keystroke::parse_with_os(key, cx.platform_info().os).unwrap()
2098            });
2099            cx.simulate_event(gpui::KeyDownEvent {
2100                keystroke: keystroke.clone(),
2101                is_held: false,
2102                prefer_character_input: false,
2103            });
2104            cx.simulate_event(gpui::KeyUpEvent { keystroke });
2105        }
2106        draw(cx);
2107        cx.update(|_, cx| assert_eq!(view.read(cx).activations, 3));
2108        view.update(cx, |this, cx| {
2109            this.disabled = true;
2110            cx.notify();
2111        });
2112        draw(cx);
2113        cx.simulate_click(location, Modifiers::default());
2114        draw(cx);
2115        for key in ["enter", "space"] {
2116            let keystroke = cx.update(|_, cx| {
2117                gpui::Keystroke::parse_with_os(key, cx.platform_info().os).unwrap()
2118            });
2119            cx.simulate_event(gpui::KeyDownEvent {
2120                keystroke: keystroke.clone(),
2121                is_held: false,
2122                prefer_character_input: false,
2123            });
2124            cx.simulate_event(gpui::KeyUpEvent { keystroke });
2125        }
2126        draw(cx);
2127        cx.update(|_, cx| assert_eq!(view.read(cx).activations, 3));
2128    }
2129    struct MotionHarness {
2130        disabled: bool,
2131        shown: bool,
2132        sample: std::rc::Rc<std::cell::Cell<f32>>,
2133        focus: gpui::FocusHandle,
2134        other: gpui::FocusHandle,
2135        activations: usize,
2136    }
2137    impl Render for MotionHarness {
2138        fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
2139            window.set_rem_size(px(14.));
2140            crate::motion::set_motion_scheme(crate::motion::MotionScheme::Expressive);
2141            let mut button = MaterialButton::new("motion-button")
2142                .track_focus(&self.focus)
2143                .button_size(ButtonSize::Large)
2144                .disabled(self.disabled)
2145                .child("Hold")
2146                .debug_selector(|| "motion-button".into())
2147                .on_click(cx.listener(|this, _, _, _| this.activations += 1));
2148            button.radius_observer = Some(self.sample.clone());
2149            div()
2150                .flex()
2151                .gap(px(30.))
2152                .children(self.shown.then_some(button))
2153                .child(
2154                    gpui_base::Button::new("other")
2155                        .track_focus(&self.other)
2156                        .child("Other"),
2157                )
2158        }
2159    }
2160    fn key(cx: &mut VisualTestContext, name: &str, down: bool) {
2161        let keystroke =
2162            cx.update(|_, cx| gpui::Keystroke::parse_with_os(name, cx.platform_info().os).unwrap());
2163        if down {
2164            cx.simulate_event(gpui::KeyDownEvent {
2165                keystroke,
2166                is_held: false,
2167                prefer_character_input: false,
2168            });
2169        } else {
2170            cx.simulate_event(gpui::KeyUpEvent { keystroke });
2171        }
2172    }
2173    #[gpui::test]
2174    fn raw_touch_animates_held_shape_and_cancellation_preserves_base_activation(
2175        cx: &mut TestAppContext,
2176    ) {
2177        use gpui::{TouchEvent, TouchId, TouchPhase};
2178        use std::{cell::Cell, rc::Rc, time::Duration};
2179        cx.update(gpui_base::init);
2180        let sample = Rc::new(Cell::new(0.));
2181        let (view, cx) = cx.add_window_view({
2182            let sample = sample.clone();
2183            move |_, cx| MotionHarness {
2184                disabled: false,
2185                shown: true,
2186                sample,
2187                focus: cx.focus_handle(),
2188                other: cx.focus_handle(),
2189                activations: 0,
2190            }
2191        });
2192        draw(cx);
2193        let center = cx.debug_bounds("motion-button").unwrap().center();
2194        cx.simulate_event(TouchEvent {
2195            id: TouchId(1),
2196            phase: TouchPhase::Started,
2197            position: center,
2198            ..Default::default()
2199        });
2200        draw(cx);
2201        assert_eq!(sample.get(), 48.);
2202        cx.executor().advance_clock(Duration::from_millis(100));
2203        draw(cx);
2204        let expected = 48.
2205            + (16. - 48.)
2206                * crate::motion::MotionScheme::Expressive
2207                    .resolve(crate::motion::MotionToken::FastSpatial)
2208                    .position(0.1);
2209        assert!((sample.get() - expected).abs() < 0.001);
2210        cx.update(|_, cx| assert_eq!(view.read(cx).activations, 0));
2211        cx.simulate_event(TouchEvent {
2212            id: TouchId(1),
2213            phase: TouchPhase::Cancelled,
2214            position: center,
2215            ..Default::default()
2216        });
2217        draw(cx);
2218        cx.executor().advance_clock(Duration::from_millis(500));
2219        draw(cx);
2220        assert_eq!(sample.get(), 48.);
2221        cx.update(|_, cx| {
2222            assert_eq!(view.read(cx).activations, 0);
2223            cx.set_reduce_motion(true);
2224        });
2225        cx.simulate_event(TouchEvent {
2226            id: TouchId(2),
2227            phase: TouchPhase::Started,
2228            position: center,
2229            ..Default::default()
2230        });
2231        draw(cx);
2232        assert_eq!(sample.get(), 16.);
2233        cx.simulate_event(TouchEvent {
2234            id: TouchId(2),
2235            phase: TouchPhase::Ended,
2236            position: center,
2237            ..Default::default()
2238        });
2239        draw(cx);
2240        assert_eq!(sample.get(), 48.);
2241        cx.update(|_, cx| assert_eq!(view.read(cx).activations, 1));
2242    }
2243    #[gpui::test]
2244    fn press_radius_matches_reference_and_retargets_without_jumps(cx: &mut TestAppContext) {
2245        use gpui::{MouseButton, point};
2246        use std::{cell::Cell, rc::Rc, time::Duration};
2247        cx.update(gpui_base::init);
2248        let sample = Rc::new(Cell::new(0.));
2249        let (_, cx) = cx.add_window_view({
2250            let sample = sample.clone();
2251            move |_, cx| MotionHarness {
2252                disabled: false,
2253                shown: true,
2254                sample,
2255                focus: cx.focus_handle(),
2256                other: cx.focus_handle(),
2257                activations: 0,
2258            }
2259        });
2260        draw(cx);
2261        let center = cx.debug_bounds("motion-button").unwrap().center();
2262        assert_eq!(sample.get(), 48.);
2263        cx.simulate_mouse_down(center, MouseButton::Left, Modifiers::default());
2264        draw(cx);
2265        assert_eq!(sample.get(), 48.);
2266        cx.executor().advance_clock(Duration::from_millis(100));
2267        draw(cx);
2268        let expected = 48.
2269            + (16. - 48.)
2270                * crate::motion::MotionScheme::Expressive
2271                    .resolve(crate::motion::MotionToken::FastSpatial)
2272                    .position(0.1);
2273        assert!((sample.get() - expected).abs() < 0.001);
2274        let before = sample.get();
2275        cx.simulate_mouse_move(
2276            center + point(px(1.), px(1.)),
2277            MouseButton::Left,
2278            Modifiers::default(),
2279        );
2280        draw(cx);
2281        assert!(
2282            (sample.get() - before).abs() < 0.001,
2283            "held move must not release"
2284        );
2285        cx.simulate_mouse_up(
2286            point(px(900.), px(800.)),
2287            MouseButton::Left,
2288            Modifiers::default(),
2289        );
2290        draw(cx);
2291        assert!(
2292            (sample.get() - before).abs() < 0.001,
2293            "release must retarget continuously"
2294        );
2295        cx.executor().advance_clock(Duration::from_secs(1));
2296        draw(cx);
2297        assert_eq!(sample.get(), 48.);
2298        cx.update(|_, cx| cx.set_reduce_motion(true));
2299        cx.simulate_mouse_down(center, MouseButton::Left, Modifiers::default());
2300        draw(cx);
2301        assert_eq!(sample.get(), 16.);
2302        cx.simulate_mouse_up(center, MouseButton::Left, Modifiers::default());
2303        draw(cx);
2304        assert_eq!(sample.get(), 48.);
2305        cx.simulate_mouse_down(center, MouseButton::Left, Modifiers::default());
2306        draw(cx);
2307        cx.simulate_mouse_move(
2308            point(px(900.), px(800.)),
2309            MouseButton::Left,
2310            Modifiers::default(),
2311        );
2312        draw(cx);
2313        assert_eq!(sample.get(), 16.);
2314        cx.simulate_event(gpui::MouseExitEvent {
2315            position: point(px(1200.), px(1000.)),
2316            pressed_button: Some(MouseButton::Left),
2317            modifiers: Modifiers::default(),
2318        });
2319        draw(cx);
2320        assert_eq!(
2321            sample.get(),
2322            48.,
2323            "leaving window after moving outside must clear press"
2324        );
2325    }
2326    #[gpui::test]
2327    fn pressed_visuals_clear_on_blur_disable_and_remount(cx: &mut TestAppContext) {
2328        use gpui::MouseButton;
2329        use std::{cell::Cell, rc::Rc};
2330        cx.update(|cx| {
2331            gpui_base::init(cx);
2332            cx.set_reduce_motion(true);
2333        });
2334        let sample = Rc::new(Cell::new(0.));
2335        let (view, cx) = cx.add_window_view({
2336            let sample = sample.clone();
2337            move |_, cx| MotionHarness {
2338                disabled: false,
2339                shown: true,
2340                sample,
2341                focus: cx.focus_handle(),
2342                other: cx.focus_handle(),
2343                activations: 0,
2344            }
2345        });
2346        draw(cx);
2347        cx.update(|window, cx| {
2348            let handle = view.read(cx).focus.clone();
2349            window.focus(&handle, cx);
2350        });
2351        draw(cx);
2352        key(cx, "space", true);
2353        draw(cx);
2354        assert_eq!(sample.get(), 16.);
2355        key(cx, "a", false);
2356        draw(cx);
2357        assert_eq!(
2358            sample.get(),
2359            16.,
2360            "unrelated key release does not release Space"
2361        );
2362        for other in ["shift", "a", "shift-a"] {
2363            key(cx, other, true);
2364            draw(cx);
2365            assert_eq!(
2366                sample.get(),
2367                16.,
2368                "{other} pressed while holding Space keeps the pressed visual"
2369            );
2370            key(cx, other, false);
2371        }
2372        cx.update(|window, cx| {
2373            let handle = view.read(cx).other.clone();
2374            window.focus(&handle, cx);
2375        });
2376        draw(cx);
2377        assert_eq!(sample.get(), 48.);
2378        cx.update(|window, cx| {
2379            let handle = view.read(cx).focus.clone();
2380            window.focus(&handle, cx);
2381        });
2382        draw(cx);
2383        key(cx, "space", false);
2384        draw(cx);
2385        cx.update(|_, cx| assert_eq!(view.read(cx).activations, 0));
2386        key(cx, "space", true);
2387        draw(cx);
2388        view.update(cx, |this, cx| {
2389            this.disabled = true;
2390            cx.notify();
2391        });
2392        draw(cx);
2393        assert_eq!(sample.get(), 48.);
2394        key(cx, "space", false);
2395        view.update(cx, |this, cx| {
2396            this.disabled = false;
2397            cx.notify();
2398        });
2399        draw(cx);
2400        let center = cx.debug_bounds("motion-button").unwrap().center();
2401        cx.simulate_mouse_down(center, MouseButton::Left, Modifiers::default());
2402        draw(cx);
2403        assert_eq!(sample.get(), 16.);
2404        view.update(cx, |this, cx| {
2405            this.shown = false;
2406            cx.notify();
2407        });
2408        draw(cx);
2409        cx.simulate_mouse_up(center, MouseButton::Left, Modifiers::default());
2410        view.update(cx, |this, cx| {
2411            this.shown = true;
2412            cx.notify();
2413        });
2414        draw(cx);
2415        assert_eq!(sample.get(), 48.);
2416        cx.update(|window, _| window.activate_window());
2417        cx.run_until_parked();
2418        cx.simulate_mouse_down(center, MouseButton::Left, Modifiers::default());
2419        draw(cx);
2420        assert_eq!(sample.get(), 16.);
2421        cx.deactivate_window();
2422        draw(cx);
2423        assert_eq!(sample.get(), 48., "window deactivation clears a held press");
2424    }
2425    struct IconHarness {
2426        size: ButtonSize,
2427        variant: ButtonVariant,
2428        position: super::ButtonIconPosition,
2429        direction: super::ButtonDirection,
2430    }
2431    impl Render for IconHarness {
2432        fn render(&mut self, window: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
2433            window.set_rem_size(px(14.));
2434            div().flex().items_start().child(
2435                MaterialButton::new("icon-button")
2436                    .button_size(self.size)
2437                    .variant(self.variant)
2438                    .icon_position(self.position)
2439                    .direction(self.direction)
2440                    .icon(crate::icons::IconName::Check.icon().size(px(100.)))
2441                    .debug_selector(|| "icon-button".into())
2442                    .child(
2443                        div()
2444                            .w(px(40.))
2445                            .h(px(10.))
2446                            .debug_selector(|| "icon-label".into()),
2447                    ),
2448            )
2449        }
2450    }
2451    #[gpui::test]
2452    fn icon_slots_and_logical_padding_match_all_sizes_variants_and_directions(
2453        cx: &mut TestAppContext,
2454    ) {
2455        use super::{ButtonDirection, ButtonIconPosition};
2456        cx.update(gpui_base::init);
2457        let (view, cx) = cx.add_window_view(|_, _| IconHarness {
2458            size: ButtonSize::Small,
2459            variant: ButtonVariant::Filled,
2460            position: ButtonIconPosition::Leading,
2461            direction: ButtonDirection::Ltr,
2462        });
2463        for (size, icon, gap, pad) in [
2464            (ButtonSize::ExtraSmall, 20., 4., 12.),
2465            (ButtonSize::Small, 20., 8., 16.),
2466            (ButtonSize::Medium, 24., 8., 24.),
2467            (ButtonSize::Large, 32., 12., 48.),
2468            (ButtonSize::ExtraLarge, 40., 16., 64.),
2469        ] {
2470            for variant in ButtonVariant::ALL {
2471                for direction in [ButtonDirection::Ltr, ButtonDirection::Rtl] {
2472                    for position in [ButtonIconPosition::Leading, ButtonIconPosition::Trailing] {
2473                        view.update(cx, |this, cx| {
2474                            this.size = size;
2475                            this.variant = variant;
2476                            this.position = position;
2477                            this.direction = direction;
2478                            cx.notify();
2479                        });
2480                        draw(cx);
2481                        let button = cx.debug_bounds("icon-button").unwrap();
2482                        let label = cx.debug_bounds("icon-label").unwrap();
2483                        // The kit: the size's State-layer padding on both sides.
2484                        let (icon_side, label_side) = (pad, pad);
2485                        let border = if variant == ButtonVariant::Outlined {
2486                            match size {
2487                                ButtonSize::Large | ButtonSize::ExtraLarge => 2.,
2488                                _ => 1.,
2489                            }
2490                        } else {
2491                            0.
2492                        };
2493                        let expected = icon_side + label_side + icon + gap + 40. + 2. * border;
2494                        assert!(
2495                            (f32::from(button.size.width) - expected).abs() < 0.01,
2496                            "{size:?} {variant:?} {position:?} {direction:?}"
2497                        );
2498                        let icon_left = (position == ButtonIconPosition::Leading)
2499                            == (direction == ButtonDirection::Ltr);
2500                        let expected_label = if icon_left {
2501                            icon_side + icon + gap + border
2502                        } else {
2503                            label_side + border
2504                        };
2505                        assert!(
2506                            (f32::from(label.left() - button.left()) - expected_label).abs() < 0.01,
2507                            "label: {size:?} {variant:?} {position:?} {direction:?}"
2508                        );
2509                    }
2510                }
2511            }
2512        }
2513    }
2514}