Misc · Asking and learning: a poll, a survey, a quiz and flashcards

misc::Poll turns to results once the owner holds the vote, with Change
vote on Vote's stop. misc::Survey asks on one page and sends when each
question is answered, thanking in Submit's place and focus. misc::Quiz
checks, goes on, scores and starts again on one button. misc::Flashcards
turn on a press and go round the deck, each face fading in.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ZacharyZhang-NYandClaude Opus 5.5 committed 2026-09-28 02:38:33 -04:00
1 parent 19a5295ed8
commit 6e02a0cce3
11 files changed
+1345 -3

No files matched your search

+154
View File
@@ -0,0 +1,154 @@
use gpui::{
Animation, AnimationExt, App, ElementId, InteractiveElement, IntoElement, ParentElement,
RenderOnce, SharedString, StatefulInteractiveElement, Styled, Window, div,
};
use crate::{
buttons::IconButton,
motion,
primitives::{FocusRing, IconName, tab_stop},
theme::{ActiveTheme, Radius, TextSize},
typography::tabular,
};
/// Where a deck stands: the card shown, and whether its back faces up.
#[derive(Default)]
struct Deck {
at: usize,
back: bool,
}
/// Cards with a question on the front and its answer on the back, one at a time: a press on the card turns it, and Previous and Next go round the deck, front up. Each face fades in.
#[derive(IntoElement)]
pub struct Flashcards {
id: ElementId,
cards: Vec<(SharedString, SharedString)>,
}
impl Flashcards {
pub fn new(
id: impl Into<ElementId>,
cards: impl IntoIterator<Item = (impl Into<SharedString>, impl Into<SharedString>)>,
) -> Self {
Self {
id: id.into(),
cards: cards
.into_iter()
.map(|(front, back)| (front.into(), back.into()))
.collect(),
}
}
}
impl RenderOnce for Flashcards {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let id = self.id;
let count = self.cards.len();
assert!(count > 0, "flashcards {id:?} has no cards");
let deck = window.use_keyed_state((id.clone(), "deck"), cx, |_, _| Deck::default());
let card = tab_stop((id.clone(), "card").into(), true, window, cx);
let (at, back) = (deck.read(cx).at, deck.read(cx).back);
let (front_words, back_words) = self.cards[at].clone();
let turn = motion::changes((id.clone(), "face"), (at, back), window, cx);
let theme = cx.theme();
let colors = &theme.colors;
let (key, caption, words) = match back {
false => ("front", "Question", front_words),
true => ("back", "Answer", back_words),
};
let face = div()
.debug_selector(|| format!("flashcard-{key}-{words}"))
.flex()
.flex_1()
.flex_col()
.gap_2()
.p_5()
.child(
div()
.text_size(theme.text_size(TextSize::Xs))
.text_color(colors.fg_muted)
.child(caption),
)
.child(
div().flex().flex_1().items_center().child(
div()
.flex_1()
.min_w_0()
.text_center()
.text_size(theme.text_size(TextSize::Lg))
.text_color(colors.fg)
.child(words.clone()),
),
)
.with_animation(
(id.clone(), format!("face-{turn}")),
Animation::new(motion::duration(motion::BASE, cx))
.with_easing(motion::ease_out_cubic),
move |face, t| if turn == 0 { face } else { face.opacity(t) },
);
let go = |by: isize| {
let deck = deck.clone();
move |_: &gpui::ClickEvent, _: &mut Window, cx: &mut App| {
deck.update(cx, |deck, cx| {
deck.at = (deck.at as isize + by).rem_euclid(count as isize) as usize;
deck.back = false;
log::info!("flashcards: card {} of {count}", deck.at + 1);
cx.notify();
})
}
};
let turned = deck.clone();
div()
.flex()
.flex_col()
.gap_3()
.child(
div()
.id((id.clone(), "card-face"))
.track_focus(&card)
.flex()
.flex_col()
.min_h(theme.misc().card)
.rounded(theme.radius(Radius::Lg))
.border_1()
.border_color(colors.border)
.bg(colors.surface)
.focus_ring(cx)
.cursor_pointer()
.on_click(move |_, _, cx| {
turned.update(cx, |deck, cx| {
deck.back = !deck.back;
log::info!(
"flashcards: turned to the {}",
if deck.back { "back" } else { "front" }
);
cx.notify();
})
})
.child(face),
)
.child(
div()
.flex()
.items_center()
.justify_between()
.gap_2()
.child(
IconButton::new((id.clone(), "previous"), IconName::ChevronLeft)
.tooltip("Previous card")
.on_click(go(-1)),
)
.child(
tabular(div())
.text_size(theme.text_size(TextSize::Sm))
.text_color(colors.fg_muted)
.child(format!("{} of {count}", at + 1)),
)
.child(
IconButton::new((id, "next"), IconName::ChevronRight)
.tooltip("Next card")
.on_click(go(1)),
),
)
}
}
+8
View File
@@ -1,8 +1,12 @@
mod calculator;
mod captcha;
mod clock;
mod flashcards;
mod poll;
mod quiz;
mod scanner;
mod stopwatch;
mod survey;
#[cfg(all(test, feature = "test-support"))]
mod tests;
mod units;
@@ -10,6 +14,10 @@ mod units;
pub use calculator::Calculator;
pub use captcha::{Captcha, CaptchaState};
pub use clock::{Clock, WorldClock};
pub use flashcards::Flashcards;
pub use poll::Poll;
pub use quiz::{Quiz, QuizQuestion};
pub use scanner::QrCodeScanner;
pub use stopwatch::Stopwatch;
pub use survey::{Answer, Question, Survey};
pub use units::UnitConverter;
+225
View File
@@ -0,0 +1,225 @@
use std::rc::Rc;
use gpui::{
App, ElementId, FontWeight, InteractiveElement, IntoElement, ParentElement, RenderOnce,
SharedString, Styled, Window, div, prelude::*,
};
use crate::{
buttons::{Button, ButtonVariant},
forms::{Choice, RadioGroup},
motion::ProgressBar,
primitives::{Icon, IconName, tab_stop},
theme::{ActiveTheme, IconSize, TextSize},
typography::{Ellipsis, format, tabular},
};
type OnVote = Rc<dyn Fn(Option<usize>, &mut Window, &mut App)>;
/// A question with options to vote on, one each. Once the owner holds the vote, the options turn to results: each one's share of the votes as a bar, the vote marked, and the count. Vote and Change vote share one focus handle.
#[derive(IntoElement)]
pub struct Poll {
id: ElementId,
question: SharedString,
options: Vec<SharedString>,
votes: Vec<u64>,
voted: Option<usize>,
on_vote: Option<OnVote>,
}
impl Poll {
pub fn new(
id: impl Into<ElementId>,
question: impl Into<SharedString>,
options: impl IntoIterator<Item = impl Into<SharedString>>,
) -> Self {
let options: Vec<SharedString> = options.into_iter().map(Into::into).collect();
Self {
id: id.into(),
question: question.into(),
votes: vec![0; options.len()],
options,
voted: None,
on_vote: None,
}
}
/// Each option's count, in order.
pub fn votes(mut self, votes: impl IntoIterator<Item = u64>) -> Self {
self.votes = votes.into_iter().collect();
self
}
/// The option this viewer voted for, as the owner holds it.
pub fn voted(mut self, option: usize) -> Self {
self.voted = Some(option);
self
}
/// Runs with the option voted for, or none when Change vote takes the vote back.
pub fn on_vote(
mut self,
handler: impl Fn(Option<usize>, &mut Window, &mut App) + 'static,
) -> Self {
self.on_vote = Some(Rc::new(handler));
self
}
}
impl RenderOnce for Poll {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let id = self.id;
let count = self.options.len();
assert!(count > 0, "poll {id:?} has no options");
assert_eq!(
self.votes.len(),
count,
"poll {id:?} has {} counts for {count} options",
self.votes.len()
);
if let Some(voted) = self.voted {
assert!(
voted < count,
"poll {id:?}: vote {voted} of {count} options"
);
}
let on_vote = self
.on_vote
.unwrap_or_else(|| panic!("poll {id:?} has no on_vote"));
let pick = window.use_keyed_state((id.clone(), "pick"), cx, |_, _| None::<usize>);
if self.voted.is_some() && *pick.read(cx) != self.voted {
pick.update(cx, |pick, _| *pick = self.voted);
}
let action = tab_stop((id.clone(), "action").into(), true, window, cx);
let theme = cx.theme();
let colors = &theme.colors;
let picked = *pick.read(cx);
let body =
match self.voted {
None => {
let choices = self
.options
.iter()
.enumerate()
.map(|(ix, label)| Choice::new(ix.to_string(), label.clone()));
let chose = pick.clone();
let group = RadioGroup::new((id.clone(), "options"), choices).on_change(
move |value, _, cx| {
let ix = value.parse().expect("an option's place");
chose.update(cx, |pick, cx| {
*pick = Some(ix);
cx.notify();
})
},
);
match picked {
Some(ix) => group.selected(ix.to_string()),
None => group,
}
.into_any_element()
}
Some(voted) => {
let total: u64 = self.votes.iter().sum();
let rows = self.options.iter().zip(&self.votes).enumerate().map(
|(ix, (label, votes))| {
let share = match total {
0 => 0.0,
_ => *votes as f32 / total as f32,
};
let percent = format::percent(share as f64, 0, false);
div()
.debug_selector(|| format!("poll-{label}-{percent}"))
.flex()
.flex_col()
.gap_1()
.child(
div()
.flex()
.items_center()
.gap_2()
.child(
div()
.flex_1()
.min_w_0()
.text_color(colors.fg)
.child(Ellipsis::new(label.clone())),
)
.when(ix == voted, |row| {
row.child(
Icon::new(IconName::CircleCheck)
.size(IconSize::Sm)
.color(colors.accent),
)
})
.child(
tabular(div())
.flex_none()
.text_color(colors.fg_muted)
.child(percent.clone()),
),
)
.child(ProgressBar::new((id.clone(), format!("bar-{ix}")), share))
},
);
div()
.flex()
.flex_col()
.gap_3()
.children(rows)
.child(
div()
.text_size(theme.text_size(TextSize::Sm))
.text_color(colors.fg_muted)
.child(format::plural(total, "vote", "votes")),
)
.into_any_element()
}
};
let button = match self.voted {
None => {
let (focus, on_vote) = (action.clone(), on_vote.clone());
Button::new((id.clone(), "vote"), "Vote")
.variant(ButtonVariant::Primary)
.focus_handle(&action)
.disabled(picked.is_none())
.on_click(move |_, window, cx| {
let ix = picked.expect("Vote waits for a pick");
log::info!("poll: voted for option {ix}");
on_vote(Some(ix), window, cx);
window.focus(&focus);
})
}
Some(_) => {
let focus = action.clone();
Button::new((id.clone(), "change"), "Change vote")
.focus_handle(&action)
.on_click(move |_, window, cx| {
log::info!("poll: vote taken back");
on_vote(None, window, cx);
window.focus(&focus);
})
}
};
div()
.flex()
.flex_col()
.gap_3()
.child(
div().flex().child(
div()
.flex_1()
.min_w_0()
.text_color(colors.fg)
.font_weight(FontWeight::SEMIBOLD)
.child(self.question),
),
)
.child(body)
.child(
div()
.debug_selector(|| "poll-action".into())
.flex()
.child(button),
)
}
}
+315
View File
@@ -0,0 +1,315 @@
use std::rc::Rc;
use gpui::{
App, ElementId, FontWeight, InteractiveElement, IntoElement, ParentElement, RenderOnce,
SharedString, Styled, Window, div, prelude::*,
};
use crate::{
buttons::{Button, ButtonVariant},
forms::{Choice, RadioGroup},
primitives::{Icon, IconName, tab_stop},
theme::{ActiveTheme, IconSize, TextSize},
typography::format,
};
type OnScore = Rc<dyn Fn(usize, usize, &mut Window, &mut App)>;
/// A question, its options, and the right one.
#[derive(Clone)]
pub struct QuizQuestion {
prompt: SharedString,
options: Vec<SharedString>,
right: usize,
}
impl QuizQuestion {
pub fn new(
prompt: impl Into<SharedString>,
options: impl IntoIterator<Item = impl Into<SharedString>>,
right: usize,
) -> Self {
let options: Vec<SharedString> = options.into_iter().map(Into::into).collect();
assert!(
options.len() >= 2,
"a quiz question needs two options or more"
);
assert!(
right < options.len(),
"right answer {right} of {} options",
options.len()
);
Self {
prompt: prompt.into(),
options,
right,
}
}
}
/// Where a quiz stands: the question, its pick, whether it was checked, the right answers so far, and whether Check came with no pick.
#[derive(Default)]
struct Sitting {
at: usize,
pick: Option<usize>,
checked: bool,
right: usize,
done: bool,
nudged: bool,
}
impl Sitting {
/// What the one button does next, and says.
fn next(&self, count: usize) -> &'static str {
match (self.done, self.checked, self.at + 1 == count) {
(true, _, _) => "Try again",
(false, false, _) => "Check",
(false, true, false) => "Next",
(false, true, true) => "See score",
}
}
/// Runs the button's step for question `right` of `count`.
fn advance(&mut self, right: usize, count: usize) {
match (self.done, self.checked) {
(true, _) => *self = Sitting::default(),
(false, false) => match self.pick {
None => self.nudged = true,
Some(pick) => {
self.checked = true;
self.right += usize::from(pick == right);
}
},
(false, true) if self.at + 1 == count => self.done = true,
(false, true) => {
self.at += 1;
self.pick = None;
self.checked = false;
}
}
}
}
/// Questions one at a time: pick an option and Check shows the right one, then Next; the last gives the score, with Try again. Check, Next, See score and Try again are one button on one focus handle; Check with no pick asks for one.
#[derive(IntoElement)]
pub struct Quiz {
id: ElementId,
questions: Vec<QuizQuestion>,
on_score: Option<OnScore>,
}
impl Quiz {
pub fn new(
id: impl Into<ElementId>,
questions: impl IntoIterator<Item = QuizQuestion>,
) -> Self {
Self {
id: id.into(),
questions: questions.into_iter().collect(),
on_score: None,
}
}
/// Runs with the right answers and the question count when the score shows.
pub fn on_score(
mut self,
handler: impl Fn(usize, usize, &mut Window, &mut App) + 'static,
) -> Self {
self.on_score = Some(Rc::new(handler));
self
}
}
impl RenderOnce for Quiz {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let id = self.id;
let count = self.questions.len();
assert!(count > 0, "quiz {id:?} has no questions");
let sitting =
window.use_keyed_state((id.clone(), "sitting"), cx, |_, _| Sitting::default());
let action = tab_stop((id.clone(), "action").into(), true, window, cx);
let theme = cx.theme();
let colors = &theme.colors;
let (at, pick, checked, right, done, nudged) = {
let sitting = sitting.read(cx);
(
sitting.at,
sitting.pick,
sitting.checked,
sitting.right,
sitting.done,
sitting.nudged,
)
};
let question = self.questions[at].clone();
let label = sitting.read(cx).next(count);
let body = match (done, checked) {
(true, _) => div()
.flex()
.flex_col()
.gap_1()
.child(
div()
.debug_selector(|| format!("quiz-score-{right}-{count}"))
.text_size(theme.text_size(TextSize::Xxl))
.text_color(colors.fg)
.child(format!("{right} of {count}")),
)
.child(div().text_color(colors.fg_muted).child(format::percent(
right as f64 / count as f64,
0,
false,
)))
.into_any_element(),
(false, false) => {
let choices = question
.options
.iter()
.enumerate()
.map(|(ix, label)| Choice::new(ix.to_string(), label.clone()));
let chose = sitting.clone();
let group = RadioGroup::new((id.clone(), format!("options-{at}")), choices)
.on_change(move |value, _, cx| {
let ix = value.parse().expect("an option's place");
chose.update(cx, |sitting, cx| {
sitting.pick = Some(ix);
sitting.nudged = false;
cx.notify();
})
});
div()
.flex()
.flex_col()
.gap_2()
.child(match pick {
Some(ix) => group.selected(ix.to_string()),
None => group,
})
.when(nudged, |body| {
body.child(
div()
.debug_selector(|| "quiz-pick-first".into())
.text_size(theme.text_size(TextSize::Sm))
.text_color(colors.danger)
.child("Pick an answer first."),
)
})
.into_any_element()
}
(false, true) => {
let rows = question.options.iter().enumerate().map(|(ix, label)| {
let (icon, tone) = match (ix == question.right, Some(ix) == pick) {
(true, _) => (Some(IconName::CircleCheck), colors.success),
(false, true) => (Some(IconName::CircleX), colors.danger),
(false, false) => (None, colors.fg_muted),
};
div()
.debug_selector(|| format!("quiz-row-{label}-{}", ix == question.right))
.flex()
.items_center()
.gap_2()
.text_color(tone)
.child(
div()
.flex_none()
.size(theme.icon_size(IconSize::Sm))
.children(
icon.map(|icon| Icon::new(icon).size(IconSize::Sm).color(tone)),
),
)
.child(div().flex_1().min_w_0().child(label.clone()))
});
div()
.flex()
.flex_col()
.gap_2()
.children(rows)
.into_any_element()
}
};
let on_score = self.on_score;
let (advanced, focus) = (sitting.clone(), action.clone());
let right_answer = question.right;
let finishing = !done && checked && at + 1 == count;
div()
.flex()
.flex_col()
.gap_3()
.child(
div()
.text_size(theme.text_size(TextSize::Sm))
.text_color(colors.fg_muted)
.child(match done {
true => "Score".to_string(),
false => format!("Question {} of {count}", at + 1),
}),
)
.when(!done, |quiz| {
quiz.child(
div().flex().child(
div()
.flex_1()
.min_w_0()
.text_color(colors.fg)
.font_weight(FontWeight::SEMIBOLD)
.child(question.prompt.clone()),
),
)
})
.child(body)
.child(
div().debug_selector(|| "quiz-action".into()).flex().child(
Button::new((id.clone(), "action"), label)
.variant(ButtonVariant::Primary)
.focus_handle(&action)
.on_click(move |_, window, cx| {
let score = advanced.update(cx, |sitting, cx| {
sitting.advance(right_answer, count);
cx.notify();
sitting.right
});
log::info!("quiz: {label}");
if let (true, Some(on_score)) = (finishing, &on_score) {
on_score(score, count, window, cx);
}
window.focus(&focus);
}),
),
)
}
}
#[cfg(test)]
mod tests {
use super::Sitting;
#[test]
fn one_button_checks_goes_on_scores_and_starts_again() {
let mut sitting = Sitting::default();
assert_eq!(sitting.next(2), "Check");
sitting.advance(1, 2);
assert!(sitting.nudged, "Check with no pick asks for one");
sitting.pick = Some(1);
sitting.advance(1, 2);
assert_eq!(
(sitting.checked, sitting.right, sitting.next(2)),
(true, 1, "Next")
);
sitting.advance(1, 2);
assert_eq!(
(sitting.at, sitting.pick, sitting.checked),
(1, None, false)
);
sitting.pick = Some(0);
sitting.advance(2, 2);
assert_eq!(
(sitting.right, sitting.next(2)),
(1, "See score"),
"a wrong pick scores nothing"
);
sitting.advance(2, 2);
assert_eq!((sitting.done, sitting.next(2)), (true, "Try again"));
sitting.advance(2, 2);
assert_eq!((sitting.at, sitting.right, sitting.done), (0, 0, false));
}
}
+262
View File
@@ -0,0 +1,262 @@
use std::rc::Rc;
use gpui::{
App, AppContext as _, ElementId, Entity, InteractiveElement, IntoElement, ParentElement,
RenderOnce, SharedString, Styled, Window, div,
};
use crate::{
buttons::{Button, ButtonVariant},
forms::{CheckboxGroup, Choice, Input, RadioGroup, TextInput},
primitives::{FocusRing, Icon, IconName, tab_stop},
theme::{ActiveTheme, IconSize, Radius, TextSize},
};
type OnSubmit = Rc<dyn Fn(&[Answer], &mut Window, &mut App)>;
/// What a survey asks, and how it is answered.
#[derive(Clone)]
pub enum Question {
/// One of the options.
Choice(SharedString, Vec<SharedString>),
/// Any of the options, one at least.
Several(SharedString, Vec<SharedString>),
/// Words.
Text(SharedString),
}
impl Question {
fn title(&self) -> &SharedString {
match self {
Question::Choice(title, _) | Question::Several(title, _) | Question::Text(title) => {
title
}
}
}
}
/// An answer, by the options' places or in words.
#[derive(Clone, Debug, PartialEq)]
pub enum Answer {
Choice(usize),
Several(Vec<usize>),
Text(SharedString),
}
/// What the survey holds: the answers picked, a field for each question in words, and whether it was sent.
struct Sheet {
picked: Vec<Option<Answer>>,
fields: Vec<Option<Entity<TextInput>>>,
sent: bool,
}
/// Questions on one page, each answered by one option, several, or words; Submit rests until every one is answered, then hands the answers to the owner, and the survey thanks the viewer in Submit's place and focus.
#[derive(IntoElement)]
pub struct Survey {
id: ElementId,
questions: Vec<Question>,
on_submit: Option<OnSubmit>,
}
impl Survey {
pub fn new(id: impl Into<ElementId>, questions: impl IntoIterator<Item = Question>) -> Self {
Self {
id: id.into(),
questions: questions.into_iter().collect(),
on_submit: None,
}
}
/// Runs with every answer, in the order of the questions.
pub fn on_submit(
mut self,
handler: impl Fn(&[Answer], &mut Window, &mut App) + 'static,
) -> Self {
self.on_submit = Some(Rc::new(handler));
self
}
}
/// Each question's answer as it stands, none where it waits for one.
fn answers(questions: &[Question], sheet: &Sheet, cx: &App) -> Vec<Option<Answer>> {
questions
.iter()
.enumerate()
.map(|(ix, question)| match question {
Question::Text(_) => {
let field = sheet.fields[ix].as_ref().expect("a field for words");
let words = field.read(cx).text().trim().to_string();
(!words.is_empty()).then(|| Answer::Text(words.into()))
}
_ => sheet.picked[ix].clone(),
})
.collect()
}
/// Options as choices keyed by their places.
fn choices(options: &[SharedString]) -> impl Iterator<Item = Choice> + '_ {
options
.iter()
.enumerate()
.map(|(ix, label)| Choice::new(ix.to_string(), label.clone()))
}
fn place(value: &SharedString) -> usize {
value.parse().expect("an option's place")
}
impl RenderOnce for Survey {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let id = self.id;
assert!(!self.questions.is_empty(), "survey {id:?} has no questions");
let on_submit = self
.on_submit
.unwrap_or_else(|| panic!("survey {id:?} has no on_submit"));
let questions = Rc::new(self.questions);
let sheet = window.use_keyed_state((id.clone(), "sheet"), cx, {
let questions = questions.clone();
move |window, cx| Sheet {
picked: vec![None; questions.len()],
fields: questions
.iter()
.map(|question| {
matches!(question, Question::Text(_))
.then(|| cx.new(|cx| TextInput::new(window, cx).multi_line(2, 5)))
})
.collect(),
sent: false,
}
});
assert_eq!(
sheet.read(cx).picked.len(),
questions.len(),
"survey {id:?} changed its questions"
);
let sent = sheet.read(cx).sent;
let action = tab_stop((id.clone(), "submit").into(), !sent, window, cx);
let theme = cx.theme();
let colors = &theme.colors;
if sent {
return div()
.id((id, "sent"))
.debug_selector(|| "survey-sent".into())
.track_focus(&action)
.flex()
.items_center()
.gap_2()
.p_4()
.rounded(theme.radius(Radius::Md))
.border_1()
.border_color(colors.border)
.bg(colors.surface)
.focus_ring(cx)
.child(
Icon::new(IconName::CircleCheck)
.size(IconSize::Sm)
.color(colors.success),
)
.child(
div()
.flex_1()
.min_w_0()
.text_color(colors.fg)
.child("Thanks. Your answers were sent."),
)
.into_any_element();
}
let now = answers(&questions, sheet.read(cx), cx);
let complete = now.iter().all(Option::is_some);
let items = questions.iter().enumerate().map(|(ix, question)| {
let key = (id.clone(), format!("question-{ix}"));
let set = sheet.clone();
let input = match question {
Question::Choice(_, options) => {
let group =
RadioGroup::new(key, choices(options)).on_change(move |value, _, cx| {
set.update(cx, |sheet, cx| {
sheet.picked[ix] = Some(Answer::Choice(place(value)));
cx.notify();
})
});
match &now[ix] {
Some(Answer::Choice(option)) => group.selected(option.to_string()),
_ => group,
}
.into_any_element()
}
Question::Several(_, options) => {
let ticked = match &now[ix] {
Some(Answer::Several(ticked)) => {
ticked.iter().map(usize::to_string).collect()
}
_ => Vec::new(),
};
CheckboxGroup::new(key, choices(options))
.selected(ticked)
.on_change(move |values, _, cx| {
let ticked: Vec<usize> = values.iter().map(place).collect();
set.update(cx, |sheet, cx| {
sheet.picked[ix] =
(!ticked.is_empty()).then_some(Answer::Several(ticked));
cx.notify();
})
})
.into_any_element()
}
Question::Text(_) => {
let field = sheet.read(cx).fields[ix]
.clone()
.expect("a field for words");
Input::new(&field).into_any_element()
}
};
div()
.flex()
.flex_col()
.gap_2()
.child(
div().flex().child(
div()
.flex_1()
.min_w_0()
.text_color(colors.fg)
.font_weight(gpui::FontWeight::SEMIBOLD)
.child(question.title().clone()),
),
)
.child(input)
});
let (submitted, focus, asked) = (sheet.clone(), action.clone(), questions.clone());
div()
.flex()
.flex_col()
.gap_5()
.text_size(theme.text_size(TextSize::Sm))
.children(items)
.child(
div()
.debug_selector(|| "survey-action".into())
.flex()
.child(
Button::new((id, "submit-button"), "Submit")
.variant(ButtonVariant::Primary)
.focus_handle(&action)
.disabled(!complete)
.on_click(move |_, window, cx| {
let given: Vec<Answer> = answers(&asked, submitted.read(cx), cx)
.into_iter()
.map(|answer| answer.expect("Submit waits for every answer"))
.collect();
log::info!("survey: sent {} answers", given.len());
on_submit(&given, window, cx);
submitted.update(cx, |sheet, cx| {
sheet.sent = true;
cx.notify();
});
window.focus(&focus);
}),
),
)
.into_any_element()
}
}
+239
View File
@@ -0,0 +1,239 @@
use std::cell::{Cell, RefCell};
use gpui::{AnyElement, IntoElement, Modifiers, TestAppContext, VisualTestContext, point, px};
use super::{at_root, press, settle, shown, stage};
use crate::misc::{Answer, Flashcards, Poll, Question, Quiz, QuizQuestion, Survey};
thread_local! {
static VOTED: Cell<Option<usize>> = const { Cell::new(None) };
static SAID: RefCell<Vec<String>> = const { RefCell::new(Vec::new()) };
}
fn said() -> Vec<String> {
SAID.with(|said| said.borrow().clone())
}
fn say(text: String) {
SAID.with(|said| said.borrow_mut().push(text));
}
/// Presses just inside the corner of what `selector` marks, where its button starts, as a pointer does.
fn click(selector: &'static str, cx: &mut VisualTestContext) {
let corner = cx.debug_bounds(selector).expect("a marked element").origin;
cx.simulate_click(corner + point(px(4.0), px(4.0)), Modifiers::none());
settle(cx);
}
fn show(cx: &mut VisualTestContext) {
cx.update(|window, _| window.refresh());
settle(cx);
}
fn poll() -> AnyElement {
let voted = VOTED.get();
let poll = Poll::new("poll", "Lunch?", ["Soup", "Tea", "Toast"])
.votes([1, u64::from(voted == Some(1)) + 2, 1])
.on_vote(|vote, _, _| say(format!("{vote:?}")));
match voted {
Some(vote) => poll.voted(vote),
None => poll,
}
.into_any_element()
}
/// Arrows pick, Vote hands it over; the results show each share, and Change vote takes it back on the same stop.
#[gpui::test]
fn a_poll_votes_shows_results_and_takes_the_vote_back(cx: &mut TestAppContext) {
let (_, cx) = stage(poll, cx);
press("tab", cx);
press("down", cx);
press("tab", cx);
press("enter", cx);
assert_eq!(said(), ["Some(1)"]);
VOTED.set(Some(1));
show(cx);
assert!(shown("poll-Tea-60%", cx) && shown("poll-Soup-20%", cx));
press("enter", cx);
assert_eq!(said(), ["Some(1)", "None"], "Change vote held Vote's focus");
VOTED.set(None);
show(cx);
press("enter", cx);
assert_eq!(
said().last().map(String::as_str),
Some("Some(1)"),
"the pick stood"
);
assert!(!at_root(cx));
}
/// With no pick, Vote is no stop: Shift-Tab from the radios comes round to them.
#[gpui::test]
fn a_poll_rests_until_picked(cx: &mut TestAppContext) {
let (_, cx) = stage(poll, cx);
press("tab", cx);
press("shift-tab", cx);
press("enter", cx);
assert!(said().is_empty());
}
/// A vote the owner holds seeds the pick, and a pointer's Vote hands focus to the button the results keep.
#[gpui::test]
fn a_poll_starts_from_the_owners_vote(cx: &mut TestAppContext) {
VOTED.set(Some(2));
let (_, cx) = stage(poll, cx);
press("tab", cx);
press("enter", cx);
VOTED.set(None);
show(cx);
press("shift-tab", cx);
press("down", cx);
click("poll-action", cx);
assert_eq!(said(), ["None", "Some(0)"], "the pick started on Toast");
VOTED.set(Some(0));
show(cx);
assert!(
!at_root(cx),
"the radios left, and focus went to Change vote"
);
}
fn survey() -> AnyElement {
Survey::new(
"survey",
[
Question::Choice("How often?".into(), vec!["Daily".into(), "Weekly".into()]),
Question::Several("Which parts?".into(), vec!["Forms".into(), "Charts".into()]),
Question::Text("Anything else?".into()),
],
)
.on_submit(|answers, _, _| say(format!("{answers:?}")))
.into_any_element()
}
/// Submit rests until each question is answered, hands the answers over, and the thanks take its focus.
#[gpui::test]
fn a_survey_sends_every_answer(cx: &mut TestAppContext) {
let (_, cx) = stage(survey, cx);
press("tab", cx);
press("down", cx);
press("tab", cx);
press("space", cx);
press("tab", cx);
press("tab", cx);
cx.simulate_input("More charts");
press("tab", cx);
press("enter", cx);
let sent = vec![
Answer::Choice(1),
Answer::Several(vec![0]),
Answer::Text("More charts".into()),
];
assert_eq!(said(), [format!("{sent:?}")]);
assert!(shown("survey-sent", cx) && !at_root(cx));
}
/// Submit rests while an answer is missing; a pointer's Submit hands focus to the thanks.
#[gpui::test]
fn a_survey_waits_for_every_answer(cx: &mut TestAppContext) {
let (_, cx) = stage(survey, cx);
press("tab", cx);
press("down", cx);
press("tab", cx);
press("space", cx);
for _ in 0..3 {
press("tab", cx);
}
press("enter", cx);
assert!(said().is_empty(), "no words yet, so Submit is no stop");
press("shift-tab", cx);
cx.simulate_input("Fine");
click("survey-action", cx);
assert_eq!(said().len(), 1);
assert!(!at_root(cx), "the field left, and focus went to the thanks");
}
fn quiz() -> AnyElement {
Quiz::new(
"quiz",
[
QuizQuestion::new("2 + 2?", ["3", "4"], 1),
QuizQuestion::new("Blue and yellow?", ["Green", "Red"], 0),
],
)
.on_score(|right, count, _, _| say(format!("{right} of {count}")))
.into_any_element()
}
/// One button checks, goes on, scores and starts again, and asks for a pick when none is made.
#[gpui::test]
fn a_quiz_checks_each_answer_and_scores(cx: &mut TestAppContext) {
let (_, cx) = stage(quiz, cx);
press("tab", cx);
press("tab", cx);
press("enter", cx);
assert!(shown("quiz-pick-first", cx));
press("shift-tab", cx);
press("down", cx);
press("tab", cx);
press("enter", cx);
assert!(shown("quiz-row-4-true", cx), "the right answer shows");
press("enter", cx);
press("shift-tab", cx);
press("down", cx);
press("tab", cx);
press("enter", cx);
assert!(shown("quiz-row-Green-true", cx) && shown("quiz-row-Red-false", cx));
press("enter", cx);
assert!(shown("quiz-score-1-2", cx));
assert_eq!(said(), ["1 of 2"]);
press("enter", cx);
assert!(!at_root(cx), "Try again kept the button's focus");
}
/// A pointer's Check hands focus from the radios, which leave, to the one button.
#[gpui::test]
fn a_pointers_check_keeps_focus_in_the_quiz(cx: &mut TestAppContext) {
let (_, cx) = stage(quiz, cx);
press("tab", cx);
press("down", cx);
click("quiz-action", cx);
assert!(shown("quiz-row-4-true", cx) && !at_root(cx));
}
fn cards() -> AnyElement {
Flashcards::new("cards", [("A", "a"), ("B", "b"), ("C", "c")]).into_any_element()
}
/// A press turns the card; Next and Previous go round the deck, and each card they reach shows its front.
#[gpui::test]
fn flashcards_turn_and_go_round(cx: &mut TestAppContext) {
let (_, cx) = stage(cards, cx);
assert!(shown("flashcard-front-A", cx));
for _ in 0..3 {
press("tab", cx);
}
press("enter", cx);
press("enter", cx);
assert!(shown("flashcard-front-C", cx));
press("enter", cx);
press("shift-tab", cx);
press("shift-tab", cx);
press("enter", cx);
assert!(
shown("flashcard-back-a", cx) && !shown("flashcard-back-c", cx),
"Next went round from the last card to the first, and the press turned it"
);
press("tab", cx);
press("enter", cx);
assert!(
!shown("flashcard-back-c", cx),
"the card Previous reached shows its front"
);
press("shift-tab", cx);
press("enter", cx);
assert!(
shown("flashcard-back-c", cx),
"Previous went round from the first card to the last"
);
}
+1
View File
@@ -13,6 +13,7 @@ use crate::{
theme::Theme,
};
mod asking;
mod codes;
mod numbers;
+3
View File
@@ -9,6 +9,8 @@ pub struct MiscSizes {
pub face: Rems,
/// A world clock row's face.
pub small_face: Rems,
/// A flashcard's least height.
pub card: Rems,
}
impl Theme {
@@ -16,6 +18,7 @@ impl Theme {
MiscSizes {
face: px_to_rems(112.0),
small_face: px_to_rems(32.0),
card: px_to_rems(176.0),
}
}
}