mirror of
https://github.com/narrrl/proton-stream.git
synced 2026-11-04 16:57:00 +01:00
894 lines
33 KiB
Rust
894 lines
33 KiB
Rust
//! 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 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 std::collections::BTreeMap;
|
|
use std::sync::Arc;
|
|
|
|
use egui::{
|
|
Color32, CornerRadius, Rect, Stroke, SystemTheme, TextureHandle, TextureOptions, Theme,
|
|
ThemePreference, ViewportCommand, Visuals,
|
|
};
|
|
use parking_lot::RwLock;
|
|
use pstr_core::appearance::Appearance;
|
|
|
|
/// Card geometry. The grid is built from these, so a change here moves
|
|
/// everything together.
|
|
pub const CARD_WIDTH: f32 = 232.0;
|
|
/// Video stills are 16:9. A poster shape would letterbox every one of them.
|
|
pub const CARD_ASPECT: f32 = 9.0 / 16.0;
|
|
pub const CARD_GAP: f32 = 14.0;
|
|
|
|
/// The type ramp, named once.
|
|
///
|
|
/// Every size in the app comes from here. They used to be literals at the call
|
|
/// site — `.size(17.0)` in one place and `.size(18.0)` in another for the same
|
|
/// kind of heading — which is not a scale, it is forty independent decisions
|
|
/// that happen to be close together. Naming them makes the ramp a thing that
|
|
/// can be changed, and gives the Android client something to match: a shared
|
|
/// palette settles colour, and this settles the other half.
|
|
///
|
|
/// Sizes rather than full `FontId`s because everything here is proportional;
|
|
/// the one monospace style in the app is egui's own, set in [`apply`].
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum Role {
|
|
/// The name of the thing you are looking at, once per page.
|
|
Display,
|
|
/// An empty state, or the heading of a page that has no display line.
|
|
Title,
|
|
/// The brand in the nav bar, and a dialog's heading.
|
|
Heading,
|
|
/// A run of content under a rule.
|
|
Section,
|
|
/// A row that groups the rows under it — a season header.
|
|
Subhead,
|
|
/// Everything that is prose.
|
|
Body,
|
|
/// The text on a small control.
|
|
Label,
|
|
/// Context rather than content: counts, sizes, errors under a field.
|
|
Caption,
|
|
/// The smallest thing that stays legible — a badge over artwork.
|
|
Micro,
|
|
}
|
|
|
|
impl Role {
|
|
pub const fn size(self) -> f32 {
|
|
match self {
|
|
Self::Display => 26.0,
|
|
Self::Title => 20.0,
|
|
Self::Heading => 18.0,
|
|
Self::Section => 17.0,
|
|
Self::Subhead => 15.0,
|
|
Self::Body => 14.0,
|
|
Self::Label => 13.0,
|
|
Self::Caption => 12.0,
|
|
Self::Micro => 11.0,
|
|
}
|
|
}
|
|
|
|
/// 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::new(self.size(), self.family())
|
|
}
|
|
|
|
/// For the parts that go through egui's widgets.
|
|
pub fn rich(self, text: impl Into<String>) -> egui::RichText {
|
|
egui::RichText::new(text)
|
|
.size(self.size())
|
|
.family(self.family())
|
|
}
|
|
}
|
|
|
|
/// The spacing scale. Gaps between things come from here rather than being
|
|
/// picked per call site, so two neighbouring groups are 12 apart or 16 apart
|
|
/// and never 14 in one place and 15 in the next.
|
|
pub mod space {
|
|
/// Between lines that belong to one thing: a title and its caption.
|
|
pub const XXS: f32 = 2.0;
|
|
pub const XS: f32 = 4.0;
|
|
/// Between controls in one cluster.
|
|
pub const S: f32 = 6.0;
|
|
pub const M: f32 = 8.0;
|
|
/// Between clusters on one row, and between rows of a form.
|
|
pub const L: f32 = 12.0;
|
|
/// Between the groups on a page.
|
|
pub const XL: f32 = 16.0;
|
|
/// Between sections.
|
|
pub const XXL: f32 = 24.0;
|
|
}
|
|
|
|
/// Corner radii, as `egui::CornerRadius::same` takes them.
|
|
pub mod radius {
|
|
/// A progress bar, or a strip along an edge.
|
|
pub const BAR: u8 = 3;
|
|
/// A badge over artwork. Android's `Shape.kt` has the same number.
|
|
pub const BADGE: u8 = 4;
|
|
/// A small control inside something else: a menu entry, a chip.
|
|
pub const SM: u8 = 6;
|
|
/// Everything else either client paints: cards, buttons, the tab pill.
|
|
pub const MD: u8 = 8;
|
|
/// Something that floats: a row card, a dialog, a toast, the hero.
|
|
pub const LG: u8 = 12;
|
|
}
|
|
|
|
/// How long things take to change, in seconds, for `Context::animate_*`.
|
|
pub mod motion {
|
|
/// A press, which has to answer before the finger is off the button.
|
|
pub const PRESS: f32 = 0.05;
|
|
/// A hover arriving and leaving.
|
|
pub const HOVER: f32 = 0.12;
|
|
/// A control changing state: a switch flipping, the tab pill travelling,
|
|
/// one page giving way to the next.
|
|
pub const STATE: f32 = 0.15;
|
|
/// Chrome fading in and out: the player's controls, a toast.
|
|
pub const FADE: f32 = 0.18;
|
|
/// Artwork arriving over its placeholder. Slower, because it replaces
|
|
/// something rather than adding to it, and a fast swap reads as a flicker.
|
|
pub const ARRIVE: f32 = 0.24;
|
|
|
|
/// Fast out, slow in: the curve every hand-driven transition uses.
|
|
pub fn ease_out(t: f32) -> f32 {
|
|
1.0 - (1.0 - t.clamp(0.0, 1.0)).powi(3)
|
|
}
|
|
}
|
|
|
|
/// 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.
|
|
///
|
|
/// Both files have their private use area mappings removed, because the
|
|
/// Phosphor icons live there. Inter maps 745 codepoints in that area to its
|
|
/// own alternate glyphs, and as the first font in the family it won: the
|
|
/// refresh arrow drew as an accented letter. The glyphs themselves are still
|
|
/// in the files; only the character map entries are gone. Strip them again
|
|
/// after any Inter update (fontTools: drop every `cmap` subtable entry in
|
|
/// U+E000..=U+F8FF).
|
|
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, which the
|
|
// bundled Inter no longer maps, so neither font shadows the other.
|
|
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.
|
|
///
|
|
/// 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())
|
|
.spawn(move || install_cjk_fallback(&ctx));
|
|
if let Err(error) = spawned {
|
|
tracing::debug!("font fallback thread: {error}");
|
|
}
|
|
}
|
|
|
|
fn install_cjk_fallback(ctx: &egui::Context) {
|
|
let Some((path, index)) = system_cjk_font() else {
|
|
return;
|
|
};
|
|
let Ok(bytes) = std::fs::read(path) else {
|
|
return;
|
|
};
|
|
let mut data = egui::FontData::from_owned(bytes);
|
|
data.index = index;
|
|
ctx.set_fonts(font_definitions(Some(data)));
|
|
ctx.request_repaint();
|
|
}
|
|
|
|
#[cfg(target_os = "linux")]
|
|
fn system_cjk_font() -> Option<(std::path::PathBuf, u32)> {
|
|
let output = std::process::Command::new("fc-match")
|
|
.args(["-f", "%{file}\\n%{index}", "Noto Sans CJK JP"])
|
|
.output()
|
|
.ok()?;
|
|
let text = std::str::from_utf8(&output.stdout).ok()?;
|
|
let mut lines = text.lines();
|
|
let path = std::path::PathBuf::from(lines.next()?);
|
|
let index = lines
|
|
.next()
|
|
.and_then(|value| value.parse().ok())
|
|
.unwrap_or(0);
|
|
path.is_file().then_some((path, index))
|
|
}
|
|
|
|
#[cfg(target_os = "windows")]
|
|
fn system_cjk_font() -> Option<(std::path::PathBuf, u32)> {
|
|
let path = std::path::PathBuf::from(r"C:\Windows\Fonts\YuGothR.ttc");
|
|
path.is_file().then_some((path, 0))
|
|
}
|
|
|
|
#[cfg(target_os = "macos")]
|
|
fn system_cjk_font() -> Option<(std::path::PathBuf, u32)> {
|
|
let path = std::path::PathBuf::from("/System/Library/Fonts/ヒラギノ角ゴシック W3.ttc");
|
|
path.is_file().then_some((path, 0))
|
|
}
|
|
|
|
#[cfg(not(any(target_os = "linux", target_os = "windows", target_os = "macos")))]
|
|
fn system_cjk_font() -> Option<(std::path::PathBuf, u32)> {
|
|
None
|
|
}
|
|
|
|
/// Every colour the app draws with, as egui wants them.
|
|
///
|
|
/// The *choice* of colours is `pstr_core::appearance` — one resolution shared
|
|
/// with the Android client, so a flavour means the same thing on both. This is
|
|
/// only that palette converted into `Color32`, which is a type `pstr-core`
|
|
/// deliberately knows nothing 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.
|
|
pub on_accent: Color32,
|
|
pub danger: Color32,
|
|
/// Whether this is a light theme.
|
|
pub light: bool,
|
|
}
|
|
|
|
const fn color(rgb: pstr_core::appearance::Rgb) -> Color32 {
|
|
Color32::from_rgb(rgb.r, rgb.g, rgb.b)
|
|
}
|
|
|
|
impl Palette {
|
|
/// The default, spelled out 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::of(pstr_core::appearance::Palette::PROTON);
|
|
|
|
const fn of(palette: pstr_core::appearance::Palette) -> Self {
|
|
Self {
|
|
background: color(palette.background),
|
|
surface: color(palette.surface),
|
|
sunken: color(palette.sunken),
|
|
card: color(palette.card),
|
|
card_hover: color(palette.card_hover),
|
|
border: color(palette.border),
|
|
text: color(palette.text),
|
|
muted: color(palette.muted),
|
|
accent: color(palette.accent),
|
|
accent_alt: color(palette.accent_alt),
|
|
accent_dim: color(palette.accent_dim),
|
|
on_accent: color(palette.on_accent),
|
|
danger: color(palette.danger),
|
|
light: palette.light,
|
|
}
|
|
}
|
|
|
|
/// Resolve a choice into colours.
|
|
pub fn resolve(appearance: Appearance) -> Self {
|
|
Self::of(pstr_core::appearance::Palette::resolve(appearance))
|
|
}
|
|
}
|
|
|
|
/// Blend two colours in linear light, through the shared implementation.
|
|
fn mix(from: Color32, to: Color32, t: f32) -> Color32 {
|
|
let rgb = |c: Color32| pstr_core::appearance::Rgb::new(c.r(), c.g(), c.b());
|
|
color(pstr_core::appearance::mix(rgb(from), rgb(to), t))
|
|
}
|
|
|
|
/// 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 ramps are drawn at all.
|
|
///
|
|
/// Wider than [`gradients`], which also asks whether the *accent* has two hues
|
|
/// to run between. The viewer who turns gradients off is usually on a panel that
|
|
/// bands them into stripes, and that applies to every ramp in the app, not only
|
|
/// the accent's — so the bar fade and the title backdrop ask this one.
|
|
pub fn ramps_on() -> bool {
|
|
ACTIVE.read().gradients
|
|
}
|
|
|
|
/// Whether the accent is currently drawn as a gradient.
|
|
///
|
|
/// True only when the viewer has gradients on *and* the accent is a pair of
|
|
/// 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 accent as a shape rather than paint, for the one caller that has to
|
|
/// draw it *behind* something already laid out: the pill that slides between
|
|
/// the tabs in the navigation bar, whose position is only known once every tab
|
|
/// has been placed.
|
|
pub fn accent_shape(
|
|
ctx: &egui::Context,
|
|
rect: Rect,
|
|
corner_radius: impl Into<CornerRadius>,
|
|
) -> egui::Shape {
|
|
let corner_radius = corner_radius.into();
|
|
let palette = palette();
|
|
if !ACTIVE.read().gradients || palette.accent == palette.accent_alt {
|
|
return egui::epaint::RectShape::filled(rect, corner_radius, palette.accent).into();
|
|
}
|
|
let texture = ramp(
|
|
ctx,
|
|
palette.accent,
|
|
palette.accent_alt,
|
|
Direction::Horizontal,
|
|
);
|
|
textured(rect, corner_radius, &texture)
|
|
}
|
|
|
|
/// The same, for a palette that is not the one on screen: the accent swatches
|
|
/// on the settings page, each of which previews the theme clicking it would
|
|
/// give.
|
|
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));
|
|
}
|
|
|
|
/// A rounded rectangle running from one colour to another, at `opacity`.
|
|
///
|
|
/// For colours that are not the palette's — a title's own, on a tile with no
|
|
/// picture. Drawn flat when the viewer has ramps off, like every other ramp.
|
|
pub fn ramp_fill(
|
|
painter: &egui::Painter,
|
|
rect: Rect,
|
|
corner_radius: CornerRadius,
|
|
(from, to): (Color32, Color32),
|
|
direction: Direction,
|
|
opacity: f32,
|
|
) {
|
|
let tint = Color32::WHITE.gamma_multiply(opacity);
|
|
if !ramps_on() {
|
|
painter.rect_filled(
|
|
rect,
|
|
corner_radius,
|
|
mix(from, to, 0.5).gamma_multiply(opacity),
|
|
);
|
|
return;
|
|
}
|
|
let texture = ramp(painter.ctx(), from, to, direction);
|
|
painter.add(
|
|
egui::epaint::RectShape::filled(rect, corner_radius, tint).with_texture(
|
|
texture.id(),
|
|
Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)),
|
|
),
|
|
);
|
|
}
|
|
|
|
/// A ramp from `color` to nothing, for laying artwork into the page.
|
|
///
|
|
/// The other ramps here run between two opaque colours; this one runs down its
|
|
/// own alpha, so what is under it shows through the far end. That is what makes
|
|
/// a backdrop end in the page rather than in a horizontal line across the
|
|
/// window.
|
|
/// `arriving` is which end of `direction` the colour is solid at: `false` for a
|
|
/// scrim that starts opaque and lets the picture out, `true` for one that lets
|
|
/// the picture *into* the page — which is the one the title backdrop wants,
|
|
/// since the page is below the band.
|
|
pub fn fade_shape(
|
|
ctx: &egui::Context,
|
|
rect: Rect,
|
|
color: Color32,
|
|
direction: Direction,
|
|
arriving: bool,
|
|
) -> egui::Shape {
|
|
let texture = alpha_ramp(ctx, color, direction, arriving);
|
|
textured(rect, CornerRadius::ZERO, &texture)
|
|
}
|
|
|
|
/// The one gradient that is not the accent: the top bar, which runs from the
|
|
/// surface colour down into the page so that the join between them is a fade
|
|
/// rather than an edge.
|
|
///
|
|
/// 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)
|
|
}
|
|
|
|
/// The shadow under a raised tile, at `strength` of its full depth.
|
|
///
|
|
/// Depth is the one separator this app has been doing without: everything is
|
|
/// told apart by fill colour, which works until two things that should be at
|
|
/// different heights are the same colour. Kept soft and offset downwards —
|
|
/// a shadow you can see the edge of reads as a border.
|
|
///
|
|
/// Scaled rather than switched on, so it can ride the same hover value as the
|
|
/// border and arrive with it.
|
|
pub fn tile_shadow(strength: f32) -> egui::epaint::Shadow {
|
|
shadow(strength, [0, 6], 16, 90)
|
|
}
|
|
|
|
/// The shadow a bar casts onto the page: shallower than a tile's, because a bar
|
|
/// is not floating over the page so much as sitting against it.
|
|
///
|
|
/// `cast_down` for the bar at the top of the window, off for the transport bar
|
|
/// at the bottom — a shadow that falls the same way on both would light the
|
|
/// window from two directions at once.
|
|
pub fn bar_shadow(cast_down: bool) -> egui::epaint::Shadow {
|
|
shadow(1.0, [0, if cast_down { 4 } else { -4 }], 12, 70)
|
|
}
|
|
|
|
fn shadow(strength: f32, offset: [i8; 2], blur: u8, alpha: u8) -> egui::epaint::Shadow {
|
|
let strength = strength.clamp(0.0, 1.0);
|
|
egui::epaint::Shadow {
|
|
offset,
|
|
blur,
|
|
spread: 0,
|
|
// A light theme cannot carry the same shadow: the same black over a
|
|
// near-white page is a smudge, where over a dark one it is barely
|
|
// visible. Half strength on light, which is roughly where the two read
|
|
// the same.
|
|
color: Color32::from_black_alpha(
|
|
(f32::from(alpha) * strength * if palette().light { 0.5 } else { 1.0 }) as u8,
|
|
),
|
|
}
|
|
}
|
|
|
|
/// The hover and press film on its own, for a control whose fill was painted by
|
|
/// somebody else — the selected tab, which sits on the pill.
|
|
pub fn veil_over(
|
|
painter: &egui::Painter,
|
|
rect: Rect,
|
|
corner_radius: impl Into<CornerRadius>,
|
|
lift: f32,
|
|
) {
|
|
veil(painter, rect, corner_radius.into(), lift);
|
|
}
|
|
|
|
/// A white or black film over `rect`, for hover and press states. A no-op at
|
|
/// zero, which is the case that matters — most controls are at rest.
|
|
fn veil(painter: &egui::Painter, rect: Rect, corner_radius: CornerRadius, lift: f32) {
|
|
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(radius::MD);
|
|
visuals.widgets.noninteractive.bg_fill = palette.surface;
|
|
visuals.widgets.noninteractive.corner_radius = radius;
|
|
visuals.widgets.noninteractive.bg_stroke = Stroke::new(1.0, palette.border);
|
|
|
|
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 = 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, palette.accent);
|
|
|
|
visuals.widgets.active.bg_fill = palette.accent_dim;
|
|
visuals.widgets.active.weak_bg_fill = palette.accent_dim;
|
|
visuals.widgets.active.corner_radius = radius;
|
|
|
|
// 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);
|
|
style.spacing.button_padding = egui::vec2(12.0, 7.0);
|
|
style.spacing.interact_size.y = 30.0;
|
|
style.spacing.scroll.bar_width = 10.0;
|
|
|
|
// egui's own style names, pointed at the ramp above so that a stock
|
|
// widget nobody restyled still lands on it.
|
|
use egui::TextStyle::{Body, Button, Heading, Monospace, Small};
|
|
style.text_styles = [
|
|
(Heading, Role::Display.font()),
|
|
(Body, Role::Body.font()),
|
|
(Button, Role::Body.font()),
|
|
(Small, Role::Caption.font()),
|
|
(
|
|
Monospace,
|
|
egui::FontId::new(12.5, egui::FontFamily::Monospace),
|
|
),
|
|
]
|
|
.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.
|
|
/// The last field separates a ramp between two colours from one that runs
|
|
/// down its own alpha — the two would otherwise collide on
|
|
/// `(colour, transparent)`.
|
|
ramps: BTreeMap<(u32, u32, bool, u8), TextureHandle>,
|
|
}
|
|
|
|
static ACTIVE: RwLock<Active> = RwLock::new(Active {
|
|
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 {
|
|
build(ctx, from, to, direction, 0, mix)
|
|
}
|
|
|
|
/// A ramp from `color` down to nothing.
|
|
///
|
|
/// The alpha is what is interpolated; the colour stays put. Fading the colour
|
|
/// as well would take a dark scrim through grey on its way out, which over
|
|
/// artwork reads as fog rather than as the picture ending.
|
|
fn alpha_ramp(
|
|
ctx: &egui::Context,
|
|
color: Color32,
|
|
direction: Direction,
|
|
arriving: bool,
|
|
) -> TextureHandle {
|
|
build(
|
|
ctx,
|
|
color,
|
|
Color32::TRANSPARENT,
|
|
direction,
|
|
1 + u8::from(arriving),
|
|
move |from, _, t| from.gamma_multiply(if arriving { t } else { 1.0 - t }),
|
|
)
|
|
}
|
|
|
|
/// One texture per ramp, built on demand and kept until the palette changes.
|
|
///
|
|
/// `kind` says which of the three kinds this is, so that a fade and a colour
|
|
/// ramp between the same two ends do not come out sharing one texture.
|
|
fn build(
|
|
ctx: &egui::Context,
|
|
from: Color32,
|
|
to: Color32,
|
|
direction: Direction,
|
|
kind: u8,
|
|
stop: impl Fn(Color32, Color32, f32) -> Color32,
|
|
) -> TextureHandle {
|
|
let key = (
|
|
u32::from_le_bytes(from.to_array()),
|
|
u32::from_le_bytes(to.to_array()),
|
|
direction == Direction::Vertical,
|
|
kind,
|
|
);
|
|
if let Some(texture) = ACTIVE.read().ramps.get(&key) {
|
|
return texture.clone();
|
|
}
|
|
|
|
let pixels: Vec<Color32> = (0..RAMP_STOPS)
|
|
.map(|index| stop(from, to, index 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;
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn the_bundled_inter_leaves_the_icon_codepoints_to_phosphor() {
|
|
use skrifa::MetadataProvider as _;
|
|
|
|
for (name, bytes) in [("regular", INTER), ("semibold", INTER_SEMIBOLD)] {
|
|
let font = skrifa::FontRef::new(bytes).expect("bundled Inter parses");
|
|
let shadowed: Vec<u32> = font
|
|
.charmap()
|
|
.mappings()
|
|
.map(|(codepoint, _)| codepoint)
|
|
.filter(|codepoint| (0xE000..=0xF8FF).contains(codepoint))
|
|
.collect();
|
|
assert!(
|
|
shadowed.is_empty(),
|
|
"Inter {name} maps {} private use codepoints, starting U+{:04X}",
|
|
shadowed.len(),
|
|
shadowed[0]
|
|
);
|
|
}
|
|
}
|
|
|
|
#[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() {
|
|
// The blend itself is `pstr_core::appearance`'s and tested there; what
|
|
// is checked here is that the conversion in and out of `Color32` is
|
|
// not lossy at the ends.
|
|
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_ne!(mix(from, to, 0.5), from);
|
|
}
|
|
}
|