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\nSource: [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 {
140 self.themes.contains_key(&theme_name.to_lowercase())
141 }
142 pub fn get_theme(&self, theme_name: &str) -> Option<&ThemeDefinition> {
143 self.themes.get(&theme_name.to_lowercase())
144 }
145 pub fn get_available_names(&self) -> Vec<String> {
146 let mut names: Vec<_> = self.themes.keys().cloned().collect();
147 names.sort();
148 names
149 }
150 pub fn get_current_name(&self) -> &str {
151 &self.current_name
152 }
153
154 fn load_themes_from_paths(
155 config_paths: &[std::path::PathBuf],
156 ) -> Result<HashMap<String, ThemeDefinition>> {
157 for path in config_paths {
158 if path.exists() {
159 if let Ok(content) = std::fs::read_to_string(path) {
160 if let Ok(themes) = Self::parse_themes_from_toml(&content) {
161 return Ok(themes);
162 }
163 }
164 }
165 }
166 Ok(HashMap::new())
167 }
168
169 fn parse_themes_from_toml(content: &str) -> Result<HashMap<String, ThemeDefinition>> {
170 let mut themes = HashMap::new();
171 let mut current_theme: Option<String> = None;
172 let mut current_data = HashMap::new();
173
174 for line in content.lines() {
175 let trimmed = line.trim();
176 if trimmed.is_empty() || trimmed.starts_with('#') {
177 continue;
178 }
179
180 if let Some(theme_name) = trimmed
181 .strip_prefix("[theme.")
182 .and_then(|s| s.strip_suffix(']'))
183 {
184 Self::finalize_theme(&mut themes, current_theme.take(), &mut current_data);
185 current_theme = Some(theme_name.to_lowercase());
186 } else if trimmed.starts_with('[') && !trimmed.starts_with("[theme.") {
187 Self::finalize_theme(&mut themes, current_theme.take(), &mut current_data);
188 } else if current_theme.is_some() && trimmed.contains('=') {
189 if let Some((key, value)) = trimmed.split_once('=') {
190 let clean_value = value.trim().trim_matches('"').trim_matches('\'');
191 if !clean_value.is_empty() {
192 current_data.insert(key.trim().to_string(), clean_value.to_string());
193 }
194 }
195 }
196 }
197 Self::finalize_theme(&mut themes, current_theme, &mut current_data);
198 Ok(themes)
199 }
200
201 fn finalize_theme(
202 themes: &mut HashMap<String, ThemeDefinition>,
203 theme_name: Option<String>,
204 data: &mut HashMap<String, String>,
205 ) {
206 if let Some(name) = theme_name {
207 if let Some(theme_def) = Self::build_theme_from_data(data) {
208 themes.insert(name, theme_def);
209 }
210 data.clear();
211 }
212 }
213
214 fn build_theme_from_data(data: &HashMap<String, String>) -> Option<ThemeDefinition> {
215 Some(ThemeDefinition {
216 input_text: data.get("input_text")?.clone(),
217 input_bg: data.get("input_bg")?.clone(),
218 output_text: data.get("output_text")?.clone(),
219 output_bg: data.get("output_bg")?.clone(),
220 input_cursor_prefix: data
221 .get("input_cursor_prefix")
222 .or(data.get("prompt_text"))
223 .unwrap_or(&"/// ".to_string())
224 .clone(),
225 input_cursor_color: data
226 .get("input_cursor_color")
227 .or(data.get("prompt_color"))
228 .unwrap_or(&"LightBlue".to_string())
229 .clone(),
230 input_cursor: data
231 .get("input_cursor")
232 .or(data.get("prompt_cursor"))
233 .unwrap_or(&"PIPE".to_string())
234 .clone(),
235 output_cursor: data
236 .get("output_cursor")
237 .unwrap_or(&"PIPE".to_string())
238 .clone(),
239 output_cursor_color: data
240 .get("output_cursor_color")
241 .or(data.get("output_color"))
242 .unwrap_or(&"White".to_string())
243 .clone(),
244 })
245 }
246
247 fn load_current_theme_name(config_paths: &[std::path::PathBuf]) -> Option<String> {
248 for path in config_paths {
249 if path.exists() {
250 if let Ok(content) = std::fs::read_to_string(path) {
251 if let Some(theme) = Self::extract_current_theme_from_toml(&content) {
252 return Some(theme);
253 }
254 }
255 }
256 }
257 None
258 }
259
260 fn extract_current_theme_from_toml(content: &str) -> Option<String> {
261 let mut in_general = false;
262 for line in content.lines() {
263 let trimmed = line.trim();
264 if trimmed == "[general]" {
265 in_general = true;
266 } else if trimmed.starts_with('[') {
267 in_general = false;
268 } else if in_general && trimmed.starts_with("current_theme") {
269 return trimmed
270 .split('=')
271 .nth(1)?
272 .trim()
273 .trim_matches('"')
274 .trim_matches('\'')
275 .to_string()
276 .into();
277 }
278 }
279 None
280 }
281
282 async fn save_current_theme_to_config(
283 config_paths: &[std::path::PathBuf],
284 theme_name: &str,
285 ) -> Result<()> {
286 for path in config_paths {
287 if path.exists() {
288 let content = tokio::fs::read_to_string(path)
289 .await
290 .map_err(AppError::Io)?;
291 let updated = Self::update_current_theme_in_toml(&content, theme_name)?;
292 tokio::fs::write(path, updated)
293 .await
294 .map_err(AppError::Io)?;
295 return Ok(());
296 }
297 }
298 Err(AppError::Validation("No config file found".to_string()))
299 }
300
301 fn update_current_theme_in_toml(content: &str, theme_name: &str) -> Result<String> {
302 let mut lines: Vec<String> = content.lines().map(|l| l.to_string()).collect();
303 let mut in_general = false;
304 let mut updated = false;
305
306 for line in lines.iter_mut() {
307 let trimmed = line.trim();
308 if trimmed == "[general]" {
309 in_general = true;
310 } else if trimmed.starts_with('[') {
311 in_general = false;
312 } else if in_general && trimmed.starts_with("current_theme") {
313 *line = format!("current_theme = \"{}\"", theme_name);
314 updated = true;
315 }
316 }
317
318 if !updated {
319 if let Some(general_idx) = lines.iter().position(|line| line.trim() == "[general]") {
320 let insert_idx = lines
321 .iter()
322 .enumerate()
323 .skip(general_idx + 1)
324 .find(|(_, line)| line.trim().starts_with('['))
325 .map(|(idx, _)| idx)
326 .unwrap_or(lines.len());
327 lines.insert(insert_idx, format!("current_theme = \"{}\"", theme_name));
328 }
329 }
330 Ok(lines.join("\n"))
331 }
332}