Misc · Terms and notices: licenses, consent and a cookie banner

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.
This commit is contained in:
ZacharyZhang-NY committed 2026-09-28 03:36:03 -04:00
1 parent 320ab4ae3a
commit 2837c802b5
10 files changed
+744 -5

No files matched your search

+10 -1
View File
@@ -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()
}
+137
View File
@@ -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)
}
+124
View File
@@ -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<ElementId>,
title: impl Into<SharedString>,
terms: impl Into<SharedString>,
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<SharedString>) -> 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);
})
})
}
}
+193
View File
@@ -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<dyn Fn(&[SharedString], &mut Window, &mut App)>;
/// Which kinds the viewer turned on, and whether the switches show.
struct Choosing {
open: bool,
on: Vec<bool>,
}
/// 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<OnChoose>,
}
impl CookieBanner {
pub fn new(id: impl Into<ElementId>, message: impl Into<SharedString>) -> 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<SharedString>, detail: impl Into<SharedString>) -> 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<SharedString>, 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),
),
)
}
+142
View File
@@ -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<SharedString>,
version: impl Into<SharedString>,
license: impl Into<SharedString>,
text: impl Into<SharedString>,
) -> 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<Package>,
}
impl LicenseViewer {
pub fn new(id: impl Into<ElementId>, packages: impl IntoIterator<Item = Package>) -> 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)
}
}
+6
View File
@@ -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;
+1
View File
@@ -15,6 +15,7 @@ use crate::{
mod asking;
mod codes;
mod notices;
mod numbers;
struct Stage {
+121
View File
@@ -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<Vec<String>> = const { RefCell::new(Vec::new()) };
static OPEN: Cell<bool> = const { Cell::new(true) };
}
fn said() -> Vec<String> {
SAID.with(|said| said.borrow().clone())
}
fn say(text: impl Into<String>) {
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"]"#]
);
}
+6
View File
@@ -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),
}
}
}
+4 -4
View File
@@ -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