rush_sync_server/commands/theme/
mod.rs1use crate::core::prelude::*;
2use std::collections::HashMap;
3
4pub mod command;
5pub use command::ThemeCommand;
6
7#[derive(Debug, Clone)]
8pub struct ThemeDefinition {
9 pub input_text: String,
10 pub input_bg: String,
11 pub output_text: String,
12 pub output_bg: String,
13 pub input_cursor_prefix: String,
14 pub input_cursor_color: String,
15 pub input_cursor: String,
16 pub output_cursor: String,
17 pub output_cursor_color: String,
18}
19
20#[derive(Debug)]
21pub struct ThemeSystem {
22 themes: HashMap<String, ThemeDefinition>,
23 current_name: String,
24 config_paths: Vec<std::path::PathBuf>,
25}
26
27impl ThemeSystem {
28 pub fn load() -> Result<Self> {
29 let config_paths = crate::setup::setup_toml::get_config_paths();
30 let themes = Self::load_themes_from_paths(&config_paths)?;
31 let current_name = Self::load_current_theme_name(&config_paths).unwrap_or_else(|| {
32 themes
33 .keys()
34 .next()
35 .cloned()
36 .unwrap_or_else(|| "default".to_string())
37 });
38
39 log::info!(
40 "ā
{} Themes loaded: {}",
41 themes.len(),
42 themes.keys().cloned().collect::<Vec<_>>().join(", ")
43 );
44
45 Ok(Self {
46 themes,
47 current_name,
48 config_paths,
49 })
50 }
51
52 pub fn show_status(&self) -> String {
53 if self.themes.is_empty() {
54 return "ā No themes available! Add [theme.xyz] sections to rush.toml.".to_string();
55 }
56 format!(
57 "Current theme: {} (from TOML)\nAvailable: {}",
58 self.current_name.to_uppercase(),
59 self.themes.keys().cloned().collect::<Vec<_>>().join(", ")
60 )
61 }
62
63 pub fn change_theme(&mut self, theme_name: &str) -> Result<String> {
64 let theme_name_lower = theme_name.to_lowercase();
65
66 if !self.themes.contains_key(&theme_name_lower) {
67 return Ok(if self.themes.is_empty() {
68 "ā No themes available! Add [theme.xyz] sections to rush.toml.".to_string()
69 } else {
70 format!(
71 "ā Theme '{}' not found. Available: {}",
72 theme_name,
73 self.themes.keys().cloned().collect::<Vec<_>>().join(", ")
74 )
75 });
76 }
77
78 self.current_name = theme_name_lower.clone();
79
80 if let Some(theme_def) = self.themes.get(&theme_name_lower) {
82 log::info!(
83 "šØ Theme '{}': input_cursor='{}' ({}), output_cursor='{}' ({}), prefix='{}'",
84 theme_name_lower.to_uppercase(),
85 theme_def.input_cursor,
86 theme_def.input_cursor_color,
87 theme_def.output_cursor,
88 theme_def.output_cursor_color,
89 theme_def.input_cursor_prefix
90 );
91 }
92
93 let name_clone = theme_name_lower.clone();
95 let paths_clone = self.config_paths.clone();
96 tokio::spawn(async move {
97 if let Err(e) = Self::save_current_theme_to_config(&paths_clone, &name_clone).await {
98 log::error!("Failed to save theme: {}", e);
99 }
100 });
101
102 Ok(format!(
103 "__LIVE_THEME_UPDATE__{}__MESSAGE__šØ Theme changed to: {} āØ",
104 theme_name_lower,
105 theme_name_lower.to_uppercase()
106 ))
107 }
108
109 pub fn preview_theme(&self, theme_name: &str) -> Result<String> {
110 let theme_name_lower = theme_name.to_lowercase();
111
112 if let Some(theme_def) = self.themes.get(&theme_name_lower) {
113 Ok(format!("šØ Theme '{}' Preview:\nInput: {} on {}\nOutput: {} on {}\nCursor Prefix: '{}' in {}\nInput Cursor: {}\nOutput Cursor: {} in {}\n\nš Source: [theme.{}] in rush.toml",
114 theme_name_lower.to_uppercase(), theme_def.input_text, theme_def.input_bg,
115 theme_def.output_text, theme_def.output_bg, theme_def.input_cursor_prefix,
116 theme_def.input_cursor_color, theme_def.input_cursor, theme_def.output_cursor,
117 theme_def.output_cursor_color, theme_name_lower))
118 } else {
119 Ok(format!(
120 "ā Theme '{}' not found. Available: {}",
121 theme_name,
122 self.themes.keys().cloned().collect::<Vec<_>>().join(", ")
123 ))
124 }
125 }
126
127 pub fn debug_theme_details(&self, theme_name: &str) -> String {
128 if let Some(theme_def) = self.themes.get(&theme_name.to_lowercase()) {
129 format!("š Theme '{}':\ninput_text: '{}'\ninput_bg: '{}'\noutput_text: '{}'\noutput_bg: '{}'\ninput_cursor_prefix: '{}'\ninput_cursor_color: '{}'\ninput_cursor: '{}'\noutput_cursor: '{}'\noutput_cursor_color: '{}'",
130 theme_name.to_uppercase(), theme_def.input_text, theme_def.input_bg,
131 theme_def.output_text, theme_def.output_bg, theme_def.input_cursor_prefix,
132 theme_def.input_cursor_color, theme_def.input_cursor, theme_def.output_cursor,
133 theme_def.output_cursor_color)
134 } else {
135 format!("ā Theme '{}' not found!", theme_name)
136 }
137 }
138
139 pub fn theme_exists(&self, theme_name: &str) -> bool {
141 self.themes.contains_key(&theme_name.to_lowercase())
142 }
143 pub fn get_theme(&self, theme_name: &str) -> Option<&ThemeDefinition> {
144 self.themes.get(&theme_name.to_lowercase())
145 }
146 pub fn get_available_names(&self) -> Vec<String> {
147 let mut names: Vec<_> = self.themes.keys().cloned().collect();
148 names.sort();
149 names
150 }
151 pub fn get_current_name(&self) -> &str {
152 &self.current_name
153 }
154
155 fn load_themes_from_paths(
156 config_paths: &[std::path::PathBuf],
157 ) -> Result<HashMap<String, ThemeDefinition>> {
158 for path in config_paths {
159 if path.exists() {
160 if let Ok(content) = std::fs::read_to_string(path) {
161 if let Ok(themes) = Self::parse_themes_from_toml(&content) {
162 return Ok(themes);
163 }
164 }
165 }
166 }
167 Ok(HashMap::new())
168 }
169
170 fn parse_themes_from_toml(content: &str) -> Result<HashMap<String, ThemeDefinition>> {
171 let mut themes = HashMap::new();
172 let mut current_theme: Option<String> = None;
173 let mut current_data = HashMap::new();
174
175 for line in content.lines() {
176 let trimmed = line.trim();
177 if trimmed.is_empty() || trimmed.starts_with('#') {
178 continue;
179 }
180
181 if let Some(theme_name) = trimmed
182 .strip_prefix("[theme.")
183 .and_then(|s| s.strip_suffix(']'))
184 {
185 Self::finalize_theme(&mut themes, current_theme.take(), &mut current_data);
186 current_theme = Some(theme_name.to_lowercase());
187 } else if trimmed.starts_with('[') && !trimmed.starts_with("[theme.") {
188 Self::finalize_theme(&mut themes, current_theme.take(), &mut current_data);
189 } else if current_theme.is_some() && trimmed.contains('=') {
190 if let Some((key, value)) = trimmed.split_once('=') {
191 let clean_value = value.trim().trim_matches('"').trim_matches('\'');
192 if !clean_value.is_empty() {
193 current_data.insert(key.trim().to_string(), clean_value.to_string());
194 }
195 }
196 }
197 }
198 Self::finalize_theme(&mut themes, current_theme, &mut current_data);
199 Ok(themes)
200 }
201
202 fn finalize_theme(
203 themes: &mut HashMap<String, ThemeDefinition>,
204 theme_name: Option<String>,
205 data: &mut HashMap<String, String>,
206 ) {
207 if let Some(name) = theme_name {
208 if let Some(theme_def) = Self::build_theme_from_data(data) {
209 themes.insert(name, theme_def);
210 }
211 data.clear();
212 }
213 }
214
215 fn build_theme_from_data(data: &HashMap<String, String>) -> Option<ThemeDefinition> {
216 Some(ThemeDefinition {
217 input_text: data.get("input_text")?.clone(),
218 input_bg: data.get("input_bg")?.clone(),
219 output_text: data.get("output_text")?.clone(),
220 output_bg: data.get("output_bg")?.clone(),
221 input_cursor_prefix: data
222 .get("input_cursor_prefix")
223 .or(data.get("prompt_text"))
224 .unwrap_or(&"/// ".to_string())
225 .clone(),
226 input_cursor_color: data
227 .get("input_cursor_color")
228 .or(data.get("prompt_color"))
229 .unwrap_or(&"LightBlue".to_string())
230 .clone(),
231 input_cursor: data
232 .get("input_cursor")
233 .or(data.get("prompt_cursor"))
234 .unwrap_or(&"PIPE".to_string())
235 .clone(),
236 output_cursor: data
237 .get("output_cursor")
238 .unwrap_or(&"PIPE".to_string())
239 .clone(),
240 output_cursor_color: data
241 .get("output_cursor_color")
242 .or(data.get("output_color"))
243 .unwrap_or(&"White".to_string())
244 .clone(),
245 })
246 }
247
248 fn load_current_theme_name(config_paths: &[std::path::PathBuf]) -> Option<String> {
249 for path in config_paths {
250 if path.exists() {
251 if let Ok(content) = std::fs::read_to_string(path) {
252 if let Some(theme) = Self::extract_current_theme_from_toml(&content) {
253 return Some(theme);
254 }
255 }
256 }
257 }
258 None
259 }
260
261 fn extract_current_theme_from_toml(content: &str) -> Option<String> {
262 let mut in_general = false;
263 for line in content.lines() {
264 let trimmed = line.trim();
265 if trimmed == "[general]" {
266 in_general = true;
267 } else if trimmed.starts_with('[') {
268 in_general = false;
269 } else if in_general && trimmed.starts_with("current_theme") {
270 return trimmed
271 .split('=')
272 .nth(1)?
273 .trim()
274 .trim_matches('"')
275 .trim_matches('\'')
276 .to_string()
277 .into();
278 }
279 }
280 None
281 }
282
283 async fn save_current_theme_to_config(
284 config_paths: &[std::path::PathBuf],
285 theme_name: &str,
286 ) -> Result<()> {
287 for path in config_paths {
288 if path.exists() {
289 let content = tokio::fs::read_to_string(path)
290 .await
291 .map_err(AppError::Io)?;
292 let updated = Self::update_current_theme_in_toml(&content, theme_name)?;
293 tokio::fs::write(path, updated)
294 .await
295 .map_err(AppError::Io)?;
296 return Ok(());
297 }
298 }
299 Err(AppError::Validation("No config file found".to_string()))
300 }
301
302 fn update_current_theme_in_toml(content: &str, theme_name: &str) -> Result<String> {
303 let mut lines: Vec<String> = content.lines().map(|l| l.to_string()).collect();
304 let mut in_general = false;
305 let mut updated = false;
306
307 for line in lines.iter_mut() {
308 let trimmed = line.trim();
309 if trimmed == "[general]" {
310 in_general = true;
311 } else if trimmed.starts_with('[') {
312 in_general = false;
313 } else if in_general && trimmed.starts_with("current_theme") {
314 *line = format!("current_theme = \"{}\"", theme_name);
315 updated = true;
316 }
317 }
318
319 if !updated {
320 if let Some(general_idx) = lines.iter().position(|line| line.trim() == "[general]") {
321 let insert_idx = lines
322 .iter()
323 .enumerate()
324 .skip(general_idx + 1)
325 .find(|(_, line)| line.trim().starts_with('['))
326 .map(|(idx, _)| idx)
327 .unwrap_or(lines.len());
328 lines.insert(insert_idx, format!("current_theme = \"{}\"", theme_name));
329 }
330 }
331 Ok(lines.join("\n"))
332 }
333}