lios 0.1.43

A polished GTK4/VTE Linux terminal emulator with themes, clickable links, live preferences, glass backgrounds, cursor accents, and desktop launcher install.
use gtk::gdk;
use vte::prelude::*;

pub const THEME_NAMES: &[&str] = &[
    "xfce",
    "xterm",
    "green-on-black",
    "white-on-black",
    "dark-pastels",
    "cyberpunk",
    "matrix",
    "ocean",
    "ember",
    "solarized-dark",
    "solarized-light",
    "black-on-white",
];

const XFCE_PALETTE: [&str; 16] = [
    "#000000", "#aa0000", "#00aa00", "#aa5500", "#0000aa", "#aa00aa", "#00aaaa", "#aaaaaa",
    "#555555", "#ff5555", "#55ff55", "#ffff55", "#5555ff", "#ff55ff", "#55ffff", "#ffffff",
];
pub const XFCE_CURSOR_BACKGROUND: &str = "#e6192e";
pub const XFCE_CURSOR_FOREGROUND: &str = "#a51d2d";

const XTERM_PALETTE: [&str; 16] = [
    "#000000", "#cd0000", "#00cd00", "#cdcd00", "#0000cd", "#cd00cd", "#00cdcd", "#e5e5e5",
    "#7f7f7f", "#ff0000", "#00ff00", "#ffff00", "#5c5cff", "#ff00ff", "#00ffff", "#ffffff",
];

const SOLARIZED_PALETTE: [&str; 16] = [
    "#073642", "#dc322f", "#859900", "#b58900", "#268bd2", "#d33682", "#2aa198", "#eee8d5",
    "#002b36", "#cb4b16", "#586e75", "#657b83", "#839496", "#6c71c4", "#93a1a1", "#fdf6e3",
];

const DARK_PASTELS_PALETTE: [&str; 16] = [
    "#3f3f3f", "#705050", "#60b48a", "#dfaf8f", "#9ab8d7", "#dc8cc3", "#8cd0d3", "#dcdcdc",
    "#709080", "#dca3a3", "#72d5a3", "#f0dfaf", "#94bff3", "#ec93d3", "#93e0e3", "#ffffff",
];

const DRACULA_PALETTE: [&str; 16] = [
    "#21222c", "#ff5555", "#50fa7b", "#f1fa8c", "#bd93f9", "#ff79c6", "#8be9fd", "#f8f8f2",
    "#6272a4", "#ff6e6e", "#69ff94", "#ffffa5", "#d6acff", "#ff92df", "#a4ffff", "#ffffff",
];

const CYBERPUNK_PALETTE: [&str; 16] = [
    "#0d0221", "#ff3864", "#00ff9f", "#fcee09", "#2de2e6", "#ff2a6d", "#05d9e8", "#d1f7ff",
    "#241734", "#ff5c8a", "#72f1b8", "#fff36d", "#00b8ff", "#f706cf", "#7df9ff", "#ffffff",
];

const MATRIX_PALETTE: [&str; 16] = [
    "#000500", "#0f6f2f", "#00ff41", "#aaff00", "#00b7ff", "#44ff99", "#00ffcc", "#c8ffc8",
    "#003b12", "#22aa55", "#5dff87", "#d8ff72", "#55dfff", "#8cffb8", "#72ffe6", "#f0fff0",
];

const OCEAN_PALETTE: [&str; 16] = [
    "#02111d", "#ef476f", "#2ec4b6", "#ffd166", "#00b4d8", "#bde0fe", "#48cae4", "#caf0f8",
    "#073b4c", "#ff6b8a", "#64dfdf", "#ffe08a", "#48cae4", "#d6f3ff", "#90e0ef", "#ffffff",
];

const EMBER_PALETTE: [&str; 16] = [
    "#160b08", "#ff5f40", "#f7c59f", "#ffbf69", "#8ecae6", "#ff6b35", "#ffa260", "#fff1db",
    "#34170f", "#ff7a59", "#ffd6a5", "#ffca7a", "#a8dcf4", "#ff8a50", "#fcbf7f", "#ffffff",
];

#[derive(Debug, Clone)]
pub struct TerminalTheme {
    foreground: gdk::RGBA,
    background: gdk::RGBA,
    cursor_background: Option<gdk::RGBA>,
    cursor_foreground: Option<gdk::RGBA>,
    palette: Vec<gdk::RGBA>,
}

impl TerminalTheme {
    pub fn canonical_name(name: &str) -> Result<&'static str, String> {
        let normalized = name.trim().to_ascii_lowercase();
        match normalized.as_str() {
            "xfce" | "default" => Ok("xfce"),
            "xterm" => Ok("xterm"),
            "solarized-dark" | "solarized_dark" => Ok("solarized-dark"),
            "solarized-light" | "solarized_light" => Ok("solarized-light"),
            "dark-pastels" | "dark_pastels" => Ok("dark-pastels"),
            "dracula" | "purple-dracula" | "purple_dracula" => Ok("dracula"),
            "cyberpunk" | "cyberpunk-neon" | "cyberpunk_neon" | "neon" => Ok("cyberpunk"),
            "matrix" | "matrix-green" | "matrix_green" => Ok("matrix"),
            "ocean" | "tide" | "tide-oracle" | "tide_oracle" | "marine" => Ok("ocean"),
            "ember" | "signal-kitchen" | "signal_kitchen" | "ember-kitchen" | "ember_kitchen" => {
                Ok("ember")
            }
            "green-on-black" | "green_on_black" => Ok("green-on-black"),
            "black-on-white" | "black_on_white" => Ok("black-on-white"),
            "white-on-black" | "white_on_black" => Ok("white-on-black"),
            _ => Err(format!(
                "unknown theme '{name}'. Available themes: {}",
                THEME_NAMES.join(", ")
            )),
        }
    }

    pub fn named(name: &str) -> Result<Self, String> {
        match Self::canonical_name(name)? {
            "xfce" => Ok(Self::scheme(
                "#ffffff",
                "#000000",
                None,
                None,
                &XFCE_PALETTE,
            )),
            "xterm" => Ok(Self::scheme(
                "#ffffff",
                "#000000",
                None,
                None,
                &XTERM_PALETTE,
            )),
            "solarized-dark" => Ok(Self::scheme(
                "#839496",
                "#002b36",
                Some("#93a1a1"),
                None,
                &SOLARIZED_PALETTE,
            )),
            "solarized-light" => Ok(Self::scheme(
                "#073642",
                "#fdf6e3",
                Some("#073642"),
                None,
                &SOLARIZED_PALETTE,
            )),
            "dark-pastels" => Ok(Self::scheme(
                "#dcdcdc",
                "#2c2c2c",
                Some("#dcdcdc"),
                None,
                &DARK_PASTELS_PALETTE,
            )),
            "dracula" => Ok(Self::scheme(
                "#f8f8f2",
                "#282a36",
                Some("#bd93f9"),
                Some("#282a36"),
                &DRACULA_PALETTE,
            )),
            "cyberpunk" => Ok(Self::scheme(
                "#f7f7ff",
                "#080014",
                Some("#ff2a6d"),
                Some("#080014"),
                &CYBERPUNK_PALETTE,
            )),
            "matrix" => Ok(Self::scheme(
                "#00ff41",
                "#000500",
                Some("#00ff41"),
                Some("#000500"),
                &MATRIX_PALETTE,
            )),
            "ocean" => Ok(Self::scheme(
                "#caf0f8",
                "#061421",
                Some("#00b4d8"),
                Some("#061421"),
                &OCEAN_PALETTE,
            )),
            "ember" => Ok(Self::scheme(
                "#fff1db",
                "#160b08",
                Some("#ff9f1c"),
                Some("#160b08"),
                &EMBER_PALETTE,
            )),
            "green-on-black" => Ok(Self::scheme(
                "#17f018",
                "#000000",
                None,
                None,
                &XFCE_PALETTE,
            )),
            "black-on-white" => Ok(Self::scheme(
                "#000000",
                "#ffffff",
                None,
                None,
                &XTERM_PALETTE,
            )),
            "white-on-black" => Ok(Self::scheme(
                "#ffffff",
                "#000000",
                None,
                None,
                &XFCE_PALETTE,
            )),
            _ => unreachable!("canonical theme names are exhaustive"),
        }
    }

    pub fn parse_color(value: &str) -> Result<gdk::RGBA, String> {
        gdk::RGBA::parse(value).map_err(|_| format!("invalid color '{value}'"))
    }

    pub fn apply_overrides(
        &mut self,
        foreground: Option<&str>,
        background: Option<&str>,
        cursor_background: Option<&str>,
        cursor_foreground: Option<&str>,
        palette: Option<&[String]>,
    ) -> Result<(), String> {
        if let Some(foreground) = foreground {
            self.foreground = Self::parse_color(foreground)?;
        }
        if let Some(background) = background {
            self.background = Self::parse_color(background)?;
        }
        apply_optional_color(&mut self.cursor_background, cursor_background)?;
        apply_optional_color(&mut self.cursor_foreground, cursor_foreground)?;
        if let Some(palette) = palette {
            if palette.len() != 16 {
                return Err("theme.palette must contain exactly 16 colors".to_string());
            }

            self.palette = palette
                .iter()
                .map(|color| Self::parse_color(color))
                .collect::<Result<Vec<_>, _>>()?;
        }

        Ok(())
    }

    pub fn with_background_alpha(&self, alpha: f64) -> Self {
        let mut theme = self.clone();
        theme.background.set_alpha(alpha as f32);
        theme
    }

    pub fn background_color(&self) -> gdk::RGBA {
        let mut color = self.background;
        color.set_alpha(1.0);
        color
    }

    pub fn cursor_background_color(&self) -> Option<gdk::RGBA> {
        self.cursor_background
    }

    pub fn cursor_foreground_color(&self) -> Option<gdk::RGBA> {
        self.cursor_foreground
    }

    fn scheme(
        foreground: &str,
        background: &str,
        cursor_background: Option<&str>,
        cursor_foreground: Option<&str>,
        palette: &[&str],
    ) -> Self {
        Self {
            foreground: rgba(foreground),
            background: rgba(background),
            cursor_background: cursor_background.map(rgba),
            cursor_foreground: cursor_foreground.map(rgba),
            palette: palette.iter().map(|color| rgba(color)).collect(),
        }
    }

    pub fn apply_to(&self, terminal: &vte::Terminal) {
        let palette: Vec<&gdk::RGBA> = self.palette.iter().collect();
        terminal.set_colors(Some(&self.foreground), Some(&self.background), &palette);
        terminal.set_color_cursor(self.cursor_background.as_ref());
        terminal.set_color_cursor_foreground(self.cursor_foreground.as_ref());
    }
}

fn apply_optional_color(target: &mut Option<gdk::RGBA>, value: Option<&str>) -> Result<(), String> {
    if let Some(value) = value {
        *target = if value.trim().is_empty() {
            None
        } else {
            Some(TerminalTheme::parse_color(value)?)
        };
    }
    Ok(())
}

fn rgba(value: &str) -> gdk::RGBA {
    gdk::RGBA::parse(value).unwrap_or_else(|_| panic!("invalid color literal: {value}"))
}

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

    #[test]
    fn xfce_theme_uses_normal_cursor_by_default() {
        let theme = TerminalTheme::named("xfce").unwrap();

        assert!(theme.cursor_background_color().is_none());
        assert!(theme.cursor_foreground_color().is_none());
    }

    #[test]
    fn cursor_foreground_override_can_be_set_and_cleared() {
        let mut theme = TerminalTheme::named("xfce").unwrap();

        theme
            .apply_overrides(None, None, Some("#ff0000"), Some(""), None)
            .unwrap();

        assert_eq!(hex(&theme.cursor_background_color().unwrap()), "#ff0000");
        assert!(theme.cursor_foreground_color().is_none());
    }

    #[test]
    fn legacy_dracula_theme_still_loads() {
        let theme = TerminalTheme::named("dracula").unwrap();

        assert_eq!(hex(&theme.background_color()), "#282a36");
        assert_eq!(hex(&theme.cursor_background_color().unwrap()), "#bd93f9");
        assert_eq!(hex(&theme.cursor_foreground_color().unwrap()), "#282a36");
        assert!(!THEME_NAMES.contains(&"dracula"));
    }

    #[test]
    fn cyberpunk_theme_uses_neon_cursor() {
        let theme = TerminalTheme::named("cyberpunk").unwrap();

        assert_eq!(hex(&theme.background_color()), "#080014");
        assert_eq!(hex(&theme.cursor_background_color().unwrap()), "#ff2a6d");
        assert_eq!(hex(&theme.cursor_foreground_color().unwrap()), "#080014");
    }

    #[test]
    fn matrix_theme_uses_green_cursor() {
        let theme = TerminalTheme::named("matrix-green").unwrap();

        assert_eq!(hex(&theme.background_color()), "#000500");
        assert_eq!(hex(&theme.cursor_background_color().unwrap()), "#00ff41");
        assert_eq!(hex(&theme.cursor_foreground_color().unwrap()), "#000500");
    }

    #[test]
    fn ocean_theme_uses_tide_oracle_palette() {
        let theme = TerminalTheme::named("tide-oracle").unwrap();

        assert_eq!(hex(&theme.background_color()), "#061421");
        assert_eq!(hex(&theme.cursor_background_color().unwrap()), "#00b4d8");
        assert_eq!(hex(&theme.cursor_foreground_color().unwrap()), "#061421");
    }

    #[test]
    fn ember_theme_uses_signal_kitchen_palette() {
        let theme = TerminalTheme::named("signal-kitchen").unwrap();

        assert_eq!(hex(&theme.background_color()), "#160b08");
        assert_eq!(hex(&theme.cursor_background_color().unwrap()), "#ff9f1c");
        assert_eq!(hex(&theme.cursor_foreground_color().unwrap()), "#160b08");
    }

    fn hex(color: &gdk::RGBA) -> String {
        let red = (color.red().clamp(0.0, 1.0) * 255.0).round() as u8;
        let green = (color.green().clamp(0.0, 1.0) * 255.0).round() as u8;
        let blue = (color.blue().clamp(0.0, 1.0) * 255.0).round() as u8;
        format!("#{red:02x}{green:02x}{blue:02x}")
    }
}