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, self.dark_bg, lang_display, "\x1b[0m"
213 )
214 } else {
215 format!("{}{}", self.dark_bg, "─".repeat(inner_width))
216 }
217 }
218
219 pub fn quote(&self, text: &str, depth: usize) -> String {
221 let prefix = self.blockquote.repeat(depth);
222 format!("{}{}", prefix, text)
223 }
224
225 pub fn bullet(&self, indent: usize) -> String {
227 let spaces = " ".repeat(indent * 2);
228 format!("{}{}•\x1b[0m ", spaces, self.symbol_fg)
229 }
230
231 pub fn list_number(&self, indent: usize, num: usize) -> String {
233 let spaces = " ".repeat(indent * 2);
234 format!("{}{}{}.\x1b[0m ", spaces, self.symbol_fg, num)
235 }
236}
237
238fn apply_hsv_multiplier(h: f64, s: f64, v: f64, multiplier: &HsvMultiplier) -> String {
251 let new_h = (h * multiplier.h) % 360.0;
253 let new_s = (s * multiplier.s).clamp(0.0, 1.0);
254 let new_v = (v * multiplier.v).clamp(0.0, 1.0);
255
256 let (r, g, b) = hsv_to_rgb(new_h, new_s, new_v);
258
259 format!("{};{};{}m", r, g, b)
260}
261
262#[cfg(test)]
263mod tests {
264 use super::*;
265
266 #[test]
267 fn test_from_config_default() {
268 let config = StyleConfig::default();
269 let computed = ComputedStyle::from_config(&config);
270
271 assert!(computed.dark.ends_with('m'));
273 assert!(computed.dark.contains(';'));
274 assert!(computed.mid.ends_with('m'));
275 assert!(computed.bright.ends_with('m'));
276
277 assert_eq!(computed.margin_spaces, " ");
279
280 assert_eq!(computed.list_indent, " ");
282
283 assert!(!computed.codepad.0.is_empty());
285 assert!(!computed.codepad.1.is_empty());
286 }
287
288 #[test]
289 fn test_from_config_no_pretty_pad() {
290 let config = StyleConfig {
291 pretty_pad: false,
292 ..Default::default()
293 };
294 let computed = ComputedStyle::from_config(&config);
295
296 assert!(computed.codepad.0.is_empty());
297 assert!(computed.codepad.1.is_empty());
298 }
299
300 #[test]
301 fn test_apply_hsv_multiplier() {
302 let result = apply_hsv_multiplier(288.0, 0.5, 0.5, &HsvMultiplier::new(1.0, 1.0, 1.0));
304
305 assert!(result.ends_with('m'));
307 let parts: Vec<&str> = result.trim_end_matches('m').split(';').collect();
308 assert_eq!(parts.len(), 3);
309
310 for part in parts {
312 let _val: u8 = part.parse().unwrap();
313 }
314 }
315
316 #[test]
317 fn test_dark_is_actually_dark() {
318 let config = StyleConfig::default();
319 let computed = ComputedStyle::from_config(&config);
320
321 let parts: Vec<u8> = computed
323 .dark
324 .trim_end_matches('m')
325 .split(';')
326 .map(|s| s.parse().unwrap())
327 .collect();
328
329 let avg = (parts[0] as u32 + parts[1] as u32 + parts[2] as u32) / 3;
331 assert!(avg < 100, "Dark should be dark, got avg brightness {}", avg);
332 }
333
334 #[test]
335 fn test_bright_is_actually_bright() {
336 let config = StyleConfig::default();
337 let computed = ComputedStyle::from_config(&config);
338
339 let parts: Vec<u8> = computed
341 .bright
342 .trim_end_matches('m')
343 .split(';')
344 .map(|s| s.parse().unwrap())
345 .collect();
346
347 let max = parts.iter().max().unwrap();
349 assert!(*max > 150, "Bright should be bright, got max {}", max);
350 }
351
352 #[test]
353 fn test_fg_method() {
354 let config = StyleConfig::default();
355 let computed = ComputedStyle::from_config(&config);
356
357 assert!(computed.fg("dark").starts_with("\x1b[38;2;"));
358 assert!(computed.fg("bright").starts_with("\x1b[38;2;"));
359 assert!(computed.fg("unknown").is_empty());
360 }
361
362 #[test]
363 fn test_style_fg() {
364 let config = StyleConfig::default();
365 let computed = ComputedStyle::from_config(&config);
366
367 let styled = computed.style_fg("head", "Hello");
368 assert!(styled.starts_with("\x1b[38;2;"));
369 assert!(styled.contains("Hello"));
370 assert!(styled.ends_with("\x1b[0m"));
371 }
372
373 #[test]
374 fn test_heading() {
375 let config = StyleConfig::default();
376 let computed = ComputedStyle::from_config(&config);
377
378 let h1 = computed.heading(1, "Title");
379 assert!(h1.contains("# Title"));
380 assert!(h1.ends_with("\x1b[0m"));
381
382 let h3 = computed.heading(3, "Section");
383 assert!(h3.contains("### Section"));
384 }
385
386 #[test]
387 fn test_bullet() {
388 let config = StyleConfig::default();
389 let computed = ComputedStyle::from_config(&config);
390
391 let bullet = computed.bullet(0);
392 assert!(bullet.contains("•"));
393
394 let indented = computed.bullet(2);
395 assert!(indented.starts_with(" ")); }
397
398 #[test]
399 fn test_list_number() {
400 let config = StyleConfig::default();
401 let computed = ComputedStyle::from_config(&config);
402
403 let num = computed.list_number(0, 1);
404 assert!(num.contains("1."));
405
406 let num5 = computed.list_number(1, 5);
407 assert!(num5.contains("5."));
408 assert!(num5.starts_with(" ")); }
410
411 #[test]
412 fn test_quote() {
413 let config = StyleConfig::default();
414 let computed = ComputedStyle::from_config(&config);
415
416 let quote = computed.quote("Hello", 1);
417 assert!(quote.contains("│"));
418 assert!(quote.contains("Hello"));
419
420 let nested = computed.quote("Nested", 2);
421 assert!(nested.matches('│').count() >= 2);
423 }
424}