Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
078b993
questionnaire: Add Questionnaire component
suxiaoshao Aug 30, 2026
bff84f3
questionnaire: Refine interactions and base-nova styling
suxiaoshao Aug 30, 2026
c42fcc8
questionnaire: Complete Story and documentation
suxiaoshao Aug 30, 2026
d7f5777
questionnaire: Allow Italian localization spelling
suxiaoshao Aug 30, 2026
93d4674
questionnaire: Follow the `gpui-kit` crate and docs layout
madcodelife Sep 19, 2026
f913b44
questionnaire: Align the skin with the ReUI `base-nova` questionnaire
madcodelife Sep 19, 2026
20d32f9
questionnaire: Give the skin one scale and tighten its seams
madcodelife Sep 19, 2026
e423921
questionnaire: Trim the Story and documentation to the examples that …
madcodelife Sep 19, 2026
c089a2e
questionnaire: Move the state machine into `gpui-base`
madcodelife Sep 19, 2026
28ff033
questionnaire: Move the keyboard contract and answer controls into `g…
madcodelife Sep 19, 2026
ef746f8
questionnaire: Drop a leftover web-validation note from the docs
madcodelife Sep 19, 2026
15e69d5
questionnaire: Drop the Stepper example that could not track the enab…
madcodelife Sep 19, 2026
bbc2b5a
questionnaire: Let the root publish one scale for all its parts
madcodelife Sep 19, 2026
bce6439
questionnaire: Register the flow with the component shell
madcodelife Sep 19, 2026
80e4292
questionnaire: Start the freeform answer on the indicator edge
madcodelife Sep 19, 2026
cf904c6
questionnaire: Center a choice's indicator and shortcut on the label …
madcodelife Sep 19, 2026
cf76949
questionnaire: Keep the shell binding and its gallery example cheap p…
madcodelife Sep 19, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions crates/base/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ mod popover;
mod popup;
mod positioner;
mod progress;
pub mod questionnaire;
mod radio;
mod radio_group;
mod reduce_motion;
Expand Down
121 changes: 121 additions & 0 deletions crates/base/src/questionnaire/control.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
use gpui::{
ElementId, Entity, InteractiveElement as _, SharedString, StatefulInteractiveElement as _,
prelude::FluentBuilder as _,
};

use super::QuestionnaireState;
use crate::{Checkbox, CheckboxState, Radio};

/// The answer control for one choice, wired to the questionnaire's behavior.
///
/// A multiple-answer item hands back a `Checkbox` and a single-answer item a
/// `Radio`, each already carrying its checked state, disabled state,
/// accessibility name, position in set, focus handle, confirm key and change
/// handler. The skin decides what the control looks like and what it contains.
// The value is handed straight to the caller's `match` and dropped into an
// element; boxing either variant would buy an allocation on every choice to
// even out a difference that never outlives one render.
#[allow(clippy::large_enum_variant)]
pub enum QuestionnaireChoiceControl {
Checkbox(Checkbox),
Radio(Radio),
}

impl QuestionnaireChoiceControl {
/// Returns `None` when the item or the choice is not part of the schema.
pub fn new(
state: &Entity<QuestionnaireState>,
item: impl Into<SharedString>,
value: impl Into<SharedString>,
id: impl Into<ElementId>,
cx: &gpui::App,
) -> Option<Self> {
let item = item.into();
let value = value.into();
let snapshot = state.read(cx);
let choice = snapshot.choice_state(&item, &value)?;
let multiple = snapshot.item_state(&item)?.is_multiple();
let definition = snapshot.choice_definition(&item, &value)?;
let label = definition.accessibility_label().clone();
let description = definition.description().cloned();
let position = snapshot.choice_position(&item, &value);
let focus_handle = snapshot.choice_focus_handle(&item, &value).cloned();
let selected = choice.is_selected();
let disabled = choice.is_disabled();

// Enter confirms an answer that is already selected; an unselected
// control keeps Enter for activation.
let confirm_state = state.clone();
let confirm =
move |event: &gpui::KeyDownEvent, window: &mut gpui::Window, cx: &mut gpui::App| {
if selected
&& !window.default_prevented()
&& !event.is_held
&& event.keystroke.key == "enter"
&& event.keystroke.modifiers.number_of_modifiers() == 0
&& confirm_state.update(cx, |state, cx| state.confirm_current(window, cx))
{
window.prevent_default();
}
};

let id = id.into();
Some(if multiple {
let change_state = state.clone();
let change_item = item.clone();
let change_value = value.clone();
Self::Checkbox(
Checkbox::new(id)
.state(if selected {
CheckboxState::Checked
} else {
CheckboxState::Unchecked
})
.disabled(disabled)
.accessibility_label(label)
.when_some(description, |this, description| {
this.aria_description(description)
})
.when_some(position, |this, (position, total)| {
this.aria_position_in_set(position).aria_size_of_set(total)
})
.when_some(focus_handle, |this, focus_handle| {
this.track_focus(&focus_handle)
})
.capture_key_down(confirm)
.on_change(move |_, _, window, cx| {
let _ = change_state.update(cx, |state, cx| {
let result = state.activate_choice(&change_item, &change_value, cx);
state.focus_choice(&change_item, &change_value, window, cx);
result
});
}),
)
} else {
let change_state = state.clone();
Self::Radio(
Radio::new(id)
.checked(selected)
.disabled(disabled)
.accessibility_label(label)
.when_some(description, |this, description| {
this.aria_description(description)
})
.when_some(position, |this, (position, total)| {
this.set_position(position, total)
})
.when_some(focus_handle, |this, focus_handle| {
this.track_focus(&focus_handle)
})
.capture_key_down(confirm)
.on_change(move |_, _, window, cx| {
let _ = change_state.update(cx, |state, cx| {
let result = state.activate_choice(&item, &value, cx);
state.focus_choice(&item, &value, window, cx);
result
});
}),
)
})
}
}
104 changes: 104 additions & 0 deletions crates/base/src/questionnaire/keyboard.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
use gpui::{App, Entity, KeyDownEvent, Window};

use super::QuestionnaireState;

/// Routes a key press to the questionnaire's behavior and reports whether it
/// was consumed. The skin installs it on the root; the contract — arrows move
/// between answers and items, Enter confirms a filled answer, a bare letter or
/// digit activates a shortcut — lives here so a different skin keeps it.
pub fn handle_key_down(
state: &Entity<QuestionnaireState>,
event: &KeyDownEvent,
window: &mut Window,
cx: &mut App,
) {
if window.default_prevented()
|| event.is_held
|| event.prefer_character_input
|| event.keystroke.is_ime_in_progress()
{
return;
}

let modifiers = event.keystroke.modifiers;
let key = event.keystroke.key.as_str();
let input_focused = state.read(cx).is_current_input_focused(window);
let input_has_text = input_focused && state.read(cx).current_input_has_text(cx);
let single_radio_focused = {
let state = state.read(cx);
state
.current_item()
.and_then(|item| state.item_state(item))
.is_some_and(|item| !item.is_multiple())
&& state.focused_current_choice(window).is_some()
};

let handled = if key == "enter" && modifiers.secondary() && modifiers.number_of_modifiers() == 1
{
state.update(cx, |state, cx| state.confirm_current(window, cx))
} else if modifiers.number_of_modifiers() != 0 {
false
} else if input_focused {
match key {
"enter" if focused_answer_is_filled(state, window, cx) => {
state.update(cx, |state, cx| state.confirm_current(window, cx))
}
"up" if !input_has_text => {
state.update(cx, |state, cx| state.focus_previous_answer(window, cx))
}
"down" if !input_has_text => {
state.update(cx, |state, cx| state.focus_next_answer(window, cx))
}
_ => false,
}
} else {
match key {
"up" => {
state.update(cx, |state, cx| state.focus_previous_answer(window, cx))
|| (single_radio_focused
&& state.update(cx, |state, cx| state.move_current_radio(-1, window, cx)))
}
"down" => {
state.update(cx, |state, cx| state.focus_next_answer(window, cx))
|| (single_radio_focused
&& state.update(cx, |state, cx| state.move_current_radio(1, window, cx)))
}
"left" if single_radio_focused => {
state.update(cx, |state, cx| state.move_current_radio(-1, window, cx))
}
"right" if single_radio_focused => {
state.update(cx, |state, cx| state.move_current_radio(1, window, cx))
}
"left" => state.update(cx, |state, cx| state.go_previous(window, cx)),
"right" if state.read(cx).navigation_state().is_confirmable() => {
state.update(cx, |state, cx| state.go_next(window, cx))
}
"right" => false,
"enter" if focused_answer_is_filled(state, window, cx) => {
state.update(cx, |state, cx| state.confirm_current(window, cx))
}
"enter" => false,
_ => state.update(cx, |state, cx| state.activate_shortcut(key, window, cx)),
}
};

if handled {
window.prevent_default();
}
}

fn focused_answer_is_filled(state: &Entity<QuestionnaireState>, window: &Window, cx: &App) -> bool {
let state = state.read(cx);
let Some(item) = state.current_item() else {
return false;
};
if state.is_current_input_focused(window) {
return state
.answer(item)
.is_some_and(|answer| answer.freeform().is_some());
}
state
.focused_current_choice(window)
.and_then(|value| state.choice_state(item, value))
.is_some_and(|choice| choice.is_selected())
}
14 changes: 14 additions & 0 deletions crates/base/src/questionnaire/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
//! Questionnaire behavior: answers, validation, navigation, focus and shortcuts.
//!
//! The state machine lives here so an application can replace the visual
//! language without reimplementing it. `gpui-component` owns the skin.

mod control;
mod keyboard;
mod state;
mod types;

pub use control::QuestionnaireChoiceControl;
pub use keyboard::handle_key_down;
pub use state::*;
pub use types::*;
Loading
Loading