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(¤t) {
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
142pub 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
154pub 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}