Skip to main content

git_iris/
theme.rs

1use std::fs;
2use std::path::{Path, PathBuf};
3use std::sync::Arc;
4
5use opaline::{self as core, OpalineError, OpalineStyle, Theme};
6
7pub use core::{ThemeInfo, ThemeVariant, gradient_string, names};
8
9const APP_NAME: &str = "git-iris";
10
11const TOKEN_GIT_STAGED: &str = "git.staged";
12const TOKEN_GIT_MODIFIED: &str = "git.modified";
13const TOKEN_GIT_UNTRACKED: &str = "git.untracked";
14const TOKEN_GIT_DELETED: &str = "git.deleted";
15const TOKEN_DIFF_ADDED: &str = "diff.added";
16const TOKEN_DIFF_REMOVED: &str = "diff.removed";
17const TOKEN_DIFF_HUNK: &str = "diff.hunk";
18const TOKEN_DIFF_CONTEXT: &str = "diff.context";
19const TOKEN_MODE_ACTIVE: &str = "mode.active";
20const TOKEN_MODE_INACTIVE: &str = "mode.inactive";
21const TOKEN_MODE_HOVER: &str = "mode.hover";
22const TOKEN_CODE_HASH: &str = "code.hash";
23const TOKEN_CODE_PATH: &str = "code.path";
24
25pub(crate) const STYLE_COMMIT_HASH: &str = "commit_hash";
26pub(crate) const STYLE_FILE_PATH: &str = "file_path";
27pub(crate) const STYLE_TIMESTAMP: &str = "timestamp";
28pub(crate) const STYLE_AUTHOR: &str = "author";
29pub(crate) const STYLE_GIT_STAGED: &str = "git_staged";
30pub(crate) const STYLE_GIT_MODIFIED: &str = "git_modified";
31pub(crate) const STYLE_GIT_UNTRACKED: &str = "git_untracked";
32pub(crate) const STYLE_GIT_DELETED: &str = "git_deleted";
33pub(crate) const STYLE_DIFF_ADDED: &str = "diff_added";
34pub(crate) const STYLE_DIFF_REMOVED: &str = "diff_removed";
35pub(crate) const STYLE_DIFF_HUNK: &str = "diff_hunk";
36pub(crate) const STYLE_DIFF_CONTEXT: &str = "diff_context";
37pub(crate) const STYLE_MODE_INACTIVE: &str = "mode_inactive";
38
39fn derive_iris_theme(theme: &mut Theme) {
40    use core::names::tokens;
41
42    theme.register_default_token(TOKEN_GIT_STAGED, theme.color(tokens::SUCCESS));
43    theme.register_default_token(TOKEN_GIT_MODIFIED, theme.color(tokens::WARNING));
44    theme.register_default_token(TOKEN_GIT_UNTRACKED, theme.color(tokens::TEXT_MUTED));
45    theme.register_default_token(TOKEN_GIT_DELETED, theme.color(tokens::ERROR));
46    theme.register_default_token(TOKEN_DIFF_ADDED, theme.color(tokens::SUCCESS));
47    theme.register_default_token(TOKEN_DIFF_REMOVED, theme.color(tokens::ERROR));
48    theme.register_default_token(TOKEN_DIFF_HUNK, theme.color(tokens::INFO));
49    theme.register_default_token(TOKEN_DIFF_CONTEXT, theme.color(tokens::TEXT_DIM));
50    theme.register_default_token(TOKEN_MODE_ACTIVE, theme.color(tokens::ACCENT_PRIMARY));
51    theme.register_default_token(TOKEN_MODE_INACTIVE, theme.color(tokens::TEXT_MUTED));
52    theme.register_default_token(TOKEN_MODE_HOVER, theme.color(tokens::ACCENT_SECONDARY));
53    theme.register_default_token(TOKEN_CODE_HASH, theme.color(tokens::ACCENT_TERTIARY));
54    theme.register_default_token(TOKEN_CODE_PATH, theme.color(tokens::ACCENT_SECONDARY));
55
56    theme.register_default_style(
57        STYLE_COMMIT_HASH,
58        OpalineStyle::fg(theme.color(TOKEN_CODE_HASH)),
59    );
60    theme.register_default_style(
61        STYLE_FILE_PATH,
62        OpalineStyle::fg(theme.color(TOKEN_CODE_PATH)),
63    );
64    theme.register_default_style(
65        STYLE_TIMESTAMP,
66        OpalineStyle::fg(theme.color(tokens::WARNING)),
67    );
68    theme.register_default_style(
69        STYLE_AUTHOR,
70        OpalineStyle::fg(theme.color(tokens::TEXT_PRIMARY)),
71    );
72    theme.register_default_style(
73        STYLE_GIT_STAGED,
74        OpalineStyle::fg(theme.color(TOKEN_GIT_STAGED)),
75    );
76    theme.register_default_style(
77        STYLE_GIT_MODIFIED,
78        OpalineStyle::fg(theme.color(TOKEN_GIT_MODIFIED)),
79    );
80    theme.register_default_style(
81        STYLE_GIT_UNTRACKED,
82        OpalineStyle::fg(theme.color(TOKEN_GIT_UNTRACKED)),
83    );
84    theme.register_default_style(
85        STYLE_GIT_DELETED,
86        OpalineStyle::fg(theme.color(TOKEN_GIT_DELETED)),
87    );
88    theme.register_default_style(
89        STYLE_DIFF_ADDED,
90        OpalineStyle::fg(theme.color(TOKEN_DIFF_ADDED)),
91    );
92    theme.register_default_style(
93        STYLE_DIFF_REMOVED,
94        OpalineStyle::fg(theme.color(TOKEN_DIFF_REMOVED)),
95    );
96    theme.register_default_style(
97        STYLE_DIFF_HUNK,
98        OpalineStyle::fg(theme.color(TOKEN_DIFF_HUNK)),
99    );
100    theme.register_default_style(
101        STYLE_DIFF_CONTEXT,
102        OpalineStyle::fg(theme.color(TOKEN_DIFF_CONTEXT)),
103    );
104    theme.register_default_style(
105        STYLE_MODE_INACTIVE,
106        OpalineStyle::fg(theme.color(TOKEN_MODE_INACTIVE)),
107    );
108}
109
110fn has_iris_derivations(theme: &Theme) -> bool {
111    theme.has_style(STYLE_GIT_STAGED)
112        && theme.has_style(STYLE_GIT_MODIFIED)
113        && theme.has_style(STYLE_GIT_UNTRACKED)
114        && theme.has_style(STYLE_GIT_DELETED)
115        && theme.has_style(STYLE_DIFF_ADDED)
116        && theme.has_style(STYLE_DIFF_REMOVED)
117        && theme.has_style(STYLE_DIFF_HUNK)
118        && theme.has_style(STYLE_DIFF_CONTEXT)
119        && theme.has_style(STYLE_COMMIT_HASH)
120        && theme.has_style(STYLE_FILE_PATH)
121        && theme.has_style(STYLE_MODE_INACTIVE)
122}
123
124#[must_use]
125pub fn current() -> Arc<Theme> {
126    let current = core::current();
127    if has_iris_derivations(&current) {
128        return current;
129    }
130
131    let mut derived = (*current).clone();
132    derive_iris_theme(&mut derived);
133    core::set_theme(derived);
134    core::current()
135}
136
137pub fn set_theme(mut theme: Theme) {
138    derive_iris_theme(&mut theme);
139    core::set_theme(theme);
140}
141
142/// Load and activate a theme from an explicit file path.
143///
144/// # Errors
145///
146/// Returns an error when the theme file cannot be read or parsed.
147pub fn load_theme(path: &Path) -> Result<(), OpalineError> {
148    let mut theme = core::load_from_file(path)?;
149    derive_iris_theme(&mut theme);
150    core::set_theme(theme);
151    Ok(())
152}
153
154/// Load and activate a theme by name from the available theme directories.
155///
156/// # Errors
157///
158/// Returns an error when no theme with the given name can be found or loaded.
159pub fn load_theme_by_name(name: &str) -> Result<(), OpalineError> {
160    if let Some(mut theme) = load_from_theme_dirs(name, core::app_theme_dirs(APP_NAME))? {
161        derive_iris_theme(&mut theme);
162        core::set_theme(theme);
163        return Ok(());
164    }
165
166    if let Some(mut theme) = core::load_by_name(name) {
167        derive_iris_theme(&mut theme);
168        core::set_theme(theme);
169        return Ok(());
170    }
171
172    Err(OpalineError::ThemeNotFound {
173        name: name.to_string(),
174    })
175}
176
177#[must_use]
178pub fn list_available_themes() -> Vec<ThemeInfo> {
179    let mut themes = Vec::new();
180
181    for theme in core::list_available_themes() {
182        push_or_replace_theme(&mut themes, theme);
183    }
184
185    for dir in core::app_theme_dirs(APP_NAME) {
186        scan_theme_dir(&mut themes, dir);
187    }
188
189    themes
190}
191
192fn push_or_replace_theme(themes: &mut Vec<ThemeInfo>, info: ThemeInfo) {
193    if let Some(existing) = themes.iter_mut().find(|theme| theme.name == info.name) {
194        *existing = info;
195    } else {
196        themes.push(info);
197    }
198}
199
200fn scan_theme_dir(themes: &mut Vec<ThemeInfo>, dir: PathBuf) {
201    let Ok(entries) = fs::read_dir(dir) else {
202        return;
203    };
204
205    for entry in entries.flatten() {
206        let path = entry.path();
207        if path.extension().is_some_and(|ext| ext == "toml")
208            && let Some(info) = theme_info_from_path(&path)
209        {
210            push_or_replace_theme(themes, info);
211        }
212    }
213}
214
215fn theme_info_from_path(path: &Path) -> Option<ThemeInfo> {
216    let theme = core::load_from_file(path).ok()?;
217    let name = path.file_stem()?.to_string_lossy().into_owned();
218
219    Some(ThemeInfo {
220        name,
221        display_name: theme.meta.name.clone(),
222        variant: theme.meta.variant,
223        author: theme.meta.author.clone().unwrap_or_default(),
224        description: theme.meta.description.clone().unwrap_or_default(),
225        builtin: false,
226        path: Some(path.to_path_buf()),
227    })
228}
229
230fn load_from_theme_dirs<I, P>(name: &str, dirs: I) -> Result<Option<Theme>, OpalineError>
231where
232    I: IntoIterator<Item = P>,
233    P: Into<PathBuf>,
234{
235    let mut matched_path = None;
236
237    for dir in dirs.into_iter().map(Into::into) {
238        let path = dir.join(format!("{name}.toml"));
239        if path.exists() {
240            matched_path = Some(path);
241        }
242    }
243
244    matched_path.map_or(Ok(None), |path| core::load_from_file(&path).map(Some))
245}