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
11const 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
32pub 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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}