1use crate::style::{HsvMultiplier, StyleConfig};
7use streamdown_ansi::color::hsv_to_rgb;
8
9#[derive(Debug, Clone, Default)]
15pub struct ComputedStyle {
16 pub dark: String,
19
20 pub mid: String,
23
24 pub symbol: String,
27
28 pub head: String,
31
32 pub grey: String,
35
36 pub bright: String,
39
40 pub margin_spaces: String,
42
43 pub blockquote: String,
45
46 pub codebg: String,
48
49 pub link: String,
51
52 pub codepad: (String, String),
55
56 pub list_indent: String,
58
59 pub dark_fg: String,
61
62 pub dark_bg: String,
64
65 pub mid_fg: String,
67
68 pub symbol_fg: String,
70
71 pub head_fg: String,
73
74 pub grey_fg: String,
76
77 pub bright_fg: String,
79}
80
81impl ComputedStyle {
82 pub fn from_config(config: &StyleConfig) -> Self {
103 let (base_h, base_s, base_v) = config.base_hsv();
104
105 let dark = apply_hsv_multiplier(base_h, base_s, base_v, &config.dark);
107 let mid = apply_hsv_multiplier(base_h, base_s, base_v, &config.mid);
108 let symbol = apply_hsv_multiplier(base_h, base_s, base_v, &config.symbol);
109 let head = apply_hsv_multiplier(base_h, base_s, base_v, &config.head);
110 let grey = apply_hsv_multiplier(base_h, base_s, base_v, &config.grey);
111 let bright = apply_hsv_multiplier(base_h, base_s, base_v, &config.bright);
112
113 let dark_fg = format!("\x1b[38;2;{}", dark);
115 let dark_bg = format!("\x1b[48;2;{}", dark);
116 let mid_fg = format!("\x1b[38;2;{}", mid);
117 let symbol_fg = format!("\x1b[38;2;{}", symbol);
118 let head_fg = format!("\x1b[38;2;{}", head);
119 let grey_fg = format!("\x1b[38;2;{}", grey);
120 let bright_fg = format!("\x1b[38;2;{}", bright);
121
122 let margin_spaces = " ".repeat(config.margin);
124
125 let list_indent = " ".repeat(config.list_indent);
127
128 let blockquote = format!("{}│\x1b[0m ", grey_fg);
130
131 let codebg = dark_bg.clone();
133
134 let link = bright_fg.clone();
136
137 let codepad = if config.pretty_pad {
139 (
141 format!("{}▌\x1b[0m", grey_fg), format!("{}▐\x1b[0m", grey_fg), )
144 } else {
145 (String::new(), String::new())
146 };
147
148 Self {
149 dark,
150 mid,
151 symbol,
152 head,
153 grey,
154 bright,
155 margin_spaces,
156 blockquote,
157 codebg,
158 link,
159 codepad,
160 list_indent,
161 dark_fg,
162 dark_bg,
163 mid_fg,
164 symbol_fg,
165 head_fg,
166 grey_fg,
167 bright_fg,
168 }
169 }
170
171 pub fn fg(&self, name: &str) -> &str {
173 match name {
174 "dark" => &self.dark_fg,
175 "mid" => &self.mid_fg,
176 "symbol" => &self.symbol_fg,
177 "head" => &self.head_fg,
178 "grey" => &self.grey_fg,
179 "bright" => &self.bright_fg,
180 _ => "",
181 }
182 }
183
184 pub fn bg(&self, name: &str) -> &str {
186 match name {
187 "dark" => &self.dark_bg,
188 _ => "",
189 }
190 }
191
192 pub fn style_fg(&self, name: &str, text: &str) -> String {
194 format!("{}{}\x1b[0m", self.fg(name), text)
195 }
196
197 pub fn heading(&self, level: u8, text: &str) -> String {
199 let prefix = "#".repeat(level as usize);
200 format!("{}{} {}\x1b[0m", self.head_fg, prefix, text)
201 }
202
203 pub fn code_start(&self, language: Option<&str>, width: usize) -> String {
205 let (left, _right) = &self.codepad;
206 let lang_display = language.unwrap_or("");
207 let inner_width = width.saturating_sub(2); if !left.is_empty() {
210 format!(
211 "{}{}─{}{}\x1b[0m",
212 left,
213 self.dark_bg,
214 lang_display,
215 "\x1b[0m"
216 )
217 } else {
218 format!("{}{}", self.dark_bg, "─".repeat(inner_width))
219 }
220 }
221
222 pub fn quote(&self, text: &str, depth: usize) -> String {
224 let prefix = self.blockquote.repeat(depth);
225 format!("{}{}", prefix, text)
226 }
227
228 pub fn bullet(&self, indent: usize) -> String {
230 let spaces = " ".repeat(indent * 2);
231 format!("{}{}•\x1b[0m ", spaces, self.symbol_fg)
232 }
233
234 pub fn list_number(&self, indent: usize, num: usize) -> String {
236 let spaces = " ".repeat(indent * 2);
237 format!("{}{}{}.\x1b[0m ", spaces, self.symbol_fg, num)
238 }
239}
240
241fn apply_hsv_multiplier(h: f64, s: f64, v: f64, multiplier: &HsvMultiplier) -> String {
254 let new_h = (h * multiplier.h) % 360.0;
256 let new_s = (s * multiplier.s).clamp(0.0, 1.0);
257 let new_v = (v * multiplier.v).clamp(0.0, 1.0);
258
259 let (r, g, b) = hsv_to_rgb(new_h, new_s, new_v);
261
262 format!("{};{};{}m", r, g, b)
263}
264
265#[cfg(test)]
266mod tests {
267 use super::*;
268
269 #[test]
270 fn test_from_config_default() {
271 let config = StyleConfig::default();
272 let computed = ComputedStyle::from_config(&config);
273
274 assert!(computed.dark.ends_with('m'));
276 assert!(computed.dark.contains(';'));
277 assert!(computed.mid.ends_with('m'));
278 assert!(computed.bright.ends_with('m'));
279
280 assert_eq!(computed.margin_spaces, " ");
282
283 assert_eq!(computed.list_indent, " ");
285
286 assert!(!computed.codepad.0.is_empty());
288 assert!(!computed.codepad.1.is_empty());
289 }
290
291 #[test]
292 fn test_from_config_no_pretty_pad() {
293 let config = StyleConfig {
294 pretty_pad: false,
295 ..Default::default()
296 };
297 let computed = ComputedStyle::from_config(&config);
298
299 assert!(computed.codepad.0.is_empty());
300 assert!(computed.codepad.1.is_empty());
301 }
302
303 #[test]
304 fn test_apply_hsv_multiplier() {
305 let result = apply_hsv_multiplier(288.0, 0.5, 0.5, &HsvMultiplier::new(1.0, 1.0, 1.0));
307
308 assert!(result.ends_with('m'));
310 let parts: Vec<&str> = result.trim_end_matches('m').split(';').collect();
311 assert_eq!(parts.len(), 3);
312
313 for part in parts {
315 let _val: u8 = part.parse().unwrap();
316 }
317 }
318
319 #[test]
320 fn test_dark_is_actually_dark() {
321 let config = StyleConfig::default();
322 let computed = ComputedStyle::from_config(&config);
323
324 let parts: Vec<u8> = computed
326 .dark
327 .trim_end_matches('m')
328 .split(';')
329 .map(|s| s.parse().unwrap())
330 .collect();
331
332 let avg = (parts[0] as u32 + parts[1] as u32 + parts[2] as u32) / 3;
334 assert!(avg < 100, "Dark should be dark, got avg brightness {}", avg);
335 }
336
337 #[test]
338 fn test_bright_is_actually_bright() {
339 let config = StyleConfig::default();
340 let computed = ComputedStyle::from_config(&config);
341
342 let parts: Vec<u8> = computed
344 .bright
345 .trim_end_matches('m')
346 .split(';')
347 .map(|s| s.parse().unwrap())
348 .collect();
349
350 let max = parts.iter().max().unwrap();
352 assert!(*max > 150, "Bright should be bright, got max {}", max);
353 }
354
355 #[test]
356 fn test_fg_method() {
357 let config = StyleConfig::default();
358 let computed = ComputedStyle::from_config(&config);
359
360 assert!(computed.fg("dark").starts_with("\x1b[38;2;"));
361 assert!(computed.fg("bright").starts_with("\x1b[38;2;"));
362 assert!(computed.fg("unknown").is_empty());
363 }
364
365 #[test]
366 fn test_style_fg() {
367 let config = StyleConfig::default();
368 let computed = ComputedStyle::from_config(&config);
369
370 let styled = computed.style_fg("head", "Hello");
371 assert!(styled.starts_with("\x1b[38;2;"));
372 assert!(styled.contains("Hello"));
373 assert!(styled.ends_with("\x1b[0m"));
374 }
375
376 #[test]
377 fn test_heading() {
378 let config = StyleConfig::default();
379 let computed = ComputedStyle::from_config(&config);
380
381 let h1 = computed.heading(1, "Title");
382 assert!(h1.contains("# Title"));
383 assert!(h1.ends_with("\x1b[0m"));
384
385 let h3 = computed.heading(3, "Section");
386 assert!(h3.contains("### Section"));
387 }
388
389 #[test]
390 fn test_bullet() {
391 let config = StyleConfig::default();
392 let computed = ComputedStyle::from_config(&config);
393
394 let bullet = computed.bullet(0);
395 assert!(bullet.contains("•"));
396
397 let indented = computed.bullet(2);
398 assert!(indented.starts_with(" ")); }
400
401 #[test]
402 fn test_list_number() {
403 let config = StyleConfig::default();
404 let computed = ComputedStyle::from_config(&config);
405
406 let num = computed.list_number(0, 1);
407 assert!(num.contains("1."));
408
409 let num5 = computed.list_number(1, 5);
410 assert!(num5.contains("5."));
411 assert!(num5.starts_with(" ")); }
413
414 #[test]
415 fn test_quote() {
416 let config = StyleConfig::default();
417 let computed = ComputedStyle::from_config(&config);
418
419 let quote = computed.quote("Hello", 1);
420 assert!(quote.contains("│"));
421 assert!(quote.contains("Hello"));
422
423 let nested = computed.quote("Nested", 2);
424 assert!(nested.matches('│').count() >= 2);
426 }
427}