mirror of
https://github.com/narrrl/proton-stream.git
synced 2026-11-04 16:57:00 +01:00
751 lines
26 KiB
Rust
751 lines
26 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, Rgba, Stroke, SystemTheme, TextureHandle, TextureOptions, Theme,
|
|
ThemePreference, ViewportCommand, Visuals,
|
|
};
|
|
use parking_lot::RwLock;
|
|
use pstr_core::appearance::{Accent, Appearance, Flavor};
|
|
|
|
/// 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;
|
|
|
|
/// Every colour the app 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: 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. 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: Color32,
|
|
pub danger: Color32,
|
|
/// Whether this is a light theme.
|
|
pub light: bool,
|
|
}
|
|
|
|
/// 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 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 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));
|
|
}
|
|
|
|
/// 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)
|
|
}
|
|
|
|
/// 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(8);
|
|
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;
|
|
|
|
use egui::FontFamily::Proportional;
|
|
use egui::TextStyle::{Body, Button, Heading, Monospace, Small};
|
|
style.text_styles = [
|
|
(Heading, egui::FontId::new(26.0, Proportional)),
|
|
(Body, egui::FontId::new(14.0, Proportional)),
|
|
(Button, egui::FontId::new(14.0, Proportional)),
|
|
(Small, egui::FontId::new(11.5, Proportional)),
|
|
(
|
|
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.
|
|
ramps: BTreeMap<(u32, u32, bool), 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 {
|
|
let key = (
|
|
u32::from_le_bytes(from.to_array()),
|
|
u32::from_le_bytes(to.to_array()),
|
|
direction == Direction::Vertical,
|
|
);
|
|
if let Some(texture) = ACTIVE.read().ramps.get(&key) {
|
|
return texture.clone();
|
|
}
|
|
|
|
let pixels: Vec<Color32> = (0..RAMP_STOPS)
|
|
.map(|stop| mix(from, to, stop 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;
|
|
|
|
/// Blend two colours in linear light. Mixing in sRGB darkens the middle of a
|
|
/// ramp between saturated hues, which is exactly where a gradient is looked at.
|
|
fn mix(from: Color32, to: Color32, t: f32) -> Color32 {
|
|
let from = Rgba::from(from);
|
|
let to = Rgba::from(to);
|
|
Color32::from(from * (1.0 - t) + to * t)
|
|
}
|
|
|
|
/// Relative luminance, in linear light, as WCAG defines it.
|
|
fn luminance(color: Color32) -> f32 {
|
|
let rgba = Rgba::from(color);
|
|
0.2126 * rgba.r() + 0.7152 * rgba.g() + 0.0722 * rgba.b()
|
|
}
|
|
|
|
/// WCAG contrast between two opaque colours: 1.0 for a colour against itself,
|
|
/// 21.0 for black against white.
|
|
fn contrast(one: Color32, other: Color32) -> 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.
|
|
fn worst_contrast(from: Color32, to: Color32, ink: Color32) -> 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.
|
|
const MIN_CONTRAST: f32 = 3.0;
|
|
|
|
/// 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: Color32,
|
|
mantle: Color32,
|
|
crust: Color32,
|
|
surface0: Color32,
|
|
surface1: Color32,
|
|
subtext0: Color32,
|
|
text: Color32,
|
|
pink: Color32,
|
|
mauve: Color32,
|
|
sky: Color32,
|
|
sapphire: Color32,
|
|
blue: Color32,
|
|
lavender: Color32,
|
|
teal: Color32,
|
|
green: Color32,
|
|
peach: Color32,
|
|
yellow: Color32,
|
|
red: Color32,
|
|
light: bool,
|
|
}
|
|
|
|
const fn rgb(hex: u32) -> Color32 {
|
|
Color32::from_rgb(
|
|
((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,
|
|
};
|
|
|
|
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,
|
|
}
|
|
}
|
|
|
|
/// 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) -> (Color32, Color32) {
|
|
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),
|
|
}
|
|
}
|
|
}
|
|
|
|
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.
|
|
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: Color32| {
|
|
if ramp.light {
|
|
mix(color, Color32::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, Color32::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::*;
|
|
|
|
#[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,
|
|
});
|
|
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 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 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() {
|
|
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!(luminance(mix(from, to, 0.5)) > luminance(from));
|
|
assert!(luminance(mix(from, to, 0.5)) < luminance(to));
|
|
}
|
|
}
|