🔒 Repository is read-only – file editing is disabled.

luna/crates/libluna-core/src/appearance.rs main

404 lines Raw ← Back
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404
//! Preferencje wyglądu: tryb (auto/jasny/ciemny), kolor akcentu oraz ich trwałość.
//!
//! Warstwa [`ThemeSettings`] jest czysta (bez I/O poza zapisem/odczytem pliku) i może
//! być używana zarówno przez platformę, jak i testy. Rozwiązywanie motywu
//! (`resolve`) jest deterministyczne i tanie.

use std::io;
use std::path::PathBuf;

use crate::color::Color;
use crate::style::Theme;

/// Kolor akcentu (paleta zgodna z duchem GNOME 47+).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Accent {
    #[default]
    Blue,
    Teal,
    Green,
    Yellow,
    Orange,
    Red,
    Pink,
    Purple,
    Slate,
}

impl Accent {
    /// Wszystkie kolory akcentu w kolejności prezentacji.
    pub const ALL: [Accent; 9] = [
        Accent::Blue,
        Accent::Teal,
        Accent::Green,
        Accent::Yellow,
        Accent::Orange,
        Accent::Red,
        Accent::Pink,
        Accent::Purple,
        Accent::Slate,
    ];

    /// Stabilna nazwa (do zapisu w konfiguracji).
    pub const fn name(self) -> &'static str {
        match self {
            Accent::Blue => "blue",
            Accent::Teal => "teal",
            Accent::Green => "green",
            Accent::Yellow => "yellow",
            Accent::Orange => "orange",
            Accent::Red => "red",
            Accent::Pink => "pink",
            Accent::Purple => "purple",
            Accent::Slate => "slate",
        }
    }

    pub fn from_name(name: &str) -> Option<Accent> {
        Accent::ALL.into_iter().find(|a| a.name() == name)
    }

    /// Kolor akcentu (jasny wariant — wspólny dla obu motywów).
    pub const fn color(self) -> Color {
        match self {
            Accent::Blue => Color::from_rgba8(0x35, 0x84, 0xe4, 0xff),
            Accent::Teal => Color::from_rgba8(0x21, 0x90, 0xa4, 0xff),
            Accent::Green => Color::from_rgba8(0x3a, 0x94, 0x4a, 0xff),
            Accent::Yellow => Color::from_rgba8(0xc8, 0x88, 0x00, 0xff),
            Accent::Orange => Color::from_rgba8(0xed, 0x5b, 0x00, 0xff),
            Accent::Red => Color::from_rgba8(0xe6, 0x2d, 0x42, 0xff),
            Accent::Pink => Color::from_rgba8(0xd5, 0x61, 0x99, 0xff),
            Accent::Purple => Color::from_rgba8(0x91, 0x41, 0xac, 0xff),
            Accent::Slate => Color::from_rgba8(0x6f, 0x83, 0x96, 0xff),
        }
    }

    /// Kolor tekstu/ikon na akcencie (kontrast).
    pub const fn foreground(self) -> Color {
        match self {
            // Żółty jest zbyt jasny na biały tekst.
            Accent::Yellow => Color::from_rgba8(0x2e, 0x2e, 0x2e, 0xff),
            _ => Color::from_rgba8(0xff, 0xff, 0xff, 0xff),
        }
    }
}

/// Preferencja trybu wyglądu.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ThemePreference {
    /// Podążaj za ustawieniem systemu.
    #[default]
    System,
    Light,
    Dark,
}

impl ThemePreference {
    pub const ALL: [ThemePreference; 3] = [
        ThemePreference::Light,
        ThemePreference::Dark,
        ThemePreference::System,
    ];

    pub const fn name(self) -> &'static str {
        match self {
            ThemePreference::System => "system",
            ThemePreference::Light => "light",
            ThemePreference::Dark => "dark",
        }
    }

    pub fn from_name(name: &str) -> Option<ThemePreference> {
        ThemePreference::ALL.into_iter().find(|p| p.name() == name)
    }

    /// Czy przy zadanym stanie systemu wybrać wariant ciemny.
    pub const fn is_dark(self, system_dark: bool) -> bool {
        match self {
            ThemePreference::System => system_dark,
            ThemePreference::Light => false,
            ThemePreference::Dark => true,
        }
    }

    /// Rozwiązuje preferencję do konkretnego motywu z zadanym akcentem.
    pub fn resolve(self, system_dark: bool, accent: Accent) -> Theme {
        let base = if self.is_dark(system_dark) {
            Theme::dark()
        } else {
            Theme::light()
        };
        base.accented(accent.color(), accent.foreground())
    }
}

/// Trwałe ustawienia wyglądu (tryb + akcent + styl) z flagą „zmienione”.
#[derive(Debug, Clone)]
pub struct ThemeSettings {
    preference: ThemePreference,
    accent: Accent,
    style: Option<String>,
    dirty: bool,
}

impl Default for ThemeSettings {
    fn default() -> Self {
        Self {
            preference: ThemePreference::System,
            accent: Accent::Blue,
            style: None,
            dirty: false,
        }
    }
}

impl PartialEq for ThemeSettings {
    fn eq(&self, other: &Self) -> bool {
        self.preference == other.preference
            && self.accent == other.accent
            && self.style == other.style
    }
}

impl ThemeSettings {
    pub fn new(preference: ThemePreference, accent: Accent) -> Self {
        Self {
            preference,
            accent,
            style: None,
            dirty: false,
        }
    }

    pub const fn preference(&self) -> ThemePreference {
        self.preference
    }

    pub const fn accent(&self) -> Accent {
        self.accent
    }

    /// Nazwa wybranego stylu (instalowanego z pliku), jeśli ustawiona.
    pub fn style(&self) -> Option<&str> {
        self.style.as_deref()
    }

    pub fn set_preference(&mut self, preference: ThemePreference) {
        if self.preference != preference {
            self.preference = preference;
            self.dirty = true;
        }
    }

    pub fn set_accent(&mut self, accent: Accent) {
        if self.accent != accent {
            self.accent = accent;
            self.dirty = true;
        }
    }

    /// Ustawia styl (instalowany z pliku); `None` = wbudowany wygląd.
    pub fn set_style(&mut self, style: Option<String>) {
        if self.style != style {
            self.style = style;
            self.dirty = true;
        }
    }

    /// Czy coś zmieniono od ostatniego [`clear_dirty`](Self::clear_dirty).
    pub const fn is_dirty(&self) -> bool {
        self.dirty
    }

    /// Zdejmuje flagę „zmienione” (opcjonalnie zapisuje stan na dysk).
    pub fn clear_dirty(&mut self) {
        self.dirty = false;
    }

    /// Rozwiązuje bieżące ustawienia do konkretnego motywu (tryb + akcent).
    pub fn resolve(&self, system_dark: bool) -> Theme {
        let base = if self.preference.is_dark(system_dark) {
            Theme::dark()
        } else {
            Theme::light()
        };
        base.accented(self.accent.color(), self.accent.foreground())
    }

    /// Jak [`resolve`](Self::resolve), ale nakłada też wybrany **styl** z pliku
    /// (`~/.local/share/luna/themes/<nazwa>.theme` itd.).
    pub fn resolve_themed(&self, system_dark: bool) -> Theme {
        let base = self.resolve(system_dark);
        match self.style.as_deref().and_then(crate::theme_file::load) {
            Some(patch) => patch.build(base),
            None => base,
        }
    }

    // --- Trwałość (prosty format `klucz = wartość`) ---

    /// Domyślna ścieżka konfiguracji: `$XDG_CONFIG_HOME/luna/theme.conf`.
    pub fn config_path() -> Option<PathBuf> {
        Some(config_dir()?.join("luna").join("theme.conf"))
    }

    /// Wczytuje ustawienia z domyślnej ścieżki (po cichu zwraca domyślne przy błędzie).
    pub fn load() -> Self {
        Self::config_path()
            .and_then(|p| std::fs::read_to_string(p).ok())
            .map(|s| Self::from_config_str(&s))
            .unwrap_or_default()
    }

    /// Parsuje konfigurację z tekstu (nieznane klucze są ignorowane).
    pub fn from_config_str(text: &str) -> Self {
        let mut settings = Self::default();
        for line in text.lines() {
            let line = line.trim();
            if line.is_empty() || line.starts_with('#') {
                continue;
            }
            let Some((key, value)) = line.split_once('=') else {
                continue;
            };
            let value = value.trim();
            match key.trim() {
                "preference" => {
                    if let Some(p) = ThemePreference::from_name(value) {
                        settings.preference = p;
                    }
                }
                "accent" => {
                    if let Some(a) = Accent::from_name(value) {
                        settings.accent = a;
                    }
                }
                "style" => {
                    settings.style = if value.is_empty() {
                        None
                    } else {
                        Some(value.to_owned())
                    };
                }
                _ => {}
            }
        }
        settings.dirty = false;
        settings
    }

    /// Serializuje ustawienia do tekstu konfiguracji.
    pub fn to_config_string(&self) -> String {
        let mut out = format!(
            "preference = {}\naccent = {}\n",
            self.preference.name(),
            self.accent.name()
        );
        if let Some(style) = &self.style {
            out.push_str(&format!("style = {style}\n"));
        }
        out
    }

    /// Zapisuje ustawienia do domyślnej ścieżki (tworzy katalogi).
    pub fn save(&self) -> io::Result<()> {
        let Some(path) = Self::config_path() else {
            return Ok(());
        };
        if let Some(parent) = path.parent() {
            std::fs::create_dir_all(parent)?;
        }
        std::fs::write(path, self.to_config_string())
    }
}

/// Katalog konfiguracji użytkownika (`$XDG_CONFIG_HOME` lub `~/.config`).
fn config_dir() -> Option<PathBuf> {
    std::env::var_os("XDG_CONFIG_HOME")
        .map(PathBuf::from)
        .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".config")))
}

/// Plik ze stanem systemowego motywu (`1` = ciemny, `0` = jasny).
///
/// Zapisuje go powłoka na podstawie portalu `org.freedesktop.appearance.color-scheme`.
pub fn system_dark_path() -> Option<PathBuf> {
    Some(config_dir()?.join("luna").join("system-dark"))
}

/// Czy system preferuje ciemny motyw.
///
/// Kolejność: jawny override `LUNA_SYSTEM_DARK` → plik zapisany przez powłokę → jasny.
pub fn system_dark() -> bool {
    if let Ok(v) = std::env::var("LUNA_SYSTEM_DARK") {
        return !v.is_empty() && v != "0";
    }
    system_dark_path()
        .and_then(|p| std::fs::read_to_string(p).ok())
        .map(|s| s.trim() == "1")
        .unwrap_or(false)
}

/// Zapisuje stan systemowego motywu (wywołuje watcher portalu w powłoce).
pub fn set_system_dark(dark: bool) -> io::Result<()> {
    let Some(path) = system_dark_path() else {
        return Ok(());
    };
    if let Some(parent) = path.parent() {
        std::fs::create_dir_all(parent)?;
    }
    std::fs::write(path, if dark { "1" } else { "0" })
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn preference_is_dark_semantics() {
        assert!(ThemePreference::Dark.is_dark(false));
        assert!(!ThemePreference::Light.is_dark(true));
        assert!(ThemePreference::System.is_dark(true));
        assert!(!ThemePreference::System.is_dark(false));
    }

    #[test]
    fn resolve_applies_accent() {
        let t = ThemePreference::Light.resolve(false, Accent::Purple);
        assert_eq!(t.colors.accent, Accent::Purple.color());
        assert_ne!(t.colors.accent, Theme::light().colors.accent);
    }

    #[test]
    fn accent_roundtrip_names() {
        for a in Accent::ALL {
            assert_eq!(Accent::from_name(a.name()), Some(a));
        }
        for p in ThemePreference::ALL {
            assert_eq!(ThemePreference::from_name(p.name()), Some(p));
        }
    }

    #[test]
    fn settings_config_roundtrip() {
        let mut s = ThemeSettings::default();
        s.set_preference(ThemePreference::Dark);
        s.set_accent(Accent::Teal);
        s.set_style(Some("nord".to_owned()));
        assert!(s.is_dirty());
        let parsed = ThemeSettings::from_config_str(&s.to_config_string());
        assert_eq!(parsed, s);
        assert_eq!(parsed.style(), Some("nord"));
        assert!(!parsed.is_dirty());
    }

    #[test]
    fn dirty_only_on_change() {
        let mut s = ThemeSettings::default();
        s.clear_dirty();
        s.set_preference(s.preference());
        assert!(!s.is_dirty());
        s.set_preference(ThemePreference::Dark);
        assert!(s.is_dirty());
    }
}