use crate::Renderable;
use crate::cells::{cell_len, set_cell_size};
use crate::console::{Console, ConsoleOptions, OverflowMethod};
use crate::measure::Measurement;
use crate::segment::Segments;
use crate::style::Style;
use crate::text::Text;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum AlignMethod {
Left,
#[default]
Center,
Right,
}
impl AlignMethod {
pub fn parse(s: &str) -> Option<Self> {
match s.to_lowercase().as_str() {
"left" => Some(AlignMethod::Left),
"center" => Some(AlignMethod::Center),
"right" => Some(AlignMethod::Right),
_ => None,
}
}
}
#[derive(Debug, Clone)]
pub struct Rule {
title: Option<Text>,
characters: String,
style: Style,
end: String,
align: AlignMethod,
}
impl Default for Rule {
fn default() -> Self {
Rule::new()
}
}
impl Rule {
pub fn new() -> Self {
Rule {
title: None,
characters: "─".to_string(),
style: Style::new(),
end: "\n".to_string(),
align: AlignMethod::Center,
}
}
pub fn with_title(mut self, title: impl Into<String>) -> Self {
let title_str = title.into();
self.title =
Some(Text::from_markup(&title_str, false).unwrap_or_else(|_| Text::plain(&title_str)));
self
}
pub fn with_title_text(mut self, title: Text) -> Self {
self.title = Some(title);
self
}
pub fn with_characters(mut self, characters: impl Into<String>) -> Self {
let chars = characters.into();
assert!(
cell_len(&chars) >= 1,
"'characters' argument must have a cell width of at least 1"
);
self.characters = chars;
self
}
pub fn with_style(mut self, style: Style) -> Self {
self.style = style;
self
}
pub fn with_end(mut self, end: impl Into<String>) -> Self {
self.end = end.into();
self
}
pub fn with_align(mut self, align: AlignMethod) -> Self {
self.align = align;
self
}
fn rule_line(&self, characters: &str, chars_len: usize, width: usize) -> Text {
let repeat_count = (width / chars_len) + 1;
let line_chars = characters.repeat(repeat_count);
let rule_text = Text::styled(line_chars, self.style);
let chars: Vec<char> = rule_text.plain_text().chars().collect();
let mut current_width = 0;
let mut char_count = 0;
for c in &chars {
let cw = crate::cells::char_width(*c);
if current_width + cw > width {
break;
}
current_width += cw;
char_count += 1;
}
let truncated: String = chars[..char_count].iter().collect();
let mut result = Text::styled(truncated, self.style);
if current_width < width {
let padding = " ".repeat(width - current_width);
result.append(&padding, Some(self.style));
}
result
}
}
impl Renderable for Rule {
fn render(&self, _console: &Console, options: &ConsoleOptions) -> Segments {
let width = options.max_width;
let characters = if options.ascii_only() && !self.characters.is_ascii() {
"-".to_string()
} else {
self.characters.clone()
};
let chars_len = cell_len(&characters);
if self.title.is_none() {
let mut rule_text = self.rule_line(&characters, chars_len, width);
if !self.end.is_empty() {
rule_text.append(&self.end, None);
}
return rule_text.render(_console, options);
}
let title = self.title.as_ref().unwrap();
let plain = title.plain_text().replace('\n', " ");
let mut title_text = if let Some(style) = title.base_style() {
Text::styled(&plain, style)
} else {
Text::plain(&plain)
};
for span in title.spans() {
title_text.stylize(span.start, span.end, span.style);
}
title_text = title_text.expand_tabs(8);
let required_space = if self.align == AlignMethod::Center {
4
} else {
2
};
let truncate_width = width.saturating_sub(required_space);
if truncate_width == 0 {
let mut rule_text = self.rule_line(&characters, chars_len, width);
if !self.end.is_empty() {
rule_text.append(&self.end, None);
}
return rule_text.render(_console, options);
}
let title_text = title_text.truncate(truncate_width, OverflowMethod::Ellipsis, false);
let mut rule_text = Text::new();
match self.align {
AlignMethod::Center => {
let title_cell_len = cell_len(title_text.plain_text());
let side_width = (width.saturating_sub(title_cell_len)) / 2;
let left_chars = characters.repeat((side_width / chars_len) + 1);
let left_truncated = set_cell_size(&left_chars, side_width.saturating_sub(1));
rule_text.append(&left_truncated, Some(self.style));
rule_text.append(" ", Some(self.style));
rule_text.append_text(&title_text);
let right_length =
width.saturating_sub(cell_len(&left_truncated) + 1 + title_cell_len);
rule_text.append(" ", Some(self.style));
let right_chars = characters.repeat((right_length / chars_len) + 1);
let right_truncated =
set_cell_size(&right_chars, right_length.saturating_sub(1).max(0));
rule_text.append(&right_truncated, Some(self.style));
}
AlignMethod::Left => {
rule_text.append_text(&title_text);
rule_text.append(" ", Some(self.style));
let remaining = width.saturating_sub(rule_text.cell_len());
let fill_chars = characters.repeat((remaining / chars_len) + 1);
let fill_truncated = set_cell_size(&fill_chars, remaining);
rule_text.append(&fill_truncated, Some(self.style));
}
AlignMethod::Right => {
let title_cell_len = cell_len(title_text.plain_text());
let fill_length = width.saturating_sub(title_cell_len + 1);
let fill_chars = characters.repeat((fill_length / chars_len) + 1);
let fill_truncated = set_cell_size(&fill_chars, fill_length);
rule_text.append(&fill_truncated, Some(self.style));
rule_text.append(" ", Some(self.style)); rule_text.append_text(&title_text);
}
}
let final_plain = set_cell_size(rule_text.plain_text(), width);
let mut final_text = Text::plain(&final_plain);
for span in rule_text.spans() {
let new_len = final_text.len();
if span.start < new_len {
final_text.stylize(span.start, span.end.min(new_len), span.style);
}
}
if let Some(base) = rule_text.base_style() {
final_text.set_base_style(Some(base));
}
if !self.end.is_empty() {
final_text.append(&self.end, None);
}
final_text.render(_console, options)
}
fn measure(&self, _console: &Console, _options: &ConsoleOptions) -> Measurement {
Measurement::new(1, 1)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_align_method_parse() {
assert_eq!(AlignMethod::parse("left"), Some(AlignMethod::Left));
assert_eq!(AlignMethod::parse("LEFT"), Some(AlignMethod::Left));
assert_eq!(AlignMethod::parse("center"), Some(AlignMethod::Center));
assert_eq!(AlignMethod::parse("CENTER"), Some(AlignMethod::Center));
assert_eq!(AlignMethod::parse("right"), Some(AlignMethod::Right));
assert_eq!(AlignMethod::parse("RIGHT"), Some(AlignMethod::Right));
assert_eq!(AlignMethod::parse("invalid"), None);
}
#[test]
fn test_align_method_default() {
assert_eq!(AlignMethod::default(), AlignMethod::Center);
}
#[test]
fn test_rule_new() {
let rule = Rule::new();
assert!(rule.title.is_none());
assert_eq!(rule.characters, "─");
assert_eq!(rule.end, "\n");
assert_eq!(rule.align, AlignMethod::Center);
}
#[test]
fn test_rule_with_title() {
let rule = Rule::new().with_title("Test");
assert!(rule.title.is_some());
assert_eq!(rule.title.as_ref().unwrap().plain_text(), "Test");
}
#[test]
fn test_rule_with_title_text() {
let text = Text::styled("Styled Title", Style::new().with_bold(true));
let rule = Rule::new().with_title_text(text);
assert!(rule.title.is_some());
assert_eq!(rule.title.as_ref().unwrap().plain_text(), "Styled Title");
}
#[test]
fn test_rule_with_characters() {
let rule = Rule::new().with_characters("=");
assert_eq!(rule.characters, "=");
}
#[test]
#[should_panic(expected = "'characters' argument must have a cell width of at least 1")]
fn test_rule_with_empty_characters() {
Rule::new().with_characters("");
}
#[test]
fn test_rule_with_style() {
let style = Style::new().with_bold(true);
let rule = Rule::new().with_style(style);
assert_eq!(rule.style.bold, Some(true));
}
#[test]
fn test_rule_with_end() {
let rule = Rule::new().with_end("");
assert_eq!(rule.end, "");
}
#[test]
fn test_rule_with_align() {
let rule = Rule::new().with_align(AlignMethod::Left);
assert_eq!(rule.align, AlignMethod::Left);
}
#[test]
fn test_rule_render_no_title() {
let rule = Rule::new().with_end("");
let console = Console::new();
let options = ConsoleOptions {
max_width: 20,
..Default::default()
};
let segments = rule.render(&console, &options);
let text: String = segments.iter().map(|s| s.text.to_string()).collect();
assert_eq!(cell_len(&text), 20);
assert!(text.contains("─"));
}
#[test]
fn test_rule_render_with_title_center() {
let rule = Rule::new().with_title("Test").with_end("");
let console = Console::new();
let options = ConsoleOptions {
max_width: 20,
..Default::default()
};
let segments = rule.render(&console, &options);
let text: String = segments.iter().map(|s| s.text.to_string()).collect();
assert!(text.contains("Test"));
assert_eq!(cell_len(&text), 20);
}
#[test]
fn test_rule_render_with_title_left() {
let rule = Rule::new()
.with_title("Left")
.with_align(AlignMethod::Left)
.with_end("");
let console = Console::new();
let options = ConsoleOptions {
max_width: 20,
..Default::default()
};
let segments = rule.render(&console, &options);
let text: String = segments.iter().map(|s| s.text.to_string()).collect();
assert!(text.starts_with("Left"));
assert_eq!(cell_len(&text), 20);
}
#[test]
fn test_rule_render_with_title_right() {
let rule = Rule::new()
.with_title("Right")
.with_align(AlignMethod::Right)
.with_end("");
let console = Console::new();
let options = ConsoleOptions {
max_width: 20,
..Default::default()
};
let segments = rule.render(&console, &options);
let text: String = segments.iter().map(|s| s.text.to_string()).collect();
assert!(text.trim_end().ends_with("Right"));
assert_eq!(cell_len(&text), 20);
}
#[test]
fn test_rule_ascii_only() {
let rule = Rule::new().with_end("");
let console = Console::new();
let options = ConsoleOptions {
max_width: 10,
encoding: "ascii".to_string(),
..Default::default()
};
let segments = rule.render(&console, &options);
let text: String = segments.iter().map(|s| s.text.to_string()).collect();
assert!(text.chars().all(|c| c == '-' || c == ' '));
assert!(!text.contains("─"));
}
#[test]
fn test_rule_long_title_truncation() {
let rule = Rule::new()
.with_title("This is a very long title that needs truncation")
.with_end("");
let console = Console::new();
let options = ConsoleOptions {
max_width: 20,
..Default::default()
};
let segments = rule.render(&console, &options);
let text: String = segments.iter().map(|s| s.text.to_string()).collect();
assert!(text.contains("…") || cell_len(&text) == 20);
assert_eq!(cell_len(&text), 20);
}
#[test]
fn test_rule_multi_char_pattern() {
let rule = Rule::new().with_characters("+-").with_end("");
let console = Console::new();
let options = ConsoleOptions {
max_width: 10,
..Default::default()
};
let segments = rule.render(&console, &options);
let text: String = segments.iter().map(|s| s.text.to_string()).collect();
assert!(text.contains('+') || text.contains('-'));
assert_eq!(cell_len(&text), 10);
}
#[test]
fn test_rule_measure() {
let rule = Rule::new().with_title("Test");
let console = Console::new();
let options = ConsoleOptions::default();
let measurement = rule.measure(&console, &options);
assert_eq!(measurement.minimum, 1);
assert_eq!(measurement.maximum, 1);
}
#[test]
fn test_rule_with_end_newline() {
let rule = Rule::new();
let console = Console::new();
let options = ConsoleOptions {
max_width: 10,
..Default::default()
};
let segments = rule.render(&console, &options);
let text: String = segments.iter().map(|s| s.text.to_string()).collect();
assert!(text.ends_with('\n'));
}
#[test]
fn test_rule_with_markup_title() {
let rule = Rule::new()
.with_title("[bold]Bold Title[/bold]")
.with_end("");
let console = Console::new();
let options = ConsoleOptions {
max_width: 30,
..Default::default()
};
let segments = rule.render(&console, &options);
let text: String = segments.iter().map(|s| s.text.to_string()).collect();
assert!(text.contains("Bold Title"));
assert!(!text.contains("[bold]"));
assert_eq!(cell_len(&text), 30);
}
#[test]
fn test_rule_with_plain_title_fallback() {
let rule = Rule::new().with_title("Plain Title").with_end("");
assert_eq!(rule.title.as_ref().unwrap().plain_text(), "Plain Title");
}
#[test]
fn test_rule_very_narrow_width() {
let rule = Rule::new().with_title("Test").with_end("");
let console = Console::new();
let options = ConsoleOptions {
max_width: 4,
..Default::default()
};
let segments = rule.render(&console, &options);
let text: String = segments.iter().map(|s| s.text.to_string()).collect();
assert_eq!(cell_len(&text), 4);
}
#[test]
fn test_rule_unicode_characters() {
let rule = Rule::new().with_characters("═").with_end("");
let console = Console::new();
let options = ConsoleOptions {
max_width: 10,
..Default::default()
};
let segments = rule.render(&console, &options);
let text: String = segments.iter().map(|s| s.text.to_string()).collect();
assert!(text.contains("═"));
assert_eq!(cell_len(&text), 10);
}
#[test]
fn test_rule_cjk_title() {
let rule = Rule::new().with_title("你好").with_end("");
let console = Console::new();
let options = ConsoleOptions {
max_width: 20,
..Default::default()
};
let segments = rule.render(&console, &options);
let text: String = segments.iter().map(|s| s.text.to_string()).collect();
assert!(text.contains("你好"));
assert_eq!(cell_len(&text), 20);
}
}