diff --git a/android/app/src/main/java/io/narl/protonstream/ui/theme/Shape.kt b/android/app/src/main/java/io/narl/protonstream/ui/theme/Shape.kt index d204391..844af98 100644 --- a/android/app/src/main/java/io/narl/protonstream/ui/theme/Shape.kt +++ b/android/app/src/main/java/io/narl/protonstream/ui/theme/Shape.kt @@ -29,7 +29,7 @@ internal val ProtonShapes = Shapes( /** The radius `pstr-app` draws everything at. */ internal const val CORNER = 8f -/** A badge over artwork — `ui/mod.rs` is the other place this number lives. */ +/** A badge over artwork — `theme::radius::BADGE` in pstr-app is the other place this number lives. */ internal const val CORNER_TIGHT = 4f /** A bottom sheet, which has no desktop counterpart. */ diff --git a/crates/pstr-app/src/app.rs b/crates/pstr-app/src/app.rs index 2dc7fb1..4af1652 100644 --- a/crates/pstr-app/src/app.rs +++ b/crates/pstr-app/src/app.rs @@ -1432,14 +1432,14 @@ impl App { focus_search: bool, ) { ui.horizontal(|ui| { - ui.add_space(4.0); + ui.add_space(theme::space::XS); ui.label( theme::Role::Heading .rich("proton-stream") .strong() .color(theme::accent()), ); - ui.add_space(12.0); + ui.add_space(theme::space::L); let on_library = matches!(self.page, Page::Library | Page::Title(_)); let active = self @@ -1475,7 +1475,7 @@ impl App { } ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - ui.add_space(4.0); + ui.add_space(theme::space::XS); if self.crawling { if ui .small_button("Stop") @@ -1504,7 +1504,7 @@ impl App { } if on_library && !self.library.is_empty() { - ui.add_space(8.0); + ui.add_space(theme::space::M); ui::search_field(ui, search, focus_search); } }); @@ -1914,7 +1914,7 @@ fn screenshot_name(title: &str, caption: &str, position: f64) -> String { /// rather than in one: the cut between two pages of tiles reads as a flicker, /// a short fade as a change of place. fn page_fade(ctx: &egui::Context, shown: &mut (Option, f64), page: &Page) -> f32 { - const SECONDS: f64 = 0.14; + const SECONDS: f64 = theme::motion::STATE as f64; let now = ctx.input(|input| input.time); if shown.0.as_ref() != Some(page) { // The first page of a launch is there at once; only changes fade. @@ -1925,7 +1925,7 @@ fn page_fade(ctx: &egui::Context, shown: &mut (Option, f64), page: &Page) if progress < 1.0 { ctx.request_repaint(); } - 1.0 - (1.0 - progress).powi(3) + theme::motion::ease_out(progress) } /// What the top bar says while shares are being listed: one share by name, diff --git a/crates/pstr-app/src/theme.rs b/crates/pstr-app/src/theme.rs index 8e099a6..4813f82 100644 --- a/crates/pstr-app/src/theme.rs +++ b/crates/pstr-app/src/theme.rs @@ -107,6 +107,59 @@ impl Role { } } +/// The spacing scale. Gaps between things come from here rather than being +/// picked per call site, so two neighbouring groups are 12 apart or 16 apart +/// and never 14 in one place and 15 in the next. +pub mod space { + /// Between lines that belong to one thing: a title and its caption. + pub const XXS: f32 = 2.0; + pub const XS: f32 = 4.0; + /// Between controls in one cluster. + pub const S: f32 = 6.0; + pub const M: f32 = 8.0; + /// Between clusters on one row, and between rows of a form. + pub const L: f32 = 12.0; + /// Between the groups on a page. + pub const XL: f32 = 16.0; + /// Between sections. + pub const XXL: f32 = 24.0; +} + +/// Corner radii, as `egui::CornerRadius::same` takes them. +pub mod radius { + /// A progress bar, or a strip along an edge. + pub const BAR: u8 = 3; + /// A badge over artwork. Android's `Shape.kt` has the same number. + pub const BADGE: u8 = 4; + /// A small control inside something else: a menu entry, a chip. + pub const SM: u8 = 6; + /// Everything else either client paints: cards, buttons, the tab pill. + pub const MD: u8 = 8; + /// Something that floats: a row card, a dialog, a toast, the hero. + pub const LG: u8 = 12; +} + +/// How long things take to change, in seconds, for `Context::animate_*`. +pub mod motion { + /// A press, which has to answer before the finger is off the button. + pub const PRESS: f32 = 0.05; + /// A hover arriving and leaving. + pub const HOVER: f32 = 0.12; + /// A control changing state: a switch flipping, the tab pill travelling, + /// one page giving way to the next. + pub const STATE: f32 = 0.15; + /// Chrome fading in and out: the player's controls, a toast. + pub const FADE: f32 = 0.18; + /// Artwork arriving over its placeholder. Slower, because it replaces + /// something rather than adding to it, and a fast swap reads as a flicker. + pub const ARRIVE: f32 = 0.24; + + /// Fast out, slow in: the curve every hand-driven transition uses. + pub fn ease_out(t: f32) -> f32 { + 1.0 - (1.0 - t.clamp(0.0, 1.0)).powi(3) + } +} + /// The UI typeface, bundled rather than taken from the system so a title /// sets the same on every desktop. Inter, under the OFL — the licence is /// beside the files. @@ -626,7 +679,7 @@ pub fn apply(ctx: &egui::Context, appearance: Appearance) { visuals.selection.stroke = Stroke::new(1.0, palette.text); visuals.hyperlink_color = palette.accent; - let radius = CornerRadius::same(8); + let radius = CornerRadius::same(radius::MD); visuals.widgets.noninteractive.bg_fill = palette.surface; visuals.widgets.noninteractive.corner_radius = radius; visuals.widgets.noninteractive.bg_stroke = Stroke::new(1.0, palette.border); diff --git a/crates/pstr-app/src/ui/downloads.rs b/crates/pstr-app/src/ui/downloads.rs index 4f444de..6053317 100644 --- a/crates/pstr-app/src/ui/downloads.rs +++ b/crates/pstr-app/src/ui/downloads.rs @@ -20,7 +20,7 @@ pub fn show(ui: &mut egui::Ui, downloads: &[DownloadItem], actions: &mut Vec) { if totals_known && group.len() > 1 { ui::progress_bar(ui, fraction(downloaded, total), ui.available_width()); } - ui.add_space(6.0); + ui.add_space(theme::space::S); for item in group { row(ui, item, actions); } @@ -117,7 +117,7 @@ fn group(ui: &mut egui::Ui, group: &[DownloadItem], actions: &mut Vec) { fn row(ui: &mut egui::Ui, item: &DownloadItem, actions: &mut Vec) { egui::Frame::new() .fill(theme::card()) - .corner_radius(egui::CornerRadius::same(6)) + .corner_radius(egui::CornerRadius::same(theme::radius::SM)) .inner_margin(egui::Margin::symmetric(12, 8)) .show(ui, |ui| { ui.set_width(ui.available_width()); @@ -142,7 +142,7 @@ fn row(ui: &mut egui::Ui, item: &DownloadItem, actions: &mut Vec) { ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { buttons(ui, item, actions); if item.total > 0 && item.state != DownloadState::Completed { - ui.add_space(8.0); + ui.add_space(theme::space::M); ui.label(ui::muted(format!( "{} / {}", size(item.downloaded), @@ -154,7 +154,7 @@ fn row(ui: &mut egui::Ui, item: &DownloadItem, actions: &mut Vec) { }); }); }); - ui.add_space(4.0); + ui.add_space(theme::space::XS); } /// What a row says about itself, and in what colour. diff --git a/crates/pstr-app/src/ui/library.rs b/crates/pstr-app/src/ui/library.rs index 02ddc49..dcce0de 100644 --- a/crates/pstr-app/src/ui/library.rs +++ b/crates/pstr-app/src/ui/library.rs @@ -47,13 +47,13 @@ pub fn show(ui: &mut egui::Ui, art: &mut Art<'_>, shelves: Shelves<'_>, actions: if !searching { if let Some(title) = featured(library, view, art.metadata, today()) { hero(ui, art, title, actions); - ui.add_space(18.0); + ui.add_space(theme::space::XL); } let resumable = pick(&view.resumable); if !resumable.is_empty() { ui::section(ui, "Continue watching"); continue_row(ui, art, &resumable, actions); - ui.add_space(16.0); + ui.add_space(theme::space::XL); } } @@ -74,10 +74,10 @@ pub fn show(ui: &mut egui::Ui, art: &mut Art<'_>, shelves: Shelves<'_>, actions: shelf_controls(ui, view, actions); }); }); - ui.add_space(6.0); + ui.add_space(theme::space::S); if matches.is_empty() { - ui.add_space(8.0); + ui.add_space(theme::space::M); ui.label(ui::muted(if searching { "Nothing here by that name." } else { @@ -143,7 +143,7 @@ fn hero(ui: &mut egui::Ui, art: &mut Art<'_>, title: &Title, actions: &mut Vec, title: &Title, actions: &mut Vec, title: &Title, actions: &mut Vec, title: &Title, actions: &mut Vec, title: &Title, actions: &mut Vec) { .rich("Nothing in the library yet") .strong(), ); - ui.add_space(6.0); + ui.add_space(theme::space::S); ui.label(ui::muted( "Add a Proton Drive share link, and everything playable behind it shows up here.", )); - ui.add_space(18.0); + ui.add_space(theme::space::XL); if ui::accent_button(ui, "Add a share").clicked() { actions.push(Action::Goto(Page::Shares)); } diff --git a/crates/pstr-app/src/ui/matcher.rs b/crates/pstr-app/src/ui/matcher.rs index 00b3ec2..92592ec 100644 --- a/crates/pstr-app/src/ui/matcher.rs +++ b/crates/pstr-app/src/ui/matcher.rs @@ -43,7 +43,7 @@ pub fn show( egui::Frame::new() .fill(theme::surface()) .inner_margin(egui::Margin::same(18)) - .corner_radius(egui::CornerRadius::same(10)), + .corner_radius(egui::CornerRadius::same(theme::radius::LG)), ) .show(ctx, |ui| { ui.set_width(620.0); @@ -58,21 +58,21 @@ pub fn show( "Everything {} answers with, in its own order — nothing is scored here.", provider.label() ))); - ui.add_space(10.0); + ui.add_space(theme::space::L); search_row(ui, matcher, actions); - ui.add_space(6.0); + ui.add_space(theme::space::S); current_row(ui, matcher, current.as_ref(), actions); - ui.add_space(10.0); + ui.add_space(theme::space::L); if let Some(error) = &matcher.error { ui.label(theme::Role::Caption.rich(error).color(theme::danger())); - ui.add_space(6.0); + ui.add_space(theme::space::S); } results(ui, matcher, art, actions); - ui.add_space(12.0); + ui.add_space(theme::space::L); ui.horizontal(|ui| { ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { if ui.button("Close").clicked() { @@ -178,7 +178,7 @@ fn results(ui: &mut egui::Ui, matcher: &Matcher, art: &mut Art<'_>, actions: &mu if row(ui, art, found).clicked() { actions.push(Action::ChooseMatch(Box::new(found.clone()))); } - ui.add_space(4.0); + ui.add_space(theme::space::XS); } }); } @@ -188,7 +188,7 @@ fn results(ui: &mut egui::Ui, matcher: &Matcher, art: &mut Art<'_>, actions: &mu fn row(ui: &mut egui::Ui, art: &mut Art<'_>, found: &TitleMetadata) -> egui::Response { let response = egui::Frame::new() .fill(theme::card()) - .corner_radius(egui::CornerRadius::same(6)) + .corner_radius(egui::CornerRadius::same(theme::radius::SM)) .inner_margin(egui::Margin::same(8)) .show(ui, |ui| { ui.set_width(ui.available_width()); @@ -204,12 +204,12 @@ fn row(ui: &mut egui::Ui, art: &mut Art<'_>, found: &TitleMetadata) -> egui::Res } else { ui.painter().rect_filled( rect, - egui::CornerRadius::same(4), + egui::CornerRadius::same(theme::radius::BADGE), theme::card_hover(), ); } - ui.add_space(10.0); + ui.add_space(theme::space::L); ui.vertical(|ui| { ui.label( theme::Role::Body @@ -241,7 +241,7 @@ fn row(ui: &mut egui::Ui, art: &mut Art<'_>, found: &TitleMetadata) -> egui::Res if response.hovered() { ui.painter().rect_stroke( response.rect, - egui::CornerRadius::same(6), + egui::CornerRadius::same(theme::radius::SM), egui::Stroke::new(1.5, theme::accent()), egui::StrokeKind::Inside, ); diff --git a/crates/pstr-app/src/ui/mod.rs b/crates/pstr-app/src/ui/mod.rs index 169a199..23ebc75 100644 --- a/crates/pstr-app/src/ui/mod.rs +++ b/crates/pstr-app/src/ui/mod.rs @@ -141,14 +141,14 @@ pub fn format_size(bytes: i64) -> String { /// A section title with a rule under it. pub fn section(ui: &mut egui::Ui, text: &str) { - ui.add_space(6.0); + ui.add_space(theme::space::S); ui.label( theme::Role::Section .rich(text) .strong() .color(theme::text()), ); - ui.add_space(2.0); + ui.add_space(theme::space::XXS); } /// Small grey text, for everything that is context rather than content. @@ -173,7 +173,7 @@ pub fn search_field(ui: &mut egui::Ui, search: &mut String, focus: bool) { let has_focus = ui.memory(|memory| memory.has_focus(id)); let lit = ui .ctx() - .animate_bool_with_time(id.with("lit"), has_focus, 0.12); + .animate_bool_with_time(id.with("lit"), has_focus, theme::motion::HOVER); let radius = CornerRadius::same((height / 2.0) as u8); ui.painter().rect_filled(outer, radius, theme::card()); @@ -258,7 +258,7 @@ pub fn search_field(ui: &mut egui::Ui, search: &mut String, focus: bool) { pub fn progress_bar(ui: &mut egui::Ui, fraction: f32, width: f32) -> egui::Response { let (rect, response) = ui.allocate_exact_size(Vec2::new(width, 6.0), Sense::hover()); if ui.is_rect_visible(rect) { - let radius = CornerRadius::same(3); + let radius = CornerRadius::same(theme::radius::BAR); ui.painter().rect_filled(rect, radius, theme::card_hover()); let mut done = rect; done.set_width(rect.width() * fraction.clamp(0.0, 1.0)); @@ -275,7 +275,7 @@ pub fn empty_state(ui: &mut egui::Ui, heading: &str, body: &str) { ui.vertical_centered(|ui| { ui.add_space(96.0); ui.label(theme::Role::Title.rich(heading).strong()); - ui.add_space(6.0); + ui.add_space(theme::space::S); ui.label(muted(body)); }); } @@ -311,7 +311,7 @@ pub fn confirm(ctx: &egui::Context, id: &str, heading: &str, body: &str, verb: & egui::Frame::new() .fill(theme::surface()) .inner_margin(egui::Margin::same(18)) - .corner_radius(CornerRadius::same(10)), + .corner_radius(CornerRadius::same(theme::radius::LG)), ) .show(ctx, |ui| { ui.set_width(420.0); @@ -321,9 +321,9 @@ pub fn confirm(ctx: &egui::Context, id: &str, heading: &str, body: &str, verb: & .strong() .color(theme::text()), ); - ui.add_space(6.0); + ui.add_space(theme::space::S); ui.label(theme::Role::Body.rich(body).color(theme::muted())); - ui.add_space(16.0); + ui.add_space(theme::space::XL); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { if danger_button(ui, verb).clicked() { answer = Answer::Confirmed; @@ -382,15 +382,17 @@ pub fn tabs(ui: &mut egui::Ui, id: egui::Id, items: &[(&str, bool)]) -> Option egui::Respo return response; } - let radius = CornerRadius::same(8); + let radius = CornerRadius::same(theme::radius::MD); // Eased rather than switched, so the veil arrives over a few frames instead // of appearing whole the moment the pointer crosses the edge. The press is // quicker than the hover: a click should feel like it registered, not like // it was considered. let ctx = ui.ctx().clone(); - let hover = ctx.animate_bool_with_time(response.id.with("hover"), response.hovered(), 0.10); + let hover = ctx.animate_bool_with_time( + response.id.with("hover"), + response.hovered(), + theme::motion::HOVER, + ); let press = ctx.animate_bool_with_time( response.id.with("press"), response.is_pointer_button_down_on(), - 0.05, + theme::motion::PRESS, ); let lift = 0.10 * hover * (1.0 - press) - 0.14 * press; @@ -510,7 +516,7 @@ pub fn card(ui: &mut egui::Ui, card: Card<'_>) -> egui::Response { let width = card.width; let image_height = (width * theme::CARD_ASPECT).round(); let text_height = CARD_TEXT_HEIGHT; - let radius = CornerRadius::same(8); + let radius = CornerRadius::same(theme::radius::MD); let (rect, response) = ui.allocate_exact_size(Vec2::new(width, image_height + text_height), Sense::click()); @@ -533,14 +539,16 @@ pub fn card(ui: &mut egui::Ui, card: Card<'_>) -> egui::Response { let hover = ui.ctx().animate_bool_with_time( response.id.with("hover"), response.hovered() || response.has_focus(), - 0.12, + theme::motion::HOVER, ); // The picture arrives whenever its download finishes, several frames after // the tile first drew. Fading it in over the placeholder turns a wall of // letters popping into stills into one settle. - let art_in = ui - .ctx() - .animate_bool_with_time(response.id.with("art"), card.art.is_some(), 0.22); + let art_in = ui.ctx().animate_bool_with_time( + response.id.with("art"), + card.art.is_some(), + theme::motion::ARRIVE, + ); let painter = ui.painter(); if hover > 0.0 { @@ -591,7 +599,7 @@ pub fn card(ui: &mut egui::Ui, card: Card<'_>) -> egui::Response { ); painter.rect_filled( background, - CornerRadius::same(4), + CornerRadius::same(theme::radius::BADGE), Color32::from_black_alpha(180), ); painter.galley(background.min + Vec2::new(5.0, 2.0), galley, Color32::WHITE); @@ -724,7 +732,7 @@ pub(crate) fn placeholder(painter: &egui::Painter, rect: Rect, name: &str, opaci theme::ramp_fill( painter, rect, - CornerRadius::same(8), + CornerRadius::same(theme::radius::MD), (from, to), theme::Direction::Vertical, opacity, diff --git a/crates/pstr-app/src/ui/player.rs b/crates/pstr-app/src/ui/player.rs index f391843..4a8a2f6 100644 --- a/crates/pstr-app/src/ui/player.rs +++ b/crates/pstr-app/src/ui/player.rs @@ -251,7 +251,7 @@ pub fn show( // Faded rather than switched, both ways: controls that blink out under a // reading eye are more distracting than the ones that were there. - let fade = ctx.animate_bool_with_time(ui.id().with("chrome"), visible, 0.18); + let fade = ctx.animate_bool_with_time(ui.id().with("chrome"), visible, theme::motion::FADE); if fade > 0.0 { overlay.chrome_height = ui .scope(|ui| { @@ -379,7 +379,7 @@ fn between(ui: &mut egui::Ui, opening: Option<&str>, rect: Rect, actions: &mut V if ui.button("Back").clicked() { actions.push(Action::LeavePlayer); } - ui.add_space(8.0); + ui.add_space(theme::space::M); ui.add(egui::Spinner::new().size(14.0)); }, ); @@ -479,7 +479,7 @@ fn up_next_card( egui::Frame::new() .fill(Color32::from_black_alpha(215)) .stroke(egui::Stroke::new(1.0, Color32::from_white_alpha(120))) - .corner_radius(CornerRadius::same(8)) + .corner_radius(CornerRadius::same(theme::radius::MD)) .inner_margin(egui::Margin::same(12)) .show(ui, |ui| { ui.set_width(size.x - 24.0); @@ -488,7 +488,7 @@ fn up_next_card( // coming and when, read in one glance. let (frame, response) = ui.allocate_exact_size(STILL, Sense::click()); let painter = ui.painter(); - let radius = CornerRadius::same(6); + let radius = CornerRadius::same(theme::radius::SM); match &card.still { Some(texture) => { painter.add( @@ -518,7 +518,7 @@ fn up_next_card( actions.push(Action::PlayAdjacent(Adjacent::Next)); } - ui.add_space(10.0); + ui.add_space(theme::space::L); ui.vertical(|ui| { ui.label( theme::Role::Caption @@ -535,12 +535,12 @@ fn up_next_card( ) .truncate(), ); - ui.add_space(6.0); + ui.add_space(theme::space::S); ui.horizontal(|ui| { if ui::accent_button(ui, "Play now").clicked() { actions.push(Action::PlayAdjacent(Adjacent::Next)); } - ui.add_space(6.0); + ui.add_space(theme::space::S); if ui .button("Watch till the end") .on_hover_text("Play this one out — nothing will be skipped") @@ -633,7 +633,7 @@ fn chrome( .add( egui::Button::new(theme::Role::Label.rich("Library")) .fill(Color32::from_black_alpha(160)) - .corner_radius(CornerRadius::same(8)) + .corner_radius(CornerRadius::same(theme::radius::MD)) .min_size(Vec2::new(96.0, 32.0)), ) .on_hover_text("Keep playing, and go back to the library (Esc)") @@ -641,7 +641,7 @@ fn chrome( { actions.push(Action::LeavePlayer); } - ui.add_space(14.0); + ui.add_space(theme::space::L); ui.vertical(|ui| { ui.label( theme::Role::Heading @@ -666,7 +666,7 @@ fn chrome( egui_phosphor::regular::CORNERS_OUT })) .fill(Color32::from_black_alpha(160)) - .corner_radius(CornerRadius::same(8)) + .corner_radius(CornerRadius::same(theme::radius::MD)) .min_size(Vec2::new(38.0, 32.0)), ) .on_hover_text(if fullscreen { diff --git a/crates/pstr-app/src/ui/settings.rs b/crates/pstr-app/src/ui/settings.rs index ec144e3..b4f2a2a 100644 --- a/crates/pstr-app/src/ui/settings.rs +++ b/crates/pstr-app/src/ui/settings.rs @@ -50,7 +50,7 @@ pub fn show( ui.add_space(margin); ui.vertical(|ui| { ui.set_width(width); - ui.add_space(8.0); + ui.add_space(theme::space::M); appearance_form(ui, prefs.appearance, actions); playback_form(ui, prefs, actions); storage_form(ui, prefs, actions); @@ -68,9 +68,9 @@ pub fn show( /// only changed a small square would be asking someone to imagine the result. fn appearance_form(ui: &mut egui::Ui, appearance: Appearance, actions: &mut Vec) { ui::widgets::settings_group(ui, "Appearance", None, |ui| { - ui.add_space(12.0); + ui.add_space(theme::space::L); ui.label(theme::Role::Body.rich("Palette").color(theme::text())); - ui.add_space(8.0); + ui.add_space(theme::space::M); ui.spacing_mut().item_spacing = egui::vec2(10.0, 10.0); ui.horizontal_wrapped(|ui| { for flavor in Flavor::ALL { @@ -86,7 +86,7 @@ fn appearance_form(ui: &mut egui::Ui, appearance: Appearance, actions: &mut Vec< } } }); - ui.add_space(14.0); + ui.add_space(theme::space::L); ui.spacing_mut().item_spacing.y = 0.0; ui::widgets::settings_row(ui, "Accent", None, |ui| { @@ -132,11 +132,11 @@ fn palette_card(ui: &mut egui::Ui, appearance: Appearance, flavor: Flavor) -> eg ..appearance }); let selected = appearance.flavor == flavor; - let hover = ui - .ctx() - .animate_bool_with_time(response.id, response.hovered(), 0.10); + let hover = + ui.ctx() + .animate_bool_with_time(response.id, response.hovered(), theme::motion::HOVER); let painter = ui.painter(); - let radius = egui::CornerRadius::same(8); + let radius = egui::CornerRadius::same(theme::radius::MD); let preview = egui::Rect::from_min_size(rect.min, egui::vec2(size.x, 64.0)); painter.rect_filled(preview, radius, palette.background); @@ -155,11 +155,20 @@ fn palette_card(ui: &mut egui::Ui, appearance: Appearance, flavor: Flavor) -> eg egui::Rect::from_min_size(preview.min + egui::vec2(x, 20.0), egui::vec2(34.0, 22.0)) }; for x in [8.0, 49.0, 90.0] { - painter.rect_filled(tile(x), egui::CornerRadius::same(3), palette.card); + painter.rect_filled( + tile(x), + egui::CornerRadius::same(theme::radius::BAR), + palette.card, + ); } let pill = egui::Rect::from_min_size(preview.min + egui::vec2(8.0, 3.0), egui::vec2(22.0, 6.0)); - theme::swatch_fill(painter, pill, egui::CornerRadius::same(3), &palette); + theme::swatch_fill( + painter, + pill, + egui::CornerRadius::same(theme::radius::BAR), + &palette, + ); painter.hline( (preview.left() + 8.0)..=(preview.left() + 60.0), preview.top() + 50.0, @@ -354,7 +363,7 @@ fn metadata_form( actions: &mut Vec, ) { ui::widgets::settings_group(ui, "Posters and descriptions", None, |ui| { - ui.add_space(12.0); + ui.add_space(theme::space::L); ui.spacing_mut().item_spacing.y = 6.0; ui.label(ui::muted( "A share filled by the Proton Drive desktop client carries no thumbnails, so \ @@ -362,7 +371,7 @@ fn metadata_form( in your library — not your files, and nothing about what you have watched — to \ the provider you choose, over HTTPS, each time a new one appears.", )); - ui.add_space(10.0); + ui.add_space(theme::space::L); let mut changed = false; let mut enabled = settings.enabled; @@ -372,7 +381,7 @@ fn metadata_form( } if settings.enabled { - ui.add_space(10.0); + ui.add_space(theme::space::L); ui.label(ui::muted("Provider")); for provider in ProviderId::ALL { if ui @@ -387,7 +396,7 @@ fn metadata_form( } if settings.provider.needs_api_key() { - ui.add_space(10.0); + ui.add_space(theme::space::L); ui.label(ui::muted(format!("{} API key", settings.provider.label()))); ui.horizontal(|ui| { ui.add( @@ -409,7 +418,7 @@ fn metadata_form( )); } - ui.add_space(12.0); + ui.add_space(theme::space::L); ui.horizontal(|ui| { if ui::accent_button(ui, "Match the library").clicked() { actions.push(Action::MatchTitles { force: false }); @@ -423,7 +432,7 @@ fn metadata_form( } }); } else { - ui.add_space(6.0); + ui.add_space(theme::space::S); ui.label(ui::muted( "Off. Nothing is sent anywhere, and turning it off also deletes the answers \ already stored.", @@ -433,6 +442,6 @@ fn metadata_form( if changed { actions.push(Action::SetMetadataConfig(settings.clone())); } - ui.add_space(12.0); + ui.add_space(theme::space::L); }); } diff --git a/crates/pstr-app/src/ui/shares.rs b/crates/pstr-app/src/ui/shares.rs index 6891758..1bb3ce8 100644 --- a/crates/pstr-app/src/ui/shares.rs +++ b/crates/pstr-app/src/ui/shares.rs @@ -38,7 +38,7 @@ pub fn show( share_row(ui, share, titles, actions); } - ui.add_space(22.0); + ui.add_space(theme::space::XXL); add_form(ui, shares, form, actions); }); } @@ -46,7 +46,7 @@ pub fn show( fn share_row(ui: &mut egui::Ui, share: &Share, titles: usize, actions: &mut Vec) { egui::Frame::new() .fill(theme::card()) - .corner_radius(egui::CornerRadius::same(8)) + .corner_radius(egui::CornerRadius::same(theme::radius::MD)) .inner_margin(egui::Margin::symmetric(12, 10)) .show(ui, |ui| { ui.set_width(ui.available_width()); @@ -76,7 +76,7 @@ fn share_row(ui: &mut egui::Ui, share: &Share, titles: usize, actions: &mut Vec< }); }); }); - ui.add_space(6.0); + ui.add_space(theme::space::S); } fn add_form(ui: &mut egui::Ui, shares: &[Share], form: &mut ShareForm, actions: &mut Vec) { @@ -85,11 +85,11 @@ fn add_form(ui: &mut egui::Ui, shares: &[Share], form: &mut ShareForm, actions: "Paste the whole link, including everything after the # — that part is the key that \ decrypts it, and it is stored in your system keyring rather than on disk.", )); - ui.add_space(10.0); + ui.add_space(theme::space::L); egui::Frame::new() .fill(theme::card()) - .corner_radius(egui::CornerRadius::same(8)) + .corner_radius(egui::CornerRadius::same(theme::radius::MD)) .inner_margin(egui::Margin::same(14)) .show(ui, |ui| { ui.set_width(ui.available_width().min(640.0)); @@ -100,7 +100,7 @@ fn add_form(ui: &mut egui::Ui, shares: &[Share], form: &mut ShareForm, actions: .hint_text("Anime") .desired_width(f32::INFINITY), ); - ui.add_space(8.0); + ui.add_space(theme::space::M); ui.label(ui::muted("Link")); ui.horizontal(|ui| { @@ -134,11 +134,11 @@ fn add_form(ui: &mut egui::Ui, shares: &[Share], form: &mut ShareForm, actions: if let Some(problem) = &problem { ui.label(theme::Role::Caption.rich(problem).color(theme::danger())); } - ui.add_space(8.0); + ui.add_space(theme::space::M); ui::widgets::toggle(ui, &mut form.has_password, "The link asks for a password"); if form.has_password { - ui.add_space(6.0); + ui.add_space(theme::space::S); ui.add( egui::TextEdit::singleline(&mut form.password) .password(true) @@ -147,7 +147,7 @@ fn add_form(ui: &mut egui::Ui, shares: &[Share], form: &mut ShareForm, actions: ); } - ui.add_space(12.0); + ui.add_space(theme::space::L); let ready = !form.name.trim().is_empty() && !form.url.trim().is_empty() && problem.is_none() @@ -156,7 +156,7 @@ fn add_form(ui: &mut egui::Ui, shares: &[Share], form: &mut ShareForm, actions: if let Some(error) = &form.error { ui.label(theme::Role::Caption.rich(error).color(theme::danger())); - ui.add_space(6.0); + ui.add_space(theme::space::S); } ui.horizontal(|ui| { if ui diff --git a/crates/pstr-app/src/ui/shortcuts.rs b/crates/pstr-app/src/ui/shortcuts.rs index e935b6a..407630a 100644 --- a/crates/pstr-app/src/ui/shortcuts.rs +++ b/crates/pstr-app/src/ui/shortcuts.rs @@ -47,7 +47,7 @@ pub fn show(ctx: &egui::Context) -> bool { egui::Frame::new() .fill(theme::surface()) .inner_margin(egui::Margin::same(20)) - .corner_radius(egui::CornerRadius::same(12)), + .corner_radius(egui::CornerRadius::same(theme::radius::LG)), ) .show(ctx, |ui| { ui.set_width(460.0); @@ -60,9 +60,9 @@ pub fn show(ctx: &egui::Context) -> bool { }); }); for (heading, keys) in GROUPS { - ui.add_space(12.0); + ui.add_space(theme::space::L); ui.label(ui::muted(heading.to_uppercase())); - ui.add_space(4.0); + ui.add_space(theme::space::XS); egui::Grid::new(*heading) .num_columns(2) .spacing([18.0, 8.0]) diff --git a/crates/pstr-app/src/ui/title.rs b/crates/pstr-app/src/ui/title.rs index 208adf5..b5e4025 100644 --- a/crates/pstr-app/src/ui/title.rs +++ b/crates/pstr-app/src/ui/title.rs @@ -33,13 +33,13 @@ pub fn show( if back_link(ui).clicked() { actions.push(Action::Goto(Page::Library)); } - ui.add_space(6.0); + ui.add_space(theme::space::S); egui::ScrollArea::vertical() .auto_shrink([false, false]) .show(ui, |ui| { header(ui, art, title, &offline, actions); - ui.add_space(18.0); + ui.add_space(theme::space::XL); // One season at a time, picked from a row of pills, rather than // every season stacked as a collapsing header: a four-season show @@ -78,7 +78,7 @@ pub fn show( picked = index; ui.data_mut(|data| data.insert_temp(picked_id, index)); } - ui.add_space(10.0); + ui.add_space(theme::space::L); } ui.label(ui::muted(ui::library::plural( season.episodes.len(), @@ -88,7 +88,7 @@ pub fn show( season_download(ui, title, season, &offline, actions); }); }); - ui.add_space(10.0); + ui.add_space(theme::space::L); let Some(season) = title.seasons.get(picked) else { return; @@ -96,7 +96,7 @@ pub fn show( for episode in &season.episodes { episode_row(ui, art, title, season, episode, &offline, actions); } - ui.add_space(12.0); + ui.add_space(theme::space::L); }); } @@ -105,7 +105,7 @@ pub fn show( fn poster(ui: &mut egui::Ui, texture: &egui::TextureHandle, progress: Option) { let size = egui::vec2(180.0, 270.0); let (rect, _) = ui.allocate_exact_size(size, egui::Sense::hover()); - let radius = egui::CornerRadius::same(10); + let radius = egui::CornerRadius::same(theme::radius::LG); ui.painter() .add(theme::tile_shadow(1.0).as_shape(rect, radius)); ui.painter().add( @@ -201,7 +201,7 @@ fn header( .as_ref() .map(|(texture, _)| (texture.clone(), ui.painter().add(egui::Shape::Noop))); - ui.add_space(6.0); + ui.add_space(theme::space::S); ui.horizontal_top(|ui| { match &picture { // A poster is drawn as a poster. Fitted into the 16:9 tile the grid @@ -224,7 +224,7 @@ fn header( } } - ui.add_space(22.0); + ui.add_space(theme::space::XXL); ui.vertical(|ui| { // Text in a column a person can read, with the picture to the // right of it rather than under it. @@ -242,9 +242,9 @@ fn header( if by_hand { " (chosen by hand)" } else { "" } ))); } - ui.add_space(4.0); + ui.add_space(theme::space::XS); ui.label(ui::muted(meta_line(title, found.as_ref()))); - ui.add_space(14.0); + ui.add_space(theme::space::L); ui.horizontal(|ui| { if let Some(episode) = title.next_up() { @@ -342,14 +342,14 @@ fn header( }); if let Some(found) = &found { - ui.add_space(14.0); + ui.add_space(theme::space::L); description(ui, found); } }); }); if let Some((texture, index)) = backdrop { - ui.add_space(10.0); + ui.add_space(theme::space::L); band(ui, &texture, index); } } @@ -456,7 +456,7 @@ fn description(ui: &mut egui::Ui, found: &TitleMetadata) { ui::widgets::chip(ui, genre); } }); - ui.add_space(8.0); + ui.add_space(theme::space::M); } if let Some(overview) = &found.overview { // Four lines, then "More". A provider's synopsis runs to three @@ -496,7 +496,7 @@ fn description(ui: &mut egui::Ui, found: &TitleMetadata) { } } if let Some(url) = &found.url { - ui.add_space(8.0); + ui.add_space(theme::space::M); ui.hyperlink_to( ui::muted(format!("More on {}", found.provider.label())), url, @@ -573,7 +573,7 @@ fn episode_row( let row_id = ui.id().with(("episode", &episode.node.link_id)); let frame = egui::Frame::new() - .corner_radius(egui::CornerRadius::same(10)) + .corner_radius(egui::CornerRadius::same(theme::radius::LG)) .inner_margin(egui::Margin::same(8)); let mut prepared = frame.begin(ui); { @@ -581,7 +581,7 @@ fn episode_row( ui.set_width(ui.available_width()); ui.horizontal_top(|ui| { still(ui, art, title, episode, watched, actions); - ui.add_space(6.0); + ui.add_space(theme::space::S); let controls = 96.0; let text_width = (ui.available_width() - controls).max(120.0); @@ -665,7 +665,7 @@ fn episode_row( duration: episode.watch.and_then(|watch| watch.duration_secs), }); } - ui.add_space(6.0); + ui.add_space(theme::space::S); download(ui, title, episode, offline, actions); }); }); @@ -676,11 +676,11 @@ fn episode_row( let lit = ui.ctx().animate_bool_with_time( row_id, response.hovered() || response.contains_pointer(), - 0.10, + theme::motion::HOVER, ); prepared.frame.fill = theme::card().gamma_multiply(0.35 + 0.65 * lit); prepared.paint(ui); - ui.add_space(2.0); + ui.add_space(theme::space::XXS); } /// The grey line under an episode's name: when it aired, how big it is, and @@ -728,7 +728,7 @@ fn still( if !ui.is_rect_visible(rect) { return; } - let radius = egui::CornerRadius::same(8); + let radius = egui::CornerRadius::same(theme::radius::MD); let painter = ui.painter(); painter.rect_filled(rect, radius, theme::card_hover()); // Asked for only once the row is on screen: a season of twenty-five is @@ -751,7 +751,7 @@ fn still( let hover = ui.ctx().animate_bool_with_time( response.id, response.hovered() || response.has_focus(), - 0.12, + theme::motion::HOVER, ); if hover > 0.0 { painter.rect_filled( diff --git a/crates/pstr-app/src/ui/toast.rs b/crates/pstr-app/src/ui/toast.rs index 77b93d6..12dba8b 100644 --- a/crates/pstr-app/src/ui/toast.rs +++ b/crates/pstr-app/src/ui/toast.rs @@ -17,7 +17,7 @@ const ERROR_SECONDS: f64 = 9.0; /// How many fit at once. A burst beyond that drops the oldest. const MOST: usize = 4; /// How long one takes to arrive and to leave. -const FADE: f64 = 0.18; +const FADE: f64 = theme::motion::FADE as f64; const WIDTH: f32 = 340.0; pub struct Toast { @@ -98,7 +98,7 @@ impl Toasts { let age = now - toast.at; let arriving = (age / FADE).clamp(0.0, 1.0) as f32; let leaving = ((life - age) / FADE).clamp(0.0, 1.0) as f32; - let opacity = ease(arriving.min(leaving)); + let opacity = theme::motion::ease_out(arriving.min(leaving)); let area = egui::Area::new(egui::Id::new(("toast", toast.id))) .order(egui::Order::Foreground) @@ -150,10 +150,6 @@ fn lifetime(error: bool) -> f64 { if error { ERROR_SECONDS } else { SECONDS } } -fn ease(t: f32) -> f32 { - 1.0 - (1.0 - t).powi(3) -} - /// Which of a toast's buttons was pressed. enum Pressed { Nothing, @@ -172,7 +168,7 @@ fn card(ui: &mut egui::Ui, toast: &Toast) -> Pressed { egui::Frame::new() .fill(theme::surface()) .stroke(Stroke::new(1.0, theme::card_hover())) - .corner_radius(CornerRadius::same(10)) + .corner_radius(CornerRadius::same(theme::radius::LG)) .inner_margin(egui::Margin { left: 16, right: 10, @@ -211,7 +207,7 @@ fn card(ui: &mut egui::Ui, toast: &Toast) -> Pressed { pressed = Pressed::Close; } if let Some((label, _)) = &toast.action { - ui.add_space(4.0); + ui.add_space(theme::space::XS); if ui .add( egui::Button::new( @@ -240,7 +236,8 @@ fn card(ui: &mut egui::Ui, toast: &Toast) -> Pressed { egui::pos2(frame.left() - 10.0, frame.top() - 4.0), egui::pos2(frame.left() - 7.0, frame.bottom() + 4.0), ); - ui.painter().rect_filled(strip, CornerRadius::same(2), edge); + ui.painter() + .rect_filled(strip, CornerRadius::same(theme::radius::BAR), edge); }); pressed } diff --git a/crates/pstr-app/src/ui/transport.rs b/crates/pstr-app/src/ui/transport.rs index 46a55ea..2060199 100644 --- a/crates/pstr-app/src/ui/transport.rs +++ b/crates/pstr-app/src/ui/transport.rs @@ -56,7 +56,7 @@ pub fn full( ) { seek_bar(ui, playback, actions, 6.0); times(ui, playback, true); - ui.add_space(6.0); + ui.add_space(theme::space::S); ui.horizontal(|ui| { ui.set_min_height(ROW_HEIGHT_FULL); @@ -87,7 +87,7 @@ pub fn full( }, ); - ui.add_space(12.0); + ui.add_space(theme::space::L); if playback.seeking || playback.buffering || !playback.loaded { ui.add(egui::Spinner::new().size(16.0)); ui.label(ui::muted(if playback.loaded && playback.seeking { @@ -100,11 +100,11 @@ pub fn full( // Right cluster: everything that is a choice rather than a move. ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { track_menus(ui, playback, actions); - ui.add_space(8.0); + ui.add_space(theme::space::M); speed_menu(ui, playback, actions); - ui.add_space(8.0); + ui.add_space(theme::space::M); chapter_menu(ui, playback, actions); - ui.add_space(8.0); + ui.add_space(theme::space::M); volume(ui, playback, actions); }); }); @@ -130,7 +130,7 @@ pub fn mini( Some(texture) => { let (rect, response) = ui.allocate_exact_size(MINI_PICTURE, Sense::click()); let painter = ui.painter(); - let radius = CornerRadius::same(6); + let radius = CornerRadius::same(theme::radius::SM); painter.rect_filled(rect, radius, Color32::BLACK); painter.add( egui::epaint::RectShape::filled(rect, radius, Color32::WHITE).with_texture( @@ -141,7 +141,7 @@ pub fn mini( let hover = ui.ctx().animate_bool_with_time( response.id.with("hover"), response.hovered(), - 0.12, + theme::motion::HOVER, ); if hover > 0.0 { painter.rect_filled( @@ -172,7 +172,7 @@ pub fn mini( .add( egui::Button::new(theme::Role::Label.rich("Back to video")) .fill(theme::card_hover()) - .corner_radius(CornerRadius::same(8)), + .corner_radius(CornerRadius::same(theme::radius::MD)), ) .on_hover_text("Show the picture again") .clicked() @@ -181,7 +181,7 @@ pub fn mini( } } } - ui.add_space(10.0); + ui.add_space(theme::space::L); ui.vertical(|ui| { ui.horizontal(|ui| { ui.set_min_height(ROW_HEIGHT_MINI); @@ -208,7 +208,7 @@ pub fn mini( if ui.button("Stop").clicked() { actions.push(Action::Player(Command::Stop)); } - ui.add_space(6.0); + ui.add_space(theme::space::S); step_button( ui, egui_phosphor::regular::SKIP_FORWARD, @@ -231,18 +231,18 @@ pub fn mini( }, ); - ui.add_space(8.0); + ui.add_space(theme::space::M); track_menus(ui, playback, actions); - ui.add_space(6.0); + ui.add_space(theme::space::S); volume(ui, playback, actions); if playback.seeking || playback.buffering || !playback.loaded { - ui.add_space(6.0); + ui.add_space(theme::space::S); ui.add(egui::Spinner::new().size(14.0)); } }); }); - ui.add_space(4.0); + ui.add_space(theme::space::XS); seek_bar(ui, playback, actions, 5.0); times(ui, playback, false); }); @@ -569,11 +569,11 @@ fn seek_bar(ui: &mut egui::Ui, playback: &Playback, actions: &mut Vec, t let active = response.hovered() || response.dragged(); // Grows rather than jumps when the pointer arrives. - let grow = ui - .ctx() - .animate_bool_with_time(response.id.with("grow"), active, 0.12); + let grow = + ui.ctx() + .animate_bool_with_time(response.id.with("grow"), active, theme::motion::HOVER); let track = Rect::from_center_size(rect.center(), Vec2::new(width, thickness + 3.0 * grow)); - let radius = CornerRadius::same(3); + let radius = CornerRadius::same(theme::radius::BAR); let painter = ui.painter(); // From the palette rather than a fixed grey: the same bar sits on the // video's dark scrim and on a light theme's transport strip. @@ -692,6 +692,10 @@ fn bubble(ui: &egui::Ui, anchor: egui::Pos2, text: &str) { let screen = ui.ctx().content_rect(); let left = (anchor.x - size.x / 2.0).clamp(screen.left() + 4.0, screen.right() - size.x - 4.0); let frame = Rect::from_min_size(egui::pos2(left, anchor.y - size.y), size); - painter.rect_filled(frame, CornerRadius::same(6), Color32::from_black_alpha(215)); + painter.rect_filled( + frame, + CornerRadius::same(theme::radius::SM), + Color32::from_black_alpha(215), + ); painter.galley(frame.min + Vec2::new(8.0, 4.0), galley, Color32::WHITE); } diff --git a/crates/pstr-app/src/ui/widgets.rs b/crates/pstr-app/src/ui/widgets.rs index 993f060..eb0de65 100644 --- a/crates/pstr-app/src/ui/widgets.rs +++ b/crates/pstr-app/src/ui/widgets.rs @@ -40,8 +40,12 @@ pub fn toggle(ui: &mut egui::Ui, on: &mut bool, text: &str) -> egui::Response { if ui.is_rect_visible(rect) { let ctx = ui.ctx(); - let t = ctx.animate_bool_with_time(response.id, *on, 0.14); - let hover = ctx.animate_bool_with_time(response.id.with("hover"), response.hovered(), 0.10); + let t = ctx.animate_bool_with_time(response.id, *on, theme::motion::STATE); + let hover = ctx.animate_bool_with_time( + response.id.with("hover"), + response.hovered(), + theme::motion::HOVER, + ); let track = egui::Rect::from_min_size( egui::pos2(rect.left(), rect.center().y - TRACK.y / 2.0), TRACK, @@ -96,8 +100,12 @@ pub fn watched_mark(ui: &mut egui::Ui, watched: bool) -> egui::Response { let (rect, response) = ui.allocate_exact_size(Vec2::splat(SIZE), Sense::click()); if ui.is_rect_visible(rect) { let ctx = ui.ctx(); - let t = ctx.animate_bool_with_time(response.id, watched, 0.14); - let hover = ctx.animate_bool_with_time(response.id.with("hover"), response.hovered(), 0.10); + let t = ctx.animate_bool_with_time(response.id, watched, theme::motion::STATE); + let hover = ctx.animate_bool_with_time( + response.id.with("hover"), + response.hovered(), + theme::motion::HOVER, + ); let painter = ui.painter(); let center = rect.center(); let radius = SIZE / 2.0 - 1.0; @@ -147,7 +155,7 @@ pub fn settings_group( description: Option<&str>, add: impl FnOnce(&mut egui::Ui) -> R, ) -> R { - ui.add_space(4.0); + ui.add_space(theme::space::XS); ui.label( theme::Role::Subhead .rich(heading) @@ -155,13 +163,13 @@ pub fn settings_group( .color(theme::text()), ); if let Some(description) = description { - ui.add_space(2.0); + ui.add_space(theme::space::XXS); ui.label(crate::ui::muted(description)); } - ui.add_space(8.0); + ui.add_space(theme::space::M); let inner = egui::Frame::new() .fill(theme::card()) - .corner_radius(CornerRadius::same(10)) + .corner_radius(CornerRadius::same(theme::radius::LG)) .inner_margin(egui::Margin::symmetric(16, 6)) .show(ui, |ui| { ui.set_width(ui.available_width()); @@ -169,7 +177,7 @@ pub fn settings_group( add(ui) }) .inner; - ui.add_space(26.0); + ui.add_space(theme::space::XXL); inner } @@ -192,7 +200,7 @@ pub fn settings_row( Stroke::new(1.0, theme::card_hover()), ); } - ui.add_space(10.0); + ui.add_space(theme::space::L); let inner = ui .horizontal(|ui| { ui.set_min_height(28.0); @@ -209,7 +217,7 @@ pub fn settings_row( .inner }) .inner; - ui.add_space(10.0); + ui.add_space(theme::space::L); inner } @@ -224,7 +232,7 @@ pub fn segmented( let mut picked = None; egui::Frame::new() .fill(theme::card_hover()) - .corner_radius(CornerRadius::same(8)) + .corner_radius(CornerRadius::same(theme::radius::MD)) .inner_margin(egui::Margin::same(3)) .show(ui, |ui| { ui.spacing_mut().item_spacing.x = 2.0; @@ -237,7 +245,7 @@ pub fn segmented( theme::text() }); let button = egui::Button::new(text) - .corner_radius(CornerRadius::same(6)) + .corner_radius(CornerRadius::same(theme::radius::SM)) .fill(if selected { theme::accent() } else { @@ -273,9 +281,9 @@ pub fn download_button(ui: &mut egui::Ui, glyph: DownloadGlyph) -> egui::Respons if !ui.is_rect_visible(rect) { return response; } - let hover = ui - .ctx() - .animate_bool_with_time(response.id, response.hovered(), 0.10); + let hover = + ui.ctx() + .animate_bool_with_time(response.id, response.hovered(), theme::motion::HOVER); let painter = ui.painter(); let c = rect.center(); if hover > 0.0 { @@ -352,7 +360,7 @@ pub fn download_button(ui: &mut egui::Ui, glyph: DownloadGlyph) -> egui::Respons pub fn chip(ui: &mut egui::Ui, text: &str) -> egui::Response { egui::Frame::new() .fill(theme::card_hover().gamma_multiply(0.8)) - .corner_radius(CornerRadius::same(10)) + .corner_radius(CornerRadius::same(theme::radius::LG)) .inner_margin(egui::Margin::symmetric(9, 3)) .show(ui, |ui| { ui.label(theme::Role::Caption.rich(text).color(theme::text())); diff --git a/docs/DESIGN-TOKENS.md b/docs/DESIGN-TOKENS.md index 97443f7..3ad1167 100644 --- a/docs/DESIGN-TOKENS.md +++ b/docs/DESIGN-TOKENS.md @@ -12,10 +12,11 @@ is the contract, and each side's copy is asserted against it by a test. |---|---|---|---| | Colour roles | `pstr_core::appearance::Palette` | `ui/theme/Theme.kt` `schemeOf` | `SchemeRolesTest` | | Type ramp | `pstr_app::theme::Role` | `ui/theme/Type.kt` | `TypeRampTest` | -| Corner radius | `pstr-app` `ui/mod.rs` | `ui/theme/Shape.kt` | `TypeRampTest` | +| Corner radius | `pstr_app::theme::radius` | `ui/theme/Shape.kt` | `TypeRampTest` | | Gradient rule | `Palette::resolve` | — (derived, see below) | `appearance.rs` tests | | Depth | `pstr_app::theme::{tile_shadow, bar_shadow}` | Material elevation | — | -| Motion | `Context::animate_*` | Compose defaults | — | +| Spacing | `pstr_app::theme::space` | Compose `dp` literals | — | +| Motion | `pstr_app::theme::motion` | Compose defaults | — | ## Type @@ -54,9 +55,19 @@ this module, and keep Material's sizes. Nothing in the app draws with them. ## Shape -**Radius 8** for everything either client paints: cards, buttons, the tab pill, -progress bars, dialogs. **4** for a badge over artwork. **16** for an Android -bottom sheet, which has no desktop counterpart. +**Radius 8** (`radius::MD`) for everything either client paints: cards, +buttons, the tab pill. **4** (`BADGE`) for a badge over artwork. The desktop +also has **3** (`BAR`) for progress bars and edge strips, **6** (`SM`) for a +small control inside something else, and **12** (`LG`) for what floats — row +cards, dialogs, toasts, the hero. **16** for an Android bottom sheet, which has +no desktop counterpart. + +## Spacing + +The desktop spaces things from one scale, `theme::space`: 2, 4, 6, 8, 12, 16, +24 (`XXS` to `XXL`). Use a step of the scale for a gap, not a literal. Sizes of +things — a still, a button, a column — are not spacing and stay where they are +defined. Two traps, both Android's: @@ -104,9 +115,11 @@ page is a smudge on a near-white one. Android gets Material's elevation, which already adapts. Neither uses a tint to say "raised". Transitions on the desktop go through `Context::animate_*`: wall-clock driven, -frame-rate independent, no state held by the caller. Durations in use are 0.05 s -for a press, 0.10–0.12 s for a hover, 0.16 s for the tab pill's travel and 0.22 s -for artwork arriving. Compose's defaults are close enough that nothing here +frame-rate independent, no state held by the caller. Durations come from +`theme::motion`: `PRESS` 0.05 s, `HOVER` 0.12 s, `STATE` 0.15 s (a switch, the +tab pill's travel, a page change), `FADE` 0.18 s (player chrome, toasts) and +`ARRIVE` 0.24 s (artwork over its placeholder). A hand-driven transition eases +with `motion::ease_out`. Compose's defaults are close enough that nothing here overrides them. ## Desktop widgets