use egui::Color32;
use serde::Deserialize;
#[derive(Clone)]
pub struct Theme {
pub backdrop: Color32, pub bg: Color32, pub card: Color32, pub tile: Color32, pub tile_hover: Color32, pub tile_selected: Color32, pub thumb: Color32, pub text: Color32, pub text_dim: Color32, pub accent: Color32, pub screen_accent: Color32, pub window_accent: Color32,
pub font: Option<String>, pub font_path: Option<String>, pub cjk_font: Option<String>, pub font_size: Option<f32>, }
impl Default for Theme {
fn default() -> Self {
let c = |r, g, b| Color32::from_rgb(r, g, b);
Self {
backdrop: Color32::from_rgba_unmultiplied(0, 0, 0, 140),
bg: c(0x1e, 0x21, 0x27),
card: c(0x21, 0x25, 0x2d),
tile: c(0x18, 0x1b, 0x22),
tile_hover: c(0x26, 0x2b, 0x33),
tile_selected: c(0x3b, 0x42, 0x52),
thumb: c(0x12, 0x14, 0x1a),
text: c(0xd8, 0xde, 0xe9),
text_dim: c(0x7a, 0x82, 0x90),
accent: c(0x88, 0xc0, 0xd0),
screen_accent: c(0x81, 0xa1, 0xc1), window_accent: c(0xb4, 0x8e, 0xad), font: None,
font_path: None,
cjk_font: None,
font_size: None,
}
}
}
#[derive(Deserialize, Default)]
#[serde(rename_all = "kebab-case", default)]
struct Raw {
backdrop: Option<String>,
bg: Option<String>,
card: Option<String>,
tile: Option<String>,
tile_hover: Option<String>,
tile_selected: Option<String>,
thumb: Option<String>,
text: Option<String>,
text_dim: Option<String>,
accent: Option<String>,
screen_accent: Option<String>,
window_accent: Option<String>,
font: Option<String>,
font_path: Option<String>,
cjk_font: Option<String>,
font_size: Option<f32>,
}
impl Theme {
pub fn load() -> Self {
let mut t = Theme::default();
let Some(raw) = config_path()
.and_then(|p| std::fs::read_to_string(p).ok())
.and_then(|s| toml::from_str::<Raw>(&s).ok())
else {
return t;
};
let set = |dst: &mut Color32, src: &Option<String>| {
if let Some(c) = src.as_deref().and_then(parse_hex) {
*dst = c;
}
};
set(&mut t.backdrop, &raw.backdrop);
set(&mut t.bg, &raw.bg);
set(&mut t.card, &raw.card);
set(&mut t.tile, &raw.tile);
set(&mut t.tile_hover, &raw.tile_hover);
set(&mut t.tile_selected, &raw.tile_selected);
set(&mut t.thumb, &raw.thumb);
set(&mut t.text, &raw.text);
set(&mut t.text_dim, &raw.text_dim);
set(&mut t.accent, &raw.accent);
set(&mut t.screen_accent, &raw.screen_accent);
set(&mut t.window_accent, &raw.window_accent);
t.font = raw.font;
t.font_path = raw.font_path;
t.cjk_font = raw.cjk_font;
t.font_size = raw.font_size;
t
}
pub fn apply(&self, ctx: &egui::Context) {
let mut v = egui::Visuals::dark();
v.panel_fill = self.bg;
v.window_fill = self.card;
v.extreme_bg_color = self.thumb;
v.override_text_color = Some(self.text);
v.selection.bg_fill = self.accent.gamma_multiply(0.4);
v.selection.stroke = egui::Stroke::new(1.0, self.accent);
v.hyperlink_color = self.accent;
v.widgets.hovered.bg_fill = self.tile_hover;
v.widgets.active.bg_fill = self.tile_selected;
ctx.set_visuals(v);
self.install_fonts(ctx);
if let Some(sz) = self.font_size {
ctx.style_mut(|s| {
use egui::{FontFamily, FontId, TextStyle};
let prop = FontFamily::Proportional;
s.text_styles
.insert(TextStyle::Body, FontId::new(sz, prop.clone()));
s.text_styles
.insert(TextStyle::Button, FontId::new(sz, prop.clone()));
s.text_styles
.insert(TextStyle::Small, FontId::new(sz * 0.85, prop.clone()));
s.text_styles
.insert(TextStyle::Heading, FontId::new(sz * 1.4, prop));
s.text_styles
.insert(TextStyle::Monospace, FontId::new(sz, FontFamily::Monospace));
});
}
}
fn install_fonts(&self, ctx: &egui::Context) {
let mut fonts = egui::FontDefinitions::default();
let mut db = fontdb::Database::new();
db.load_system_fonts();
let primary = self
.font_path
.as_deref()
.and_then(read_font_file)
.or_else(|| self.font.as_deref().and_then(|f| load_family(&db, f)));
if let Some(data) = primary {
fonts.font_data.insert("ui".into(), data.into());
for fam in [egui::FontFamily::Proportional, egui::FontFamily::Monospace] {
fonts
.families
.entry(fam)
.or_default()
.insert(0, "ui".into());
}
}
let cjk = self
.cjk_font
.as_deref()
.and_then(|f| load_family(&db, f))
.or_else(|| CJK_FAMILIES.iter().find_map(|f| load_family(&db, f)));
if let Some(data) = cjk {
fonts.font_data.insert("cjk".into(), data.into());
for fam in [egui::FontFamily::Proportional, egui::FontFamily::Monospace] {
fonts.families.entry(fam).or_default().push("cjk".into());
}
}
ctx.set_fonts(fonts);
}
}
const CJK_FAMILIES: &[&str] = &[
"Noto Sans CJK JP",
"Noto Sans CJK SC",
"Noto Sans CJK KR",
"Source Han Sans",
"Sarasa Gothic",
"WenQuanYi Zen Hei",
];
fn read_font_file(path: &str) -> Option<egui::FontData> {
let bytes = std::fs::read(path).ok()?;
Some(egui::FontData::from_owned(bytes))
}
fn load_family(db: &fontdb::Database, family: &str) -> Option<egui::FontData> {
let query = fontdb::Query {
families: &[fontdb::Family::Name(family)],
..Default::default()
};
let id = db.query(&query)?;
db.with_face_data(id, |bytes, index| {
let mut data = egui::FontData::from_owned(bytes.to_vec());
data.index = index;
data
})
}
fn config_path() -> Option<std::path::PathBuf> {
let base = std::env::var_os("XDG_CONFIG_HOME")
.map(std::path::PathBuf::from)
.or_else(|| {
std::env::var_os("HOME").map(|h| std::path::PathBuf::from(h).join(".config"))
})?;
Some(base.join("wlr-chooser").join("theme.toml"))
}
fn parse_hex(s: &str) -> Option<Color32> {
let h = s.trim().strip_prefix('#')?;
let n = |i: usize| u8::from_str_radix(&h[i..i + 2], 16).ok();
match h.len() {
6 => Some(Color32::from_rgb(n(0)?, n(2)?, n(4)?)),
8 => Some(Color32::from_rgba_unmultiplied(n(0)?, n(2)?, n(4)?, n(6)?)),
3 => {
let d = |i: usize| {
let v = u8::from_str_radix(&h[i..i + 1], 16).ok()?;
Some(v * 17)
};
Some(Color32::from_rgb(d(0)?, d(1)?, d(2)?))
}
_ => None,
}
}