diff --git a/examples/gallery/pages/misc/asking.rs b/examples/gallery/pages/misc/asking.rs new file mode 100644 index 0000000..ab4c7f4 --- /dev/null +++ b/examples/gallery/pages/misc/asking.rs @@ -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, +} + +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, + )), + ), + ) +} diff --git a/examples/gallery/pages/misc/mod.rs b/examples/gallery/pages/misc/mod.rs index f410dbd..88691f3 100644 --- a/examples/gallery/pages/misc/mod.rs +++ b/examples/gallery/pages/misc/mod.rs @@ -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() } diff --git a/src/misc/flashcards.rs b/src/misc/flashcards.rs new file mode 100644 index 0000000..732ca3c --- /dev/null +++ b/src/misc/flashcards.rs @@ -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, + cards: impl IntoIterator, impl Into)>, + ) -> 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)), + ), + ) + } +} diff --git a/src/misc/mod.rs b/src/misc/mod.rs index 9fe22e4..cb78514 100644 --- a/src/misc/mod.rs +++ b/src/misc/mod.rs @@ -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; diff --git a/src/misc/poll.rs b/src/misc/poll.rs new file mode 100644 index 0000000..9d7dfec --- /dev/null +++ b/src/misc/poll.rs @@ -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, &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, + votes: Vec, + voted: Option, + on_vote: Option, +} + +impl Poll { + pub fn new( + id: impl Into, + question: impl Into, + options: impl IntoIterator>, + ) -> Self { + let options: Vec = 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) -> 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, &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::); + 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), + ) + } +} diff --git a/src/misc/quiz.rs b/src/misc/quiz.rs new file mode 100644 index 0000000..982eb21 --- /dev/null +++ b/src/misc/quiz.rs @@ -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; + +/// A question, its options, and the right one. +#[derive(Clone)] +pub struct QuizQuestion { + prompt: SharedString, + options: Vec, + right: usize, +} + +impl QuizQuestion { + pub fn new( + prompt: impl Into, + options: impl IntoIterator>, + right: usize, + ) -> Self { + let options: Vec = 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, + 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, + on_score: Option, +} + +impl Quiz { + pub fn new( + id: impl Into, + questions: impl IntoIterator, + ) -> 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)); + } +} diff --git a/src/misc/survey.rs b/src/misc/survey.rs new file mode 100644 index 0000000..c3b5f3b --- /dev/null +++ b/src/misc/survey.rs @@ -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; + +/// What a survey asks, and how it is answered. +#[derive(Clone)] +pub enum Question { + /// One of the options. + Choice(SharedString, Vec), + /// Any of the options, one at least. + Several(SharedString, Vec), + /// 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), + 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>, + fields: Vec>>, + 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, + on_submit: Option, +} + +impl Survey { + pub fn new(id: impl Into, questions: impl IntoIterator) -> 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> { + 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 + '_ { + 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 = 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 = 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() + } +} diff --git a/src/misc/tests/asking.rs b/src/misc/tests/asking.rs new file mode 100644 index 0000000..e21edd2 --- /dev/null +++ b/src/misc/tests/asking.rs @@ -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> = const { Cell::new(None) }; + static SAID: RefCell> = const { RefCell::new(Vec::new()) }; +} + +fn said() -> Vec { + 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" + ); +} diff --git a/src/misc/tests/mod.rs b/src/misc/tests/mod.rs index b1d6256..2259b21 100644 --- a/src/misc/tests/mod.rs +++ b/src/misc/tests/mod.rs @@ -13,6 +13,7 @@ use crate::{ theme::Theme, }; +mod asking; mod codes; mod numbers; diff --git a/src/theme/misc.rs b/src/theme/misc.rs index a285122..eb26c50 100644 --- a/src/theme/misc.rs +++ b/src/theme/misc.rs @@ -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), } } } diff --git a/tasks/ch31-43.md b/tasks/ch31-43.md index 6138add..b15a3a4 100644 --- a/tasks/ch31-43.md +++ b/tasks/ch31-43.md @@ -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