Skip to main content

gpui_base/questionnaire/
state.rs

1use std::{collections::HashSet, time::Duration};
2
3use crate::input::{InputEvent, InputState};
4use gpui::{
5    App, Context, Entity, EventEmitter, FocusHandle, Focusable as _, SharedString, Subscription,
6    Task, Window,
7};
8
9use super::types::*;
10
11/// How long a newly chosen single answer stays on screen, selected, before the
12/// item confirms. Long enough to register the selection, short enough not to
13/// read as a wait.
14const CHOICE_CONFIRM_DELAY: Duration = Duration::from_millis(150);
15
16struct ItemRuntime {
17    disabled: bool,
18    choice_disabled: Vec<bool>,
19    input_disabled: bool,
20    answer: QuestionnaireAnswer,
21    initial_answer: QuestionnaireAnswer,
22    initial_input_value: Option<SharedString>,
23    skipped: bool,
24    validation_attempted: bool,
25    internal_error: Option<QuestionnaireValidationError>,
26    external_error: Option<QuestionnaireValidationError>,
27    focus_handle: FocusHandle,
28    choice_focus_handles: Vec<FocusHandle>,
29    input_focus_handle: Option<FocusHandle>,
30}
31
32/// Owns questionnaire answers, validation, navigation and focus state.
33pub struct QuestionnaireState {
34    items: Vec<QuestionnaireItemDefinition>,
35    runtime: Vec<ItemRuntime>,
36    current: Option<usize>,
37    initial_current: Option<SharedString>,
38    shortcut_mode: Option<QuestionnaireShortcutMode>,
39    complete: bool,
40    /// The confirm a chosen single answer scheduled. Changing an answer or the
41    /// current item, or confirming by any other path, drops it first.
42    pending_confirm: Option<Task<()>>,
43    focus_handle: FocusHandle,
44    _subscriptions: Vec<Subscription>,
45}
46
47impl QuestionnaireState {
48    pub fn new(
49        items: Vec<QuestionnaireItemDefinition>,
50        cx: &mut Context<Self>,
51    ) -> Result<Self, QuestionnaireSchemaError> {
52        Self::validate_schema(&items)?;
53
54        let mut runtime = Vec::with_capacity(items.len());
55        let mut subscriptions = Vec::new();
56
57        for item in &items {
58            let mut answer = QuestionnaireAnswer::new().with_choices(
59                item.choices()
60                    .iter()
61                    .filter(|choice| choice.is_default_selected() && !choice.is_disabled())
62                    .map(|choice| choice.value().clone()),
63            );
64
65            let mut initial_input_value = None;
66            let mut input_focus_handle = None;
67            if let Some(input) = item.input() {
68                let state = input.state();
69                state.update(cx, |state, cx| {
70                    state.set_disabled(item.is_disabled() || input.is_disabled(), cx);
71                });
72
73                let value = state.read(cx).value();
74                initial_input_value = Some(value.clone());
75                input_focus_handle = Some(state.focus_handle(cx));
76                if !value.trim().is_empty() && !input.is_disabled() {
77                    if !item.is_multiple() {
78                        answer = QuestionnaireAnswer::new();
79                    }
80                    answer.freeform = Some(value);
81                }
82
83                subscriptions.push(cx.subscribe(state, |this, input, event: &InputEvent, cx| {
84                    if matches!(event, InputEvent::Change) {
85                        this.on_input_change(&input, cx);
86                    }
87                }));
88            }
89
90            runtime.push(ItemRuntime {
91                disabled: item.is_disabled(),
92                choice_disabled: item
93                    .choices()
94                    .iter()
95                    .map(QuestionnaireChoiceDefinition::is_disabled)
96                    .collect(),
97                input_disabled: item
98                    .input()
99                    .is_none_or(QuestionnaireInputDefinition::is_disabled),
100                initial_answer: answer.clone(),
101                initial_input_value,
102                answer,
103                skipped: false,
104                validation_attempted: false,
105                internal_error: None,
106                external_error: None,
107                focus_handle: cx.focus_handle(),
108                choice_focus_handles: item.choices().iter().map(|_| cx.focus_handle()).collect(),
109                input_focus_handle,
110            });
111        }
112
113        let current = runtime.iter().position(|item| !item.disabled);
114        let initial_current = current.map(|ix| items[ix].name().clone());
115
116        Ok(Self {
117            items,
118            runtime,
119            current,
120            initial_current,
121            shortcut_mode: None,
122            complete: false,
123            pending_confirm: None,
124            focus_handle: cx.focus_handle(),
125            _subscriptions: subscriptions,
126        })
127    }
128
129    fn validate_schema(
130        items: &[QuestionnaireItemDefinition],
131    ) -> Result<(), QuestionnaireSchemaError> {
132        let mut item_names = HashSet::new();
133        for item in items {
134            if !item_names.insert(item.name().to_string()) {
135                return Err(QuestionnaireSchemaError::DuplicateItem(item.name().clone()));
136            }
137
138            let mut choices = HashSet::new();
139            let mut defaults = 0;
140            for choice in item.choices() {
141                if !choices.insert(choice.value().to_string()) {
142                    return Err(QuestionnaireSchemaError::DuplicateChoice {
143                        item: item.name().clone(),
144                        choice: choice.value().clone(),
145                    });
146                }
147                defaults += usize::from(choice.is_default_selected());
148            }
149            if !item.is_multiple() && defaults > 1 {
150                return Err(QuestionnaireSchemaError::MultipleDefaultsForSingleItem(
151                    item.name().clone(),
152                ));
153            }
154        }
155        Ok(())
156    }
157
158    pub fn with_current_item(
159        mut self,
160        name: impl Into<SharedString>,
161    ) -> Result<Self, QuestionnaireSchemaError> {
162        let name = name.into();
163        let ix = self.item_ix(&name)?;
164        if !self.runtime[ix].disabled {
165            self.current = Some(ix);
166            self.initial_current = Some(name);
167        }
168        Ok(self)
169    }
170
171    pub fn with_shortcuts(mut self, mode: QuestionnaireShortcutMode) -> Self {
172        self.shortcut_mode = Some(mode);
173        self
174    }
175
176    pub fn current_item(&self) -> Option<&SharedString> {
177        self.current.map(|ix| self.items[ix].name())
178    }
179
180    pub fn current_ix(&self) -> Option<usize> {
181        let current = self.current?;
182        self.enabled_indices().position(|ix| ix == current)
183    }
184
185    pub fn total(&self) -> usize {
186        self.enabled_indices().count()
187    }
188
189    pub fn progress(&self) -> QuestionnaireProgressState {
190        QuestionnaireProgressState::new(self.current_ix().map_or(0, |ix| ix + 1), self.total())
191    }
192
193    pub fn item_definition(&self, name: &str) -> Option<&QuestionnaireItemDefinition> {
194        self.items.iter().find(|item| item.name().as_ref() == name)
195    }
196
197    pub fn choice_definition(
198        &self,
199        item: &str,
200        value: &str,
201    ) -> Option<&QuestionnaireChoiceDefinition> {
202        self.item_definition(item)?
203            .choices()
204            .iter()
205            .find(|choice| choice.value().as_ref() == value)
206    }
207
208    pub fn item_state(&self, name: &str) -> Option<QuestionnaireItemState> {
209        let ix = self.item_ix_opt(name)?;
210        let definition = &self.items[ix];
211        Some(QuestionnaireItemState::new(
212            definition.name().clone(),
213            self.status(ix),
214            definition.is_required(),
215            definition.is_multiple(),
216            self.runtime[ix].disabled,
217            self.error_at(ix).is_some(),
218            definition.input().is_some(),
219        ))
220    }
221
222    pub fn choice_state(&self, item: &str, value: &str) -> Option<QuestionnaireChoiceState> {
223        let item_ix = self.item_ix_opt(item)?;
224        let choice_ix = self.choice_ix_opt(item_ix, value)?;
225        let runtime = &self.runtime[item_ix];
226        let definition = &self.items[item_ix].choices()[choice_ix];
227        Some(QuestionnaireChoiceState::new(
228            definition.value().clone(),
229            runtime.answer.choices.contains(definition.value()),
230            runtime.disabled || runtime.choice_disabled[choice_ix],
231            self.error_at(item_ix).is_some(),
232            self.shortcut_for_choice(item, value),
233        ))
234    }
235
236    /// One-based position of a choice among its item's enabled choices, with
237    /// the enabled total. Assistive technology announces the pair.
238    pub fn choice_position(&self, item: &str, value: &str) -> Option<(usize, usize)> {
239        let definition = self.item_definition(item)?;
240        let enabled: Vec<_> = definition
241            .choices()
242            .iter()
243            .filter(|choice| {
244                self.choice_state(item, choice.value())
245                    .is_some_and(|choice| !choice.is_disabled())
246            })
247            .collect();
248        enabled
249            .iter()
250            .position(|choice| choice.value().as_ref() == value)
251            .map(|position| (position + 1, enabled.len()))
252    }
253
254    pub fn navigation_state(&self) -> QuestionnaireNavigationState {
255        let Some(ix) = self.current_ix() else {
256            return QuestionnaireNavigationState::default();
257        };
258        let total = self.total();
259        let item_ix = self
260            .current
261            .expect("current item exists when enabled index exists");
262        QuestionnaireNavigationState::new(
263            ix > 0,
264            ix + 1 < total,
265            !self.items[item_ix].is_required(),
266            ix + 1 == total,
267            self.status(item_ix) != QuestionnaireItemStatus::Unanswered,
268        )
269    }
270
271    pub fn answer(&self, name: &str) -> Option<QuestionnaireAnswer> {
272        let ix = self.item_ix_opt(name)?;
273        Some(self.effective_answer(ix))
274    }
275
276    pub fn answers(&self) -> QuestionnaireAnswers {
277        QuestionnaireAnswers::from_entries(
278            self.enabled_indices()
279                .map(|ix| (self.items[ix].name().clone(), self.effective_answer(ix)))
280                .collect(),
281        )
282    }
283
284    /// The active validation failure for an item, if any. Base reports the
285    /// reason; the presentation layer turns a built-in reason into text.
286    pub fn error(&self, name: &str) -> Option<&QuestionnaireValidationError> {
287        self.item_ix_opt(name).and_then(|ix| self.error_at(ix))
288    }
289
290    pub fn is_complete(&self) -> bool {
291        self.complete
292    }
293
294    pub fn input_state(&self, name: &str) -> Option<Entity<InputState>> {
295        self.item_definition(name)?
296            .input()
297            .map(|input| input.state().clone())
298    }
299
300    pub fn focus_handle(&self) -> &FocusHandle {
301        &self.focus_handle
302    }
303
304    pub fn item_focus_handle(&self, name: &str) -> Option<&FocusHandle> {
305        self.item_ix_opt(name)
306            .map(|ix| &self.runtime[ix].focus_handle)
307    }
308
309    pub fn choice_focus_handle(&self, item: &str, value: &str) -> Option<&FocusHandle> {
310        let item_ix = self.item_ix_opt(item)?;
311        let choice_ix = self.choice_ix_opt(item_ix, value)?;
312        Some(&self.runtime[item_ix].choice_focus_handles[choice_ix])
313    }
314
315    pub fn is_current_input_focused(&self, window: &Window) -> bool {
316        let Some(ix) = self.current else { return false };
317        self.runtime[ix]
318            .input_focus_handle
319            .as_ref()
320            .is_some_and(|handle| handle.is_focused(window))
321    }
322
323    /// Whether the active item's freeform input currently holds text. The skin
324    /// needs it to decide whether an arrow key moves focus or edits the draft.
325    pub fn current_input_has_text(&self, cx: &App) -> bool {
326        let Some(item_ix) = self.current else {
327            return false;
328        };
329        self.items[item_ix]
330            .input()
331            .is_some_and(|input| !input.state().read(cx).value().trim().is_empty())
332    }
333
334    pub fn focused_current_choice(&self, window: &Window) -> Option<&SharedString> {
335        let ix = self.current?;
336        self.runtime[ix]
337            .choice_focus_handles
338            .iter()
339            .position(|handle| handle.is_focused(window))
340            .map(|choice_ix| self.items[ix].choices()[choice_ix].value())
341    }
342
343    pub fn shortcut_mode(&self) -> Option<QuestionnaireShortcutMode> {
344        self.shortcut_mode
345    }
346
347    pub fn shortcut_for_choice(&self, item: &str, value: &str) -> Option<SharedString> {
348        let mode = self.shortcut_mode?;
349        let item_ix = self.item_ix_opt(item)?;
350        let choice_ix = self.choice_ix_opt(item_ix, value)?;
351        if self.runtime[item_ix].disabled || self.runtime[item_ix].choice_disabled[choice_ix] {
352            return None;
353        }
354        let enabled_position = (0..=choice_ix)
355            .filter(|ix| !self.runtime[item_ix].choice_disabled[*ix])
356            .count()
357            .checked_sub(1)?;
358        match mode {
359            QuestionnaireShortcutMode::Letters if enabled_position < 26 => {
360                Some(char::from(b'A' + enabled_position as u8).to_string().into())
361            }
362            QuestionnaireShortcutMode::Numbers if enabled_position < 9 => {
363                Some((enabled_position + 1).to_string().into())
364            }
365            _ => None,
366        }
367    }
368
369    pub fn choice_for_shortcut(&self, item: &str, key: &str) -> Option<&SharedString> {
370        let item_ix = self.item_ix_opt(item)?;
371        self.items[item_ix].choices().iter().find_map(|choice| {
372            self.shortcut_for_choice(item, choice.value())
373                .is_some_and(|shortcut| shortcut.as_ref().eq_ignore_ascii_case(key))
374                .then_some(choice.value())
375        })
376    }
377
378    pub fn activate_shortcut(
379        &mut self,
380        key: &str,
381        window: &mut Window,
382        cx: &mut Context<Self>,
383    ) -> bool {
384        let Some(item_ix) = self.current else {
385            return false;
386        };
387        let item = self.items[item_ix].name().clone();
388        let Some(choice) = self.choice_for_shortcut(&item, key).cloned() else {
389            return false;
390        };
391        self.choose(&item, &choice, window, cx).is_ok()
392    }
393
394    pub fn set_current_item(
395        &mut self,
396        name: &str,
397        window: &mut Window,
398        cx: &mut Context<Self>,
399    ) -> Result<(), QuestionnaireSchemaError> {
400        let ix = self.item_ix(name)?;
401        if !self.runtime[ix].disabled {
402            self.pending_confirm = None;
403            self.current = Some(ix);
404            self.focus_current_item(window, cx);
405            cx.notify();
406        }
407        Ok(())
408    }
409
410    pub fn set_answer(
411        &mut self,
412        item: &str,
413        mut answer: QuestionnaireAnswer,
414        window: &mut Window,
415        cx: &mut Context<Self>,
416    ) -> Result<(), QuestionnaireSchemaError> {
417        let item_ix = self.item_ix(item)?;
418        let before = self.effective_answer(item_ix);
419        let before_status = self.status(item_ix);
420        if answer
421            .freeform
422            .as_ref()
423            .is_some_and(|value| value.trim().is_empty())
424        {
425            answer.freeform = None;
426        }
427        self.check_answer(item_ix, &answer)?;
428        answer.choices = self.items[item_ix]
429            .choices()
430            .iter()
431            .filter(|choice| answer.choices.contains(choice.value()))
432            .map(|choice| choice.value().clone())
433            .collect();
434        self.runtime[item_ix].answer = answer.clone();
435        self.runtime[item_ix].skipped = false;
436        if let (Some(input), Some(value)) =
437            (self.items[item_ix].input(), answer.freeform().cloned())
438        {
439            input
440                .state()
441                .update(cx, |input, cx| input.set_value(value, window, cx));
442        }
443        if before != self.effective_answer(item_ix) || before_status != self.status(item_ix) {
444            self.answer_did_change(item_ix, false, cx);
445        }
446        Ok(())
447    }
448
449    pub fn set_input_value(
450        &mut self,
451        item: &str,
452        value: impl Into<SharedString>,
453        window: &mut Window,
454        cx: &mut Context<Self>,
455    ) -> Result<(), QuestionnaireSchemaError> {
456        let item_ix = self.item_ix(item)?;
457        let Some(input) = self.items[item_ix]
458            .input()
459            .map(|input| input.state().clone())
460        else {
461            return Err(QuestionnaireSchemaError::AnswerDoesNotMatchItem(
462                self.items[item_ix].name().clone(),
463            ));
464        };
465        let value: SharedString = value.into();
466        input.update(cx, |input, cx| input.set_value(value, window, cx));
467        self.sync_input_answer(item_ix, false, cx);
468        Ok(())
469    }
470
471    pub fn set_item_disabled(
472        &mut self,
473        name: &str,
474        disabled: bool,
475        window: &mut Window,
476        cx: &mut Context<Self>,
477    ) -> Result<(), QuestionnaireSchemaError> {
478        let ix = self.item_ix(name)?;
479        if self.runtime[ix].disabled == disabled {
480            return Ok(());
481        }
482        self.runtime[ix].disabled = disabled;
483        self.pending_confirm = None;
484        if let Some(input) = self.items[ix].input() {
485            let input_disabled = disabled || self.runtime[ix].input_disabled;
486            input
487                .state()
488                .update(cx, |input, cx| input.set_disabled(input_disabled, cx));
489        }
490        self.complete = false;
491        if self.current == Some(ix) && disabled {
492            let next = self
493                .enabled_indices()
494                .find(|candidate| *candidate > ix)
495                .or_else(|| {
496                    self.enabled_indices()
497                        .rev()
498                        .find(|candidate| *candidate < ix)
499                });
500            self.current = next;
501            self.focus_current_item(window, cx);
502        } else if self.current.is_none() && !disabled {
503            self.current = Some(ix);
504            self.focus_current_item(window, cx);
505        }
506        cx.notify();
507        Ok(())
508    }
509
510    pub fn set_choice_disabled(
511        &mut self,
512        item: &str,
513        value: &str,
514        disabled: bool,
515        cx: &mut Context<Self>,
516    ) -> Result<(), QuestionnaireSchemaError> {
517        let item_ix = self.item_ix(item)?;
518        let choice_ix = self.choice_ix(item_ix, value)?;
519        if self.runtime[item_ix].choice_disabled[choice_ix] == disabled {
520            return Ok(());
521        }
522        self.runtime[item_ix].choice_disabled[choice_ix] = disabled;
523        self.answer_did_change(item_ix, false, cx);
524        Ok(())
525    }
526
527    pub fn set_external_error(
528        &mut self,
529        item: &str,
530        error: impl Into<SharedString>,
531        cx: &mut Context<Self>,
532    ) -> Result<(), QuestionnaireSchemaError> {
533        let ix = self.item_ix(item)?;
534        self.runtime[ix].external_error = Some(QuestionnaireValidationError::Message(error.into()));
535        self.complete = false;
536        cx.notify();
537        Ok(())
538    }
539
540    pub fn clear_external_error(
541        &mut self,
542        item: &str,
543        cx: &mut Context<Self>,
544    ) -> Result<(), QuestionnaireSchemaError> {
545        let ix = self.item_ix(item)?;
546        self.runtime[ix].external_error = None;
547        cx.notify();
548        Ok(())
549    }
550
551    pub fn reset(&mut self, window: &mut Window, cx: &mut Context<Self>) {
552        for ix in 0..self.items.len() {
553            let initial = self.runtime[ix].initial_answer.clone();
554            self.runtime[ix].answer = initial;
555            self.runtime[ix].skipped = false;
556            self.runtime[ix].validation_attempted = false;
557            self.runtime[ix].internal_error = None;
558            if let Some(input) = self.items[ix].input() {
559                let value = self.runtime[ix]
560                    .initial_input_value
561                    .as_ref()
562                    .cloned()
563                    .unwrap_or_default();
564                input
565                    .state()
566                    .update(cx, |input, cx| input.set_value(value, window, cx));
567            }
568        }
569        self.complete = false;
570        self.pending_confirm = None;
571        self.current = self
572            .initial_current
573            .as_ref()
574            .and_then(|name| self.item_ix_opt(name));
575        if self.current.is_some_and(|ix| self.runtime[ix].disabled) {
576            let next = self.enabled_indices().next();
577            self.current = next;
578        }
579        self.focus_current_item(window, cx);
580        cx.notify();
581    }
582
583    /// Selects a choice, or toggles it on a multiple-answer item, without
584    /// confirming the item. Arrow-key movement inside a radio group uses this;
585    /// a person's click, Space, Enter or shortcut goes through [`Self::choose`].
586    pub fn activate_choice(
587        &mut self,
588        item: &str,
589        value: &str,
590        cx: &mut Context<Self>,
591    ) -> Result<(), QuestionnaireSchemaError> {
592        let item_ix = self.item_ix(item)?;
593        let choice_ix = self.choice_ix(item_ix, value)?;
594        if self.runtime[item_ix].disabled || self.runtime[item_ix].choice_disabled[choice_ix] {
595            return Ok(());
596        }
597        let before = self.effective_answer(item_ix);
598        let before_status = self.status(item_ix);
599
600        if self.items[item_ix].is_multiple() {
601            let selected = self.runtime[item_ix]
602                .answer
603                .choices
604                .iter()
605                .position(|choice| choice.as_ref() == value);
606            if let Some(ix) = selected {
607                self.runtime[item_ix].answer.choices.remove(ix);
608            } else {
609                self.runtime[item_ix].answer.choices.push(value.into());
610            }
611        } else {
612            self.runtime[item_ix].answer.choices.clear();
613            self.runtime[item_ix].answer.choices.push(value.into());
614            self.runtime[item_ix].answer.freeform = None;
615        }
616        self.runtime[item_ix].skipped = false;
617        if before != self.effective_answer(item_ix) || before_status != self.status(item_ix) {
618            self.answer_did_change(item_ix, true, cx);
619        }
620        Ok(())
621    }
622
623    /// Applies a person's activation of a choice: a click, Space or Enter on the
624    /// focused choice, or its shortcut.
625    ///
626    /// A multiple-answer item only toggles the choice. A single-answer item
627    /// selects it and then confirms the current item as [`Self::confirm_current`]
628    /// does: it moves to the next enabled item, or submits on the last one,
629    /// unless validation fails. A choice that changes the answer stays selected
630    /// on screen for a moment before the confirm runs, so the person sees what
631    /// they picked; with reduced motion the confirm runs as soon as the answer
632    /// change has been delivered. Choosing the selected answer again confirms
633    /// at once. Disabled items and choices ignore activation.
634    ///
635    /// Arrow-key movement inside a radio group uses [`Self::activate_choice`]
636    /// and never confirms.
637    pub fn choose(
638        &mut self,
639        item: &str,
640        value: &str,
641        window: &mut Window,
642        cx: &mut Context<Self>,
643    ) -> Result<(), QuestionnaireSchemaError> {
644        let item_ix = self.item_ix(item)?;
645        let choice_ix = self.choice_ix(item_ix, value)?;
646        if self.runtime[item_ix].disabled || self.runtime[item_ix].choice_disabled[choice_ix] {
647            return Ok(());
648        }
649        let before = self.effective_answer(item_ix);
650        self.activate_choice(item, value, cx)?;
651        self.focus_choice(item, value, window, cx);
652        if self.items[item_ix].is_multiple() || self.current != Some(item_ix) {
653            return Ok(());
654        }
655        if before == self.effective_answer(item_ix) {
656            self.confirm_current(window, cx);
657        } else {
658            self.schedule_confirm(item_ix, window, cx);
659        }
660        Ok(())
661    }
662
663    /// Confirms `item_ix` after the selection has had time to show. The task
664    /// runs on the next turn of the event loop at the earliest, after
665    /// `AnswerChanged` subscribers have reacted (a host enabling a follow-up
666    /// item, say), and does nothing if another item became current meanwhile.
667    fn schedule_confirm(&mut self, item_ix: usize, window: &mut Window, cx: &mut Context<Self>) {
668        let delay = (!cx.reduce_motion()).then_some(CHOICE_CONFIRM_DELAY);
669        self.pending_confirm = Some(cx.spawn_in(window, async move |this, cx| {
670            if let Some(delay) = delay {
671                cx.background_executor().timer(delay).await;
672            }
673            let _ = this.update_in(cx, |this, window, cx| {
674                this.pending_confirm = None;
675                if this.current == Some(item_ix) {
676                    this.confirm_current(window, cx);
677                }
678            });
679        }));
680    }
681
682    pub fn confirm_current(&mut self, window: &mut Window, cx: &mut Context<Self>) -> bool {
683        let Some(current_ix) = self.current_ix() else {
684            return false;
685        };
686        if current_ix + 1 == self.total() {
687            self.submit(window, cx)
688        } else {
689            self.go_next(window, cx)
690        }
691    }
692
693    pub fn go_previous(&mut self, window: &mut Window, cx: &mut Context<Self>) -> bool {
694        self.pending_confirm = None;
695        let Some(ix) = self.current_ix() else {
696            return false;
697        };
698        let enabled: Vec<_> = self.enabled_indices().collect();
699        if ix == 0 {
700            return false;
701        }
702        self.change_current(Some(enabled[ix - 1]), true, window, cx);
703        true
704    }
705
706    pub fn go_next(&mut self, window: &mut Window, cx: &mut Context<Self>) -> bool {
707        self.pending_confirm = None;
708        let Some(current) = self.current else {
709            return false;
710        };
711        if !self.validate_item(current) {
712            self.focus_invalid_item(self.items[current].name(), window, cx);
713            cx.notify();
714            return false;
715        }
716        let Some(ix) = self.current_ix() else {
717            return false;
718        };
719        let enabled: Vec<_> = self.enabled_indices().collect();
720        if ix + 1 >= enabled.len() {
721            return false;
722        }
723        self.change_current(Some(enabled[ix + 1]), true, window, cx);
724        true
725    }
726
727    pub fn skip_current(&mut self, window: &mut Window, cx: &mut Context<Self>) -> bool {
728        let Some(ix) = self.current else { return false };
729        if self.items[ix].is_required() {
730            return false;
731        }
732        self.runtime[ix].answer = QuestionnaireAnswer::new();
733        self.runtime[ix].skipped = true;
734        self.complete = false;
735        self.emit_answer_changed(ix, cx);
736        if self
737            .current_ix()
738            .is_some_and(|current| current + 1 == self.total())
739        {
740            self.submit(window, cx)
741        } else {
742            self.go_next(window, cx)
743        }
744    }
745
746    pub fn submit(&mut self, window: &mut Window, cx: &mut Context<Self>) -> bool {
747        self.pending_confirm = None;
748        let enabled: Vec<_> = self.enabled_indices().collect();
749        let mut first_invalid = None;
750        for ix in enabled {
751            if !self.validate_item(ix) && first_invalid.is_none() {
752                first_invalid = Some(ix);
753            }
754        }
755        if let Some(ix) = first_invalid {
756            self.change_current(Some(ix), true, window, cx);
757            self.focus_invalid_item(self.items[ix].name(), window, cx);
758            cx.notify();
759            return false;
760        }
761
762        let submission = self.submission();
763        if !self.complete {
764            self.complete = true;
765            cx.emit(QuestionnaireEvent::Completed(submission.clone()));
766        }
767        cx.emit(QuestionnaireEvent::Submit(submission));
768        cx.notify();
769        true
770    }
771
772    pub fn focus_current_item(&self, window: &mut Window, cx: &mut App) -> bool {
773        let Some(ix) = self.current else { return false };
774        self.runtime[ix].focus_handle.focus(window, cx);
775        true
776    }
777
778    pub fn focus_invalid_item(&self, item: &str, window: &mut Window, cx: &mut App) -> bool {
779        let Some(item_ix) = self.item_ix_opt(item) else {
780            return false;
781        };
782        if self.runtime[item_ix].answer.freeform().is_some()
783            && !self.runtime[item_ix].input_disabled
784            && let Some(focus_handle) = &self.runtime[item_ix].input_focus_handle
785        {
786            focus_handle.focus(window, cx);
787            return true;
788        }
789        for (choice_ix, choice) in self.items[item_ix].choices().iter().enumerate() {
790            if self.runtime[item_ix]
791                .answer
792                .choices
793                .contains(choice.value())
794                && !self.runtime[item_ix].choice_disabled[choice_ix]
795            {
796                self.runtime[item_ix].choice_focus_handles[choice_ix].focus(window, cx);
797                return true;
798            }
799        }
800        if let Some(choice_ix) = self.runtime[item_ix]
801            .choice_disabled
802            .iter()
803            .position(|disabled| !disabled)
804        {
805            self.runtime[item_ix].choice_focus_handles[choice_ix].focus(window, cx);
806            return true;
807        }
808        if !self.runtime[item_ix].input_disabled
809            && let Some(focus_handle) = &self.runtime[item_ix].input_focus_handle
810        {
811            focus_handle.focus(window, cx);
812            return true;
813        }
814        self.runtime[item_ix].focus_handle.focus(window, cx);
815        true
816    }
817
818    pub fn focus_choice(&self, item: &str, value: &str, window: &mut Window, cx: &mut App) -> bool {
819        let Some(item_ix) = self.item_ix_opt(item) else {
820            return false;
821        };
822        let Some(choice_ix) = self.choice_ix_opt(item_ix, value) else {
823            return false;
824        };
825        self.runtime[item_ix].choice_focus_handles[choice_ix].focus(window, cx);
826        true
827    }
828
829    pub fn focus_input(&self, item: &str, window: &mut Window, cx: &mut App) -> bool {
830        let Some(item_ix) = self.item_ix_opt(item) else {
831            return false;
832        };
833        let Some(focus_handle) = &self.runtime[item_ix].input_focus_handle else {
834            return false;
835        };
836        focus_handle.focus(window, cx);
837        true
838    }
839
840    pub fn focus_previous_answer(&mut self, window: &mut Window, cx: &mut Context<Self>) -> bool {
841        self.focus_adjacent_answer(-1, window, cx)
842    }
843
844    pub fn focus_next_answer(&mut self, window: &mut Window, cx: &mut Context<Self>) -> bool {
845        self.focus_adjacent_answer(1, window, cx)
846    }
847
848    pub fn move_current_radio(
849        &mut self,
850        direction: isize,
851        window: &mut Window,
852        cx: &mut Context<Self>,
853    ) -> bool {
854        let Some(item_ix) = self.current else {
855            return false;
856        };
857        if self.items[item_ix].is_multiple() || self.is_current_input_focused(window) {
858            return false;
859        }
860        let enabled: Vec<_> = self.runtime[item_ix]
861            .choice_disabled
862            .iter()
863            .enumerate()
864            .filter_map(|(ix, disabled)| (!disabled).then_some(ix))
865            .collect();
866        if enabled.is_empty() {
867            return false;
868        }
869        let current = enabled
870            .iter()
871            .position(|ix| self.runtime[item_ix].choice_focus_handles[*ix].is_focused(window))
872            .or_else(|| {
873                enabled.iter().position(|ix| {
874                    self.runtime[item_ix]
875                        .answer
876                        .choices
877                        .contains(self.items[item_ix].choices()[*ix].value())
878                })
879            });
880        let target = match (current, direction.is_negative()) {
881            (Some(ix), true) => ix.checked_sub(1).unwrap_or(enabled.len() - 1),
882            (Some(ix), false) => (ix + 1) % enabled.len(),
883            (None, true) => enabled.len() - 1,
884            (None, false) => 0,
885        };
886        let choice_ix = enabled[target];
887        let item = self.items[item_ix].name().clone();
888        let choice = self.items[item_ix].choices()[choice_ix].value().clone();
889        let _ = self.activate_choice(&item, &choice, cx);
890        self.runtime[item_ix].choice_focus_handles[choice_ix].focus(window, cx);
891        true
892    }
893
894    fn focus_adjacent_answer(
895        &mut self,
896        direction: isize,
897        window: &mut Window,
898        cx: &mut Context<Self>,
899    ) -> bool {
900        let Some(item_ix) = self.current else {
901            return false;
902        };
903        if self.is_current_input_focused(window) && self.current_input_has_text(cx) {
904            return false;
905        }
906
907        #[derive(Clone, Copy)]
908        enum Target {
909            Choice(usize),
910            Input,
911        }
912        let mut targets = Vec::new();
913        for choice_ix in 0..self.items[item_ix].choices().len() {
914            if !self.runtime[item_ix].choice_disabled[choice_ix] {
915                targets.push(Target::Choice(choice_ix));
916            }
917        }
918        if self.items[item_ix].input().is_some() && !self.runtime[item_ix].input_disabled {
919            targets.push(Target::Input);
920        }
921        if targets.is_empty() {
922            return false;
923        }
924
925        let focused = targets.iter().position(|target| match target {
926            Target::Choice(ix) => {
927                self.runtime[item_ix].choice_focus_handles[*ix].is_focused(window)
928            }
929            Target::Input => self.is_current_input_focused(window),
930        });
931
932        if focused.is_none() && self.runtime[item_ix].focus_handle.is_focused(window) {
933            let filled: Vec<_> = targets
934                .iter()
935                .enumerate()
936                .filter_map(|(ix, target)| match target {
937                    Target::Choice(choice_ix)
938                        if self.runtime[item_ix]
939                            .answer
940                            .choices
941                            .contains(self.items[item_ix].choices()[*choice_ix].value()) =>
942                    {
943                        Some(ix)
944                    }
945                    Target::Input if self.runtime[item_ix].answer.freeform().is_some() => Some(ix),
946                    _ => None,
947                })
948                .collect();
949            let filled_ix = if direction.is_negative() {
950                filled.last().copied()
951            } else {
952                filled.first().copied()
953            };
954            if let Some(filled_ix) = filled_ix {
955                match targets[filled_ix] {
956                    Target::Choice(choice_ix) => {
957                        self.runtime[item_ix].choice_focus_handles[choice_ix].focus(window, cx);
958                    }
959                    Target::Input => {
960                        if let Some(focus_handle) = &self.runtime[item_ix].input_focus_handle {
961                            focus_handle.focus(window, cx);
962                        }
963                    }
964                }
965                return true;
966            }
967        }
968
969        let target_ix = match (focused, direction.is_negative()) {
970            (Some(ix), true) => ix.checked_sub(1).unwrap_or(targets.len() - 1),
971            (Some(ix), false) => (ix + 1) % targets.len(),
972            (None, true) => targets.len() - 1,
973            (None, false) => 0,
974        };
975
976        // GPUI's Radio primitive intentionally leaves group arrow behavior to
977        // its owner. Let the root emulate native radio movement only when both
978        // adjacent answers are radios; crossing the boundary to an Input stays
979        // in this schema-ordered answer sequence.
980        if !self.items[item_ix].is_multiple()
981            && focused.is_some_and(|focused_ix| {
982                matches!(targets[focused_ix], Target::Choice(_))
983                    && matches!(targets[target_ix], Target::Choice(_))
984            })
985        {
986            return false;
987        }
988
989        match targets[target_ix] {
990            Target::Choice(choice_ix) => {
991                if !self.items[item_ix].is_multiple() {
992                    let item = self.items[item_ix].name().clone();
993                    let choice = self.items[item_ix].choices()[choice_ix].value().clone();
994                    let _ = self.activate_choice(&item, &choice, cx);
995                }
996                self.runtime[item_ix].choice_focus_handles[choice_ix].focus(window, cx);
997            }
998            Target::Input => {
999                if let Some(focus_handle) = &self.runtime[item_ix].input_focus_handle {
1000                    focus_handle.focus(window, cx);
1001                }
1002            }
1003        }
1004        true
1005    }
1006
1007    fn on_input_change(&mut self, input: &Entity<InputState>, cx: &mut Context<Self>) {
1008        if let Some(ix) = self.input_item_ix(input) {
1009            self.sync_input_answer(ix, true, cx);
1010        }
1011    }
1012
1013    fn sync_input_answer(&mut self, item_ix: usize, emit: bool, cx: &mut Context<Self>) {
1014        if self.runtime[item_ix].disabled || self.runtime[item_ix].input_disabled {
1015            return;
1016        }
1017        let before = self.effective_answer(item_ix);
1018        let before_status = self.status(item_ix);
1019        let Some(input) = self.items[item_ix].input() else {
1020            return;
1021        };
1022        let value = input.state().read(cx).value();
1023        if value.trim().is_empty() {
1024            self.runtime[item_ix].answer.freeform = None;
1025        } else {
1026            if !self.items[item_ix].is_multiple() {
1027                self.runtime[item_ix].answer.choices.clear();
1028            }
1029            self.runtime[item_ix].answer.freeform = Some(value);
1030            self.runtime[item_ix].skipped = false;
1031        }
1032        if before != self.effective_answer(item_ix) || before_status != self.status(item_ix) {
1033            self.answer_did_change(item_ix, emit, cx);
1034        }
1035    }
1036
1037    fn answer_did_change(&mut self, item_ix: usize, emit: bool, cx: &mut Context<Self>) {
1038        self.pending_confirm = None;
1039        if self.runtime[item_ix].validation_attempted {
1040            self.validate_item(item_ix);
1041        } else {
1042            self.runtime[item_ix].internal_error = None;
1043        }
1044        self.complete = false;
1045        if emit {
1046            self.emit_answer_changed(item_ix, cx);
1047        }
1048        cx.notify();
1049    }
1050
1051    fn emit_answer_changed(&self, item_ix: usize, cx: &mut Context<Self>) {
1052        cx.emit(QuestionnaireEvent::AnswerChanged(
1053            QuestionnaireAnswerChange::new(
1054                self.items[item_ix].name().clone(),
1055                self.effective_answer(item_ix),
1056                self.status(item_ix),
1057            ),
1058        ));
1059    }
1060
1061    fn validate_item(&mut self, item_ix: usize) -> bool {
1062        if self.runtime[item_ix].disabled || self.runtime[item_ix].skipped {
1063            return true;
1064        }
1065        self.runtime[item_ix].validation_attempted = true;
1066        let answer = self.effective_answer(item_ix);
1067        let error = if answer.is_empty() {
1068            Some(if self.items[item_ix].is_required() {
1069                QuestionnaireValidationError::Required
1070            } else {
1071                QuestionnaireValidationError::Unanswered
1072            })
1073        } else if let Some(validator) = self.items[item_ix].validator().cloned() {
1074            validator(&QuestionnaireValidationContext::new(
1075                self.items[item_ix].name().clone(),
1076                answer,
1077                self.answers(),
1078            ))
1079            .err()
1080            .map(QuestionnaireValidationError::Message)
1081        } else {
1082            None
1083        };
1084        self.runtime[item_ix].internal_error = error;
1085        self.error_at(item_ix).is_none()
1086    }
1087
1088    fn submission(&self) -> QuestionnaireSubmission {
1089        QuestionnaireSubmission::new(
1090            self.enabled_indices()
1091                .map(|ix| {
1092                    QuestionnaireSubmissionItem::new(
1093                        self.items[ix].name().clone(),
1094                        self.status(ix),
1095                        self.effective_answer(ix),
1096                    )
1097                })
1098                .collect(),
1099        )
1100    }
1101
1102    fn status(&self, item_ix: usize) -> QuestionnaireItemStatus {
1103        if self.runtime[item_ix].skipped {
1104            QuestionnaireItemStatus::Skipped
1105        } else if self.effective_answer(item_ix).is_empty() {
1106            QuestionnaireItemStatus::Unanswered
1107        } else {
1108            QuestionnaireItemStatus::Answered
1109        }
1110    }
1111
1112    fn effective_answer(&self, item_ix: usize) -> QuestionnaireAnswer {
1113        if self.runtime[item_ix].disabled {
1114            return QuestionnaireAnswer::new();
1115        }
1116        let runtime = &self.runtime[item_ix];
1117        QuestionnaireAnswer {
1118            choices: self.items[item_ix]
1119                .choices()
1120                .iter()
1121                .filter(|choice| {
1122                    runtime.answer.choices.contains(choice.value())
1123                        && self
1124                            .choice_ix_opt(item_ix, choice.value())
1125                            .is_some_and(|ix| !runtime.choice_disabled[ix])
1126                })
1127                .map(|choice| choice.value().clone())
1128                .collect(),
1129            freeform: (!runtime.input_disabled)
1130                .then(|| runtime.answer.freeform.clone())
1131                .flatten(),
1132        }
1133    }
1134
1135    fn error_at(&self, item_ix: usize) -> Option<&QuestionnaireValidationError> {
1136        if self.runtime[item_ix].skipped || self.runtime[item_ix].disabled {
1137            return None;
1138        }
1139        self.runtime[item_ix].external_error.as_ref().or_else(|| {
1140            self.runtime[item_ix]
1141                .validation_attempted
1142                .then_some(self.runtime[item_ix].internal_error.as_ref())
1143                .flatten()
1144        })
1145    }
1146
1147    fn check_answer(
1148        &self,
1149        item_ix: usize,
1150        answer: &QuestionnaireAnswer,
1151    ) -> Result<(), QuestionnaireSchemaError> {
1152        let item = &self.items[item_ix];
1153        let sources = answer.choices.len() + usize::from(answer.freeform.is_some());
1154        if (!item.is_multiple() && sources > 1)
1155            || (answer.freeform.is_some() && item.input().is_none())
1156        {
1157            return Err(QuestionnaireSchemaError::AnswerDoesNotMatchItem(
1158                item.name().clone(),
1159            ));
1160        }
1161        for choice in &answer.choices {
1162            let choice_ix = self.choice_ix(item_ix, choice)?;
1163            if self.runtime[item_ix].choice_disabled[choice_ix] {
1164                return Err(QuestionnaireSchemaError::AnswerDoesNotMatchItem(
1165                    item.name().clone(),
1166                ));
1167            }
1168        }
1169        Ok(())
1170    }
1171
1172    fn change_current(
1173        &mut self,
1174        next: Option<usize>,
1175        emit: bool,
1176        window: &mut Window,
1177        cx: &mut Context<Self>,
1178    ) {
1179        if self.current == next {
1180            return;
1181        }
1182        self.pending_confirm = None;
1183        let previous = self.current.map(|ix| self.items[ix].name().clone());
1184        self.current = next;
1185        self.focus_current_item(window, cx);
1186        if emit {
1187            cx.emit(QuestionnaireEvent::CurrentItemChanged {
1188                previous,
1189                current: next.map(|ix| self.items[ix].name().clone()),
1190            });
1191        }
1192        cx.notify();
1193    }
1194
1195    fn enabled_indices(&self) -> impl DoubleEndedIterator<Item = usize> + '_ {
1196        self.runtime
1197            .iter()
1198            .enumerate()
1199            .filter_map(|(ix, runtime)| (!runtime.disabled).then_some(ix))
1200    }
1201
1202    fn item_ix(&self, name: &str) -> Result<usize, QuestionnaireSchemaError> {
1203        self.item_ix_opt(name)
1204            .ok_or_else(|| QuestionnaireSchemaError::UnknownItem(name.into()))
1205    }
1206
1207    fn item_ix_opt(&self, name: &str) -> Option<usize> {
1208        self.items
1209            .iter()
1210            .position(|item| item.name().as_ref() == name)
1211    }
1212
1213    fn choice_ix(&self, item_ix: usize, value: &str) -> Result<usize, QuestionnaireSchemaError> {
1214        self.choice_ix_opt(item_ix, value)
1215            .ok_or_else(|| QuestionnaireSchemaError::UnknownChoice {
1216                item: self.items[item_ix].name().clone(),
1217                choice: value.into(),
1218            })
1219    }
1220
1221    fn choice_ix_opt(&self, item_ix: usize, value: &str) -> Option<usize> {
1222        self.items[item_ix]
1223            .choices()
1224            .iter()
1225            .position(|choice| choice.value().as_ref() == value)
1226    }
1227
1228    fn input_item_ix(&self, input: &Entity<InputState>) -> Option<usize> {
1229        self.items.iter().position(|item| {
1230            item.input()
1231                .is_some_and(|definition| definition.state().entity_id() == input.entity_id())
1232        })
1233    }
1234}
1235
1236impl EventEmitter<QuestionnaireEvent> for QuestionnaireState {}
1237
1238#[cfg(test)]
1239mod tests {
1240    use gpui::{
1241        AppContext as _, Context, Entity, IntoElement, Render, TestAppContext, VisualTestContext,
1242        div,
1243    };
1244
1245    use super::*;
1246
1247    struct Harness {
1248        state: Entity<QuestionnaireState>,
1249        first_input: Entity<InputState>,
1250        second_input: Entity<InputState>,
1251        events: Vec<&'static str>,
1252        _subscription: Subscription,
1253    }
1254
1255    impl Harness {
1256        fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
1257            let first_input = cx.new(|cx| InputState::new(window, cx));
1258            let second_input =
1259                cx.new(|cx| InputState::new(window, cx).default_value("initial draft"));
1260            let items = vec![
1261                QuestionnaireItemDefinition::new("first", "First question")
1262                    .with_required(true)
1263                    .with_choices([
1264                        QuestionnaireChoiceDefinition::new("a", "A"),
1265                        QuestionnaireChoiceDefinition::new("b", "B"),
1266                    ])
1267                    .with_input(QuestionnaireInputDefinition::new(
1268                        first_input.clone(),
1269                        "Another answer",
1270                    )),
1271                QuestionnaireItemDefinition::new("second", "Second question")
1272                    .with_multiple(true)
1273                    .with_choices([
1274                        QuestionnaireChoiceDefinition::new("x", "X"),
1275                        QuestionnaireChoiceDefinition::new("y", "Y"),
1276                    ])
1277                    .with_input(QuestionnaireInputDefinition::new(
1278                        second_input.clone(),
1279                        "Another answer",
1280                    ))
1281                    .with_validator(|context| {
1282                        (context.answer().freeform().map(SharedString::as_ref) == Some("valid"))
1283                            .then_some(())
1284                            .ok_or_else(|| SharedString::from("Use the valid answer"))
1285                    }),
1286                QuestionnaireItemDefinition::new("disabled", "Disabled").with_disabled(true),
1287            ];
1288            let state = cx.new(|cx| {
1289                QuestionnaireState::new(items, cx)
1290                    .unwrap()
1291                    .with_shortcuts(QuestionnaireShortcutMode::Letters)
1292            });
1293            let subscription = cx.subscribe(&state, |this, _, event, _| {
1294                this.events.push(event_name(event));
1295            });
1296            Self {
1297                state,
1298                first_input,
1299                second_input,
1300                events: Vec::new(),
1301                _subscription: subscription,
1302            }
1303        }
1304    }
1305
1306    fn event_name(event: &QuestionnaireEvent) -> &'static str {
1307        match event {
1308            QuestionnaireEvent::CurrentItemChanged { .. } => "current",
1309            QuestionnaireEvent::AnswerChanged(_) => "answer",
1310            QuestionnaireEvent::Completed(_) => "completed",
1311            QuestionnaireEvent::Submit(_) => "submit",
1312        }
1313    }
1314
1315    impl Render for Harness {
1316        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
1317            div()
1318        }
1319    }
1320
1321    fn harness(
1322        cx: &mut TestAppContext,
1323    ) -> (
1324        Entity<Harness>,
1325        Entity<QuestionnaireState>,
1326        Entity<InputState>,
1327        Entity<InputState>,
1328        &mut VisualTestContext,
1329    ) {
1330        cx.update(crate::init);
1331        let (harness, cx) = cx.add_window_view(Harness::new);
1332        let (state, first_input, second_input) = harness.read_with(cx, |harness, _| {
1333            (
1334                harness.state.clone(),
1335                harness.first_input.clone(),
1336                harness.second_input.clone(),
1337            )
1338        });
1339        (harness, state, first_input, second_input, cx)
1340    }
1341
1342    #[test]
1343    fn schema_rejects_duplicate_names_and_invalid_single_defaults() {
1344        let duplicate_items = vec![
1345            QuestionnaireItemDefinition::new("same", "One"),
1346            QuestionnaireItemDefinition::new("same", "Two"),
1347        ];
1348        assert_eq!(
1349            QuestionnaireState::validate_schema(&duplicate_items),
1350            Err(QuestionnaireSchemaError::DuplicateItem("same".into()))
1351        );
1352
1353        let invalid_default = vec![
1354            QuestionnaireItemDefinition::new("single", "Single").with_choices([
1355                QuestionnaireChoiceDefinition::new("a", "A").with_default_selected(true),
1356                QuestionnaireChoiceDefinition::new("b", "B").with_default_selected(true),
1357            ]),
1358        ];
1359        assert_eq!(
1360            QuestionnaireState::validate_schema(&invalid_default),
1361            Err(QuestionnaireSchemaError::MultipleDefaultsForSingleItem(
1362                "single".into()
1363            ))
1364        );
1365
1366        let duplicate_choice = vec![
1367            QuestionnaireItemDefinition::new("item", "Item").with_choices([
1368                QuestionnaireChoiceDefinition::new("same", "One"),
1369                QuestionnaireChoiceDefinition::new("same", "Two"),
1370            ]),
1371        ];
1372        assert_eq!(
1373            QuestionnaireState::validate_schema(&duplicate_choice),
1374            Err(QuestionnaireSchemaError::DuplicateChoice {
1375                item: "item".into(),
1376                choice: "same".into(),
1377            })
1378        );
1379    }
1380
1381    #[gpui::test]
1382    fn validates_navigates_skips_and_emits_completion_before_submit(cx: &mut TestAppContext) {
1383        let (harness, state, _, _, cx) = harness(cx);
1384
1385        assert_eq!(cx.read(|cx| state.read(cx).progress().current()), 1);
1386        assert_eq!(cx.read(|cx| state.read(cx).progress().total()), 2);
1387        assert_eq!(
1388            cx.read(|cx| state.read(cx).item_state("first").unwrap().status()),
1389            QuestionnaireItemStatus::Unanswered
1390        );
1391        assert!(!cx.update(|window, cx| state.update(cx, |state, cx| state.submit(window, cx))));
1392        assert!(cx.read(|cx| state.read(cx).error("first").is_some()));
1393        assert_eq!(
1394            cx.read(|cx| state.read(cx).error("second").unwrap().clone()),
1395            QuestionnaireValidationError::Message("Use the valid answer".into())
1396        );
1397
1398        cx.update(|window, cx| {
1399            state.update(cx, |state, cx| {
1400                state.set_input_value("first", "draft", window, cx).unwrap();
1401            });
1402        });
1403        assert!(cx.read(|cx| state.read(cx).error("first").is_none()));
1404        cx.update(|window, cx| {
1405            state.update(cx, |state, cx| {
1406                state.set_input_value("first", "", window, cx).unwrap();
1407            });
1408        });
1409        assert!(
1410            cx.read(|cx| state.read(cx).error("first").is_some()),
1411            "once validation has been attempted, clearing an answer updates the error live"
1412        );
1413
1414        state.update(cx, |state, cx| {
1415            state.activate_choice("first", "a", cx).unwrap()
1416        });
1417        assert!(
1418            cx.update(|window, cx| { state.update(cx, |state, cx| state.go_next(window, cx)) })
1419        );
1420        assert_eq!(
1421            cx.read(|cx| state.read(cx).current_item().unwrap().clone()),
1422            "second"
1423        );
1424        assert!(
1425            cx.update(|window, cx| {
1426                state.update(cx, |state, cx| state.skip_current(window, cx))
1427            })
1428        );
1429
1430        assert!(cx.read(|cx| state.read(cx).is_complete()));
1431        assert_eq!(
1432            cx.read(|cx| state.read(cx).item_state("second").unwrap().status()),
1433            QuestionnaireItemStatus::Skipped
1434        );
1435        assert_eq!(
1436            cx.read(|cx| harness.read(cx).events.clone()),
1437            vec!["answer", "current", "answer", "completed", "submit"]
1438        );
1439    }
1440
1441    #[gpui::test]
1442    fn keeps_input_draft_separate_and_synchronizes_silent_setters_and_reset(
1443        cx: &mut TestAppContext,
1444    ) {
1445        let (harness, state, first_input, second_input, cx) = harness(cx);
1446        let initial_events = cx.read(|cx| harness.read(cx).events.len());
1447
1448        cx.update(|window, cx| {
1449            state.update(cx, |state, cx| {
1450                state
1451                    .set_answer(
1452                        "first",
1453                        QuestionnaireAnswer::new().with_freeform("  custom  "),
1454                        window,
1455                        cx,
1456                    )
1457                    .unwrap();
1458            });
1459        });
1460        assert_eq!(
1461            cx.read(|cx| harness.read(cx).events.len()),
1462            initial_events,
1463            "programmatic setters are silent"
1464        );
1465        assert_eq!(cx.read(|cx| first_input.read(cx).value()), "  custom  ");
1466        assert_eq!(
1467            cx.read(|cx| {
1468                state
1469                    .read(cx)
1470                    .answer("first")
1471                    .unwrap()
1472                    .freeform()
1473                    .unwrap()
1474                    .clone()
1475            }),
1476            "  custom  "
1477        );
1478
1479        cx.update(|window, cx| {
1480            state.update(cx, |state, cx| {
1481                state
1482                    .set_answer(
1483                        "first",
1484                        QuestionnaireAnswer::new().with_choices(["a"]),
1485                        window,
1486                        cx,
1487                    )
1488                    .unwrap();
1489            });
1490        });
1491        assert_eq!(cx.read(|cx| first_input.read(cx).value()), "  custom  ");
1492        assert!(cx.read(|cx| { state.read(cx).answer("first").unwrap().freeform().is_none() }));
1493        assert_eq!(
1494            cx.read(|cx| state.read(cx).answer("first").unwrap().choices()[0].clone()),
1495            "a"
1496        );
1497
1498        cx.update(|window, cx| {
1499            first_input.update(cx, |input, cx| input.replace_all("", window, cx));
1500        });
1501        cx.run_until_parked();
1502        assert_eq!(
1503            cx.read(|cx| harness.read(cx).events.len()),
1504            initial_events,
1505            "editing an unselected draft does not change the semantic answer"
1506        );
1507        assert_eq!(
1508            cx.read(|cx| state.read(cx).answer("first").unwrap().choices()[0].clone()),
1509            "a"
1510        );
1511
1512        state.update(cx, |state, cx| {
1513            state.activate_choice("first", "b", cx).unwrap()
1514        });
1515
1516        cx.update(|window, cx| {
1517            state.update(cx, |state, cx| state.reset(window, cx));
1518        });
1519        assert_eq!(cx.read(|cx| first_input.read(cx).value()), "");
1520        assert_eq!(cx.read(|cx| second_input.read(cx).value()), "initial draft");
1521        assert!(cx.read(|cx| state.read(cx).answer("first").unwrap().is_empty()));
1522        assert_eq!(
1523            cx.read(|cx| {
1524                state
1525                    .read(cx)
1526                    .answer("second")
1527                    .unwrap()
1528                    .freeform()
1529                    .unwrap()
1530                    .clone()
1531            }),
1532            "initial draft"
1533        );
1534        assert_eq!(
1535            cx.read(|cx| harness.read(cx).events.len()),
1536            initial_events + 1
1537        );
1538    }
1539
1540    #[gpui::test]
1541    fn validates_all_items_and_returns_to_the_first_invalid_item(cx: &mut TestAppContext) {
1542        let (_, state, _, _, cx) = harness(cx);
1543        state.update(cx, |state, cx| {
1544            state.activate_choice("first", "a", cx).unwrap()
1545        });
1546
1547        assert!(
1548            !cx.update(|window, cx| { state.update(cx, |state, cx| state.submit(window, cx)) })
1549        );
1550        assert_eq!(
1551            cx.read(|cx| state.read(cx).current_item().unwrap().clone()),
1552            "second"
1553        );
1554        assert_eq!(
1555            cx.read(|cx| state.read(cx).error("second").unwrap().clone()),
1556            QuestionnaireValidationError::Message("Use the valid answer".into())
1557        );
1558
1559        state.update(cx, |state, cx| {
1560            state
1561                .set_external_error("first", "Server rejected it", cx)
1562                .unwrap()
1563        });
1564        assert!(
1565            !cx.update(|window, cx| { state.update(cx, |state, cx| state.submit(window, cx)) })
1566        );
1567        assert_eq!(
1568            cx.read(|cx| state.read(cx).current_item().unwrap().clone()),
1569            "first"
1570        );
1571
1572        cx.update(|window, cx| {
1573            state.update(cx, |state, cx| {
1574                state.clear_external_error("first", cx).unwrap();
1575                state
1576                    .set_input_value("second", "valid", window, cx)
1577                    .unwrap();
1578            });
1579        });
1580        assert!(cx.update(|window, cx| { state.update(cx, |state, cx| state.submit(window, cx)) }));
1581    }
1582
1583    #[gpui::test]
1584    fn preserves_schema_order_and_temporarily_excludes_disabled_answers(cx: &mut TestAppContext) {
1585        let (_, state, _, _, cx) = harness(cx);
1586
1587        cx.update(|window, cx| {
1588            state.update(cx, |state, cx| {
1589                state
1590                    .set_answer(
1591                        "second",
1592                        QuestionnaireAnswer::new()
1593                            .with_choices(["y", "x", "y"])
1594                            .with_freeform("valid"),
1595                        window,
1596                        cx,
1597                    )
1598                    .unwrap();
1599            });
1600        });
1601        assert_eq!(
1602            cx.read(|cx| state.read(cx).answer("second").unwrap().choices().to_vec()),
1603            vec![SharedString::from("x"), SharedString::from("y")]
1604        );
1605        cx.update(|window, cx| {
1606            state.update(cx, |state, cx| {
1607                state.set_current_item("second", window, cx).unwrap();
1608                state.focus_current_item(window, cx);
1609                assert!(state.focus_next_answer(window, cx));
1610            });
1611        });
1612        assert_eq!(
1613            cx.update(|window, cx| state.read(cx).focused_current_choice(window).cloned()),
1614            Some("x".into()),
1615            "filled multiple choices are focused in schema order"
1616        );
1617        assert_eq!(
1618            cx.read(|cx| state.read(cx).answer("second").unwrap().choices().to_vec()),
1619            vec![SharedString::from("x"), SharedString::from("y")],
1620            "focusing a filled choice does not toggle it"
1621        );
1622
1623        state.update(cx, |state, cx| {
1624            state.set_choice_disabled("second", "x", true, cx).unwrap()
1625        });
1626        assert_eq!(
1627            cx.read(|cx| state.read(cx).answer("second").unwrap().choices().to_vec()),
1628            vec![SharedString::from("y")]
1629        );
1630        state.update(cx, |state, cx| {
1631            state.set_choice_disabled("second", "x", false, cx).unwrap()
1632        });
1633        assert_eq!(
1634            cx.read(|cx| state.read(cx).answer("second").unwrap().choices().to_vec()),
1635            vec![SharedString::from("x"), SharedString::from("y")]
1636        );
1637
1638        let error = cx.update(|window, cx| {
1639            state.update(cx, |state, cx| {
1640                state.set_answer(
1641                    "second",
1642                    QuestionnaireAnswer::new().with_choices(["unknown"]),
1643                    window,
1644                    cx,
1645                )
1646            })
1647        });
1648        assert_eq!(
1649            error,
1650            Err(QuestionnaireSchemaError::UnknownChoice {
1651                item: "second".into(),
1652                choice: "unknown".into(),
1653            })
1654        );
1655    }
1656
1657    #[gpui::test]
1658    fn shortcuts_disabled_current_fallback_and_recompletion_are_deterministic(
1659        cx: &mut TestAppContext,
1660    ) {
1661        let (harness, state, first_input, _, cx) = harness(cx);
1662        cx.update(|window, cx| {
1663            state.update(cx, |state, cx| {
1664                state
1665                    .set_answer(
1666                        "first",
1667                        QuestionnaireAnswer::new().with_choices(["b"]),
1668                        window,
1669                        cx,
1670                    )
1671                    .unwrap();
1672                state.focus_current_item(window, cx);
1673                assert!(state.focus_next_answer(window, cx));
1674            });
1675        });
1676        assert_eq!(
1677            cx.update(|window, cx| state.read(cx).focused_current_choice(window).cloned()),
1678            Some("b".into()),
1679            "the first move from the item group focuses the existing answer"
1680        );
1681        assert_eq!(
1682            cx.read(|cx| state.read(cx).answer("first").unwrap().choices()[0].clone()),
1683            "b",
1684            "focusing the filled radio does not replace the answer"
1685        );
1686        cx.update(|window, cx| state.update(cx, |state, cx| state.reset(window, cx)));
1687
1688        cx.update(|window, cx| {
1689            state.update(cx, |state, cx| {
1690                state.focus_input("first", window, cx);
1691                assert!(!state.current_input_has_text(cx));
1692                assert!(state.focus_next_answer(window, cx));
1693            });
1694        });
1695        assert_eq!(
1696            cx.read(|cx| state.read(cx).answer("first").unwrap().choices()[0].clone()),
1697            "a",
1698            "an empty focused input may move to and activate a radio"
1699        );
1700        cx.update(|window, cx| state.update(cx, |state, cx| state.reset(window, cx)));
1701
1702        cx.update(|window, cx| {
1703            state.update(cx, |state, cx| {
1704                state.set_input_value("first", "draft", window, cx).unwrap();
1705                state.focus_input("first", window, cx);
1706                assert!(state.current_input_has_text(cx));
1707                assert!(!state.focus_next_answer(window, cx));
1708            });
1709        });
1710        assert!(cx.update(|window, cx| first_input.focus_handle(cx).is_focused(window)));
1711        cx.update(|window, cx| state.update(cx, |state, cx| state.reset(window, cx)));
1712
1713        assert!(cx.update(|window, cx| {
1714            state.update(cx, |state, cx| {
1715                state.focus_current_item(window, cx);
1716                state.focus_next_answer(window, cx)
1717            })
1718        }));
1719        assert_eq!(
1720            cx.read(|cx| state.read(cx).answer("first").unwrap().choices()[0].clone()),
1721            "a",
1722            "moving from the item group to a radio activates it"
1723        );
1724        cx.update(|window, cx| state.update(cx, |state, cx| state.reset(window, cx)));
1725
1726        assert_eq!(
1727            cx.read(|cx| state.read(cx).shortcut_for_choice("first", "a")),
1728            Some("A".into())
1729        );
1730        state.update(cx, |state, cx| {
1731            state.set_choice_disabled("first", "a", true, cx).unwrap()
1732        });
1733        assert_eq!(
1734            cx.read(|cx| state.read(cx).shortcut_for_choice("first", "b")),
1735            Some("A".into())
1736        );
1737        state.update(cx, |state, cx| {
1738            state.set_choice_disabled("first", "a", false, cx).unwrap()
1739        });
1740        cx.update(|window, cx| {
1741            state.update(cx, |state, cx| {
1742                assert!(state.activate_shortcut("a", window, cx));
1743                state.focus_choice("first", "a", window, cx);
1744                assert!(state.move_current_radio(1, window, cx));
1745                assert!(state.go_next(window, cx));
1746                assert!(state.skip_current(window, cx));
1747            });
1748        });
1749        assert!(cx.read(|cx| state.read(cx).is_complete()));
1750
1751        cx.update(|window, cx| {
1752            state.update(cx, |state, cx| {
1753                state
1754                    .set_input_value("second", "valid", window, cx)
1755                    .unwrap();
1756                state.activate_choice("second", "x", cx).unwrap();
1757            });
1758        });
1759        assert!(!cx.read(|cx| state.read(cx).is_complete()));
1760        assert!(cx.update(|window, cx| { state.update(cx, |state, cx| state.submit(window, cx)) }));
1761        let events = cx.read(|cx| harness.read(cx).events.clone());
1762        assert_eq!(
1763            events.iter().filter(|event| **event == "completed").count(),
1764            2
1765        );
1766
1767        let before_disable = events.len();
1768        cx.update(|window, cx| {
1769            state.update(cx, |state, cx| {
1770                state.set_item_disabled("second", true, window, cx).unwrap();
1771            });
1772        });
1773        assert_eq!(
1774            cx.read(|cx| state.read(cx).current_item().unwrap().clone()),
1775            "first"
1776        );
1777        assert_eq!(
1778            cx.read(|cx| harness.read(cx).events.len()),
1779            before_disable,
1780            "programmatic disable and fallback are silent"
1781        );
1782    }
1783
1784    /// Two single-answer items around a multiple-answer one; `rejected` fails
1785    /// validation on the first item.
1786    struct ChooseHarness {
1787        state: Entity<QuestionnaireState>,
1788        input: Entity<InputState>,
1789        events: Vec<&'static str>,
1790        _subscription: Subscription,
1791    }
1792
1793    impl Render for ChooseHarness {
1794        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
1795            div()
1796        }
1797    }
1798
1799    fn choose_harness(
1800        cx: &mut TestAppContext,
1801    ) -> (
1802        Entity<ChooseHarness>,
1803        Entity<QuestionnaireState>,
1804        &mut VisualTestContext,
1805    ) {
1806        cx.update(crate::init);
1807        let (harness, cx) = cx.add_window_view(|window, cx| {
1808            let input = cx.new(|cx| InputState::new(window, cx));
1809            let items = vec![
1810                QuestionnaireItemDefinition::new("first", "First")
1811                    .with_choices([
1812                        QuestionnaireChoiceDefinition::new("a", "A"),
1813                        QuestionnaireChoiceDefinition::new("b", "B"),
1814                        QuestionnaireChoiceDefinition::new("rejected", "Rejected"),
1815                        QuestionnaireChoiceDefinition::new("off", "Off").with_disabled(true),
1816                    ])
1817                    .with_input(QuestionnaireInputDefinition::new(input.clone(), "Other"))
1818                    .with_validator(|context| {
1819                        (!context.answer().choices().iter().any(|c| c == "rejected"))
1820                            .then_some(())
1821                            .ok_or_else(|| SharedString::from("Pick another answer"))
1822                    }),
1823                QuestionnaireItemDefinition::new("second", "Second")
1824                    .with_multiple(true)
1825                    .with_choices([
1826                        QuestionnaireChoiceDefinition::new("x", "X"),
1827                        QuestionnaireChoiceDefinition::new("y", "Y"),
1828                    ]),
1829                QuestionnaireItemDefinition::new("third", "Third").with_choices([
1830                    QuestionnaireChoiceDefinition::new("c", "C"),
1831                    QuestionnaireChoiceDefinition::new("d", "D"),
1832                ]),
1833            ];
1834            let state = cx.new(|cx| QuestionnaireState::new(items, cx).unwrap());
1835            let subscription = cx.subscribe(&state, |this: &mut ChooseHarness, _, event, _| {
1836                this.events.push(event_name(event));
1837            });
1838            ChooseHarness {
1839                state,
1840                input,
1841                events: Vec::new(),
1842                _subscription: subscription,
1843            }
1844        });
1845        let state = harness.read_with(cx, |harness, _| harness.state.clone());
1846        (harness, state, cx)
1847    }
1848
1849    fn choose(
1850        cx: &mut VisualTestContext,
1851        state: &Entity<QuestionnaireState>,
1852        item: &str,
1853        value: &str,
1854    ) {
1855        cx.update(|window, cx| {
1856            state
1857                .update(cx, |state, cx| state.choose(item, value, window, cx))
1858                .unwrap();
1859        });
1860    }
1861
1862    fn current(cx: &mut VisualTestContext, state: &Entity<QuestionnaireState>) -> SharedString {
1863        cx.read(|cx| state.read(cx).current_item().unwrap().clone())
1864    }
1865
1866    fn wait_out_hold(cx: &mut VisualTestContext) {
1867        cx.executor().advance_clock(CHOICE_CONFIRM_DELAY);
1868        cx.run_until_parked();
1869    }
1870
1871    fn count(cx: &mut VisualTestContext, harness: &Entity<ChooseHarness>, name: &str) -> usize {
1872        cx.read(|cx| {
1873            harness
1874                .read(cx)
1875                .events
1876                .iter()
1877                .filter(|event| **event == name)
1878                .count()
1879        })
1880    }
1881
1882    #[gpui::test]
1883    fn choosing_a_single_answer_holds_it_then_confirms(cx: &mut TestAppContext) {
1884        let (harness, state, cx) = choose_harness(cx);
1885
1886        choose(cx, &state, "first", "a");
1887        cx.run_until_parked();
1888        assert_eq!(
1889            current(cx, &state),
1890            "first",
1891            "the choice is shown before moving"
1892        );
1893        assert_eq!(
1894            cx.read(|cx| state.read(cx).answer("first").unwrap().choices().to_vec()),
1895            vec![SharedString::from("a")]
1896        );
1897        wait_out_hold(cx);
1898        assert_eq!(current(cx, &state), "second");
1899
1900        // Going back and choosing the selected answer again confirms at once.
1901        cx.update(|window, cx| state.update(cx, |state, cx| state.go_previous(window, cx)));
1902        choose(cx, &state, "first", "a");
1903        assert_eq!(current(cx, &state), "second");
1904
1905        // The last item submits once, even when activated twice in a row.
1906        choose(cx, &state, "second", "x");
1907        cx.update(|window, cx| state.update(cx, |state, cx| state.go_next(window, cx)));
1908        assert_eq!(current(cx, &state), "third");
1909        choose(cx, &state, "third", "c");
1910        choose(cx, &state, "third", "c");
1911        wait_out_hold(cx);
1912        assert_eq!(count(cx, &harness, "submit"), 1);
1913        assert_eq!(count(cx, &harness, "completed"), 1);
1914
1915        // A second answer replaces the pending confirm instead of adding one.
1916        choose(cx, &state, "third", "d");
1917        choose(cx, &state, "third", "c");
1918        wait_out_hold(cx);
1919        assert_eq!(count(cx, &harness, "submit"), 2);
1920    }
1921
1922    #[gpui::test]
1923    fn arrows_multiple_choices_and_typing_never_confirm(cx: &mut TestAppContext) {
1924        let (harness, state, cx) = choose_harness(cx);
1925
1926        cx.update(|window, cx| {
1927            state.update(cx, |state, cx| {
1928                state.focus_choice("first", "a", window, cx);
1929                assert!(state.move_current_radio(1, window, cx));
1930            });
1931        });
1932        wait_out_hold(cx);
1933        assert_eq!(
1934            cx.read(|cx| state.read(cx).answer("first").unwrap().choices().to_vec()),
1935            vec![SharedString::from("b")],
1936            "an arrow moves the selection"
1937        );
1938        assert_eq!(current(cx, &state), "first", "an arrow never confirms");
1939
1940        // Typing after choosing drops the pending confirm.
1941        choose(cx, &state, "first", "a");
1942        let input = cx.read(|cx| harness.read(cx).input.clone());
1943        cx.update(|window, cx| {
1944            input.update(cx, |input, cx| input.replace_all("draft", window, cx))
1945        });
1946        wait_out_hold(cx);
1947        assert_eq!(current(cx, &state), "first");
1948        assert!(cx.read(|cx| state.read(cx).answer("first").unwrap().freeform().is_some()));
1949
1950        // A disabled choice ignores activation.
1951        choose(cx, &state, "first", "off");
1952        wait_out_hold(cx);
1953        assert_eq!(current(cx, &state), "first");
1954
1955        cx.update(|window, cx| state.update(cx, |state, cx| state.go_next(window, cx)));
1956        choose(cx, &state, "second", "x");
1957        choose(cx, &state, "second", "y");
1958        choose(cx, &state, "second", "x");
1959        wait_out_hold(cx);
1960        assert_eq!(
1961            current(cx, &state),
1962            "second",
1963            "a multiple-answer item only toggles"
1964        );
1965        assert_eq!(
1966            cx.read(|cx| state.read(cx).answer("second").unwrap().choices().to_vec()),
1967            vec![SharedString::from("y")]
1968        );
1969    }
1970
1971    #[gpui::test]
1972    fn a_rejected_choice_stays_on_its_item(cx: &mut TestAppContext) {
1973        let (harness, state, cx) = choose_harness(cx);
1974
1975        choose(cx, &state, "first", "rejected");
1976        wait_out_hold(cx);
1977        assert_eq!(current(cx, &state), "first");
1978        assert_eq!(
1979            cx.read(|cx| state.read(cx).error("first").cloned()),
1980            Some(QuestionnaireValidationError::Message(
1981                "Pick another answer".into()
1982            ))
1983        );
1984        assert_eq!(
1985            cx.update(|window, cx| state.read(cx).focused_current_choice(window).cloned()),
1986            Some("rejected".into()),
1987            "the invalid answer keeps focus"
1988        );
1989        assert_eq!(count(cx, &harness, "current"), 0);
1990    }
1991
1992    #[gpui::test]
1993    fn reduced_motion_confirms_without_the_hold(cx: &mut TestAppContext) {
1994        let (_, state, cx) = choose_harness(cx);
1995        cx.update(|_, cx| cx.set_reduce_motion(true));
1996
1997        choose(cx, &state, "first", "a");
1998        assert_eq!(
1999            current(cx, &state),
2000            "first",
2001            "the confirm waits for the answer change to be delivered"
2002        );
2003        cx.run_until_parked();
2004        assert_eq!(current(cx, &state), "second");
2005    }
2006}