FONT_FAMILY is "Varela Round, sans-serif" but emission wrapped the whole string in quotes, so CSS looked up one family named that string. Emit 'Varela Round', sans-serif instead and lock that in the tests.
579 lines
24 KiB
Rust
579 lines
24 KiB
Rust
#[cfg(feature = "adw")]
|
||
pub mod adw;
|
||
#[cfg(feature = "gtk")]
|
||
pub mod gtk;
|
||
mod output;
|
||
pub mod palette;
|
||
|
||
pub use output::{
|
||
generate_output, load_palette_for, output_css_path, output_palette_path, palette_from_image,
|
||
palette_from_json, palettes_dir, sanitize_output, themes_dir, write_output_css,
|
||
write_output_palette, write_shared_css_from,
|
||
};
|
||
pub use palette::{load_palette, Palette};
|
||
|
||
/// Design tokens from BREAD_DESIGN_SYSTEM.md.
|
||
pub mod tokens {
|
||
pub const FONT_FAMILY: &str = "Varela Round, sans-serif";
|
||
pub const FONT_SIZE_BASE: u8 = 14;
|
||
pub const FONT_SIZE_SECONDARY: u8 = 12;
|
||
|
||
// Spacing scale (px, 4px units)
|
||
pub const SPACE_XS: u8 = 4;
|
||
pub const SPACE_SM: u8 = 8;
|
||
pub const SPACE_MD: u8 = 12;
|
||
pub const SPACE_LG: u8 = 16;
|
||
pub const SPACE_XL: u8 = 20;
|
||
|
||
// Border radius
|
||
pub const RADIUS_PRIMARY: u8 = 8;
|
||
pub const RADIUS_SECONDARY: u8 = 6;
|
||
pub const RADIUS_TERTIARY: u8 = 4;
|
||
pub const RADIUS_PILL: u16 = 999;
|
||
}
|
||
|
||
/// CSS `font-family` list: quote the named face, leave the generic fallback
|
||
/// unquoted. Wrapping [`tokens::FONT_FAMILY`] in one pair of quotes would
|
||
/// make a single family named "Varela Round, sans-serif" and drop sans-serif.
|
||
fn css_font_family() -> &'static str {
|
||
"'Varela Round', sans-serif"
|
||
}
|
||
|
||
/// Emit the `@define-color` block that all bread apps use, plus the shared
|
||
/// font rule.
|
||
///
|
||
/// Kept for API compatibility with older callers that only want the color
|
||
/// variables (not the full [`stylesheet`] component rules). It used to carry
|
||
/// its own hand-written `@define-color` block that predated the `accent` and
|
||
/// computed-ink (`on-*`) colors — that duplication is exactly what let it
|
||
/// drift out of sync and reintroduce the illegible-text bug (light pywal
|
||
/// colors + no computed ink meant white-on-white / black-on-black text
|
||
/// wherever a caller's own CSS referenced `@on-surface`, `@on-accent`, etc.,
|
||
/// since those names simply didn't exist in this block). It now delegates
|
||
/// to the same [`define_colors`] the full stylesheet uses, so there is only
|
||
/// one color-block implementation and it cannot drift again.
|
||
pub fn css_vars(p: &Palette) -> String {
|
||
format!(
|
||
"{vars}* {{ font-family: {font}; font-size: {size}px; }}\n",
|
||
vars = define_colors(p),
|
||
font = css_font_family(),
|
||
size = tokens::FONT_SIZE_BASE,
|
||
)
|
||
}
|
||
|
||
/// Relative luminance (WCAG, sRGB) of a `#rrggbb` colour, 0.0 (black) – 1.0 (white).
|
||
pub fn luminance(hex: &str) -> f32 {
|
||
let h = hex.trim_start_matches('#');
|
||
let lin = |i: usize| -> f32 {
|
||
let c = u8::from_str_radix(h.get(i..i + 2).unwrap_or("00"), 16).unwrap_or(0) as f32 / 255.0;
|
||
if c <= 0.04045 {
|
||
c / 12.92
|
||
} else {
|
||
((c + 0.055) / 1.055).powf(2.4)
|
||
}
|
||
};
|
||
0.2126 * lin(0) + 0.7152 * lin(2) + 0.0722 * lin(4)
|
||
}
|
||
|
||
/// Pick a legible ink (near-black or near-white) for text drawn on `hex`.
|
||
/// 0.179 is the WCAG crossover where contrast against black equals contrast
|
||
/// against white — so whichever side we pick always wins. This is what keeps
|
||
/// text readable no matter how light or dark pywal makes a given palette slot,
|
||
/// without altering the palette colours themselves.
|
||
pub fn ink_on(hex: &str) -> &'static str {
|
||
if luminance(hex) > 0.179 {
|
||
"#11111b"
|
||
} else {
|
||
"#f5f5f5"
|
||
}
|
||
}
|
||
|
||
/// Canonical (name, value) list: the single naming all bread apps share.
|
||
/// `surface` = color0 (darkest surface), `overlay` = color7 (muted), and
|
||
/// `accent` = color4. Apps must use these names, not raw palette slots, so the
|
||
/// whole ecosystem recolours together.
|
||
///
|
||
/// The `on-*` colours are computed ink (black/white) guaranteed to be legible on
|
||
/// the matching background — use `on-surface` for text on a `surface` panel,
|
||
/// `on-accent` on an `accent` button, etc. They exist because pywal can emit a
|
||
/// light value in any slot, and white text on a light surface disappears.
|
||
///
|
||
/// [`define_colors`] (GTK `@define-color`) and [`css_custom_properties`] (web
|
||
/// `:root { --name: ... }`) both format this same list rather than each
|
||
/// hand-writing their own — see `css_vars_and_stylesheet_agree_on_color_block`
|
||
/// and `css_custom_properties_matches_define_colors_name_set` for the
|
||
/// regression tests this exists to satisfy.
|
||
fn color_pairs(p: &Palette) -> [(&'static str, String); 16] {
|
||
[
|
||
("bg", p.background.clone()),
|
||
("fg", p.foreground.clone()),
|
||
("surface", p.color0.clone()),
|
||
("overlay", p.color7.clone()),
|
||
("accent", p.color4.clone()),
|
||
("red", p.color1.clone()),
|
||
("green", p.color2.clone()),
|
||
("yellow", p.color3.clone()),
|
||
("blue", p.color4.clone()),
|
||
("pink", p.color5.clone()),
|
||
("teal", p.color6.clone()),
|
||
("on-bg", ink_on(&p.background).to_string()),
|
||
("on-surface", ink_on(&p.color0).to_string()),
|
||
("on-accent", ink_on(&p.color4).to_string()),
|
||
("on-red", ink_on(&p.color1).to_string()),
|
||
("on-overlay", ink_on(&p.color7).to_string()),
|
||
]
|
||
}
|
||
|
||
/// GTK `@define-color` block built from [`color_pairs`].
|
||
fn define_colors(p: &Palette) -> String {
|
||
color_pairs(p)
|
||
.iter()
|
||
.map(|(name, value)| format!("@define-color {name} {value};\n"))
|
||
.collect()
|
||
}
|
||
|
||
/// CSS custom-properties block (`:root { --bg: ...; --on-accent: ...; }`) for
|
||
/// web frontends (Tauri), using the exact same names as [`define_colors`] so
|
||
/// the GTK and web outputs cannot drift apart independently — both are
|
||
/// generated from [`color_pairs`], not two hand-written copies.
|
||
pub fn css_custom_properties(p: &Palette) -> String {
|
||
let vars: String = color_pairs(p)
|
||
.iter()
|
||
.map(|(name, value)| format!(" --{name}: {value};\n"))
|
||
.collect();
|
||
format!(":root {{\n{vars}}}\n")
|
||
}
|
||
|
||
/// CSS custom-properties for [`tokens`] (font, spacing, radii) — the web
|
||
/// counterpart to [`tokens`] being hand-read by GTK code, so a web frontend
|
||
/// isn't hand-copying the same numbers into a second source of truth.
|
||
pub fn css_tokens() -> String {
|
||
use tokens::*;
|
||
format!(
|
||
":root {{\n\
|
||
\x20\x20--font-family: {font};\n\
|
||
\x20\x20--font-size-base: {base}px;\n\
|
||
\x20\x20--font-size-secondary: {sec}px;\n\
|
||
\x20\x20--space-xs: {xs}px;\n\
|
||
\x20\x20--space-sm: {sm}px;\n\
|
||
\x20\x20--space-md: {md}px;\n\
|
||
\x20\x20--space-lg: {lg}px;\n\
|
||
\x20\x20--space-xl: {xl}px;\n\
|
||
\x20\x20--radius-primary: {r1}px;\n\
|
||
\x20\x20--radius-secondary: {r2}px;\n\
|
||
\x20\x20--radius-tertiary: {r3}px;\n\
|
||
\x20\x20--radius-pill: {pill}px;\n\
|
||
}}\n",
|
||
font = css_font_family(),
|
||
base = FONT_SIZE_BASE,
|
||
sec = FONT_SIZE_SECONDARY,
|
||
xs = SPACE_XS,
|
||
sm = SPACE_SM,
|
||
md = SPACE_MD,
|
||
lg = SPACE_LG,
|
||
xl = SPACE_XL,
|
||
r1 = RADIUS_PRIMARY,
|
||
r2 = RADIUS_SECONDARY,
|
||
r3 = RADIUS_TERTIARY,
|
||
pill = RADIUS_PILL,
|
||
)
|
||
}
|
||
|
||
/// The full shared component stylesheet — the single source of truth for how
|
||
/// every bread GUI (bos-settings, breadbar, breadbox, breadpad, breadman) styles
|
||
/// common widgets. Apps load this, then append only their own *layout* rules.
|
||
///
|
||
/// Built entirely from the design tokens (font, spacing, radii) and the
|
||
/// `@define-color` palette, so changing the palette recolours every app.
|
||
pub fn stylesheet(p: &Palette) -> String {
|
||
use tokens::*;
|
||
format!(
|
||
"{vars}\
|
||
* {{ font-family: {font}; font-size: {base}px; }}\n\
|
||
/* Colour is set on containers; labels inherit it, so text on any panel,\
|
||
button, or accent is always the legible ink for that background. Bare\
|
||
`label {{ color }}` is deliberately avoided — as a type selector it\
|
||
would override a container's colour on its own child labels. */\n\
|
||
window {{ background-color: @bg; color: @on-bg; }}\n\
|
||
.dim-label, .dim {{ opacity: 0.6; font-size: {sec}px; }}\n\
|
||
/* Named `.page-title`, not the more obvious `.title` - libadwaita's\
|
||
own row/window-title widgets (AdwActionRow, AdwWindowTitle, GtkHeaderBar)\
|
||
put a bare `title` CSS class on their internal label, so a generic\
|
||
`.title` rule here would inflate every libadwaita row's title text\
|
||
to 1.4em too (this is exactly what caused the settings screen's\
|
||
~24px row-title bug). Scoping the name avoids the collision instead\
|
||
of trying to out-specificity a first-party GTK/libadwaita class. */\n\
|
||
.page-title {{ font-size: 1.4em; font-weight: bold; }}\n\
|
||
.heading {{ font-weight: bold; opacity: 0.85; }}\n\
|
||
/* Same libadwaita-collision reasoning as `.page-title` above - a bare\
|
||
`.subtitle` also matches libadwaita's internal row-subtitle labels.\
|
||
Unused by any app today, but scoped so a future caller doesn't\
|
||
reintroduce the fight. */\n\
|
||
.page-subtitle {{ opacity: 0.7; font-size: {sec}px; }}\n\
|
||
button {{ background-color: @surface; color: @on-surface; border: none;\
|
||
border-radius: {r1}px; padding: {sm}px {lg}px; }}\n\
|
||
button:hover {{ background-color: alpha(@on-surface, 0.14); }}\n\
|
||
button:active {{ background-color: alpha(@on-surface, 0.20); }}\n\
|
||
button:disabled {{ opacity: 0.5; }}\n\
|
||
button.flat {{ background-color: transparent; color: @on-bg; }}\n\
|
||
button.suggested-action {{ background-color: @accent; color: @on-accent; }}\n\
|
||
button.suggested-action:hover {{ background-color: alpha(@accent, 0.85); }}\n\
|
||
/* Deliberately NOT @red: pywal can hand `red` any hue depending on\
|
||
the wallpaper (a blue-toned wallpaper's \"red\" slot can literally\
|
||
render blue), which would make a destructive action indistinguishable\
|
||
from a normal accent button - exactly backwards for a warning colour.\
|
||
GNOME's own destructive-action is a fixed red for the same reason;\
|
||
this is the one button style in the whole system that intentionally\
|
||
doesn't follow the palette. */\n\
|
||
button.destructive-action {{ background-color: #e01b24; color: #ffffff; }}\n\
|
||
button.destructive-action:hover {{ background-color: #c01c28; }}\n\
|
||
entry, spinbutton {{ background-color: @surface; color: @on-surface;\
|
||
border: 1px solid @overlay; border-radius: {r2}px;\
|
||
padding: {xs}px {sm}px; caret-color: @on-surface; }}\n\
|
||
entry:focus-within, spinbutton:focus-within {{ border-color: @accent; outline: none; }}\n\
|
||
entry image, spinbutton button {{ color: @on-surface; }}\n\
|
||
dropdown > button {{ background-color: @surface; color: @on-surface; border-radius: {r2}px; }}\n\
|
||
popover > contents {{ background-color: @surface; color: @on-surface; border-radius: {r1}px; }}\n\
|
||
switch {{ background-color: @overlay; border-radius: {pill}px; }}\n\
|
||
switch:checked {{ background-color: @accent; }}\n\
|
||
switch slider {{ background-color: @on-surface; border-radius: {pill}px; }}\n\
|
||
/* GtkScale (sliders) render with GTK's own default accent (a fixed\
|
||
blue, independent of the app's theme) unless styled explicitly —\
|
||
every app with a volume/brightness slider was silently showing\
|
||
that default instead of the palette's accent until this rule\
|
||
existed. */\n\
|
||
scale trough {{ background-color: @overlay; border-radius: {pill}px; min-height: 6px; }}\n\
|
||
scale trough highlight {{ background-color: @accent; border-radius: {pill}px; min-height: 6px; }}\n\
|
||
scale slider {{ background-color: @on-bg; border-radius: {pill}px; }}\n\
|
||
list, listbox {{ background-color: transparent; }}\n\
|
||
/* libadwaita's AdwPreferencesGroup wraps its rows in a GtkListBox\
|
||
carrying the `boxed-list` class, expecting a surface fill + radius\
|
||
to read as a card. The bare-type rule above (needed so plain\
|
||
GTK4 sidebars/lists stay transparent) was overriding that with\
|
||
equal specificity and no fill ever won, leaving preference groups\
|
||
as a bare bordered table instead of a card. This is scoped to the\
|
||
class only, so it doesn't touch any non-adw list. */\n\
|
||
list.boxed-list, listbox.boxed-list {{ background-color: @surface; border-radius: {r1}px; }}\n\
|
||
row {{ border-radius: {r2}px; }}\n\
|
||
row:selected, list row:selected {{ background-color: @accent; color: @on-accent; }}\n\
|
||
.sidebar {{ background-color: @surface; color: @on-surface; }}\n\
|
||
.sidebar row {{ padding: {sm}px {md}px; }}\n\
|
||
.sidebar row:selected {{ background-color: @accent; color: @on-accent; }}\n\
|
||
.sidebar .section-header {{ padding: {md}px {md}px {xs}px {md}px;\
|
||
font-size: {sec}px; font-weight: bold; opacity: 0.55; }}\n\
|
||
.card {{ background-color: @surface; color: @on-surface; border-radius: {r1}px; padding: {md}px; }}\n\
|
||
.chip, .pill {{ background-color: @overlay; color: @on-overlay; border-radius: {pill}px;\
|
||
padding: {xs}px {md}px; font-size: {sec}px; }}\n\
|
||
.chip.active, .pill.active {{ background-color: @accent; color: @on-accent; }}\n\
|
||
scrollbar {{ background-color: transparent; }}\n\
|
||
scrollbar slider {{ background-color: alpha(@on-bg, 0.25); border-radius: {pill}px;\
|
||
min-width: 6px; min-height: 6px; }}\n\
|
||
scrollbar slider:hover {{ background-color: alpha(@on-bg, 0.45); }}\n\
|
||
textview, .mono {{ font-family: monospace; }}\n\
|
||
textview text {{ background-color: @surface; color: @on-surface; }}\n",
|
||
vars = define_colors(p),
|
||
font = css_font_family(),
|
||
base = FONT_SIZE_BASE,
|
||
sec = FONT_SIZE_SECONDARY,
|
||
xs = SPACE_XS, sm = SPACE_SM, md = SPACE_MD, lg = SPACE_LG,
|
||
r1 = RADIUS_PRIMARY, r2 = RADIUS_SECONDARY, pill = RADIUS_PILL,
|
||
)
|
||
}
|
||
|
||
/// Render the shared stylesheet for the current (pywal) palette. Used by the
|
||
/// `bread-theme` generator and as the in-app fallback when the generated file
|
||
/// isn't present yet.
|
||
pub fn render() -> String {
|
||
stylesheet(&load_palette())
|
||
}
|
||
|
||
/// Canonical path of the generated shared stylesheet. Apps load it; the
|
||
/// `bread-theme generate` CLI writes it. Per-session under `XDG_RUNTIME_DIR`,
|
||
/// falling back to the cache dir.
|
||
pub fn shared_css_path() -> std::path::PathBuf {
|
||
output::runtime_bread_dir().join("theme.css")
|
||
}
|
||
|
||
/// Write the shared stylesheet to [`shared_css_path`] (atomic rename). Returns
|
||
/// the path written. Used by the `bread-theme` CLI.
|
||
pub fn write_shared_css() -> std::io::Result<std::path::PathBuf> {
|
||
write_shared_css_from(&load_palette())
|
||
}
|
||
|
||
/// `stylesheet()` with `@name` references in rule bodies replaced by hex.
|
||
/// Longer names first (`on-surface` before `surface`, `on-bg` before `bg`)
|
||
/// so a prefix match cannot half-replace `@on-bg`.
|
||
pub fn stylesheet_resolved(p: &Palette) -> String {
|
||
resolve_color_names(&stylesheet(p), p)
|
||
}
|
||
|
||
/// Replace `@define-color` names (`@accent`, `@on-bg`, …) with hex values.
|
||
/// Used by [`stylesheet_resolved`] and by GTK `bind_window` so display-global
|
||
/// named colors cannot leak the wrong monitor's accent.
|
||
pub(crate) fn resolve_color_names(css: &str, p: &Palette) -> String {
|
||
let mut pairs: Vec<(&str, String)> = color_pairs(p).into_iter().collect();
|
||
pairs.sort_by(|a, b| b.0.len().cmp(&a.0.len()));
|
||
let mut out = css.to_string();
|
||
for (name, value) in pairs {
|
||
out = out.replace(&format!("@{name}"), &value);
|
||
}
|
||
out
|
||
}
|
||
|
||
/// Convert a `#rrggbb` hex colour to `rgba(r, g, b, alpha)`.
|
||
pub fn hex_to_rgba(hex: &str, alpha: f32) -> String {
|
||
let h = hex.trim_start_matches('#');
|
||
let r = u8::from_str_radix(h.get(0..2).unwrap_or("00"), 16).unwrap_or(0);
|
||
let g = u8::from_str_radix(h.get(2..4).unwrap_or("00"), 16).unwrap_or(0);
|
||
let b = u8::from_str_radix(h.get(4..6).unwrap_or("00"), 16).unwrap_or(0);
|
||
format!("rgba({r}, {g}, {b}, {alpha})")
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn css_vars_contains_all_define_color_names() {
|
||
let css = css_vars(&Palette::default());
|
||
for name in &[
|
||
"bg", "fg", "surface", "red", "green", "yellow", "blue", "pink", "teal", "overlay",
|
||
] {
|
||
assert!(
|
||
css.contains(&format!("@define-color {name} ")),
|
||
"missing @define-color {name}"
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn css_vars_contains_font_rule() {
|
||
let css = css_vars(&Palette::default());
|
||
assert!(css.contains("font-family: 'Varela Round', sans-serif;"));
|
||
assert!(
|
||
!css.contains("font-family: 'Varela Round, sans-serif'"),
|
||
"named face and generic fallback must not be one quoted family"
|
||
);
|
||
assert!(css.contains("14px"));
|
||
}
|
||
|
||
#[test]
|
||
fn css_vars_includes_accent_and_computed_ink_colors() {
|
||
// Regression test: css_vars() used to be a second, hand-written
|
||
// @define-color block that predated `accent` and the computed `on-*`
|
||
// ink colors. Any caller whose own CSS referenced `@on-surface` /
|
||
// `@on-accent` etc. against that older block would hit an undefined
|
||
// color name — the illegible-text bug. css_vars() must now emit
|
||
// exactly the same color set as the full stylesheet.
|
||
let css = css_vars(&Palette::default());
|
||
for name in &[
|
||
"accent",
|
||
"on-bg",
|
||
"on-surface",
|
||
"on-accent",
|
||
"on-red",
|
||
"on-overlay",
|
||
] {
|
||
assert!(
|
||
css.contains(&format!("@define-color {name} ")),
|
||
"missing @define-color {name}"
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn css_vars_and_stylesheet_agree_on_color_block() {
|
||
// Both must derive their color variables from the same
|
||
// `define_colors` implementation, so they can't drift apart again.
|
||
let p = Palette::default();
|
||
let vars = css_vars(&p);
|
||
let sheet = stylesheet(&p);
|
||
for name in &[
|
||
"bg",
|
||
"fg",
|
||
"surface",
|
||
"overlay",
|
||
"accent",
|
||
"on-bg",
|
||
"on-surface",
|
||
"on-accent",
|
||
] {
|
||
let needle = format!("@define-color {name} ");
|
||
assert!(vars.contains(&needle) && sheet.contains(&needle));
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn stylesheet_defines_canonical_colors_and_components() {
|
||
let css = stylesheet(&Palette::default());
|
||
for name in &["bg", "fg", "surface", "overlay", "accent", "red", "blue"] {
|
||
assert!(
|
||
css.contains(&format!("@define-color {name} ")),
|
||
"missing @define-color {name}"
|
||
);
|
||
}
|
||
// a representative spread of the shared component selectors
|
||
for sel in &[
|
||
"button",
|
||
"entry",
|
||
"switch:checked",
|
||
".card",
|
||
".sidebar",
|
||
"scrollbar slider",
|
||
".page-title",
|
||
] {
|
||
assert!(css.contains(sel), "stylesheet missing selector: {sel}");
|
||
}
|
||
assert!(css.contains("font-family: 'Varela Round', sans-serif;"));
|
||
assert!(
|
||
!css.contains("font-family: 'Varela Round, sans-serif'"),
|
||
"named face and generic fallback must not be one quoted family"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn css_custom_properties_matches_define_colors_name_set() {
|
||
// Both must derive from the same color_pairs() list, so the web
|
||
// output can't drift from the GTK one the way css_vars/stylesheet
|
||
// used to (see css_vars_and_stylesheet_agree_on_color_block above).
|
||
let p = Palette::default();
|
||
let gtk = define_colors(&p);
|
||
let web = css_custom_properties(&p);
|
||
for (name, _) in color_pairs(&p) {
|
||
assert!(
|
||
gtk.contains(&format!("@define-color {name} ")),
|
||
"gtk missing {name}"
|
||
);
|
||
assert!(web.contains(&format!("--{name}: ")), "web missing {name}");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn css_custom_properties_is_valid_root_block() {
|
||
let p = Palette::default();
|
||
let css = css_custom_properties(&p);
|
||
assert!(css.starts_with(":root {\n"));
|
||
assert!(css.trim_end().ends_with('}'));
|
||
assert!(css.contains(&format!("--accent: {};", p.color4)));
|
||
}
|
||
|
||
#[test]
|
||
fn css_tokens_contains_font_and_spacing_vars() {
|
||
let css = css_tokens();
|
||
assert!(css.contains("--font-family: 'Varela Round', sans-serif;"));
|
||
assert!(
|
||
!css.contains("--font-family: 'Varela Round, sans-serif'"),
|
||
"named face and generic fallback must not be one quoted family"
|
||
);
|
||
assert!(css.contains("--font-size-base: 14px;"));
|
||
assert!(css.contains("--space-md: 12px;"));
|
||
assert!(css.contains("--radius-pill: 999px;"));
|
||
}
|
||
|
||
#[test]
|
||
fn luminance_black_and_white_are_extremes() {
|
||
assert!(luminance("#000000") < 0.01);
|
||
assert!(luminance("#ffffff") > 0.99);
|
||
}
|
||
|
||
#[test]
|
||
fn ink_on_picks_dark_text_for_light_backgrounds() {
|
||
// Light pywal slots (the case that made white text vanish) get dark ink.
|
||
assert_eq!(ink_on("#ffffff"), "#11111b");
|
||
assert_eq!(ink_on("#f9e2af"), "#11111b"); // pale yellow
|
||
assert_eq!(ink_on("#a6e3a1"), "#11111b"); // pale green
|
||
}
|
||
|
||
#[test]
|
||
fn ink_on_picks_light_text_for_dark_backgrounds() {
|
||
assert_eq!(ink_on("#000000"), "#f5f5f5");
|
||
assert_eq!(ink_on("#1e1e2e"), "#f5f5f5"); // catppuccin base
|
||
}
|
||
|
||
#[test]
|
||
fn stylesheet_defines_on_colors() {
|
||
let css = stylesheet(&Palette::default());
|
||
for name in &["on-bg", "on-surface", "on-accent", "on-red", "on-overlay"] {
|
||
assert!(
|
||
css.contains(&format!("@define-color {name} ")),
|
||
"missing @define-color {name}"
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn stylesheet_has_no_blanket_label_color_rule() {
|
||
// A bare `label { color: ... }` would override container colours on child
|
||
// labels — the bug that made coloured-background text illegible.
|
||
let css = stylesheet(&Palette::default());
|
||
assert!(
|
||
!css.contains("label { color:"),
|
||
"blanket label colour rule reintroduced"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn shared_css_path_uses_runtime_dir() {
|
||
let _lock = crate::output::XDG_ENV_LOCK
|
||
.lock()
|
||
.unwrap_or_else(|e| e.into_inner());
|
||
std::env::set_var("XDG_RUNTIME_DIR", "/run/user/1234");
|
||
assert_eq!(
|
||
shared_css_path(),
|
||
std::path::PathBuf::from("/run/user/1234/bread/theme.css")
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn stylesheet_resolved_inlines_color4_and_drops_named_refs_in_rules() {
|
||
let mut p = Palette::default();
|
||
p.color4 = "#7aa2f7".into();
|
||
let css = stylesheet_resolved(&p);
|
||
assert!(css.contains("#7aa2f7"), "color4 must appear as hex: {css}");
|
||
// Rule bodies must not keep named colors — GTK display-global
|
||
// @define-color would otherwise leak the wrong monitor's accent.
|
||
let rules = css
|
||
.lines()
|
||
.filter(|l| !l.trim_start().starts_with("@define-color"))
|
||
.collect::<Vec<_>>()
|
||
.join("\n");
|
||
assert!(
|
||
!rules.contains("@accent"),
|
||
"leftover @accent in rules:\n{rules}"
|
||
);
|
||
assert!(
|
||
!rules.contains("@on-bg"),
|
||
"leftover @on-bg in rules:\n{rules}"
|
||
);
|
||
assert!(
|
||
!rules.contains("@on-surface"),
|
||
"leftover @on-surface in rules:\n{rules}"
|
||
);
|
||
assert!(
|
||
!rules.contains("@on-accent"),
|
||
"leftover @on-accent in rules:\n{rules}"
|
||
);
|
||
// Longer names first: @on-bg must not become @on-#...
|
||
assert!(
|
||
!rules.contains("@on-#"),
|
||
"half-replaced on-* name:\n{rules}"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn render_is_nonempty_css() {
|
||
assert!(render().contains("@define-color bg "));
|
||
}
|
||
|
||
#[test]
|
||
fn hex_to_rgba_known_value() {
|
||
assert_eq!(hex_to_rgba("#1e1e2e", 1.0), "rgba(30, 30, 46, 1)");
|
||
}
|
||
|
||
#[test]
|
||
fn hex_to_rgba_strips_hash() {
|
||
let a = hex_to_rgba("#ffffff", 0.5);
|
||
let b = hex_to_rgba("ffffff", 0.5);
|
||
assert_eq!(a, b);
|
||
}
|
||
}
|