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,
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    /// Create a blockquote line.
223    pub fn quote(&self, text: &str, depth: usize) -> String {
224        let prefix = self.blockquote.repeat(depth);
225        format!("{}{}", prefix, text)
226    }
227
228    /// Create a list bullet.
229    pub fn bullet(&self, indent: usize) -> String {
230        let spaces = " ".repeat(indent * 2);
231        format!("{}{}•\x1b[0m ", spaces, self.symbol_fg)
232    }
233
234    /// Create an ordered list number.
235    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
241/// Apply HSV multiplier to base HSV values and return ANSI RGB string.
242///
243/// # Arguments
244///
245/// * `h` - Base hue (0..360)
246/// * `s` - Base saturation (0..1)
247/// * `v` - Base value (0..1)
248/// * `multiplier` - The HSV multiplier to apply
249///
250/// # Returns
251///
252/// String in format "r;g;bm" ready for ANSI escape sequences.
253fn apply_hsv_multiplier(h: f64, s: f64, v: f64, multiplier: &HsvMultiplier) -> String {
254    // Apply multipliers with clamping
255    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    // Convert to RGB
260    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        // Verify format of color strings
275        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        // Verify margin spaces
281        assert_eq!(computed.margin_spaces, "  ");
282
283        // Verify list indent
284        assert_eq!(computed.list_indent, "  ");
285
286        // Verify codepad is set (pretty_pad is true by default)
287        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        // Base HSV: 288°, 0.5 saturation, 0.5 value (purple)
306        let result = apply_hsv_multiplier(288.0, 0.5, 0.5, &HsvMultiplier::new(1.0, 1.0, 1.0));
307
308        // Should be some valid RGB format
309        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        // All parts should be valid u8 values
314        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        // Parse the RGB values from dark
325        let parts: Vec<u8> = computed
326            .dark
327            .trim_end_matches('m')
328            .split(';')
329            .map(|s| s.parse().unwrap())
330            .collect();
331
332        // With V multiplier of 0.25, values should be low
333        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        // Parse the RGB values from bright
343        let parts: Vec<u8> = computed
344            .bright
345            .trim_end_matches('m')
346            .split(';')
347            .map(|s| s.parse().unwrap())
348            .collect();
349
350        // With V multiplier of 2.0 (clamped to 1.0), at least one value should be high
351        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("    ")); // 2 * 2 spaces
399    }
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("  ")); // 1 * 2 spaces
412    }
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        // Should have two quote prefixes
425        assert!(nested.matches('│').count() >= 2);
426    }
427}