mirror of
https://github.com/narrrl/proton-stream.git
synced 2026-11-03 16:41:26 +01:00
bug fixes, windows build
This commit is contained in:
1 parent
a1b345a029
commit
14c48428a3
29 files changed
+2514
-170
No files matched your search
+181
-22
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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(" · ")
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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()),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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| {
|
||||
|
||||
@@ -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()),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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 },
|
||||
|
||||
Reference in new issue
Block a user