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:
1 parent
19a5295ed8
commit
6e02a0cce3
11 files changed
+1345
-3
No files matched your search
@@ -0,0 +1,120 @@
|
||||
use ely_gpui_component::misc::{Flashcards, Poll, Question, Quiz, QuizQuestion, Survey};
|
||||
use gpui::{App, IntoElement, ParentElement, Styled, Window, div, px};
|
||||
|
||||
use crate::probe::probe;
|
||||
use crate::ui::{section, specimen, specimens};
|
||||
|
||||
/// The gallery's poll: each option's count, and this viewer's vote.
|
||||
struct Tally {
|
||||
votes: [u64; 3],
|
||||
voted: Option<usize>,
|
||||
}
|
||||
|
||||
pub fn render(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
|
||||
let tally = window.use_keyed_state("poll-tally", cx, |_, _| Tally {
|
||||
votes: [14, 9, 5],
|
||||
voted: None,
|
||||
});
|
||||
let (votes, voted) = (tally.read(cx).votes, tally.read(cx).voted);
|
||||
let poll = Poll::new(
|
||||
"poll",
|
||||
"Which chart should come next?",
|
||||
["Sankey", "Sunburst", "Candlestick"],
|
||||
)
|
||||
.votes(votes)
|
||||
.on_vote(move |vote, _, cx| {
|
||||
tally.update(cx, |tally, cx| {
|
||||
match (vote, tally.voted) {
|
||||
(Some(ix), _) => tally.votes[ix] += 1,
|
||||
(None, Some(ix)) => tally.votes[ix] -= 1,
|
||||
(None, None) => panic!("gallery poll: no vote to take back"),
|
||||
}
|
||||
tally.voted = vote;
|
||||
cx.notify();
|
||||
})
|
||||
});
|
||||
let poll = match voted {
|
||||
Some(ix) => poll.voted(ix),
|
||||
None => poll,
|
||||
};
|
||||
div()
|
||||
.child(
|
||||
section(
|
||||
"Poll / Survey",
|
||||
"A poll turns to results once the owner holds the vote, with Change vote on the same stop. A survey asks on one page and sends when every question has its answer.",
|
||||
cx,
|
||||
)
|
||||
.child(
|
||||
specimens()
|
||||
.child(specimen("poll", probe("poll", div().w(px(280.0)).child(poll)), cx))
|
||||
.child(specimen(
|
||||
"survey",
|
||||
div().w(px(320.0)).child(
|
||||
Survey::new(
|
||||
"survey",
|
||||
[
|
||||
Question::Choice(
|
||||
"How often do you ship?".into(),
|
||||
vec!["Daily".into(), "Weekly".into(), "Monthly".into()],
|
||||
),
|
||||
Question::Several(
|
||||
"Which parts do you use?".into(),
|
||||
vec!["Forms".into(), "Charts".into(), "Tables".into()],
|
||||
),
|
||||
Question::Text("What should we build next?".into()),
|
||||
],
|
||||
)
|
||||
.on_submit(|answers, _, _| log::info!("gallery: survey {answers:?}")),
|
||||
),
|
||||
cx,
|
||||
)),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
section(
|
||||
"Quiz / Flashcard",
|
||||
"A quiz checks each answer and shows the right one, then the score. Flashcards turn on a press and go round the deck.",
|
||||
cx,
|
||||
)
|
||||
.child(
|
||||
specimens()
|
||||
.child(specimen(
|
||||
"quiz",
|
||||
probe(
|
||||
"quiz",
|
||||
div().w(px(280.0)).child(Quiz::new(
|
||||
"quiz",
|
||||
[
|
||||
QuizQuestion::new(
|
||||
"Which planet is the largest?",
|
||||
["Mars", "Jupiter", "Venus"],
|
||||
1,
|
||||
),
|
||||
QuizQuestion::new(
|
||||
"How many sides has a hexagon?",
|
||||
["Five", "Six", "Eight"],
|
||||
1,
|
||||
),
|
||||
],
|
||||
)),
|
||||
),
|
||||
cx,
|
||||
))
|
||||
.child(specimen(
|
||||
"flashcards",
|
||||
probe(
|
||||
"flashcards",
|
||||
div().w(px(280.0)).child(Flashcards::new(
|
||||
"flashcards",
|
||||
[
|
||||
("Hola", "Hello"),
|
||||
("Gracias", "Thank you"),
|
||||
("Buenos días", "Good morning"),
|
||||
],
|
||||
)),
|
||||
),
|
||||
cx,
|
||||
)),
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -7,13 +7,14 @@ use crate::probe::probe;
|
||||
use crate::script::Step;
|
||||
use crate::ui::{section, specimen, specimens};
|
||||
|
||||
mod asking;
|
||||
mod codes;
|
||||
|
||||
pub const PAGE: Page = Page {
|
||||
number: 42,
|
||||
slug: "misc",
|
||||
title: "Misc",
|
||||
summary: "Small tools that stand alone: clocks, a stopwatch, a calculator, a unit converter, a QR code scanner, a captcha.",
|
||||
summary: "Small tools that stand alone: clocks, a stopwatch, a calculator, a unit converter, a QR code scanner, a captcha, a poll, a survey, a quiz, flashcards.",
|
||||
render,
|
||||
script: &[
|
||||
Step::DownAt("stopwatch-start", 30.0, 62.0),
|
||||
@@ -42,6 +43,19 @@ pub const PAGE: Page = Page {
|
||||
Step::Type("w7xk"),
|
||||
Step::Key("enter"),
|
||||
Step::Shot("captcha-passed"),
|
||||
Step::DownAt("poll", 50.0, 70.0),
|
||||
Step::UpAt("poll", 50.0, 70.0),
|
||||
Step::DownAt("poll", 27.0, 135.0),
|
||||
Step::UpAt("poll", 27.0, 135.0),
|
||||
Step::Shot("poll-voted"),
|
||||
Step::DownAt("quiz", 45.0, 103.0),
|
||||
Step::UpAt("quiz", 45.0, 103.0),
|
||||
Step::DownAt("quiz", 33.0, 168.0),
|
||||
Step::UpAt("quiz", 33.0, 168.0),
|
||||
Step::Shot("quiz-checked"),
|
||||
Step::DownAt("flashcards", 140.0, 80.0),
|
||||
Step::UpAt("flashcards", 140.0, 80.0),
|
||||
Step::Shot("flashcard-turned"),
|
||||
],
|
||||
};
|
||||
|
||||
@@ -56,6 +70,7 @@ fn render(window: &mut Window, cx: &mut App) -> AnyElement {
|
||||
.child(calculator(cx))
|
||||
.child(converter(cx))
|
||||
.child(codes::render(window, cx))
|
||||
.child(asking::render(window, cx))
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
|
||||
@@ -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)),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
@@ -13,6 +13,7 @@ use crate::{
|
||||
theme::Theme,
|
||||
};
|
||||
|
||||
mod asking;
|
||||
mod codes;
|
||||
mod numbers;
|
||||
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -328,8 +328,8 @@
|
||||
|
||||
## T42d Misc · Asking and learning
|
||||
|
||||
- [ ] Poll / Survey
|
||||
- [ ] Quiz / Flashcard
|
||||
- [x] Poll / Survey → `misc::Poll`: one vote, then each option's share with the vote marked, and Change vote on Vote's stop; `misc::Survey`: questions on one page answered by an option, several, or words, Submit once each is answered, and thanks in its place and focus
|
||||
- [x] Quiz / Flashcard → `misc::Quiz`: a question at a time, Check shows the right answer, then Next and the score, one button on one stop; `misc::Flashcards`: a press turns the card, Previous and Next go round the deck
|
||||
|
||||
## T42e Misc · Terms and notices
|
||||
|
||||
|
||||
Reference in new issue
Block a user