refactor(app): spacing, radius and motion tokens

theme::space, theme::radius and theme::motion name the steps the UI is
drawn with. Literal gaps, corner radii and animation durations across ui/
now come from them, which folds a few near-misses onto the scale (10 and 14
point gaps onto 12, 10 point radii onto 12, 0.10 s hovers onto 0.12 s).
Page fades and toasts share one ease_out curve.
This commit is contained in:
nvrl committed 2026-10-02 23:10:39 +02:00
1 parent 2128a2d705
commit 85092fa893
16 files changed
+274 -177

No files matched your search

@@ -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. */
+6 -6
View File
@@ -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<Page>, 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<Page>, 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,
+54 -1
View File
@@ -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);
+6 -6
View File
@@ -20,7 +20,7 @@ pub fn show(ui: &mut egui::Ui, downloads: &[DownloadItem], actions: &mut Vec<Act
.show(ui, |ui| {
ui.set_max_width(960.0);
summary(ui, downloads);
ui.add_space(12.0);
ui.add_space(theme::space::L);
let mut start = 0;
while start < downloads.len() {
@@ -30,7 +30,7 @@ pub fn show(ui: &mut egui::Ui, downloads: &[DownloadItem], actions: &mut Vec<Act
.position(|item| item.target.title_key != *title_key)
.map_or(downloads.len(), |offset| start + offset);
group(ui, &downloads[start..end], actions);
ui.add_space(14.0);
ui.add_space(theme::space::L);
start = end;
}
});
@@ -108,7 +108,7 @@ fn group(ui: &mut egui::Ui, group: &[DownloadItem], actions: &mut Vec<Action>) {
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<Action>) {
fn row(ui: &mut egui::Ui, item: &DownloadItem, actions: &mut Vec<Action>) {
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<Action>) {
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<Action>) {
});
});
});
ui.add_space(4.0);
ui.add_space(theme::space::XS);
}
/// What a row says about itself, and in what colour.
+22 -17
View File
@@ -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<A
if !ui.is_rect_visible(rect) {
return;
}
let radius = egui::CornerRadius::same(12);
let radius = egui::CornerRadius::same(theme::radius::LG);
let picture = art.of(title);
let found = art
.metadata
@@ -152,9 +152,11 @@ fn hero(ui: &mut egui::Ui, art: &mut Art<'_>, title: &Title, actions: &mut Vec<A
let painter = ui.painter().with_clip_rect(rect);
painter.rect_filled(rect, radius, theme::card());
let art_in = ui
.ctx()
.animate_bool_with_time(response.id.with("art"), picture.is_some(), 0.3);
let art_in = ui.ctx().animate_bool_with_time(
response.id.with("art"),
picture.is_some(),
theme::motion::ARRIVE,
);
if art_in < 1.0 {
ui::placeholder(&painter, rect, "", 1.0 - art_in);
}
@@ -220,13 +222,13 @@ fn hero(ui: &mut egui::Ui, art: &mut Art<'_>, title: &Title, actions: &mut Vec<A
if ui::accent_button(ui, &label).clicked() {
actions.push(Action::Play(PlaybackTarget::new(title, episode)));
}
ui.add_space(6.0);
ui.add_space(theme::space::S);
}
if ui.button("More info").clicked() {
actions.push(Action::Goto(Page::Title(title.key.clone())));
}
});
ui.add_space(14.0);
ui.add_space(theme::space::L);
if let Some(overview) = found.as_ref().and_then(|found| found.overview.as_deref()) {
let mut job = egui::text::LayoutJob::simple(
overview.to_owned(),
@@ -236,7 +238,7 @@ fn hero(ui: &mut egui::Ui, art: &mut Art<'_>, title: &Title, actions: &mut Vec<A
);
job.wrap.max_rows = 3;
ui.label(job);
ui.add_space(8.0);
ui.add_space(theme::space::M);
}
let mut facts = vec![subtitle(title)];
if let Some(found) = &found {
@@ -246,7 +248,7 @@ fn hero(ui: &mut egui::Ui, art: &mut Art<'_>, title: &Title, actions: &mut Vec<A
facts.extend(found.genres.iter().take(3).cloned());
}
ui.label(ui::muted(facts.join(" · ")));
ui.add_space(2.0);
ui.add_space(theme::space::XXS);
ui.add(
egui::Label::new(
theme::Role::Display
@@ -290,7 +292,7 @@ fn shelf_controls(ui: &mut egui::Ui, view: &LibraryView, actions: &mut Vec<Actio
if let Some(Some(sort)) = sort.inner {
actions.push(Action::SetShelf(view.filter, sort));
}
ui.add_space(10.0);
ui.add_space(theme::space::L);
if let Some(filter) = ui::widgets::segmented(
ui,
view.filter,
@@ -527,8 +529,11 @@ fn placeholder(ui: &mut egui::Ui) {
rect.min,
egui::vec2(grid.width, (grid.width * theme::CARD_ASPECT).round()),
);
ui.painter()
.rect_filled(image, egui::CornerRadius::same(8), theme::card());
ui.painter().rect_filled(
image,
egui::CornerRadius::same(theme::radius::MD),
theme::card(),
);
}
});
ui.add_space(theme::CARD_GAP);
@@ -543,11 +548,11 @@ fn empty_state(ui: &mut egui::Ui, actions: &mut Vec<Action>) {
.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));
}
+11 -11
View File
@@ -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,
);
+29 -21
View File
@@ -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<u
// the pill from overshooting when it moves between tabs of different
// lengths — both edges arrive at the same time.
let ctx = ui.ctx();
let left = ctx.animate_value_with_time(id.with("left"), target.left(), 0.16);
let right = ctx.animate_value_with_time(id.with("right"), target.right(), 0.16);
let left =
ctx.animate_value_with_time(id.with("left"), target.left(), theme::motion::STATE);
let right =
ctx.animate_value_with_time(id.with("right"), target.right(), theme::motion::STATE);
let rect = Rect::from_min_max(
egui::pos2(left, target.top()),
egui::pos2(right, target.bottom()),
);
ui.painter().set(
pill,
theme::accent_shape(ui.ctx(), rect, CornerRadius::same(8)),
theme::accent_shape(ui.ctx(), rect, CornerRadius::same(theme::radius::MD)),
);
}
@@ -431,17 +433,21 @@ fn filled(ui: &mut egui::Ui, text: &str, fill: Fill, extra: Vec2) -> 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,
+10 -10
View File
@@ -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 {
+26 -17
View File
@@ -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<Action>) {
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<Action>,
) {
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);
});
}
+10 -10
View File
@@ -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<Action>) {
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<Action>) {
@@ -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
+3 -3
View File
@@ -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])
+21 -21
View File
@@ -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<f64>) {
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(
+6 -9
View File
@@ -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
}
+23 -19
View File
@@ -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<Action>, 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);
}
+25 -17
View File
@@ -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<R>(
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<R>(
.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<R>(
add(ui)
})
.inner;
ui.add_space(26.0);
ui.add_space(theme::space::XXL);
inner
}
@@ -192,7 +200,7 @@ pub fn settings_row<R>(
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<R>(
.inner
})
.inner;
ui.add_space(10.0);
ui.add_space(theme::space::L);
inner
}
@@ -224,7 +232,7 @@ pub fn segmented<T: Copy + PartialEq>(
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<T: Copy + PartialEq>(
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()));
+21 -8
View File
@@ -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