1use 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
11pub(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 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
196pub 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 .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 .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 .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 .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 .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
343pub 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
388pub 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 #[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}