diff --git a/Cargo.lock b/Cargo.lock index 93ccef6..bea9b35 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1767,6 +1767,15 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "egui-phosphor" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "249826f9529c1757d71dbbb3a22ec101b21110ab663c45fb771c2b86121379e4" +dependencies = [ + "egui", +] + [[package]] name = "egui-wgpu" version = "0.35.0" @@ -4399,6 +4408,7 @@ dependencies = [ "directories", "eframe", "egui", + "egui-phosphor", "futures", "image", "parking_lot", diff --git a/Cargo.toml b/Cargo.toml index 14069ea..10667f1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -77,6 +77,9 @@ eframe = { version = "0.35", default-features = false, features = [ "x11", ] } egui = "0.35" +# The icons on buttons. Glyphs like โญ and ๐Ÿ”Š come out of whichever fallback +# font has them, at its own size and baseline; an icon font is one set. +egui-phosphor = { version = "0.13", default-features = false, features = ["regular"] } # Poster thumbnails only: Proton renders them as JPEG, and a decoder that also # pulls in every exotic format is weight this app has no use for. image = { version = "0.25", default-features = false, features = ["jpeg", "png", "webp"] } diff --git a/README.md b/README.md index 75249a2..e408807 100644 --- a/README.md +++ b/README.md @@ -115,3 +115,6 @@ see [`docs/ANDROID.md`](docs/ANDROID.md) for exact setup and release signing. The shared Rust crates and desktop application are MIT licensed. The Android application and its combined native distribution are GPL-3.0-or-later; see [`android/LICENSE.md`](android/LICENSE.md) and the bundled-component notices. +The desktop application embeds the Inter typeface, under the SIL Open Font +License 1.1 ([`crates/pstr-app/assets/fonts/Inter-OFL.txt`](crates/pstr-app/assets/fonts/Inter-OFL.txt)), +and the Phosphor icon font, under MIT. diff --git a/crates/pstr-app/Cargo.toml b/crates/pstr-app/Cargo.toml index b771049..b898285 100644 --- a/crates/pstr-app/Cargo.toml +++ b/crates/pstr-app/Cargo.toml @@ -24,6 +24,7 @@ tracing.workspace = true tracing-subscriber.workspace = true eframe.workspace = true egui.workspace = true +egui-phosphor.workspace = true image.workspace = true sha2.workspace = true parking_lot.workspace = true diff --git a/crates/pstr-app/assets/fonts/Inter-OFL.txt b/crates/pstr-app/assets/fonts/Inter-OFL.txt new file mode 100644 index 0000000..9b2ca37 --- /dev/null +++ b/crates/pstr-app/assets/fonts/Inter-OFL.txt @@ -0,0 +1,92 @@ +Copyright (c) 2016 The Inter Project Authors (https://github.com/rsms/inter) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION AND CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/crates/pstr-app/assets/fonts/Inter-Regular.ttf b/crates/pstr-app/assets/fonts/Inter-Regular.ttf new file mode 100644 index 0000000..b7aaca8 Binary files /dev/null and b/crates/pstr-app/assets/fonts/Inter-Regular.ttf differ diff --git a/crates/pstr-app/assets/fonts/Inter-SemiBold.ttf b/crates/pstr-app/assets/fonts/Inter-SemiBold.ttf new file mode 100644 index 0000000..47f8ab1 Binary files /dev/null and b/crates/pstr-app/assets/fonts/Inter-SemiBold.ttf differ diff --git a/crates/pstr-app/assets/icon-256.png b/crates/pstr-app/assets/icon-256.png new file mode 100644 index 0000000..9ce8ced Binary files /dev/null and b/crates/pstr-app/assets/icon-256.png differ diff --git a/crates/pstr-app/src/app.rs b/crates/pstr-app/src/app.rs index b92771b..2dc7fb1 100644 --- a/crates/pstr-app/src/app.rs +++ b/crates/pstr-app/src/app.rs @@ -479,7 +479,7 @@ impl App { // before switching is a window that flashes on every launch. let window = crate::window::WindowMemory::new(dirs.window_file()); let (engine, events) = Engine::new(runtime, dirs, cc.egui_ctx.clone())?; - theme::install_font_fallbacks(&cc.egui_ctx); + theme::install_fonts(&cc.egui_ctx); theme::apply(&cc.egui_ctx, engine.appearance()); // Paint from the catalog immediately; the network catches up. A library @@ -1493,7 +1493,10 @@ impl App { ui.add(egui::Spinner::new().size(16.0)); ui.label(ui::muted("connecting")); } else if ui - .button("Refresh") + .button(format!( + "{} Refresh", + egui_phosphor::regular::ARROW_CLOCKWISE + )) .on_hover_text("Re-crawl every share") .clicked() { diff --git a/crates/pstr-app/src/main.rs b/crates/pstr-app/src/main.rs index b71dfa4..0bb893d 100644 --- a/crates/pstr-app/src/main.rs +++ b/crates/pstr-app/src/main.rs @@ -32,8 +32,15 @@ fn main() -> anyhow::Result<()> { let dirs = AppDirs::ensure().context("resolve app directories")?; let window = pstr_app::window::WindowState::load(&dirs.window_file()); + let mut viewport = egui::ViewportBuilder::default(); + // X11 and Windows take the icon from the window. Wayland ignores this and + // looks the app id up in the installed desktop entry instead. + match eframe::icon_data::from_png_bytes(include_bytes!("../assets/icon-256.png")) { + Ok(icon) => viewport = viewport.with_icon(icon), + Err(error) => tracing::debug!("window icon: {error}"), + } let mut options = eframe::NativeOptions { - viewport: egui::ViewportBuilder::default() + viewport: viewport .with_title("proton-stream") .with_inner_size(window.map_or([1180.0, 780.0], |state| [state.width, state.height])) .with_maximized(window.is_some_and(|state| state.maximized)) diff --git a/crates/pstr-app/src/theme.rs b/crates/pstr-app/src/theme.rs index a3b1e84..8e099a6 100644 --- a/crates/pstr-app/src/theme.rs +++ b/crates/pstr-app/src/theme.rs @@ -84,26 +84,90 @@ impl Role { } } + /// Headings are set in the semibold cut; everything below them in the + /// regular one. + pub fn family(self) -> egui::FontFamily { + match self { + Self::Display | Self::Title | Self::Heading | Self::Section => semibold(), + _ => egui::FontFamily::Proportional, + } + } + /// For the hand-painted parts, which lay text out through a `Painter` and /// never see a `RichText`. pub fn font(self) -> egui::FontId { - egui::FontId::proportional(self.size()) + egui::FontId::new(self.size(), self.family()) } /// For the parts that go through egui's widgets. pub fn rich(self, text: impl Into) -> egui::RichText { - egui::RichText::new(text).size(self.size()) + egui::RichText::new(text) + .size(self.size()) + .family(self.family()) } } -/// Install a real CJK fallback before the default egui fonts. egui ships a -/// compact Latin font, but it intentionally does not bundle the multi-megabyte -/// CJK families. Native desktops already provide one, so use it when present. +/// The UI typeface, bundled rather than taken from the system so a title +/// sets the same on every desktop. Inter, under the OFL โ€” the licence is +/// beside the files. +const INTER: &[u8] = include_bytes!("../assets/fonts/Inter-Regular.ttf"); +const INTER_SEMIBOLD: &[u8] = include_bytes!("../assets/fonts/Inter-SemiBold.ttf"); + +/// The family the headings are set in. egui has no notion of weight, so the +/// semibold cut is a family of its own. +pub fn semibold() -> egui::FontFamily { + egui::FontFamily::Name("semibold".into()) +} + +/// Inter, the icon font, and egui's own fonts behind them for the emoji and +/// symbols Inter does not carry. `cjk` goes last, when there is one. +fn font_definitions(cjk: Option) -> egui::FontDefinitions { + use egui::FontFamily::{Monospace, Proportional}; + + let mut fonts = egui::FontDefinitions::default(); + fonts + .font_data + .insert("inter".into(), Arc::new(egui::FontData::from_static(INTER))); + fonts.font_data.insert( + "inter-semibold".into(), + Arc::new(egui::FontData::from_static(INTER_SEMIBOLD)), + ); + fonts + .families + .entry(Proportional) + .or_default() + .insert(0, "inter".into()); + // Right behind Inter. The icons are in the private use area, so nothing + // Inter has is shadowed by them. + egui_phosphor::add_to_fonts(&mut fonts, egui_phosphor::Variant::Regular); + if let Some(cjk) = cjk { + let name = "system-cjk-fallback".to_owned(); + fonts.font_data.insert(name.clone(), Arc::new(cjk)); + for family in [Proportional, Monospace] { + fonts.families.entry(family).or_default().push(name.clone()); + } + } + let fallbacks = fonts.families[&Proportional] + .iter() + .filter(|name| *name != "inter") + .cloned(); + let semibold_chain = std::iter::once("inter-semibold".to_owned()) + .chain(fallbacks) + .collect(); + fonts.families.insert(semibold(), semibold_chain); + fonts +} + +/// Install the bundled fonts now, and a CJK fallback when the system has one. /// -/// On a thread of its own: `fc-match` and reading a font file of tens of -/// megabytes held the first frame back by a visible moment, and every name -/// that needs the fallback is still loading from the catalog by then anyway. -pub fn install_font_fallbacks(ctx: &egui::Context) { +/// The bundled ones are compiled in and cost nothing to hand over. The CJK +/// fallback is on a thread of its own: `fc-match` and reading a font file of +/// tens of megabytes held the first frame back by a visible moment, and every +/// name that needs the fallback is still loading from the catalog by then +/// anyway. egui deliberately does not bundle the multi-megabyte CJK families, +/// and native desktops already provide one. +pub fn install_fonts(ctx: &egui::Context) { + ctx.set_fonts(font_definitions(None)); let ctx = ctx.clone(); let spawned = std::thread::Builder::new() .name("fonts".into()) @@ -120,16 +184,9 @@ fn install_cjk_fallback(ctx: &egui::Context) { let Ok(bytes) = std::fs::read(path) else { return; }; - - let mut fonts = egui::FontDefinitions::default(); let mut data = egui::FontData::from_owned(bytes); data.index = index; - let name = "system-cjk-fallback".to_owned(); - fonts.font_data.insert(name.clone(), Arc::new(data)); - for family in [egui::FontFamily::Proportional, egui::FontFamily::Monospace] { - fonts.families.entry(family).or_default().push(name.clone()); - } - ctx.set_fonts(fonts); + ctx.set_fonts(font_definitions(Some(data))); ctx.request_repaint(); } diff --git a/crates/pstr-app/src/ui/library.rs b/crates/pstr-app/src/ui/library.rs index 55fac6d..02ddc49 100644 --- a/crates/pstr-app/src/ui/library.rs +++ b/crates/pstr-app/src/ui/library.rs @@ -211,9 +211,11 @@ fn hero(ui: &mut egui::Ui, art: &mut Art<'_>, title: &Title, actions: &mut Vec format!("โ–ถ Resume {numbering}"), - (Some(_), None) => "โ–ถ Resume".to_owned(), - (None, _) => "โ–ถ Play".to_owned(), + (Some(_), Some(numbering)) => { + format!("{} Resume {numbering}", egui_phosphor::regular::PLAY) + } + (Some(_), None) => format!("{} Resume", egui_phosphor::regular::PLAY), + (None, _) => format!("{} Play", egui_phosphor::regular::PLAY), }; if ui::accent_button(ui, &label).clicked() { actions.push(Action::Play(PlaybackTarget::new(title, episode))); @@ -239,7 +241,7 @@ fn hero(ui: &mut egui::Ui, art: &mut Art<'_>, title: &Title, actions: &mut Vec String { parts.push(ui::library::plural(episodes as usize, "episode")); } if let Some(rating) = found.rating { - parts.push(format!("โ˜… {rating:.1}")); + parts.push(format!("{} {rating:.1}", egui_phosphor::regular::STAR)); } parts.join(" ยท ") } diff --git a/crates/pstr-app/src/ui/player.rs b/crates/pstr-app/src/ui/player.rs index e5a919c..f391843 100644 --- a/crates/pstr-app/src/ui/player.rs +++ b/crates/pstr-app/src/ui/player.rs @@ -657,14 +657,23 @@ fn chrome( }); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let fullscreen = ui.input(|input| input.viewport().fullscreen.unwrap_or(false)); if ui .add( - egui::Button::new(theme::Role::Subhead.rich("โ›ถ")) - .fill(Color32::from_black_alpha(160)) - .corner_radius(CornerRadius::same(8)) - .min_size(Vec2::new(38.0, 32.0)), + egui::Button::new(theme::Role::Subhead.rich(if fullscreen { + egui_phosphor::regular::CORNERS_IN + } else { + egui_phosphor::regular::CORNERS_OUT + })) + .fill(Color32::from_black_alpha(160)) + .corner_radius(CornerRadius::same(8)) + .min_size(Vec2::new(38.0, 32.0)), ) - .on_hover_text("Fullscreen (F)") + .on_hover_text(if fullscreen { + "Leave fullscreen (F)" + } else { + "Fullscreen (F)" + }) .clicked() { actions.push(Action::ToggleFullscreen); diff --git a/crates/pstr-app/src/ui/title.rs b/crates/pstr-app/src/ui/title.rs index 3d25d55..b3b6075 100644 --- a/crates/pstr-app/src/ui/title.rs +++ b/crates/pstr-app/src/ui/title.rs @@ -128,7 +128,7 @@ fn poster(ui: &mut egui::Ui, texture: &egui::TextureHandle, progress: Option egui::Response { - let text = theme::Role::Label.rich("โ† Library"); + let text = theme::Role::Label.rich(format!("{} Library", egui_phosphor::regular::ARROW_LEFT)); let response = ui.add(egui::Label::new(text.color(theme::muted())).sense(egui::Sense::click())); if response.hovered() { ui.painter().hline( @@ -532,7 +532,7 @@ fn meta_line(title: &Title, found: Option<&TitleMetadata>) -> String { if let Some(found) = found { if let Some(rating) = found.rating { - parts.push(format!("โ˜… {rating:.1}")); + parts.push(format!("{} {rating:.1}", egui_phosphor::regular::STAR)); } // Only when it disagrees with what is on disk: "25 episodes ยท 25 // episodes" tells nobody anything, but "12 episodes ยท 25 on AniList" diff --git a/crates/pstr-app/src/ui/transport.rs b/crates/pstr-app/src/ui/transport.rs index 574afa9..46a55ea 100644 --- a/crates/pstr-app/src/ui/transport.rs +++ b/crates/pstr-app/src/ui/transport.rs @@ -65,7 +65,7 @@ pub fn full( // hand reaches for it. step_button( ui, - "โฎ", + egui_phosphor::regular::SKIP_BACK, "Previous (P)", neighbours.previous, actions, @@ -76,9 +76,16 @@ pub fn full( skip_button(ui, "โˆ’10s", "Back ten seconds (โ†)", -10.0, actions); play_pause(ui, playback, actions, 44.0); skip_button(ui, "+30s", "Forward thirty seconds (โ†’)", 30.0, actions); - step_button(ui, "โญ", "Next (N)", neighbours.next, actions, |a| { - a.push(Action::PlayAdjacent(Adjacent::Next)); - }); + step_button( + ui, + egui_phosphor::regular::SKIP_FORWARD, + "Next (N)", + neighbours.next, + actions, + |a| { + a.push(Action::PlayAdjacent(Adjacent::Next)); + }, + ); ui.add_space(12.0); if playback.seeking || playback.buffering || !playback.loaded { @@ -202,13 +209,20 @@ pub fn mini( actions.push(Action::Player(Command::Stop)); } ui.add_space(6.0); - step_button(ui, "โญ", "Next (N)", neighbours.next, actions, |a| { - a.push(Action::PlayAdjacent(Adjacent::Next)); - }); + step_button( + ui, + egui_phosphor::regular::SKIP_FORWARD, + "Next (N)", + neighbours.next, + actions, + |a| { + a.push(Action::PlayAdjacent(Adjacent::Next)); + }, + ); play_pause(ui, playback, actions, 34.0); step_button( ui, - "โฎ", + egui_phosphor::regular::SKIP_BACK, "Previous (P)", neighbours.previous, actions, @@ -414,12 +428,14 @@ fn volume(ui: &mut egui::Ui, playback: &Playback, actions: &mut Vec) { } let icon = if playback.muted || playback.volume <= 0.0 { - "๐Ÿ”‡" + egui_phosphor::regular::SPEAKER_SLASH + } else if playback.volume < 50.0 { + egui_phosphor::regular::SPEAKER_LOW } else { - "๐Ÿ”Š" + egui_phosphor::regular::SPEAKER_HIGH }; if ui - .button(icon) + .button(theme::Role::Subhead.rich(icon)) .on_hover_text(format!("Mute (M) โ€” {:.0}%", playback.volume)) .clicked() { diff --git a/docs/DESIGN-TOKENS.md b/docs/DESIGN-TOKENS.md index 0b553c1..97443f7 100644 --- a/docs/DESIGN-TOKENS.md +++ b/docs/DESIGN-TOKENS.md @@ -35,8 +35,16 @@ screen and not for anything in a library grid). | Caption | 12 | context rather than content: counts, sizes, errors under a field | | Micro | 11 | the smallest thing that stays legible โ€” a badge over artwork | -Weight and leading are each side's own: egui has one weight, and Compose derives -line height as 1.35 ร— size rather than naming it nine more times. +Weight and leading are each side's own. The desktop bundles Inter +(`pstr-app/assets/fonts`, OFL) and sets Display, Title, Heading and Section in +its semibold cut, which egui sees as a family of its own (`theme::semibold`); +everything below Section is the regular cut. Compose derives line height as +1.35 ร— size rather than naming it nine more times. + +Icons on the desktop come from the Phosphor icon font (`egui-phosphor`, +regular weight), installed right behind Inter so an icon can sit inside an +ordinary label. Do not use Unicode symbols such as โญ or ๐Ÿ”Š as icons: they come +out of whichever fallback font has them, at that font's size and baseline. Both clients point their framework's *default* styles at the ramp โ€” egui's five `TextStyle`s, Compose's fifteen โ€” so a stock widget nobody restyled still lands