mirror of
https://github.com/narrrl/proton-stream.git
synced 2026-11-04 16:57:00 +01:00
The library's banner now runs edge to edge and turns through up to five titles: the one watched last, then titles with a wide picture, starting from a different one each day. It moves on every eight seconds, holds still under the pointer, and has dots to pick a title. A part-watched title shows how far in the viewer is under its buttons. The veils over the banner and over a title page's band are tinted with the picture's own colour. The weighting and the veil live in pstr_core::appearance (mood, mood_veil), so the desktop and both phones, which reach them through the bridge, land on the same colour. On Android the banner is a HorizontalPager under a clear bar that fills in as it scrolls away; iOS uses a paged TabView the same way.
881 lines
29 KiB
Rust
881 lines
29 KiB
Rust
//! What the window wears.
|
|
//!
|
|
//! Only the *choice* lives here — the colours themselves are `pstr-app`'s, and
|
|
//! nothing outside the window has an opinion about them. This is stored beside
|
|
//! the playback preferences and read the same way: a file that will not parse is
|
|
//! a line in the log and a fall back to the defaults, because a broken theme
|
|
//! file must never be a reason the library does not open.
|
|
|
|
use crate::config::{AppDirs, read_json, write_json};
|
|
use crate::error::Result;
|
|
|
|
/// A palette family: Catppuccin's four, the one this app shipped with, and one
|
|
/// borrowed from a menu screen.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
|
|
#[serde(rename_all = "kebab-case")]
|
|
pub enum Flavor {
|
|
/// Near-black and Proton purple. The default, and the only one that is not
|
|
/// somebody else's palette.
|
|
#[default]
|
|
Proton,
|
|
/// Catppuccin Latte — the light one.
|
|
Latte,
|
|
Frappe,
|
|
Macchiato,
|
|
Mocha,
|
|
/// Black, white and one loud red — the Persona 5 menus.
|
|
Persona5,
|
|
}
|
|
|
|
impl Flavor {
|
|
pub const ALL: [Self; 6] = [
|
|
Self::Proton,
|
|
Self::Mocha,
|
|
Self::Macchiato,
|
|
Self::Frappe,
|
|
Self::Latte,
|
|
Self::Persona5,
|
|
];
|
|
|
|
pub fn label(self) -> &'static str {
|
|
match self {
|
|
Self::Proton => "Proton",
|
|
Self::Latte => "Catppuccin Latte",
|
|
Self::Frappe => "Catppuccin Frappé",
|
|
Self::Macchiato => "Catppuccin Macchiato",
|
|
Self::Mocha => "Catppuccin Mocha",
|
|
Self::Persona5 => "Persona 5",
|
|
}
|
|
}
|
|
|
|
pub fn description(self) -> &'static str {
|
|
match self {
|
|
Self::Proton => "Near-black, so the artwork is the only bright thing on screen",
|
|
Self::Latte => "Light. The only one that is",
|
|
Self::Frappe => "Dark, warm, the lowest contrast of the three",
|
|
Self::Macchiato => "Dark, between Frappé and Mocha",
|
|
Self::Mocha => "Dark, the deepest of Catppuccin's three",
|
|
Self::Persona5 => "Black and white with one loud red — pick the Red accent for it",
|
|
}
|
|
}
|
|
|
|
/// Whether this flavour is a light theme. Latte, and only Latte.
|
|
///
|
|
/// It decides more than the colours: egui keeps a style per theme and the
|
|
/// platform draws the title bar from the window's own, so both have to be
|
|
/// told which of the two this is.
|
|
pub fn is_light(self) -> bool {
|
|
matches!(self, Self::Latte)
|
|
}
|
|
}
|
|
|
|
/// The one strong colour in the window — and, where a gradient is drawn, the
|
|
/// hue it runs into.
|
|
///
|
|
/// Named for the first hue rather than for the pair, except where the pair *is*
|
|
/// the point: [`Accent::PinkSky`] is a gradient before it is a colour.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
|
|
#[serde(rename_all = "kebab-case")]
|
|
pub enum Accent {
|
|
/// Proton purple, and the equivalent hue in every Catppuccin flavour.
|
|
#[default]
|
|
Mauve,
|
|
Pink,
|
|
Sky,
|
|
/// Pink running into light blue.
|
|
PinkSky,
|
|
Lavender,
|
|
Blue,
|
|
Teal,
|
|
Peach,
|
|
/// The flavour's warning colour, used on purpose. Every flavour has one
|
|
/// because everything here needs a colour for a failure, and in
|
|
/// [`Flavor::Persona5`] it is the point of the palette rather than an
|
|
/// afterthought.
|
|
Red,
|
|
}
|
|
|
|
impl Accent {
|
|
pub const ALL: [Self; 9] = [
|
|
Self::Mauve,
|
|
Self::Pink,
|
|
Self::Sky,
|
|
Self::PinkSky,
|
|
Self::Lavender,
|
|
Self::Blue,
|
|
Self::Teal,
|
|
Self::Peach,
|
|
Self::Red,
|
|
];
|
|
|
|
pub fn label(self) -> &'static str {
|
|
match self {
|
|
Self::Mauve => "Mauve",
|
|
Self::Pink => "Pink",
|
|
Self::Sky => "Sky",
|
|
Self::PinkSky => "Pink → Sky",
|
|
Self::Lavender => "Lavender",
|
|
Self::Blue => "Blue",
|
|
Self::Teal => "Teal",
|
|
Self::Peach => "Peach",
|
|
Self::Red => "Red",
|
|
}
|
|
}
|
|
}
|
|
|
|
/// What shape the library's grid is.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
|
|
#[serde(rename_all = "kebab-case")]
|
|
pub enum Tiles {
|
|
/// 2:3 covers. The default, because the art a provider has for every
|
|
/// title is a cover; a 16:9 still exists for fewer of them, and a grid of
|
|
/// letterboxed covers was the desktop's weakest page.
|
|
#[default]
|
|
Posters,
|
|
/// 16:9 stills: the provider's fanart, else a frame from the file.
|
|
Stills,
|
|
}
|
|
|
|
impl Tiles {
|
|
pub const ALL: [Self; 2] = [Self::Posters, Self::Stills];
|
|
|
|
pub fn label(self) -> &'static str {
|
|
match self {
|
|
Self::Posters => "Posters",
|
|
Self::Stills => "Stills",
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The whole of the choice.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
|
// An older or hand-edited file is missing fields rather than invalid.
|
|
#[serde(default)]
|
|
pub struct Appearance {
|
|
pub flavor: Flavor,
|
|
pub accent: Accent,
|
|
/// Whether the accent is painted as a gradient — the seek bar, the play
|
|
/// button, the top bar — or flat.
|
|
///
|
|
/// A switch rather than a fixed decision because a gradient is the first
|
|
/// thing to go wrong on a screen that cannot show it: on 6-bit panels a
|
|
/// slow ramp across a wide bar bands visibly, and flat is better than
|
|
/// striped.
|
|
pub gradients: bool,
|
|
/// The library grid's shape.
|
|
pub tiles: Tiles,
|
|
/// Which name a title goes by.
|
|
pub names: crate::metadata::TitleNames,
|
|
}
|
|
|
|
impl Default for Appearance {
|
|
fn default() -> Self {
|
|
Self {
|
|
flavor: Flavor::default(),
|
|
accent: Accent::default(),
|
|
gradients: true,
|
|
tiles: Tiles::default(),
|
|
names: crate::metadata::TitleNames::default(),
|
|
}
|
|
}
|
|
}
|
|
|
|
fn appearance_file(dirs: &AppDirs) -> std::path::PathBuf {
|
|
dirs.config.join("appearance.json")
|
|
}
|
|
|
|
/// The stored choice, or the defaults on a first run.
|
|
pub fn load(dirs: &AppDirs) -> Result<Appearance> {
|
|
Ok(read_json::<Appearance>(&appearance_file(dirs))?.unwrap_or_default())
|
|
}
|
|
|
|
/// Write the choice.
|
|
pub fn save(dirs: &AppDirs, appearance: &Appearance) -> Result<()> {
|
|
write_json(&appearance_file(dirs), appearance)
|
|
}
|
|
|
|
/// One colour, 8 bits a channel, with no alpha.
|
|
///
|
|
/// Deliberately not a front end's colour type: this module resolves a choice
|
|
/// into colours for *both* clients, and neither egui's `Color32` nor Compose's
|
|
/// `Color` may appear here.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub struct Rgb {
|
|
pub r: u8,
|
|
pub g: u8,
|
|
pub b: u8,
|
|
}
|
|
|
|
impl Rgb {
|
|
pub const BLACK: Self = Self::new(0, 0, 0);
|
|
|
|
pub const fn new(r: u8, g: u8, b: u8) -> Self {
|
|
Self { r, g, b }
|
|
}
|
|
|
|
/// Packed as `0xAARRGGBB`, opaque — the form both Android and a hex literal
|
|
/// want.
|
|
pub const fn argb(self) -> u32 {
|
|
0xff00_0000 | ((self.r as u32) << 16) | ((self.g as u32) << 8) | self.b as u32
|
|
}
|
|
}
|
|
|
|
/// One channel from sRGB into linear light, as the sRGB transfer function
|
|
/// defines it. Blending has to happen here: mixing sRGB values directly darkens
|
|
/// the middle of a ramp between saturated hues, which is exactly where a
|
|
/// gradient is looked at.
|
|
fn linear(channel: u8) -> f32 {
|
|
let value = channel as f32 / 255.0;
|
|
if value <= 0.04045 {
|
|
value / 12.92
|
|
} else {
|
|
((value + 0.055) / 1.055).powf(2.4)
|
|
}
|
|
}
|
|
|
|
/// Back out of linear light, rounded to a channel.
|
|
fn gamma(value: f32) -> u8 {
|
|
let value = value.clamp(0.0, 1.0);
|
|
let encoded = if value <= 0.003_130_8 {
|
|
value * 12.92
|
|
} else {
|
|
1.055 * value.powf(1.0 / 2.4) - 0.055
|
|
};
|
|
(encoded * 255.0).round().clamp(0.0, 255.0) as u8
|
|
}
|
|
|
|
/// Blend two colours in linear light.
|
|
pub fn mix(from: Rgb, to: Rgb, t: f32) -> Rgb {
|
|
let blend = |from: u8, to: u8| gamma(linear(from) * (1.0 - t) + linear(to) * t);
|
|
Rgb::new(
|
|
blend(from.r, to.r),
|
|
blend(from.g, to.g),
|
|
blend(from.b, to.b),
|
|
)
|
|
}
|
|
|
|
/// Relative luminance, in linear light, as WCAG defines it.
|
|
pub fn luminance(color: Rgb) -> f32 {
|
|
0.2126 * linear(color.r) + 0.7152 * linear(color.g) + 0.0722 * linear(color.b)
|
|
}
|
|
|
|
/// WCAG contrast between two opaque colours: 1.0 for a colour against itself,
|
|
/// 21.0 for black against white.
|
|
fn contrast(one: Rgb, other: Rgb) -> 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.
|
|
pub fn worst_contrast(from: Rgb, to: Rgb, ink: Rgb) -> 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.
|
|
pub const MIN_CONTRAST: f32 = 3.0;
|
|
|
|
/// The colour a picture leaves behind: its pixels averaged, with the vivid
|
|
/// ones counting for more. `None` for a picture with nothing opaque in it.
|
|
///
|
|
/// Weighted rather than a plain mean, because a plain mean of almost any
|
|
/// still is a brownish grey — the sky and the shadows cancel out, and the one
|
|
/// red coat that makes the poster what it is barely moves the sum. Each pixel
|
|
/// counts once plus [`MOOD_CHROMA_WEIGHT`] times its chroma, so a saturated
|
|
/// patch pulls the result towards itself without a single pixel deciding it.
|
|
///
|
|
/// Takes whatever pixels the caller has, in any order and at any size: every
|
|
/// client hands over a picture already squeezed small, so this is a few
|
|
/// hundred values, not a frame.
|
|
pub fn mood(pixels: impl IntoIterator<Item = (Rgb, u8)>) -> Option<Rgb> {
|
|
let (mut r, mut g, mut b, mut total) = (0.0_f32, 0.0_f32, 0.0_f32, 0.0_f32);
|
|
for (pixel, alpha) in pixels {
|
|
// Half transparent and below is a logo's anti-aliasing or a poster's
|
|
// rounded corner, not the picture.
|
|
if alpha < 128 {
|
|
continue;
|
|
}
|
|
let high = pixel.r.max(pixel.g).max(pixel.b);
|
|
let low = pixel.r.min(pixel.g).min(pixel.b);
|
|
let weight = 1.0 + MOOD_CHROMA_WEIGHT * f32::from(high - low) / 255.0;
|
|
r += weight * f32::from(pixel.r);
|
|
g += weight * f32::from(pixel.g);
|
|
b += weight * f32::from(pixel.b);
|
|
total += weight;
|
|
}
|
|
(total > 0.0).then(|| {
|
|
let channel = |sum: f32| (sum / total).round().clamp(0.0, 255.0) as u8;
|
|
Rgb::new(channel(r), channel(g), channel(b))
|
|
})
|
|
}
|
|
|
|
/// How much more a fully saturated pixel counts towards [`mood`] than a grey
|
|
/// one.
|
|
pub const MOOD_CHROMA_WEIGHT: f32 = 4.0;
|
|
|
|
/// The brightest channel a mood is brought down to before it tints a dark
|
|
/// palette. Any brighter and the veil stops being the page with a hint of the
|
|
/// picture in it, and text over it loses the contrast the veil is there for.
|
|
pub const MOOD_DEPTH: u8 = 60;
|
|
|
|
/// How far a dark palette's veil moves from the page colour towards the mood.
|
|
pub const MOOD_DARK: f32 = 0.55;
|
|
|
|
/// The same for a light palette — much less, because a light page tinted as
|
|
/// hard reads as a coloured page rather than a lit one.
|
|
pub const MOOD_LIGHT: f32 = 0.12;
|
|
|
|
/// The page colour, tinted by a picture's [`mood`]: what the veils over a
|
|
/// title's backdrop are painted in, so each title's banner carries its own
|
|
/// light rather than the same flat colour.
|
|
///
|
|
/// On a dark palette the mood is first darkened to [`MOOD_DEPTH`] — only its
|
|
/// hue and saturation are wanted, never its brightness — and the page moves
|
|
/// [`MOOD_DARK`] of the way to it. A light palette moves [`MOOD_LIGHT`] of the
|
|
/// way to the mood as it is.
|
|
pub fn mood_veil(background: Rgb, mood: Rgb, light: bool) -> Rgb {
|
|
if light {
|
|
return mix(background, mood, MOOD_LIGHT);
|
|
}
|
|
let high = mood.r.max(mood.g).max(mood.b);
|
|
if high == 0 {
|
|
return mix(background, mood, MOOD_DARK);
|
|
}
|
|
let scale = f32::from(MOOD_DEPTH.min(high)) / f32::from(high);
|
|
let deep = |channel: u8| (f32::from(channel) * scale).round() as u8;
|
|
mix(
|
|
background,
|
|
Rgb::new(deep(mood.r), deep(mood.g), deep(mood.b)),
|
|
MOOD_DARK,
|
|
)
|
|
}
|
|
|
|
/// Every colour a client 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: Rgb,
|
|
/// The bars above and below it.
|
|
pub surface: Rgb,
|
|
/// The deepest colour in the flavour — text fields, wells.
|
|
pub sunken: Rgb,
|
|
/// Tiles, rows, forms.
|
|
pub card: Rgb,
|
|
pub card_hover: Rgb,
|
|
pub border: Rgb,
|
|
pub text: Rgb,
|
|
pub muted: Rgb,
|
|
/// The one strong colour.
|
|
pub accent: Rgb,
|
|
/// 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: Rgb,
|
|
/// The accent taken down into the background, for a pressed control and for
|
|
/// selection behind text.
|
|
pub accent_dim: Rgb,
|
|
/// 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: Rgb,
|
|
pub danger: Rgb,
|
|
/// Whether this is a light theme.
|
|
pub light: bool,
|
|
}
|
|
|
|
/// 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: Rgb,
|
|
mantle: Rgb,
|
|
crust: Rgb,
|
|
surface0: Rgb,
|
|
surface1: Rgb,
|
|
subtext0: Rgb,
|
|
text: Rgb,
|
|
pink: Rgb,
|
|
mauve: Rgb,
|
|
sky: Rgb,
|
|
sapphire: Rgb,
|
|
blue: Rgb,
|
|
lavender: Rgb,
|
|
teal: Rgb,
|
|
green: Rgb,
|
|
peach: Rgb,
|
|
yellow: Rgb,
|
|
red: Rgb,
|
|
light: bool,
|
|
}
|
|
|
|
const fn rgb(hex: u32) -> Rgb {
|
|
Rgb::new(
|
|
((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,
|
|
};
|
|
|
|
/// Persona 5's menus: black, white, and a red that is not asking permission.
|
|
///
|
|
/// The three colours that matter are `crust`/`base` (black), `text` (white) and
|
|
/// `red` — everything between them is that same red bled into the black, which
|
|
/// is what keeps a card from reading as grey furniture in a palette that has no
|
|
/// grey in it. The other hues exist because an accent can be any of them; they
|
|
/// are pushed to full saturation to sit on a black page, which is the one thing
|
|
/// this palette has in common with [`PROTON`].
|
|
const PERSONA5: Ramp = Ramp {
|
|
base: rgb(0x0a0708),
|
|
mantle: rgb(0x140d0f),
|
|
crust: rgb(0x000000),
|
|
surface0: rgb(0x1d1215),
|
|
surface1: rgb(0x33191d),
|
|
subtext0: rgb(0xb59a9d),
|
|
text: rgb(0xf5f2f2),
|
|
pink: rgb(0xff2e63),
|
|
mauve: rgb(0xb13bd6),
|
|
sky: rgb(0x2ec7ff),
|
|
sapphire: rgb(0x1f9bd0),
|
|
blue: rgb(0x2f5ee0),
|
|
lavender: rgb(0xa07bff),
|
|
teal: rgb(0x12c2a0),
|
|
green: rgb(0x2fbf4f),
|
|
peach: rgb(0xff7a1a),
|
|
yellow: rgb(0xffc400),
|
|
red: rgb(0xe60012),
|
|
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,
|
|
Flavor::Persona5 => PERSONA5,
|
|
}
|
|
}
|
|
|
|
/// 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) -> (Rgb, Rgb) {
|
|
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),
|
|
Accent::Red => (self.red, self.pink),
|
|
}
|
|
}
|
|
}
|
|
|
|
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.
|
|
pub 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: Rgb| {
|
|
if ramp.light {
|
|
mix(color, Rgb::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, Rgb::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::*;
|
|
|
|
fn dirs(root: &std::path::Path) -> AppDirs {
|
|
AppDirs {
|
|
config: root.to_path_buf(),
|
|
data: root.to_path_buf(),
|
|
cache: root.to_path_buf(),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn a_picture_of_one_colour_has_that_colour_as_its_mood() {
|
|
let teal = Rgb::new(20, 140, 150);
|
|
assert_eq!(mood(std::iter::repeat_n((teal, 255), 16)), Some(teal));
|
|
}
|
|
|
|
#[test]
|
|
fn a_vivid_patch_outweighs_as_much_grey_in_the_mood() {
|
|
let red = Rgb::new(220, 30, 30);
|
|
let grey = Rgb::new(120, 120, 120);
|
|
let found = mood([(red, 255), (grey, 255)]).unwrap();
|
|
assert!(found.r > 180, "{found:?}");
|
|
assert!(found.g < 60 && found.b < 60, "{found:?}");
|
|
}
|
|
|
|
#[test]
|
|
fn transparent_pixels_do_not_count_towards_the_mood() {
|
|
let blue = Rgb::new(30, 60, 200);
|
|
assert_eq!(
|
|
mood([(blue, 255), (Rgb::new(255, 255, 255), 0)]),
|
|
Some(blue)
|
|
);
|
|
assert_eq!(mood([(blue, 10)]), None);
|
|
assert_eq!(mood(std::iter::empty()), None);
|
|
}
|
|
|
|
#[test]
|
|
fn a_dark_veil_takes_the_moods_hue_but_never_its_brightness() {
|
|
let background = Rgb::new(11, 11, 15);
|
|
let veil = mood_veil(background, Rgb::new(255, 240, 40), false);
|
|
// Warmer than the page…
|
|
assert!(veil.r > background.r && veil.g > background.g, "{veil:?}");
|
|
// …and still dark enough to be a page under white text.
|
|
assert!(luminance(veil) < 0.05, "{veil:?}");
|
|
assert!(contrast(veil, Rgb::new(255, 255, 255)) > 12.0, "{veil:?}");
|
|
}
|
|
|
|
#[test]
|
|
fn a_light_veil_moves_only_a_little_way_to_the_mood() {
|
|
let background = Rgb::new(239, 241, 245);
|
|
let veil = mood_veil(background, Rgb::new(200, 20, 20), true);
|
|
assert!(veil.r >= veil.g, "{veil:?}");
|
|
assert!(luminance(veil) > 0.6, "{veil:?}");
|
|
}
|
|
|
|
#[test]
|
|
fn a_first_run_gets_the_palette_the_app_shipped_with() {
|
|
let appearance = Appearance::default();
|
|
assert_eq!(appearance.flavor, Flavor::Proton);
|
|
assert_eq!(appearance.accent, Accent::Mauve);
|
|
assert!(appearance.gradients);
|
|
}
|
|
|
|
#[test]
|
|
fn what_was_saved_is_what_loads() {
|
|
let temp = std::env::temp_dir().join(format!("pstr-appearance-{}", std::process::id()));
|
|
std::fs::create_dir_all(&temp).unwrap();
|
|
let dirs = dirs(&temp);
|
|
|
|
let appearance = Appearance {
|
|
flavor: Flavor::Mocha,
|
|
accent: Accent::PinkSky,
|
|
gradients: false,
|
|
..Appearance::default()
|
|
};
|
|
save(&dirs, &appearance).unwrap();
|
|
assert_eq!(load(&dirs).unwrap(), appearance);
|
|
|
|
std::fs::remove_dir_all(&temp).ok();
|
|
}
|
|
|
|
#[test]
|
|
fn a_file_written_before_a_field_existed_keeps_the_rest() {
|
|
let temp = std::env::temp_dir().join(format!("pstr-appearance-old-{}", std::process::id()));
|
|
std::fs::create_dir_all(&temp).unwrap();
|
|
let dirs = dirs(&temp);
|
|
|
|
std::fs::write(appearance_file(&dirs), r#"{"flavor":"mocha"}"#).unwrap();
|
|
let appearance = load(&dirs).unwrap();
|
|
assert_eq!(appearance.flavor, Flavor::Mocha);
|
|
assert_eq!(appearance.accent, Accent::default());
|
|
assert!(appearance.gradients);
|
|
|
|
std::fs::remove_dir_all(&temp).ok();
|
|
}
|
|
|
|
#[test]
|
|
fn only_latte_is_a_light_theme() {
|
|
for flavor in Flavor::ALL {
|
|
assert_eq!(flavor.is_light(), flavor == Flavor::Latte);
|
|
}
|
|
}
|
|
|
|
#[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,
|
|
..Appearance::default()
|
|
});
|
|
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 persona_5_is_black_with_that_red_in_it() {
|
|
let palette = Palette::resolve(Appearance {
|
|
flavor: Flavor::Persona5,
|
|
accent: Accent::Red,
|
|
gradients: false,
|
|
..Appearance::default()
|
|
});
|
|
assert_eq!(palette.accent, PERSONA5.red);
|
|
assert!(!palette.light);
|
|
// Black enough that the artwork is the brightest thing on the page:
|
|
// darker than the palette this app shipped with, which is the other
|
|
// near-black one.
|
|
assert!(luminance(palette.background) < luminance(PROTON.base));
|
|
}
|
|
|
|
#[test]
|
|
fn every_flavour_can_wear_its_own_warning_colour() {
|
|
for flavor in Flavor::ALL {
|
|
let palette = Palette::resolve(Appearance {
|
|
flavor,
|
|
accent: Accent::Red,
|
|
gradients: true,
|
|
..Appearance::default()
|
|
});
|
|
// The accent is the flavour's red — deepened on the light flavour,
|
|
// where it would otherwise be too bright to put a label on.
|
|
assert!(palette.accent.r > palette.accent.b, "{flavor:?}");
|
|
assert_ne!(palette.accent, palette.background, "{flavor:?}");
|
|
}
|
|
}
|
|
|
|
#[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 a_blend_stays_between_the_two_colours_it_blends() {
|
|
let (from, to) = (Rgb::new(0, 0, 0), Rgb::new(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));
|
|
}
|
|
}
|