Skip to main content

gpui_macos/
controls.rs

1//! Styled constructors retain gpui-base's controlled-state builder APIs.
2use crate::{ActiveTheme, Button, IconName, Link, icon};
3use gpui_kit::base::{Checkbox, CheckboxState, Radio, Switch, Tab, Tabs, Toggle};
4use gpui_kit::prelude::FluentBuilder;
5use gpui_kit::rems;
6use gpui_kit::{
7    App, ElementId, FontWeight, Hsla, InteractiveElement, IntoElement, ParentElement, RenderOnce,
8    SharedString, StatefulInteractiveElement, Styled, Window, div,
9};
10
11/// AppKit keeps inactive controls enabled while replacing accent color with a
12/// neutral selection. Resolve this per window, including secondary windows.
13pub(crate) fn control_selection(theme: &crate::Theme, active: bool) -> Hsla {
14    if active {
15        theme.accent
16    } else {
17        theme
18            .control_fill()
19            .blend(
20                theme
21                    .foreground
22                    .opacity(if theme.is_dark() { 0.1 } else { 0.08 }),
23            )
24    }
25}
26
27pub(crate) fn control_mark(theme: &crate::Theme, active: bool) -> Hsla {
28    if active {
29        theme.on_accent
30    } else {
31        theme.foreground
32    }
33}
34
35pub(crate) fn control_thumb(theme: &crate::Theme, active: bool) -> Hsla {
36    if !active && theme.is_dark() {
37        gpui_kit::rgb(0xdedede).into()
38    } else {
39        gpui_kit::rgb(0xffffff).into()
40    }
41}
42
43pub(crate) fn unchecked_control_fill(theme: &crate::Theme, active: bool) -> Hsla {
44    if !active {
45        gpui_kit::rgb(if theme.is_dark() { 0x3c3d40 } else { 0xdfdfdf }).into()
46    } else {
47        theme.control_fill()
48    }
49}
50
51pub(crate) fn slider_selection(theme: &crate::Theme, active: bool) -> Hsla {
52    if active {
53        theme.accent
54    } else {
55        theme
56            .control_fill()
57            .blend(
58                theme
59                    .foreground
60                    .opacity(if theme.is_dark() { 0.25 } else { 0.22 }),
61            )
62    }
63}
64
65#[derive(IntoElement)]
66struct NativeCheckboxIndicator(ElementId, CheckboxState);
67
68impl RenderOnce for NativeCheckboxIndicator {
69    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
70        let theme = cx.macos();
71        let active = window.is_window_active();
72        let mark = control_mark(theme, active);
73        div()
74            .size(rems(1.))
75            .debug_selector(move || format!("macos-checkbox-indicator-{}", self.0))
76            .flex_shrink_0()
77            .flex()
78            .items_center()
79            .justify_center()
80            .rounded(rems(0.25))
81            .bg(if self.1 == CheckboxState::Unchecked {
82                unchecked_control_fill(theme, active)
83            } else {
84                control_selection(theme, active)
85            })
86            .text_color(mark)
87            .when(self.1 != CheckboxState::Unchecked, |indicator| {
88                indicator.child(
89                    icon(if self.1 == CheckboxState::Checked {
90                        IconName::Check
91                    } else {
92                        IconName::Minus
93                    })
94                    .size(rems(0.875))
95                    .text_color(mark),
96                )
97            })
98    }
99}
100
101#[derive(IntoElement)]
102struct NativeRadioIndicator(ElementId, bool);
103
104impl RenderOnce for NativeRadioIndicator {
105    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
106        let theme = cx.macos();
107        let active = window.is_window_active();
108        div()
109            .size(rems(1.))
110            .debug_selector(move || format!("macos-radio-indicator-{}", self.0))
111            .flex_shrink_0()
112            .rounded_full()
113            .bg(if self.1 {
114                control_selection(theme, active)
115            } else {
116                unchecked_control_fill(theme, active)
117            })
118            .flex()
119            .items_center()
120            .justify_center()
121            .when(self.1, |indicator| {
122                indicator.child(
123                    div()
124                        .size(rems(0.25))
125                        .rounded_full()
126                        .bg(control_mark(theme, active)),
127                )
128            })
129    }
130}
131
132#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
133pub enum ButtonVariant {
134    Primary,
135    Outline,
136    #[default]
137    Secondary,
138    Danger,
139}
140
141pub fn button(
142    id: impl Into<ElementId>,
143    label: impl Into<SharedString>,
144    variant: ButtonVariant,
145    cx: &App,
146) -> Button {
147    let t = cx.macos();
148    let (bg, fg) = match variant {
149        ButtonVariant::Primary => (t.accent, t.on_accent),
150        ButtonVariant::Danger => (t.danger, t.on_accent),
151        ButtonVariant::Outline | ButtonVariant::Secondary => (t.control_fill(), t.foreground),
152    };
153    let filled = matches!(variant, ButtonVariant::Primary | ButtonVariant::Danger);
154    // Resolve overlays before animating so an opaque control stays opaque.
155    let hover_bg = bg.blend(if filled {
156        gpui_kit::Hsla::black().opacity(0.08)
157    } else {
158        t.hover_fill()
159    });
160    let pressed_bg = bg.blend(if filled {
161        gpui_kit::Hsla::black().opacity(0.16)
162    } else {
163        t.pressed_fill()
164    });
165    let label = label.into();
166    Button::new(id)
167        .accessibility_label(label.clone())
168        .when(!label.is_empty(), |button| button.child(label))
169        .min_h(rems(1.5))
170        .py(rems(0.1875))
171        .px(rems(0.75))
172        .gap(rems(0.375))
173        .border_1()
174        .rounded(rems(0.375))
175        .border_color(t.foreground.opacity(0.))
176        .font_family(t.font.clone())
177        .text_size(rems(0.8125))
178        .line_height(gpui_kit::relative(1.2))
179        .font_weight(FontWeight::NORMAL)
180        .bg(bg)
181        .text_color(fg)
182        .when(filled, |button| {
183            button.inactive_style(t.control_fill(), t.foreground)
184        })
185        .hover(|s| s.bg(hover_bg))
186        .focus_visible(|s| s.border_color(t.accent).shadow(t.focus_shadow()))
187        .active(|s| s.bg(pressed_bg))
188        .selected_fill(if filled {
189            bg
190        } else {
191            bg.blend(t.selected_fill())
192        })
193        .styles(|s| s.disabled(|s| s.opacity(0.45)))
194}
195
196/// A square, icon-only button. `label` names the action for assistive tech;
197/// pair it with [`crate::with_tooltip`] so sighted users can read it too.
198pub fn icon_button(
199    id: impl Into<ElementId>,
200    name: IconName,
201    label: impl Into<SharedString>,
202    variant: ButtonVariant,
203    cx: &App,
204) -> Button {
205    button(id, "", variant, cx)
206        .accessibility_label(label)
207        .fill_duration(crate::motion::CURSOR_COLOR)
208        // Equal padding around a fixed-size glyph keeps the hit target square.
209        .p(rems(0.25))
210        .size(rems(1.5))
211        .justify_center()
212        .child(icon(name).size(rems(0.875)))
213}
214
215pub fn checkbox(
216    id: impl Into<ElementId>,
217    label: impl Into<SharedString>,
218    state: CheckboxState,
219    cx: &App,
220) -> Checkbox {
221    let t = cx.macos();
222    let id = id.into();
223    let label = label.into();
224    Checkbox::new(id.clone())
225        .state(state)
226        .accessibility_label(label.clone())
227        .flex()
228        // Hug the control and its label instead of stretching across a column.
229        .self_start()
230        .items_center()
231        .gap(rems(0.3125))
232        .min_h(rems(1.))
233        .rounded(rems(0.25))
234        .text_color(t.foreground)
235        .font_family(t.font.clone())
236        .text_size(rems(0.8125))
237        .line_height(gpui_kit::relative(1.2))
238        .child(NativeCheckboxIndicator(id, state))
239        .child(label)
240        .focus_visible(|s| s.shadow(t.focus_shadow()))
241        .styles(|s| s.disabled(|s| s.opacity(0.45)))
242}
243
244pub fn switch(
245    id: impl Into<ElementId>,
246    label: impl Into<SharedString>,
247    checked: bool,
248    cx: &App,
249) -> Switch {
250    let t = cx.macos();
251    let id = id.into();
252    let label = label.into();
253    Switch::new(id.clone())
254        .checked(checked)
255        .accessibility_label(label.clone())
256        .flex()
257        // Hug the control and its label instead of stretching across a column.
258        .self_start()
259        .items_center()
260        .gap(rems(0.5))
261        .rounded(rems(0.75))
262        .text_color(t.foreground)
263        .font_family(t.font.clone())
264        .text_size(rems(0.8125))
265        .line_height(gpui_kit::relative(1.2))
266        .focus_visible(|s| s.shadow(t.focus_shadow()))
267        .styles(|s| s.disabled(|s| s.opacity(0.45)))
268        .child(crate::switch_motion::SwitchMotion::new(id, checked))
269        .child(label)
270}
271
272pub fn radio(
273    id: impl Into<ElementId>,
274    label: impl Into<SharedString>,
275    checked: bool,
276    cx: &App,
277) -> Radio {
278    let t = cx.macos();
279    let id = id.into();
280    let label = label.into();
281    Radio::new(id.clone())
282        .checked(checked)
283        .accessibility_label(label.clone())
284        .flex()
285        // Hug the control and its label instead of stretching across a column.
286        .self_start()
287        .items_center()
288        .gap(rems(0.3125))
289        .min_h(rems(1.))
290        .rounded(rems(0.25))
291        .text_color(t.foreground)
292        .font_family(t.font.clone())
293        .text_size(rems(0.8125))
294        .line_height(gpui_kit::relative(1.2))
295        .child(NativeRadioIndicator(id, checked))
296        .child(label)
297        .focus_visible(|s| s.shadow(t.focus_shadow()))
298        .styles(|s| s.disabled(|s| s.opacity(0.45)))
299}
300
301pub fn toggle(
302    id: impl Into<ElementId>,
303    label: impl Into<SharedString>,
304    pressed: bool,
305    cx: &App,
306) -> Toggle {
307    let t = cx.macos();
308    let label = label.into();
309    Toggle::new(id)
310        .pressed(pressed)
311        .accessibility_label(label.clone())
312        .flex()
313        // Hug the control and its label instead of stretching across a column.
314        .self_start()
315        .items_center()
316        .gap(rems(0.5))
317        .min_h(rems(1.5))
318        .py(rems(0.1875))
319        .px(rems(0.625))
320        .border_1()
321        .rounded(rems(0.375))
322        .border_color(t.foreground.opacity(0.))
323        .bg(t.control_fill())
324        .text_color(t.foreground)
325        .font_family(t.font.clone())
326        .text_size(rems(0.8125))
327        .line_height(gpui_kit::relative(1.2))
328        .font_weight(FontWeight::NORMAL)
329        .child(label)
330        .hover(|s| s.bg(t.hover_fill()).border_color(t.focus_border()))
331        .focus_visible(|s| s.border_color(t.accent).shadow(t.focus_shadow()))
332        .active(|s| s.bg(t.pressed_fill()))
333        .styles(|s| {
334            s.pressed(|s| {
335                s.bg(t.accent)
336                    .text_color(t.on_accent)
337                    .border_color(t.accent)
338            })
339            .disabled(|s| s.opacity(0.45))
340        })
341}
342
343/// A multi-choice toolbar. Compose independent `toggle` children; each retains
344/// its own keyboard focus and controlled pressed state.
345pub fn toggle_group(id: impl Into<ElementId>, cx: &App) -> gpui_kit::base::ToggleGroup {
346    let t = cx.macos();
347    gpui_kit::base::ToggleGroup::new(id)
348        .flex()
349        .flex_wrap()
350        .items_center()
351        .gap(rems(0.375))
352        .font_family(t.font.clone())
353        .text_size(rems(0.8125))
354        .line_height(gpui_kit::relative(1.2))
355        .text_color(t.foreground)
356}
357
358pub fn link(
359    id: impl Into<ElementId>,
360    label: impl Into<SharedString>,
361    href: impl Into<SharedString>,
362    cx: &App,
363) -> Link {
364    let t = cx.macos();
365    let label = label.into();
366    Link::new(id)
367        .href(href)
368        .open_with(|url, _, _, cx| cx.open_url(url))
369        .accessibility_label(label.clone())
370        .child(label)
371        .flex()
372        .items_center()
373        .gap(rems(0.375))
374        .text_size(rems(0.8125))
375        .line_height(gpui_kit::relative(1.2))
376        .font_family(t.font.clone())
377        .text_color(t.accent)
378        .rounded(rems(0.25))
379        .border_1()
380        .border_color(t.foreground.opacity(0.))
381        .px(rems(0.25))
382        .py(rems(0.375))
383        .hover(|s| s.text_color(t.accent).underline())
384        .focus_visible(|s| s.border_color(t.accent).shadow(t.focus_shadow()))
385        .styles(|s| s.disabled(|s| s.opacity(0.45)))
386}
387
388/// A rounded segmented strip with gpui-base Tab semantics.
389pub fn tabs(id: impl Into<ElementId>, cx: &App) -> Tabs {
390    let t = cx.macos();
391    Tabs::new(id)
392        .flex()
393        .h(rems(1.5))
394        .items_center()
395        .gap(rems(0.125))
396        .p(rems(0.0625))
397        .rounded(rems(0.375))
398        .bg(t.control_fill())
399}
400
401pub fn tab(
402    id: impl Into<ElementId>,
403    label: impl Into<SharedString>,
404    selected: bool,
405    cx: &App,
406) -> Tab {
407    let t = cx.macos();
408    let label = label.into();
409    Tab::new(id)
410        .selected(selected)
411        .accessibility_label(label.clone())
412        .child(label)
413        .px_3()
414        .h(rems(1.375))
415        .py(rems(0.))
416        .flex()
417        .flex_shrink_0()
418        .items_center()
419        .gap_1()
420        .border_1()
421        .rounded(rems(0.3125))
422        .border_color(t.foreground.opacity(0.))
423        .font_family(t.font.clone())
424        .text_size(rems(0.8125))
425        .line_height(gpui_kit::relative(1.2))
426        .font_weight(FontWeight::NORMAL)
427        .text_color(t.secondary)
428        .hover(|s| s.bg(t.hover_fill()))
429        .focus_visible(|s| s.border_color(t.accent).shadow(t.focus_shadow()))
430        .active(|s| s.bg(t.pressed_fill()))
431        .styles(|s| {
432            s.selected(|s| s.bg(t.surface).text_color(t.foreground))
433                .disabled(|s| s.opacity(0.45))
434        })
435}
436
437#[cfg(test)]
438mod tests {
439    use super::*;
440    use gpui_kit::{
441        AppContext, Context, Entity, FocusHandle, Render, TestAppContext, VisualTestContext, Window,
442    };
443
444    struct ActivationHarness {
445        slider: Entity<gpui_kit::base::slider::SliderState>,
446        checked: CheckboxState,
447        checkbox_changes: usize,
448    }
449
450    impl Render for ActivationHarness {
451        fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
452            let target = cx.entity();
453            crate::focus_scope("window-activation-controls")
454                .size_full()
455                .flex()
456                .flex_col()
457                .items_start()
458                .gap(rems(0.5))
459                .child(
460                    button("activation-primary", "Continue", ButtonVariant::Primary, cx)
461                        .debug_selector(|| "activation-primary".into()),
462                )
463                .child(
464                    button(
465                        "activation-disabled",
466                        "Disabled",
467                        ButtonVariant::Primary,
468                        cx,
469                    )
470                    .disabled(true)
471                    .debug_selector(|| "activation-disabled".into()),
472                )
473                .child(
474                    checkbox("activation-checkbox", "Remember me", self.checked, cx)
475                        .debug_selector(|| "activation-checkbox".into())
476                        .on_change(move |state, _, _, cx| {
477                            target.update(cx, |this, cx| {
478                                this.checked = state;
479                                this.checkbox_changes += 1;
480                                cx.notify();
481                            })
482                        }),
483                )
484                .child(radio("activation-radio", "Automatic", true, cx))
485                .child(radio("activation-off-radio", "Manual", false, cx))
486                .child(switch("activation-switch", "Wi-Fi", true, cx))
487                .child(switch("activation-off-switch", "AirDrop", false, cx))
488                .child(crate::slider(&self.slider, false, window, cx).w(rems(12.)))
489        }
490    }
491
492    fn painted_background(cx: &mut VisualTestContext, selector: &'static str) -> Hsla {
493        let bounds = cx.debug_bounds(selector).expect(selector);
494        cx.update(|window, _| {
495            let bounds = bounds.scale(window.scale_factor());
496            window
497                .painted_quads()
498                .into_iter()
499                .find(|quad| quad.bounds == bounds && quad.background.as_solid().is_some())
500                .unwrap_or_else(|| panic!("{selector} has a painted background"))
501                .background
502                .as_solid()
503                .unwrap()
504        })
505    }
506
507    fn painted_radio_mark(cx: &mut VisualTestContext) -> Hsla {
508        let center = cx
509            .debug_bounds("macos-radio-indicator-activation-radio")
510            .unwrap()
511            .center();
512        cx.update(|window, _| {
513            window
514                .painted_quads()
515                .into_iter()
516                .find(|quad| {
517                    quad.bounds.center() == center.scale(window.scale_factor())
518                        && quad.bounds.size
519                            == gpui_kit::size(gpui_kit::px(4.), gpui_kit::px(4.))
520                                .scale(window.scale_factor())
521                })
522                .unwrap()
523                .background
524                .as_solid()
525                .unwrap()
526        })
527    }
528
529    #[gpui_kit::test]
530    fn rendered_controls_follow_real_window_activation_and_keep_disabled_distinct(
531        cx: &mut TestAppContext,
532    ) {
533        cx.update(|cx| {
534            crate::init(cx);
535            cx.set_reduce_motion(true);
536        });
537        let (view, cx) = cx.add_window_view(|_, cx| ActivationHarness {
538            slider: cx.new(|_| {
539                gpui_kit::base::slider::SliderState::new()
540                    .min(0.)
541                    .max(100.)
542                    .default_value(65.)
543            }),
544            checked: CheckboxState::Checked,
545            checkbox_changes: 0,
546        });
547        cx.simulate_mouse_move(
548            gpui_kit::point(gpui_kit::px(500.), gpui_kit::px(500.)),
549            None,
550            Default::default(),
551        );
552        for theme in [crate::Theme::light(), crate::Theme::dark()] {
553            cx.update(|window, cx| {
554                theme.clone().apply(cx);
555                window.activate_window();
556            });
557            cx.run_until_parked();
558            cx.simulate_mouse_move(
559                gpui_kit::point(gpui_kit::px(500.), gpui_kit::px(500.)),
560                None,
561                Default::default(),
562            );
563            cx.update(|window, cx| {
564                assert!(window.is_window_active());
565                view.update(cx, |_, cx| cx.notify());
566                window.draw(cx).clear(cx);
567            });
568            for selector in [
569                "activation-primary",
570                "macos-checkbox-indicator-activation-checkbox",
571                "macos-radio-indicator-activation-radio",
572                "macos-switch-track-activation-switch",
573                "macos-slider-selection",
574            ] {
575                assert_eq!(
576                    painted_background(cx, selector),
577                    theme.accent,
578                    "{selector} uses the active accent"
579                );
580            }
581            let disabled = painted_background(cx, "activation-disabled");
582            assert_eq!(painted_radio_mark(cx), theme.on_accent);
583            assert!(disabled.a < 1., "disabled bezels remain faded");
584            assert_eq!(disabled.s, 0., "disabled primary uses a neutral bezel");
585            cx.deactivate_window();
586            cx.update(|window, cx| {
587                assert!(!window.is_window_active());
588                window.draw(cx).clear(cx);
589            });
590            for selector in [
591                "activation-primary",
592                "macos-checkbox-indicator-activation-checkbox",
593                "macos-radio-indicator-activation-radio",
594                "macos-switch-track-activation-switch",
595                "macos-slider-selection",
596            ] {
597                let fill = painted_background(cx, selector);
598                assert_eq!(fill.s, 0., "{selector} becomes neutral on blur");
599                assert_eq!(fill.a, 1., "inactive remains enabled rather than disabled");
600                assert_ne!(fill, theme.accent);
601            }
602            let expected_thumb: Hsla =
603                gpui_kit::rgb(if theme.is_dark() { 0xdedede } else { 0xffffff }).into();
604            {
605                let off_fill: Hsla =
606                    gpui_kit::rgb(if theme.is_dark() { 0x3c3d40 } else { 0xdfdfdf }).into();
607                assert_eq!(
608                    painted_background(cx, "macos-radio-indicator-activation-off-radio"),
609                    off_fill
610                );
611                assert_eq!(
612                    painted_background(cx, "macos-switch-track-activation-off-switch"),
613                    off_fill
614                );
615            }
616            assert_eq!(
617                painted_radio_mark(cx),
618                theme.foreground,
619                "inactive radio uses the native black/white foreground mark"
620            );
621            assert_eq!(
622                painted_background(cx, "macos-switch-thumb-activation-switch"),
623                expected_thumb
624            );
625            assert_eq!(
626                painted_background(cx, "macos-slider-thumb-end"),
627                expected_thumb
628            );
629            cx.update(|window, _| window.activate_window());
630            cx.run_until_parked();
631            cx.simulate_mouse_move(
632                gpui_kit::point(gpui_kit::px(500.), gpui_kit::px(500.)),
633                None,
634                Default::default(),
635            );
636            cx.update(|window, cx| window.draw(cx).clear(cx));
637            assert_eq!(
638                painted_background(cx, "activation-primary"),
639                theme.accent,
640                "reactivation restores primary color without reconstruction"
641            );
642        }
643    }
644
645    #[gpui_kit::test]
646    fn inactive_checkbox_stays_interactive_and_preserves_its_controlled_state(
647        cx: &mut TestAppContext,
648    ) {
649        cx.update(|cx| {
650            crate::init(cx);
651            cx.set_reduce_motion(true);
652        });
653        let (view, cx) = cx.add_window_view(|_, cx| ActivationHarness {
654            slider: cx.new(|_| gpui_kit::base::slider::SliderState::new()),
655            checked: CheckboxState::Checked,
656            checkbox_changes: 0,
657        });
658        cx.deactivate_window();
659        cx.update(|window, cx| window.draw(cx).clear(cx));
660        let control = cx.debug_bounds("activation-checkbox").unwrap();
661        cx.simulate_click(control.center(), Default::default());
662        cx.update(|_, cx| {
663            assert_eq!(view.read(cx).checked, CheckboxState::Unchecked);
664            assert_eq!(
665                view.read(cx).checkbox_changes,
666                1,
667                "a gray inactive control is still enabled"
668            );
669        });
670    }
671
672    struct GeometryHarness {
673        input: Entity<gpui_kit::base::input::InputState>,
674        choice: Entity<crate::ChoiceState>,
675        slider: Entity<gpui_kit::base::slider::SliderState>,
676    }
677
678    impl Render for GeometryHarness {
679        fn render(
680            &mut self,
681            window: &mut Window,
682            cx: &mut Context<Self>,
683        ) -> impl gpui_kit::IntoElement {
684            crate::focus_scope("measured-native-controls")
685                .size_full()
686                .flex()
687                .flex_col()
688                .items_start()
689                .gap(rems(0.5))
690                .child(
691                    button("measured-button", "Continue", ButtonVariant::Primary, cx)
692                        .debug_selector(|| "measured-button".into()),
693                )
694                .child(
695                    checkbox(
696                        "measured-checkbox",
697                        "Remember me",
698                        CheckboxState::Checked,
699                        cx,
700                    )
701                    .debug_selector(|| "measured-checkbox".into()),
702                )
703                .child(
704                    radio("measured-radio", "Automatic", true, cx)
705                        .debug_selector(|| "measured-radio".into()),
706                )
707                .child(switch("measured-switch", "Wi-Fi", false, cx))
708                .child(
709                    crate::input("measured-input", &self.input, window, cx)
710                        .w(rems(12.))
711                        .debug_selector(|| "measured-input".into()),
712                )
713                .child(
714                    crate::button_group(
715                        "measured-segments",
716                        vec![
717                            crate::ChoiceItem::new("general", "General"),
718                            crate::ChoiceItem::new("network", "Network"),
719                        ],
720                        Some(0),
721                        |_, _, _| {},
722                        window,
723                        cx,
724                    )
725                    .w(rems(12.))
726                    .debug_selector(|| "measured-segments".into()),
727                )
728                .child(
729                    div()
730                        .w(rems(12.))
731                        .debug_selector(|| "measured-popup".into())
732                        .child(crate::select("measured-popup", &self.choice, window, cx)),
733                )
734                .child(
735                    div()
736                        .w(rems(12.))
737                        .debug_selector(|| "measured-slider".into())
738                        .child(crate::slider(&self.slider, false, window, cx)),
739                )
740        }
741    }
742
743    /// Preserve the actual AppKit baseline while independently scaling GPUI.
744    /// Native intrinsic widths depend on the platform font and are intentionally
745    /// left flexible; intrinsic heights and switch geometry are authoritative.
746    #[gpui_kit::test]
747    fn native_intrinsic_geometry_matches_appkit_at_each_zoom(cx: &mut TestAppContext) {
748        let reference: serde_json::Value =
749            serde_json::from_str(include_str!("../docs/native-metrics.json")).unwrap();
750        cx.update(crate::init);
751        let (view, cx) = cx.add_window_view(|window, cx| GeometryHarness {
752            input: cx.new(|cx| {
753                gpui_kit::base::input::InputState::new(window, cx).default_value("Taylor")
754            }),
755            choice: cx.new(|cx| {
756                crate::ChoiceState::new(
757                    vec![crate::ChoiceItem::new("standard", "Standard")],
758                    window,
759                    cx,
760                )
761                .default_selected(0)
762            }),
763            slider: cx.new(|_| {
764                gpui_kit::base::slider::SliderState::new()
765                    .min(0.)
766                    .max(100.)
767                    .default_value(65.)
768            }),
769        });
770        for (appearance, theme) in [
771            ("light", crate::Theme::light()),
772            ("dark", crate::Theme::dark()),
773        ] {
774            for zoom in [1., 1.25, 2.] {
775                cx.update(|window, cx| {
776                    theme.clone().apply(cx);
777                    window.set_rem_size(gpui_kit::px(16. * zoom));
778                    view.update(cx, |_, cx| cx.notify());
779                    window.draw(cx).clear(cx);
780                });
781                for (selector, native) in [
782                    ("measured-button", "button_primary"),
783                    ("measured-checkbox", "checkbox"),
784                    ("measured-radio", "radio"),
785                    ("macos-switch-track-measured-switch", "switch_off"),
786                    ("measured-input", "text_field"),
787                    ("measured-segments", "segmented"),
788                    ("measured-popup", "popup"),
789                    ("measured-slider", "slider"),
790                ] {
791                    let expected = reference[appearance]["controls"][native]["intrinsic_height"]
792                        .as_f64()
793                        .unwrap() as f32
794                        * zoom;
795                    let actual = cx.debug_bounds(selector).unwrap().size.height.as_f32();
796                    assert!(
797                        (actual - expected).abs() < 0.1,
798                        "{appearance} {zoom} {selector}: {actual} vs native {expected}"
799                    );
800                }
801                let track = cx
802                    .debug_bounds("macos-switch-track-measured-switch")
803                    .unwrap();
804                let native_width =
805                    reference[appearance]["controls"]["switch_off"]["intrinsic_width"]
806                        .as_f64()
807                        .unwrap() as f32;
808                assert_eq!(track.size.width, gpui_kit::px(native_width * zoom));
809                let thumb = cx
810                    .debug_bounds("macos-switch-thumb-measured-switch")
811                    .unwrap();
812                assert_eq!(
813                    thumb.size,
814                    gpui_kit::size(gpui_kit::px(32. * zoom), gpui_kit::px(20. * zoom))
815                );
816                assert_eq!(thumb.origin.x - track.origin.x, gpui_kit::px(2. * zoom));
817                assert_eq!(thumb.origin.y - track.origin.y, gpui_kit::px(2. * zoom));
818            }
819        }
820    }
821
822    struct Harness {
823        checkbox: CheckboxState,
824        radio: bool,
825        switch: bool,
826        disabled: bool,
827        changes: [usize; 3],
828        focus: [FocusHandle; 3],
829    }
830
831    impl Render for Harness {
832        fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl gpui_kit::IntoElement {
833            let checkbox_target = cx.entity();
834            let radio_target = cx.entity();
835            let switch_target = cx.entity();
836            crate::focus_scope("native-controls")
837                .size_full()
838                .flex()
839                .flex_col()
840                .gap(rems(0.5))
841                .child(
842                    checkbox("remember", "Remember me", self.checkbox, cx)
843                        .debug_selector(|| "native-checkbox".into())
844                        .track_focus(&self.focus[0])
845                        .disabled(self.disabled)
846                        .on_change(move |value, _, _, cx| {
847                            checkbox_target.update(cx, |this, cx| {
848                                this.checkbox = value;
849                                this.changes[0] += 1;
850                                cx.notify();
851                            });
852                        }),
853                )
854                .child(
855                    radio("automatic", "Automatic", self.radio, cx)
856                        .debug_selector(|| "native-radio".into())
857                        .track_focus(&self.focus[1])
858                        .disabled(self.disabled)
859                        .on_change(move |value, _, _, cx| {
860                            radio_target.update(cx, |this, cx| {
861                                this.radio = value;
862                                this.changes[1] += 1;
863                                cx.notify();
864                            });
865                        }),
866                )
867                .child(
868                    switch("wireless", "Wi-Fi", self.switch, cx)
869                        .debug_selector(|| "native-switch".into())
870                        .track_focus(&self.focus[2])
871                        .disabled(self.disabled)
872                        .on_change(move |value, _, _, cx| {
873                            switch_target.update(cx, |this, cx| {
874                                this.switch = value;
875                                this.changes[2] += 1;
876                                cx.notify();
877                            });
878                        }),
879                )
880        }
881    }
882
883    fn harness(cx: &mut TestAppContext) -> (Entity<Harness>, &mut VisualTestContext) {
884        cx.update(crate::init);
885        cx.add_window_view(|_, cx| Harness {
886            checkbox: CheckboxState::Indeterminate,
887            radio: false,
888            switch: false,
889            disabled: false,
890            changes: [0; 3],
891            focus: std::array::from_fn(|_| cx.focus_handle()),
892        })
893    }
894
895    fn click(cx: &mut VisualTestContext, selector: &'static str) {
896        cx.update(|window, cx| window.draw(cx).clear(cx));
897        let point = cx.debug_bounds(selector).unwrap().center();
898        cx.simulate_click(point, Default::default());
899        cx.update(|window, cx| window.draw(cx).clear(cx));
900    }
901
902    fn press(cx: &mut VisualTestContext, key: &str) {
903        let keystroke = gpui_kit::Keystroke::parse(key).unwrap();
904        cx.simulate_event(gpui_kit::KeyDownEvent {
905            keystroke: keystroke.clone(),
906            is_held: false,
907            prefer_character_input: false,
908        });
909        cx.simulate_event(gpui_kit::KeyUpEvent { keystroke });
910        cx.update(|window, cx| window.draw(cx).clear(cx));
911    }
912
913    #[gpui_kit::test]
914    fn native_controls_preserve_controlled_pointer_and_keyboard_activation(
915        cx: &mut TestAppContext,
916    ) {
917        let (view, cx) = harness(cx);
918        click(cx, "native-checkbox");
919        cx.update(|window, cx| {
920            assert_eq!(view.read(cx).checkbox, CheckboxState::Checked);
921            assert!(view.read(cx).focus[0].is_focused(window));
922        });
923        press(cx, "space");
924        cx.update(|window, cx| {
925            window.draw(cx).clear(cx);
926            assert_eq!(view.read(cx).checkbox, CheckboxState::Unchecked);
927        });
928
929        click(cx, "native-radio");
930        press(cx, "space");
931        cx.update(|_, cx| {
932            assert!(view.read(cx).radio);
933            assert_eq!(
934                view.read(cx).changes[1],
935                1,
936                "a radio cannot deselect itself"
937            );
938        });
939        cx.update(|window, cx| {
940            view.update(cx, |this, cx| {
941                this.radio = false;
942                cx.notify();
943            });
944            window.draw(cx).clear(cx);
945        });
946        press(cx, "enter");
947        cx.update(|_, cx| assert!(view.read(cx).radio));
948
949        click(cx, "native-switch");
950        cx.update(|_, cx| assert!(view.read(cx).switch));
951        press(cx, "space");
952        cx.update(|window, cx| {
953            window.draw(cx).clear(cx);
954            assert!(!view.read(cx).switch);
955        });
956        press(cx, "enter");
957        cx.update(|_, cx| {
958            assert!(view.read(cx).switch);
959            assert_eq!(view.read(cx).changes, [2, 2, 3]);
960        });
961    }
962
963    #[gpui_kit::test]
964    fn disabled_native_controls_reject_pointer_and_keyboard_changes(cx: &mut TestAppContext) {
965        let (view, cx) = harness(cx);
966        view.update(cx, |this, cx| {
967            this.disabled = true;
968            cx.notify();
969        });
970        for (index, selector) in ["native-checkbox", "native-radio", "native-switch"]
971            .into_iter()
972            .enumerate()
973        {
974            click(cx, selector);
975            cx.update(|window, cx| {
976                let focus = view.read(cx).focus[index].clone();
977                focus.focus(window, cx);
978            });
979            press(cx, "enter");
980            press(cx, "space");
981        }
982        cx.update(|_, cx| {
983            let state = view.read(cx);
984            assert_eq!(state.checkbox, CheckboxState::Indeterminate);
985            assert!(!state.radio && !state.switch);
986            assert_eq!(state.changes, [0; 3]);
987        });
988    }
989}