#![forbid(unsafe_code)]
use crate::block::Alignment;
use crate::borders::BorderType;
use crate::measurable::{MeasurableWidget, SizeConstraints};
use crate::{Widget, apply_style, clear_text_row, draw_text_span};
use ftui_core::geometry::{Rect, Size};
use ftui_render::buffer::Buffer;
use ftui_render::cell::Cell;
use ftui_render::frame::Frame;
use ftui_style::Style;
use ftui_text::display_width;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Rule<'a> {
title: Option<&'a str>,
title_alignment: Alignment,
style: Style,
title_style: Option<Style>,
border_type: BorderType,
}
impl<'a> Default for Rule<'a> {
fn default() -> Self {
Self {
title: None,
title_alignment: Alignment::Center,
style: Style::default(),
title_style: None,
border_type: BorderType::Square,
}
}
}
impl<'a> Rule<'a> {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn title(mut self, title: &'a str) -> Self {
self.title = Some(title);
self
}
#[must_use]
pub fn title_alignment(mut self, alignment: Alignment) -> Self {
self.title_alignment = alignment;
self
}
#[must_use]
pub fn style(mut self, style: Style) -> Self {
self.style = style;
self
}
#[must_use]
pub fn title_style(mut self, style: Style) -> Self {
self.title_style = Some(style);
self
}
#[must_use]
pub fn border_type(mut self, border_type: BorderType) -> Self {
self.border_type = border_type;
self
}
fn fill_rule_char(&self, buf: &mut Buffer, y: u16, start: u16, end: u16) {
let ch = if buf.degradation.use_unicode_borders() {
self.border_type.to_border_set().horizontal
} else {
'-' };
let style = if buf.degradation.apply_styling() {
self.style
} else {
Style::default()
};
for x in start..end {
let mut cell = Cell::from_char(ch);
apply_style(&mut cell, style);
buf.set_fast(x, y, cell);
}
}
}
impl Widget for Rule<'_> {
fn render(&self, area: Rect, frame: &mut Frame) {
#[cfg(feature = "tracing")]
let _span = tracing::debug_span!(
"widget_render",
widget = "Rule",
x = area.x,
y = area.y,
w = area.width,
h = area.height
)
.entered();
if area.is_empty() {
return;
}
if !frame.buffer.degradation.render_decorative() {
clear_text_row(
frame,
Rect::new(area.x, area.y, area.width, 1),
Style::default(),
);
return;
}
let deg = frame.buffer.degradation;
let y = area.y;
let width = area.width;
let rule_style = if deg.apply_styling() {
self.style
} else {
Style::default()
};
let title_style = if deg.apply_styling() {
self.title_style.unwrap_or(self.style)
} else {
Style::default()
};
match self.title {
None => {
self.fill_rule_char(&mut frame.buffer, y, area.x, area.right());
}
Some("") => self.fill_rule_char(&mut frame.buffer, y, area.x, area.right()),
Some(title) => {
let title_width = display_width(title) as u16;
let min_width_for_title = title_width.saturating_add(2);
if width < min_width_for_title || width < 3 {
if title_width > width {
self.fill_rule_char(&mut frame.buffer, y, area.x, area.right());
} else {
draw_text_span(frame, area.x, y, title, title_style, area.right());
let after = area.x.saturating_add(title_width);
self.fill_rule_char(&mut frame.buffer, y, after, area.right());
}
return;
}
let max_title_width = width.saturating_sub(2);
let display_width = title_width.min(max_title_width);
let title_block_width = display_width + 2; let title_block_x = match self.title_alignment {
Alignment::Left => area.x,
Alignment::Center => area
.x
.saturating_add((width.saturating_sub(title_block_width)) / 2),
Alignment::Right => area.right().saturating_sub(title_block_width),
};
self.fill_rule_char(&mut frame.buffer, y, area.x, title_block_x);
let pad_x = title_block_x;
let mut cell_pad_l = Cell::from_char(' ');
crate::apply_style(&mut cell_pad_l, rule_style);
frame.buffer.set_fast(pad_x, y, cell_pad_l);
let title_x = pad_x.saturating_add(1);
let title_end = title_x.saturating_add(display_width);
draw_text_span(frame, title_x, y, title, title_style, title_end);
let right_pad_x = title_end;
if right_pad_x < area.right() {
let mut cell_pad_r = Cell::from_char(' ');
crate::apply_style(&mut cell_pad_r, rule_style);
frame.buffer.set_fast(right_pad_x, y, cell_pad_r);
}
let right_rule_start = right_pad_x.saturating_add(1);
self.fill_rule_char(&mut frame.buffer, y, right_rule_start, area.right());
}
}
}
}
impl MeasurableWidget for Rule<'_> {
fn measure(&self, _available: Size) -> SizeConstraints {
let min_width = 1u16;
let preferred_width = if let Some(title) = self.title {
let title_width = display_width(title) as u16;
title_width.saturating_add(4) } else {
1 };
SizeConstraints {
min: Size::new(min_width, 1),
preferred: Size::new(preferred_width, 1),
max: Some(Size::new(u16::MAX, 1)), }
}
fn has_intrinsic_size(&self) -> bool {
true
}
}
#[cfg(test)]
mod tests {
use super::*;
use ftui_render::cell::PackedRgba;
use ftui_render::grapheme_pool::GraphemePool;
fn row_chars(buf: &Buffer, y: u16, width: u16) -> Vec<char> {
(0..width)
.map(|x| {
buf.get(x, y)
.and_then(|c| c.content.as_char())
.unwrap_or(' ')
})
.collect()
}
fn row_string(buf: &Buffer, y: u16, width: u16) -> String {
let chars: String = row_chars(buf, y, width).into_iter().collect();
chars.trim_end().to_string()
}
#[test]
fn no_title_fills_width() {
let rule = Rule::new();
let area = Rect::new(0, 0, 10, 1);
let mut pool = GraphemePool::new();
let mut frame = Frame::new(10, 1, &mut pool);
rule.render(area, &mut frame);
let row = row_chars(&frame.buffer, 0, 10);
assert!(
row.iter().all(|&c| c == '─'),
"Expected all ─, got: {row:?}"
);
}
#[test]
fn no_title_heavy_border() {
let rule = Rule::new().border_type(BorderType::Heavy);
let area = Rect::new(0, 0, 5, 1);
let mut pool = GraphemePool::new();
let mut frame = Frame::new(5, 1, &mut pool);
rule.render(area, &mut frame);
let row = row_chars(&frame.buffer, 0, 5);
assert!(
row.iter().all(|&c| c == '━'),
"Expected all ━, got: {row:?}"
);
}
#[test]
fn no_title_double_border() {
let rule = Rule::new().border_type(BorderType::Double);
let area = Rect::new(0, 0, 5, 1);
let mut pool = GraphemePool::new();
let mut frame = Frame::new(5, 1, &mut pool);
rule.render(area, &mut frame);
let row = row_chars(&frame.buffer, 0, 5);
assert!(
row.iter().all(|&c| c == '═'),
"Expected all ═, got: {row:?}"
);
}
#[test]
fn no_title_ascii_border() {
let rule = Rule::new().border_type(BorderType::Ascii);
let area = Rect::new(0, 0, 5, 1);
let mut pool = GraphemePool::new();
let mut frame = Frame::new(5, 1, &mut pool);
rule.render(area, &mut frame);
let row = row_chars(&frame.buffer, 0, 5);
assert!(
row.iter().all(|&c| c == '-'),
"Expected all -, got: {row:?}"
);
}
#[test]
fn title_center_default() {
let rule = Rule::new().title("Hi");
let area = Rect::new(0, 0, 20, 1);
let mut pool = GraphemePool::new();
let mut frame = Frame::new(20, 1, &mut pool);
rule.render(area, &mut frame);
let s = row_string(&frame.buffer, 0, 20);
assert!(
s.contains(" Hi "),
"Expected centered title with spaces, got: '{s}'"
);
assert!(s.contains('─'), "Expected rule chars, got: '{s}'");
}
#[test]
fn title_left_aligned() {
let rule = Rule::new().title("Hi").title_alignment(Alignment::Left);
let area = Rect::new(0, 0, 20, 1);
let mut pool = GraphemePool::new();
let mut frame = Frame::new(20, 1, &mut pool);
rule.render(area, &mut frame);
let s = row_string(&frame.buffer, 0, 20);
assert!(
s.starts_with(" Hi "),
"Left-aligned should start with ' Hi ', got: '{s}'"
);
}
#[test]
fn title_right_aligned() {
let rule = Rule::new().title("Hi").title_alignment(Alignment::Right);
let area = Rect::new(0, 0, 20, 1);
let mut pool = GraphemePool::new();
let mut frame = Frame::new(20, 1, &mut pool);
rule.render(area, &mut frame);
let s = row_string(&frame.buffer, 0, 20);
assert!(
s.ends_with(" Hi"),
"Right-aligned should end with ' Hi', got: '{s}'"
);
}
#[test]
fn title_truncated_at_narrow_width() {
let rule = Rule::new().title("Hello");
let area = Rect::new(0, 0, 7, 1);
let mut pool = GraphemePool::new();
let mut frame = Frame::new(7, 1, &mut pool);
rule.render(area, &mut frame);
let s = row_string(&frame.buffer, 0, 7);
assert!(s.contains("Hello"), "Title should be present, got: '{s}'");
}
#[test]
fn title_too_wide_falls_back_to_rule() {
let rule = Rule::new().title("VeryLongTitle");
let area = Rect::new(0, 0, 5, 1);
let mut pool = GraphemePool::new();
let mut frame = Frame::new(5, 1, &mut pool);
rule.render(area, &mut frame);
let row = row_chars(&frame.buffer, 0, 5);
assert!(
row.iter().all(|&c| c == '─'),
"Expected fallback to rule, got: {row:?}"
);
}
#[test]
fn empty_title_same_as_no_title() {
let rule = Rule::new().title("");
let area = Rect::new(0, 0, 10, 1);
let mut pool = GraphemePool::new();
let mut frame = Frame::new(10, 1, &mut pool);
rule.render(area, &mut frame);
let row = row_chars(&frame.buffer, 0, 10);
assert!(
row.iter().all(|&c| c == '─'),
"Empty title should be plain rule, got: {row:?}"
);
}
#[test]
fn zero_width_no_panic() {
let rule = Rule::new().title("Test");
let area = Rect::new(0, 0, 0, 0);
let mut pool = GraphemePool::new();
let mut frame = Frame::new(1, 1, &mut pool);
rule.render(area, &mut frame);
}
#[test]
fn width_one_no_title() {
let rule = Rule::new();
let area = Rect::new(0, 0, 1, 1);
let mut pool = GraphemePool::new();
let mut frame = Frame::new(1, 1, &mut pool);
rule.render(area, &mut frame);
assert_eq!(frame.buffer.get(0, 0).unwrap().content.as_char(), Some('─'));
}
#[test]
fn width_two_with_title() {
let rule = Rule::new().title("X");
let area = Rect::new(0, 0, 2, 1);
let mut pool = GraphemePool::new();
let mut frame = Frame::new(2, 1, &mut pool);
rule.render(area, &mut frame);
let s = row_string(&frame.buffer, 0, 2);
assert!(!s.is_empty(), "Should render something, got empty");
}
#[test]
fn offset_area() {
let rule = Rule::new();
let area = Rect::new(5, 3, 10, 1);
let mut pool = GraphemePool::new();
let mut frame = Frame::new(20, 5, &mut pool);
rule.render(area, &mut frame);
assert_ne!(frame.buffer.get(4, 3).unwrap().content.as_char(), Some('─'));
assert_eq!(frame.buffer.get(5, 3).unwrap().content.as_char(), Some('─'));
assert_eq!(
frame.buffer.get(14, 3).unwrap().content.as_char(),
Some('─')
);
assert_ne!(
frame.buffer.get(15, 3).unwrap().content.as_char(),
Some('─')
);
}
#[test]
fn style_applied_to_rule_chars() {
use ftui_render::cell::PackedRgba;
let fg = PackedRgba::rgb(255, 0, 0);
let rule = Rule::new().style(Style::new().fg(fg));
let area = Rect::new(0, 0, 5, 1);
let mut pool = GraphemePool::new();
let mut frame = Frame::new(5, 1, &mut pool);
rule.render(area, &mut frame);
for x in 0..5 {
assert_eq!(frame.buffer.get(x, 0).unwrap().fg, fg);
}
}
#[test]
fn title_style_distinct_from_rule_style() {
use ftui_render::cell::PackedRgba;
let rule_fg = PackedRgba::rgb(255, 0, 0);
let title_fg = PackedRgba::rgb(0, 255, 0);
let rule = Rule::new()
.title("AB")
.title_alignment(Alignment::Center)
.style(Style::new().fg(rule_fg))
.title_style(Style::new().fg(title_fg));
let area = Rect::new(0, 0, 20, 1);
let mut pool = GraphemePool::new();
let mut frame = Frame::new(20, 1, &mut pool);
rule.render(area, &mut frame);
let mut found_title = false;
for x in 0..20u16 {
if let Some(cell) = frame.buffer.get(x, 0)
&& cell.content.as_char() == Some('A')
{
assert_eq!(cell.fg, title_fg, "Title char should have title_fg");
found_title = true;
}
}
assert!(found_title, "Should have found title character 'A'");
let first = frame.buffer.get(0, 0).unwrap();
assert_eq!(first.content.as_char(), Some('─'));
assert_eq!(first.fg, rule_fg, "Rule char should have rule_fg");
}
#[test]
fn unicode_title() {
let rule = Rule::new().title("日本");
let area = Rect::new(0, 0, 20, 1);
let mut pool = GraphemePool::new();
let mut frame = Frame::new(20, 1, &mut pool);
rule.render(area, &mut frame);
let s = row_string(&frame.buffer, 0, 20);
assert!(s.contains('─'), "Should contain rule chars, got: '{s}'");
let mut found_wide = false;
for x in 0..20u16 {
if let Some(cell) = frame.buffer.get(x, 0)
&& !cell.is_empty()
&& cell.content.width() > 1
{
found_wide = true;
break;
}
}
assert!(found_wide, "Should have rendered unicode title (wide char)");
}
#[test]
fn degradation_essential_only_skips_entirely() {
use ftui_render::budget::DegradationLevel;
let rule = Rule::new();
let area = Rect::new(0, 0, 10, 1);
let mut pool = GraphemePool::new();
let mut frame = Frame::new(10, 1, &mut pool);
rule.render(area, &mut frame);
frame.buffer.degradation = DegradationLevel::EssentialOnly;
rule.render(area, &mut frame);
for x in 0..10u16 {
assert_eq!(frame.buffer.get(x, 0).unwrap().content.as_char(), Some(' '));
}
}
#[test]
fn degradation_skeleton_skips_entirely() {
use ftui_render::budget::DegradationLevel;
let rule = Rule::new();
let area = Rect::new(0, 0, 10, 1);
let mut pool = GraphemePool::new();
let mut frame = Frame::new(10, 1, &mut pool);
rule.render(area, &mut frame);
frame.buffer.degradation = DegradationLevel::Skeleton;
rule.render(area, &mut frame);
for x in 0..10u16 {
assert_eq!(frame.buffer.get(x, 0).unwrap().content.as_char(), Some(' '));
}
}
#[test]
fn degradation_simple_borders_uses_ascii() {
use ftui_render::budget::DegradationLevel;
let rule = Rule::new().border_type(BorderType::Square);
let area = Rect::new(0, 0, 10, 1);
let mut pool = GraphemePool::new();
let mut frame = Frame::new(10, 1, &mut pool);
frame.buffer.degradation = DegradationLevel::SimpleBorders;
rule.render(area, &mut frame);
let row = row_chars(&frame.buffer, 0, 10);
assert!(
row.iter().all(|&c| c == '-'),
"Expected all -, got: {row:?}"
);
}
#[test]
fn degradation_full_uses_unicode() {
use ftui_render::budget::DegradationLevel;
let rule = Rule::new().border_type(BorderType::Square);
let area = Rect::new(0, 0, 10, 1);
let mut pool = GraphemePool::new();
let mut frame = Frame::new(10, 1, &mut pool);
frame.buffer.degradation = DegradationLevel::Full;
rule.render(area, &mut frame);
let row = row_chars(&frame.buffer, 0, 10);
assert!(
row.iter().all(|&c| c == '─'),
"Expected all ─, got: {row:?}"
);
}
#[test]
fn degradation_no_styling_drops_title_and_padding_styles() {
use ftui_render::budget::DegradationLevel;
let rule_fg = PackedRgba::rgb(255, 0, 0);
let title_fg = PackedRgba::rgb(0, 255, 0);
let rule = Rule::new()
.title("Hi")
.style(Style::new().fg(rule_fg).bg(PackedRgba::rgb(1, 2, 3)))
.title_style(Style::new().fg(title_fg).bg(PackedRgba::rgb(4, 5, 6)));
let area = Rect::new(0, 0, 10, 1);
let mut pool = GraphemePool::new();
let mut frame = Frame::new(10, 1, &mut pool);
frame.buffer.degradation = DegradationLevel::NoStyling;
rule.render(area, &mut frame);
let row = row_chars(&frame.buffer, 0, 10);
let title_x = row
.iter()
.position(|&c| c == 'H')
.expect("title should render");
let title_cell = frame.buffer.get(title_x as u16, 0).unwrap();
let left_pad = frame.buffer.get(title_x as u16 - 1, 0).unwrap();
assert_ne!(title_cell.fg, title_fg);
assert_ne!(left_pad.fg, rule_fg);
assert_ne!(left_pad.bg, PackedRgba::rgb(1, 2, 3));
}
#[test]
fn degradation_no_styling_narrow_title_branch_drops_styles() {
use ftui_render::budget::DegradationLevel;
let title_fg = PackedRgba::rgb(0, 255, 0);
let rule = Rule::new()
.title("X")
.style(Style::new().fg(PackedRgba::rgb(255, 0, 0)))
.title_style(Style::new().fg(title_fg).bg(PackedRgba::rgb(4, 5, 6)));
let area = Rect::new(0, 0, 2, 1);
let mut pool = GraphemePool::new();
let mut frame = Frame::new(2, 1, &mut pool);
frame.buffer.degradation = DegradationLevel::NoStyling;
rule.render(area, &mut frame);
let title_cell = frame.buffer.get(0, 0).unwrap();
assert_eq!(title_cell.content.as_char(), Some('X'));
assert_ne!(title_cell.fg, title_fg);
assert_ne!(title_cell.bg, PackedRgba::rgb(4, 5, 6));
}
use crate::MeasurableWidget;
use ftui_core::geometry::Size;
#[test]
fn measure_no_title() {
let rule = Rule::new();
let constraints = rule.measure(Size::MAX);
assert_eq!(constraints.min, Size::new(1, 1));
assert_eq!(constraints.preferred, Size::new(1, 1));
assert_eq!(constraints.max, Some(Size::new(u16::MAX, 1)));
}
#[test]
fn measure_with_title() {
let rule = Rule::new().title("Test");
let constraints = rule.measure(Size::MAX);
assert_eq!(constraints.min, Size::new(1, 1));
assert_eq!(constraints.preferred, Size::new(8, 1));
assert_eq!(constraints.max.unwrap().height, 1);
}
#[test]
fn measure_with_long_title() {
let rule = Rule::new().title("Very Long Title");
let constraints = rule.measure(Size::MAX);
assert_eq!(constraints.preferred, Size::new(19, 1));
}
#[test]
fn measure_fixed_height() {
let rule = Rule::new().title("Hi");
let constraints = rule.measure(Size::MAX);
assert_eq!(constraints.min.height, 1);
assert_eq!(constraints.preferred.height, 1);
assert_eq!(constraints.max.unwrap().height, 1);
}
#[test]
fn rule_has_intrinsic_size() {
let rule = Rule::new();
assert!(rule.has_intrinsic_size());
}
#[test]
fn rule_measure_is_pure() {
let rule = Rule::new().title("Hello");
let a = rule.measure(Size::new(100, 50));
let b = rule.measure(Size::new(100, 50));
assert_eq!(a, b);
}
}