diff --git a/crates/pstr-app/src/app.rs b/crates/pstr-app/src/app.rs index 26970bd..525bd8f 100644 --- a/crates/pstr-app/src/app.rs +++ b/crates/pstr-app/src/app.rs @@ -51,6 +51,7 @@ impl FrameTimer { Page::Title(_) => "title", Page::Shares => "shares", Page::Downloads => "downloads", + Page::Settings => "settings", Page::Player => "player", }, } @@ -85,6 +86,8 @@ pub enum Page { Title(String), Shares, Downloads, + /// How the app looks, how it plays, and what it looks up. + Settings, /// The picture, filling the window. Leaving this page does not stop /// playback — the transport bar at the bottom is how you get back to it. Player, @@ -896,20 +899,14 @@ impl App { ui.horizontal(|ui| { ui.add_space(4.0); ui.label( - egui::RichText::new("proton-stream") - .size(18.0) + theme::Role::Heading + .rich("proton-stream") .strong() .color(theme::accent()), ); ui.add_space(12.0); let on_library = matches!(self.page, Page::Library | Page::Title(_)); - if ui::tab(ui, on_library, "Library").clicked() { - actions.push(Action::Goto(Page::Library)); - } - if ui::tab(ui, self.page == Page::Shares, "Shares").clicked() { - actions.push(Action::Goto(Page::Shares)); - } let active = self .downloads .iter() @@ -922,13 +919,24 @@ impl App { ) }) .count(); - let label = if active == 0 { + let downloads = if active == 0 { "Downloads".to_owned() } else { format!("Downloads ({active})") }; - if ui::tab(ui, self.page == Page::Downloads, &label).clicked() { - actions.push(Action::Goto(Page::Downloads)); + let pages = [Page::Library, Page::Shares, Page::Downloads, Page::Settings]; + let clicked = ui::tabs( + ui, + ui.id().with("nav"), + &[ + ("Library", on_library), + ("Shares", self.page == Page::Shares), + (&downloads, self.page == Page::Downloads), + ("Settings", self.page == Page::Settings), + ], + ); + if let Some(index) = clicked { + actions.push(Action::Goto(pages[index].clone())); } ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { @@ -1027,7 +1035,7 @@ impl eframe::App for App { { let mut search = std::mem::take(&mut self.search); let neighbours = self.neighbours(); - let prefs = ui::shares::Prefs { + let prefs = ui::settings::Prefs { autoplay: self.engine.playback_prefs().autoplay_next, appearance: self.engine.appearance(), }; @@ -1044,6 +1052,7 @@ impl eframe::App for App { NAV_MARGIN.y as i8, ))) .show(ui, |ui| { + let shadow = ui.painter().add(egui::Shape::Noop); let background = ui.painter().add(egui::Shape::Noop); self.navigation(ui, &mut actions, &mut search); // Full width from the space the panel was given, height from @@ -1056,6 +1065,13 @@ impl eframe::App for App { .expand2(NAV_MARGIN); ui.painter() .set(background, theme::bar_shape(ui.ctx(), bar)); + // Past the panel's own edge, or the half of the shadow that + // falls on the page below it — the only half worth drawing + // — is clipped away. + ui.painter().with_clip_rect(ui.ctx().viewport_rect()).set( + shadow, + theme::bar_shadow(true).as_shape(bar, egui::CornerRadius::ZERO), + ); }); self.search = search; @@ -1091,6 +1107,22 @@ impl eframe::App for App { .inner_margin(egui::Margin::symmetric(16, 10)), ) .show(ui, |ui| { + // Above the bar rather than below it: this one is at the + // bottom of the window, so the page it separates itself + // from is the one over it. + // + // Clipped to the strip it falls on, because a `Frame` + // paints its fill *under* its contents and a shadow + // added here would otherwise lay its opaque middle over + // the bar it is cast by. + let bar = ui.max_rect().expand2(egui::vec2(16.0, 10.0)); + let above = egui::Rect::from_min_max( + egui::pos2(bar.left(), bar.top() - 16.0), + egui::pos2(bar.right(), bar.top()), + ); + ui.painter() + .with_clip_rect(above) + .add(theme::bar_shadow(false).as_shape(bar, egui::CornerRadius::ZERO)); transport_panel( ui, playback.as_ref(), @@ -1117,7 +1149,7 @@ impl eframe::App for App { } else { theme::muted() }; - ui.label(egui::RichText::new(&line.text).size(12.0).color(colour)); + ui.label(theme::Role::Caption.rich(&line.text).color(colour)); }); // Repaint once the line is due to disappear, or it lingers // until something else happens to cause a frame. @@ -1159,15 +1191,10 @@ impl eframe::App for App { }, &mut actions, ), - Page::Shares => ui::shares::show( - ui, - shares, - form, - settings, - prefs, - api_key, - &mut actions, - ), + Page::Shares => ui::shares::show(ui, shares, form, &mut actions), + Page::Settings => { + ui::settings::show(ui, settings, prefs, api_key, &mut actions) + } Page::Downloads => ui::downloads::show(ui, downloads, &mut actions), // Drawn above, without any of these panels. Page::Player => {} diff --git a/crates/pstr-app/src/theme.rs b/crates/pstr-app/src/theme.rs index b42c3d6..3d67e41 100644 --- a/crates/pstr-app/src/theme.rs +++ b/crates/pstr-app/src/theme.rs @@ -36,6 +36,66 @@ pub const CARD_WIDTH: f32 = 232.0; pub const CARD_ASPECT: f32 = 9.0 / 16.0; pub const CARD_GAP: f32 = 14.0; +/// The type ramp, named once. +/// +/// Every size in the app comes from here. They used to be literals at the call +/// site — `.size(17.0)` in one place and `.size(18.0)` in another for the same +/// kind of heading — which is not a scale, it is forty independent decisions +/// that happen to be close together. Naming them makes the ramp a thing that +/// can be changed, and gives the Android client something to match: a shared +/// palette settles colour, and this settles the other half. +/// +/// Sizes rather than full `FontId`s because everything here is proportional; +/// the one monospace style in the app is egui's own, set in [`apply`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Role { + /// The name of the thing you are looking at, once per page. + Display, + /// An empty state, or the heading of a page that has no display line. + Title, + /// The brand in the nav bar, and a dialog's heading. + Heading, + /// A run of content under a rule. + Section, + /// A row that groups the rows under it — a season header. + Subhead, + /// Everything that is prose. + Body, + /// The text on a small control. + Label, + /// Context rather than content: counts, sizes, errors under a field. + Caption, + /// The smallest thing that stays legible — a badge over artwork. + Micro, +} + +impl Role { + pub const fn size(self) -> f32 { + match self { + Self::Display => 26.0, + Self::Title => 20.0, + Self::Heading => 18.0, + Self::Section => 17.0, + Self::Subhead => 15.0, + Self::Body => 14.0, + Self::Label => 13.0, + Self::Caption => 12.0, + Self::Micro => 11.0, + } + } + + /// For the hand-painted parts, which lay text out through a `Painter` and + /// never see a `RichText`. + pub fn font(self) -> egui::FontId { + egui::FontId::proportional(self.size()) + } + + /// For the parts that go through egui's widgets. + pub fn rich(self, text: impl Into) -> egui::RichText { + egui::RichText::new(text).size(self.size()) + } +} + /// Install a real CJK fallback before the default egui fonts. egui ships a /// compact Latin font, but it intentionally does not bundle the multi-megabyte /// CJK families. Native desktops already provide one, so use it when present. @@ -210,6 +270,16 @@ pub fn danger() -> Color32 { palette().danger } +/// Whether ramps are drawn at all. +/// +/// Wider than [`gradients`], which also asks whether the *accent* has two hues +/// to run between. The viewer who turns gradients off is usually on a panel that +/// bands them into stripes, and that applies to every ramp in the app, not only +/// the accent's — so the bar fade and the title backdrop ask this one. +pub fn ramps_on() -> bool { + ACTIVE.read().gradients +} + /// Whether the accent is currently drawn as a gradient. /// /// True only when the viewer has gradients on *and* the accent is a pair of @@ -237,6 +307,29 @@ pub fn accent_fill( veil(painter, rect, corner_radius, lift); } +/// The accent as a shape rather than paint, for the one caller that has to +/// draw it *behind* something already laid out: the pill that slides between +/// the tabs in the navigation bar, whose position is only known once every tab +/// has been placed. +pub fn accent_shape( + ctx: &egui::Context, + rect: Rect, + corner_radius: impl Into, +) -> egui::Shape { + let corner_radius = corner_radius.into(); + let palette = palette(); + if !ACTIVE.read().gradients || palette.accent == palette.accent_alt { + return egui::epaint::RectShape::filled(rect, corner_radius, palette.accent).into(); + } + let texture = ramp( + ctx, + palette.accent, + palette.accent_alt, + Direction::Horizontal, + ); + textured(rect, corner_radius, &texture) +} + /// The same, for a palette that is not the one on screen: the accent swatches /// on the settings page, each of which previews the theme clicking it would /// give. @@ -265,6 +358,27 @@ fn fill(painter: &egui::Painter, rect: Rect, corner_radius: CornerRadius, palett painter.add(textured(rect, corner_radius, &texture)); } +/// A ramp from `color` to nothing, for laying artwork into the page. +/// +/// The other ramps here run between two opaque colours; this one runs down its +/// own alpha, so what is under it shows through the far end. That is what makes +/// a backdrop end in the page rather than in a horizontal line across the +/// window. +/// `arriving` is which end of `direction` the colour is solid at: `false` for a +/// scrim that starts opaque and lets the picture out, `true` for one that lets +/// the picture *into* the page — which is the one the title backdrop wants, +/// since the page is below the band. +pub fn fade_shape( + ctx: &egui::Context, + rect: Rect, + color: Color32, + direction: Direction, + arriving: bool, +) -> egui::Shape { + let texture = alpha_ramp(ctx, color, direction, arriving); + textured(rect, CornerRadius::ZERO, &texture) +} + /// The one gradient that is not the accent: the top bar, which runs from the /// surface colour down into the page so that the join between them is a fade /// rather than an edge. @@ -289,6 +403,56 @@ pub fn bar_shape(ctx: &egui::Context, rect: Rect) -> egui::Shape { textured(rect, CornerRadius::ZERO, &texture) } +/// The shadow under a raised tile, at `strength` of its full depth. +/// +/// Depth is the one separator this app has been doing without: everything is +/// told apart by fill colour, which works until two things that should be at +/// different heights are the same colour. Kept soft and offset downwards — +/// a shadow you can see the edge of reads as a border. +/// +/// Scaled rather than switched on, so it can ride the same hover value as the +/// border and arrive with it. +pub fn tile_shadow(strength: f32) -> egui::epaint::Shadow { + shadow(strength, [0, 6], 16, 90) +} + +/// The shadow a bar casts onto the page: shallower than a tile's, because a bar +/// is not floating over the page so much as sitting against it. +/// +/// `cast_down` for the bar at the top of the window, off for the transport bar +/// at the bottom — a shadow that falls the same way on both would light the +/// window from two directions at once. +pub fn bar_shadow(cast_down: bool) -> egui::epaint::Shadow { + shadow(1.0, [0, if cast_down { 4 } else { -4 }], 12, 70) +} + +fn shadow(strength: f32, offset: [i8; 2], blur: u8, alpha: u8) -> egui::epaint::Shadow { + let strength = strength.clamp(0.0, 1.0); + egui::epaint::Shadow { + offset, + blur, + spread: 0, + // A light theme cannot carry the same shadow: the same black over a + // near-white page is a smudge, where over a dark one it is barely + // visible. Half strength on light, which is roughly where the two read + // the same. + color: Color32::from_black_alpha( + (f32::from(alpha) * strength * if palette().light { 0.5 } else { 1.0 }) as u8, + ), + } +} + +/// The hover and press film on its own, for a control whose fill was painted by +/// somebody else — the selected tab, which sits on the pill. +pub fn veil_over( + painter: &egui::Painter, + rect: Rect, + corner_radius: impl Into, + lift: f32, +) { + veil(painter, rect, corner_radius.into(), lift); +} + /// A white or black film over `rect`, for hover and press states. A no-op at /// zero, which is the case that matters — most controls are at rest. fn veil(painter: &egui::Painter, rect: Rect, corner_radius: CornerRadius, lift: f32) { @@ -391,13 +555,14 @@ pub fn apply(ctx: &egui::Context, appearance: Appearance) { style.spacing.interact_size.y = 30.0; style.spacing.scroll.bar_width = 10.0; - use egui::FontFamily::Proportional; + // egui's own style names, pointed at the ramp above so that a stock + // widget nobody restyled still lands on it. use egui::TextStyle::{Body, Button, Heading, Monospace, Small}; style.text_styles = [ - (Heading, egui::FontId::new(26.0, Proportional)), - (Body, egui::FontId::new(14.0, Proportional)), - (Button, egui::FontId::new(14.0, Proportional)), - (Small, egui::FontId::new(11.5, Proportional)), + (Heading, Role::Display.font()), + (Body, Role::Body.font()), + (Button, Role::Body.font()), + (Small, Role::Caption.font()), ( Monospace, egui::FontId::new(12.5, egui::FontFamily::Monospace), @@ -430,7 +595,10 @@ struct Active { /// flavour the window is not wearing are eight ramps that belong to no /// palette in particular. Bounded by the number of flavours times the /// number of accents, and only the ones actually drawn. - ramps: BTreeMap<(u32, u32, bool), TextureHandle>, + /// The last field separates a ramp between two colours from one that runs + /// down its own alpha — the two would otherwise collide on + /// `(colour, transparent)`. + ramps: BTreeMap<(u32, u32, bool, u8), TextureHandle>, } static ACTIVE: RwLock = RwLock::new(Active { @@ -441,17 +609,54 @@ static ACTIVE: RwLock = RwLock::new(Active { /// A ramp between two colours, built once and kept. fn ramp(ctx: &egui::Context, from: Color32, to: Color32, direction: Direction) -> TextureHandle { + build(ctx, from, to, direction, 0, mix) +} + +/// A ramp from `color` down to nothing. +/// +/// The alpha is what is interpolated; the colour stays put. Fading the colour +/// as well would take a dark scrim through grey on its way out, which over +/// artwork reads as fog rather than as the picture ending. +fn alpha_ramp( + ctx: &egui::Context, + color: Color32, + direction: Direction, + arriving: bool, +) -> TextureHandle { + build( + ctx, + color, + Color32::TRANSPARENT, + direction, + 1 + u8::from(arriving), + move |from, _, t| from.gamma_multiply(if arriving { t } else { 1.0 - t }), + ) +} + +/// One texture per ramp, built on demand and kept until the palette changes. +/// +/// `kind` says which of the three kinds this is, so that a fade and a colour +/// ramp between the same two ends do not come out sharing one texture. +fn build( + ctx: &egui::Context, + from: Color32, + to: Color32, + direction: Direction, + kind: u8, + stop: impl Fn(Color32, Color32, f32) -> Color32, +) -> TextureHandle { let key = ( u32::from_le_bytes(from.to_array()), u32::from_le_bytes(to.to_array()), direction == Direction::Vertical, + kind, ); if let Some(texture) = ACTIVE.read().ramps.get(&key) { return texture.clone(); } let pixels: Vec = (0..RAMP_STOPS) - .map(|stop| mix(from, to, stop as f32 / (RAMP_STOPS - 1) as f32)) + .map(|index| stop(from, to, index as f32 / (RAMP_STOPS - 1) as f32)) .collect(); let size = match direction { Direction::Horizontal => [RAMP_STOPS, 1], diff --git a/crates/pstr-app/src/ui/downloads.rs b/crates/pstr-app/src/ui/downloads.rs index 39e01e8..c14b837 100644 --- a/crates/pstr-app/src/ui/downloads.rs +++ b/crates/pstr-app/src/ui/downloads.rs @@ -32,7 +32,7 @@ pub fn show(ui: &mut egui::Ui, downloads: &[DownloadItem], actions: &mut Vec 0); ui.horizontal(|ui| { if totals_known { @@ -145,7 +145,11 @@ fn row(ui: &mut egui::Ui, item: &DownloadItem, actions: &mut Vec) { } } DownloadState::Completed => { - if ui.button("Make online-only").clicked() { + if ui + .button("Delete") + .on_hover_text("Delete the offline copy; keep the online source") + .clicked() + { actions.push(Action::RemoveDownload(item.key.clone(), false)); } } diff --git a/crates/pstr-app/src/ui/library.rs b/crates/pstr-app/src/ui/library.rs index 72e0de5..6aee37e 100644 --- a/crates/pstr-app/src/ui/library.rs +++ b/crates/pstr-app/src/ui/library.rs @@ -93,6 +93,9 @@ fn continue_row( subtitle: remaining, progress: episode.progress().map(|value| value as f32), badge: episode.numbering(), + // A shelf that scrolls sideways has no right edge + // to reach, so nothing to flex to. + width: theme::CARD_WIDTH, }, ) .clicked(); @@ -107,9 +110,14 @@ fn continue_row( /// Every title, wrapped to the window. fn grid(ui: &mut egui::Ui, art: &mut Art<'_>, titles: &[&Title], actions: &mut Vec) { - let columns = ui::columns(ui.available_width()); - for row in titles.chunks(columns) { + let grid = ui::columns(ui.available_width()); + for row in titles.chunks(grid.columns) { ui.horizontal(|ui| { + // The gap the width was divided around, so what is drawn matches + // what was measured. egui's default item spacing is narrower, and + // the difference times the column count is a visible drift towards + // the left edge. + ui.spacing_mut().item_spacing.x = theme::CARD_GAP; for title in row { let clicked = ui::card( ui, @@ -121,6 +129,7 @@ fn grid(ui: &mut egui::Ui, art: &mut Art<'_>, titles: &[&Title], actions: &mut V // What a film is says itself in the subtitle below the // card; a second `Film` over the poster is noise. badge: None, + width: grid.width, }, ) .clicked(); @@ -163,8 +172,8 @@ fn empty_state(ui: &mut egui::Ui, actions: &mut Vec) { ui.vertical_centered(|ui| { ui.add_space(120.0); ui.label( - egui::RichText::new("Nothing in the library yet") - .size(20.0) + theme::Role::Title + .rich("Nothing in the library yet") .strong(), ); ui.add_space(6.0); diff --git a/crates/pstr-app/src/ui/matcher.rs b/crates/pstr-app/src/ui/matcher.rs index 1c1f52a..2518a1e 100644 --- a/crates/pstr-app/src/ui/matcher.rs +++ b/crates/pstr-app/src/ui/matcher.rs @@ -49,8 +49,8 @@ pub fn show( ui.set_width(620.0); ui.label( - egui::RichText::new(format!("Match “{}”", matcher.title_name)) - .size(18.0) + theme::Role::Heading + .rich(format!("Match “{}”", matcher.title_name)) .strong() .color(theme::text()), ); @@ -66,7 +66,7 @@ pub fn show( ui.add_space(10.0); if let Some(error) = &matcher.error { - ui.label(egui::RichText::new(error).size(12.0).color(theme::danger())); + ui.label(theme::Role::Caption.rich(error).color(theme::danger())); ui.add_space(6.0); } @@ -212,8 +212,8 @@ fn row(ui: &mut egui::Ui, art: &mut Art<'_>, found: &TitleMetadata) -> egui::Res ui.add_space(10.0); ui.vertical(|ui| { ui.label( - egui::RichText::new(&found.name) - .size(14.0) + theme::Role::Body + .rich(&found.name) .strong() .color(theme::text()), ); @@ -224,7 +224,7 @@ fn row(ui: &mut egui::Ui, art: &mut Art<'_>, found: &TitleMetadata) -> egui::Res if let Some(overview) = &found.overview { let mut job = egui::text::LayoutJob::simple( overview.replace('\n', " "), - egui::FontId::proportional(11.5), + theme::Role::Caption.font(), theme::muted(), ui.available_width(), ); diff --git a/crates/pstr-app/src/ui/mod.rs b/crates/pstr-app/src/ui/mod.rs index 01b3845..e4a9ab8 100644 --- a/crates/pstr-app/src/ui/mod.rs +++ b/crates/pstr-app/src/ui/mod.rs @@ -7,6 +7,7 @@ pub mod downloads; pub mod library; pub mod matcher; pub mod player; +pub mod settings; pub mod shares; pub mod title; pub mod transport; @@ -114,8 +115,8 @@ pub fn format_size(bytes: i64) -> String { pub fn section(ui: &mut egui::Ui, text: &str) { ui.add_space(6.0); ui.label( - egui::RichText::new(text) - .size(17.0) + theme::Role::Section + .rich(text) .strong() .color(theme::text()), ); @@ -124,18 +125,81 @@ pub fn section(ui: &mut egui::Ui, text: &str) { /// Small grey text, for everything that is context rather than content. pub fn muted(text: impl Into) -> egui::RichText { - egui::RichText::new(text).size(12.0).color(theme::muted()) + theme::Role::Caption.rich(text).color(theme::muted()) } /// The one button style that means "this is the action". pub fn accent_button(ui: &mut egui::Ui, text: &str) -> egui::Response { - filled(ui, text, true, Vec2::new(0.0, 0.0)) + filled(ui, text, Fill::Accent, Vec2::new(0.0, 0.0)) } -/// A tab in the top bar. The selected one wears the accent; the others are text -/// until the pointer is over them. -pub fn tab(ui: &mut egui::Ui, selected: bool, text: &str) -> egui::Response { - filled(ui, text, selected, Vec2::new(4.0, 0.0)) +/// What [`filled`] paints behind its label. +#[derive(Clone, Copy, PartialEq, Eq)] +enum Fill { + /// The accent, gradient and all. + Accent, + /// Nothing — something else already painted the accent here, and the label + /// only has to be legible on it. + Ink, + /// Nothing at rest, the hover colour under the pointer. + None, +} + +/// A row of tabs with one accent pill that slides between them. +/// +/// Drawn as a row rather than a tab at a time because the pill has to be +/// painted *behind* labels whose positions are not known until they have been +/// laid out, and because a pill that moves has to be one pill: a per-tab fill +/// can only ever cross-fade, which reads as two things blinking rather than one +/// thing travelling. +/// +/// Returns the index that was clicked, if any. +pub fn tabs(ui: &mut egui::Ui, id: egui::Id, items: &[(&str, bool)]) -> Option { + // Reserved now, filled in below: everything drawn after this lands on top + // of it whatever it turns out to be. + let pill = ui.painter().add(egui::Shape::Noop); + + let mut clicked = None; + let mut selected = None; + for (index, (text, is_selected)) in items.iter().enumerate() { + let response = tab(ui, *is_selected, text); + if *is_selected { + selected = Some(response.rect); + } + if response.clicked() { + clicked = Some(index); + } + } + + if let Some(target) = selected { + // Interpolating the edges rather than the centre and the width keeps + // 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 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)), + ); + } + + clicked +} + +/// One tab. The selected one is ink on the pill [`tabs`] draws; the others are +/// text until the pointer is over them. +fn tab(ui: &mut egui::Ui, selected: bool, text: &str) -> egui::Response { + filled( + ui, + text, + if selected { Fill::Ink } else { Fill::None }, + Vec2::new(4.0, 0.0), + ) } /// A button painted by hand, so that "filled with the accent" can mean a @@ -144,7 +208,7 @@ pub fn tab(ui: &mut egui::Ui, selected: bool, text: &str) -> egui::Response { /// `egui::Button` takes a single `Color32` and there is no way in to give it /// anything else, so the fill, the label and the hover states are all drawn /// here. Everything else — sizing, padding, the click — is still egui's. -fn filled(ui: &mut egui::Ui, text: &str, accented: bool, extra: Vec2) -> egui::Response { +fn filled(ui: &mut egui::Ui, text: &str, fill: Fill, extra: Vec2) -> egui::Response { let padding = ui.spacing().button_padding + extra; let galley = ui.painter().layout_no_wrap( text.to_owned(), @@ -162,23 +226,35 @@ fn filled(ui: &mut egui::Ui, text: &str, accented: bool, extra: Vec2) -> egui::R } let radius = CornerRadius::same(8); - let lift = if response.is_pointer_button_down_on() { - -0.14 - } else if response.hovered() { - 0.10 - } else { - 0.0 - }; + // 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 press = ctx.animate_bool_with_time( + response.id.with("press"), + response.is_pointer_button_down_on(), + 0.05, + ); + let lift = 0.10 * hover * (1.0 - press) - 0.14 * press; - let ink = if accented { - theme::accent_fill(ui.painter(), rect, radius, lift); - theme::on_accent() - } else { - if response.hovered() { - ui.painter().rect_filled(rect, radius, theme::card_hover()); - theme::text() - } else { - theme::muted() + let ink = match fill { + Fill::Accent => { + theme::accent_fill(ui.painter(), rect, radius, lift); + theme::on_accent() + } + // The pill is already under this one; only the veil is missing. + Fill::Ink => { + theme::veil_over(ui.painter(), rect, radius, lift); + theme::on_accent() + } + Fill::None => { + if hover > 0.0 { + ui.painter() + .rect_filled(rect, radius, theme::card_hover().gamma_multiply(hover)); + } + theme::muted().lerp_to_gamma(theme::text(), hover) } }; @@ -197,6 +273,12 @@ pub struct Card<'a> { pub progress: Option, /// A short label in the corner — `S01E04`. pub badge: Option, + /// How wide to draw it. + /// + /// A grid flexes this so its rows reach the right edge of the window; a + /// sideways-scrolling shelf has no edge to reach and passes + /// [`theme::CARD_WIDTH`]. + pub width: f32, } /// Draw one tile and report whether it was clicked. @@ -208,7 +290,7 @@ pub struct Card<'a> { /// the title, usually. The gap it leaves is filled with the card colour, which /// reads as deliberate in a way a stretched poster does not. pub fn card(ui: &mut egui::Ui, card: Card<'_>) -> egui::Response { - let width = theme::CARD_WIDTH; + let width = card.width; let image_height = (width * theme::CARD_ASPECT).round(); // Two lines of name plus one of subtitle. Fixed, because the tile is // allocated before the name is laid out — and because a grid whose rows are @@ -223,16 +305,46 @@ pub fn card(ui: &mut egui::Ui, card: Card<'_>) -> egui::Response { } let image_rect = Rect::from_min_size(rect.min, Vec2::new(width, image_height)); - let hovered = response.hovered(); + // Hover is a value, not a flag: the border, the lift and the shadow all + // ride it, so they arrive together rather than each snapping on its own. + // egui drives this from wall-clock time, so it is the same speed whatever + // the frame rate — and it needs no state kept here. + let hover = + ui.ctx() + .animate_bool_with_time(response.id.with("hover"), response.hovered(), 0.12); + // 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 painter = ui.painter(); + if hover > 0.0 { + painter.add(theme::tile_shadow(hover).as_shape(image_rect, radius)); + } painter.rect_filled(image_rect, radius, theme::card()); + // Under the picture, not instead of it: it is what shows through while the + // art fades in, and it is the whole tile when there is no art to come. + if art_in < 1.0 { + painter.text( + image_rect.center(), + Align2::CENTER_CENTER, + initials(card.name), + egui::FontId::proportional(28.0), + theme::muted().gamma_multiply(1.0 - art_in), + ); + } + + // White because a textured rect *multiplies* its fill by the texture; the + // alpha on it is what fades the picture in. + let ink = Color32::WHITE.gamma_multiply(art_in); match &card.art { Some((texture, ArtShape::Landscape)) => { let size = texture.size_vec2(); painter.add( - egui::epaint::RectShape::filled(image_rect, radius, Color32::WHITE) + egui::epaint::RectShape::filled(image_rect, radius, ink) .with_texture(texture.id(), cover_uv(size, image_rect.size())), ); } @@ -241,7 +353,7 @@ pub fn card(ui: &mut egui::Ui, card: Card<'_>) -> egui::Response { egui::epaint::RectShape::filled( contain_rect(texture.size_vec2(), image_rect), radius, - Color32::WHITE, + ink, ) .with_texture( texture.id(), @@ -249,24 +361,13 @@ pub fn card(ui: &mut egui::Ui, card: Card<'_>) -> egui::Response { ), ); } - None => { - painter.text( - image_rect.center(), - Align2::CENTER_CENTER, - initials(card.name), - egui::FontId::proportional(28.0), - theme::muted(), - ); - } + None => {} } if let Some(badge) = &card.badge { let anchor = image_rect.right_top() + Vec2::new(-8.0, 8.0); - let galley = painter.layout_no_wrap( - badge.clone(), - egui::FontId::proportional(11.0), - Color32::WHITE, - ); + let galley = + painter.layout_no_wrap(badge.clone(), theme::Role::Micro.font(), Color32::WHITE); let background = Rect::from_min_size( anchor - Vec2::new(galley.size().x + 10.0, 0.0), galley.size() + Vec2::new(10.0, 4.0), @@ -314,11 +415,11 @@ pub fn card(ui: &mut egui::Ui, card: Card<'_>) -> egui::Response { theme::accent_fill(&clipped_painter, played, played_radius, 0.0); } - if hovered { + if hover > 0.0 { painter.rect_stroke( image_rect, radius, - Stroke::new(2.0, theme::accent()), + Stroke::new(2.0, theme::accent().gamma_multiply(hover)), egui::StrokeKind::Inside, ); } @@ -333,12 +434,10 @@ pub fn card(ui: &mut egui::Ui, card: Card<'_>) -> egui::Response { // the line below it. let mut job = egui::text::LayoutJob::simple( card.name.to_string(), - egui::FontId::proportional(14.0), - if hovered { - Color32::WHITE - } else { - theme::text() - }, + theme::Role::Body.font(), + // Towards white as the pointer arrives, so the name lifts with the + // border rather than staying put while everything around it moves. + theme::text().lerp_to_gamma(Color32::WHITE, hover), width, ); job.wrap.max_rows = 2; @@ -350,7 +449,7 @@ pub fn card(ui: &mut egui::Ui, card: Card<'_>) -> egui::Response { text_rect.min + Vec2::new(0.0, name_height + 2.0), Align2::LEFT_TOP, card.subtitle, - egui::FontId::proportional(11.5), + theme::Role::Caption.font(), theme::muted(), ); @@ -396,10 +495,33 @@ fn initials(name: &str) -> String { .collect() } -/// How many cards fit, and the gap that leaves them evenly spread. -pub fn columns(available: f32) -> usize { - (((available + theme::CARD_GAP) / (theme::CARD_WIDTH + theme::CARD_GAP)).floor() as usize) - .max(1) +/// How a grid divides the width it was given. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Grid { + pub columns: usize, + /// What each card should be drawn at, so the row reaches both edges. + pub width: f32, +} + +/// Fit as many cards as will go, then widen them to use the rest. +/// +/// [`theme::CARD_WIDTH`] is a *minimum*, not the width. Dividing by it and +/// flooring — which is what this used to do — leaves up to one whole card of +/// dead space at the right of every row, and on a wide window that reads as a +/// layout that failed to line up rather than as a margin. +pub fn columns(available: f32) -> Grid { + let columns = (((available + theme::CARD_GAP) / (theme::CARD_WIDTH + theme::CARD_GAP)).floor() + as usize) + .max(1); + // The gaps come out of the width before it is split. Floored, because a + // fraction of a point times four columns is enough to push the last card + // onto a row of its own. A window too narrow for even one full card gets a + // card narrower than the minimum, which is better than one clipped by the + // edge. + let width = ((available - theme::CARD_GAP * (columns - 1) as f32) / columns as f32) + .floor() + .max(1.0); + Grid { columns, width } } #[cfg(test)] @@ -454,7 +576,31 @@ mod tests { #[test] fn column_count_never_reaches_zero() { - assert_eq!(columns(0.0), 1); - assert!(columns(2000.0) > 1); + assert_eq!(columns(0.0).columns, 1); + assert!(columns(2000.0).columns > 1); + } + + #[test] + fn a_row_of_cards_fills_the_width_it_was_given() { + // The defect this replaced: floor(available / CARD_WIDTH) cards at a + // fixed width leave up to one card's worth of the window empty. + for available in [400.0, 813.0, 1280.0, 1920.5, 3440.0] { + let grid = columns(available); + let used = + grid.width * grid.columns as f32 + theme::CARD_GAP * (grid.columns - 1) as f32; + assert!(used <= available, "{available}: {used} overflows the row"); + assert!( + available - used < grid.columns as f32 + 1.0, + "{available}: {} left over", + available - used, + ); + } + } + + #[test] + fn cards_never_go_below_the_minimum_while_they_still_fit() { + for available in [500.0, 900.0, 2400.0] { + assert!(columns(available).width >= theme::CARD_WIDTH); + } } } diff --git a/crates/pstr-app/src/ui/player.rs b/crates/pstr-app/src/ui/player.rs index bd89284..fd12e50 100644 --- a/crates/pstr-app/src/ui/player.rs +++ b/crates/pstr-app/src/ui/player.rs @@ -287,7 +287,7 @@ fn waiting(ui: &mut egui::Ui, playback: &Playback, rect: Rect) { rect.center() - Vec2::new(0.0, 18.0), Align2::CENTER_CENTER, &playback.target.title_name, - egui::FontId::proportional(20.0), + theme::Role::Title.font(), theme::text(), ); painter.text( @@ -298,7 +298,7 @@ fn waiting(ui: &mut egui::Ui, playback: &Playback, rect: Rect) { } else { "playing in mpv's own window" }, - egui::FontId::proportional(13.0), + theme::Role::Label.font(), theme::muted(), ); } @@ -313,14 +313,14 @@ fn between(ui: &mut egui::Ui, opening: Option<&str>, rect: Rect, actions: &mut V rect.center() - Vec2::new(0.0, 16.0), Align2::CENTER_CENTER, "Up next", - egui::FontId::proportional(14.0), + theme::Role::Body.font(), theme::muted(), ); ui.painter().text( rect.center() + Vec2::new(0.0, 12.0), Align2::CENTER_CENTER, opening.unwrap_or("opening…"), - egui::FontId::proportional(20.0), + theme::Role::Title.font(), theme::text(), ); @@ -361,7 +361,7 @@ fn skip( // recap" are different lengths, and one constant leaves the shorter of them // adrift in a box of its own whitespace, which is what made this read as a // rectangle with some text in it instead of a button. - let font = egui::FontId::proportional(14.0); + let font = theme::Role::Body.font(); let text_width = ui .painter() .layout_no_wrap(label.to_owned(), font.clone(), Color32::WHITE) @@ -440,8 +440,8 @@ fn up_next_card( ui.set_width(size.x - 28.0); ui.horizontal(|ui| { ui.label( - egui::RichText::new("Up next") - .size(12.0) + theme::Role::Caption + .rich("Up next") .strong() .color(Color32::from_white_alpha(200)), ); @@ -449,16 +449,16 @@ fn up_next_card( // Rounded up, so a card that says "1s" is never // followed by a second of nothing happening. ui.label( - egui::RichText::new(format!("in {}s", card.seconds.ceil() as u32)) - .size(12.0) + theme::Role::Caption + .rich(format!("in {}s", card.seconds.ceil() as u32)) .color(theme::accent()), ); }); }); ui.add( egui::Label::new( - egui::RichText::new(&card.caption) - .size(14.0) + theme::Role::Body + .rich(&card.caption) .strong() .color(Color32::WHITE), ) @@ -533,7 +533,7 @@ fn chrome( |ui| { if ui .add( - egui::Button::new(egui::RichText::new("Library").size(13.0)) + egui::Button::new(theme::Role::Label.rich("Library")) .fill(Color32::from_black_alpha(160)) .corner_radius(CornerRadius::same(8)) .min_size(Vec2::new(96.0, 32.0)), @@ -546,14 +546,14 @@ fn chrome( ui.add_space(14.0); ui.vertical(|ui| { ui.label( - egui::RichText::new(&playback.target.title_name) - .size(18.0) + theme::Role::Heading + .rich(&playback.target.title_name) .strong() .color(Color32::WHITE), ); ui.label( - egui::RichText::new(playback.target.caption()) - .size(13.0) + theme::Role::Label + .rich(playback.target.caption()) .color(Color32::from_white_alpha(190)), ); }); @@ -561,7 +561,7 @@ fn chrome( ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { if ui .add( - egui::Button::new(egui::RichText::new("⛶").size(15.0)) + egui::Button::new(theme::Role::Subhead.rich("⛶")) .fill(Color32::from_black_alpha(160)) .corner_radius(CornerRadius::same(8)) .min_size(Vec2::new(38.0, 32.0)), diff --git a/crates/pstr-app/src/ui/settings.rs b/crates/pstr-app/src/ui/settings.rs new file mode 100644 index 0000000..479e0ec --- /dev/null +++ b/crates/pstr-app/src/ui/settings.rs @@ -0,0 +1,269 @@ +//! Settings: how the app looks, how it plays, and what it asks the internet. +//! +//! Split out of the shares page, where these lived because that was the only +//! page with a form on it. Nobody looking for the theme looks under the page +//! that manages Proton Drive links, and a page that is half link management and +//! half preferences is two pages. + +use pstr_core::appearance::{Accent, Appearance, Flavor}; +use pstr_core::metadata::{MetadataConfig, ProviderId}; + +use crate::app::Action; +use crate::theme; +use crate::ui; + +/// The settings that are a single value rather than a form. +/// +/// Bundled for the same reason [`crate::ui::Art`] is: they arrive together, +/// they are read-only, and passing them one by one is what pushed this +/// function past the argument count anybody can read. +#[derive(Debug, Clone, Copy)] +pub struct Prefs { + pub autoplay: bool, + pub appearance: Appearance, +} + +pub fn show( + ui: &mut egui::Ui, + settings: &mut MetadataConfig, + prefs: Prefs, + api_key: &mut String, + actions: &mut Vec, +) { + egui::ScrollArea::vertical() + .auto_shrink([false, false]) + .show(ui, |ui| { + appearance_form(ui, prefs.appearance, actions); + + ui.add_space(28.0); + playback_form(ui, prefs.autoplay, actions); + + ui.add_space(28.0); + metadata_form(ui, settings, api_key, actions); + }); +} + +/// The theme: a flavour, an accent, and whether the accent is a gradient. +/// +/// Every control here repaints the whole window the moment it is clicked — +/// there is no "apply" — because the preview *is* the app. A swatch row that +/// 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::section(ui, "Appearance"); + + egui::Frame::new() + .fill(theme::card()) + .corner_radius(egui::CornerRadius::same(8)) + .inner_margin(egui::Margin::same(14)) + .show(ui, |ui| { + ui.set_width(ui.available_width().min(640.0)); + + ui.label(ui::muted("Palette")); + for flavor in Flavor::ALL { + if ui + .radio(appearance.flavor == flavor, flavor.label()) + .on_hover_text(flavor.description()) + .clicked() + && appearance.flavor != flavor + { + actions.push(Action::SetAppearance(Appearance { + flavor, + ..appearance + })); + } + } + + ui.add_space(12.0); + ui.label(ui::muted("Accent")); + ui.horizontal_wrapped(|ui| { + for accent in Accent::ALL { + if swatch(ui, appearance, accent).clicked() && appearance.accent != accent { + actions.push(Action::SetAppearance(Appearance { + accent, + ..appearance + })); + } + } + }); + + ui.add_space(12.0); + let mut gradients = appearance.gradients; + if ui + .checkbox(&mut gradients, "Fade the accent into a second colour") + .on_hover_text( + "Off draws it flat — better on a panel that bands a gradient into stripes", + ) + .changed() + { + actions.push(Action::SetAppearance(Appearance { + gradients, + ..appearance + })); + } + }); +} + +/// One accent, drawn in the colours it would actually produce. +/// +/// Resolved against the *selected* flavour rather than the active palette, so +/// the row previews what clicking it would give rather than what is on screen. +fn swatch(ui: &mut egui::Ui, appearance: Appearance, accent: Accent) -> egui::Response { + let selected = appearance.accent == accent; + let size = egui::Vec2::new(52.0, 26.0); + let (rect, response) = ui.allocate_exact_size(size, egui::Sense::click()); + if ui.is_rect_visible(rect) { + let palette = theme::Palette::resolve(Appearance { + accent, + ..appearance + }); + let radius = egui::CornerRadius::same(6); + theme::swatch_fill(ui.painter(), rect, radius, &palette); + if selected { + // A ring rather than a tick: the tick would have to be drawn in an + // ink that reads on eight different fills. + ui.painter().rect_stroke( + rect.expand(2.0), + egui::CornerRadius::same(8), + egui::Stroke::new(2.0, theme::text()), + egui::StrokeKind::Outside, + ); + } + } + response + .on_hover_text(accent.label()) + .on_hover_cursor(egui::CursorIcon::PointingHand) +} + +/// The handful of playback settings that are not per-file. +/// +/// Volume, audio language and subtitle language are all set from the player +/// itself, where a viewer can hear the result — only autoplay has no natural +/// home there, because by the time it matters the episode is over. +fn playback_form(ui: &mut egui::Ui, autoplay: bool, actions: &mut Vec) { + ui::section(ui, "Playback"); + + egui::Frame::new() + .fill(theme::card()) + .corner_radius(egui::CornerRadius::same(8)) + .inner_margin(egui::Margin::same(14)) + .show(ui, |ui| { + ui.set_width(ui.available_width().min(640.0)); + + let mut enabled = autoplay; + if ui + .checkbox(&mut enabled, "Play the next episode automatically") + .on_hover_text("Only when an episode reaches its end, never after a failure") + .changed() + { + actions.push(Action::SetAutoplay(enabled)); + } + ui.label(ui::muted( + "Volume, audio language and subtitles are set from the player, and remembered \ + from there.", + )); + }); +} + +/// Enrichment: the switch, the provider, and the cost of turning it on. +/// +/// The privacy note is not decoration and it is not in a tooltip. Turning this +/// on sends the titles in someone's library to a third party, which is a thing +/// they can only agree to if they are told — so it is stated where the switch +/// is, before the switch, in the same size as everything else. +fn metadata_form( + ui: &mut egui::Ui, + settings: &mut MetadataConfig, + api_key: &mut String, + actions: &mut Vec, +) { + ui::section(ui, "Posters and descriptions"); + + egui::Frame::new() + .fill(theme::card()) + .corner_radius(egui::CornerRadius::same(8)) + .inner_margin(egui::Margin::same(14)) + .show(ui, |ui| { + ui.set_width(ui.available_width().min(640.0)); + + ui.label(ui::muted( + "A share filled by the Proton Drive desktop client carries no thumbnails, so \ + without this every tile is a pair of initials. Turning it on sends the titles \ + 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); + + let mut changed = false; + let mut enabled = settings.enabled; + if ui + .checkbox(&mut enabled, "Look up posters and descriptions") + .changed() + { + settings.enabled = enabled; + changed = true; + } + + if settings.enabled { + ui.add_space(10.0); + ui.label(ui::muted("Provider")); + for provider in ProviderId::ALL { + if ui + .radio(settings.provider == provider, provider.label()) + .on_hover_text(provider.description()) + .clicked() + && settings.provider != provider + { + settings.provider = provider; + changed = true; + } + } + + if settings.provider.needs_api_key() { + ui.add_space(10.0); + ui.label(ui::muted(format!("{} API key", settings.provider.label()))); + ui.horizontal(|ui| { + ui.add( + egui::TextEdit::singleline(api_key) + .password(true) + .hint_text("v3 API key") + .desired_width(300.0), + ); + if ui.button("Save key").clicked() { + actions.push(Action::SetApiKey { + provider: settings.provider, + key: std::mem::take(api_key), + }); + } + }); + ui.label(ui::muted( + "Stored in your system keyring, never in a config file. Leave the box \ + empty and save to forget it.", + )); + } + + ui.add_space(12.0); + ui.horizontal(|ui| { + if ui::accent_button(ui, "Match the library").clicked() { + actions.push(Action::MatchTitles { force: false }); + } + if ui + .button("Match everything again") + .on_hover_text("Ask about every title, including ones already matched") + .clicked() + { + actions.push(Action::MatchTitles { force: true }); + } + }); + } else { + ui.add_space(6.0); + ui.label(ui::muted( + "Off. Nothing is sent anywhere, and turning it off also deletes the answers \ + already stored.", + )); + } + + if changed { + actions.push(Action::SetMetadataConfig(settings.clone())); + } + }); +} diff --git a/crates/pstr-app/src/ui/shares.rs b/crates/pstr-app/src/ui/shares.rs index 617a89c..b1610de 100644 --- a/crates/pstr-app/src/ui/shares.rs +++ b/crates/pstr-app/src/ui/shares.rs @@ -7,33 +7,12 @@ //! typed. use pstr_core::Share; -use pstr_core::appearance::{Accent, Appearance, Flavor}; -use pstr_core::metadata::{MetadataConfig, ProviderId}; use crate::app::{Action, ShareForm}; use crate::theme; use crate::ui; -/// The settings on this page that are a single value rather than a form. -/// -/// Bundled for the same reason [`crate::ui::Art`] is: they arrive together, -/// they are read-only, and passing them one by one is what pushed this -/// function past the argument count anybody can read. -#[derive(Debug, Clone, Copy)] -pub struct Prefs { - pub autoplay: bool, - pub appearance: Appearance, -} - -pub fn show( - ui: &mut egui::Ui, - shares: &[Share], - form: &mut ShareForm, - settings: &mut MetadataConfig, - prefs: Prefs, - api_key: &mut String, - actions: &mut Vec, -) { +pub fn show(ui: &mut egui::Ui, shares: &[Share], form: &mut ShareForm, actions: &mut Vec) { egui::ScrollArea::vertical() .auto_shrink([false, false]) .show(ui, |ui| { @@ -47,240 +26,6 @@ pub fn show( ui.add_space(22.0); add_form(ui, form, actions); - - ui.add_space(28.0); - playback_form(ui, prefs.autoplay, actions); - - ui.add_space(28.0); - appearance_form(ui, prefs.appearance, actions); - - ui.add_space(28.0); - metadata_form(ui, settings, api_key, actions); - }); -} - -/// The theme: a flavour, an accent, and whether the accent is a gradient. -/// -/// Every control here repaints the whole window the moment it is clicked — -/// there is no "apply" — because the preview *is* the app. A swatch row that -/// 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::section(ui, "Appearance"); - - egui::Frame::new() - .fill(theme::card()) - .corner_radius(egui::CornerRadius::same(8)) - .inner_margin(egui::Margin::same(14)) - .show(ui, |ui| { - ui.set_width(ui.available_width().min(640.0)); - - ui.label(ui::muted("Palette")); - for flavor in Flavor::ALL { - if ui - .radio(appearance.flavor == flavor, flavor.label()) - .on_hover_text(flavor.description()) - .clicked() - && appearance.flavor != flavor - { - actions.push(Action::SetAppearance(Appearance { - flavor, - ..appearance - })); - } - } - - ui.add_space(12.0); - ui.label(ui::muted("Accent")); - ui.horizontal_wrapped(|ui| { - for accent in Accent::ALL { - if swatch(ui, appearance, accent).clicked() && appearance.accent != accent { - actions.push(Action::SetAppearance(Appearance { - accent, - ..appearance - })); - } - } - }); - - ui.add_space(12.0); - let mut gradients = appearance.gradients; - if ui - .checkbox(&mut gradients, "Fade the accent into a second colour") - .on_hover_text( - "Off draws it flat — better on a panel that bands a gradient into stripes", - ) - .changed() - { - actions.push(Action::SetAppearance(Appearance { - gradients, - ..appearance - })); - } - }); -} - -/// One accent, drawn in the colours it would actually produce. -/// -/// Resolved against the *selected* flavour rather than the active palette, so -/// the row previews what clicking it would give rather than what is on screen. -fn swatch(ui: &mut egui::Ui, appearance: Appearance, accent: Accent) -> egui::Response { - let selected = appearance.accent == accent; - let size = egui::Vec2::new(52.0, 26.0); - let (rect, response) = ui.allocate_exact_size(size, egui::Sense::click()); - if ui.is_rect_visible(rect) { - let palette = theme::Palette::resolve(Appearance { - accent, - ..appearance - }); - let radius = egui::CornerRadius::same(6); - theme::swatch_fill(ui.painter(), rect, radius, &palette); - if selected { - // A ring rather than a tick: the tick would have to be drawn in an - // ink that reads on eight different fills. - ui.painter().rect_stroke( - rect.expand(2.0), - egui::CornerRadius::same(8), - egui::Stroke::new(2.0, theme::text()), - egui::StrokeKind::Outside, - ); - } - } - response - .on_hover_text(accent.label()) - .on_hover_cursor(egui::CursorIcon::PointingHand) -} - -/// Enrichment: the switch, the provider, and the cost of turning it on. -/// -/// The privacy note is not decoration and it is not in a tooltip. Turning this -/// on sends the titles in someone's library to a third party, which is a thing -/// they can only agree to if they are told — so it is stated where the switch -/// is, before the switch, in the same size as everything else. -/// The handful of playback settings that are not per-file. -/// -/// Volume, audio language and subtitle language are all set from the player -/// itself, where a viewer can hear the result — only autoplay has no natural -/// home there, because by the time it matters the episode is over. -fn playback_form(ui: &mut egui::Ui, autoplay: bool, actions: &mut Vec) { - ui::section(ui, "Playback"); - - egui::Frame::new() - .fill(theme::card()) - .corner_radius(egui::CornerRadius::same(8)) - .inner_margin(egui::Margin::same(14)) - .show(ui, |ui| { - ui.set_width(ui.available_width().min(640.0)); - - let mut enabled = autoplay; - if ui - .checkbox(&mut enabled, "Play the next episode automatically") - .on_hover_text("Only when an episode reaches its end, never after a failure") - .changed() - { - actions.push(Action::SetAutoplay(enabled)); - } - ui.label(ui::muted( - "Volume, audio language and subtitles are set from the player, and remembered \ - from there.", - )); - }); -} - -fn metadata_form( - ui: &mut egui::Ui, - settings: &mut MetadataConfig, - api_key: &mut String, - actions: &mut Vec, -) { - ui::section(ui, "Posters and descriptions"); - - egui::Frame::new() - .fill(theme::card()) - .corner_radius(egui::CornerRadius::same(8)) - .inner_margin(egui::Margin::same(14)) - .show(ui, |ui| { - ui.set_width(ui.available_width().min(640.0)); - - ui.label(ui::muted( - "A share filled by the Proton Drive desktop client carries no thumbnails, so \ - without this every tile is a pair of initials. Turning it on sends the titles \ - 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); - - let mut changed = false; - let mut enabled = settings.enabled; - if ui - .checkbox(&mut enabled, "Look up posters and descriptions") - .changed() - { - settings.enabled = enabled; - changed = true; - } - - if settings.enabled { - ui.add_space(10.0); - ui.label(ui::muted("Provider")); - for provider in ProviderId::ALL { - if ui - .radio(settings.provider == provider, provider.label()) - .on_hover_text(provider.description()) - .clicked() - && settings.provider != provider - { - settings.provider = provider; - changed = true; - } - } - - if settings.provider.needs_api_key() { - ui.add_space(10.0); - ui.label(ui::muted(format!("{} API key", settings.provider.label()))); - ui.horizontal(|ui| { - ui.add( - egui::TextEdit::singleline(api_key) - .password(true) - .hint_text("v3 API key") - .desired_width(300.0), - ); - if ui.button("Save key").clicked() { - actions.push(Action::SetApiKey { - provider: settings.provider, - key: std::mem::take(api_key), - }); - } - }); - ui.label(ui::muted( - "Stored in your system keyring, never in a config file. Leave the box \ - empty and save to forget it.", - )); - } - - ui.add_space(12.0); - ui.horizontal(|ui| { - if ui::accent_button(ui, "Match the library").clicked() { - actions.push(Action::MatchTitles { force: false }); - } - if ui - .button("Match everything again") - .on_hover_text("Ask about every title, including ones already matched") - .clicked() - { - actions.push(Action::MatchTitles { force: true }); - } - }); - } else { - ui.add_space(6.0); - ui.label(ui::muted( - "Off. Nothing is sent anywhere, and turning it off also deletes the answers \ - already stored.", - )); - } - - if changed { - actions.push(Action::SetMetadataConfig(settings.clone())); - } }); } diff --git a/crates/pstr-app/src/ui/title.rs b/crates/pstr-app/src/ui/title.rs index 13dc51a..4580aba 100644 --- a/crates/pstr-app/src/ui/title.rs +++ b/crates/pstr-app/src/ui/title.rs @@ -44,13 +44,13 @@ pub fn show( for (index, season) in title.seasons.iter().enumerate() { let open = index == 0 || title.seasons.len() == 1; egui::CollapsingHeader::new( - egui::RichText::new(format!( - "{} · {}", - season.label(), - ui::library::plural(season.episodes.len(), "episode") - )) - .size(15.0) - .strong(), + theme::Role::Subhead + .rich(format!( + "{} · {}", + season.label(), + ui::library::plural(season.episodes.len(), "episode") + )) + .strong(), ) .id_salt(("season", index)) .default_open(open) @@ -58,13 +58,17 @@ pub fn show( let season_keys: Vec<_> = season.episodes.iter().map(key_of).collect(); let all_offline = season_keys.iter().all(|key| offline.files.contains(key)); if all_offline { - if ui.button("Make season online-only").clicked() { + if ui + .button("Delete season") + .on_hover_text("Delete the offline copies; keep the online source") + .clicked() + { for key in season_keys { actions.push(Action::RemoveDownload(key, false)); } } } else if ui - .button("Make season offline") + .button("Download season") .on_hover_text("Download every episode in this season") .clicked() { @@ -98,22 +102,34 @@ fn header( let record = art.metadata.get(&title.key); let found = record.and_then(|record| record.metadata.clone()); let by_hand = record.is_some_and(|record| record.manual); + // Asked for once and used twice: the same picture is the poster beside the + // text and the banner behind all of it. + let picture = art.of(title); + // Reserved before the header is laid out, because the band is as tall as + // whatever the header comes to — a fixed height would clip a long overview + // on one title and leave a gap under a short one on the next. + let backdrop = picture + .as_ref() + .map(|(texture, _)| (texture.clone(), ui.painter().add(egui::Shape::Noop))); + + ui.add_space(6.0); ui.horizontal_top(|ui| { ui::card( ui, Card { - art: art.of(title), + art: picture, name: &title.name, subtitle: String::new(), progress: title.resume().and_then(|e| e.progress()).map(|v| v as f32), badge: None, + width: theme::CARD_WIDTH, }, ); ui.add_space(18.0); ui.vertical(|ui| { - ui.label(egui::RichText::new(&title.name).size(26.0).strong()); + ui.label(theme::Role::Display.rich(&title.name).strong()); // The provider's name for it, when it is not the one the files use. // Worth showing rather than replacing the filename's: a viewer // should be able to tell what the match actually matched. @@ -147,13 +163,17 @@ fn header( let title_keys: Vec<_> = title.episodes().map(key_of).collect(); let all_offline = title_keys.iter().all(|key| offline.files.contains(key)); if all_offline { - if ui.button("Make show online-only").clicked() { + if ui + .button("Delete show") + .on_hover_text("Delete the offline copies; keep the online source") + .clicked() + { for key in title_keys { actions.push(Action::RemoveDownload(key, false)); } } } else if ui - .button("Make show offline") + .button("Download show") .on_hover_text("Download every episode for disconnected playback") .clicked() { @@ -220,6 +240,101 @@ fn header( } }); }); + + if let Some((texture, index)) = backdrop { + ui.add_space(10.0); + band(ui, &texture, index); + } +} + +/// The picture again, across the whole width, behind the header. +/// +/// The one piece of this page that had to wait for artwork to exist at all: the +/// providers already hand back a picture and its shape, so a backdrop costs no +/// new request — only somewhere to put it. +/// +/// Three shapes over one another, in this order: the picture, a flat veil of the +/// page colour so text laid over it stays readable whatever the still happens to +/// be, and a fade into the page along the bottom so the band ends rather than +/// stops. +fn band(ui: &mut egui::Ui, texture: &egui::TextureHandle, index: egui::layers::ShapeIdx) { + // Full width of the window, not of the content: the panel's own margin is + // what the band has to reach past, or it reads as a picture in a box. + const BLEED: f32 = 18.0; + let content = ui.min_rect(); + let rect = egui::Rect::from_min_max( + egui::pos2(ui.max_rect().left() - BLEED, content.top()), + egui::pos2(ui.max_rect().right() + BLEED, content.bottom()), + ); + + // Past the panel margin, or the bleed is trimmed off at exactly the edge it + // exists to cross. + let painter = ui.painter().with_clip_rect(rect); + // Every layer of the band goes into the one slot reserved before the header + // was laid out. Painting the veils here, after the fact, would put them over + // the name and the overview as well as the still — which is the picture + // being readable at the cost of the text it exists behind. + let mut layers: Vec = vec![ + egui::epaint::RectShape::filled(rect, egui::CornerRadius::ZERO, egui::Color32::WHITE) + .with_texture( + texture.id(), + super::cover_uv(texture.size_vec2(), rect.size()), + ) + .into(), + ]; + // Heavier than it looks: a still is arbitrary, and the one it lands on may + // be a white frame. This is the difference between a hero and an unreadable + // page. + // + // Two coats rather than one heavy one. The first is flat and near-opaque — + // enough on its own that body text over the busiest frame still reads. The + // second is the picture's own contrast being knocked back further behind the + // left half, where the poster, the name and the overview actually sit; the + // right half keeps more of the still, so the band is still a picture. + // + // Heavier still when there is no fade coming, since then these are the only + // things standing between the text and the picture. + let ramps = theme::ramps_on(); + layers.push( + egui::epaint::RectShape::filled( + rect, + egui::CornerRadius::ZERO, + theme::background().gamma_multiply(if ramps { 0.90 } else { 0.94 }), + ) + .into(), + ); + if ramps { + let text_side = egui::Rect::from_min_max( + rect.min, + egui::pos2(rect.left() + rect.width() * 0.72, rect.bottom()), + ); + layers.push(theme::fade_shape( + ui.ctx(), + text_side, + theme::background(), + theme::Direction::Horizontal, + // Solid at the left, where the poster and the text are. + false, + )); + } + + // A viewer with ramps off is on a panel that bands them; a 120-point ramp + // of one hue is exactly the case that shows it. The band then ends at its + // own edge, which is the honest version of the same thing. + if ramps { + let fade = + egui::Rect::from_min_max(egui::pos2(rect.left(), rect.bottom() - 120.0), rect.max); + layers.push(theme::fade_shape( + ui.ctx(), + fade, + theme::background(), + theme::Direction::Vertical, + // Solid at the bottom, where the page it hands over to is. + true, + )); + } + + painter.set(index, egui::Shape::Vec(layers)); } /// What the provider had to say. Only ever drawn under a match. @@ -229,14 +344,7 @@ fn description(ui: &mut egui::Ui, found: &TitleMetadata) { ui.add_space(6.0); } if let Some(overview) = &found.overview { - ui.add( - egui::Label::new( - egui::RichText::new(overview) - .size(13.0) - .color(theme::text()), - ) - .wrap(), - ); + ui.add(egui::Label::new(theme::Role::Label.rich(overview).color(theme::text())).wrap()); } if let Some(url) = &found.url { ui.add_space(8.0); @@ -324,10 +432,7 @@ fn episode_row( // carries a checkbox, and a click that means "seen" must never // be one that starts a 1.4 GiB stream instead. if ui - .add( - egui::Button::new(egui::RichText::new("▶").size(13.0)) - .fill(theme::card_hover()), - ) + .add(egui::Button::new(theme::Role::Label.rich("▶")).fill(theme::card_hover())) .on_hover_text("Play") .clicked() { @@ -337,7 +442,7 @@ fn episode_row( let current = offline.downloads.iter().find(|item| item.key == key); if offline.files.contains(&key) { if ui - .small_button("Online-only") + .small_button("Delete") .on_hover_text("Delete the offline copy; keep the online source") .clicked() { @@ -443,11 +548,7 @@ fn episode_row( // saying so on the row rather than only when a click on // it comes back with "has no content". Some(0) => { - ui.label( - egui::RichText::new("empty") - .size(12.0) - .color(theme::danger()), - ) + ui.label(theme::Role::Caption.rich("empty").color(theme::danger())) .on_hover_text( "The share reports no content for this file; it cannot be played.", ); @@ -459,8 +560,8 @@ fn episode_row( } if let Some(at) = episode.resume_at() { ui.label( - egui::RichText::new(format!("resume {}", ui::format_time(at))) - .size(12.0) + theme::Role::Caption + .rich(format!("resume {}", ui::format_time(at))) .color(theme::accent()), ); } diff --git a/crates/pstr-app/src/ui/transport.rs b/crates/pstr-app/src/ui/transport.rs index fcb6e76..ba0e5f7 100644 --- a/crates/pstr-app/src/ui/transport.rs +++ b/crates/pstr-app/src/ui/transport.rs @@ -116,7 +116,7 @@ pub fn mini( // one-way trip: playback carries on with nowhere to watch it. if ui .add( - egui::Button::new(egui::RichText::new("Back to video").size(13.0)) + egui::Button::new(theme::Role::Label.rich("Back to video")) .fill(theme::card_hover()) .corner_radius(CornerRadius::same(8)), ) @@ -132,12 +132,8 @@ pub fn mini( // is the only thing that still knows which title this came from. if ui .add( - egui::Label::new( - egui::RichText::new(&playback.target.title_name) - .size(14.0) - .strong(), - ) - .sense(Sense::click()), + egui::Label::new(theme::Role::Body.rich(&playback.target.title_name).strong()) + .sense(Sense::click()), ) .on_hover_text("Show this title") .clicked() @@ -287,7 +283,7 @@ fn step_button( ) { let response = ui.add_enabled( enabled, - egui::Button::new(egui::RichText::new(label).size(15.0)).min_size(Vec2::new(38.0, 30.0)), + egui::Button::new(theme::Role::Subhead.rich(label)).min_size(Vec2::new(38.0, 30.0)), ); if response.on_hover_text(hover).clicked() { push(actions);