Skip to main content

gpui_component/questionnaire/
components.rs

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/// Where a questionnaire's scale lives between the root and its parts.
28///
29/// GPUI has no style cascade, and `with_rem_size` resolves during layout, which
30/// a `RenderOnce` part cannot reach. So the root records the scale it was given
31/// under its state's id, and every part of that questionnaire reads it back. A
32/// part still accepts its own `with_size`, which wins.
33///
34/// A root renders before its children, so the entry is in place by the time a
35/// part looks for it; a part rendered outside a root falls back to `Medium`.
36#[derive(Default)]
37struct QuestionnaireSizes(std::collections::HashMap<gpui::EntityId, Size>);
38
39impl gpui::Global for QuestionnaireSizes {}
40
41/// Bounded so an application that builds and drops many questionnaires cannot
42/// grow the table without end. Every root rewrites its entry on the next frame,
43/// so clearing it costs at most one frame at the default scale.
44const 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/// The questionnaire skin's geometry, following the ReUI `base-nova`
64/// questionnaire at `Medium`. Every number comes from the semantic spacing and
65/// radius tokens; the size picks which of them apply.
66#[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
180/// Answer text matches the Checkbox and Radio family's label at each size.
181fn 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
192/// Secondary text sits one step below the answer text.
193fn 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
229/// The line box the answer label occupies. An indicator or a shortcut badge
230/// centers on that first line, so a two-line answer keeps them beside the
231/// label rather than drifting toward the description.
232fn 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
243/// How far to push an adornment of `height` down so it centers on that line.
244fn 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/// A part addresses its question by name. A name the schema does not define
268/// renders nothing, which is silent enough to hide a typo, so a debug build
269/// names what went missing. It is a warning rather than an assertion because
270/// one view may legitimately render the parts of several questionnaires and
271/// hand each a state that defines only some of them.
272#[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/// The same for a choice value inside a question that does exist.
279#[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/// The composable questionnaire root. It owns layout and keyboard routing while
298/// [`QuestionnaireState`] remains the single source of behavioral state.
299#[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        // The root is the one place a caller names the scale, so it records it
340        // for the parts before any of them render.
341        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/// Textual progress, following the ReUI `base-nova` questionnaire skin.
368#[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                // The item stacks its parts on one gap. A title with no
490                // description of its own closes the gap the description would
491                // have filled, so answers never crowd the question.
492                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/// The active question group. Inactive or disabled items do not enter layout,
525/// focus traversal, or the accessibility tree.
526#[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/// Container for an item's answer controls.
606#[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/// A selectable choice card, following the ReUI `base-nova` questionnaire skin.
684#[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        // The slot, not the element, owns the vertical alignment, so a custom
910        // indicator lands on the label's line without having to know the metrics.
911        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/// Secondary text for custom choice compositions.
1026#[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/// The optional freeform answer input for an item.
1082#[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            // The freeform answer is one of the answers, so its text starts on
1140            // the same edge a choice's indicator does — the card padding.
1141            .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/// Validation error for an item. It only enters the tree while invalid.
1152#[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
1196/// Base reports why an item failed; the skin owns the sentence a person reads.
1197fn 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/// Layout part for questionnaire navigation actions.
1239#[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        // A part left alone must land exactly where the same part told to use
1749        // the root's size lands: the root publishes the scale for all of them.
1750        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        // A different root scale has to move the part, or nothing was inherited.
1755        let large_root = scale_harness(cx, Size::Large, None);
1756        assert!(large_root.size.height > inherited.size.height);
1757
1758        // A part that names its own size keeps it whatever the root says.
1759        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    /// A single-answer item, a multiple-answer item, then a last single-answer
1983    /// item with a freeform input; each choice sits in a wrapper the test can
1984    /// click by its debug selector.
1985    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    /// Past the hold a newly chosen single answer keeps before confirming.
2080    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        // Choosing the selected answer again after going back confirms it.
2168        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}