aether-wisp 0.7.0

A terminal UI for AI coding agents via the Agent Client Protocol (ACP)
Documentation
use crate::settings::{ThemeSettings, load_theme_file};
use clankerdiff_ratatui::{RatatuiTheme, RatatuiUiTheme, composite_color, layered_style};
use clankerdiff_ratatui::theme::{ReviewTheme, SelectionState, ThemeId};
use ratatui::style::{Color, Style};
use std::path::Path;
use std::sync::Arc;

#[derive(Clone, Debug)]
pub struct Theme {
    pub text_primary: Color,
    pub text_secondary: Color,
    pub background: Color,
    pub surface: Color,
    pub accent: Color,
    pub heading: Color,
    pub link: Color,
    pub blockquote: Color,
    pub code_fg: Color,
    pub code_bg: Color,
    pub success: Color,
    pub warning: Color,
    pub error: Color,
    pub info: Color,
    pub muted: Color,
    pub diff_added_fg: Color,
    pub diff_added_bg: Color,
    pub diff_removed_fg: Color,
    pub diff_removed_bg: Color,
    review: Arc<ReviewTheme>,
}

#[derive(Debug, thiserror::Error)]
pub enum ThemeLoadError {
    #[error("Could not read theme: {0}")]
    Io(#[from] std::io::Error),
    #[error(transparent)]
    Theme(#[from] clankerdiff_ratatui::theme::ThemeError),
    #[error("Invalid theme file name: {0}")]
    InvalidFile(String),
}

#[derive(Debug, thiserror::Error)]
pub enum ThemeApplicationError {
    #[error(transparent)]
    Load(#[from] ThemeLoadError),
    #[error("Could not save theme settings: {0}")]
    Save(#[source] std::io::Error),
    #[error("Theme task failed: {0}")]
    Task(#[from] tokio::task::JoinError),
}

impl Theme {
    pub fn load_selection(selection: &ThemeSettings) -> Result<Self, ThemeLoadError> {
        match selection {
            ThemeSettings::Builtin { id } => Ok(Self::from_review(ReviewTheme::builtin(id)?)),
            ThemeSettings::File { file } => load_theme_file(file),
        }
    }

    pub fn load_from_path(path: &Path) -> Result<Self, ThemeLoadError> {
        if path.extension().is_none_or(|extension| extension != "json") {
            return Err(ThemeLoadError::InvalidFile(path.display().to_string()));
        }
        let bytes = std::fs::read(path)?;
        Ok(Self::from_review(ReviewTheme::from_bytes(
            ThemeId::Custom(path.file_name().unwrap_or_default().to_string_lossy().into_owned()),
            &bytes,
        )?))
    }

    pub fn review(&self) -> &ReviewTheme {
        &self.review
    }

    pub fn surface_style(&self) -> Style {
        let ui = self.review.ui;
        layered_style(ui.text, ui.surface, ui.canvas)
    }

    pub fn selection_style(&self, state: SelectionState) -> Style {
        RatatuiUiTheme::from(&self.review.ui).selection_style(state)
    }

    pub fn from_review(review: ReviewTheme) -> Self {
        let native = RatatuiTheme::from(&review);
        let ui = native.ui;
        let color = |value| composite_color(value, review.diff.background);
        Self {
            text_primary: ui.text,
            text_secondary: ui.text_secondary,
            background: ui.canvas,
            surface: ui.surface,
            accent: ui.accent,
            heading: color(review.markdown.heading),
            link: color(review.markdown.link),
            blockquote: color(review.markdown.quote),
            code_fg: color(review.markdown.code),
            code_bg: color(review.markdown.code_background),
            success: ui.positive,
            warning: ui.warning,
            error: ui.destructive,
            info: ui.info,
            muted: ui.text_muted,
            diff_added_fg: native.addition,
            diff_added_bg: native.addition_background,
            diff_removed_fg: native.deletion,
            diff_removed_bg: native.deletion_background,
            review: Arc::new(review),
        }
    }
}

impl Default for Theme {
    fn default() -> Self {
        Self::from_review(ReviewTheme::default())
    }
}