From 2837c802b51fb756df5b826a03c9fb01bdf360d2 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:36:03 -0400 Subject: [PATCH] =?UTF-8?q?Misc=20=C2=B7=20Terms=20and=20notices:=20licens?= =?UTF-8?q?es,=20consent=20and=20a=20cookie=20banner?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit misc::LicenseViewer lists what a product ships with each license beside the chosen one's text. misc::ConsentDialog asks for a box before Accept and answers each way out once, accept or decline. misc::CookieBanner takes Accept all, Reject all, or a switch per kind through Choose, which turns into Save choices on its focus. ChangeLog Viewer points to data_display::Changelog. --- examples/gallery/pages/misc/mod.rs | 11 +- examples/gallery/pages/misc/notices.rs | 137 ++++++++++++++++++ src/misc/consent.rs | 124 ++++++++++++++++ src/misc/cookies.rs | 193 +++++++++++++++++++++++++ src/misc/licenses.rs | 142 ++++++++++++++++++ src/misc/mod.rs | 6 + src/misc/tests/mod.rs | 1 + src/misc/tests/notices.rs | 121 ++++++++++++++++ src/theme/misc.rs | 6 + tasks/ch31-43.md | 8 +- 10 files changed, 744 insertions(+), 5 deletions(-) create mode 100644 examples/gallery/pages/misc/notices.rs create mode 100644 src/misc/consent.rs create mode 100644 src/misc/cookies.rs create mode 100644 src/misc/licenses.rs create mode 100644 src/misc/tests/notices.rs diff --git a/examples/gallery/pages/misc/mod.rs b/examples/gallery/pages/misc/mod.rs index 88691f3..8eb6b4b 100644 --- a/examples/gallery/pages/misc/mod.rs +++ b/examples/gallery/pages/misc/mod.rs @@ -9,12 +9,13 @@ use crate::ui::{section, specimen, specimens}; mod asking; mod codes; +mod notices; 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, a poll, a survey, a quiz, flashcards.", + 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, licenses, terms and a cookie banner.", render, script: &[ Step::DownAt("stopwatch-start", 30.0, 62.0), @@ -56,6 +57,13 @@ pub const PAGE: Page = Page { Step::DownAt("flashcards", 140.0, 80.0), Step::UpAt("flashcards", 140.0, 80.0), Step::Shot("flashcard-turned"), + Step::DownAt("consent-open", 20.0, 16.0), + Step::UpAt("consent-open", 20.0, 16.0), + Step::Shot("consent"), + Step::Key("escape"), + Step::DownAt("cookies", 230.0, 84.0), + Step::UpAt("cookies", 230.0, 84.0), + Step::Shot("cookies-choosing"), ], }; @@ -71,6 +79,7 @@ fn render(window: &mut Window, cx: &mut App) -> AnyElement { .child(converter(cx)) .child(codes::render(window, cx)) .child(asking::render(window, cx)) + .child(notices::render(window, cx)) .into_any_element() } diff --git a/examples/gallery/pages/misc/notices.rs b/examples/gallery/pages/misc/notices.rs new file mode 100644 index 0000000..07ca54b --- /dev/null +++ b/examples/gallery/pages/misc/notices.rs @@ -0,0 +1,137 @@ +use ely_gpui_component::{ + buttons::Button, + misc::{ConsentDialog, CookieBanner, LicenseViewer, Package}, + theme::ActiveTheme, +}; +use gpui::{App, IntoElement, ParentElement, SharedString, Styled, Window, div, px}; + +use crate::probe::probe; +use crate::ui::section; + +const TERMS: &str = "These terms cover the gallery's sample app. You may use it to try the components, read their code, and share what you find. You may not use it to harm anyone. The app keeps no data about you; it forgets your choices when it closes."; + +/// What the product ships and under which license, read from the repository's own files. +fn packages() -> [Package; 4] { + [ + Package::new( + "Ely GPUI Component", + "0.1.0", + "MIT OR Apache-2.0", + include_str!("../../../../LICENSE-MIT"), + ), + Package::new( + "Lucide", + "1.48.0", + "ISC", + include_str!("../../../../assets/icons/LICENSE"), + ), + Package::new( + "Inter", + "4.1", + "OFL-1.1", + include_str!("../../../../assets/fonts/Inter-LICENSE.txt"), + ), + Package::new( + "JetBrains Mono", + "2.304", + "OFL-1.1", + include_str!("../../../../assets/fonts/JetBrainsMono-LICENSE.txt"), + ), + ] +} + +pub fn render(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { + let open = window.use_keyed_state("consent-open", cx, |_, _| false); + let said = window.use_keyed_state("notices-said", cx, |_, _| SharedString::default()); + let answer = |word: &'static str| { + let (open, said) = (open.clone(), said.clone()); + move |_: &mut Window, cx: &mut App| { + open.update(cx, |open, cx| { + *open = false; + cx.notify(); + }); + said.update(cx, |said, cx| { + *said = word.into(); + cx.notify(); + }); + } + }; + let dialog = open.read(cx).then(|| { + ConsentDialog::new( + "consent", + "Terms of use", + TERMS, + answer("Terms accepted."), + answer("Terms declined."), + ) + }); + let opener = open.clone(); + let chose = said.clone(); + div() + .child(section( + "ChangeLog Viewer", + "ChangeLog Viewer is data_display::Changelog, shown on the Data Display page.", + cx, + )) + .child( + section( + "LicenseViewer", + "The packages a product ships, each with its license, beside the chosen one's text. These are Ely's own.", + cx, + ) + .child(div().w(px(560.0)).child(LicenseViewer::new("licenses", packages()))), + ) + .child( + section( + "Terms / Consent Dialog / CookieBanner", + "Terms to agree to before going on, and cookies to choose when the app shows web content. Each hands its answer to the owner.", + cx, + ) + .child( + div() + .flex() + .flex_col() + .gap_4() + .child( + probe( + "consent-open", + div().child(Button::new("consent-button", "Review the terms").on_click( + move |_, _, cx| { + opener.update(cx, |open, cx| { + *open = true; + cx.notify(); + }) + }, + )), + ), + ) + .child(probe( + "cookies", + div().w(px(360.0)).child( + CookieBanner::new( + "cookies", + "The help pages set cookies. Necessary ones keep them working; the rest are up to you.", + ) + .kind("Analytics", "Counts which pages help, without names.") + .kind("Video", "Lets embedded tutorials play and remember where you stopped.") + .on_choose(move |kinds, _, cx| { + let words = match kinds.len() { + 0 => "Only necessary cookies.".to_string(), + _ => format!("Cookies on: {}.", kinds.join(", ")), + }; + chose.update(cx, |said, cx| { + *said = words.into(); + cx.notify(); + }) + }), + ), + )) + .child( + div() + .text_color(cx.theme().colors.fg_muted) + .child(said.read(cx).clone()), + ), + ), + ) + .children(dialog) +} diff --git a/src/misc/consent.rs b/src/misc/consent.rs new file mode 100644 index 0000000..ced9aed --- /dev/null +++ b/src/misc/consent.rs @@ -0,0 +1,124 @@ +use std::{cell::Cell, rc::Rc}; + +use gpui::{ + App, ElementId, IntoElement, ParentElement, RenderOnce, SharedString, Styled, Window, div, + prelude::*, +}; + +use crate::{ + buttons::{Button, ButtonVariant}, + forms::{Checkbox, Run}, + overlays::Dialog, + theme::{ActiveTheme, TextSize}, +}; + +/// Terms to read and agree to, over the page: a box says the viewer agrees, and Accept asks for the box until it is ticked. Each way out answers once: Accept accepts, and Decline, Escape or a press on the scrim declines. Render it while open. +#[derive(IntoElement)] +pub struct ConsentDialog { + id: ElementId, + title: SharedString, + terms: SharedString, + agreement: SharedString, + on_accept: Run, + on_decline: Run, +} + +impl ConsentDialog { + pub fn new( + id: impl Into, + title: impl Into, + terms: impl Into, + on_accept: impl Fn(&mut Window, &mut App) + 'static, + on_decline: impl Fn(&mut Window, &mut App) + 'static, + ) -> Self { + Self { + id: id.into(), + title: title.into(), + terms: terms.into(), + agreement: "I have read and agree to these terms.".into(), + on_accept: Rc::new(on_accept), + on_decline: Rc::new(on_decline), + } + } + + /// What the box says the viewer agrees to. + pub fn agreement(mut self, text: impl Into) -> Self { + self.agreement = text.into(); + self + } +} + +impl RenderOnce for ConsentDialog { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let id = self.id; + let state = window.use_keyed_state((id.clone(), "agreed"), cx, |_, _| (false, false)); + let (agreed, nudged) = *state.read(cx); + let theme = cx.theme(); + let colors = &theme.colors; + let (ticked, asked) = (state.clone(), state); + let (decline, accept) = (self.on_decline, self.on_accept); + let (declined, accepted) = (id.clone(), id.clone()); + let answered = Rc::new(Cell::new(false)); + let answer = answered.clone(); + Dialog::new(id.clone(), self.title, move |window, cx| { + if !answered.get() { + log::info!("consent dialog {declined:?}: declined"); + decline(window, cx) + } + }) + .child( + div().flex().child( + div() + .flex_1() + .min_w_0() + .text_size(theme.text_size(TextSize::Sm)) + .text_color(colors.fg) + .child(self.terms), + ), + ) + .child( + Checkbox::new((id.clone(), "agree"), agreed) + .label(self.agreement) + .on_change(move |on, _, cx| { + ticked.update(cx, |state, cx| { + *state = (on, false); + cx.notify(); + }) + }), + ) + .when(nudged, |dialog| { + dialog.child( + div() + .debug_selector(|| "consent-tick-first".into()) + .text_size(theme.text_size(TextSize::Sm)) + .text_color(colors.danger) + .child("Tick the box to agree first."), + ) + }) + .action({ + let id = id.clone(); + move |close| { + Button::new((id, "decline"), "Decline") + .variant(ButtonVariant::Ghost) + .on_click(move |_, window, cx| close(window, cx)) + } + }) + .action(move |close| { + Button::new((id, "accept"), "Accept") + .primary() + .on_click(move |_, window, cx| { + if !agreed { + asked.update(cx, |state, cx| { + state.1 = true; + cx.notify(); + }); + return; + } + log::info!("consent dialog {accepted:?}: accepted"); + answer.set(true); + close(window, cx); + accept(window, cx); + }) + }) + } +} diff --git a/src/misc/cookies.rs b/src/misc/cookies.rs new file mode 100644 index 0000000..8639852 --- /dev/null +++ b/src/misc/cookies.rs @@ -0,0 +1,193 @@ +use std::rc::Rc; + +use gpui::{ + App, ElementId, IntoElement, ParentElement, RenderOnce, SharedString, Styled, Window, div, +}; + +use crate::{ + buttons::{Button, ButtonVariant}, + forms::Switch, + primitives::tab_stop, + theme::{ActiveTheme, Radius, TextSize}, +}; + +type OnChoose = Rc; + +/// Which kinds the viewer turned on, and whether the switches show. +struct Choosing { + open: bool, + on: Vec, +} + +/// Cookies a site in the app would set: what they are for, then Accept all, Reject all, or Choose, which lays out a switch for each kind, with Save choices in Choose's place and focus. Necessary cookies stay on. +#[derive(IntoElement)] +pub struct CookieBanner { + id: ElementId, + message: SharedString, + kinds: Vec<(SharedString, SharedString)>, + on_choose: Option, +} + +impl CookieBanner { + pub fn new(id: impl Into, message: impl Into) -> Self { + Self { + id: id.into(), + message: message.into(), + kinds: Vec::new(), + on_choose: None, + } + } + + /// A kind the viewer may turn on: its name, and what it is for. + pub fn kind(mut self, name: impl Into, detail: impl Into) -> Self { + self.kinds.push((name.into(), detail.into())); + self + } + + /// Runs with the kinds turned on, by name; necessary cookies go unnamed. + pub fn on_choose( + mut self, + handler: impl Fn(&[SharedString], &mut Window, &mut App) + 'static, + ) -> Self { + self.on_choose = Some(Rc::new(handler)); + self + } +} + +impl RenderOnce for CookieBanner { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let id = self.id; + assert!( + !self.kinds.is_empty(), + "cookie banner {id:?} has no kinds to choose" + ); + let on_choose = self + .on_choose + .unwrap_or_else(|| panic!("cookie banner {id:?} has no on_choose")); + let count = self.kinds.len(); + let choosing = window.use_keyed_state((id.clone(), "choosing"), cx, move |_, _| Choosing { + open: false, + on: vec![false; count], + }); + assert_eq!( + choosing.read(cx).on.len(), + count, + "cookie banner {id:?} changed its kinds" + ); + let choose = tab_stop((id.clone(), "choose").into(), true, window, cx); + let (open, on) = (choosing.read(cx).open, choosing.read(cx).on.clone()); + let theme = cx.theme(); + let colors = &theme.colors; + let names: Rc<[SharedString]> = self.kinds.iter().map(|(name, _)| name.clone()).collect(); + let answer = move |picked: Vec, window: &mut Window, cx: &mut App| { + log::info!("cookie banner: {} kinds on", picked.len()); + on_choose(&picked, window, cx); + }; + let answer = Rc::new(answer); + let switches = open.then(|| { + let rows = self.kinds.iter().enumerate().map(|(ix, (name, detail))| { + let turned = choosing.clone(); + row( + Switch::new((id.clone(), format!("kind-{ix}")), on[ix]) + .label(name.clone()) + .on_change(move |next, _, cx| { + turned.update(cx, |choosing, cx| { + choosing.on[ix] = next; + cx.notify(); + }) + }), + detail.clone(), + cx, + ) + }); + div() + .flex() + .flex_col() + .gap_3() + .child(row( + Switch::new((id.clone(), "necessary"), true) + .label("Necessary") + .disabled(true), + "Keeps the site working; always on.".into(), + cx, + )) + .children(rows) + }); + let (all, none, saved) = (answer.clone(), answer.clone(), answer); + let (every, chosen, opened) = (names.clone(), names, choosing.clone()); + div() + .flex() + .flex_col() + .gap_3() + .p_4() + .rounded(theme.radius(Radius::Lg)) + .border_1() + .border_color(colors.border) + .bg(colors.surface) + .text_size(theme.text_size(TextSize::Sm)) + .child( + div().flex().child( + div() + .flex_1() + .min_w_0() + .text_color(colors.fg) + .child(self.message), + ), + ) + .children(switches) + .child( + div() + .flex() + .flex_wrap() + .gap_2() + .child( + Button::new((id.clone(), "accept"), "Accept all") + .primary() + .on_click(move |_, window, cx| all(every.to_vec(), window, cx)), + ) + .child( + Button::new((id.clone(), "reject"), "Reject all") + .on_click(move |_, window, cx| none(Vec::new(), window, cx)), + ) + .child(match open { + false => Button::new((id.clone(), "choose"), "Choose") + .variant(ButtonVariant::Ghost) + .focus_handle(&choose) + .on_click(move |_, _, cx| { + log::info!("cookie banner: choosing"); + opened.update(cx, |choosing, cx| { + choosing.open = true; + cx.notify(); + }) + }), + true => Button::new((id, "save"), "Save choices") + .variant(ButtonVariant::Ghost) + .focus_handle(&choose) + .on_click(move |_, window, cx| { + let picked = chosen + .iter() + .zip(&on) + .filter(|(_, on)| **on) + .map(|(name, _)| name.clone()) + .collect(); + saved(picked, window, cx) + }), + }), + ) + } +} + +/// A switch over what its kind is for. +fn row(switch: Switch, detail: SharedString, cx: &App) -> impl IntoElement { + let theme = cx.theme(); + div().flex().flex_col().gap_1().child(switch).child( + div().flex().child( + div() + .flex_1() + .min_w_0() + .text_size(theme.text_size(TextSize::Xs)) + .text_color(theme.colors.fg_muted) + .child(detail), + ), + ) +} diff --git a/src/misc/licenses.rs b/src/misc/licenses.rs new file mode 100644 index 0000000..e8f7be3 --- /dev/null +++ b/src/misc/licenses.rs @@ -0,0 +1,142 @@ +use gpui::{ + App, ElementId, InteractiveElement, IntoElement, ParentElement, RenderOnce, SharedString, + StatefulInteractiveElement, Styled, Window, div, +}; + +use crate::{ + layout::on_axis, + lists::{ListItem, SelectableList}, + theme::{ActiveTheme, Radius, TextSize}, + typography::literal, +}; + +/// A package a product ships: its name, version, license by SPDX name, and the license's text. +#[derive(Clone)] +pub struct Package { + name: SharedString, + version: SharedString, + license: SharedString, + text: SharedString, +} + +impl Package { + pub fn new( + name: impl Into, + version: impl Into, + license: impl Into, + text: impl Into, + ) -> Self { + Self { + name: name.into(), + version: version.into(), + license: license.into(), + text: text.into(), + } + } +} + +/// The packages a product ships, a row each with its version and license, beside the chosen one's text, which scrolls; narrow, the text drops below the list. The first package shows until one is chosen. +#[derive(IntoElement)] +pub struct LicenseViewer { + id: ElementId, + packages: Vec, +} + +impl LicenseViewer { + pub fn new(id: impl Into, packages: impl IntoIterator) -> Self { + Self { + id: id.into(), + packages: packages.into_iter().collect(), + } + } +} + +impl RenderOnce for LicenseViewer { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let id = self.id; + assert!( + !self.packages.is_empty(), + "license viewer {id:?} has no packages" + ); + let chosen = window.use_keyed_state((id.clone(), "chosen"), cx, |_, _| 0); + let at = *chosen.read(cx); + assert!( + at < self.packages.len(), + "license viewer {id:?} lost package {at} of {}", + self.packages.len() + ); + let theme = cx.theme(); + let colors = &theme.colors; + let list = self.packages.iter().enumerate().fold( + SelectableList::new((id.clone(), "list")) + .selected([at.to_string()]) + .on_change(move |keys, _, cx| { + let Some(key) = keys.first() else { + return; + }; + let ix = key.parse().expect("a package's place"); + log::info!("license viewer: package {ix}"); + chosen.update(cx, |chosen, cx| { + *chosen = ix; + cx.notify(); + }) + }), + |list, (ix, package)| { + list.row( + ix.to_string(), + ListItem::new((id.clone(), format!("row-{ix}")), package.name.clone()) + .description(format!("{} · {}", package.version, package.license)), + ) + }, + ); + let package = &self.packages[at]; + let detail = div() + .flex_1() + .min_w(theme.misc().licenses) + .flex() + .flex_col() + .gap_2() + .child( + div() + .debug_selector(|| format!("license-{}", package.name)) + .flex() + .child( + div() + .flex_1() + .min_w_0() + .text_color(colors.fg) + .font_weight(gpui::FontWeight::SEMIBOLD) + .child(format!("{} {}", package.name, package.version)), + ), + ) + .child( + div() + .text_size(theme.text_size(TextSize::Sm)) + .text_color(colors.fg_muted) + .child(package.license.clone()), + ) + .child(on_axis( + div() + .id((id.clone(), "text")) + .overflow_y_scroll() + .max_h(theme.misc().reading) + .p_3() + .rounded(theme.radius(Radius::Md)) + .bg(colors.sunken) + .child( + literal(div()) + .font_family(theme.mono_family.clone()) + .text_size(theme.text_size(TextSize::Xs)) + .text_color(colors.fg) + .child(package.text.clone()), + ), + )); + div() + .flex() + .flex_wrap() + .items_start() + .gap_4() + .child(div().flex_none().w(theme.misc().licenses).child(list)) + .child(detail) + } +} diff --git a/src/misc/mod.rs b/src/misc/mod.rs index cb78514..9da54f2 100644 --- a/src/misc/mod.rs +++ b/src/misc/mod.rs @@ -1,7 +1,10 @@ mod calculator; mod captcha; mod clock; +mod consent; +mod cookies; mod flashcards; +mod licenses; mod poll; mod quiz; mod scanner; @@ -14,7 +17,10 @@ mod units; pub use calculator::Calculator; pub use captcha::{Captcha, CaptchaState}; pub use clock::{Clock, WorldClock}; +pub use consent::ConsentDialog; +pub use cookies::CookieBanner; pub use flashcards::Flashcards; +pub use licenses::{LicenseViewer, Package}; pub use poll::Poll; pub use quiz::{Quiz, QuizQuestion}; pub use scanner::QrCodeScanner; diff --git a/src/misc/tests/mod.rs b/src/misc/tests/mod.rs index 2259b21..a455732 100644 --- a/src/misc/tests/mod.rs +++ b/src/misc/tests/mod.rs @@ -15,6 +15,7 @@ use crate::{ mod asking; mod codes; +mod notices; mod numbers; struct Stage { diff --git a/src/misc/tests/notices.rs b/src/misc/tests/notices.rs new file mode 100644 index 0000000..d2c6575 --- /dev/null +++ b/src/misc/tests/notices.rs @@ -0,0 +1,121 @@ +use std::cell::{Cell, RefCell}; + +use gpui::{AnyElement, IntoElement, TestAppContext}; + +use super::{at_root, press, shown, stage}; +use crate::misc::{ConsentDialog, CookieBanner, LicenseViewer, Package}; + +thread_local! { + static SAID: RefCell> = const { RefCell::new(Vec::new()) }; + static OPEN: Cell = const { Cell::new(true) }; +} + +fn said() -> Vec { + SAID.with(|said| said.borrow().clone()) +} + +fn say(text: impl Into) { + SAID.with(|said| said.borrow_mut().push(text.into())); +} + +fn licenses() -> AnyElement { + LicenseViewer::new( + "licenses", + [ + Package::new("Alpha", "1.0", "MIT", "Permission is hereby granted"), + Package::new("Beta", "2.3", "ISC", "Permission to use, copy, modify"), + ], + ) + .into_any_element() +} + +/// The first package's license shows; a move down the list shows the next one's. +#[gpui::test] +fn a_license_viewer_shows_the_chosen_license(cx: &mut TestAppContext) { + let (_, cx) = stage(licenses, cx); + assert!(shown("license-Alpha", cx)); + press("tab", cx); + press("down", cx); + press("space", cx); + assert!(shown("license-Beta", cx)); +} + +fn consent() -> AnyElement { + let answer = |word: &'static str| { + move |_: &mut gpui::Window, _: &mut gpui::App| { + OPEN.set(false); + say(word) + } + }; + match OPEN.get() { + true => ConsentDialog::new( + "consent", + "Terms", + "Be kind.", + answer("accepted"), + answer("declined"), + ) + .into_any_element(), + false => gpui::div().into_any_element(), + } +} + +/// Accept asks for the box until it is ticked, then answers once. +#[gpui::test] +fn consent_waits_for_the_box(cx: &mut TestAppContext) { + let (_, cx) = stage(consent, cx); + press("tab", cx); + press("tab", cx); + press("tab", cx); + press("enter", cx); + assert!(said().is_empty() && shown("consent-tick-first", cx)); + press("shift-tab", cx); + press("shift-tab", cx); + press("space", cx); + press("tab", cx); + press("tab", cx); + press("enter", cx); + assert_eq!(said(), ["accepted"], "Accept answers alone"); +} + +/// Escape declines, once. +#[gpui::test] +fn escape_declines_consent(cx: &mut TestAppContext) { + let (_, cx) = stage(consent, cx); + press("tab", cx); + press("escape", cx); + assert_eq!(said(), ["declined"]); +} + +fn cookies() -> AnyElement { + CookieBanner::new("cookies", "This page would set cookies.") + .kind("Analytics", "Counts visits.") + .kind("Marketing", "Shows offers.") + .on_choose(|kinds, _, _| say(format!("{kinds:?}"))) + .into_any_element() +} + +/// Accept all names every kind and Reject all none; Choose turns into Save choices on its focus, which names the switches turned on. +#[gpui::test] +fn a_cookie_banner_hands_over_the_kinds_chosen(cx: &mut TestAppContext) { + let (_, cx) = stage(cookies, cx); + press("tab", cx); + press("enter", cx); + press("tab", cx); + press("enter", cx); + press("tab", cx); + press("enter", cx); + assert!(!at_root(cx), "Save choices took Choose's focus"); + for _ in 0..3 { + press("shift-tab", cx); + } + press("space", cx); + for _ in 0..3 { + press("tab", cx); + } + press("enter", cx); + assert_eq!( + said(), + [r#"["Analytics", "Marketing"]"#, "[]", r#"["Marketing"]"#] + ); +} diff --git a/src/theme/misc.rs b/src/theme/misc.rs index eb26c50..f3d315d 100644 --- a/src/theme/misc.rs +++ b/src/theme/misc.rs @@ -11,6 +11,10 @@ pub struct MiscSizes { pub small_face: Rems, /// A flashcard's least height. pub card: Rems, + /// A license viewer's list, and the least width of the text beside it. + pub licenses: Rems, + /// How tall a license's text grows before it scrolls. + pub reading: Rems, } impl Theme { @@ -19,6 +23,8 @@ impl Theme { face: px_to_rems(112.0), small_face: px_to_rems(32.0), card: px_to_rems(176.0), + licenses: px_to_rems(240.0), + reading: px_to_rems(320.0), } } } diff --git a/tasks/ch31-43.md b/tasks/ch31-43.md index 0c3474e..13fc938 100644 --- a/tasks/ch31-43.md +++ b/tasks/ch31-43.md @@ -333,10 +333,10 @@ ## T42e Misc · Terms and notices -- [ ] ChangeLog Viewer → `data_display::Changelog` -- [ ] LicenseViewer -- [ ] Terms / Consent Dialog -- [ ] CookieBanner +- [x] ChangeLog Viewer → `data_display::Changelog` +- [x] LicenseViewer → `misc::LicenseViewer`: each package with its version and license, beside the chosen one's text, which scrolls; narrow, the text drops below the list +- [x] Terms / Consent Dialog → `misc::ConsentDialog` on `overlays::Dialog`: a box to agree, and Accept asks for it until ticked; each way out answers once, accept or decline +- [x] CookieBanner → `misc::CookieBanner`: Accept all, Reject all, or Choose, which turns into Save choices over a switch for each kind; necessary cookies stay on ## T42f Misc · Web