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

+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),
}
}
}