From 46aa381eee65acac5ae8b14310f79fa1fd70fd7d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Mon, 28 Sep 2026 03:11:52 -0400 Subject: [PATCH] =?UTF-8?q?Misc=20=C2=B7=20Asking=20and=20learning:=20Subm?= =?UTF-8?q?it=20never=20rests;=20radio=20labels=20wrap?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Survey's Submit asks for any missing answer instead of resting, so no untick takes its focus handle out of the tree. Flashcards turn and go round, and a quiz pick clears its ask, through methods the tests reach; the poll's rows show which one holds the vote. forms::Radio's label wraps in its own block, as Checkbox's does, so a long one stays in a narrow box. --- src/forms/radio.rs | 8 +++++++- src/forms/tests/choices.rs | 21 --------------------- src/forms/tests/other.rs | 33 ++++++++++++++++++++++++++++++--- src/misc/flashcards.rs | 35 ++++++++++++++++++++++++++++++++--- src/misc/poll.rs | 21 ++++++++++----------- src/misc/quiz.rs | 13 +++++++++---- src/misc/survey.rs | 34 ++++++++++++++++++++++++++-------- src/misc/tests/asking.rs | 25 +++++++++++++++++++++---- tasks/ch31-43.md | 2 +- 9 files changed, 136 insertions(+), 56 deletions(-) diff --git a/src/forms/radio.rs b/src/forms/radio.rs index 9df2844..a3c2f43 100644 --- a/src/forms/radio.rs +++ b/src/forms/radio.rs @@ -143,7 +143,13 @@ impl RenderOnce for Radio { }) }) .child(radio_mark(self.on, focused, self.disabled, changes, cx)) - .children(self.label) + .children(self.label.map(|label| { + div() + .debug_selector(|| "radio-label".into()) + .flex_1() + .min_w_0() + .child(label) + })) } } diff --git a/src/forms/tests/choices.rs b/src/forms/tests/choices.rs index 8fc0ac9..0e544ab 100644 --- a/src/forms/tests/choices.rs +++ b/src/forms/tests/choices.rs @@ -475,24 +475,3 @@ fn a_long_choice_keeps_its_list_inside_the_window(cx: &mut TestAppContext) { "{list:?}" ); } - -/// A checkbox whose label runs past its narrow box. -struct Worded; - -impl Render for Worded { - fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { - let label = "Projects/Atrium/Recordings/Walkthrough of the atrium at noon.mov"; - div() - .w(px(200.0)) - .child(Checkbox::new("worded", false).label(label)) - } -} - -#[gpui::test] -fn a_long_checkbox_label_wraps_inside_its_box(cx: &mut TestAppContext) { - setup(cx); - let (_, cx) = cx.add_window_view(|_, _| Worded); - cx.run_until_parked(); - let label = cx.debug_bounds("checkbox-label").expect("the label draws"); - assert!(label.right() <= px(200.0), "{label:?}"); -} diff --git a/src/forms/tests/other.rs b/src/forms/tests/other.rs index 6f5fe8e..87d970c 100644 --- a/src/forms/tests/other.rs +++ b/src/forms/tests/other.rs @@ -1,11 +1,11 @@ use gpui::{ - Context, IntoElement, KeyBinding, KeyUpEvent, Keystroke, Modifiers, MouseButton, Render, - SharedString, TestAppContext, VisualTestContext, Window, point, px, + Context, IntoElement, KeyBinding, KeyUpEvent, Keystroke, Modifiers, MouseButton, ParentElement, + Render, SharedString, Styled, TestAppContext, VisualTestContext, Window, div, point, px, }; use super::setup; use crate::{ - forms::{IconPicker, InlineEdit, SignaturePad, Stroke}, + forms::{Checkbox, Choice, IconPicker, InlineEdit, RadioGroup, SignaturePad, Stroke}, primitives::{FocusNext, IconName}, }; @@ -144,3 +144,30 @@ fn an_inline_edit_takes_tab_opens_on_enter_and_takes_focus_back(cx: &mut TestApp "focus came back after the first" ); } + +/// A checkbox and a radio whose labels run past their narrow box. +struct Worded; + +impl Render for Worded { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + let label = "Projects/Atrium/Recordings/Walkthrough of the atrium at noon.mov"; + div() + .w(px(200.0)) + .child(Checkbox::new("worded", false).label(label)) + .child(RadioGroup::new( + "worded-radios", + [Choice::new("one", label)], + )) + } +} + +#[gpui::test] +fn long_labels_wrap_inside_their_box(cx: &mut TestAppContext) { + setup(cx); + let (_, cx) = cx.add_window_view(|_, _| Worded); + cx.run_until_parked(); + for selector in ["checkbox-label", "radio-label"] { + let label = cx.debug_bounds(selector).expect("the label draws"); + assert!(label.right() <= px(200.0), "{selector}: {label:?}"); + } +} diff --git a/src/misc/flashcards.rs b/src/misc/flashcards.rs index 732ca3c..2dc29ca 100644 --- a/src/misc/flashcards.rs +++ b/src/misc/flashcards.rs @@ -18,6 +18,18 @@ struct Deck { back: bool, } +impl Deck { + fn turn(&mut self) { + self.back = !self.back; + } + + /// Goes `by` cards round a deck of `count`, front up. + fn go(&mut self, by: isize, count: usize) { + self.at = (self.at as isize + by).rem_euclid(count as isize) as usize; + self.back = false; + } +} + /// 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 { @@ -90,8 +102,7 @@ impl RenderOnce for Flashcards { 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; + deck.go(by, count); log::info!("flashcards: card {} of {count}", deck.at + 1); cx.notify(); }) @@ -117,7 +128,7 @@ impl RenderOnce for Flashcards { .cursor_pointer() .on_click(move |_, _, cx| { turned.update(cx, |deck, cx| { - deck.back = !deck.back; + deck.turn(); log::info!( "flashcards: turned to the {}", if deck.back { "back" } else { "front" } @@ -152,3 +163,21 @@ impl RenderOnce for Flashcards { ) } } + +#[cfg(test)] +mod tests { + use super::Deck; + + #[test] + fn a_deck_turns_and_goes_round_front_up() { + let mut deck = Deck::default(); + deck.turn(); + deck.turn(); + assert!(!deck.back, "a second press turns it back"); + deck.turn(); + deck.go(-1, 3); + assert_eq!((deck.at, deck.back), (2, false)); + deck.go(1, 3); + assert_eq!(deck.at, 0); + } +} diff --git a/src/misc/poll.rs b/src/misc/poll.rs index 9d7dfec..03e7aca 100644 --- a/src/misc/poll.rs +++ b/src/misc/poll.rs @@ -128,7 +128,10 @@ impl RenderOnce for Poll { }; let percent = format::percent(share as f64, 0, false); div() - .debug_selector(|| format!("poll-{label}-{percent}")) + .debug_selector(|| match ix == voted { + true => format!("poll-{label}-{percent}-voted"), + false => format!("poll-{label}-{percent}"), + }) .flex() .flex_col() .gap_1() @@ -189,16 +192,12 @@ impl RenderOnce for Poll { 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); - }) - } + Some(_) => 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); + }), }; div() .flex() diff --git a/src/misc/quiz.rs b/src/misc/quiz.rs index 982eb21..cf41c89 100644 --- a/src/misc/quiz.rs +++ b/src/misc/quiz.rs @@ -59,6 +59,11 @@ struct Sitting { } impl Sitting { + fn pick(&mut self, option: usize) { + self.pick = Some(option); + self.nudged = false; + } + /// What the one button does next, and says. fn next(&self, count: usize) -> &'static str { match (self.done, self.checked, self.at + 1 == count) { @@ -172,8 +177,7 @@ impl RenderOnce for Quiz { .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; + sitting.pick(ix); cx.notify(); }) }); @@ -289,7 +293,8 @@ mod tests { 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.pick(1); + assert!(!sitting.nudged, "a pick answers the ask"); sitting.advance(1, 2); assert_eq!( (sitting.checked, sitting.right, sitting.next(2)), @@ -300,7 +305,7 @@ mod tests { (sitting.at, sitting.pick, sitting.checked), (1, None, false) ); - sitting.pick = Some(0); + sitting.pick(0); sitting.advance(2, 2); assert_eq!( (sitting.right, sitting.next(2)), diff --git a/src/misc/survey.rs b/src/misc/survey.rs index c3b5f3b..98127f7 100644 --- a/src/misc/survey.rs +++ b/src/misc/survey.rs @@ -2,7 +2,7 @@ use std::rc::Rc; use gpui::{ App, AppContext as _, ElementId, Entity, InteractiveElement, IntoElement, ParentElement, - RenderOnce, SharedString, Styled, Window, div, + RenderOnce, SharedString, Styled, Window, div, prelude::*, }; use crate::{ @@ -43,14 +43,15 @@ pub enum Answer { Text(SharedString), } -/// What the survey holds: the answers picked, a field for each question in words, and whether it was sent. +/// What the survey holds: the answers picked, a field for each question in words, whether it was sent, and whether Submit came with an answer missing. struct Sheet { picked: Vec>, fields: Vec>>, sent: bool, + nudged: 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. +/// Questions on one page, each answered by one option, several, or words. Submit hands the answers to the owner once every one is answered, and asks for the rest before; then the survey thanks the viewer in Submit's place and focus. #[derive(IntoElement)] pub struct Survey { id: ElementId, @@ -125,6 +126,7 @@ impl RenderOnce for Survey { }) .collect(), sent: false, + nudged: false, } }); assert_eq!( @@ -226,6 +228,7 @@ impl RenderOnce for Survey { ) .child(input) }); + let nudged = sheet.read(cx).nudged && !complete; let (submitted, focus, asked) = (sheet.clone(), action.clone(), questions.clone()); div() .flex() @@ -233,6 +236,14 @@ impl RenderOnce for Survey { .gap_5() .text_size(theme.text_size(TextSize::Sm)) .children(items) + .when(nudged, |survey| { + survey.child( + div() + .debug_selector(|| "survey-answer-first".into()) + .text_color(colors.danger) + .child("Answer each question first."), + ) + }) .child( div() .debug_selector(|| "survey-action".into()) @@ -241,12 +252,19 @@ impl RenderOnce for Survey { 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(); + let given: Option> = + answers(&asked, submitted.read(cx), cx) + .into_iter() + .collect(); + let Some(given) = given else { + log::info!("survey: an answer is missing"); + submitted.update(cx, |sheet, cx| { + sheet.nudged = true; + cx.notify(); + }); + return; + }; log::info!("survey: sent {} answers", given.len()); on_submit(&given, window, cx); submitted.update(cx, |sheet, cx| { diff --git a/src/misc/tests/asking.rs b/src/misc/tests/asking.rs index e21edd2..fb6fa19 100644 --- a/src/misc/tests/asking.rs +++ b/src/misc/tests/asking.rs @@ -53,7 +53,7 @@ fn a_poll_votes_shows_results_and_takes_the_vote_back(cx: &mut TestAppContext) { assert_eq!(said(), ["Some(1)"]); VOTED.set(Some(1)); show(cx); - assert!(shown("poll-Tea-60%", cx) && shown("poll-Soup-20%", cx)); + assert!(shown("poll-Tea-60%-voted", cx) && shown("poll-Soup-20%", cx)); press("enter", cx); assert_eq!(said(), ["Some(1)", "None"], "Change vote held Vote's focus"); VOTED.set(None); @@ -133,7 +133,7 @@ fn a_survey_sends_every_answer(cx: &mut TestAppContext) { assert!(shown("survey-sent", cx) && !at_root(cx)); } -/// Submit rests while an answer is missing; a pointer's Submit hands focus to the thanks. +/// Submit asks for what is missing: words of only spaces, or a box ticked and unticked; 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); @@ -141,13 +141,30 @@ fn a_survey_waits_for_every_answer(cx: &mut TestAppContext) { press("down", cx); press("tab", cx); press("space", cx); + press("tab", cx); + press("tab", cx); + cx.simulate_input(" "); + press("tab", cx); + press("enter", cx); + assert!( + said().is_empty() && shown("survey-answer-first", cx), + "blank words are no answer" + ); + press("shift-tab", cx); + cx.simulate_input("Fine"); + press("shift-tab", cx); + press("shift-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"); + assert!(said().is_empty(), "no box ticked is no answer"); + assert!(!at_root(cx), "Submit kept its focus"); press("shift-tab", cx); - cx.simulate_input("Fine"); + press("shift-tab", cx); + press("shift-tab", cx); + press("space", cx); click("survey-action", cx); assert_eq!(said().len(), 1); assert!(!at_root(cx), "the field left, and focus went to the thanks"); diff --git a/tasks/ch31-43.md b/tasks/ch31-43.md index b15a3a4..0c3474e 100644 --- a/tasks/ch31-43.md +++ b/tasks/ch31-43.md @@ -328,7 +328,7 @@ ## T42d Misc · Asking and learning -- [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] 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 asks for what is missing, then sends, 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