use crate::align::HorizontalAlign;
use crate::cells::{cell_len, set_cell_size};
use crate::console::{Console, ConsoleOptions, Overflow};
use crate::measure::Measurement;
use crate::protocol::Renderable;
use crate::segment::Segment;
use crate::style::StyleType;
use crate::text::{Text, DEFAULT_TAB_SIZE};
pub struct Rule {
title: Option<String>,
title_text: Option<Text>,
characters: String,
style: StyleType,
end: String,
align: HorizontalAlign,
}
impl Default for Rule {
fn default() -> Self {
Rule {
title: None,
title_text: None,
characters: "─".to_string(),
style: StyleType::Name("rule.line".to_string()),
end: "\n".to_string(),
align: HorizontalAlign::Center,
}
}
}
impl Rule {
pub fn line() -> Self {
Rule::default()
}
pub fn new(title: impl Into<String>) -> Self {
Rule {
title: Some(title.into()),
..Rule::default()
}
}
pub fn with_title_text(title: Text) -> Self {
Rule {
title_text: Some(title),
..Rule::default()
}
}
pub fn end(mut self, end: impl Into<String>) -> Self {
self.end = end.into();
self
}
pub fn characters(mut self, characters: impl Into<String>) -> Self {
self.characters = characters.into();
self
}
pub fn style(mut self, style: impl Into<StyleType>) -> Self {
self.style = style.into();
self
}
pub fn align(mut self, align: HorizontalAlign) -> Self {
self.align = align;
self
}
fn fill(characters: &str, width: usize) -> String {
if width == 0 {
return String::new();
}
let chars_len = cell_len(characters).max(1);
let repeat = width / chars_len + 1;
let repeated = characters.repeat(repeat);
set_cell_size(&repeated, width)
}
fn build_text(&self, console: &Console, options: &ConsoleOptions) -> (Text, bool) {
let width = options.max_width;
let characters = if options.ascii_only() && !self.characters.is_ascii() {
"-"
} else {
self.characters.as_str()
};
let rule_line = || Text::styled(Self::fill(&self.characters, width), self.style.clone());
let title = match (&self.title_text, &self.title) {
(Some(text), _) if !text.plain().is_empty() => {
let mut title = text.blank_copy();
title.append(&text.plain().replace('\n', " "), None);
for span in text.spans() {
title.push_span(span.clone());
}
title
}
(None, Some(title)) if !title.is_empty() => {
let parsed = console.build_text(title);
let mut title = parsed.blank_copy();
title.append(&parsed.plain().replace('\n', " "), None);
for span in parsed.spans() {
title.push_span(span.clone());
}
title.set_base_style("rule.text");
title
}
_ => return (rule_line(), false),
};
let mut title = title;
let required_space = if matches!(self.align, HorizontalAlign::Center) {
4
} else {
2
};
let truncate_width = width.saturating_sub(required_space);
if truncate_width == 0 {
return (rule_line(), false);
}
title.expand_tabs(DEFAULT_TAB_SIZE);
title.truncate(truncate_width, Some(Overflow::Ellipsis), false);
let mut text = match self.align {
HorizontalAlign::Center => {
let title_len = title.cell_len();
let side_width = width.saturating_sub(title_len) / 2;
let left = Self::fill(characters, side_width.saturating_sub(1));
let right_length = width
.saturating_sub(title_len)
.saturating_sub(cell_len(&left))
.saturating_sub(2);
let right = Self::fill(characters, right_length);
let mut text = Text::new("");
text.append(&format!("{left} "), Some(self.style.clone()));
text = text.append_text(&title);
text.append(&format!(" {right}"), Some(self.style.clone()));
text
}
HorizontalAlign::Left => {
let fill_len = width.saturating_sub(title.cell_len()).saturating_sub(1);
let mut text = Text::new("");
text = text.append_text(&title);
text.append(" ", None);
text.append(&Self::fill(characters, fill_len), Some(self.style.clone()));
text
}
HorizontalAlign::Right => {
let repeat = width.saturating_sub(title.cell_len()).saturating_sub(1);
let mut text = Text::new("");
text.append(&characters.repeat(repeat), Some(self.style.clone()));
text.append(" ", None);
text = text.append_text(&title);
text
}
};
text.truncate(width, Some(Overflow::Crop), true);
(text, true)
}
}
impl Renderable for Rule {
fn rich_render(&self, console: &Console, options: &ConsoleOptions) -> Vec<Segment> {
crate::protocol::report_region(
console,
|| {
crate::protocol::RegionInfo::new(crate::protocol::RegionRole::Rule)
.label(self.title_plain(console))
},
|| self.render_rule(console, options),
)
}
fn measure(&self, _console: &Console, _options: &ConsoleOptions) -> Measurement {
Measurement::new(1, 1)
}
}
impl Rule {
fn title_plain(&self, console: &Console) -> String {
match (&self.title_text, &self.title) {
(Some(text), _) => text.plain().to_string(),
(None, Some(title)) => console.build_text(title).plain().to_string(),
(None, None) => String::new(),
}
}
fn render_rule(&self, console: &Console, options: &ConsoleOptions) -> Vec<Segment> {
let (text, titled) = self.build_text(console, options);
let mut segments = text.render(console.theme(), console.base_style());
if titled {
let end = self.end.strip_suffix('\n').unwrap_or(&self.end);
if !end.is_empty() {
segments.push(Segment::new(end, None));
}
}
segments
}
}
#[cfg(test)]
mod tests {
use super::*;
fn console() -> Console {
Console::builder()
.force_terminal(true)
.color_system(Some(crate::color::ColorSystem::Truecolor))
.width(20)
.build()
}
#[test]
fn plain_rule_fills_width() {
let out = console().render_export(&Rule::line());
assert_eq!(out, format!("\x1b[92m{}\x1b[0m\n", "─".repeat(20)));
}
#[test]
fn titled_rule_centers() {
let out = console().render_export(&Rule::new("Hi"));
assert_eq!(out, "\x1b[92m──────── \x1b[0mHi\x1b[92m ────────\x1b[0m\n");
}
#[test]
fn a_title_that_cannot_fit_falls_back_to_a_plain_rule() {
for width in [1usize, 2, 3, 4] {
let console = Console::builder().width(width).color_system(None).build();
let out = console.render_to_string(&Rule::new("TITLE"));
assert_eq!(
out.trim_end_matches('\n'),
"\u{2500}".repeat(width),
"width {width} did not fall back to a plain rule"
);
}
}
#[test]
fn an_over_long_title_is_ellipsised() {
let console = Console::builder().width(5).color_system(None).build();
let out = console.render_to_string(&Rule::new("TITLE"));
assert_eq!(out.trim_end_matches('\n'), "\u{2500} \u{2026} \u{2500}");
}
#[test]
fn right_aligned_title_is_dropped_by_a_multi_cell_fill() {
let console = Console::builder()
.force_terminal(true)
.color_system(Some(crate::color::ColorSystem::Truecolor))
.width(10)
.highlight(false)
.build();
let rule = Rule::new("x")
.characters("-~")
.align(HorizontalAlign::Right);
assert_eq!(console.render_export(&rule), "\x1b[92m-~-~-~-~-~\x1b[0m\n");
}
}