Skip to main content

streamdown_config/
computed.rs

1//! Computed style values.
2//!
3//! This module contains `ComputedStyle` which holds pre-computed
4//! ANSI color codes derived from the style configuration.
5
6use crate::style::{HsvMultiplier, StyleConfig};
7use streamdown_ansi::color::hsv_to_rgb;
8
9/// Pre-computed ANSI color strings.
10///
11/// These values are computed from `StyleConfig` by applying HSV multipliers
12/// to the base color. Each string is formatted for ANSI escape sequences,
13/// e.g., "48;23;15m" for background colors or "255;128;64m" for foreground.
14#[derive(Debug, Clone, Default)]
15pub struct ComputedStyle {
16    /// Dark color (for backgrounds, code block bg).
17    /// Format: "r;g;bm"
18    pub dark: String,
19
20    /// Mid color (for secondary elements).
21    /// Format: "r;g;bm"
22    pub mid: String,
23
24    /// Symbol color (for special characters, bullets).
25    /// Format: "r;g;bm"
26    pub symbol: String,
27
28    /// Head color (for headers).
29    /// Format: "r;g;bm"
30    pub head: String,
31
32    /// Grey color (for muted text, blockquote bars).
33    /// Format: "r;g;bm"
34    pub grey: String,
35
36    /// Bright color (for emphasis, links).
37    /// Format: "r;g;bm"
38    pub bright: String,
39
40    /// Margin spaces string (e.g., "  " for margin=2).
41    pub margin_spaces: String,
42
43    /// Block quote prefix string with ANSI styling.
44    pub blockquote: String,
45
46    /// Code block background ANSI sequence.
47    pub codebg: String,
48
49    /// Link color ANSI sequence.
50    pub link: String,
51
52    /// Code block padding characters (left, right).
53    /// Used for pretty padding around code blocks.
54    pub codepad: (String, String),
55
56    /// List indent string.
57    pub list_indent: String,
58
59    /// Full ANSI foreground escape for dark.
60    pub dark_fg: String,
61
62    /// Full ANSI background escape for dark.
63    pub dark_bg: String,
64
65    /// Full ANSI foreground escape for mid.
66    pub mid_fg: String,
67
68    /// Full ANSI foreground escape for symbol.
69    pub symbol_fg: String,
70
71    /// Full ANSI foreground escape for head.
72    pub head_fg: String,
73
74    /// Full ANSI foreground escape for grey.
75    pub grey_fg: String,
76
77    /// Full ANSI foreground escape for bright.
78    pub bright_fg: String,
79}
80
81impl ComputedStyle {
82    /// Compute style values from a StyleConfig.
83    ///
84    /// This applies the HSV multipliers to the base color to generate
85    /// all derived colors, then formats them as ANSI escape sequences.
86    ///
87    /// # Arguments
88    ///
89    /// * `config` - The style configuration to compute from
90    ///
91    /// # Example
92    ///
93    /// ```
94    /// use streamdown_config::{StyleConfig, ComputedStyle};
95    ///
96    /// let config = StyleConfig::default();
97    /// let computed = ComputedStyle::from_config(&config);
98    ///
99    /// // Use the computed dark background
100    /// let bg_escape = format!("\x1b[48;2;{}", computed.dark);
101    /// ```
102    pub fn from_config(config: &StyleConfig) -> Self {
103        let (base_h, base_s, base_v) = config.base_hsv();
104
105        // Compute all colors by applying multipliers
106        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        // Pre-compute full ANSI sequences
114        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        // Margin spaces
123        let margin_spaces = " ".repeat(config.margin);
124
125        // List indent
126        let list_indent = " ".repeat(config.list_indent);
127
128        // Block quote with grey bar
129        let blockquote = format!("{}│\x1b[0m ", grey_fg);
130
131        // Code background
132        let codebg = dark_bg.clone();
133
134        // Link color (using bright)
135        let link = bright_fg.clone();
136
137        // Code padding characters
138        let codepad = if config.pretty_pad {
139            // Use box drawing characters for pretty padding
140            (
141                format!("{}▌\x1b[0m", grey_fg), // Left half block
142                format!("{}▐\x1b[0m", grey_fg), // Right half block
143            )
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    /// Get the foreground ANSI escape for a specific style.
172    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    /// Get the background ANSI escape for a specific style.
185    pub fn bg(&self, name: &str) -> &str {
186        match name {
187            "dark" => &self.dark_bg,
188            _ => "",
189        }
190    }
191
192    /// Format text with a specific foreground style.
193    pub fn style_fg(&self, name: &str, text: &str) -> String {
194        format!("{}{}\x1b[0m", self.fg(name), text)
195    }
196
197    /// Create a heading line with the head color.
198    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    /// Create a code block start line.
204    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); // Account for padding chars
208
209        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    /// Create a blockquote line.
220    pub fn quote(&self, text: &str, depth: usize) -> String {
221        let prefix = self.blockquote.repeat(depth);
222        format!("{}{}", prefix, text)
223    }
224
225    /// Create a list bullet.
226    pub fn bullet(&self, indent: usize) -> String {
227        let spaces = " ".repeat(indent * 2);
228        format!("{}{}•\x1b[0m ", spaces, self.symbol_fg)
229    }
230
231    /// Create an ordered list number.
232    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
238/// Apply HSV multiplier to base HSV values and return ANSI RGB string.
239///
240/// # Arguments
241///
242/// * `h` - Base hue (0..360)
243/// * `s` - Base saturation (0..1)
244/// * `v` - Base value (0..1)
245/// * `multiplier` - The HSV multiplier to apply
246///
247/// # Returns
248///
249/// String in format "r;g;bm" ready for ANSI escape sequences.
250fn apply_hsv_multiplier(h: f64, s: f64, v: f64, multiplier: &HsvMultiplier) -> String {
251    // Apply multipliers with clamping
252    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    // Convert to RGB
257    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        // Verify format of color strings
272        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        // Verify margin spaces
278        assert_eq!(computed.margin_spaces, "  ");
279
280        // Verify list indent
281        assert_eq!(computed.list_indent, "  ");
282
283        // Verify codepad is set (pretty_pad is true by default)
284        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        // Base HSV: 288°, 0.5 saturation, 0.5 value (purple)
303        let result = apply_hsv_multiplier(288.0, 0.5, 0.5, &HsvMultiplier::new(1.0, 1.0, 1.0));
304
305        // Should be some valid RGB format
306        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        // All parts should be valid u8 values
311        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        // Parse the RGB values from dark
322        let parts: Vec<u8> = computed
323            .dark
324            .trim_end_matches('m')
325            .split(';')
326            .map(|s| s.parse().unwrap())
327            .collect();
328
329        // With V multiplier of 0.25, values should be low
330        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        // Parse the RGB values from bright
340        let parts: Vec<u8> = computed
341            .bright
342            .trim_end_matches('m')
343            .split(';')
344            .map(|s| s.parse().unwrap())
345            .collect();
346
347        // With V multiplier of 2.0 (clamped to 1.0), at least one value should be high
348        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("    ")); // 2 * 2 spaces
396    }
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("  ")); // 1 * 2 spaces
409    }
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        // Should have two quote prefixes
422        assert!(nested.matches('│').count() >= 2);
423    }
424}