🔒 Repository is read-only – file editing is disabled.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313
//! Style instalowane z plików (`*.theme`) — tokeny motywu bez rekompilacji.
//!
//! Format jest prosty i liniowy (`klucz = wartość`, `#` to komentarz), w duchu plików
//! językowych `libluna-i18n`. Klucze są **nazwane kropkowo**, np. `colors.text`,
//! `spacing.md`, `radius.small`, `typography.body.size`, `metrics.button.height`,
//! a `__name__` to nazwa wyświetlana stylu. Nieznane klucze są ignorowane (zgodność wprzód).
//!
//! Katalogi wyszukiwania (kolejność = priorytet):
//! `~/.config/luna/themes`, `~/.local/share/luna/themes`, `/usr/share/luna/themes`.
use std::collections::BTreeMap;
use std::io;
use std::path::{Path, PathBuf};
use crate::appearance::Accent;
use crate::color::Color;
use crate::style::{ColorScheme, Theme};
/// Nadpisania tokenów motywu wczytane z pliku.
#[derive(Debug, Clone, Default)]
pub struct ThemePatch {
name: Option<String>,
entries: BTreeMap<String, String>,
}
impl ThemePatch {
/// Parsuje styl z tekstu (nieznane klucze są zachowane, ale ignorowane przy nakładaniu).
pub fn parse(src: &str) -> Self {
let mut patch = ThemePatch::default();
for line in src.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
let Some((key, value)) = line.split_once('=') else {
continue;
};
let key = key.trim();
let value = value.trim();
if key == "__name__" {
patch.name = Some(value.to_owned());
} else {
patch.entries.insert(key.to_owned(), value.to_owned());
}
}
patch
}
/// Wczytuje styl z pliku.
pub fn from_file(path: &Path) -> io::Result<ThemePatch> {
let src = std::fs::read_to_string(path)?;
Ok(ThemePatch::parse(&src))
}
/// Nazwa wyświetlana stylu (`__name__`), jeśli podana.
pub fn name(&self) -> Option<&str> {
self.name.as_deref()
}
/// Pary `klucz → wartość` (kolejność posortowana).
pub fn entries(&self) -> impl Iterator<Item = (&str, &str)> {
self.entries.iter().map(|(k, v)| (k.as_str(), v.as_str()))
}
fn appearance(&self) -> Option<&str> {
self.entries.get("appearance").map(String::as_str)
}
/// Buduje motyw: bazą jest wariant z `appearance` (jeśli podany), w przeciwnym razie
/// `fallback`. Akcent z `fallback` jest zachowany, o ile styl go nie nadpisuje.
pub fn build(&self, fallback: Theme) -> Theme {
let mut theme = match self.appearance() {
Some("dark") => Theme::dark(),
Some("light") => Theme::light(),
_ => {
let mut theme = fallback;
self.apply(&mut theme);
return theme;
}
};
// Zachowaj wybrany przez użytkownika akcent, chyba że styl go nadpisze.
theme.colors.accent = fallback.colors.accent;
theme.colors.accent_fg = fallback.colors.accent_fg;
self.apply(&mut theme);
theme
}
/// Nakłada nadpisania na istniejący motyw (nieznane klucze są ignorowane).
pub fn apply(&self, theme: &mut Theme) {
for (key, value) in &self.entries {
apply_entry(theme, key, value);
}
}
}
fn apply_entry(theme: &mut Theme, key: &str, value: &str) {
if let Some(field) = key.strip_prefix("colors.") {
if let Some(color) = Color::from_hex(value) {
set_color(&mut theme.colors, field, color);
}
return;
}
macro_rules! set_f64 {
($target:expr) => {
if let Ok(n) = value.parse::<f64>() {
$target = n;
}
};
}
match key {
"spacing.xs" => set_f64!(theme.spacing.xs),
"spacing.sm" => set_f64!(theme.spacing.sm),
"spacing.md" => set_f64!(theme.spacing.md),
"spacing.lg" => set_f64!(theme.spacing.lg),
"spacing.xl" => set_f64!(theme.spacing.xl),
"radius.small" => set_f64!(theme.radius.small),
"radius.medium" => set_f64!(theme.radius.medium),
"radius.large" => set_f64!(theme.radius.large),
"typography.title.size" => set_f64!(theme.typography.title.size),
"typography.heading.size" => set_f64!(theme.typography.heading.size),
"typography.body.size" => set_f64!(theme.typography.body.size),
"typography.caption.size" => set_f64!(theme.typography.caption.size),
"metrics.headerbar.height" => set_f64!(theme.metrics.headerbar.height),
"metrics.headerbar.control" => set_f64!(theme.metrics.headerbar.control),
"metrics.button.height" => set_f64!(theme.metrics.button.height),
"metrics.switch.width" => set_f64!(theme.metrics.switch.width),
"metrics.switch.height" => set_f64!(theme.metrics.switch.height),
"metrics.slider.track_height" => set_f64!(theme.metrics.slider.track_height),
"metrics.tabs.height" => set_f64!(theme.metrics.tabs.height),
"metrics.entry.height" => set_f64!(theme.metrics.entry.height),
"metrics.menu.item_h" => set_f64!(theme.metrics.menu.item_h),
"metrics.card.header" => set_f64!(theme.metrics.card.header),
"metrics.itemview.row_h" => set_f64!(theme.metrics.itemview.row_h),
"accent" => {
if let Some(a) = Accent::from_name(value) {
theme.colors.accent = a.color();
theme.colors.accent_fg = a.foreground();
}
}
_ => {}
}
}
fn set_color(colors: &mut ColorScheme, field: &str, color: Color) {
match field {
"window_bg" => colors.window_bg = color,
"view_bg" => colors.view_bg = color,
"card_bg" => colors.card_bg = color,
"headerbar_bg" => colors.headerbar_bg = color,
"sidebar_bg" => colors.sidebar_bg = color,
"text" => colors.text = color,
"dim_text" => colors.dim_text = color,
"accent" => colors.accent = color,
"accent_fg" => colors.accent_fg = color,
"border" => colors.border = color,
"shade" => colors.shade = color,
"error" => colors.error = color,
"warning" => colors.warning = color,
"success" => colors.success = color,
_ => {}
}
}
/// Wpis dostępnego stylu: identyfikator (nazwa pliku bez rozszerzenia), ścieżka i nazwa.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct StyledTheme {
pub id: String,
pub name: String,
pub path: PathBuf,
}
/// Katalogi wyszukiwania stylów (kolejność = priorytet).
///
/// Oprócz katalogów standardowych uwzględnia `LUNA_THEMES_DIR` — listę katalogów
/// rozdzielonych `:` (przydatne w dev/testach, by wskazać katalog ze stylami).
pub fn search_dirs() -> Vec<PathBuf> {
let mut dirs = Vec::new();
if let Some(extra) = std::env::var_os("LUNA_THEMES_DIR") {
for part in std::env::split_paths(&extra) {
if !part.as_os_str().is_empty() {
dirs.push(part);
}
}
}
if let Some(home) = std::env::var_os("HOME") {
let home = PathBuf::from(home);
dirs.push(home.join(".config").join("luna").join("themes"));
dirs.push(
home.join(".local")
.join("share")
.join("luna")
.join("themes"),
);
}
dirs.push(PathBuf::from("/usr/share/luna/themes"));
dirs
}
/// Style dostępne w podanych katalogach (pierwszy wygrywa przy powtórzonym `id`).
pub fn available_in(dirs: &[PathBuf]) -> Vec<StyledTheme> {
let mut out: Vec<StyledTheme> = Vec::new();
for dir in dirs {
let Ok(read) = std::fs::read_dir(dir) else {
continue;
};
for entry in read.flatten() {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("theme") {
continue;
}
let Some(id) = path.file_stem().and_then(|s| s.to_str()).map(str::to_owned) else {
continue;
};
if out.iter().any(|t| t.id == id) {
continue;
}
let name = ThemePatch::from_file(&path)
.ok()
.and_then(|p| p.name().map(str::to_owned))
.unwrap_or_else(|| id.clone());
out.push(StyledTheme { id, name, path });
}
}
out.sort_by(|a, b| a.id.cmp(&b.id));
out
}
/// Style dostępne w standardowych katalogach.
pub fn available() -> Vec<StyledTheme> {
available_in(&search_dirs())
}
/// Wczytuje styl o danym `id` ze standardowych katalogów.
pub fn load(id: &str) -> Option<ThemePatch> {
available()
.into_iter()
.find(|t| t.id == id)
.and_then(|t| ThemePatch::from_file(&t.path).ok())
}
/// Wczytuje styl z podanej nazwy (`id`) albo bezpośredniej ścieżki do pliku.
pub fn load_spec(spec: &str) -> Option<ThemePatch> {
let path = Path::new(spec);
if path.extension().and_then(|e| e.to_str()) == Some("theme") {
return ThemePatch::from_file(path).ok();
}
load(spec)
}
#[cfg(test)]
mod tests {
use super::*;
const NORD: &str = "\
# przykładowy styl
__name__ = Nord
appearance = dark
colors.card_bg = #3b4252
colors.text = #eceff4
accent = teal
radius.small = 4
metrics.button.height = 38
nieznany.klucz = cokolwiek
";
#[test]
fn parse_name_and_entries() {
let p = ThemePatch::parse(NORD);
assert_eq!(p.name(), Some("Nord"));
assert_eq!(p.entries().count(), 7);
}
#[test]
fn build_overrides_and_keeps_fallback_accent() {
// Styl bez `accent` → zachowuje akcent z fallbacku.
let patch = ThemePatch::parse("appearance = dark\ncolors.text = #123456\n");
let base = Theme::light().accented(Accent::Purple.color(), Accent::Purple.foreground());
let theme = patch.build(base);
assert_eq!(theme.colors.text, Color::from_hex("#123456").unwrap());
assert_eq!(theme.colors.accent, Accent::Purple.color());
// `appearance = dark` przełącza paletę na ciemną.
assert_ne!(theme.colors.window_bg, Theme::light().colors.window_bg);
}
#[test]
fn parse_applies_geometry_and_accent_and_ignores_unknown() {
let patch = ThemePatch::parse(NORD);
let theme = patch.build(Theme::light());
assert_eq!(theme.radius.small, 4.0);
assert_eq!(theme.metrics.button.height, 38.0);
assert_eq!(theme.colors.accent, Accent::Teal.color());
assert_eq!(theme.colors.card_bg, Color::from_hex("#3b4252").unwrap());
}
#[test]
fn available_and_load_from_dir() {
let dir = std::env::temp_dir().join("libluna-theme-test");
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("testish.theme");
std::fs::write(&path, "__name__ = Testish\ncolors.text = #010203\n").unwrap();
let list = available_in(std::slice::from_ref(&dir));
assert!(list
.iter()
.any(|t| t.id == "testish" && t.name == "Testish"));
let patch = ThemePatch::from_file(&path).unwrap();
let theme = patch.build(Theme::light());
assert_eq!(theme.colors.text, Color::from_hex("#010203").unwrap());
let _ = std::fs::remove_dir_all(&dir);
}
}