feat(app): bundled Inter and Phosphor icons, window icon

Headings are set in Inter SemiBold, everything else in Inter Regular, so a
title sets the same on every desktop. Media and navigation glyphs (⏭, 🔊, ⛶,
★) come from the Phosphor icon font instead of whichever fallback font had
them. The CJK fallback still loads on its own thread and now joins the
bundled fonts instead of replacing them. The window carries the app icon
on X11 and Windows.
This commit is contained in:
nvrl committed 2026-10-02 23:07:05 +02:00
1 parent 6518aef5ee
commit 558446b9e4
17 files changed
+256 -45

No files matched your search

Generated
+10
View File
@@ -1767,6 +1767,15 @@ dependencies = [
"unicode-segmentation", "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]] [[package]]
name = "egui-wgpu" name = "egui-wgpu"
version = "0.35.0" version = "0.35.0"
@@ -4399,6 +4408,7 @@ dependencies = [
"directories", "directories",
"eframe", "eframe",
"egui", "egui",
"egui-phosphor",
"futures", "futures",
"image", "image",
"parking_lot", "parking_lot",
+3
View File
@@ -77,6 +77,9 @@ eframe = { version = "0.35", default-features = false, features = [
"x11", "x11",
] } ] }
egui = "0.35" 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 # 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. # pulls in every exotic format is weight this app has no use for.
image = { version = "0.25", default-features = false, features = ["jpeg", "png", "webp"] } image = { version = "0.25", default-features = false, features = ["jpeg", "png", "webp"] }
+3
View File
@@ -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 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 application and its combined native distribution are GPL-3.0-or-later; see
[`android/LICENSE.md`](android/LICENSE.md) and the bundled-component notices. [`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.
+1
View File
@@ -24,6 +24,7 @@ tracing.workspace = true
tracing-subscriber.workspace = true tracing-subscriber.workspace = true
eframe.workspace = true eframe.workspace = true
egui.workspace = true egui.workspace = true
egui-phosphor.workspace = true
image.workspace = true image.workspace = true
sha2.workspace = true sha2.workspace = true
parking_lot.workspace = true parking_lot.workspace = true
@@ -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.
Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

+5 -2
View File
@@ -479,7 +479,7 @@ impl App {
// before switching is a window that flashes on every launch. // before switching is a window that flashes on every launch.
let window = crate::window::WindowMemory::new(dirs.window_file()); let window = crate::window::WindowMemory::new(dirs.window_file());
let (engine, events) = Engine::new(runtime, dirs, cc.egui_ctx.clone())?; 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()); theme::apply(&cc.egui_ctx, engine.appearance());
// Paint from the catalog immediately; the network catches up. A library // 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.add(egui::Spinner::new().size(16.0));
ui.label(ui::muted("connecting")); ui.label(ui::muted("connecting"));
} else if ui } else if ui
.button("Refresh") .button(format!(
"{} Refresh",
egui_phosphor::regular::ARROW_CLOCKWISE
))
.on_hover_text("Re-crawl every share") .on_hover_text("Re-crawl every share")
.clicked() .clicked()
{ {
+8 -1
View File
@@ -32,8 +32,15 @@ fn main() -> anyhow::Result<()> {
let dirs = AppDirs::ensure().context("resolve app directories")?; let dirs = AppDirs::ensure().context("resolve app directories")?;
let window = pstr_app::window::WindowState::load(&dirs.window_file()); 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 { let mut options = eframe::NativeOptions {
viewport: egui::ViewportBuilder::default() viewport: viewport
.with_title("proton-stream") .with_title("proton-stream")
.with_inner_size(window.map_or([1180.0, 780.0], |state| [state.width, state.height])) .with_inner_size(window.map_or([1180.0, 780.0], |state| [state.width, state.height]))
.with_maximized(window.is_some_and(|state| state.maximized)) .with_maximized(window.is_some_and(|state| state.maximized))
+74 -17
View File
@@ -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 /// For the hand-painted parts, which lay text out through a `Painter` and
/// never see a `RichText`. /// never see a `RichText`.
pub fn font(self) -> egui::FontId { 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. /// For the parts that go through egui's widgets.
pub fn rich(self, text: impl Into<String>) -> egui::RichText { pub fn rich(self, text: impl Into<String>) -> 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 /// The UI typeface, bundled rather than taken from the system so a title
/// compact Latin font, but it intentionally does not bundle the multi-megabyte /// sets the same on every desktop. Inter, under the OFL — the licence is
/// CJK families. Native desktops already provide one, so use it when present. /// 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::FontData>) -> 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 /// The bundled ones are compiled in and cost nothing to hand over. The CJK
/// megabytes held the first frame back by a visible moment, and every name /// fallback is on a thread of its own: `fc-match` and reading a font file of
/// that needs the fallback is still loading from the catalog by then anyway. /// tens of megabytes held the first frame back by a visible moment, and every
pub fn install_font_fallbacks(ctx: &egui::Context) { /// 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 ctx = ctx.clone();
let spawned = std::thread::Builder::new() let spawned = std::thread::Builder::new()
.name("fonts".into()) .name("fonts".into())
@@ -120,16 +184,9 @@ fn install_cjk_fallback(ctx: &egui::Context) {
let Ok(bytes) = std::fs::read(path) else { let Ok(bytes) = std::fs::read(path) else {
return; return;
}; };
let mut fonts = egui::FontDefinitions::default();
let mut data = egui::FontData::from_owned(bytes); let mut data = egui::FontData::from_owned(bytes);
data.index = index; data.index = index;
let name = "system-cjk-fallback".to_owned(); ctx.set_fonts(font_definitions(Some(data)));
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.request_repaint(); ctx.request_repaint();
} }
+6 -4
View File
@@ -211,9 +211,11 @@ fn hero(ui: &mut egui::Ui, art: &mut Art<'_>, title: &Title, actions: &mut Vec<A
ui.horizontal(|ui| { ui.horizontal(|ui| {
if let Some(episode) = title.next_up() { if let Some(episode) = title.next_up() {
let label = match (title.resume(), episode.numbering()) { let label = match (title.resume(), episode.numbering()) {
(Some(_), Some(numbering)) => format!("▶ Resume {numbering}"), (Some(_), Some(numbering)) => {
(Some(_), None) => "▶ Resume".to_owned(), format!("{} Resume {numbering}", egui_phosphor::regular::PLAY)
(None, _) => "▶ Play".to_owned(), }
(Some(_), None) => format!("{} Resume", egui_phosphor::regular::PLAY),
(None, _) => format!("{} Play", egui_phosphor::regular::PLAY),
}; };
if ui::accent_button(ui, &label).clicked() { if ui::accent_button(ui, &label).clicked() {
actions.push(Action::Play(PlaybackTarget::new(title, episode))); 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<A
let mut facts = vec![subtitle(title)]; let mut facts = vec![subtitle(title)];
if let Some(found) = &found { if let Some(found) = &found {
if let Some(rating) = found.rating { if let Some(rating) = found.rating {
facts.push(format!("★ {rating:.1}")); facts.push(format!("{} {rating:.1}", egui_phosphor::regular::STAR));
} }
facts.extend(found.genres.iter().take(3).cloned()); facts.extend(found.genres.iter().take(3).cloned());
} }
+1 -1
View File
@@ -279,7 +279,7 @@ fn facts(found: &TitleMetadata) -> String {
parts.push(ui::library::plural(episodes as usize, "episode")); parts.push(ui::library::plural(episodes as usize, "episode"));
} }
if let Some(rating) = found.rating { if let Some(rating) = found.rating {
parts.push(format!("★ {rating:.1}")); parts.push(format!("{} {rating:.1}", egui_phosphor::regular::STAR));
} }
parts.join(" · ") parts.join(" · ")
} }
+14 -5
View File
@@ -657,14 +657,23 @@ fn chrome(
}); });
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { 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 if ui
.add( .add(
egui::Button::new(theme::Role::Subhead.rich("⛶")) egui::Button::new(theme::Role::Subhead.rich(if fullscreen {
.fill(Color32::from_black_alpha(160)) egui_phosphor::regular::CORNERS_IN
.corner_radius(CornerRadius::same(8)) } else {
.min_size(Vec2::new(38.0, 32.0)), 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() .clicked()
{ {
actions.push(Action::ToggleFullscreen); actions.push(Action::ToggleFullscreen);
+2 -2
View File
@@ -128,7 +128,7 @@ fn poster(ui: &mut egui::Ui, texture: &egui::TextureHandle, progress: Option<f64
/// "← Library", as a link rather than a button: it is navigation, and a /// "← Library", as a link rather than a button: it is navigation, and a
/// filled button beside the page's real actions competed with them. /// filled button beside the page's real actions competed with them.
fn back_link(ui: &mut egui::Ui) -> egui::Response { fn back_link(ui: &mut egui::Ui) -> 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())); let response = ui.add(egui::Label::new(text.color(theme::muted())).sense(egui::Sense::click()));
if response.hovered() { if response.hovered() {
ui.painter().hline( ui.painter().hline(
@@ -532,7 +532,7 @@ fn meta_line(title: &Title, found: Option<&TitleMetadata>) -> String {
if let Some(found) = found { if let Some(found) = found {
if let Some(rating) = found.rating { 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 // Only when it disagrees with what is on disk: "25 episodes · 25
// episodes" tells nobody anything, but "12 episodes · 25 on AniList" // episodes" tells nobody anything, but "12 episodes · 25 on AniList"
+27 -11
View File
@@ -65,7 +65,7 @@ pub fn full(
// hand reaches for it. // hand reaches for it.
step_button( step_button(
ui, ui,
"⏮", egui_phosphor::regular::SKIP_BACK,
"Previous (P)", "Previous (P)",
neighbours.previous, neighbours.previous,
actions, actions,
@@ -76,9 +76,16 @@ pub fn full(
skip_button(ui, "−10s", "Back ten seconds (←)", -10.0, actions); skip_button(ui, "−10s", "Back ten seconds (←)", -10.0, actions);
play_pause(ui, playback, actions, 44.0); play_pause(ui, playback, actions, 44.0);
skip_button(ui, "+30s", "Forward thirty seconds (→)", 30.0, actions); skip_button(ui, "+30s", "Forward thirty seconds (→)", 30.0, actions);
step_button(ui, "⏭", "Next (N)", neighbours.next, actions, |a| { step_button(
a.push(Action::PlayAdjacent(Adjacent::Next)); ui,
}); egui_phosphor::regular::SKIP_FORWARD,
"Next (N)",
neighbours.next,
actions,
|a| {
a.push(Action::PlayAdjacent(Adjacent::Next));
},
);
ui.add_space(12.0); ui.add_space(12.0);
if playback.seeking || playback.buffering || !playback.loaded { if playback.seeking || playback.buffering || !playback.loaded {
@@ -202,13 +209,20 @@ pub fn mini(
actions.push(Action::Player(Command::Stop)); actions.push(Action::Player(Command::Stop));
} }
ui.add_space(6.0); ui.add_space(6.0);
step_button(ui, "⏭", "Next (N)", neighbours.next, actions, |a| { step_button(
a.push(Action::PlayAdjacent(Adjacent::Next)); 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); play_pause(ui, playback, actions, 34.0);
step_button( step_button(
ui, ui,
"⏮", egui_phosphor::regular::SKIP_BACK,
"Previous (P)", "Previous (P)",
neighbours.previous, neighbours.previous,
actions, actions,
@@ -414,12 +428,14 @@ fn volume(ui: &mut egui::Ui, playback: &Playback, actions: &mut Vec<Action>) {
} }
let icon = if playback.muted || playback.volume <= 0.0 { 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 { } else {
"🔊" egui_phosphor::regular::SPEAKER_HIGH
}; };
if ui if ui
.button(icon) .button(theme::Role::Subhead.rich(icon))
.on_hover_text(format!("Mute (M) — {:.0}%", playback.volume)) .on_hover_text(format!("Mute (M) — {:.0}%", playback.volume))
.clicked() .clicked()
{ {
+10 -2
View File
@@ -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 | | Caption | 12 | context rather than content: counts, sizes, errors under a field |
| Micro | 11 | the smallest thing that stays legible — a badge over artwork | | 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 Weight and leading are each side's own. The desktop bundles Inter
line height as 1.35 × size rather than naming it nine more times. (`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 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 `TextStyle`s, Compose's fifteen — so a stock widget nobody restyled still lands