Init commit

This commit is contained in:
Breadway 2026-06-06 13:26:48 +08:00
commit 6c5536733f
19 changed files with 3312 additions and 0 deletions

50
bread-theme/src/gtk.rs Normal file
View file

@ -0,0 +1,50 @@
use gtk4::CssProvider;
use std::cell::RefCell;
use std::path::Path;
/// Apply a CSS string to the default display at APPLICATION priority.
/// Re-uses an existing provider if one is passed in (for SIGHUP reloads).
pub fn apply_css(css: &str, provider: &RefCell<Option<CssProvider>>) {
let display = gtk4::gdk::Display::default().expect("no display");
let mut guard = provider.borrow_mut();
if let Some(p) = guard.as_ref() {
p.load_from_string(css);
} else {
let p = CssProvider::new();
p.load_from_string(css);
gtk4::style_context_add_provider_for_display(
&display,
&p,
gtk4::STYLE_PROVIDER_PRIORITY_APPLICATION,
);
*guard = Some(p);
}
}
/// Apply a user CSS override file at USER priority. Clears the provider if the
/// file is absent so stale overrides don't persist across SIGHUP reloads.
pub fn apply_user_css(path: &Path, provider: &RefCell<Option<CssProvider>>) {
let display = gtk4::gdk::Display::default().expect("no display");
let mut guard = provider.borrow_mut();
match std::fs::read_to_string(path) {
Ok(css) => {
if let Some(p) = guard.as_ref() {
p.load_from_string(&css);
} else {
let p = CssProvider::new();
p.load_from_string(&css);
gtk4::style_context_add_provider_for_display(
&display,
&p,
gtk4::STYLE_PROVIDER_PRIORITY_USER,
);
*guard = Some(p);
}
}
Err(_) => {
if let Some(p) = guard.as_ref() {
p.load_from_string("");
}
}
}
}

96
bread-theme/src/lib.rs Normal file
View file

@ -0,0 +1,96 @@
pub mod palette;
#[cfg(feature = "gtk")]
pub mod gtk;
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;
}
/// Emit the `@define-color` block that all bread apps use.
/// Apps append their own rules below this; user CSS goes on top.
pub fn css_vars(p: &Palette) -> String {
format!(
"@define-color bg {bg};\n\
@define-color fg {fg};\n\
@define-color surface {c0};\n\
@define-color red {c1};\n\
@define-color green {c2};\n\
@define-color yellow {c3};\n\
@define-color blue {c4};\n\
@define-color pink {c5};\n\
@define-color teal {c6};\n\
@define-color overlay {c7};\n\
* {{ font-family: '{font}'; font-size: {size}px; }}\n",
bg = p.background,
fg = p.foreground,
c0 = p.color0,
c1 = p.color1,
c2 = p.color2,
c3 = p.color3,
c4 = p.color4,
c5 = p.color5,
c6 = p.color6,
c7 = p.color7,
font = tokens::FONT_FAMILY,
size = tokens::FONT_SIZE_BASE,
)
}
/// 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("Varela Round"));
assert!(css.contains("14px"));
}
#[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);
}
}

162
bread-theme/src/palette.rs Normal file
View file

@ -0,0 +1,162 @@
use serde::Deserialize;
use std::collections::HashMap;
use std::path::PathBuf;
/// Full 8-colour pywal palette. Catppuccin Mocha is the fallback.
#[derive(Debug, Clone)]
pub struct Palette {
pub background: String,
pub foreground: String,
/// ANSI color0 — darkest surface / overlay
pub color0: String,
/// ANSI color1 — red
pub color1: String,
/// ANSI color2 — green
pub color2: String,
/// ANSI color3 — yellow
pub color3: String,
/// ANSI color4 — blue (primary accent)
pub color4: String,
/// ANSI color5 — pink / magenta
pub color5: String,
/// ANSI color6 — teal / cyan
pub color6: String,
/// ANSI color7 — light overlay / muted fg
pub color7: String,
}
impl Default for Palette {
fn default() -> Self {
Palette {
background: "#1e1e2e".into(),
foreground: "#cdd6f4".into(),
color0: "#45475a".into(),
color1: "#f38ba8".into(),
color2: "#a6e3a1".into(),
color3: "#f9e2af".into(),
color4: "#89b4fa".into(),
color5: "#f5c2e7".into(),
color6: "#94e2d5".into(),
color7: "#bac2de".into(),
}
}
}
#[derive(Deserialize)]
struct WalColors {
#[serde(default)]
colors: HashMap<String, String>,
special: Option<WalSpecial>,
}
#[derive(Deserialize)]
struct WalSpecial {
background: Option<String>,
foreground: Option<String>,
}
/// Load palette from pywal's `colors.json`. Falls back to Catppuccin Mocha.
pub fn load_palette() -> Palette {
let path = wal_path();
std::fs::read_to_string(&path)
.ok()
.and_then(|s| from_wal_json(&s))
.unwrap_or_default()
}
pub(crate) fn from_wal_json(json: &str) -> Option<Palette> {
let wal: WalColors = serde_json::from_str(json).ok()?;
let c = |k: &str, fallback: &str| -> String {
wal.colors.get(k).cloned().unwrap_or_else(|| fallback.into())
};
Some(Palette {
background: wal.special.as_ref().and_then(|s| s.background.clone())
.unwrap_or_else(|| "#1e1e2e".into()),
foreground: wal.special.as_ref().and_then(|s| s.foreground.clone())
.unwrap_or_else(|| "#cdd6f4".into()),
color0: c("color0", "#45475a"),
color1: c("color1", "#f38ba8"),
color2: c("color2", "#a6e3a1"),
color3: c("color3", "#f9e2af"),
color4: c("color4", "#89b4fa"),
color5: c("color5", "#f5c2e7"),
color6: c("color6", "#94e2d5"),
color7: c("color7", "#bac2de"),
})
}
fn wal_path() -> PathBuf {
dirs::cache_dir()
.unwrap_or_else(|| PathBuf::from("~/.cache"))
.join("wal/colors.json")
}
#[cfg(test)]
mod tests {
use super::*;
const TOKYO_NIGHT: &str = r##"{
"special": { "background": "#1a1b26", "foreground": "#c0caf5" },
"colors": {
"color0": "#15161e", "color1": "#f7768e", "color2": "#9ece6a",
"color3": "#e0af68", "color4": "#7aa2f7", "color5": "#bb9af7",
"color6": "#7dcfff", "color7": "#a9b1d6"
}
}"##;
#[test]
fn default_is_catppuccin_mocha() {
let p = Palette::default();
assert_eq!(p.background, "#1e1e2e");
assert_eq!(p.foreground, "#cdd6f4");
assert_eq!(p.color4, "#89b4fa");
}
#[test]
fn wal_json_parses_special() {
let p = from_wal_json(TOKYO_NIGHT).unwrap();
assert_eq!(p.background, "#1a1b26");
assert_eq!(p.foreground, "#c0caf5");
}
#[test]
fn wal_json_parses_colors() {
let p = from_wal_json(TOKYO_NIGHT).unwrap();
assert_eq!(p.color0, "#15161e");
assert_eq!(p.color4, "#7aa2f7");
assert_eq!(p.color7, "#a9b1d6");
}
#[test]
fn wal_json_missing_special_uses_catppuccin_fallback() {
let p = from_wal_json(r#"{"colors":{}}"#).unwrap();
assert_eq!(p.background, "#1e1e2e");
assert_eq!(p.foreground, "#cdd6f4");
}
#[test]
fn wal_json_missing_color_uses_catppuccin_fallback() {
let p = from_wal_json(r##"{"special":{"background":"#ff0000","foreground":"#ffffff"},"colors":{}}"##).unwrap();
assert_eq!(p.color4, "#89b4fa");
}
#[test]
fn invalid_json_returns_none() {
assert!(from_wal_json("not json").is_none());
assert!(from_wal_json("").is_none());
}
#[test]
fn empty_object_returns_all_defaults() {
let p = from_wal_json("{}").unwrap();
assert_eq!(p.background, "#1e1e2e");
}
#[test]
fn load_palette_returns_valid_hex_strings() {
let p = load_palette();
for val in [&p.background, &p.foreground, &p.color0, &p.color4] {
assert!(val.starts_with('#'), "expected hex, got: {val}");
}
}
}