1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
37pub enum AlignMethod {
38 Left,
40 #[default]
42 Center,
43 Right,
45}
46
47impl AlignMethod {
48 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#[derive(Debug, Clone)]
85pub struct Rule {
86 title: Option<Text>,
88 characters: String,
90 style: Style,
92 end: String,
94 align: AlignMethod,
96}
97
98impl Default for Rule {
99 fn default() -> Self {
100 Rule::new()
101 }
102}
103
104impl Rule {
105 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 pub fn with_title(mut self, title: impl Into<String>) -> Self {
128 let title_str = title.into();
129 self.title =
131 Some(Text::from_markup(&title_str, false).unwrap_or_else(|_| Text::plain(&title_str)));
132 self
133 }
134
135 pub fn with_title_text(mut self, title: Text) -> Self {
139 self.title = Some(title);
140 self
141 }
142
143 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 pub fn with_style(mut self, style: Style) -> Self {
160 self.style = style;
161 self
162 }
163
164 pub fn with_end(mut self, end: impl Into<String>) -> Self {
166 self.end = end.into();
167 self
168 }
169
170 pub fn with_align(mut self, align: AlignMethod) -> Self {
172 self.align = align;
173 self
174 }
175
176 fn rule_line(&self, characters: &str, chars_len: usize, width: usize) -> Text {
178 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 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 let truncated: String = chars[..char_count].iter().collect();
198 let mut result = Text::styled(truncated, self.style);
199
200 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 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 self.title.is_none() {
225 let mut rule_text = self.rule_line(&characters, chars_len, width);
226 if !self.end.is_empty() {
228 rule_text.append(&self.end, None);
229 }
230 return rule_text.render(_console, options);
231 }
232
233 let title = self.title.as_ref().unwrap();
235
236 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 for span in title.spans() {
248 title_text.stylize(span.start, span.end, span.style);
249 }
250
251 title_text = title_text.expand_tabs(8);
253
254 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 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 let title_text = title_text.truncate(truncate_width, OverflowMethod::Ellipsis, false);
274
275 let mut rule_text = Text::new();
277
278 match self.align {
279 AlignMethod::Center => {
280 let title_cell_len = cell_len(title_text.plain_text());
282 let side_width = (width.saturating_sub(title_cell_len)) / 2;
283
284 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 rule_text.append_text(&title_text);
292
293 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 rule_text.append_text(&title_text);
305 rule_text.append(" ", Some(self.style)); 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 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)); rule_text.append_text(&title_text);
321 }
322 }
323
324 let final_plain = set_cell_size(rule_text.plain_text(), width);
326 let mut final_text = Text::plain(&final_plain);
327
328 for span in rule_text.spans() {
330 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 if let Some(base) = rule_text.base_style() {
339 final_text.set_base_style(Some(base));
340 }
341
342 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 Measurement::new(1, 1)
354 }
355}
356
357#[cfg(test)]
362mod tests {
363 use super::*;
364
365 #[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 #[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 #[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 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 assert!(text.contains("Test"));
473 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 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 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 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 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 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 assert!(text.ends_with('\n'));
596 }
597
598 #[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 assert!(text.contains("Bold Title"));
616 assert!(!text.contains("[bold]"));
617
618 assert_eq!(cell_len(&text), 30);
620 }
621
622 #[test]
623 fn test_rule_with_plain_title_fallback() {
624 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 #[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 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}