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:
1 parent
320ab4ae3a
commit
2837c802b5
10 files changed
+744
-5
No files matched your search
@@ -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);
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -15,6 +15,7 @@ use crate::{
|
||||
|
||||
mod asking;
|
||||
mod codes;
|
||||
mod notices;
|
||||
mod numbers;
|
||||
|
||||
struct Stage {
|
||||
|
||||
@@ -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"]"#]
|
||||
);
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user