Skip to main content

rich_rs/
rule.rs

1//! Rule: a horizontal line renderable.
2//!
3//! A rule is a horizontal line that can optionally have a title.
4//! The title can be aligned left, center, or right.
5//!
6//! # Example
7//!
8//! ```
9//! use rich_rs::rule::{Rule, AlignMethod};
10//!
11//! // Simple rule without title
12//! let rule = Rule::new();
13//!
14//! // Rule with centered title
15//! let rule = Rule::new().with_title("Section Header");
16//!
17//! // Rule with left-aligned title
18//! let rule = Rule::new()
19//!     .with_title("Left Title")
20//!     .with_align(AlignMethod::Left);
21//! ```
22
23use crate::Renderable;
24use crate::cells::{cell_len, set_cell_size};
25use crate::console::{Console, ConsoleOptions, OverflowMethod};
26use crate::measure::Measurement;
27use crate::segment::Segments;
28use crate::style::Style;
29use crate::text::Text;
30
31// ============================================================================
32// AlignMethod
33// ============================================================================
34
35/// Text alignment method for Rule titles.
36#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
37pub enum AlignMethod {
38    /// Left-aligned title.
39    Left,
40    /// Center-aligned title (default).
41    #[default]
42    Center,
43    /// Right-aligned title.
44    Right,
45}
46
47impl AlignMethod {
48    /// Parse an alignment method from a string.
49    pub fn parse(s: &str) -> Option<Self> {
50        match s.to_lowercase().as_str() {
51            "left" => Some(AlignMethod::Left),
52            "center" => Some(AlignMethod::Center),
53            "right" => Some(AlignMethod::Right),
54            _ => None,
55        }
56    }
57}
58
59// ============================================================================
60// Rule
61// ============================================================================
62
63/// A horizontal rule (line) that can optionally have a title.
64///
65/// # Example
66///
67/// ```
68/// use rich_rs::rule::{Rule, AlignMethod};
69/// use rich_rs::Style;
70///
71/// // Simple horizontal line
72/// let rule = Rule::new();
73///
74/// // Rule with a centered title
75/// let titled_rule = Rule::new().with_title("My Section");
76///
77/// // Customized rule
78/// let custom_rule = Rule::new()
79///     .with_title("Header")
80///     .with_characters("=")
81///     .with_align(AlignMethod::Left)
82///     .with_style(Style::new().with_bold(true));
83/// ```
84#[derive(Debug, Clone)]
85pub struct Rule {
86    /// Optional title text.
87    title: Option<Text>,
88    /// Characters used to draw the line (default: "─").
89    characters: String,
90    /// Style for the rule line.
91    style: Style,
92    /// String to append at the end (default: "\n").
93    end: String,
94    /// Title alignment (default: Center).
95    align: AlignMethod,
96}
97
98impl Default for Rule {
99    fn default() -> Self {
100        Rule::new()
101    }
102}
103
104impl Rule {
105    /// Create a new rule with default settings.
106    ///
107    /// Default settings:
108    /// - No title
109    /// - "─" (box drawing horizontal) as the line character
110    /// - Default style (from theme "rule.line")
111    /// - Newline at the end
112    /// - Center alignment
113    pub fn new() -> Self {
114        Rule {
115            title: None,
116            characters: "─".to_string(),
117            style: Style::new(),
118            end: "\n".to_string(),
119            align: AlignMethod::Center,
120        }
121    }
122
123    /// Set the title from a string.
124    ///
125    /// The title supports BBCode-style markup (e.g., `[bold]Title[/bold]`).
126    /// If markup parsing fails, the title is used as plain text.
127    pub fn with_title(mut self, title: impl Into<String>) -> Self {
128        let title_str = title.into();
129        // Try to parse as markup; fall back to plain text on error.
130        self.title =
131            Some(Text::from_markup(&title_str, false).unwrap_or_else(|_| Text::plain(&title_str)));
132        self
133    }
134
135    /// Set the title from a Text object.
136    ///
137    /// This allows for styled titles.
138    pub fn with_title_text(mut self, title: Text) -> Self {
139        self.title = Some(title);
140        self
141    }
142
143    /// Set the characters used to draw the line.
144    ///
145    /// # Panics
146    ///
147    /// Panics if the characters have a cell width less than 1.
148    pub fn with_characters(mut self, characters: impl Into<String>) -> Self {
149        let chars = characters.into();
150        assert!(
151            cell_len(&chars) >= 1,
152            "'characters' argument must have a cell width of at least 1"
153        );
154        self.characters = chars;
155        self
156    }
157
158    /// Set the style for the rule line.
159    pub fn with_style(mut self, style: Style) -> Self {
160        self.style = style;
161        self
162    }
163
164    /// Set the end string (default is "\n").
165    pub fn with_end(mut self, end: impl Into<String>) -> Self {
166        self.end = end.into();
167        self
168    }
169
170    /// Set the title alignment.
171    pub fn with_align(mut self, align: AlignMethod) -> Self {
172        self.align = align;
173        self
174    }
175
176    /// Generate a line of characters without a title.
177    fn rule_line(&self, characters: &str, chars_len: usize, width: usize) -> Text {
178        // Create enough characters to fill the width, then truncate
179        let repeat_count = (width / chars_len) + 1;
180        let line_chars = characters.repeat(repeat_count);
181        let rule_text = Text::styled(line_chars, self.style);
182
183        // Truncate to exact width (using character count, not cell width)
184        let chars: Vec<char> = rule_text.plain_text().chars().collect();
185        let mut current_width = 0;
186        let mut char_count = 0;
187        for c in &chars {
188            let cw = crate::cells::char_width(*c);
189            if current_width + cw > width {
190                break;
191            }
192            current_width += cw;
193            char_count += 1;
194        }
195
196        // Rebuild the text with the truncated content
197        let truncated: String = chars[..char_count].iter().collect();
198        let mut result = Text::styled(truncated, self.style);
199
200        // Pad to exact width if needed
201        if current_width < width {
202            let padding = " ".repeat(width - current_width);
203            result.append(&padding, Some(self.style));
204        }
205
206        result
207    }
208}
209
210impl Renderable for Rule {
211    fn render(&self, _console: &Console, options: &ConsoleOptions) -> Segments {
212        let width = options.max_width;
213
214        // Handle ASCII-only mode
215        let characters = if options.ascii_only() && !self.characters.is_ascii() {
216            "-".to_string()
217        } else {
218            self.characters.clone()
219        };
220
221        let chars_len = cell_len(&characters);
222
223        // If no title, just render the line
224        if self.title.is_none() {
225            let mut rule_text = self.rule_line(&characters, chars_len, width);
226            // Apply the end string
227            if !self.end.is_empty() {
228                rule_text.append(&self.end, None);
229            }
230            return rule_text.render(_console, options);
231        }
232
233        // We have a title - need to build the rule with title
234        let title = self.title.as_ref().unwrap();
235
236        // Prepare title text: replace newlines with spaces and expand tabs
237        // We preserve the base style from the original title
238        let plain = title.plain_text().replace('\n', " ");
239        let mut title_text = if let Some(style) = title.base_style() {
240            Text::styled(&plain, style)
241        } else {
242            Text::plain(&plain)
243        };
244
245        // Copy over spans from original title
246        // Since we just replaced newlines with spaces, character offsets are preserved
247        for span in title.spans() {
248            title_text.stylize(span.start, span.end, span.style);
249        }
250
251        // Expand tabs
252        title_text = title_text.expand_tabs(8);
253
254        // Calculate required space for title
255        // Center alignment needs 2 chars on each side, left/right needs 2 chars on one side
256        let required_space = if self.align == AlignMethod::Center {
257            4
258        } else {
259            2
260        };
261
262        let truncate_width = width.saturating_sub(required_space);
263        if truncate_width == 0 {
264            // No room for title, just render the line
265            let mut rule_text = self.rule_line(&characters, chars_len, width);
266            if !self.end.is_empty() {
267                rule_text.append(&self.end, None);
268            }
269            return rule_text.render(_console, options);
270        }
271
272        // Truncate title if needed
273        let title_text = title_text.truncate(truncate_width, OverflowMethod::Ellipsis, false);
274
275        // Build the rule text based on alignment
276        let mut rule_text = Text::new();
277
278        match self.align {
279            AlignMethod::Center => {
280                // ───── Title ─────
281                let title_cell_len = cell_len(title_text.plain_text());
282                let side_width = (width.saturating_sub(title_cell_len)) / 2;
283
284                // Left side: characters filling side_width - 1 (leave space for " ")
285                let left_chars = characters.repeat((side_width / chars_len) + 1);
286                let left_truncated = set_cell_size(&left_chars, side_width.saturating_sub(1));
287                rule_text.append(&left_truncated, Some(self.style));
288                rule_text.append(" ", Some(self.style));
289
290                // Title
291                rule_text.append_text(&title_text);
292
293                // Right side: fill remaining space
294                let right_length =
295                    width.saturating_sub(cell_len(&left_truncated) + 1 + title_cell_len);
296                rule_text.append(" ", Some(self.style));
297                let right_chars = characters.repeat((right_length / chars_len) + 1);
298                let right_truncated =
299                    set_cell_size(&right_chars, right_length.saturating_sub(1).max(0));
300                rule_text.append(&right_truncated, Some(self.style));
301            }
302            AlignMethod::Left => {
303                // Title ─────────────
304                rule_text.append_text(&title_text);
305                rule_text.append(" ", Some(self.style)); // Separator uses rule style
306
307                let remaining = width.saturating_sub(rule_text.cell_len());
308                let fill_chars = characters.repeat((remaining / chars_len) + 1);
309                let fill_truncated = set_cell_size(&fill_chars, remaining);
310                rule_text.append(&fill_truncated, Some(self.style));
311            }
312            AlignMethod::Right => {
313                // ───────────── Title
314                let title_cell_len = cell_len(title_text.plain_text());
315                let fill_length = width.saturating_sub(title_cell_len + 1);
316                let fill_chars = characters.repeat((fill_length / chars_len) + 1);
317                let fill_truncated = set_cell_size(&fill_chars, fill_length);
318                rule_text.append(&fill_truncated, Some(self.style));
319                rule_text.append(" ", Some(self.style)); // Separator uses rule style
320                rule_text.append_text(&title_text);
321            }
322        }
323
324        // Ensure exact width
325        let final_plain = set_cell_size(rule_text.plain_text(), width);
326        let mut final_text = Text::plain(&final_plain);
327
328        // Re-apply styles from rule_text
329        for span in rule_text.spans() {
330            // Clamp span to new text length
331            let new_len = final_text.len();
332            if span.start < new_len {
333                final_text.stylize(span.start, span.end.min(new_len), span.style);
334            }
335        }
336
337        // Apply base style if present
338        if let Some(base) = rule_text.base_style() {
339            final_text.set_base_style(Some(base));
340        }
341
342        // Append end string
343        if !self.end.is_empty() {
344            final_text.append(&self.end, None);
345        }
346
347        final_text.render(_console, options)
348    }
349
350    fn measure(&self, _console: &Console, _options: &ConsoleOptions) -> Measurement {
351        // Rule always uses exactly 1 cell minimum and maximum width
352        // (it expands to fill available space)
353        Measurement::new(1, 1)
354    }
355}
356
357// ============================================================================
358// Tests
359// ============================================================================
360
361#[cfg(test)]
362mod tests {
363    use super::*;
364
365    // ==================== AlignMethod tests ====================
366
367    #[test]
368    fn test_align_method_parse() {
369        assert_eq!(AlignMethod::parse("left"), Some(AlignMethod::Left));
370        assert_eq!(AlignMethod::parse("LEFT"), Some(AlignMethod::Left));
371        assert_eq!(AlignMethod::parse("center"), Some(AlignMethod::Center));
372        assert_eq!(AlignMethod::parse("CENTER"), Some(AlignMethod::Center));
373        assert_eq!(AlignMethod::parse("right"), Some(AlignMethod::Right));
374        assert_eq!(AlignMethod::parse("RIGHT"), Some(AlignMethod::Right));
375        assert_eq!(AlignMethod::parse("invalid"), None);
376    }
377
378    #[test]
379    fn test_align_method_default() {
380        assert_eq!(AlignMethod::default(), AlignMethod::Center);
381    }
382
383    // ==================== Rule construction tests ====================
384
385    #[test]
386    fn test_rule_new() {
387        let rule = Rule::new();
388        assert!(rule.title.is_none());
389        assert_eq!(rule.characters, "─");
390        assert_eq!(rule.end, "\n");
391        assert_eq!(rule.align, AlignMethod::Center);
392    }
393
394    #[test]
395    fn test_rule_with_title() {
396        let rule = Rule::new().with_title("Test");
397        assert!(rule.title.is_some());
398        assert_eq!(rule.title.as_ref().unwrap().plain_text(), "Test");
399    }
400
401    #[test]
402    fn test_rule_with_title_text() {
403        let text = Text::styled("Styled Title", Style::new().with_bold(true));
404        let rule = Rule::new().with_title_text(text);
405        assert!(rule.title.is_some());
406        assert_eq!(rule.title.as_ref().unwrap().plain_text(), "Styled Title");
407    }
408
409    #[test]
410    fn test_rule_with_characters() {
411        let rule = Rule::new().with_characters("=");
412        assert_eq!(rule.characters, "=");
413    }
414
415    #[test]
416    #[should_panic(expected = "'characters' argument must have a cell width of at least 1")]
417    fn test_rule_with_empty_characters() {
418        Rule::new().with_characters("");
419    }
420
421    #[test]
422    fn test_rule_with_style() {
423        let style = Style::new().with_bold(true);
424        let rule = Rule::new().with_style(style);
425        assert_eq!(rule.style.bold, Some(true));
426    }
427
428    #[test]
429    fn test_rule_with_end() {
430        let rule = Rule::new().with_end("");
431        assert_eq!(rule.end, "");
432    }
433
434    #[test]
435    fn test_rule_with_align() {
436        let rule = Rule::new().with_align(AlignMethod::Left);
437        assert_eq!(rule.align, AlignMethod::Left);
438    }
439
440    // ==================== Rule render tests ====================
441
442    #[test]
443    fn test_rule_render_no_title() {
444        let rule = Rule::new().with_end("");
445        let console = Console::new();
446        let options = ConsoleOptions {
447            max_width: 20,
448            ..Default::default()
449        };
450
451        let segments = rule.render(&console, &options);
452        let text: String = segments.iter().map(|s| s.text.to_string()).collect();
453
454        // Should be exactly 20 cells of "─"
455        assert_eq!(cell_len(&text), 20);
456        assert!(text.contains("─"));
457    }
458
459    #[test]
460    fn test_rule_render_with_title_center() {
461        let rule = Rule::new().with_title("Test").with_end("");
462        let console = Console::new();
463        let options = ConsoleOptions {
464            max_width: 20,
465            ..Default::default()
466        };
467
468        let segments = rule.render(&console, &options);
469        let text: String = segments.iter().map(|s| s.text.to_string()).collect();
470
471        // Should contain the title
472        assert!(text.contains("Test"));
473        // Should be exactly 20 cells
474        assert_eq!(cell_len(&text), 20);
475    }
476
477    #[test]
478    fn test_rule_render_with_title_left() {
479        let rule = Rule::new()
480            .with_title("Left")
481            .with_align(AlignMethod::Left)
482            .with_end("");
483        let console = Console::new();
484        let options = ConsoleOptions {
485            max_width: 20,
486            ..Default::default()
487        };
488
489        let segments = rule.render(&console, &options);
490        let text: String = segments.iter().map(|s| s.text.to_string()).collect();
491
492        // Title should be at the left
493        assert!(text.starts_with("Left"));
494        assert_eq!(cell_len(&text), 20);
495    }
496
497    #[test]
498    fn test_rule_render_with_title_right() {
499        let rule = Rule::new()
500            .with_title("Right")
501            .with_align(AlignMethod::Right)
502            .with_end("");
503        let console = Console::new();
504        let options = ConsoleOptions {
505            max_width: 20,
506            ..Default::default()
507        };
508
509        let segments = rule.render(&console, &options);
510        let text: String = segments.iter().map(|s| s.text.to_string()).collect();
511
512        // Title should be at the right
513        assert!(text.trim_end().ends_with("Right"));
514        assert_eq!(cell_len(&text), 20);
515    }
516
517    #[test]
518    fn test_rule_ascii_only() {
519        let rule = Rule::new().with_end("");
520        let console = Console::new();
521        let options = ConsoleOptions {
522            max_width: 10,
523            encoding: "ascii".to_string(),
524            ..Default::default()
525        };
526
527        let segments = rule.render(&console, &options);
528        let text: String = segments.iter().map(|s| s.text.to_string()).collect();
529
530        // Should use "-" instead of "─" in ASCII mode
531        assert!(text.chars().all(|c| c == '-' || c == ' '));
532        assert!(!text.contains("─"));
533    }
534
535    #[test]
536    fn test_rule_long_title_truncation() {
537        let rule = Rule::new()
538            .with_title("This is a very long title that needs truncation")
539            .with_end("");
540        let console = Console::new();
541        let options = ConsoleOptions {
542            max_width: 20,
543            ..Default::default()
544        };
545
546        let segments = rule.render(&console, &options);
547        let text: String = segments.iter().map(|s| s.text.to_string()).collect();
548
549        // Should truncate with ellipsis
550        assert!(text.contains("…") || cell_len(&text) == 20);
551        assert_eq!(cell_len(&text), 20);
552    }
553
554    #[test]
555    fn test_rule_multi_char_pattern() {
556        let rule = Rule::new().with_characters("+-").with_end("");
557        let console = Console::new();
558        let options = ConsoleOptions {
559            max_width: 10,
560            ..Default::default()
561        };
562
563        let segments = rule.render(&console, &options);
564        let text: String = segments.iter().map(|s| s.text.to_string()).collect();
565
566        // Should contain the pattern
567        assert!(text.contains('+') || text.contains('-'));
568        assert_eq!(cell_len(&text), 10);
569    }
570
571    #[test]
572    fn test_rule_measure() {
573        let rule = Rule::new().with_title("Test");
574        let console = Console::new();
575        let options = ConsoleOptions::default();
576
577        let measurement = rule.measure(&console, &options);
578        assert_eq!(measurement.minimum, 1);
579        assert_eq!(measurement.maximum, 1);
580    }
581
582    #[test]
583    fn test_rule_with_end_newline() {
584        let rule = Rule::new();
585        let console = Console::new();
586        let options = ConsoleOptions {
587            max_width: 10,
588            ..Default::default()
589        };
590
591        let segments = rule.render(&console, &options);
592        let text: String = segments.iter().map(|s| s.text.to_string()).collect();
593
594        // Should end with newline
595        assert!(text.ends_with('\n'));
596    }
597
598    // ==================== Markup tests ====================
599
600    #[test]
601    fn test_rule_with_markup_title() {
602        let rule = Rule::new()
603            .with_title("[bold]Bold Title[/bold]")
604            .with_end("");
605        let console = Console::new();
606        let options = ConsoleOptions {
607            max_width: 30,
608            ..Default::default()
609        };
610
611        let segments = rule.render(&console, &options);
612        let text: String = segments.iter().map(|s| s.text.to_string()).collect();
613
614        // The markup should be parsed, so "Bold Title" appears (without the tags)
615        assert!(text.contains("Bold Title"));
616        assert!(!text.contains("[bold]"));
617
618        // Should have correct width
619        assert_eq!(cell_len(&text), 30);
620    }
621
622    #[test]
623    fn test_rule_with_plain_title_fallback() {
624        // If markup is invalid, it should fall back to plain text
625        let rule = Rule::new().with_title("Plain Title").with_end("");
626        assert_eq!(rule.title.as_ref().unwrap().plain_text(), "Plain Title");
627    }
628
629    // ==================== Edge case tests ====================
630
631    #[test]
632    fn test_rule_very_narrow_width() {
633        let rule = Rule::new().with_title("Test").with_end("");
634        let console = Console::new();
635        let options = ConsoleOptions {
636            max_width: 4,
637            ..Default::default()
638        };
639
640        let segments = rule.render(&console, &options);
641        let text: String = segments.iter().map(|s| s.text.to_string()).collect();
642
643        // With only 4 cells, no room for title (requires 4 for padding)
644        // Should render just the line
645        assert_eq!(cell_len(&text), 4);
646    }
647
648    #[test]
649    fn test_rule_unicode_characters() {
650        let rule = Rule::new().with_characters("═").with_end("");
651        let console = Console::new();
652        let options = ConsoleOptions {
653            max_width: 10,
654            ..Default::default()
655        };
656
657        let segments = rule.render(&console, &options);
658        let text: String = segments.iter().map(|s| s.text.to_string()).collect();
659
660        assert!(text.contains("═"));
661        assert_eq!(cell_len(&text), 10);
662    }
663
664    #[test]
665    fn test_rule_cjk_title() {
666        let rule = Rule::new().with_title("你好").with_end("");
667        let console = Console::new();
668        let options = ConsoleOptions {
669            max_width: 20,
670            ..Default::default()
671        };
672
673        let segments = rule.render(&console, &options);
674        let text: String = segments.iter().map(|s| s.text.to_string()).collect();
675
676        assert!(text.contains("你好"));
677        assert_eq!(cell_len(&text), 20);
678    }
679}