mirror of
https://github.com/narrrl/proton-stream.git
synced 2026-11-04 00:47:00 +01:00
The AniList query now keeps the start date, season, banner, popularity, status, next airing episode, tags, main studios, director and story relations, as TitleDetails in one JSON column (schema v10). Matches stored without them are fetched again by id, never searched, so hand-picked matches stay; refreshing the same entry keeps its episodes and fanart. Enrichment records the entries it walked. node_first_seen remembers when each file appeared, across recrawls. pstr_core::franchise groups titles that share a provider id. The grid folds each franchise into its lead with a +N badge, and the title page lists the whole franchise in release order. pstr_core::shelves picks rows by airing status, director, studio and genre. A row needs three franchises, so one franchise's titles never make a row on their own. Desktop: - Covers are the default grid (Settings > Appearance > Library tiles). - The hero and title band use fanart or banner decoded at 1920 px. - New sorts: recently added, newest, highest rated, most popular. - Titles can show the share's, English or romaji name; search matches all of them. Android carries TitleDetails through a hand-picked match; its screens are unchanged.
761 lines
24 KiB
Rust
761 lines
24 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;
|
|
|
|
/// 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_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));
|
|
}
|
|
}
|