Init commit
This commit is contained in:
commit
6c5536733f
19 changed files with 3312 additions and 0 deletions
20
bread-theme/CHANGELOG.md
Normal file
20
bread-theme/CHANGELOG.md
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
# bread-theme changelog
|
||||
|
||||
## Coordinated bump policy
|
||||
|
||||
`bread-theme` is consumed by `breadbar`, `breadbox`, and `breadpad` as a pinned
|
||||
git dependency. A breaking change to `Palette`, `css_vars`, or the `gtk` feature
|
||||
API requires all three dependents to bump their `Cargo.toml` git tag and cut a
|
||||
release together. Note the impact in this file before tagging.
|
||||
|
||||
---
|
||||
|
||||
## theme-v0.1.0 (2026-06-06)
|
||||
|
||||
- Initial extraction from `breadpad-shared/src/theme.rs`
|
||||
- `Palette` struct with `color0`–`color7` and Catppuccin Mocha default
|
||||
- `load_palette()` reads `~/.cache/wal/colors.json`, falls back to default
|
||||
- `css_vars(palette)` emits `@define-color` block + font declaration
|
||||
- `hex_to_rgba(hex, alpha)` utility
|
||||
- `tokens` module with spacing scale, border radii, font sizes from `BREAD_DESIGN_SYSTEM.md`
|
||||
- `gtk` feature: `apply_css()` and `apply_user_css()` helpers for GTK4 CSS providers
|
||||
20
bread-theme/Cargo.toml
Normal file
20
bread-theme/Cargo.toml
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
[package]
|
||||
name = "bread-theme"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
authors.workspace = true
|
||||
description = "Shared pywal + Catppuccin theming crate for the bread ecosystem"
|
||||
repository = "https://github.com/Breadway/bread-ecosystem"
|
||||
keywords = ["theming", "pywal", "gtk4", "wayland"]
|
||||
|
||||
[dependencies]
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
dirs = { workspace = true }
|
||||
gtk4 = { version = "0.11", features = ["v4_12"], optional = true }
|
||||
|
||||
[features]
|
||||
# Enable GTK4 CSS provider helpers (breadbar, breadbox, breadpad use this).
|
||||
# bread (daemon) and breadcrumbs (CLI) depend on this crate without the feature.
|
||||
gtk = ["dep:gtk4"]
|
||||
50
bread-theme/src/gtk.rs
Normal file
50
bread-theme/src/gtk.rs
Normal 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
96
bread-theme/src/lib.rs
Normal 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
162
bread-theme/src/palette.rs
Normal 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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue