mirror of
https://github.com/narrrl/proton-stream.git
synced 2026-11-04 00:47:00 +01:00
feat(app): design tokens, settings page, backdrop layering
Type/shape/motion tokens live in theme.rs and every ui/ module reads them instead of scattering literal sizes and radii. Appearance and playback preferences move out of the shares page into their own Settings page and nav tab. The title backdrop's veil and fades were painted after the header was laid out, so they covered the name and overview as well as the still — the text was being dimmed by the thing meant to make it readable. All band layers now go into the shape slot reserved before the header, behind the text.
This commit is contained in:
1 parent
46bb68e3a5
commit
cb03f96eca
11 files changed
+915
-413
No files matched your search
+49
-22
@@ -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 => {}
|
||||
|
||||
@@ -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<String>) -> 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<CornerRadius>,
|
||||
) -> 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<CornerRadius>,
|
||||
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<Active> = RwLock::new(Active {
|
||||
@@ -441,17 +609,54 @@ static ACTIVE: RwLock<Active> = 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<Color32> = (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],
|
||||
|
||||
@@ -32,7 +32,7 @@ pub fn show(ui: &mut egui::Ui, downloads: &[DownloadItem], actions: &mut Vec<Act
|
||||
.filter(|item| item.state == DownloadState::Completed)
|
||||
.count();
|
||||
|
||||
ui.label(egui::RichText::new(title).size(17.0).strong());
|
||||
ui.label(theme::Role::Section.rich(title).strong());
|
||||
let totals_known = group.iter().all(|item| item.total > 0);
|
||||
ui.horizontal(|ui| {
|
||||
if totals_known {
|
||||
@@ -145,7 +145,11 @@ fn row(ui: &mut egui::Ui, item: &DownloadItem, actions: &mut Vec<Action>) {
|
||||
}
|
||||
}
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Action>) {
|
||||
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<Action>) {
|
||||
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);
|
||||
|
||||
@@ -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(),
|
||||
);
|
||||
|
||||
+204
-58
@@ -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<String>) -> 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<usize> {
|
||||
// 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<f32>,
|
||||
/// A short label in the corner — `S01E04`.
|
||||
pub badge: Option<String>,
|
||||
/// 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)),
|
||||
|
||||
@@ -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<Action>,
|
||||
) {
|
||||
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<Action>) {
|
||||
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<Action>) {
|
||||
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<Action>,
|
||||
) {
|
||||
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()));
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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<Action>,
|
||||
) {
|
||||
pub fn show(ui: &mut egui::Ui, shares: &[Share], form: &mut ShareForm, actions: &mut Vec<Action>) {
|
||||
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<Action>) {
|
||||
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<Action>) {
|
||||
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<Action>,
|
||||
) {
|
||||
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()));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
+134
-33
@@ -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<egui::Shape> = 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()),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user