1use std::rc::Rc;
2
3use gpui::{
4 AnyElement, App, ElementId, Entity, InteractiveElement, IntoElement, ParentElement, RenderOnce,
5 Role, SharedString, StatefulInteractiveElement, StyleRefinement, Styled, Window, div,
6 prelude::FluentBuilder as _, svg,
7};
8use gpui_base::RadioGroup;
9use rust_i18n::t;
10
11use crate::{
12 ActiveTheme as _, IconName, Sizable, Size, StyledExt as _, ThemeStyled as _,
13 button::{Button, ButtonVariants as _},
14 icon::IconNamed as _,
15 input::Input,
16 kbd::Kbd,
17};
18
19use gpui_base::questionnaire::{
20 QuestionnaireChoiceControl, QuestionnaireChoiceState, QuestionnaireState,
21 QuestionnaireValidationError,
22};
23
24type ChoiceRenderer =
25 Rc<dyn Fn(&QuestionnaireChoiceState, &mut Window, &mut App) -> AnyElement + 'static>;
26
27#[derive(Default)]
37struct QuestionnaireSizes(std::collections::HashMap<gpui::EntityId, Size>);
38
39impl gpui::Global for QuestionnaireSizes {}
40
41const MAX_TRACKED_QUESTIONNAIRES: usize = 128;
45
46fn publish_size(state: &Entity<QuestionnaireState>, size: Size, cx: &mut App) {
47 let id = state.entity_id();
48 let sizes = cx.default_global::<QuestionnaireSizes>();
49 if sizes.0.len() >= MAX_TRACKED_QUESTIONNAIRES && !sizes.0.contains_key(&id) {
50 sizes.0.clear();
51 }
52 sizes.0.insert(id, size);
53}
54
55fn resolve_size(own: Option<Size>, state: &Entity<QuestionnaireState>, cx: &App) -> Size {
56 own.unwrap_or_else(|| {
57 cx.try_global::<QuestionnaireSizes>()
58 .and_then(|sizes| sizes.0.get(&state.entity_id()).copied())
59 .unwrap_or(Size::Medium)
60 })
61}
62
63#[derive(Clone, Copy)]
67struct QuestionnaireMetrics {
68 root_gap: gpui::Pixels,
69 item_gap: gpui::Pixels,
70 choices_gap: gpui::Pixels,
71 choice_gap: gpui::Pixels,
72 content_gap: gpui::Pixels,
73 choice_padding_x: gpui::Pixels,
74 choice_padding_y: gpui::Pixels,
75 choice_min_height: gpui::Pixels,
76 choice_radius: gpui::Pixels,
77 indicator_size: gpui::Pixels,
78 indicator_mark_size: gpui::Pixels,
79 indicator_check_size: gpui::Pixels,
80 shortcut_size: gpui::Pixels,
81 shortcut_text_size: gpui::Pixels,
82 shortcut_radius: gpui::Pixels,
83}
84
85impl QuestionnaireMetrics {
86 fn new(size: Size, cx: &App) -> Self {
87 let tokens = cx.theme().semantic_tokens();
88 let spacing = tokens.spacing;
89 let radius = tokens.radius;
90 match size {
91 Size::XSmall => Self {
92 root_gap: spacing.sm,
93 item_gap: spacing.sm,
94 choices_gap: spacing.xs,
95 choice_gap: spacing.xs + spacing.xxs,
96 content_gap: spacing.xxs,
97 choice_padding_x: spacing.sm,
98 choice_padding_y: spacing.xs,
99 choice_min_height: spacing.xl + spacing.xs,
100 choice_radius: radius.md,
101 indicator_size: spacing.md,
102 indicator_mark_size: spacing.xs + spacing.xxs * 0.5,
103 indicator_check_size: spacing.sm + spacing.xxs,
104 shortcut_size: spacing.lg,
105 shortcut_text_size: spacing.sm,
106 shortcut_radius: radius.sm,
107 },
108 Size::Small => Self {
109 root_gap: spacing.md,
110 item_gap: spacing.md,
111 choices_gap: spacing.xs + spacing.xxs,
112 choice_gap: spacing.sm,
113 content_gap: spacing.xxs,
114 choice_padding_x: spacing.sm + spacing.xxs,
115 choice_padding_y: spacing.sm,
116 choice_min_height: spacing.xxl + spacing.xs,
117 choice_radius: radius.lg,
118 indicator_size: spacing.md + spacing.xxs,
119 indicator_mark_size: spacing.xs + spacing.xxs,
120 indicator_check_size: spacing.md,
121 shortcut_size: spacing.lg + spacing.xxs,
122 shortcut_text_size: spacing.sm + spacing.xxs * 0.5,
123 shortcut_radius: radius.md,
124 },
125 Size::Large => Self {
126 root_gap: spacing.xl,
127 item_gap: spacing.xl,
128 choices_gap: spacing.sm + spacing.xxs,
129 choice_gap: spacing.md,
130 content_gap: spacing.xs,
131 choice_padding_x: spacing.lg,
132 choice_padding_y: spacing.md,
133 choice_min_height: spacing.xxl + spacing.lg,
134 choice_radius: radius.xl,
135 indicator_size: spacing.lg + spacing.xxs,
136 indicator_mark_size: spacing.sm + spacing.xxs,
137 indicator_check_size: spacing.lg,
138 shortcut_size: spacing.xl,
139 shortcut_text_size: spacing.md,
140 shortcut_radius: radius.lg,
141 },
142 Size::Size(value) => Self {
143 root_gap: value,
144 item_gap: value,
145 choices_gap: value * 0.5,
146 choice_gap: value * 0.625,
147 content_gap: value * 0.125,
148 choice_padding_x: value * 0.75,
149 choice_padding_y: value * 0.625,
150 choice_min_height: value * 2.75,
151 choice_radius: radius.lg,
152 indicator_size: value,
153 indicator_mark_size: value * 0.5,
154 indicator_check_size: value * 0.875,
155 shortcut_size: value * 1.25,
156 shortcut_text_size: value * 0.625,
157 shortcut_radius: radius.md,
158 },
159 Size::Medium => Self {
160 root_gap: spacing.lg,
161 item_gap: spacing.lg,
162 choices_gap: spacing.sm,
163 choice_gap: spacing.sm + spacing.xxs,
164 content_gap: spacing.xxs,
165 choice_padding_x: spacing.md,
166 choice_padding_y: spacing.sm + spacing.xxs,
167 choice_min_height: spacing.xxl + spacing.md,
168 choice_radius: radius.lg,
169 indicator_size: spacing.lg,
170 indicator_mark_size: spacing.sm,
171 indicator_check_size: spacing.md + spacing.xxs,
172 shortcut_size: spacing.lg + spacing.xs,
173 shortcut_text_size: spacing.sm + spacing.xxs,
174 shortcut_radius: radius.md,
175 },
176 }
177 }
178}
179
180fn text_style<T: Styled>(element: T, size: Size, cx: &App) -> T {
182 let typography = cx.theme().semantic_tokens().typography;
183 match size {
184 Size::XSmall => apply_text_token(element, typography.xs),
185 Size::Small => apply_text_token(element, typography.sm),
186 Size::Large => apply_text_token(element, typography.lg),
187 Size::Size(value) => element.text_size(value),
188 Size::Medium => apply_text_token(element, typography.md),
189 }
190}
191
192fn secondary_text_style<T: Styled>(element: T, size: Size, cx: &App) -> T {
194 let typography = cx.theme().semantic_tokens().typography;
195 match size {
196 Size::XSmall | Size::Small => apply_text_token(element, typography.xs),
197 Size::Large => apply_text_token(element, typography.md),
198 Size::Size(value) => element.text_size(value * 0.875),
199 Size::Medium => apply_text_token(element, typography.sm),
200 }
201}
202
203fn progress_text_style<T: Styled>(element: T, size: Size, cx: &App) -> T {
204 let typography = cx.theme().semantic_tokens().typography;
205 match size {
206 Size::Large => apply_text_token(element, typography.sm),
207 Size::Size(value) => element.text_size(value * 0.75),
208 _ => apply_text_token(element, typography.xs),
209 }
210 .font_weight(gpui::FontWeight::MEDIUM)
211}
212
213fn description_text_style<T: Styled>(element: T, size: Size, cx: &App) -> T {
214 secondary_text_style(element, size, cx)
215}
216
217fn title_text_style<T: Styled>(element: T, size: Size, cx: &App) -> T {
218 let typography = cx.theme().semantic_tokens().typography;
219 match size {
220 Size::XSmall => apply_text_token(element, typography.sm),
221 Size::Small => apply_text_token(element, typography.md),
222 Size::Large => apply_text_token(element, typography.xl),
223 Size::Size(value) => element.text_size(value * 1.125),
224 Size::Medium => apply_text_token(element, typography.lg),
225 }
226 .font_weight(gpui::FontWeight::MEDIUM)
227}
228
229fn answer_line_height(size: Size, cx: &App) -> gpui::Pixels {
233 let typography = cx.theme().semantic_tokens().typography;
234 match size {
235 Size::XSmall => typography.xs.line_height,
236 Size::Small => typography.sm.line_height,
237 Size::Large => typography.lg.line_height,
238 Size::Size(value) => value * 1.5,
239 Size::Medium => typography.md.line_height,
240 }
241}
242
243fn center_on_answer_line(height: gpui::Pixels, size: Size, cx: &App) -> gpui::Pixels {
245 ((answer_line_height(size, cx) - height) * 0.5).max(gpui::Pixels::ZERO)
246}
247
248fn apply_text_token<T: Styled>(element: T, token: gpui_base::TextStyleToken) -> T {
249 element
250 .text_size(token.size)
251 .line_height(token.line_height)
252 .font_weight(token.weight)
253}
254
255fn item_label(
256 definition: &gpui_base::questionnaire::QuestionnaireItemDefinition,
257) -> Option<SharedString> {
258 Some(definition.accessibility_label().clone())
259}
260
261fn item_description(
262 definition: &gpui_base::questionnaire::QuestionnaireItemDefinition,
263) -> Option<SharedString> {
264 definition.description().cloned()
265}
266
267#[cfg(debug_assertions)]
273#[track_caller]
274fn report_unknown_item(item: &SharedString) {
275 tracing::warn!("questionnaire has no item named `{item}`; the part renders nothing");
276}
277
278#[cfg(debug_assertions)]
280#[track_caller]
281fn report_unknown_choice(item: &SharedString, value: &SharedString) {
282 tracing::warn!(
283 "questionnaire item `{item}` has no choice named `{value}`; the part renders nothing"
284 );
285}
286
287#[cfg(not(debug_assertions))]
288fn report_unknown_item(_: &SharedString) {}
289
290#[cfg(not(debug_assertions))]
291fn report_unknown_choice(_: &SharedString, _: &SharedString) {}
292
293fn element_id(state: &Entity<QuestionnaireState>, suffix: impl std::fmt::Display) -> ElementId {
294 ElementId::Name(format!("questionnaire-{}-{suffix}", state.entity_id()).into())
295}
296
297#[derive(IntoElement)]
300pub struct Questionnaire {
301 state: Entity<QuestionnaireState>,
302 style: StyleRefinement,
303 size: Option<Size>,
304 children: Vec<AnyElement>,
305}
306
307impl Questionnaire {
308 pub fn new(state: &Entity<QuestionnaireState>) -> Self {
309 Self {
310 state: state.clone(),
311 style: StyleRefinement::default(),
312 size: None,
313 children: Vec::new(),
314 }
315 }
316}
317
318impl Sizable for Questionnaire {
319 fn with_size(mut self, size: impl Into<Size>) -> Self {
320 self.size = Some(size.into());
321 self
322 }
323}
324
325impl Styled for Questionnaire {
326 fn style(&mut self) -> &mut StyleRefinement {
327 &mut self.style
328 }
329}
330
331impl ParentElement for Questionnaire {
332 fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
333 self.children.extend(elements);
334 }
335}
336
337impl RenderOnce for Questionnaire {
338 fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
339 let size = self.size.unwrap_or(Size::Medium);
342 publish_size(&self.state, size, cx);
343 let metrics = QuestionnaireMetrics::new(size, cx);
344 let focus_handle = self.state.read(cx).focus_handle().clone();
345 let state = self.state.clone();
346 let debug_selector = format!("questionnaire-{}-root", self.state.entity_id());
347
348 div()
349 .id(element_id(&self.state, "root"))
350 .debug_selector(move || debug_selector)
351 .role(Role::Form)
352 .key_context("Questionnaire")
353 .track_focus(&focus_handle)
354 .capture_key_down(move |event, window, cx| {
355 gpui_base::questionnaire::handle_key_down(&state, event, window, cx)
356 })
357 .flex()
358 .flex_col()
359 .min_w_0()
360 .gap(metrics.root_gap)
361 .w_full()
362 .refine_style(&self.style)
363 .children(self.children)
364 }
365}
366
367#[derive(IntoElement)]
369pub struct QuestionnaireProgress {
370 state: Entity<QuestionnaireState>,
371 style: StyleRefinement,
372 size: Option<Size>,
373 children: Vec<AnyElement>,
374}
375
376impl QuestionnaireProgress {
377 pub fn new(state: &Entity<QuestionnaireState>) -> Self {
378 Self {
379 state: state.clone(),
380 style: StyleRefinement::default(),
381 size: None,
382 children: Vec::new(),
383 }
384 }
385}
386
387impl Sizable for QuestionnaireProgress {
388 fn with_size(mut self, size: impl Into<Size>) -> Self {
389 self.size = Some(size.into());
390 self
391 }
392}
393
394impl Styled for QuestionnaireProgress {
395 fn style(&mut self) -> &mut StyleRefinement {
396 &mut self.style
397 }
398}
399
400impl ParentElement for QuestionnaireProgress {
401 fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
402 self.children.extend(elements);
403 }
404}
405
406impl RenderOnce for QuestionnaireProgress {
407 fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
408 let progress = self.state.read(cx).progress();
409 let current = progress.current();
410 let total = progress.total();
411 let label: SharedString =
412 t!("Questionnaire.progress", current = current, total = total).into();
413 let colors = cx.theme().semantic_tokens().colors;
414 let has_children = !self.children.is_empty();
415
416 let size = resolve_size(self.size, &self.state, cx);
417 progress_text_style(
418 div()
419 .id(element_id(&self.state, "progress"))
420 .role(Role::ProgressIndicator)
421 .aria_label(label.clone())
422 .aria_min_numeric_value(0.)
423 .aria_max_numeric_value(total as f64)
424 .aria_numeric_value(current as f64)
425 .text_color(colors.muted_foreground),
426 size,
427 cx,
428 )
429 .refine_style(&self.style)
430 .when(!has_children, |this| this.child(label))
431 .children(self.children)
432 }
433}
434
435macro_rules! questionnaire_item_part {
436 ($name:ident, $fallback:ident, $style:ident, $color:ident, $closes_item_gap:expr) => {
437 #[derive(IntoElement)]
438 pub struct $name {
439 state: Entity<QuestionnaireState>,
440 item: SharedString,
441 style: StyleRefinement,
442 size: Option<Size>,
443 children: Vec<AnyElement>,
444 }
445
446 impl $name {
447 pub fn new(state: &Entity<QuestionnaireState>, item: impl Into<SharedString>) -> Self {
448 Self {
449 state: state.clone(),
450 item: item.into(),
451 style: StyleRefinement::default(),
452 size: None,
453 children: Vec::new(),
454 }
455 }
456 }
457
458 impl Sizable for $name {
459 fn with_size(mut self, size: impl Into<Size>) -> Self {
460 self.size = Some(size.into());
461 self
462 }
463 }
464
465 impl Styled for $name {
466 fn style(&mut self) -> &mut StyleRefinement {
467 &mut self.style
468 }
469 }
470
471 impl ParentElement for $name {
472 fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
473 self.children.extend(elements);
474 }
475 }
476
477 impl RenderOnce for $name {
478 fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
479 let Some(definition) = self.state.read(cx).item_definition(&self.item) else {
480 report_unknown_item(&self.item);
481 return gpui::Empty.into_any_element();
482 };
483 let fallback = $fallback(definition);
484 let has_children = !self.children.is_empty();
485 if !has_children && fallback.is_none() {
486 return gpui::Empty.into_any_element();
487 }
488 let colors = cx.theme().semantic_tokens().colors;
489 let closes_item_gap = $closes_item_gap && item_description(definition).is_none();
493 let size = resolve_size(self.size, &self.state, cx);
494 $style(div().w_full().text_color(colors.$color), size, cx)
495 .when(closes_item_gap, |this| {
496 this.mb(QuestionnaireMetrics::new(size, cx).item_gap)
497 })
498 .refine_style(&self.style)
499 .when(!has_children, |this| {
500 this.when_some(fallback, |this, fallback| this.child(fallback))
501 })
502 .children(self.children)
503 .into_any_element()
504 }
505 }
506 };
507}
508
509questionnaire_item_part!(
510 QuestionnaireTitle,
511 item_label,
512 title_text_style,
513 foreground,
514 true
515);
516questionnaire_item_part!(
517 QuestionnaireDescription,
518 item_description,
519 description_text_style,
520 muted_foreground,
521 false
522);
523
524#[derive(IntoElement)]
527pub struct QuestionnaireItem {
528 state: Entity<QuestionnaireState>,
529 item: SharedString,
530 style: StyleRefinement,
531 size: Option<Size>,
532 children: Vec<AnyElement>,
533}
534
535impl QuestionnaireItem {
536 pub fn new(state: &Entity<QuestionnaireState>, item: impl Into<SharedString>) -> Self {
537 Self {
538 state: state.clone(),
539 item: item.into(),
540 style: StyleRefinement::default(),
541 size: None,
542 children: Vec::new(),
543 }
544 }
545}
546
547impl Sizable for QuestionnaireItem {
548 fn with_size(mut self, size: impl Into<Size>) -> Self {
549 self.size = Some(size.into());
550 self
551 }
552}
553
554impl Styled for QuestionnaireItem {
555 fn style(&mut self) -> &mut StyleRefinement {
556 &mut self.style
557 }
558}
559
560impl ParentElement for QuestionnaireItem {
561 fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
562 self.children.extend(elements);
563 }
564}
565
566impl RenderOnce for QuestionnaireItem {
567 fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
568 let state = self.state.read(cx);
569 let active = state.current_item().is_some_and(|name| name == &self.item);
570 let Some(item_state) = state.item_state(&self.item) else {
571 report_unknown_item(&self.item);
572 return gpui::Empty.into_any_element();
573 };
574 if !active || item_state.is_disabled() {
575 return gpui::Empty.into_any_element();
576 }
577 let Some(definition) = state.item_definition(&self.item) else {
578 return gpui::Empty.into_any_element();
579 };
580 let focus_handle = state.item_focus_handle(&self.item).cloned();
581 let label = definition.accessibility_label().clone();
582 let description = definition.description().cloned();
583 let metrics = QuestionnaireMetrics::new(resolve_size(self.size, &self.state, cx), cx);
584
585 div()
586 .id(element_id(&self.state, format!("item-{}", self.item)))
587 .role(Role::Group)
588 .aria_label(label)
589 .when_some(description, |this, description| {
590 this.aria_description(description)
591 })
592 .when_some(focus_handle, |this, focus_handle| {
593 this.track_focus(&focus_handle.tab_index(-1).tab_stop(false))
594 })
595 .flex()
596 .flex_col()
597 .gap(metrics.item_gap)
598 .w_full()
599 .refine_style(&self.style)
600 .children(self.children)
601 .into_any_element()
602 }
603}
604
605#[derive(IntoElement)]
607pub struct QuestionnaireChoices {
608 state: Entity<QuestionnaireState>,
609 item: SharedString,
610 style: StyleRefinement,
611 size: Option<Size>,
612 children: Vec<AnyElement>,
613}
614
615impl QuestionnaireChoices {
616 pub fn new(state: &Entity<QuestionnaireState>, item: impl Into<SharedString>) -> Self {
617 Self {
618 state: state.clone(),
619 item: item.into(),
620 style: StyleRefinement::default(),
621 size: None,
622 children: Vec::new(),
623 }
624 }
625}
626
627impl Sizable for QuestionnaireChoices {
628 fn with_size(mut self, size: impl Into<Size>) -> Self {
629 self.size = Some(size.into());
630 self
631 }
632}
633
634impl Styled for QuestionnaireChoices {
635 fn style(&mut self) -> &mut StyleRefinement {
636 &mut self.style
637 }
638}
639
640impl ParentElement for QuestionnaireChoices {
641 fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
642 self.children.extend(elements);
643 }
644}
645
646impl RenderOnce for QuestionnaireChoices {
647 fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
648 let state = self.state.read(cx);
649 let active = state.current_item().is_some_and(|name| name == &self.item);
650 let Some(item) = state.item_state(&self.item) else {
651 report_unknown_item(&self.item);
652 return gpui::Empty.into_any_element();
653 };
654 if !active || item.is_disabled() {
655 return gpui::Empty.into_any_element();
656 }
657 let metrics = QuestionnaireMetrics::new(resolve_size(self.size, &self.state, cx), cx);
658
659 if item.is_multiple() {
660 div()
661 .id(element_id(&self.state, format!("choices-{}", self.item)))
662 .role(Role::Group)
663 .flex()
664 .flex_col()
665 .gap(metrics.choices_gap)
666 .w_full()
667 .refine_style(&self.style)
668 .children(self.children)
669 .into_any_element()
670 } else {
671 RadioGroup::new(element_id(&self.state, format!("choices-{}", self.item)))
672 .flex()
673 .flex_col()
674 .gap(metrics.choices_gap)
675 .w_full()
676 .refine_style(&self.style)
677 .children(self.children)
678 .into_any_element()
679 }
680 }
681}
682
683#[derive(IntoElement)]
685pub struct QuestionnaireChoice {
686 state: Entity<QuestionnaireState>,
687 item: SharedString,
688 value: SharedString,
689 style: StyleRefinement,
690 indicator_style: StyleRefinement,
691 content_style: StyleRefinement,
692 shortcut_style: StyleRefinement,
693 size: Option<Size>,
694 children: Vec<AnyElement>,
695 indicator_renderer: Option<ChoiceRenderer>,
696 shortcut_renderer: Option<ChoiceRenderer>,
697}
698
699impl QuestionnaireChoice {
700 pub fn new(
701 state: &Entity<QuestionnaireState>,
702 item: impl Into<SharedString>,
703 value: impl Into<SharedString>,
704 ) -> Self {
705 Self {
706 state: state.clone(),
707 item: item.into(),
708 value: value.into(),
709 style: StyleRefinement::default(),
710 indicator_style: StyleRefinement::default(),
711 content_style: StyleRefinement::default(),
712 shortcut_style: StyleRefinement::default(),
713 size: None,
714 children: Vec::new(),
715 indicator_renderer: None,
716 shortcut_renderer: None,
717 }
718 }
719
720 pub fn indicator_style(mut self, style: StyleRefinement) -> Self {
721 self.indicator_style = style;
722 self
723 }
724
725 pub fn content_style(mut self, style: StyleRefinement) -> Self {
726 self.content_style = style;
727 self
728 }
729
730 pub fn shortcut_style(mut self, style: StyleRefinement) -> Self {
731 self.shortcut_style = style;
732 self
733 }
734
735 pub fn render_indicator(
736 mut self,
737 renderer: impl Fn(&QuestionnaireChoiceState, &mut Window, &mut App) -> AnyElement + 'static,
738 ) -> Self {
739 self.indicator_renderer = Some(Rc::new(renderer));
740 self
741 }
742
743 pub fn render_shortcut(
744 mut self,
745 renderer: impl Fn(&QuestionnaireChoiceState, &mut Window, &mut App) -> AnyElement + 'static,
746 ) -> Self {
747 self.shortcut_renderer = Some(Rc::new(renderer));
748 self
749 }
750}
751
752impl Sizable for QuestionnaireChoice {
753 fn with_size(mut self, size: impl Into<Size>) -> Self {
754 self.size = Some(size.into());
755 self
756 }
757}
758
759impl Styled for QuestionnaireChoice {
760 fn style(&mut self) -> &mut StyleRefinement {
761 &mut self.style
762 }
763}
764
765impl ParentElement for QuestionnaireChoice {
766 fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
767 self.children.extend(elements);
768 }
769}
770
771#[allow(clippy::too_many_arguments)]
772fn style_choice_card<T>(
773 base: T,
774 indicator: AnyElement,
775 content: AnyElement,
776 shortcut: AnyElement,
777 metrics: QuestionnaireMetrics,
778 selected: bool,
779 disabled: bool,
780 invalid: bool,
781 focused: bool,
782 instance_style: &StyleRefinement,
783 window: &Window,
784 cx: &App,
785) -> T
786where
787 T: Styled + ParentElement + StatefulInteractiveElement + gpui::prelude::FluentBuilder,
788{
789 let tokens = cx.theme().semantic_tokens();
790 base.flex()
791 .items_start()
792 .gap(metrics.choice_gap)
793 .w_full()
794 .min_h(metrics.choice_min_height)
795 .px(metrics.choice_padding_x)
796 .py(metrics.choice_padding_y)
797 .border_1()
798 .border_color(if invalid {
799 tokens.colors.destructive
800 } else if selected {
801 tokens.colors.primary.opacity(0.4)
802 } else {
803 tokens.colors.input
804 })
805 .bg(if selected {
806 tokens.colors.muted
807 } else if cx.theme().is_dark() {
808 tokens.colors.input.opacity(0.2)
809 } else {
810 tokens.colors.background.opacity(0.)
811 })
812 .rounded(metrics.choice_radius)
813 .when(!disabled, |this| {
814 this.hover(|style| style.bg(tokens.colors.muted.opacity(0.5)))
815 })
816 .when(focused, |this| this.focus_ring_style(window, cx))
817 .when(disabled, |this| this.opacity(0.5))
818 .refine_style(instance_style)
819 .child(indicator)
820 .child(content)
821 .child(shortcut)
822}
823
824impl RenderOnce for QuestionnaireChoice {
825 fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
826 let state = self.state.read(cx);
827 let active = state.current_item().is_some_and(|name| name == &self.item);
828 let Some(choice_state) = state.choice_state(&self.item, &self.value) else {
829 if state.item_state(&self.item).is_none() {
830 report_unknown_item(&self.item);
831 } else {
832 report_unknown_choice(&self.item, &self.value);
833 }
834 return gpui::Empty.into_any_element();
835 };
836 if !active {
837 return gpui::Empty.into_any_element();
838 }
839 let Some(item) = state.item_state(&self.item) else {
840 report_unknown_item(&self.item);
841 return gpui::Empty.into_any_element();
842 };
843 let Some(definition) = state.choice_definition(&self.item, &self.value) else {
844 return gpui::Empty.into_any_element();
845 };
846 let multiple = item.is_multiple();
847 let label = definition.accessibility_label().clone();
848 let description = definition.description().cloned();
849 let selected = choice_state.is_selected();
850 let disabled = choice_state.is_disabled();
851 let invalid = choice_state.is_invalid();
852 let shortcut = choice_state.shortcut().cloned();
853 let focus_handle = state.choice_focus_handle(&self.item, &self.value).cloned();
854 let colors = cx.theme().semantic_tokens().colors;
855 let radius = cx.theme().semantic_tokens().radius;
856 let indicator_background = cx.theme().input_background();
857 let mono_font = cx.theme().semantic_tokens().typography.mono.clone();
858 let size = resolve_size(self.size, &self.state, cx);
859 let metrics = QuestionnaireMetrics::new(size, cx);
860 let indicator_offset = center_on_answer_line(metrics.indicator_size, size, cx);
861 let shortcut_offset = center_on_answer_line(metrics.shortcut_size, size, cx);
862 let focused = focus_handle
863 .as_ref()
864 .is_some_and(|focus_handle| focus_handle.is_focused(window));
865 let has_children = !self.children.is_empty();
866 let check_path = crate::resolve_icon_path(IconName::Check.path(), cx);
867
868 let default_indicator = || {
869 div()
870 .relative()
871 .flex()
872 .items_center()
873 .justify_center()
874 .flex_shrink_0()
875 .size(metrics.indicator_size)
876 .border_1()
877 .border_color(if selected {
878 colors.primary
879 } else {
880 colors.input
881 })
882 .bg(if selected {
883 colors.primary
884 } else {
885 indicator_background
886 })
887 .when(multiple, |this| this.rounded(radius.sm))
888 .when(!multiple, |this| this.rounded(radius.full))
889 .refine_style(&self.indicator_style)
890 .when(selected && multiple, |this| {
891 this.child(
892 svg()
893 .size(metrics.indicator_check_size)
894 .path(check_path.clone())
895 .text_color(colors.primary_foreground),
896 )
897 })
898 .when(selected && !multiple, |this| {
899 this.child(
900 div()
901 .size(metrics.indicator_mark_size)
902 .rounded(radius.full)
903 .bg(colors.primary_foreground),
904 )
905 })
906 .into_any_element()
907 };
908
909 let indicator = div()
912 .flex_shrink_0()
913 .mt(indicator_offset)
914 .child(
915 self.indicator_renderer
916 .as_ref()
917 .map(|renderer| renderer(&choice_state, window, cx))
918 .unwrap_or_else(default_indicator),
919 )
920 .into_any_element();
921
922 let content = div()
923 .flex()
924 .flex_1()
925 .flex_col()
926 .gap(metrics.content_gap)
927 .refine_style(&self.content_style)
928 .when(!has_children, |this| {
929 this.child(text_style(
930 div().text_color(colors.foreground).child(label.clone()),
931 size,
932 cx,
933 ))
934 .when_some(description.clone(), |this, description| {
935 this.child(secondary_text_style(
936 div().text_color(colors.muted_foreground).child(description),
937 size,
938 cx,
939 ))
940 })
941 })
942 .children(self.children);
943
944 let os = cx.platform_info().os;
945 let default_shortcut = || {
946 let Some(shortcut) = shortcut.clone() else {
947 return gpui::Empty.into_any_element();
948 };
949 let Ok(keystroke) = gpui::Keystroke::parse_with_os(&shortcut.to_lowercase(), os) else {
950 return gpui::Empty.into_any_element();
951 };
952 Kbd::new(keystroke)
953 .outline()
954 .flex()
955 .items_center()
956 .justify_center()
957 .size(metrics.shortcut_size)
958 .p_0()
959 .bg(colors.background)
960 .border_color(colors.input)
961 .text_color(colors.muted_foreground)
962 .font_family(mono_font.clone())
963 .text_size(metrics.shortcut_text_size)
964 .font_weight(gpui::FontWeight::MEDIUM)
965 .rounded(metrics.shortcut_radius)
966 .refine_style(&self.shortcut_style)
967 .into_any_element()
968 };
969 let shortcut_element = div()
970 .flex_shrink_0()
971 .mt(shortcut_offset)
972 .child(
973 self.shortcut_renderer
974 .as_ref()
975 .map(|renderer| renderer(&choice_state, window, cx))
976 .unwrap_or_else(default_shortcut),
977 )
978 .into_any_element();
979
980 let id = element_id(&self.state, format!("choice-{}-{}", self.item, self.value));
981 let instance_style = self.style.clone();
982 let item_name = self.item.clone();
983 let choice_value = self.value.clone();
984
985 let Some(control) =
986 QuestionnaireChoiceControl::new(&self.state, item_name, choice_value, id, cx)
987 else {
988 return gpui::Empty.into_any_element();
989 };
990 match control {
991 QuestionnaireChoiceControl::Checkbox(base) => style_choice_card(
992 base,
993 indicator,
994 content.into_any_element(),
995 shortcut_element,
996 metrics,
997 selected,
998 disabled,
999 invalid,
1000 focused,
1001 &instance_style,
1002 window,
1003 cx,
1004 )
1005 .into_any_element(),
1006 QuestionnaireChoiceControl::Radio(base) => style_choice_card(
1007 base,
1008 indicator,
1009 content.into_any_element(),
1010 shortcut_element,
1011 metrics,
1012 selected,
1013 disabled,
1014 invalid,
1015 focused,
1016 &instance_style,
1017 window,
1018 cx,
1019 )
1020 .into_any_element(),
1021 }
1022 }
1023}
1024
1025#[derive(IntoElement)]
1027pub struct QuestionnaireChoiceDescription {
1028 style: StyleRefinement,
1029 size: Option<Size>,
1030 children: Vec<AnyElement>,
1031}
1032
1033impl QuestionnaireChoiceDescription {
1034 pub fn new() -> Self {
1035 Self {
1036 style: StyleRefinement::default(),
1037 size: None,
1038 children: Vec::new(),
1039 }
1040 }
1041}
1042
1043impl Default for QuestionnaireChoiceDescription {
1044 fn default() -> Self {
1045 Self::new()
1046 }
1047}
1048
1049impl Sizable for QuestionnaireChoiceDescription {
1050 fn with_size(mut self, size: impl Into<Size>) -> Self {
1051 self.size = Some(size.into());
1052 self
1053 }
1054}
1055
1056impl Styled for QuestionnaireChoiceDescription {
1057 fn style(&mut self) -> &mut StyleRefinement {
1058 &mut self.style
1059 }
1060}
1061
1062impl ParentElement for QuestionnaireChoiceDescription {
1063 fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
1064 self.children.extend(elements);
1065 }
1066}
1067
1068impl RenderOnce for QuestionnaireChoiceDescription {
1069 fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
1070 let colors = cx.theme().semantic_tokens().colors;
1071 secondary_text_style(
1072 div().text_color(colors.muted_foreground),
1073 self.size.unwrap_or(Size::Medium),
1074 cx,
1075 )
1076 .refine_style(&self.style)
1077 .children(self.children)
1078 }
1079}
1080
1081#[derive(IntoElement)]
1083pub struct QuestionnaireInput {
1084 state: Entity<QuestionnaireState>,
1085 item: SharedString,
1086 style: StyleRefinement,
1087 size: Option<Size>,
1088}
1089
1090impl QuestionnaireInput {
1091 pub fn new(state: &Entity<QuestionnaireState>, item: impl Into<SharedString>) -> Self {
1092 Self {
1093 state: state.clone(),
1094 item: item.into(),
1095 style: StyleRefinement::default(),
1096 size: None,
1097 }
1098 }
1099}
1100
1101impl Sizable for QuestionnaireInput {
1102 fn with_size(mut self, size: impl Into<Size>) -> Self {
1103 self.size = Some(size.into());
1104 self
1105 }
1106}
1107
1108impl Styled for QuestionnaireInput {
1109 fn style(&mut self) -> &mut StyleRefinement {
1110 &mut self.style
1111 }
1112}
1113
1114impl RenderOnce for QuestionnaireInput {
1115 fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
1116 let state = self.state.read(cx);
1117 let active = state.current_item().is_some_and(|name| name == &self.item);
1118 let Some(item_state) = state.item_state(&self.item) else {
1119 report_unknown_item(&self.item);
1120 return gpui::Empty.into_any_element();
1121 };
1122 let Some(definition) = state.item_definition(&self.item) else {
1123 return gpui::Empty.into_any_element();
1124 };
1125 let Some(input_definition) = definition.input() else {
1126 return gpui::Empty.into_any_element();
1127 };
1128 if !active {
1129 return gpui::Empty.into_any_element();
1130 }
1131
1132 let size = resolve_size(self.size, &self.state, cx);
1133 let metrics = QuestionnaireMetrics::new(size, cx);
1134
1135 Input::new(input_definition.state())
1136 .aria_label(input_definition.accessibility_label().clone())
1137 .disabled(item_state.is_disabled() || input_definition.is_disabled())
1138 .with_size(size)
1139 .pl(metrics.choice_padding_x)
1142 .rounded(metrics.choice_radius)
1143 .when(item_state.is_invalid(), |this| {
1144 this.border_color(cx.theme().semantic_tokens().colors.destructive)
1145 })
1146 .refine_style(&self.style)
1147 .into_any_element()
1148 }
1149}
1150
1151#[derive(IntoElement)]
1153pub struct QuestionnaireError {
1154 state: Entity<QuestionnaireState>,
1155 item: SharedString,
1156 style: StyleRefinement,
1157 size: Option<Size>,
1158 children: Vec<AnyElement>,
1159}
1160
1161impl QuestionnaireError {
1162 pub fn new(state: &Entity<QuestionnaireState>, item: impl Into<SharedString>) -> Self {
1163 Self {
1164 state: state.clone(),
1165 item: item.into(),
1166 style: StyleRefinement::default(),
1167 size: None,
1168 children: Vec::new(),
1169 }
1170 }
1171}
1172
1173impl Sizable for QuestionnaireError {
1174 fn with_size(mut self, size: impl Into<Size>) -> Self {
1175 self.size = Some(size.into());
1176 self
1177 }
1178}
1179
1180impl Styled for QuestionnaireError {
1181 fn style(&mut self) -> &mut StyleRefinement {
1182 &mut self.style
1183 }
1184}
1185
1186impl ParentElement for QuestionnaireError {
1187 fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
1188 self.children.extend(elements);
1189 }
1190}
1191
1192fn questionnaire_error_root(id: ElementId) -> gpui::Stateful<gpui::Div> {
1193 div().id(id).role(Role::Alert)
1194}
1195
1196fn error_text(error: &QuestionnaireValidationError) -> SharedString {
1198 match error {
1199 QuestionnaireValidationError::Required => t!("Questionnaire.error.required").into(),
1200 QuestionnaireValidationError::Unanswered => t!("Questionnaire.error.optional").into(),
1201 QuestionnaireValidationError::Message(message) => message.clone(),
1202 _ => SharedString::default(),
1203 }
1204}
1205
1206impl RenderOnce for QuestionnaireError {
1207 fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
1208 let state = self.state.read(cx);
1209 let Some(item) = state.item_state(&self.item) else {
1210 report_unknown_item(&self.item);
1211 return gpui::Empty.into_any_element();
1212 };
1213 let error = state.error(&self.item).cloned();
1214 if !item.is_invalid() || error.is_none() {
1215 return gpui::Empty.into_any_element();
1216 }
1217 let has_children = !self.children.is_empty();
1218 let colors = cx.theme().semantic_tokens().colors;
1219 let spacing = cx.theme().semantic_tokens().spacing;
1220 let size = resolve_size(self.size, &self.state, cx);
1221
1222 secondary_text_style(
1223 questionnaire_error_root(element_id(&self.state, format!("error-{}", self.item)))
1224 .mt(spacing.sm)
1225 .text_color(colors.destructive),
1226 size,
1227 cx,
1228 )
1229 .refine_style(&self.style)
1230 .when(!has_children, |this| {
1231 this.when_some(error, |this, error| this.child(error_text(&error)))
1232 })
1233 .children(self.children)
1234 .into_any_element()
1235 }
1236}
1237
1238#[derive(IntoElement)]
1240pub struct QuestionnaireActions {
1241 state: Entity<QuestionnaireState>,
1242 style: StyleRefinement,
1243 size: Option<Size>,
1244 children: Vec<AnyElement>,
1245}
1246
1247impl QuestionnaireActions {
1248 pub fn new(state: &Entity<QuestionnaireState>) -> Self {
1249 Self {
1250 state: state.clone(),
1251 style: StyleRefinement::default(),
1252 size: None,
1253 children: Vec::new(),
1254 }
1255 }
1256}
1257
1258impl Sizable for QuestionnaireActions {
1259 fn with_size(mut self, size: impl Into<Size>) -> Self {
1260 self.size = Some(size.into());
1261 self
1262 }
1263}
1264
1265impl Styled for QuestionnaireActions {
1266 fn style(&mut self) -> &mut StyleRefinement {
1267 &mut self.style
1268 }
1269}
1270
1271impl ParentElement for QuestionnaireActions {
1272 fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
1273 self.children.extend(elements);
1274 }
1275}
1276
1277impl RenderOnce for QuestionnaireActions {
1278 fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
1279 let metrics = QuestionnaireMetrics::new(resolve_size(self.size, &self.state, cx), cx);
1280 let debug_selector = format!("questionnaire-{}-actions", self.state.entity_id());
1281 div()
1282 .id(element_id(&self.state, "actions"))
1283 .debug_selector(move || debug_selector)
1284 .flex()
1285 .min_w_0()
1286 .items_center()
1287 .justify_start()
1288 .gap(metrics.choices_gap)
1289 .w_full()
1290 .refine_style(&self.style)
1291 .children(self.children)
1292 }
1293}
1294
1295#[derive(Clone, Copy)]
1296enum QuestionnaireAction {
1297 Previous,
1298 Skip,
1299 Next,
1300 Submit,
1301}
1302
1303macro_rules! questionnaire_action_part {
1304 ($name:ident, $action:ident, $translation:literal, $outline:expr, $primary:expr) => {
1305 #[derive(IntoElement)]
1306 pub struct $name {
1307 state: Entity<QuestionnaireState>,
1308 style: StyleRefinement,
1309 size: Option<Size>,
1310 children: Vec<AnyElement>,
1311 }
1312
1313 impl $name {
1314 pub fn new(state: &Entity<QuestionnaireState>) -> Self {
1315 Self {
1316 state: state.clone(),
1317 style: StyleRefinement::default(),
1318 size: None,
1319 children: Vec::new(),
1320 }
1321 }
1322 }
1323
1324 impl Styled for $name {
1325 fn style(&mut self) -> &mut StyleRefinement {
1326 &mut self.style
1327 }
1328 }
1329
1330 impl Sizable for $name {
1331 fn with_size(mut self, size: impl Into<Size>) -> Self {
1332 self.size = Some(size.into());
1333 self
1334 }
1335 }
1336
1337 impl ParentElement for $name {
1338 fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
1339 self.children.extend(elements);
1340 }
1341 }
1342
1343 impl RenderOnce for $name {
1344 fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
1345 let navigation = self.state.read(cx).navigation_state();
1346 let action = QuestionnaireAction::$action;
1347 let visible = match action {
1348 QuestionnaireAction::Previous => navigation.is_previous_visible(),
1349 QuestionnaireAction::Skip => navigation.is_skip_visible(),
1350 QuestionnaireAction::Next => navigation.is_next_visible(),
1351 QuestionnaireAction::Submit => navigation.is_submit_visible(),
1352 };
1353 if !visible {
1354 return gpui::Empty.into_any_element();
1355 }
1356
1357 let anchors_trailing_actions = match action {
1358 QuestionnaireAction::Skip => true,
1359 QuestionnaireAction::Next | QuestionnaireAction::Submit => {
1360 !navigation.is_skip_visible()
1361 }
1362 QuestionnaireAction::Previous => false,
1363 };
1364 let state = self.state.clone();
1365 let has_children = !self.children.is_empty();
1366 let debug_selector = format!(
1367 "questionnaire-{}-{}",
1368 self.state.entity_id(),
1369 stringify!($action)
1370 );
1371 Button::new(element_id(&self.state, stringify!($action)))
1372 .debug_selector(move || debug_selector)
1373 .with_size(resolve_size(self.size, &self.state, cx))
1374 .when($outline, |this| this.outline())
1375 .when($primary, |this| this.primary())
1376 .when(anchors_trailing_actions, |this| this.ml_auto())
1377 .on_click(move |_, window, cx| {
1378 state.update(cx, |state, cx| match action {
1379 QuestionnaireAction::Previous => state.go_previous(window, cx),
1380 QuestionnaireAction::Skip => state.skip_current(window, cx),
1381 QuestionnaireAction::Next => state.go_next(window, cx),
1382 QuestionnaireAction::Submit => state.submit(window, cx),
1383 });
1384 })
1385 .refine_style(&self.style)
1386 .when(!has_children, |this| this.label(t!($translation)))
1387 .children(self.children)
1388 .into_any_element()
1389 }
1390 }
1391 };
1392}
1393
1394questionnaire_action_part!(
1395 QuestionnairePrevious,
1396 Previous,
1397 "Questionnaire.previous",
1398 true,
1399 false
1400);
1401questionnaire_action_part!(QuestionnaireSkip, Skip, "Questionnaire.skip", true, false);
1402questionnaire_action_part!(QuestionnaireNext, Next, "Questionnaire.next", false, true);
1403questionnaire_action_part!(
1404 QuestionnaireSubmit,
1405 Submit,
1406 "Questionnaire.submit",
1407 false,
1408 true
1409);
1410
1411#[cfg(test)]
1412mod tests {
1413 use super::*;
1414 use gpui::{
1415 AppContext as _, Context, Element as _, Focusable as _, KeyDownEvent, Keystroke, Render,
1416 TestAppContext, VisualTestContext, accesskit, px,
1417 };
1418
1419 use gpui_base::questionnaire::{
1420 QuestionnaireChoiceDefinition, QuestionnaireEvent, QuestionnaireInputDefinition,
1421 QuestionnaireItemDefinition, QuestionnaireShortcutMode,
1422 };
1423
1424 #[test]
1425 fn compound_parts_support_builder_customization() {
1426 let _ = QuestionnaireChoiceDescription::new()
1427 .opacity(0.8)
1428 .child("Description");
1429 }
1430
1431 struct QuestionnaireHarness {
1432 state: Entity<QuestionnaireState>,
1433 override_skip_margin: bool,
1434 }
1435
1436 impl Render for QuestionnaireHarness {
1437 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
1438 Questionnaire::new(&self.state)
1439 .size(px(480.))
1440 .child(
1441 QuestionnaireItem::new(&self.state, "choice")
1442 .child(
1443 QuestionnaireChoices::new(&self.state, "choice")
1444 .child(QuestionnaireChoice::new(&self.state, "choice", "alpha")),
1445 )
1446 .child(QuestionnaireInput::new(&self.state, "choice")),
1447 )
1448 .child(
1449 QuestionnaireItem::new(&self.state, "first")
1450 .child(
1451 QuestionnaireChoices::new(&self.state, "first")
1452 .child(QuestionnaireChoice::new(&self.state, "first", "alpha"))
1453 .child(QuestionnaireChoice::new(&self.state, "first", "beta")),
1454 )
1455 .child(QuestionnaireInput::new(&self.state, "first")),
1456 )
1457 .child(
1458 QuestionnaireItem::new(&self.state, "second")
1459 .child(QuestionnaireInput::new(&self.state, "second")),
1460 )
1461 .child(
1462 QuestionnaireActions::new(&self.state)
1463 .child(QuestionnairePrevious::new(&self.state))
1464 .child(
1465 QuestionnaireSkip::new(&self.state)
1466 .when(self.override_skip_margin, |this| this.ml(px(0.))),
1467 )
1468 .child(QuestionnaireNext::new(&self.state))
1469 .child(QuestionnaireSubmit::new(&self.state)),
1470 )
1471 }
1472 }
1473
1474 fn visual_harness(
1475 cx: &mut TestAppContext,
1476 items: Vec<QuestionnaireItemDefinition>,
1477 shortcuts: Option<QuestionnaireShortcutMode>,
1478 ) -> (&mut VisualTestContext, Entity<QuestionnaireState>) {
1479 cx.update(crate::init);
1480 let (view, cx) = cx.add_window_view(move |_, cx| {
1481 let state = cx.new(|cx| {
1482 let state = QuestionnaireState::new(items, cx).unwrap();
1483 match shortcuts {
1484 Some(shortcuts) => state.with_shortcuts(shortcuts),
1485 None => state,
1486 }
1487 });
1488 QuestionnaireHarness {
1489 state,
1490 override_skip_margin: false,
1491 }
1492 });
1493 cx.update(|window, cx| window.draw(cx).clear(cx));
1494 let state = cx.update(|_, cx| view.read(cx).state.clone());
1495 cx.update(|window, cx| {
1496 let focus_handle = state.read(cx).focus_handle().clone();
1497 focus_handle.focus(window, cx);
1498 });
1499 (cx, state)
1500 }
1501
1502 fn input_visual_harness(
1503 cx: &mut TestAppContext,
1504 multiple: bool,
1505 ) -> (&mut VisualTestContext, Entity<QuestionnaireState>) {
1506 cx.update(crate::init);
1507 let (view, cx) = cx.add_window_view(move |window, cx| {
1508 let input = cx.new(|cx| crate::input::InputState::new(window, cx));
1509 let state = cx.new(|cx| {
1510 QuestionnaireState::new(
1511 vec![
1512 QuestionnaireItemDefinition::new("first", "First")
1513 .with_required(true)
1514 .with_multiple(multiple)
1515 .with_choices([
1516 QuestionnaireChoiceDefinition::new("alpha", "Alpha"),
1517 QuestionnaireChoiceDefinition::new("beta", "Beta"),
1518 ])
1519 .with_input(QuestionnaireInputDefinition::new(input, "Other")),
1520 QuestionnaireItemDefinition::new("second", "Second"),
1521 ],
1522 cx,
1523 )
1524 .unwrap()
1525 });
1526 QuestionnaireHarness {
1527 state,
1528 override_skip_margin: false,
1529 }
1530 });
1531 cx.update(|window, cx| window.draw(cx).clear(cx));
1532 let state = cx.update(|_, cx| view.read(cx).state.clone());
1533 (cx, state)
1534 }
1535
1536 fn actions_visual_harness(
1537 cx: &mut TestAppContext,
1538 override_skip_margin: bool,
1539 ) -> (&mut VisualTestContext, Entity<QuestionnaireState>) {
1540 cx.update(crate::init);
1541 let (view, cx) = cx.add_window_view(|_, cx| {
1542 let state = cx.new(|cx| {
1543 QuestionnaireState::new(
1544 vec![
1545 QuestionnaireItemDefinition::new("first", "First").with_required(true),
1546 QuestionnaireItemDefinition::new("second", "Second"),
1547 QuestionnaireItemDefinition::new("third", "Third").with_required(true),
1548 ],
1549 cx,
1550 )
1551 .unwrap()
1552 .with_current_item("second")
1553 .unwrap()
1554 });
1555 QuestionnaireHarness {
1556 state,
1557 override_skip_margin,
1558 }
1559 });
1560 cx.update(|window, cx| window.draw(cx).clear(cx));
1561 let state = cx.update(|_, cx| view.read(cx).state.clone());
1562 (cx, state)
1563 }
1564
1565 fn focus_input(cx: &mut VisualTestContext, state: &Entity<QuestionnaireState>, item: &str) {
1566 cx.update(|window, cx| {
1567 let input = state.read(cx).input_state(item).unwrap();
1568 let focus_handle = input.read(cx).focus_handle(cx);
1569 focus_handle.focus(window, cx);
1570 });
1571 }
1572
1573 fn simulate_key(cx: &mut VisualTestContext, key: &str, is_held: bool, simulate_ime: bool) {
1574 let os = cx.update(|_, cx| cx.platform_info().os);
1575 let mut keystroke = Keystroke::parse_with_os(key, os).unwrap();
1576 if simulate_ime {
1577 keystroke = keystroke.with_simulated_ime();
1578 }
1579 cx.simulate_event(KeyDownEvent {
1580 keystroke,
1581 is_held,
1582 prefer_character_input: false,
1583 });
1584 }
1585
1586 #[gpui::test]
1587 fn progress_projects_numeric_accessibility(cx: &mut TestAppContext) {
1588 cx.update(crate::init);
1589 let window = cx.add_empty_window();
1590 window.update(|window, cx| {
1591 let state = cx.new(|cx| {
1592 QuestionnaireState::new(
1593 vec![
1594 QuestionnaireItemDefinition::new("first", "First"),
1595 QuestionnaireItemDefinition::new("second", "Second"),
1596 ],
1597 cx,
1598 )
1599 .unwrap()
1600 });
1601 let mut node = accesskit::Node::new(Role::ProgressIndicator);
1602 QuestionnaireProgress::new(&state)
1603 .render(window, cx)
1604 .into_element()
1605 .write_a11y_info(&mut node);
1606
1607 assert_eq!(node.numeric_value(), Some(1.));
1608 assert_eq!(node.min_numeric_value(), Some(0.));
1609 assert_eq!(node.max_numeric_value(), Some(2.));
1610
1611 let root = Questionnaire::new(&state).render(window, cx).into_element();
1612 assert_eq!(root.a11y_role(), Some(Role::Form));
1613 });
1614 }
1615
1616 #[gpui::test]
1617 fn actions_stay_inside_questionnaire_width(cx: &mut TestAppContext) {
1618 let (cx, state) = actions_visual_harness(cx, false);
1619 let entity_id = state.entity_id();
1620 let root_id = Box::leak(format!("questionnaire-{entity_id}-root").into_boxed_str());
1621 let actions_id = Box::leak(format!("questionnaire-{entity_id}-actions").into_boxed_str());
1622 let previous_id = Box::leak(format!("questionnaire-{entity_id}-Previous").into_boxed_str());
1623 let skip_id = Box::leak(format!("questionnaire-{entity_id}-Skip").into_boxed_str());
1624 let next_id = Box::leak(format!("questionnaire-{entity_id}-Next").into_boxed_str());
1625 let submit_id = Box::leak(format!("questionnaire-{entity_id}-Submit").into_boxed_str());
1626 let root = cx.debug_bounds(root_id).expect("questionnaire rendered");
1627 let actions = cx.debug_bounds(actions_id).expect("actions rendered");
1628 let previous = cx.debug_bounds(previous_id).expect("previous rendered");
1629 let skip = cx.debug_bounds(skip_id).expect("skip rendered");
1630 let next = cx.debug_bounds(next_id).expect("next rendered");
1631
1632 assert!(actions.left() >= root.left());
1633 assert!(actions.right() <= root.right());
1634 assert_eq!(previous.left(), actions.left());
1635 assert!(previous.right() <= skip.left());
1636 assert!(skip.right() <= next.left());
1637 assert_eq!(next.right(), actions.right());
1638
1639 cx.update(|window, cx| {
1640 state
1641 .update(cx, |state, cx| state.set_current_item("first", window, cx))
1642 .unwrap();
1643 window.draw(cx).clear(cx);
1644 });
1645 let first_actions = cx.debug_bounds(actions_id).expect("first actions rendered");
1646 let first_next = cx.debug_bounds(next_id).expect("first next rendered");
1647 assert!(first_next.left() >= first_actions.left());
1648 assert_eq!(first_next.right(), first_actions.right());
1649
1650 cx.update(|window, cx| {
1651 state
1652 .update(cx, |state, cx| state.set_current_item("third", window, cx))
1653 .unwrap();
1654 window.draw(cx).clear(cx);
1655 });
1656 let last_actions = cx.debug_bounds(actions_id).expect("last actions rendered");
1657 let last_previous = cx
1658 .debug_bounds(previous_id)
1659 .expect("last previous rendered");
1660 let submit = cx.debug_bounds(submit_id).expect("submit rendered");
1661 assert_eq!(last_previous.left(), last_actions.left());
1662 assert!(last_previous.right() <= submit.left());
1663 assert_eq!(submit.right(), last_actions.right());
1664 }
1665
1666 #[gpui::test]
1667 fn action_instance_style_overrides_default_trailing_anchor(cx: &mut TestAppContext) {
1668 let (cx, state) = actions_visual_harness(cx, true);
1669 let entity_id = state.entity_id();
1670 let actions_id = Box::leak(format!("questionnaire-{entity_id}-actions").into_boxed_str());
1671 let previous_id = Box::leak(format!("questionnaire-{entity_id}-Previous").into_boxed_str());
1672 let skip_id = Box::leak(format!("questionnaire-{entity_id}-Skip").into_boxed_str());
1673 let next_id = Box::leak(format!("questionnaire-{entity_id}-Next").into_boxed_str());
1674 let actions = cx.debug_bounds(actions_id).expect("actions rendered");
1675 let previous = cx.debug_bounds(previous_id).expect("previous rendered");
1676 let skip = cx.debug_bounds(skip_id).expect("skip rendered");
1677 let next = cx.debug_bounds(next_id).expect("next rendered");
1678
1679 assert_eq!(previous.left(), actions.left());
1680 assert!(previous.right() <= skip.left());
1681 assert!(skip.right() <= next.left());
1682 assert!(next.right() < actions.right());
1683 }
1684
1685 struct ScaleHarness {
1686 state: Entity<QuestionnaireState>,
1687 root_size: Size,
1688 part_size: Option<Size>,
1689 }
1690
1691 impl Render for ScaleHarness {
1692 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
1693 let part_size = self.part_size;
1694 Questionnaire::new(&self.state)
1695 .with_size(self.root_size)
1696 .size(px(480.))
1697 .child(
1698 QuestionnaireItem::new(&self.state, "scale")
1699 .when_some(part_size, |this, size| this.with_size(size))
1700 .child(
1701 QuestionnaireChoices::new(&self.state, "scale")
1702 .when_some(part_size, |this, size| this.with_size(size))
1703 .child(
1704 div()
1705 .id("scale-choice")
1706 .debug_selector(|| "scale-choice".to_string())
1707 .child(
1708 QuestionnaireChoice::new(&self.state, "scale", "alpha")
1709 .when_some(part_size, |this, size| {
1710 this.with_size(size)
1711 }),
1712 ),
1713 ),
1714 ),
1715 )
1716 }
1717 }
1718
1719 fn scale_harness(
1720 cx: &mut TestAppContext,
1721 root_size: Size,
1722 part_size: Option<Size>,
1723 ) -> gpui::Bounds<gpui::Pixels> {
1724 cx.update(crate::init);
1725 let (_view, cx) = cx.add_window_view(move |_, cx| {
1726 let state = cx.new(|cx| {
1727 QuestionnaireState::new(
1728 vec![
1729 QuestionnaireItemDefinition::new("scale", "Scale")
1730 .with_choice(QuestionnaireChoiceDefinition::new("alpha", "Alpha")),
1731 ],
1732 cx,
1733 )
1734 .unwrap()
1735 });
1736 ScaleHarness {
1737 state,
1738 root_size,
1739 part_size,
1740 }
1741 });
1742 cx.update(|window, cx| window.draw(cx).clear(cx));
1743 cx.debug_bounds("scale-choice").expect("choice rendered")
1744 }
1745
1746 #[gpui::test]
1747 fn parts_take_their_scale_from_the_root_and_a_part_may_override_it(cx: &mut TestAppContext) {
1748 let inherited = scale_harness(cx, Size::Small, None);
1751 let explicit = scale_harness(cx, Size::Small, Some(Size::Small));
1752 assert_eq!(inherited.size, explicit.size);
1753
1754 let large_root = scale_harness(cx, Size::Large, None);
1756 assert!(large_root.size.height > inherited.size.height);
1757
1758 let overridden = scale_harness(cx, Size::Large, Some(Size::Small));
1760 assert_eq!(overridden.size, inherited.size);
1761 }
1762
1763 #[gpui::test]
1764 fn shortcut_guards_held_keys_before_activation(cx: &mut TestAppContext) {
1765 let (cx, state) = visual_harness(
1766 cx,
1767 vec![
1768 QuestionnaireItemDefinition::new("choice", "Choice")
1769 .with_choice(QuestionnaireChoiceDefinition::new("alpha", "Alpha")),
1770 QuestionnaireItemDefinition::new("second", "Second"),
1771 ],
1772 Some(QuestionnaireShortcutMode::Letters),
1773 );
1774
1775 simulate_key(cx, "a", true, true);
1776 simulate_key(cx, "a", false, false);
1777 simulate_key(cx, "shift-a", false, true);
1778 cx.update(|_, cx| assert!(state.read(cx).answer("choice").unwrap().is_empty()));
1779
1780 simulate_key(cx, "a", false, true);
1781 cx.update(|window, cx| {
1782 assert_eq!(
1783 state.read(cx).answer("choice").unwrap().choices(),
1784 &[SharedString::from("alpha")]
1785 );
1786 assert_eq!(
1787 state
1788 .read(cx)
1789 .focused_current_choice(window)
1790 .map(SharedString::as_ref),
1791 Some("alpha")
1792 );
1793 });
1794 simulate_key(cx, "enter", false, false);
1795 cx.update(|_, cx| {
1796 assert_eq!(state.read(cx).current_item().unwrap().as_ref(), "second");
1797 });
1798 }
1799
1800 #[gpui::test]
1801 fn root_keyboard_preserves_navigation_and_radio_semantics(cx: &mut TestAppContext) {
1802 let (cx, state) = visual_harness(
1803 cx,
1804 vec![
1805 QuestionnaireItemDefinition::new("first", "First")
1806 .with_required(true)
1807 .with_choices([
1808 QuestionnaireChoiceDefinition::new("alpha", "Alpha"),
1809 QuestionnaireChoiceDefinition::new("beta", "Beta"),
1810 ]),
1811 QuestionnaireItemDefinition::new("second", "Second"),
1812 ],
1813 None,
1814 );
1815
1816 simulate_key(cx, "right", false, false);
1817 cx.update(|_, cx| {
1818 assert_eq!(state.read(cx).current_item().unwrap().as_ref(), "first");
1819 assert!(state.read(cx).answer("first").unwrap().is_empty());
1820 });
1821
1822 cx.update(|window, cx| {
1823 let focus_handle = state
1824 .read(cx)
1825 .choice_focus_handle("first", "alpha")
1826 .unwrap()
1827 .clone();
1828 focus_handle.focus(window, cx);
1829 });
1830 simulate_key(cx, "enter", false, false);
1831 cx.update(|_, cx| {
1832 assert_eq!(state.read(cx).current_item().unwrap().as_ref(), "first");
1833 assert!(state.read(cx).answer("first").unwrap().is_empty());
1834 });
1835
1836 simulate_key(cx, "right", false, false);
1837 cx.update(|window, cx| window.draw(cx).clear(cx));
1838 cx.update(|_, cx| {
1839 assert_eq!(
1840 state.read(cx).answer("first").unwrap().choices(),
1841 &[SharedString::from("beta")]
1842 );
1843 assert_eq!(state.read(cx).current_item().unwrap().as_ref(), "first");
1844 });
1845
1846 simulate_key(cx, "enter", false, false);
1847 cx.update(|_, cx| {
1848 assert_eq!(state.read(cx).current_item().unwrap().as_ref(), "second");
1849 });
1850 }
1851
1852 #[gpui::test]
1853 fn empty_input_enter_stays_put_and_arrows_move_to_answers(cx: &mut TestAppContext) {
1854 let (cx, state) = input_visual_harness(cx, false);
1855 focus_input(cx, &state, "first");
1856
1857 simulate_key(cx, "enter", false, false);
1858 cx.update(|_, cx| {
1859 assert_eq!(state.read(cx).current_item().unwrap().as_ref(), "first");
1860 assert!(state.read(cx).answer("first").unwrap().is_empty());
1861 assert!(state.read(cx).error("first").is_none());
1862 });
1863
1864 simulate_key(cx, "secondary-enter", false, false);
1865 cx.update(|_, cx| {
1866 assert_eq!(state.read(cx).current_item().unwrap().as_ref(), "first");
1867 assert!(state.read(cx).error("first").is_some());
1868 });
1869
1870 focus_input(cx, &state, "first");
1871 simulate_key(cx, "up", false, false);
1872 cx.update(|window, cx| {
1873 assert_eq!(
1874 state
1875 .read(cx)
1876 .focused_current_choice(window)
1877 .map(SharedString::as_ref),
1878 Some("beta")
1879 );
1880 });
1881
1882 simulate_key(cx, "down", false, false);
1883 cx.update(|window, cx| {
1884 assert!(state.read(cx).is_current_input_focused(window));
1885 assert_eq!(
1886 state.read(cx).answer("first").unwrap().choices(),
1887 &[SharedString::from("beta")]
1888 );
1889 });
1890
1891 simulate_key(cx, "down", false, false);
1892 cx.update(|window, cx| {
1893 assert_eq!(
1894 state
1895 .read(cx)
1896 .focused_current_choice(window)
1897 .map(SharedString::as_ref),
1898 Some("alpha")
1899 );
1900 assert_eq!(
1901 state.read(cx).answer("first").unwrap().choices(),
1902 &[SharedString::from("alpha")]
1903 );
1904 });
1905
1906 simulate_key(cx, "down", false, false);
1907 cx.update(|window, cx| {
1908 assert_eq!(
1909 state
1910 .read(cx)
1911 .focused_current_choice(window)
1912 .map(SharedString::as_ref),
1913 Some("beta")
1914 );
1915 assert_eq!(
1916 state.read(cx).answer("first").unwrap().choices(),
1917 &[SharedString::from("beta")]
1918 );
1919 });
1920
1921 cx.update(|window, cx| {
1922 state.update(cx, |state, cx| {
1923 state
1924 .set_input_value("first", "Preserved draft", window, cx)
1925 .unwrap();
1926 state.activate_choice("first", "alpha", cx).unwrap();
1927 });
1928 });
1929 focus_input(cx, &state, "first");
1930 simulate_key(cx, "enter", false, false);
1931 cx.update(|_, cx| {
1932 let answer = state.read(cx).answer("first").unwrap();
1933 assert_eq!(state.read(cx).current_item().unwrap().as_ref(), "first");
1934 assert_eq!(answer.choices(), &[SharedString::from("alpha")]);
1935 assert!(answer.freeform().is_none());
1936 });
1937 }
1938
1939 #[gpui::test]
1940 fn filled_group_input_keeps_text_editing_directions(cx: &mut TestAppContext) {
1941 let (cx, state) = input_visual_harness(cx, true);
1942 cx.update(|window, cx| {
1943 state
1944 .update(cx, |state, cx| {
1945 state.set_input_value("first", "Freeform answer", window, cx)
1946 })
1947 .unwrap();
1948 });
1949 focus_input(cx, &state, "first");
1950
1951 simulate_key(cx, "down", false, false);
1952 cx.update(|window, cx| {
1953 let state = state.read(cx);
1954 assert!(state.is_current_input_focused(window));
1955 assert_eq!(
1956 state
1957 .answer("first")
1958 .unwrap()
1959 .freeform()
1960 .map(SharedString::as_ref),
1961 Some("Freeform answer")
1962 );
1963 assert!(state.answer("first").unwrap().choices().is_empty());
1964 });
1965
1966 simulate_key(cx, "up", false, false);
1967 cx.update(|window, cx| {
1968 let state = state.read(cx);
1969 assert!(state.is_current_input_focused(window));
1970 assert_eq!(
1971 state
1972 .answer("first")
1973 .unwrap()
1974 .freeform()
1975 .map(SharedString::as_ref),
1976 Some("Freeform answer")
1977 );
1978 assert!(state.answer("first").unwrap().choices().is_empty());
1979 });
1980 }
1981
1982 struct ChooseHarness {
1986 state: Entity<QuestionnaireState>,
1987 submits: usize,
1988 _subscription: gpui::Subscription,
1989 }
1990
1991 impl Render for ChooseHarness {
1992 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
1993 let state = &self.state;
1994 let choice = |item: &'static str, value: &'static str| {
1995 div()
1996 .id(SharedString::from(format!("choice-{item}-{value}")))
1997 .debug_selector(move || format!("choice-{item}-{value}"))
1998 .child(QuestionnaireChoice::new(state, item, value))
1999 };
2000 Questionnaire::new(state)
2001 .size(px(480.))
2002 .child(
2003 QuestionnaireItem::new(state, "first").child(
2004 QuestionnaireChoices::new(state, "first")
2005 .child(choice("first", "alpha"))
2006 .child(choice("first", "beta")),
2007 ),
2008 )
2009 .child(
2010 QuestionnaireItem::new(state, "second").child(
2011 QuestionnaireChoices::new(state, "second")
2012 .child(choice("second", "x"))
2013 .child(choice("second", "y")),
2014 ),
2015 )
2016 .child(
2017 QuestionnaireItem::new(state, "third")
2018 .child(
2019 QuestionnaireChoices::new(state, "third")
2020 .child(choice("third", "omega")),
2021 )
2022 .child(QuestionnaireInput::new(state, "third")),
2023 )
2024 }
2025 }
2026
2027 fn choose_harness(
2028 cx: &mut TestAppContext,
2029 ) -> (
2030 &mut VisualTestContext,
2031 Entity<ChooseHarness>,
2032 Entity<QuestionnaireState>,
2033 ) {
2034 cx.update(crate::init);
2035 let (view, cx) = cx.add_window_view(|window, cx| {
2036 let input = cx.new(|cx| crate::input::InputState::new(window, cx));
2037 let state = cx.new(|cx| {
2038 QuestionnaireState::new(
2039 vec![
2040 QuestionnaireItemDefinition::new("first", "First").with_choices([
2041 QuestionnaireChoiceDefinition::new("alpha", "Alpha"),
2042 QuestionnaireChoiceDefinition::new("beta", "Beta"),
2043 ]),
2044 QuestionnaireItemDefinition::new("second", "Second")
2045 .with_multiple(true)
2046 .with_choices([
2047 QuestionnaireChoiceDefinition::new("x", "X"),
2048 QuestionnaireChoiceDefinition::new("y", "Y"),
2049 ]),
2050 QuestionnaireItemDefinition::new("third", "Third")
2051 .with_choice(QuestionnaireChoiceDefinition::new("omega", "Omega"))
2052 .with_input(QuestionnaireInputDefinition::new(input, "Other")),
2053 ],
2054 cx,
2055 )
2056 .unwrap()
2057 .with_shortcuts(QuestionnaireShortcutMode::Numbers)
2058 });
2059 let subscription = cx.subscribe(&state, |this: &mut ChooseHarness, _, event, _| {
2060 if matches!(event, QuestionnaireEvent::Submit(_)) {
2061 this.submits += 1;
2062 }
2063 });
2064 ChooseHarness {
2065 state,
2066 submits: 0,
2067 _subscription: subscription,
2068 }
2069 });
2070 cx.update(|window, cx| window.draw(cx).clear(cx));
2071 let state = cx.update(|_, cx| view.read(cx).state.clone());
2072 cx.update(|window, cx| {
2073 let focus_handle = state.read(cx).focus_handle().clone();
2074 focus_handle.focus(window, cx);
2075 });
2076 (cx, view, state)
2077 }
2078
2079 fn wait_out_hold(cx: &mut VisualTestContext) {
2081 cx.executor()
2082 .advance_clock(std::time::Duration::from_millis(200));
2083 cx.run_until_parked();
2084 cx.update(|window, cx| window.draw(cx).clear(cx));
2085 }
2086
2087 fn click_choice(cx: &mut VisualTestContext, item: &str, value: &str) {
2088 cx.update(|window, cx| window.draw(cx).clear(cx));
2089 let selector = Box::leak(format!("choice-{item}-{value}").into_boxed_str());
2090 let bounds = cx.debug_bounds(selector).expect("choice rendered");
2091 cx.simulate_click(bounds.center(), gpui::Modifiers::default());
2092 }
2093
2094 fn press(cx: &mut VisualTestContext, key: &str) {
2095 let os = cx.update(|_, cx| cx.platform_info().os);
2096 let keystroke = Keystroke::parse_with_os(key, os).unwrap();
2097 cx.simulate_event(KeyDownEvent {
2098 keystroke: keystroke.clone(),
2099 is_held: false,
2100 prefer_character_input: false,
2101 });
2102 cx.simulate_event(gpui::KeyUpEvent { keystroke });
2103 }
2104
2105 fn focus_choice(
2106 cx: &mut VisualTestContext,
2107 state: &Entity<QuestionnaireState>,
2108 item: &str,
2109 value: &str,
2110 ) {
2111 cx.update(|window, cx| {
2112 let focus_handle = state
2113 .read(cx)
2114 .choice_focus_handle(item, value)
2115 .unwrap()
2116 .clone();
2117 focus_handle.focus(window, cx);
2118 });
2119 }
2120
2121 fn current(cx: &mut VisualTestContext, state: &Entity<QuestionnaireState>) -> SharedString {
2122 cx.update(|_, cx| state.read(cx).current_item().unwrap().clone())
2123 }
2124
2125 fn choices(
2126 cx: &mut VisualTestContext,
2127 state: &Entity<QuestionnaireState>,
2128 item: &str,
2129 ) -> Vec<SharedString> {
2130 cx.update(|_, cx| state.read(cx).answer(item).unwrap().choices().to_vec())
2131 }
2132
2133 #[gpui::test]
2134 fn clicking_a_single_choice_shows_it_then_advances(cx: &mut TestAppContext) {
2135 let (cx, view, state) = choose_harness(cx);
2136
2137 click_choice(cx, "first", "alpha");
2138 cx.run_until_parked();
2139 assert_eq!(
2140 choices(cx, &state, "first"),
2141 vec![SharedString::from("alpha")]
2142 );
2143 assert_eq!(current(cx, &state), "first", "the selection paints first");
2144 wait_out_hold(cx);
2145 assert_eq!(current(cx, &state), "second");
2146
2147 click_choice(cx, "second", "x");
2148 wait_out_hold(cx);
2149 assert_eq!(current(cx, &state), "second", "a checkbox only toggles");
2150 assert_eq!(choices(cx, &state, "second"), vec![SharedString::from("x")]);
2151
2152 cx.update(|window, cx| state.update(cx, |state, cx| state.go_next(window, cx)));
2153 focus_input(cx, &state, "third");
2154 cx.simulate_input("draft");
2155 wait_out_hold(cx);
2156 assert_eq!(current(cx, &state), "third", "typing never advances");
2157
2158 click_choice(cx, "third", "omega");
2159 click_choice(cx, "third", "omega");
2160 wait_out_hold(cx);
2161 assert_eq!(
2162 cx.update(|_, cx| view.read(cx).submits),
2163 1,
2164 "a double click on the last item submits once"
2165 );
2166
2167 cx.update(|window, cx| {
2169 state
2170 .update(cx, |state, cx| state.set_current_item("first", window, cx))
2171 .unwrap();
2172 });
2173 click_choice(cx, "first", "alpha");
2174 assert_eq!(current(cx, &state), "second");
2175 }
2176
2177 #[gpui::test]
2178 fn shortcuts_and_space_advance_while_arrows_only_move(cx: &mut TestAppContext) {
2179 let (cx, _, state) = choose_harness(cx);
2180
2181 focus_choice(cx, &state, "first", "alpha");
2182 press(cx, "down");
2183 wait_out_hold(cx);
2184 assert_eq!(
2185 choices(cx, &state, "first"),
2186 vec![SharedString::from("beta")]
2187 );
2188 assert_eq!(
2189 current(cx, &state),
2190 "first",
2191 "an arrow moves the selection only"
2192 );
2193
2194 simulate_key(cx, "1", false, true);
2195 cx.run_until_parked();
2196 assert_eq!(
2197 choices(cx, &state, "first"),
2198 vec![SharedString::from("alpha")]
2199 );
2200 assert_eq!(current(cx, &state), "first");
2201 wait_out_hold(cx);
2202 assert_eq!(current(cx, &state), "second");
2203
2204 simulate_key(cx, "2", false, true);
2205 wait_out_hold(cx);
2206 assert_eq!(
2207 current(cx, &state),
2208 "second",
2209 "a multiple-answer shortcut toggles"
2210 );
2211 assert_eq!(choices(cx, &state, "second"), vec![SharedString::from("y")]);
2212
2213 cx.update(|window, cx| state.update(cx, |state, cx| state.go_previous(window, cx)));
2214 focus_choice(cx, &state, "first", "beta");
2215 press(cx, "space");
2216 wait_out_hold(cx);
2217 assert_eq!(
2218 choices(cx, &state, "first"),
2219 vec![SharedString::from("beta")]
2220 );
2221 assert_eq!(current(cx, &state), "second", "Space chooses and advances");
2222 }
2223
2224 #[test]
2225 fn invalid_error_projects_alert_role() {
2226 let error = questionnaire_error_root("questionnaire-error-test".into()).into_element();
2227 assert_eq!(error.a11y_role(), Some(Role::Alert));
2228 }
2229}