bug fixes, windows build

This commit is contained in:
nvrl committed 2026-08-03 09:09:40 +02:00
1 parent a1b345a029
commit 14c48428a3
29 files changed
+2514 -170

No files matched your search

+181 -22
View File
@@ -9,6 +9,7 @@ use std::collections::HashMap;
use std::sync::mpsc::Receiver;
use pstr_core::Share;
use pstr_core::appearance::Appearance;
use pstr_core::config::AppDirs;
use pstr_core::library::Library;
use pstr_core::metadata::{EpisodeGuide, MetadataConfig, MetadataRecord};
@@ -128,10 +129,21 @@ pub enum Action {
MatchTitles {
force: bool,
},
/// Open the hand-matching search for one title, seeded with its own name.
OpenMatcher(String),
CloseMatcher,
/// Ask the provider what the text in the box might be.
SearchMatches,
/// Pin the open title to this entry.
ChooseMatch(Box<pstr_core::metadata::TitleMetadata>),
/// Forget what is stored for a title, so it is matched from scratch again.
ForgetMatch(String),
/// Play the file before or after the one playing, within its title.
PlayAdjacent(Adjacent),
/// Start or stop the next episode playing on its own at the end of one.
SetAutoplay(bool),
/// Repaint the window in a different palette.
SetAppearance(Appearance),
/// In or out of fullscreen, from the player page.
ToggleFullscreen,
/// Stop watching, keep playing: back to the page this film came from, with
@@ -178,6 +190,52 @@ pub struct Status {
pub at: f64,
}
/// Picking a title's entry by hand.
///
/// The escape hatch from the matcher, which is deliberately unwilling to guess:
/// [`pstr_meta::matching::MATCH_FLOOR`] is set where a wrong poster is worse
/// than none, and the cost of that is a handful of titles that match nothing.
/// The `Fate/stay night [Heaven's Feel]` films are the standing example — three
/// films in one folder, filed by AniList as three separate entries, so there is
/// no answer for the scorer to find and only the viewer knows which one the
/// folder means.
pub struct Matcher {
/// The title being matched, by [`pstr_core::library::Title::key`].
pub title_key: String,
/// Its name as the library has it, for the dialog's own heading.
pub title_name: String,
pub kind: pstr_core::library::TitleKind,
/// What is in the search box. Seeded with the library's name for the title,
/// which is the thing that failed — so it is the right starting point to
/// edit rather than to re-send.
pub query: String,
pub results: Vec<pstr_core::metadata::TitleMetadata>,
/// A request is out. The box stays usable; only the button is held.
pub searching: bool,
/// Whether a search has come back yet, so an empty list can read as "nothing
/// found" rather than "nothing asked".
pub asked: bool,
pub error: Option<String>,
/// Set for the frame the dialog opens, to put the caret in the box.
pub focus: bool,
}
impl Matcher {
pub fn new(title: &pstr_core::library::Title) -> Self {
Self {
title_key: title.key.clone(),
title_name: title.name.clone(),
kind: title.kind,
query: title.name.clone(),
results: Vec::new(),
searching: false,
asked: false,
error: None,
focus: true,
}
}
}
/// The share the viewer is typing in.
#[derive(Default)]
pub struct ShareForm {
@@ -206,6 +264,8 @@ pub struct App {
/// it goes straight to the credential store on save.
pub api_key: String,
pub matching: bool,
/// The hand-matching dialog, while it is open.
pub matcher: Option<Matcher>,
pub search: String,
pub form: ShareForm,
pub playback: Option<Playback>,
@@ -229,9 +289,11 @@ impl App {
runtime: std::sync::Arc<tokio::runtime::Runtime>,
dirs: AppDirs,
) -> anyhow::Result<Self> {
theme::apply(&cc.egui_ctx);
// After the engine, not before: the engine is what reads the stored
// theme, and a window that paints one frame in the default palette
// before switching is a window that flashes on every launch.
let (engine, events) = Engine::new(runtime, dirs, cc.egui_ctx.clone())?;
theme::apply(&cc.egui_ctx, engine.appearance());
// Paint from the catalog immediately; the network catches up. A library
// that was crawled yesterday is on screen before the shares open.
@@ -254,6 +316,7 @@ impl App {
settings,
api_key: String::new(),
matching: false,
matcher: None,
search: String::new(),
form: ShareForm::default(),
playback: None,
@@ -345,6 +408,28 @@ impl App {
self.note(ctx, text, failed > 0);
}
Event::MatchFinished => self.matching = false,
// Both of these are addressed to a dialog, and the viewer may
// have closed it or opened another one while the request was
// out — so an answer for a title that is not the open one is
// dropped rather than shown under the wrong heading.
Event::MatchOptions { title_key, options } => {
if let Some(matcher) = &mut self.matcher
&& matcher.title_key == title_key
{
matcher.results = options;
matcher.searching = false;
matcher.asked = true;
matcher.error = None;
}
}
Event::MatchSearchFailed { title_key, error } => {
if let Some(matcher) = &mut self.matcher
&& matcher.title_key == title_key
{
matcher.searching = false;
matcher.error = Some(error);
}
}
Event::PlaybackReady { target, stream } => {
self.opening = None;
// Dropped before the new one is built: two mpv cores would
@@ -605,6 +690,43 @@ impl App {
self.engine.match_titles(self.library.titles.clone(), force);
}
}
Action::OpenMatcher(key) => {
self.matcher = self.library.get(&key).map(Matcher::new);
// The library's own name is what the automatic search already
// failed on, so the first search is the viewer's to press —
// except that pressing it unchanged is exactly what someone
// wants when the *scorer* was the problem rather than the name.
// So it is sent, and the box stays theirs to edit.
if self.matcher.is_some() {
self.apply(ctx, Action::SearchMatches);
}
}
Action::CloseMatcher => self.matcher = None,
Action::SearchMatches => {
if let Some(matcher) = &mut self.matcher {
matcher.searching = true;
matcher.error = None;
self.engine.search_matches(
matcher.title_key.clone(),
matcher.query.clone(),
matcher.kind,
);
}
}
Action::ChooseMatch(found) => {
if let Some(title) = self
.matcher
.as_ref()
.and_then(|matcher| self.library.get(&matcher.title_key))
{
self.engine.choose_match(title.clone(), *found);
}
self.matcher = None;
}
Action::ForgetMatch(key) => {
self.engine.forget_match(key);
self.matcher = None;
}
Action::ToggleFullscreen => {
self.fullscreen = !self.fullscreen;
ctx.send_viewport_cmd(egui::ViewportCommand::Fullscreen(self.fullscreen));
@@ -679,6 +801,10 @@ impl App {
prefs.autoplay_next = autoplay;
self.engine.set_playback_prefs(prefs, true);
}
Action::SetAppearance(appearance) => {
self.engine.set_appearance(appearance);
theme::apply(ctx, appearance);
}
Action::SetVolume { volume, commit } => self.set_volume(volume, commit),
Action::ToggleMute => {
let mut prefs = self.engine.playback_prefs();
@@ -734,18 +860,15 @@ impl App {
egui::RichText::new("proton-stream")
.size(18.0)
.strong()
.color(theme::ACCENT),
.color(theme::accent()),
);
ui.add_space(12.0);
let on_library = matches!(self.page, Page::Library | Page::Title(_));
if ui.selectable_label(on_library, "Library").clicked() {
if ui::tab(ui, on_library, "Library").clicked() {
actions.push(Action::Goto(Page::Library));
}
if ui
.selectable_label(self.page == Page::Shares, "Shares")
.clicked()
{
if ui::tab(ui, self.page == Page::Shares, "Shares").clicked() {
actions.push(Action::Goto(Page::Shares));
}
@@ -845,17 +968,35 @@ impl eframe::App for App {
{
let mut search = std::mem::take(&mut self.search);
let neighbours = self.neighbours();
let autoplay = self.engine.playback_prefs().autoplay_next;
let prefs = ui::shares::Prefs {
autoplay: self.engine.playback_prefs().autoplay_next,
appearance: self.engine.appearance(),
};
const NAV_MARGIN: egui::Vec2 = egui::vec2(14.0, 10.0);
egui::Panel::top("nav")
.resizable(false)
.frame(
egui::Frame::new()
.fill(theme::SURFACE)
.inner_margin(egui::Margin::symmetric(14, 10)),
)
// No fill: the bar is a gradient, and a `Frame` only takes a
// colour. It is painted below, into a shape reserved before the
// contents are laid out — which is the only point at which the
// height they came to is known.
.frame(egui::Frame::NONE.inner_margin(egui::Margin::symmetric(
NAV_MARGIN.x as i8,
NAV_MARGIN.y as i8,
)))
.show(ui, |ui| {
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
// the space its contents took.
let content = ui.min_rect();
let bar = egui::Rect::from_min_max(
egui::pos2(ui.max_rect().left(), content.top()),
egui::pos2(ui.max_rect().right(), content.bottom()),
)
.expand2(NAV_MARGIN);
ui.painter()
.set(background, theme::bar_shape(ui.ctx(), bar));
});
self.search = search;
@@ -872,6 +1013,7 @@ impl eframe::App for App {
api_key,
search,
form,
matcher,
playback,
opening,
status,
@@ -883,7 +1025,7 @@ impl eframe::App for App {
.resizable(false)
.frame(
egui::Frame::new()
.fill(theme::SURFACE)
.fill(theme::surface())
.inner_margin(egui::Margin::symmetric(16, 10)),
)
.show(ui, |ui| {
@@ -904,14 +1046,14 @@ impl eframe::App for App {
.resizable(false)
.frame(
egui::Frame::new()
.fill(theme::BACKGROUND)
.fill(theme::background())
.inner_margin(egui::Margin::symmetric(16, 6)),
)
.show(ui, |ui| {
let colour = if line.error {
theme::DANGER
theme::danger()
} else {
theme::MUTED
theme::muted()
};
ui.label(egui::RichText::new(&line.text).size(12.0).color(colour));
});
@@ -926,14 +1068,17 @@ impl eframe::App for App {
egui::CentralPanel::default()
.frame(
egui::Frame::new()
.fill(theme::BACKGROUND)
.fill(theme::background())
.inner_margin(egui::Margin::symmetric(18, 12)),
)
.show(ui, |ui| {
// Reborrowed rather than moved: the matching dialog below is
// drawn from the same caches, and a moved `&mut` would leave
// nothing to draw it with.
let mut art = ui::Art {
engine,
thumbs,
posters,
thumbs: &mut *thumbs,
posters: &mut *posters,
metadata,
episodes,
};
@@ -949,7 +1094,7 @@ impl eframe::App for App {
shares,
form,
settings,
autoplay,
prefs,
api_key,
&mut actions,
),
@@ -957,6 +1102,20 @@ impl eframe::App for App {
Page::Player => {}
}
});
// Over everything, and after it: a modal takes the input the page
// under it would otherwise get, and it can only do that for widgets
// laid out after it claims the layer.
if let Some(open) = matcher {
let mut art = ui::Art {
engine,
thumbs,
posters,
metadata,
episodes,
};
ui::matcher::show(ctx, open, &mut art, settings.provider, &mut actions);
}
}
for action in actions {
+144 -2
View File
@@ -23,9 +23,10 @@ use std::sync::Arc;
use std::sync::mpsc::{Receiver, Sender, channel};
use parking_lot::Mutex;
use pstr_core::appearance::Appearance;
use pstr_core::catalog::{Catalog, CatalogNode, WatchState, build_rows};
use pstr_core::config::AppDirs;
use pstr_core::library::{Library, Title};
use pstr_core::library::{Library, Title, TitleKind};
use pstr_core::metadata::{EpisodeGuide, MetadataConfig, MetadataRecord, ProviderId};
use pstr_core::prefs::PlaybackPrefs;
use pstr_core::proton_drive_rs::ThumbnailType;
@@ -85,6 +86,16 @@ pub enum Event {
},
/// Every requested match is done.
MatchFinished,
/// What the provider answered a hand search with, unscored and in its own
/// order. `title_key` says which title asked, because the viewer can close
/// the search and open another one while a request is still out.
MatchOptions {
title_key: String,
options: Vec<pstr_core::metadata::TitleMetadata>,
},
/// A hand search did not get an answer. Distinct from an empty one: nothing
/// found is a result, and a rate limit is not.
MatchSearchFailed { title_key: String, error: String },
/// A decoded piece of provider artwork, keyed by title key.
Poster {
key: String,
@@ -158,6 +169,9 @@ pub struct Engine {
/// because a new player is built from them, and the player is started from
/// a background event.
prefs: Arc<Mutex<PlaybackPrefs>>,
/// Which theme the window wears. Kept beside the playback preferences for
/// the same reason: it is read from the UI and written to disk off it.
appearance: Arc<Mutex<Appearance>>,
events: Sender<Event>,
ctx: egui::Context,
}
@@ -221,6 +235,14 @@ impl Engine {
PlaybackPrefs::default()
});
// And the same again for the theme, which is the least load-bearing
// file of the three: an unreadable one costs the viewer their colours
// until they pick them again.
let appearance = pstr_core::appearance::load(&dirs).unwrap_or_else(|error| {
tracing::warn!("read the appearance: {error}");
Appearance::default()
});
let engine = Self {
runtime,
store: Arc::new(ShareStore::new(dirs.clone())),
@@ -232,6 +254,7 @@ impl Engine {
lookups: Arc::new(tokio::sync::Semaphore::new(LOOKUP_CONCURRENCY)),
metadata: Arc::new(Mutex::new(Enrichment::build(config))),
prefs: Arc::new(Mutex::new(prefs)),
appearance: Arc::new(Mutex::new(appearance)),
events,
ctx,
};
@@ -271,6 +294,27 @@ impl Engine {
});
}
/// Which theme the window wears.
pub fn appearance(&self) -> Appearance {
*self.appearance.lock()
}
/// Change the theme and write it.
///
/// Always committed, unlike the volume: a theme is picked with a click and
/// there is no drag to coalesce.
pub fn set_appearance(&self, appearance: Appearance) {
*self.appearance.lock() = appearance;
let engine = self.clone();
self.runtime.spawn_blocking(move || {
if let Err(error) = pstr_core::appearance::save(&engine.dirs, &appearance) {
// Not `fail`, for the same reason the playback preferences are
// not: the change happened, and only the remembering did not.
tracing::warn!("save the appearance: {error}");
}
});
}
/// Send an event and wake the UI. A closed channel means the window is
/// gone, which is not an error worth propagating anywhere.
pub(crate) fn emit(&self, event: Event) {
@@ -745,7 +789,13 @@ impl Engine {
let mut pending: Vec<Work> = Vec::new();
for title in titles {
let record = stored.get(&title.key);
if force || !pstr_meta::service::is_usable(record, provider) {
// A hand-picked entry is never searched for again, not even by
// "match again" — that button means "the automatic answers are
// wrong", and re-deciding the one title the viewer already fixed by
// hand is the opposite of what they asked for. Its episode list is
// still fetched below if it is missing.
let pinned = record.is_some_and(|record| record.manual && record.provider == provider);
if !pinned && (force || !pstr_meta::service::is_usable(record, provider)) {
pending.push(Work::Match(title));
continue;
}
@@ -848,6 +898,98 @@ impl Engine {
self.load_metadata();
}
/// Ask the provider what `term` might be, for a viewer choosing by hand.
///
/// Unscored and unfiltered — see [`MetadataService::search`]. Nothing is
/// stored: this is a look, and only [`Engine::choose_match`] writes.
pub fn search_matches(&self, title_key: String, term: String, kind: TitleKind) {
let Some(service) = self.metadata.lock().service.clone() else {
let config = self.metadata_config();
return self.emit(Event::MatchSearchFailed {
title_key,
error: if !config.enabled {
"turn on metadata enrichment first".into()
} else {
format!("{} needs an API key", config.provider.label())
},
});
};
let engine = self.clone();
let permits = Arc::clone(&self.lookups);
self.runtime.spawn(async move {
// The same permit the batch run takes: a viewer typing in the search
// box while a match run is going must not be what earns the 429.
let Ok(_permit) = permits.acquire().await else {
return;
};
match service.search(&term, kind).await {
Ok(options) => engine.emit(Event::MatchOptions { title_key, options }),
Err(error) => {
tracing::warn!("search {term:?}: {error}");
engine.emit(Event::MatchSearchFailed {
title_key,
error: error.to_string(),
});
}
}
});
}
/// Pin one title to the entry the viewer picked, and take its episodes.
///
/// Stored as [`MetadataRecord::manual`], so it outlives every TTL and
/// survives "match again". The episode list is fetched here rather than left
/// to the next match run, because the point of choosing an entry by hand is
/// usually that its episode names were wrong too.
pub fn choose_match(&self, title: Title, found: pstr_core::metadata::TitleMetadata) {
let Some(service) = self.metadata.lock().service.clone() else {
return self.emit(Event::Error("turn on metadata enrichment first".into()));
};
let engine = self.clone();
self.runtime.spawn(async move {
let name = found.name.clone();
let record = service.chosen(title.key.clone(), found.clone());
if let Err(error) = engine.catalog.lock().set_metadata(&record) {
return engine.fail("store the match", error);
}
let episodes = episodes_of(&service, &title, &found).await;
if !episodes.is_empty() {
let stored = engine.catalog.lock().set_episode_metadata(
&title.key,
service.provider(),
now(),
&episodes,
);
if let Err(error) = stored {
tracing::warn!("store episodes for {}: {error}", title.key);
}
}
engine.emit(Event::Status(format!("{} is now {name}", title.name)));
engine.load_metadata();
});
}
/// Drop what is stored for one title, so it is matched from scratch again.
///
/// The undo for [`Engine::choose_match`], and the repair for a bad automatic
/// match: the row goes entirely rather than becoming a stored miss, which is
/// what makes the next match run treat the title as one it has never asked
/// about.
pub fn forget_match(&self, title_key: String) {
let engine = self.clone();
self.runtime.spawn_blocking(move || {
if let Err(error) = engine.catalog.lock().forget_metadata(&title_key) {
return engine.fail("forget the match", error);
}
engine.emit(Event::Status("match cleared".into()));
engine.load_metadata();
});
}
/// Fetch, decode and post one title's artwork.
///
/// Cached on disk under a hash of the URL, so a provider that moves its CDN
+8
View File
@@ -4,6 +4,14 @@
//! logging, the tokio runtime, the window, and the app inside it. See
//! [`pstr_app`] for how the three threads divide the work.
// A GUI binary linked against the Windows *console* subsystem gets a console
// allocated for it, so launching from the Start menu flashes up a cmd window
// that lives as long as the app. Only release builds are switched: with the
// windows subsystem there is no stdout for `tracing_subscriber` to write to, and
// a developer running `cargo run` on Windows wants the log more than the tidy
// window. `pstr-cli` stays on the console subsystem, which is correct for it.
#![cfg_attr(all(windows, not(debug_assertions)), windows_subsystem = "windows")]
use anyhow::Context;
use pstr_core::config::AppDirs;
+703 -34
View File
@@ -1,21 +1,33 @@
//! The look: one dark palette, applied once at startup.
//! The look: one palette, chosen once and swapped at runtime.
//!
//! Deliberately dark and low-contrast around the edges — this is a window full
//! of video stills, and every bit of chrome brightness competes with them. The
//! accent is Proton's purple, which is also the only strong colour in the app,
//! so anything wearing it is something to click.
//! accent is the only strong colour in the app, so anything wearing it is
//! something to click.
//!
//! ## Why the palette is a function call and not a constant
//!
//! It used to be a wall of `const`s, which is what a theme wants to be right up
//! until there is more than one of them. The colours now come from
//! [`palette()`], which reads a process-wide palette that [`apply`] writes.
//! Every alternative was worse: threading a `&Palette` through the fifty call
//! sites in `ui/` would have put a lifetime on every drawing signature in the
//! app, and stashing it in `egui::Context` would have made the colour of a
//! label depend on which `Ui` happened to be in scope. There is one window and
//! one palette in it; a global is what that is.
//!
//! The palette is only ever written by [`apply`], from the UI thread, between
//! frames.
use egui::{Color32, CornerRadius, Stroke, Visuals};
use std::collections::BTreeMap;
use std::sync::Arc;
pub const BACKGROUND: Color32 = Color32::from_rgb(0x0e, 0x0e, 0x12);
pub const SURFACE: Color32 = Color32::from_rgb(0x17, 0x17, 0x1d);
pub const CARD: Color32 = Color32::from_rgb(0x1e, 0x1e, 0x26);
pub const CARD_HOVER: Color32 = Color32::from_rgb(0x2a, 0x2a, 0x35);
pub const TEXT: Color32 = Color32::from_rgb(0xea, 0xea, 0xf0);
pub const MUTED: Color32 = Color32::from_rgb(0x8e, 0x8e, 0x9c);
pub const ACCENT: Color32 = Color32::from_rgb(0x7d, 0x4d, 0xff);
pub const ACCENT_DIM: Color32 = Color32::from_rgb(0x53, 0x33, 0xad);
pub const DANGER: Color32 = Color32::from_rgb(0xe0, 0x55, 0x61);
use egui::{
Color32, CornerRadius, Rect, Rgba, Stroke, SystemTheme, TextureHandle, TextureOptions, Theme,
ThemePreference, ViewportCommand, Visuals,
};
use parking_lot::RwLock;
use pstr_core::appearance::{Accent, Appearance, Flavor};
/// Card geometry. The grid is built from these, so a change here moves
/// everything together.
@@ -24,39 +36,259 @@ pub const CARD_WIDTH: f32 = 232.0;
pub const CARD_ASPECT: f32 = 9.0 / 16.0;
pub const CARD_GAP: f32 = 14.0;
pub fn apply(ctx: &egui::Context) {
let mut visuals = Visuals::dark();
/// Every colour the app draws with, resolved from a [`Flavor`] and an
/// [`Accent`].
///
/// `Copy`, and small, so reading it is a lock and a memcpy rather than anything
/// a drawing loop has to think about.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Palette {
/// Behind everything: the page.
pub background: Color32,
/// The bars above and below it.
pub surface: Color32,
/// The deepest colour in the flavour — text fields, wells.
pub sunken: Color32,
/// Tiles, rows, forms.
pub card: Color32,
pub card_hover: Color32,
pub border: Color32,
pub text: Color32,
pub muted: Color32,
/// The one strong colour.
pub accent: Color32,
/// What a gradient in the accent runs into. Equal to [`Self::accent`] when
/// the accent is a single hue and gradients would have nothing to do.
pub accent_alt: Color32,
/// The accent taken down into the background, for a pressed control and for
/// selection behind text.
pub accent_dim: Color32,
/// Ink that stays readable on top of the accent. Chosen by the accent's
/// luminance, because Catppuccin's dark flavours have *pale* accents and
/// white-on-pastel is not readable at 13 px.
pub on_accent: Color32,
pub danger: Color32,
/// Whether this is a light theme.
pub light: bool,
}
visuals.panel_fill = BACKGROUND;
visuals.window_fill = SURFACE;
visuals.extreme_bg_color = Color32::from_rgb(0x0a, 0x0a, 0x0d);
visuals.faint_bg_color = SURFACE;
visuals.override_text_color = Some(TEXT);
visuals.selection.bg_fill = ACCENT_DIM;
visuals.selection.stroke = Stroke::new(1.0, TEXT);
visuals.hyperlink_color = ACCENT;
/// Which way a gradient runs.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Direction {
Horizontal,
Vertical,
}
/// The palette as it stands.
pub fn palette() -> Palette {
ACTIVE.read().palette
}
pub fn background() -> Color32 {
palette().background
}
pub fn surface() -> Color32 {
palette().surface
}
pub fn card() -> Color32 {
palette().card
}
pub fn card_hover() -> Color32 {
palette().card_hover
}
pub fn text() -> Color32 {
palette().text
}
pub fn muted() -> Color32 {
palette().muted
}
pub fn accent() -> Color32 {
palette().accent
}
pub fn accent_dim() -> Color32 {
palette().accent_dim
}
pub fn on_accent() -> Color32 {
palette().on_accent
}
pub fn danger() -> Color32 {
palette().danger
}
/// Whether the accent is currently drawn as a gradient.
///
/// True only when the viewer has gradients on *and* the accent is a pair of
/// hues rather than one: a "gradient" from a colour to itself is a flat fill
/// that costs a texture lookup.
pub fn gradients() -> bool {
let active = ACTIVE.read();
active.gradients && active.palette.accent != active.palette.accent_alt
}
/// Fill `rect` with the accent, as a gradient when there is one to draw.
///
/// `lift` brightens the result for a hovered control and darkens it for a
/// pressed one — `0.0` is the resting state. It is a veil over the top rather
/// than a change to the colours, because the gradient is a texture and tinting
/// a texture can only ever darken it.
pub fn accent_fill(
painter: &egui::Painter,
rect: Rect,
corner_radius: impl Into<CornerRadius>,
lift: f32,
) {
let corner_radius = corner_radius.into();
fill(painter, rect, corner_radius, &palette());
veil(painter, rect, corner_radius, lift);
}
/// 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.
pub fn swatch_fill(
painter: &egui::Painter,
rect: Rect,
corner_radius: impl Into<CornerRadius>,
palette: &Palette,
) {
fill(painter, rect, corner_radius.into(), palette);
}
/// Paint `palette`'s accent — gradient if it has two hues and gradients are on,
/// flat otherwise.
fn fill(painter: &egui::Painter, rect: Rect, corner_radius: CornerRadius, palette: &Palette) {
if !ACTIVE.read().gradients || palette.accent == palette.accent_alt {
painter.rect_filled(rect, corner_radius, palette.accent);
return;
}
let texture = ramp(
painter.ctx(),
palette.accent,
palette.accent_alt,
Direction::Horizontal,
);
painter.add(textured(rect, corner_radius, &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.
///
/// Handed back as a shape rather than painted, because a panel's background has
/// to be drawn *behind* content whose size is only known once it is laid out —
/// the caller reserves an index before drawing and fills it in after.
pub fn bar_shape(ctx: &egui::Context, rect: Rect) -> egui::Shape {
let palette = palette();
if !ACTIVE.read().gradients {
return egui::epaint::RectShape::filled(rect, CornerRadius::ZERO, palette.surface).into();
}
let texture = ramp(
ctx,
palette.surface,
// Not all the way to the page colour: a bar that ends in exactly the
// background has no bottom edge, and the shadow under a top panel then
// reads as the only thing separating the chrome from the content.
mix(palette.surface, palette.background, 0.7),
Direction::Vertical,
);
textured(rect, CornerRadius::ZERO, &texture)
}
/// 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) {
if lift == 0.0 {
return;
}
let alpha = (lift.abs() * 255.0).clamp(0.0, 255.0) as u8;
let film = if lift > 0.0 {
Color32::from_white_alpha(alpha)
} else {
Color32::from_black_alpha(alpha)
};
painter.rect_filled(rect, corner_radius, film);
}
/// A rounded rectangle painted with a gradient texture.
///
/// The fill is white because `Brush` *multiplies* the texture by it, so
/// anything else would tint the ramp.
fn textured(rect: Rect, corner_radius: CornerRadius, texture: &TextureHandle) -> egui::Shape {
let mut shape = egui::epaint::RectShape::filled(rect, corner_radius, Color32::WHITE);
shape.brush = Some(Arc::new(egui::epaint::Brush {
fill_texture_id: texture.id(),
uv: Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)),
}));
shape.into()
}
/// Install a palette, and tell egui and the window manager which of the two
/// kinds of theme it is.
pub fn apply(ctx: &egui::Context, appearance: Appearance) {
let palette = Palette::resolve(appearance);
// egui keeps a separate style per theme and follows the OS by default, and
// `set_visuals` only writes the one that is active *right now*. On the
// first call this runs before the platform has reported its theme, so on a
// Windows desktop set to light the palette below would land on the unused
// dark style and then be swapped out — panels stayed dark because they are
// painted by hand, but every stock widget (buttons, the search field, the
// volume slider) came back white. Pin the preference and write both styles.
let theme = if palette.light {
Theme::Light
} else {
Theme::Dark
};
ctx.set_theme(match theme {
Theme::Light => ThemePreference::Light,
Theme::Dark => ThemePreference::Dark,
});
// The native title bar follows the *window's* theme, not egui's. Without
// this, Windows draws a light caption above a dark window.
ctx.send_viewport_cmd(ViewportCommand::SetTheme(match theme {
Theme::Light => SystemTheme::Light,
Theme::Dark => SystemTheme::Dark,
}));
let mut visuals = if palette.light {
Visuals::light()
} else {
Visuals::dark()
};
visuals.panel_fill = palette.background;
visuals.window_fill = palette.surface;
visuals.extreme_bg_color = palette.sunken;
visuals.faint_bg_color = palette.surface;
visuals.override_text_color = Some(palette.text);
visuals.selection.bg_fill = palette.accent_dim;
visuals.selection.stroke = Stroke::new(1.0, palette.text);
visuals.hyperlink_color = palette.accent;
let radius = CornerRadius::same(8);
visuals.widgets.noninteractive.bg_fill = SURFACE;
visuals.widgets.noninteractive.bg_fill = palette.surface;
visuals.widgets.noninteractive.corner_radius = radius;
visuals.widgets.noninteractive.bg_stroke =
Stroke::new(1.0, Color32::from_rgb(0x26, 0x26, 0x30));
visuals.widgets.noninteractive.bg_stroke = Stroke::new(1.0, palette.border);
visuals.widgets.inactive.bg_fill = CARD;
visuals.widgets.inactive.weak_bg_fill = CARD;
visuals.widgets.inactive.bg_fill = palette.card;
visuals.widgets.inactive.weak_bg_fill = palette.card;
visuals.widgets.inactive.corner_radius = radius;
visuals.widgets.inactive.bg_stroke = Stroke::NONE;
visuals.widgets.hovered.bg_fill = CARD_HOVER;
visuals.widgets.hovered.weak_bg_fill = CARD_HOVER;
visuals.widgets.hovered.bg_fill = palette.card_hover;
visuals.widgets.hovered.weak_bg_fill = palette.card_hover;
visuals.widgets.hovered.corner_radius = radius;
visuals.widgets.hovered.bg_stroke = Stroke::new(1.0, ACCENT);
visuals.widgets.hovered.bg_stroke = Stroke::new(1.0, palette.accent);
visuals.widgets.active.bg_fill = ACCENT_DIM;
visuals.widgets.active.weak_bg_fill = ACCENT_DIM;
visuals.widgets.active.bg_fill = palette.accent_dim;
visuals.widgets.active.weak_bg_fill = palette.accent_dim;
visuals.widgets.active.corner_radius = radius;
ctx.set_visuals(visuals);
// Both, not just the active one: a menu or a tooltip that opens while the
// platform is mid-way through telling us its theme should not flash the
// stock palette.
ctx.set_visuals_of(Theme::Dark, visuals.clone());
ctx.set_visuals_of(Theme::Light, visuals);
ctx.all_styles_mut(|style| {
style.spacing.item_spacing = egui::vec2(10.0, 10.0);
@@ -78,4 +310,441 @@ pub fn apply(ctx: &egui::Context) {
]
.into();
});
// Dropped, not rebuilt: the textures are only wanted if something draws a
// gradient this frame, and building them here would load two textures on
// every theme change including the ones nothing gradient-filled follows.
let mut active = ACTIVE.write();
active.palette = palette;
active.gradients = appearance.gradients;
active.ramps.clear();
drop(active);
ctx.request_repaint();
}
/// The palette, and the gradient textures drawn from it.
struct Active {
palette: Palette,
gradients: bool,
/// Gradient ramps by the pair of colours and the direction they run,
/// emptied whenever the palette changes.
///
/// Keyed on the colours rather than on what they are *for* because the
/// settings page previews every accent at once: eight swatches in a
/// 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>,
}
static ACTIVE: RwLock<Active> = RwLock::new(Active {
palette: Palette::PROTON,
gradients: true,
ramps: BTreeMap::new(),
});
/// A ramp between two colours, built once and kept.
fn ramp(ctx: &egui::Context, from: Color32, to: Color32, direction: Direction) -> TextureHandle {
let key = (
u32::from_le_bytes(from.to_array()),
u32::from_le_bytes(to.to_array()),
direction == Direction::Vertical,
);
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))
.collect();
let size = match direction {
Direction::Horizontal => [RAMP_STOPS, 1],
Direction::Vertical => [1, RAMP_STOPS],
};
let texture = ctx.load_texture(
"pstr-ramp",
egui::ColorImage::new(size, pixels),
// Clamped and filtered: the ramp is stretched across a whole seek bar,
// and nearest-neighbour would show every one of its stops.
TextureOptions::LINEAR,
);
ACTIVE.write().ramps.insert(key, texture.clone());
texture
}
/// How many texels a ramp is drawn from.
///
/// Sampled linearly, so this is not the number of visible steps — it is how
/// closely the ramp follows sRGB. Interpolation happens in the texture's own
/// gamma space, and a two-texel ramp between distant hues takes a visibly
/// different path through colour space than one built stop by stop in linear
/// light, which is what this does.
const RAMP_STOPS: usize = 64;
/// Blend two colours in linear light. Mixing in sRGB darkens the middle of a
/// ramp between saturated hues, which is exactly where a gradient is looked at.
fn mix(from: Color32, to: Color32, t: f32) -> Color32 {
let from = Rgba::from(from);
let to = Rgba::from(to);
Color32::from(from * (1.0 - t) + to * t)
}
/// Relative luminance, in linear light, as WCAG defines it.
fn luminance(color: Color32) -> f32 {
let rgba = Rgba::from(color);
0.2126 * rgba.r() + 0.7152 * rgba.g() + 0.0722 * rgba.b()
}
/// WCAG contrast between two opaque colours: 1.0 for a colour against itself,
/// 21.0 for black against white.
fn contrast(one: Color32, other: Color32) -> f32 {
let (one, other) = (luminance(one), luminance(other));
(one.max(other) + 0.05) / (one.min(other) + 0.05)
}
/// The least contrast a label gets anywhere along a gradient between two
/// colours. Sampled rather than solved: the worst point is not always an end,
/// because the ink can be brighter than one end and darker than the other.
fn worst_contrast(from: Color32, to: Color32, ink: Color32) -> f32 {
(0..=4)
.map(|step| contrast(mix(from, to, step as f32 / 4.0), ink))
.fold(f32::INFINITY, f32::min)
}
/// The least contrast the app will accept between a label and the fill under
/// it: WCAG AA for large text, which is what wears the accent — button labels,
/// a tab, a play glyph, never body copy.
const MIN_CONTRAST: f32 = 3.0;
/// One flavour's raw colours, named as Catppuccin names them.
///
/// Stored rather than computed: these are somebody else's palette, and the
/// point of using it is to use it exactly.
#[derive(Clone, Copy)]
struct Ramp {
base: Color32,
mantle: Color32,
crust: Color32,
surface0: Color32,
surface1: Color32,
subtext0: Color32,
text: Color32,
pink: Color32,
mauve: Color32,
sky: Color32,
sapphire: Color32,
blue: Color32,
lavender: Color32,
teal: Color32,
green: Color32,
peach: Color32,
yellow: Color32,
red: Color32,
light: bool,
}
const fn rgb(hex: u32) -> Color32 {
Color32::from_rgb(
((hex >> 16) & 0xff) as u8,
((hex >> 8) & 0xff) as u8,
(hex & 0xff) as u8,
)
}
/// The palette this app shipped with, extended to the hues an accent can be.
///
/// Only `mauve` — Proton purple — and `red` are from the original; the rest are
/// chosen to sit on a near-black base at roughly the saturation that one does,
/// which is a good deal hotter than any Catppuccin flavour.
const PROTON: Ramp = Ramp {
base: rgb(0x0e0e12),
mantle: rgb(0x17171d),
crust: rgb(0x0a0a0d),
surface0: rgb(0x1e1e26),
surface1: rgb(0x2a2a35),
subtext0: rgb(0x8e8e9c),
text: rgb(0xeaeaf0),
pink: rgb(0xff5fbe),
mauve: rgb(0x7d4dff),
sky: rgb(0x4dd2ff),
sapphire: rgb(0x3a8fc4),
blue: rgb(0x3f6fe0),
lavender: rgb(0xa68cff),
teal: rgb(0x3fd6b8),
green: rgb(0x56d364),
peach: rgb(0xff9a4d),
yellow: rgb(0xffd166),
red: rgb(0xe05561),
light: false,
};
const LATTE: Ramp = Ramp {
base: rgb(0xeff1f5),
mantle: rgb(0xe6e9ef),
crust: rgb(0xdce0e8),
surface0: rgb(0xccd0da),
surface1: rgb(0xbcc0cc),
subtext0: rgb(0x6c6f85),
text: rgb(0x4c4f69),
pink: rgb(0xea76cb),
mauve: rgb(0x8839ef),
sky: rgb(0x04a5e5),
sapphire: rgb(0x209fb5),
blue: rgb(0x1e66f5),
lavender: rgb(0x7287fd),
teal: rgb(0x179299),
green: rgb(0x40a02b),
peach: rgb(0xfe640b),
yellow: rgb(0xdf8e1d),
red: rgb(0xd20f39),
light: true,
};
const FRAPPE: Ramp = Ramp {
base: rgb(0x303446),
mantle: rgb(0x292c3c),
crust: rgb(0x232634),
surface0: rgb(0x414559),
surface1: rgb(0x51576d),
subtext0: rgb(0xa5adce),
text: rgb(0xc6d0f5),
pink: rgb(0xf4b8e4),
mauve: rgb(0xca9ee6),
sky: rgb(0x99d1db),
sapphire: rgb(0x85c1dc),
blue: rgb(0x8caaee),
lavender: rgb(0xbabbf1),
teal: rgb(0x81c8be),
green: rgb(0xa6d189),
peach: rgb(0xef9f76),
yellow: rgb(0xe5c890),
red: rgb(0xe78284),
light: false,
};
const MACCHIATO: Ramp = Ramp {
base: rgb(0x24273a),
mantle: rgb(0x1e2030),
crust: rgb(0x181926),
surface0: rgb(0x363a4f),
surface1: rgb(0x494d64),
subtext0: rgb(0xa5adcb),
text: rgb(0xcad3f5),
pink: rgb(0xf5bde6),
mauve: rgb(0xc6a0f6),
sky: rgb(0x91d7e3),
sapphire: rgb(0x7dc4e4),
blue: rgb(0x8aadf4),
lavender: rgb(0xb7bdf8),
teal: rgb(0x8bd5ca),
green: rgb(0xa6da95),
peach: rgb(0xf5a97f),
yellow: rgb(0xeed49f),
red: rgb(0xed8796),
light: false,
};
const MOCHA: Ramp = Ramp {
base: rgb(0x1e1e2e),
mantle: rgb(0x181825),
crust: rgb(0x11111b),
surface0: rgb(0x313244),
surface1: rgb(0x45475a),
subtext0: rgb(0xa6adc8),
text: rgb(0xcdd6f4),
pink: rgb(0xf5c2e7),
mauve: rgb(0xcba6f7),
sky: rgb(0x89dceb),
sapphire: rgb(0x74c7ec),
blue: rgb(0x89b4fa),
lavender: rgb(0xb4befe),
teal: rgb(0x94e2d5),
green: rgb(0xa6e3a1),
peach: rgb(0xfab387),
yellow: rgb(0xf9e2af),
red: rgb(0xf38ba8),
light: false,
};
impl Ramp {
const fn of(flavor: Flavor) -> Self {
match flavor {
Flavor::Proton => PROTON,
Flavor::Latte => LATTE,
Flavor::Frappe => FRAPPE,
Flavor::Macchiato => MACCHIATO,
Flavor::Mocha => MOCHA,
}
}
/// The accent, and the hue a gradient in it runs into.
///
/// The partners are neighbours on the wheel rather than complements: a
/// gradient across half the spectrum passes through a colour that belongs
/// to neither end, and on a seek bar that reads as a bug. The exception is
/// [`Accent::PinkSky`], which is the whole point of that entry.
const fn accents(&self, accent: Accent) -> (Color32, Color32) {
match accent {
Accent::Mauve => (self.mauve, self.blue),
Accent::Pink => (self.pink, self.mauve),
Accent::Sky => (self.sky, self.sapphire),
Accent::PinkSky => (self.pink, self.sky),
Accent::Lavender => (self.lavender, self.blue),
Accent::Blue => (self.blue, self.sapphire),
Accent::Teal => (self.teal, self.green),
Accent::Peach => (self.peach, self.yellow),
}
}
}
impl Palette {
/// The default, spelled out as a constant so the global has something to
/// hold before [`apply`] first runs — a `Ui` built during startup, or a
/// panic message painted before the engine exists, still gets colours.
const PROTON: Self = Self {
background: PROTON.base,
surface: PROTON.mantle,
sunken: PROTON.crust,
card: PROTON.surface0,
card_hover: PROTON.surface1,
border: rgb(0x262630),
text: PROTON.text,
muted: PROTON.subtext0,
accent: PROTON.mauve,
accent_alt: PROTON.blue,
accent_dim: rgb(0x5333ad),
on_accent: PROTON.text,
danger: PROTON.red,
light: false,
};
/// Resolve a choice into colours.
pub fn resolve(appearance: Appearance) -> Self {
let ramp = Ramp::of(appearance.flavor);
let (accent, partner) = ramp.accents(appearance.accent);
// Catppuccin's Latte accents are tuned to be *read*, on a light page,
// at body-text weight — which makes them mid-luminance, and a
// mid-luminance fill is one that neither black nor white sits on. They
// are taken down in value here, and only here: the hue and the
// saturation are Latte's, so it still looks like Latte, and a label on
// a button is legible.
let deepen = |color: Color32| {
if ramp.light {
mix(color, Color32::BLACK, 0.30)
} else {
color
}
};
let accent = deepen(accent);
let accent_alt = if appearance.gradients {
deepen(partner)
} else {
accent
};
// Ink that survives on the accent. The flavour's own reading first —
// pale text on a dark theme — and the opposite when that does not clear
// the bar, which on Catppuccin's dark flavours is most of the time:
// their accents are *pastel*, and white on #f5c2e7 is not a label.
//
// Latte's darkest colour is its body text, and even that is only #4c4f69,
// so the dark side is taken a third of the way to black.
let ink_dark = if ramp.light {
mix(ramp.text, Color32::BLACK, 0.35)
} else {
ramp.crust
};
let ink_light = if ramp.light { ramp.base } else { ramp.text };
let on_accent = if worst_contrast(accent, accent_alt, ink_light) >= MIN_CONTRAST {
ink_light
} else {
ink_dark
};
Self {
background: ramp.base,
surface: ramp.mantle,
sunken: ramp.crust,
card: ramp.surface0,
card_hover: ramp.surface1,
border: ramp.surface1,
text: ramp.text,
muted: ramp.subtext0,
accent,
accent_alt,
// Behind text, so it is the accent taken most of the way back to
// the page: a selection in a saturated hue is a selection nobody
// can read through.
accent_dim: mix(accent, ramp.base, if ramp.light { 0.55 } else { 0.45 }),
on_accent,
danger: ramp.red,
light: ramp.light,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn every_flavour_and_accent_resolves_to_readable_ink() {
for flavor in Flavor::ALL {
for accent in Accent::ALL {
let palette = Palette::resolve(Appearance {
flavor,
accent,
gradients: true,
});
let ratio = worst_contrast(palette.accent, palette.accent_alt, palette.on_accent);
assert!(
ratio >= MIN_CONTRAST,
"{flavor:?}/{accent:?}: contrast {ratio:.2}",
);
}
}
}
#[test]
fn only_the_light_flavour_reports_itself_light() {
for flavor in Flavor::ALL {
let palette = Palette::resolve(Appearance {
flavor,
..Appearance::default()
});
assert_eq!(palette.light, flavor.is_light());
// And the page is on the right side of the middle either way.
assert_eq!(luminance(palette.background) > 0.5, flavor.is_light());
}
}
#[test]
fn turning_gradients_off_leaves_one_colour_to_draw() {
let flat = Palette::resolve(Appearance {
accent: Accent::PinkSky,
gradients: false,
..Appearance::default()
});
assert_eq!(flat.accent, flat.accent_alt);
}
#[test]
fn the_shipped_default_is_the_palette_that_was_here_before() {
let resolved = Palette::resolve(Appearance::default());
assert_eq!(resolved.background, Palette::PROTON.background);
assert_eq!(resolved.accent, Palette::PROTON.accent);
assert_eq!(resolved.text, Palette::PROTON.text);
assert_eq!(resolved.muted, Palette::PROTON.muted);
}
#[test]
fn a_ramp_runs_from_one_colour_to_the_other() {
let from = Color32::from_rgb(0, 0, 0);
let to = Color32::from_rgb(255, 255, 255);
assert_eq!(mix(from, to, 0.0), from);
assert_eq!(mix(from, to, 1.0), to);
assert!(luminance(mix(from, to, 0.5)) > luminance(from));
assert!(luminance(mix(from, to, 0.5)) < luminance(to));
}
}
+40 -6
View File
@@ -118,7 +118,9 @@ fn grid(ui: &mut egui::Ui, art: &mut Art<'_>, titles: &[&Title], actions: &mut V
name: &title.name,
subtitle: subtitle(title),
progress: title.resume().and_then(|e| e.progress()).map(|v| v as f32),
badge: (title.kind == TitleKind::Film).then(|| "Film".to_string()),
// What a film is says itself in the subtitle below the
// card; a second `Film` over the poster is noise.
badge: None,
},
)
.clicked();
@@ -132,13 +134,19 @@ fn grid(ui: &mut egui::Ui, art: &mut Art<'_>, titles: &[&Title], actions: &mut V
}
}
/// The grey line under a card: how much there is, and how much is left.
/// The grey line under a card: what it is, how much there is, and how much is
/// left.
///
/// A film says so, and says its year after it. The year alone was ambiguous in
/// the one place it mattered: `Ghost in the Shell` over `1995` reads as a
/// season, an episode count, anything — where every series card on the same
/// shelf is counting episodes.
fn subtitle(title: &Title) -> String {
if title.kind == TitleKind::Film {
return title
.year
.map(|year| year.to_string())
.unwrap_or_else(|| "Film".into());
return match title.year {
Some(year) => format!("Film · {year}"),
None => "Film".into(),
};
}
let total = title.episode_count();
let watched = title.watched_count();
@@ -182,6 +190,32 @@ pub fn plural(count: usize, noun: &str) -> String {
mod tests {
use super::*;
fn title(kind: TitleKind, year: Option<u32>) -> Title {
Title {
key: "k".into(),
name: "Ghost in the Shell".into(),
year,
kind,
seasons: Vec::new(),
share_ids: Vec::new(),
}
}
/// A film says it is one. The year on its own was read as anything but —
/// the cards beside it are all counting episodes.
#[test]
fn a_film_is_labelled_a_film_and_dated_after_it() {
assert_eq!(
subtitle(&title(TitleKind::Film, Some(1995))),
"Film · 1995"
);
assert_eq!(subtitle(&title(TitleKind::Film, None)), "Film");
assert_eq!(
subtitle(&title(TitleKind::Series, Some(1995))),
"0 episodes"
);
}
#[test]
fn plurals_read_naturally() {
assert_eq!(plural(1, "title"), "1 title");
+286
View File
@@ -0,0 +1,286 @@
//! Picking a title's provider entry by hand.
//!
//! The matcher is deliberately unwilling to guess — see
//! [`pstr_meta::matching::MATCH_FLOOR`], and the reasoning that a wrong poster
//! does not look like a bug but like the library being wrong. The cost of that
//! is a handful of titles it will not decide about, and this dialog is where
//! they get decided: the provider's own answers, unscored and in its own order,
//! and a click that pins one of them.
//!
//! Nothing here scores anything. A person reading a list of eight entries with
//! their posters next to them is not guessing, which is exactly why the floor
//! that stops the scorer should not also stop them.
use pstr_core::metadata::{ProviderId, TitleMetadata};
use crate::app::{Action, Matcher};
use crate::theme;
use crate::ui::{self, Art};
/// How tall the result list may get before it scrolls. Enough for four rows,
/// which is what a page of provider answers usually needs.
const RESULTS_HEIGHT: f32 = 340.0;
/// A result row's poster, at the shape a cover image actually is.
const THUMB: egui::Vec2 = egui::vec2(46.0, 69.0);
pub fn show(
ctx: &egui::Context,
matcher: &mut Matcher,
art: &mut Art<'_>,
provider: ProviderId,
actions: &mut Vec<Action>,
) {
// What the title is matched to now, cloned out before `art` is borrowed for
// the rows below.
let current = art
.metadata
.get(&matcher.title_key)
.map(|record| (record.metadata.clone(), record.manual));
let modal = egui::Modal::new(egui::Id::new("matcher"))
.frame(
egui::Frame::new()
.fill(theme::surface())
.inner_margin(egui::Margin::same(18))
.corner_radius(egui::CornerRadius::same(10)),
)
.show(ctx, |ui| {
ui.set_width(620.0);
ui.label(
egui::RichText::new(format!("Match “{}”", matcher.title_name))
.size(18.0)
.strong()
.color(theme::text()),
);
ui.label(ui::muted(format!(
"Everything {} answers with, in its own order — nothing is scored here.",
provider.label()
)));
ui.add_space(10.0);
search_row(ui, matcher, actions);
ui.add_space(6.0);
current_row(ui, matcher, current.as_ref(), actions);
ui.add_space(10.0);
if let Some(error) = &matcher.error {
ui.label(egui::RichText::new(error).size(12.0).color(theme::danger()));
ui.add_space(6.0);
}
results(ui, matcher, art, actions);
ui.add_space(12.0);
ui.horizontal(|ui| {
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if ui.button("Close").clicked() {
actions.push(Action::CloseMatcher);
}
});
});
});
// Escape, a click on the backdrop, or the close button above: all of them
// mean the same thing, and the dialog changes nothing until a row is
// clicked, so leaving it needs no confirmation.
if modal.should_close() {
actions.push(Action::CloseMatcher);
}
}
/// The box and the button. Enter searches, because a search box that ignores it
/// is a search box people think is broken.
fn search_row(ui: &mut egui::Ui, matcher: &mut Matcher, actions: &mut Vec<Action>) {
ui.horizontal(|ui| {
let box_width = ui.available_width() - 90.0;
let field = ui.add(
egui::TextEdit::singleline(&mut matcher.query)
.hint_text("Title to search for")
.desired_width(box_width.max(120.0)),
);
if std::mem::take(&mut matcher.focus) {
field.request_focus();
}
let entered = field.lost_focus() && ui.input(|input| input.key_pressed(egui::Key::Enter));
if matcher.searching {
ui.add(egui::Spinner::new().size(16.0));
} else if (ui.button("Search").clicked() || entered) && !matcher.query.trim().is_empty() {
actions.push(Action::SearchMatches);
}
});
}
/// What the title is matched to now, and the way back to no match at all.
fn current_row(
ui: &mut egui::Ui,
matcher: &Matcher,
current: Option<&(Option<TitleMetadata>, bool)>,
actions: &mut Vec<Action>,
) {
ui.horizontal(|ui| {
match current {
Some((Some(found), manual)) => {
ui.label(ui::muted(format!(
"Currently {}{}",
found.name,
if *manual { ", chosen by hand" } else { "" }
)));
// Only worth offering when there is something to undo: with no
// row stored, "clear" would delete nothing.
if ui
.button("Clear")
.on_hover_text("Forget this match and let the next match run decide again")
.clicked()
{
actions.push(Action::ForgetMatch(matcher.title_key.clone()));
}
}
Some((None, _)) => {
ui.label(ui::muted("Currently unmatched — the provider had nothing."));
if ui
.button("Clear")
.on_hover_text("Forget the stored miss so this title is asked about again")
.clicked()
{
actions.push(Action::ForgetMatch(matcher.title_key.clone()));
}
}
None => {
ui.label(ui::muted("Not matched yet."));
}
};
});
}
/// The provider's answers, one clickable row each.
fn results(ui: &mut egui::Ui, matcher: &Matcher, art: &mut Art<'_>, actions: &mut Vec<Action>) {
if matcher.results.is_empty() {
ui.label(ui::muted(if matcher.searching {
"Searching…"
} else if matcher.asked {
// Both providers match on whole words, so a name the index has never
// seen empties the page rather than returning something near it.
"Nothing came back. Try a shorter name, or the title in another language."
} else {
"Search to see what the provider has."
}));
return;
}
egui::ScrollArea::vertical()
.max_height(RESULTS_HEIGHT)
.auto_shrink([false, true])
.show(ui, |ui| {
for found in &matcher.results {
if row(ui, art, found).clicked() {
actions.push(Action::ChooseMatch(Box::new(found.clone())));
}
ui.add_space(4.0);
}
});
}
/// One answer: its cover, its names, and enough of its synopsis to tell two
/// entries in the same franchise apart — which is the whole job of this list.
fn row(ui: &mut egui::Ui, art: &mut Art<'_>, found: &TitleMetadata) -> egui::Response {
let response = egui::Frame::new()
.fill(theme::card())
.corner_radius(egui::CornerRadius::same(6))
.inner_margin(egui::Margin::same(8))
.show(ui, |ui| {
ui.set_width(ui.available_width());
ui.horizontal_top(|ui| {
let (rect, _) = ui.allocate_exact_size(THUMB, egui::Sense::hover());
if let Some(texture) = poster(art, found) {
ui.painter().image(
texture.id(),
rect,
egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)),
egui::Color32::WHITE,
);
} else {
ui.painter().rect_filled(
rect,
egui::CornerRadius::same(4),
theme::card_hover(),
);
}
ui.add_space(10.0);
ui.vertical(|ui| {
ui.label(
egui::RichText::new(&found.name)
.size(14.0)
.strong()
.color(theme::text()),
);
if let Some(original) = &found.original_name {
ui.label(ui::muted(original.clone()));
}
ui.label(ui::muted(facts(found)));
if let Some(overview) = &found.overview {
let mut job = egui::text::LayoutJob::simple(
overview.replace('\n', " "),
egui::FontId::proportional(11.5),
theme::muted(),
ui.available_width(),
);
job.wrap.max_rows = 2;
job.wrap.overflow_character = Some('…');
ui.label(job);
}
});
});
})
.response;
let response = response.interact(egui::Sense::click());
if response.hovered() {
ui.painter().rect_stroke(
response.rect,
egui::CornerRadius::same(6),
egui::Stroke::new(1.5, theme::accent()),
egui::StrokeKind::Inside,
);
}
response.on_hover_cursor(egui::CursorIcon::PointingHand)
}
/// The cover for one answer, cached under the provider's id for it rather than
/// under a title key — these are not any title's artwork until one is picked.
fn poster(art: &mut Art<'_>, found: &TitleMetadata) -> Option<egui::TextureHandle> {
let url = found
.poster_url
.clone()
.or_else(|| found.backdrop_url.clone())?;
let key = format!("option:{}:{}", found.provider.as_str(), found.remote_id);
let engine = art.engine;
let requested = key.clone();
art.posters
.texture(key, || engine.request_poster(requested, url))
}
/// `Film · 2017 · 26 episodes · ★ 8.2`, with whatever the provider left out
/// simply absent.
fn facts(found: &TitleMetadata) -> String {
let mut parts: Vec<String> = vec![
match found.kind {
pstr_core::library::TitleKind::Series => "Series",
pstr_core::library::TitleKind::Film => "Film",
}
.to_string(),
];
if let Some(year) = found.year {
parts.push(year.to_string());
}
if let Some(episodes) = found.episodes {
parts.push(ui::library::plural(episodes as usize, "episode"));
}
if let Some(rating) = found.rating {
parts.push(format!("★ {rating:.1}"));
}
parts.join(" · ")
}
+71 -15
View File
@@ -4,6 +4,7 @@
//! applies them after the frame is drawn.
pub mod library;
pub mod matcher;
pub mod player;
pub mod shares;
pub mod title;
@@ -115,23 +116,74 @@ pub fn section(ui: &mut egui::Ui, text: &str) {
egui::RichText::new(text)
.size(17.0)
.strong()
.color(theme::TEXT),
.color(theme::text()),
);
ui.add_space(2.0);
}
/// 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)
egui::RichText::new(text).size(12.0).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 {
ui.add(
egui::Button::new(egui::RichText::new(text).strong().color(Color32::WHITE))
.fill(theme::ACCENT)
.corner_radius(CornerRadius::same(8)),
)
filled(ui, text, true, 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))
}
/// A button painted by hand, so that "filled with the accent" can mean a
/// gradient.
///
/// `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 {
let padding = ui.spacing().button_padding + extra;
let galley = ui.painter().layout_no_wrap(
text.to_owned(),
egui::TextStyle::Button.resolve(ui.style()),
Color32::PLACEHOLDER,
);
let size = Vec2::new(
galley.size().x + padding.x * 2.0,
(galley.size().y + padding.y * 2.0).max(ui.spacing().interact_size.y),
);
let (rect, response) = ui.allocate_exact_size(size, Sense::click());
if !ui.is_rect_visible(rect) {
return response;
}
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
};
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 position = rect.center() - galley.size() / 2.0;
ui.painter().galley(position, galley, ink);
response.on_hover_cursor(egui::CursorIcon::PointingHand)
}
/// What a tile shows.
@@ -142,7 +194,7 @@ pub struct Card<'a> {
pub subtitle: String,
/// Fraction watched, drawn as a bar across the bottom of the still.
pub progress: Option<f32>,
/// A short label in the corner — `S01E04`, `Film`.
/// A short label in the corner — `S01E04`.
pub badge: Option<String>,
}
@@ -173,7 +225,7 @@ pub fn card(ui: &mut egui::Ui, card: Card<'_>) -> egui::Response {
let hovered = response.hovered();
let painter = ui.painter();
painter.rect_filled(image_rect, radius, theme::CARD);
painter.rect_filled(image_rect, radius, theme::card());
match &card.art {
Some((texture, ArtShape::Landscape)) => {
@@ -199,7 +251,7 @@ pub fn card(ui: &mut egui::Ui, card: Card<'_>) -> egui::Response {
Align2::CENTER_CENTER,
initials(card.name),
egui::FontId::proportional(28.0),
theme::MUTED,
theme::muted(),
);
}
}
@@ -231,14 +283,14 @@ pub fn card(ui: &mut egui::Ui, card: Card<'_>) -> egui::Response {
painter.rect_filled(track, CornerRadius::ZERO, Color32::from_black_alpha(150));
let mut played = track;
played.set_width(track.width() * progress.clamp(0.0, 1.0));
painter.rect_filled(played, CornerRadius::ZERO, theme::ACCENT);
theme::accent_fill(painter, played, CornerRadius::ZERO, 0.0);
}
if hovered {
painter.rect_stroke(
image_rect,
radius,
Stroke::new(2.0, theme::ACCENT),
Stroke::new(2.0, theme::accent()),
egui::StrokeKind::Inside,
);
}
@@ -254,20 +306,24 @@ pub fn card(ui: &mut egui::Ui, card: Card<'_>) -> egui::Response {
let mut job = egui::text::LayoutJob::simple(
card.name.to_string(),
egui::FontId::proportional(14.0),
if hovered { Color32::WHITE } else { theme::TEXT },
if hovered {
Color32::WHITE
} else {
theme::text()
},
width,
);
job.wrap.max_rows = 2;
job.wrap.overflow_character = Some('…');
let name = painter.layout_job(job);
let name_height = name.size().y;
painter.galley(text_rect.min, name, theme::TEXT);
painter.galley(text_rect.min, name, theme::text());
painter.text(
text_rect.min + Vec2::new(0.0, name_height + 2.0),
Align2::LEFT_TOP,
card.subtitle,
egui::FontId::proportional(11.5),
theme::MUTED,
theme::muted(),
);
response.on_hover_cursor(egui::CursorIcon::PointingHand)
+5 -5
View File
@@ -288,7 +288,7 @@ fn waiting(ui: &mut egui::Ui, playback: &Playback, rect: Rect) {
Align2::CENTER_CENTER,
&playback.target.title_name,
egui::FontId::proportional(20.0),
theme::TEXT,
theme::text(),
);
painter.text(
rect.center() + Vec2::new(0.0, 10.0),
@@ -299,7 +299,7 @@ fn waiting(ui: &mut egui::Ui, playback: &Playback, rect: Rect) {
"playing in mpv's own window"
},
egui::FontId::proportional(13.0),
theme::MUTED,
theme::muted(),
);
}
@@ -314,14 +314,14 @@ fn between(ui: &mut egui::Ui, opening: Option<&str>, rect: Rect, actions: &mut V
Align2::CENTER_CENTER,
"Up next",
egui::FontId::proportional(14.0),
theme::MUTED,
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::TEXT,
theme::text(),
);
ui.scope_builder(
@@ -451,7 +451,7 @@ fn up_next_card(
ui.label(
egui::RichText::new(format!("in {}s", card.seconds.ceil() as u32))
.size(12.0)
.color(theme::ACCENT),
.color(theme::accent()),
);
});
});
+113 -7
View File
@@ -7,18 +7,30 @@
//! 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,
autoplay: bool,
prefs: Prefs,
api_key: &mut String,
actions: &mut Vec<Action>,
) {
@@ -37,13 +49,107 @@ pub fn show(
add_form(ui, form, actions);
ui.add_space(28.0);
playback_form(ui, autoplay, actions);
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
@@ -59,7 +165,7 @@ fn playback_form(ui: &mut egui::Ui, autoplay: bool, actions: &mut Vec<Action>) {
ui::section(ui, "Playback");
egui::Frame::new()
.fill(theme::CARD)
.fill(theme::card())
.corner_radius(egui::CornerRadius::same(8))
.inner_margin(egui::Margin::same(14))
.show(ui, |ui| {
@@ -89,7 +195,7 @@ fn metadata_form(
ui::section(ui, "Posters and descriptions");
egui::Frame::new()
.fill(theme::CARD)
.fill(theme::card())
.corner_radius(egui::CornerRadius::same(8))
.inner_margin(egui::Margin::same(14))
.show(ui, |ui| {
@@ -180,7 +286,7 @@ fn metadata_form(
fn share_row(ui: &mut egui::Ui, share: &Share, actions: &mut Vec<Action>) {
egui::Frame::new()
.fill(theme::CARD)
.fill(theme::card())
.corner_radius(egui::CornerRadius::same(8))
.inner_margin(egui::Margin::symmetric(12, 10))
.show(ui, |ui| {
@@ -198,7 +304,7 @@ fn share_row(ui: &mut egui::Ui, share: &Share, actions: &mut Vec<Action>) {
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if ui
.add(egui::Button::new("Remove").fill(theme::CARD_HOVER))
.add(egui::Button::new("Remove").fill(theme::card_hover()))
.on_hover_text("Forget this share, its catalog rows and its stored secrets")
.clicked()
{
@@ -222,7 +328,7 @@ fn add_form(ui: &mut egui::Ui, form: &mut ShareForm, actions: &mut Vec<Action>)
ui.add_space(10.0);
egui::Frame::new()
.fill(theme::CARD)
.fill(theme::card())
.corner_radius(egui::CornerRadius::same(8))
.inner_margin(egui::Margin::same(14))
.show(ui, |ui| {
+37 -14
View File
@@ -61,10 +61,9 @@ pub fn show(
fn header(ui: &mut egui::Ui, art: &mut Art<'_>, title: &Title, actions: &mut Vec<Action>) {
// Cloned out of the borrow: `art` is held mutably for the card below, and
// the description is drawn beside it.
let found = art
.metadata
.get(&title.key)
.and_then(|record| record.metadata.clone());
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);
ui.horizontal_top(|ui| {
ui::card(
@@ -87,7 +86,11 @@ fn header(ui: &mut egui::Ui, art: &mut Art<'_>, title: &Title, actions: &mut Vec
if let Some(found) = &found
&& found.name != title.name
{
ui.label(ui::muted(format!("also known as {}", found.name)));
ui.label(ui::muted(format!(
"also known as {}{}",
found.name,
if by_hand { " (chosen by hand)" } else { "" }
)));
}
ui.add_space(4.0);
ui.label(ui::muted(meta_line(title, found.as_ref())));
@@ -122,6 +125,21 @@ fn header(ui: &mut egui::Ui, art: &mut Art<'_>, title: &Title, actions: &mut Vec
}
}
}
// The way out of a match the scorer would not make, or made
// wrongly. Offered whether or not anything matched: the two
// cases it exists for are a title with no poster at all and a
// title wearing someone else's.
if ui
.button(match &found {
Some(_) => "Change match",
None => "Match…",
})
.on_hover_text("Search the metadata provider and pick the entry yourself")
.clicked()
{
actions.push(Action::OpenMatcher(title.key.clone()));
}
});
if let Some(found) = &found {
@@ -140,7 +158,12 @@ fn description(ui: &mut egui::Ui, found: &TitleMetadata) {
}
if let Some(overview) = &found.overview {
ui.add(
egui::Label::new(egui::RichText::new(overview).size(13.0).color(theme::TEXT)).wrap(),
egui::Label::new(
egui::RichText::new(overview)
.size(13.0)
.color(theme::text()),
)
.wrap(),
);
}
if let Some(url) = &found.url {
@@ -215,9 +238,9 @@ fn episode_row(
let watched = episode.is_watched();
egui::Frame::new()
.fill(if watched {
theme::BACKGROUND
theme::background()
} else {
theme::CARD
theme::card()
})
.corner_radius(egui::CornerRadius::same(6))
.inner_margin(egui::Margin::symmetric(10, 8))
@@ -230,7 +253,7 @@ fn episode_row(
if ui
.add(
egui::Button::new(egui::RichText::new("▶").size(13.0))
.fill(theme::CARD_HOVER),
.fill(theme::card_hover()),
)
.on_hover_text("Play")
.clicked()
@@ -247,7 +270,7 @@ fn episode_row(
egui::Label::new(
egui::RichText::new(numbering)
.monospace()
.color(theme::MUTED),
.color(theme::muted()),
),
);
@@ -259,9 +282,9 @@ fn episode_row(
.and_then(|(name, _, _)| name.clone())
.unwrap_or_else(|| episode.detail().to_string());
let label = egui::RichText::new(name).color(if watched {
theme::MUTED
theme::muted()
} else {
theme::TEXT
theme::text()
});
let hover = match &found {
// The synopsis, where the provider has one — AniList does
@@ -306,7 +329,7 @@ fn episode_row(
ui.label(
egui::RichText::new("empty")
.size(12.0)
.color(theme::DANGER),
.color(theme::danger()),
)
.on_hover_text(
"The share reports no content for this file; it cannot be played.",
@@ -321,7 +344,7 @@ fn episode_row(
ui.label(
egui::RichText::new(format!("resume {}", ui::format_time(at)))
.size(12.0)
.color(theme::ACCENT),
.color(theme::accent()),
);
}
});
+82 -12
View File
@@ -10,7 +10,7 @@
//! Both push the same [`Action`]s, so there is one behaviour and two layouts,
//! and neither mutates: this module draws.
use egui::{Color32, CornerRadius, Rect, Sense, Vec2};
use egui::{Color32, CornerRadius, Rect, Sense, Stroke, Vec2, pos2};
use pstr_player::{MAX_VOLUME, Track, TrackKind};
use crate::app::{Action, Adjacent, Page};
@@ -26,6 +26,20 @@ const SEEK_HIT_HEIGHT: f32 = 18.0;
/// enough that it does not read as a second seek bar.
const VOLUME_WIDTH: f32 = 90.0;
/// Height of the transport row, and the reason it is a constant.
///
/// `Align::Center` in a horizontal layout centres each widget against the row
/// height *known when that widget is added*, and the row only grows as it goes.
/// The play button is the tallest thing in the cluster and sits in the middle,
/// so everything left of it — previous, −10s — is centred against a shorter row
/// and lands a few pixels high. Calling `set_min_height` with the tallest
/// widget's height before adding anything gives every one of them the same
/// height to centre against.
const ROW_HEIGHT_FULL: f32 = 44.0;
/// The same, for the library strip. See [`ROW_HEIGHT_FULL`].
const ROW_HEIGHT_MINI: f32 = 34.0;
/// Whether there is a file before and after this one in the same title.
#[derive(Debug, Clone, Copy, Default)]
pub struct Neighbours {
@@ -45,6 +59,8 @@ pub fn full(
ui.add_space(6.0);
ui.horizontal(|ui| {
ui.set_min_height(ROW_HEIGHT_FULL);
// Left cluster: everything that moves the playhead, in the order a
// hand reaches for it.
step_button(
@@ -93,13 +109,15 @@ pub fn mini(
actions: &mut Vec<Action>,
) {
ui.horizontal(|ui| {
ui.set_min_height(ROW_HEIGHT_MINI);
// The way back to the picture. First, and a real button rather than a
// label, because leaving the player page with Esc is otherwise a
// 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))
.fill(theme::CARD_HOVER)
.fill(theme::card_hover())
.corner_radius(CornerRadius::same(8)),
)
.on_hover_text("Show the picture again")
@@ -167,15 +185,63 @@ pub fn mini(
}
/// The one button whose position should never move.
///
/// Painted rather than set as `▶`/`⏸` text. A glyph is centred by its advance
/// width, and `U+25B6`'s advance carries side bearings that are not symmetric —
/// in a round button that reads, correctly, as a triangle sitting off to one
/// side. It also comes from whichever fallback font has it, so the shape and its
/// weight vary by platform. Two polygons cost less than either problem.
fn play_pause(ui: &mut egui::Ui, playback: &Playback, actions: &mut Vec<Action>, size: f32) {
let symbol = if playback.paused { "▶" } else { "⏸" };
if ui
.add(
egui::Button::new(egui::RichText::new(symbol).size(size * 0.42))
.fill(theme::ACCENT)
.corner_radius(CornerRadius::same((size / 2.0) as u8))
.min_size(Vec2::splat(size)),
)
let (rect, response) = ui.allocate_exact_size(Vec2::splat(size), Sense::click());
let lift = if response.is_pointer_button_down_on() {
-0.18
} else if response.hovered() {
0.12
} else {
0.0
};
let painter = ui.painter();
// A rounded rectangle whose corner radius is its half-width *is* a circle,
// and unlike `circle_filled` it can be painted with a gradient.
theme::accent_fill(
painter,
rect,
CornerRadius::same((size / 2.0).round() as u8),
lift,
);
let centre = rect.center();
if playback.paused {
// A triangle's centroid is a third of the way from base to apex, not
// half — so centring its bounding box puts the visual mass left of the
// circle's centre, which is the usual reason a play button looks wrong.
// Placed by centroid instead: base at `centre.x - width / 3`.
let (width, height) = (size * 0.30, size * 0.34);
let base = centre.x - width / 3.0;
painter.add(egui::Shape::convex_polygon(
vec![
pos2(base, centre.y - height / 2.0),
pos2(base, centre.y + height / 2.0),
pos2(base + width, centre.y),
],
theme::on_accent(),
Stroke::NONE,
));
} else {
// Pause is symmetric, so it is centred the ordinary way.
let (bar, height, gap) = (size * 0.09, size * 0.32, size * 0.10);
for side in [-1.0f32, 1.0] {
let x = centre.x + side * (gap / 2.0 + bar / 2.0);
painter.rect_filled(
Rect::from_center_size(pos2(x, centre.y), Vec2::new(bar, height)),
CornerRadius::same((bar / 2.0).round() as u8),
theme::on_accent(),
);
}
}
if response
.on_hover_text(if playback.paused {
"Play (Space)"
} else {
@@ -232,7 +298,7 @@ fn times(ui: &mut egui::Ui, playback: &Playback, over_picture: bool) {
let (size, ink, dim) = if over_picture {
(13.0, Color32::WHITE, Color32::from_white_alpha(215))
} else {
(12.0, theme::MUTED, theme::MUTED)
(12.0, theme::muted(), theme::muted())
};
let time = |text: String| egui::RichText::new(text).size(size).color(ink);
@@ -413,7 +479,11 @@ fn seek_bar(ui: &mut egui::Ui, playback: &Playback, actions: &mut Vec<Action>, t
if let Some(progress) = playback.progress() {
let mut played = track;
played.set_width(track.width() * progress);
painter.rect_filled(played, CornerRadius::same(3), theme::ACCENT);
// The gradient is sized to the *played* part, not to the whole bar, so
// the far end of it arrives at the playhead rather than at the end of
// the file. A bar that only ever shows the first fifth of its own ramp
// does not read as a gradient at all.
theme::accent_fill(painter, played, CornerRadius::same(3), 0.0);
painter.circle_filled(
egui::pos2(played.right(), track.center().y),
if hovered { 8.0 } else { 6.0 },