1#![forbid(unsafe_code)]
2
3use crate::block::Alignment;
9use crate::borders::BorderType;
10use crate::measurable::{MeasurableWidget, SizeConstraints};
11use crate::{Widget, apply_style, clear_text_row, draw_text_span};
12use ftui_core::geometry::{Rect, Size};
13use ftui_render::buffer::Buffer;
14use ftui_render::cell::Cell;
15use ftui_render::frame::Frame;
16use ftui_style::Style;
17use ftui_text::display_width;
18
19#[derive(Debug, Clone, PartialEq, Eq)]
39pub struct Rule<'a> {
40 title: Option<&'a str>,
42 title_alignment: Alignment,
44 style: Style,
46 title_style: Option<Style>,
48 border_type: BorderType,
50}
51
52impl<'a> Default for Rule<'a> {
53 fn default() -> Self {
54 Self {
55 title: None,
56 title_alignment: Alignment::Center,
57 style: Style::default(),
58 title_style: None,
59 border_type: BorderType::Square,
60 }
61 }
62}
63
64impl<'a> Rule<'a> {
65 #[must_use]
67 pub fn new() -> Self {
68 Self::default()
69 }
70
71 #[must_use]
73 pub fn title(mut self, title: &'a str) -> Self {
74 self.title = Some(title);
75 self
76 }
77
78 #[must_use]
80 pub fn title_alignment(mut self, alignment: Alignment) -> Self {
81 self.title_alignment = alignment;
82 self
83 }
84
85 #[must_use]
87 pub fn style(mut self, style: Style) -> Self {
88 self.style = style;
89 self
90 }
91
92 #[must_use]
96 pub fn title_style(mut self, style: Style) -> Self {
97 self.title_style = Some(style);
98 self
99 }
100
101 #[must_use]
103 pub fn border_type(mut self, border_type: BorderType) -> Self {
104 self.border_type = border_type;
105 self
106 }
107
108 fn fill_rule_char(&self, buf: &mut Buffer, y: u16, start: u16, end: u16) {
110 let ch = if buf.degradation.use_unicode_borders() {
111 self.border_type.to_border_set().horizontal
112 } else {
113 '-' };
115 let style = if buf.degradation.apply_styling() {
116 self.style
117 } else {
118 Style::default()
119 };
120 for x in start..end {
121 let mut cell = Cell::from_char(ch);
122 apply_style(&mut cell, style);
123 buf.set_fast(x, y, cell);
124 }
125 }
126}
127
128impl Widget for Rule<'_> {
129 fn render(&self, area: Rect, frame: &mut Frame) {
130 #[cfg(feature = "tracing")]
131 let _span = tracing::debug_span!(
132 "widget_render",
133 widget = "Rule",
134 x = area.x,
135 y = area.y,
136 w = area.width,
137 h = area.height
138 )
139 .entered();
140
141 if area.is_empty() {
142 return;
143 }
144
145 if !frame.buffer.degradation.render_decorative() {
147 clear_text_row(
148 frame,
149 Rect::new(area.x, area.y, area.width, 1),
150 Style::default(),
151 );
152 return;
153 }
154
155 let deg = frame.buffer.degradation;
156 let y = area.y;
157 let width = area.width;
158 let rule_style = if deg.apply_styling() {
159 self.style
160 } else {
161 Style::default()
162 };
163 let title_style = if deg.apply_styling() {
164 self.title_style.unwrap_or(self.style)
165 } else {
166 Style::default()
167 };
168
169 match self.title {
170 None => {
171 self.fill_rule_char(&mut frame.buffer, y, area.x, area.right());
173 }
174 Some("") => self.fill_rule_char(&mut frame.buffer, y, area.x, area.right()),
175 Some(title) => {
176 let title_width = display_width(title) as u16;
177
178 let min_width_for_title = title_width.saturating_add(2);
182 if width < min_width_for_title || width < 3 {
183 if title_width > width {
186 self.fill_rule_char(&mut frame.buffer, y, area.x, area.right());
188 } else {
189 draw_text_span(frame, area.x, y, title, title_style, area.right());
191 let after = area.x.saturating_add(title_width);
193 self.fill_rule_char(&mut frame.buffer, y, after, area.right());
194 }
195 return;
196 }
197
198 let max_title_width = width.saturating_sub(2);
200 let display_width = title_width.min(max_title_width);
201
202 let title_block_width = display_width + 2; let title_block_x = match self.title_alignment {
205 Alignment::Left => area.x,
206 Alignment::Center => area
207 .x
208 .saturating_add((width.saturating_sub(title_block_width)) / 2),
209 Alignment::Right => area.right().saturating_sub(title_block_width),
210 };
211
212 self.fill_rule_char(&mut frame.buffer, y, area.x, title_block_x);
214
215 let pad_x = title_block_x;
217 let mut cell_pad_l = Cell::from_char(' ');
218 crate::apply_style(&mut cell_pad_l, rule_style);
219 frame.buffer.set_fast(pad_x, y, cell_pad_l);
220
221 let title_x = pad_x.saturating_add(1);
223 let title_end = title_x.saturating_add(display_width);
224 draw_text_span(frame, title_x, y, title, title_style, title_end);
225
226 let right_pad_x = title_end;
228 if right_pad_x < area.right() {
229 let mut cell_pad_r = Cell::from_char(' ');
230 crate::apply_style(&mut cell_pad_r, rule_style);
231 frame.buffer.set_fast(right_pad_x, y, cell_pad_r);
232 }
233
234 let right_rule_start = right_pad_x.saturating_add(1);
236 self.fill_rule_char(&mut frame.buffer, y, right_rule_start, area.right());
237 }
238 }
239 }
240}
241
242impl MeasurableWidget for Rule<'_> {
243 fn measure(&self, _available: Size) -> SizeConstraints {
244 let min_width = 1u16;
247
248 let preferred_width = if let Some(title) = self.title {
249 let title_width = display_width(title) as u16;
251 title_width.saturating_add(4) } else {
253 1 };
255
256 SizeConstraints {
257 min: Size::new(min_width, 1),
258 preferred: Size::new(preferred_width, 1),
259 max: Some(Size::new(u16::MAX, 1)), }
261 }
262
263 fn has_intrinsic_size(&self) -> bool {
264 true
266 }
267}
268
269#[cfg(test)]
270mod tests {
271 use super::*;
272 use ftui_render::cell::PackedRgba;
273 use ftui_render::grapheme_pool::GraphemePool;
274
275 fn row_chars(buf: &Buffer, y: u16, width: u16) -> Vec<char> {
277 (0..width)
278 .map(|x| {
279 buf.get(x, y)
280 .and_then(|c| c.content.as_char())
281 .unwrap_or(' ')
282 })
283 .collect()
284 }
285
286 fn row_string(buf: &Buffer, y: u16, width: u16) -> String {
288 let chars: String = row_chars(buf, y, width).into_iter().collect();
289 chars.trim_end().to_string()
290 }
291
292 #[test]
295 fn no_title_fills_width() {
296 let rule = Rule::new();
297 let area = Rect::new(0, 0, 10, 1);
298 let mut pool = GraphemePool::new();
299 let mut frame = Frame::new(10, 1, &mut pool);
300 rule.render(area, &mut frame);
301
302 let row = row_chars(&frame.buffer, 0, 10);
303 assert!(
304 row.iter().all(|&c| c == '─'),
305 "Expected all ─, got: {row:?}"
306 );
307 }
308
309 #[test]
310 fn no_title_heavy_border() {
311 let rule = Rule::new().border_type(BorderType::Heavy);
312 let area = Rect::new(0, 0, 5, 1);
313 let mut pool = GraphemePool::new();
314 let mut frame = Frame::new(5, 1, &mut pool);
315 rule.render(area, &mut frame);
316
317 let row = row_chars(&frame.buffer, 0, 5);
318 assert!(
319 row.iter().all(|&c| c == '━'),
320 "Expected all ━, got: {row:?}"
321 );
322 }
323
324 #[test]
325 fn no_title_double_border() {
326 let rule = Rule::new().border_type(BorderType::Double);
327 let area = Rect::new(0, 0, 5, 1);
328 let mut pool = GraphemePool::new();
329 let mut frame = Frame::new(5, 1, &mut pool);
330 rule.render(area, &mut frame);
331
332 let row = row_chars(&frame.buffer, 0, 5);
333 assert!(
334 row.iter().all(|&c| c == '═'),
335 "Expected all ═, got: {row:?}"
336 );
337 }
338
339 #[test]
340 fn no_title_ascii_border() {
341 let rule = Rule::new().border_type(BorderType::Ascii);
342 let area = Rect::new(0, 0, 5, 1);
343 let mut pool = GraphemePool::new();
344 let mut frame = Frame::new(5, 1, &mut pool);
345 rule.render(area, &mut frame);
346
347 let row = row_chars(&frame.buffer, 0, 5);
348 assert!(
349 row.iter().all(|&c| c == '-'),
350 "Expected all -, got: {row:?}"
351 );
352 }
353
354 #[test]
357 fn title_center_default() {
358 let rule = Rule::new().title("Hi");
359 let area = Rect::new(0, 0, 20, 1);
360 let mut pool = GraphemePool::new();
361 let mut frame = Frame::new(20, 1, &mut pool);
362 rule.render(area, &mut frame);
363
364 let s = row_string(&frame.buffer, 0, 20);
365 assert!(
366 s.contains(" Hi "),
367 "Expected centered title with spaces, got: '{s}'"
368 );
369 assert!(s.contains('─'), "Expected rule chars, got: '{s}'");
370 }
371
372 #[test]
373 fn title_left_aligned() {
374 let rule = Rule::new().title("Hi").title_alignment(Alignment::Left);
375 let area = Rect::new(0, 0, 20, 1);
376 let mut pool = GraphemePool::new();
377 let mut frame = Frame::new(20, 1, &mut pool);
378 rule.render(area, &mut frame);
379
380 let s = row_string(&frame.buffer, 0, 20);
381 assert!(
382 s.starts_with(" Hi "),
383 "Left-aligned should start with ' Hi ', got: '{s}'"
384 );
385 }
386
387 #[test]
388 fn title_right_aligned() {
389 let rule = Rule::new().title("Hi").title_alignment(Alignment::Right);
390 let area = Rect::new(0, 0, 20, 1);
391 let mut pool = GraphemePool::new();
392 let mut frame = Frame::new(20, 1, &mut pool);
393 rule.render(area, &mut frame);
394
395 let s = row_string(&frame.buffer, 0, 20);
396 assert!(
397 s.ends_with(" Hi"),
398 "Right-aligned should end with ' Hi', got: '{s}'"
399 );
400 }
401
402 #[test]
403 fn title_truncated_at_narrow_width() {
404 let rule = Rule::new().title("Hello");
406 let area = Rect::new(0, 0, 7, 1);
407 let mut pool = GraphemePool::new();
408 let mut frame = Frame::new(7, 1, &mut pool);
409 rule.render(area, &mut frame);
410
411 let s = row_string(&frame.buffer, 0, 7);
412 assert!(s.contains("Hello"), "Title should be present, got: '{s}'");
413 }
414
415 #[test]
416 fn title_too_wide_falls_back_to_rule() {
417 let rule = Rule::new().title("VeryLongTitle");
419 let area = Rect::new(0, 0, 5, 1);
420 let mut pool = GraphemePool::new();
421 let mut frame = Frame::new(5, 1, &mut pool);
422 rule.render(area, &mut frame);
423
424 let row = row_chars(&frame.buffer, 0, 5);
425 assert!(
427 row.iter().all(|&c| c == '─'),
428 "Expected fallback to rule, got: {row:?}"
429 );
430 }
431
432 #[test]
433 fn empty_title_same_as_no_title() {
434 let rule = Rule::new().title("");
435 let area = Rect::new(0, 0, 10, 1);
436 let mut pool = GraphemePool::new();
437 let mut frame = Frame::new(10, 1, &mut pool);
438 rule.render(area, &mut frame);
439
440 let row = row_chars(&frame.buffer, 0, 10);
441 assert!(
442 row.iter().all(|&c| c == '─'),
443 "Empty title should be plain rule, got: {row:?}"
444 );
445 }
446
447 #[test]
450 fn zero_width_no_panic() {
451 let rule = Rule::new().title("Test");
452 let area = Rect::new(0, 0, 0, 0);
453 let mut pool = GraphemePool::new();
454 let mut frame = Frame::new(1, 1, &mut pool);
455 rule.render(area, &mut frame);
456 }
458
459 #[test]
460 fn width_one_no_title() {
461 let rule = Rule::new();
462 let area = Rect::new(0, 0, 1, 1);
463 let mut pool = GraphemePool::new();
464 let mut frame = Frame::new(1, 1, &mut pool);
465 rule.render(area, &mut frame);
466
467 assert_eq!(frame.buffer.get(0, 0).unwrap().content.as_char(), Some('─'));
468 }
469
470 #[test]
471 fn width_two_with_title() {
472 let rule = Rule::new().title("X");
474 let area = Rect::new(0, 0, 2, 1);
475 let mut pool = GraphemePool::new();
476 let mut frame = Frame::new(2, 1, &mut pool);
477 rule.render(area, &mut frame);
478
479 let s = row_string(&frame.buffer, 0, 2);
481 assert!(!s.is_empty(), "Should render something, got empty");
482 }
483
484 #[test]
485 fn offset_area() {
486 let rule = Rule::new();
488 let area = Rect::new(5, 3, 10, 1);
489 let mut pool = GraphemePool::new();
490 let mut frame = Frame::new(20, 5, &mut pool);
491 rule.render(area, &mut frame);
492
493 assert_ne!(frame.buffer.get(4, 3).unwrap().content.as_char(), Some('─'));
495 assert_eq!(frame.buffer.get(5, 3).unwrap().content.as_char(), Some('─'));
497 assert_eq!(
498 frame.buffer.get(14, 3).unwrap().content.as_char(),
499 Some('─')
500 );
501 assert_ne!(
503 frame.buffer.get(15, 3).unwrap().content.as_char(),
504 Some('─')
505 );
506 }
507
508 #[test]
509 fn style_applied_to_rule_chars() {
510 use ftui_render::cell::PackedRgba;
511
512 let fg = PackedRgba::rgb(255, 0, 0);
513 let rule = Rule::new().style(Style::new().fg(fg));
514 let area = Rect::new(0, 0, 5, 1);
515 let mut pool = GraphemePool::new();
516 let mut frame = Frame::new(5, 1, &mut pool);
517 rule.render(area, &mut frame);
518
519 for x in 0..5 {
520 assert_eq!(frame.buffer.get(x, 0).unwrap().fg, fg);
521 }
522 }
523
524 #[test]
525 fn title_style_distinct_from_rule_style() {
526 use ftui_render::cell::PackedRgba;
527
528 let rule_fg = PackedRgba::rgb(255, 0, 0);
529 let title_fg = PackedRgba::rgb(0, 255, 0);
530 let rule = Rule::new()
531 .title("AB")
532 .title_alignment(Alignment::Center)
533 .style(Style::new().fg(rule_fg))
534 .title_style(Style::new().fg(title_fg));
535 let area = Rect::new(0, 0, 20, 1);
536 let mut pool = GraphemePool::new();
537 let mut frame = Frame::new(20, 1, &mut pool);
538 rule.render(area, &mut frame);
539
540 let mut found_title = false;
542 for x in 0..20u16 {
543 if let Some(cell) = frame.buffer.get(x, 0)
544 && cell.content.as_char() == Some('A')
545 {
546 assert_eq!(cell.fg, title_fg, "Title char should have title_fg");
547 found_title = true;
548 }
549 }
550 assert!(found_title, "Should have found title character 'A'");
551
552 let first = frame.buffer.get(0, 0).unwrap();
554 assert_eq!(first.content.as_char(), Some('─'));
555 assert_eq!(first.fg, rule_fg, "Rule char should have rule_fg");
556 }
557
558 #[test]
561 fn unicode_title() {
562 let rule = Rule::new().title("日本");
564 let area = Rect::new(0, 0, 20, 1);
565 let mut pool = GraphemePool::new();
566 let mut frame = Frame::new(20, 1, &mut pool);
567 rule.render(area, &mut frame);
568
569 let s = row_string(&frame.buffer, 0, 20);
570 assert!(s.contains('─'), "Should contain rule chars, got: '{s}'");
571 let mut found_wide = false;
575 for x in 0..20u16 {
576 if let Some(cell) = frame.buffer.get(x, 0)
577 && !cell.is_empty()
578 && cell.content.width() > 1
579 {
580 found_wide = true;
581 break;
582 }
583 }
584 assert!(found_wide, "Should have rendered unicode title (wide char)");
585 }
586
587 #[test]
590 fn degradation_essential_only_skips_entirely() {
591 use ftui_render::budget::DegradationLevel;
592
593 let rule = Rule::new();
594 let area = Rect::new(0, 0, 10, 1);
595 let mut pool = GraphemePool::new();
596 let mut frame = Frame::new(10, 1, &mut pool);
597 rule.render(area, &mut frame);
598 frame.buffer.degradation = DegradationLevel::EssentialOnly;
599 rule.render(area, &mut frame);
600
601 for x in 0..10u16 {
602 assert_eq!(frame.buffer.get(x, 0).unwrap().content.as_char(), Some(' '));
603 }
604 }
605
606 #[test]
607 fn degradation_skeleton_skips_entirely() {
608 use ftui_render::budget::DegradationLevel;
609
610 let rule = Rule::new();
611 let area = Rect::new(0, 0, 10, 1);
612 let mut pool = GraphemePool::new();
613 let mut frame = Frame::new(10, 1, &mut pool);
614 rule.render(area, &mut frame);
615 frame.buffer.degradation = DegradationLevel::Skeleton;
616 rule.render(area, &mut frame);
617
618 for x in 0..10u16 {
619 assert_eq!(frame.buffer.get(x, 0).unwrap().content.as_char(), Some(' '));
620 }
621 }
622
623 #[test]
624 fn degradation_simple_borders_uses_ascii() {
625 use ftui_render::budget::DegradationLevel;
626
627 let rule = Rule::new().border_type(BorderType::Square);
628 let area = Rect::new(0, 0, 10, 1);
629 let mut pool = GraphemePool::new();
630 let mut frame = Frame::new(10, 1, &mut pool);
631 frame.buffer.degradation = DegradationLevel::SimpleBorders;
632 rule.render(area, &mut frame);
633
634 let row = row_chars(&frame.buffer, 0, 10);
636 assert!(
637 row.iter().all(|&c| c == '-'),
638 "Expected all -, got: {row:?}"
639 );
640 }
641
642 #[test]
643 fn degradation_full_uses_unicode() {
644 use ftui_render::budget::DegradationLevel;
645
646 let rule = Rule::new().border_type(BorderType::Square);
647 let area = Rect::new(0, 0, 10, 1);
648 let mut pool = GraphemePool::new();
649 let mut frame = Frame::new(10, 1, &mut pool);
650 frame.buffer.degradation = DegradationLevel::Full;
651 rule.render(area, &mut frame);
652
653 let row = row_chars(&frame.buffer, 0, 10);
654 assert!(
655 row.iter().all(|&c| c == '─'),
656 "Expected all ─, got: {row:?}"
657 );
658 }
659
660 #[test]
661 fn degradation_no_styling_drops_title_and_padding_styles() {
662 use ftui_render::budget::DegradationLevel;
663
664 let rule_fg = PackedRgba::rgb(255, 0, 0);
665 let title_fg = PackedRgba::rgb(0, 255, 0);
666 let rule = Rule::new()
667 .title("Hi")
668 .style(Style::new().fg(rule_fg).bg(PackedRgba::rgb(1, 2, 3)))
669 .title_style(Style::new().fg(title_fg).bg(PackedRgba::rgb(4, 5, 6)));
670 let area = Rect::new(0, 0, 10, 1);
671 let mut pool = GraphemePool::new();
672 let mut frame = Frame::new(10, 1, &mut pool);
673 frame.buffer.degradation = DegradationLevel::NoStyling;
674 rule.render(area, &mut frame);
675
676 let row = row_chars(&frame.buffer, 0, 10);
677 let title_x = row
678 .iter()
679 .position(|&c| c == 'H')
680 .expect("title should render");
681 let title_cell = frame.buffer.get(title_x as u16, 0).unwrap();
682 let left_pad = frame.buffer.get(title_x as u16 - 1, 0).unwrap();
683
684 assert_ne!(title_cell.fg, title_fg);
685 assert_ne!(left_pad.fg, rule_fg);
686 assert_ne!(left_pad.bg, PackedRgba::rgb(1, 2, 3));
687 }
688
689 #[test]
690 fn degradation_no_styling_narrow_title_branch_drops_styles() {
691 use ftui_render::budget::DegradationLevel;
692
693 let title_fg = PackedRgba::rgb(0, 255, 0);
694 let rule = Rule::new()
695 .title("X")
696 .style(Style::new().fg(PackedRgba::rgb(255, 0, 0)))
697 .title_style(Style::new().fg(title_fg).bg(PackedRgba::rgb(4, 5, 6)));
698 let area = Rect::new(0, 0, 2, 1);
699 let mut pool = GraphemePool::new();
700 let mut frame = Frame::new(2, 1, &mut pool);
701 frame.buffer.degradation = DegradationLevel::NoStyling;
702 rule.render(area, &mut frame);
703
704 let title_cell = frame.buffer.get(0, 0).unwrap();
705 assert_eq!(title_cell.content.as_char(), Some('X'));
706 assert_ne!(title_cell.fg, title_fg);
707 assert_ne!(title_cell.bg, PackedRgba::rgb(4, 5, 6));
708 }
709
710 use crate::MeasurableWidget;
713 use ftui_core::geometry::Size;
714
715 #[test]
716 fn measure_no_title() {
717 let rule = Rule::new();
718 let constraints = rule.measure(Size::MAX);
719
720 assert_eq!(constraints.min, Size::new(1, 1));
722 assert_eq!(constraints.preferred, Size::new(1, 1));
723 assert_eq!(constraints.max, Some(Size::new(u16::MAX, 1)));
724 }
725
726 #[test]
727 fn measure_with_title() {
728 let rule = Rule::new().title("Test");
729 let constraints = rule.measure(Size::MAX);
730
731 assert_eq!(constraints.min, Size::new(1, 1));
733 assert_eq!(constraints.preferred, Size::new(8, 1));
734 assert_eq!(constraints.max.unwrap().height, 1);
735 }
736
737 #[test]
738 fn measure_with_long_title() {
739 let rule = Rule::new().title("Very Long Title");
740 let constraints = rule.measure(Size::MAX);
741
742 assert_eq!(constraints.preferred, Size::new(19, 1));
744 }
745
746 #[test]
747 fn measure_fixed_height() {
748 let rule = Rule::new().title("Hi");
749 let constraints = rule.measure(Size::MAX);
750
751 assert_eq!(constraints.min.height, 1);
753 assert_eq!(constraints.preferred.height, 1);
754 assert_eq!(constraints.max.unwrap().height, 1);
755 }
756
757 #[test]
758 fn rule_has_intrinsic_size() {
759 let rule = Rule::new();
760 assert!(rule.has_intrinsic_size());
761 }
762
763 #[test]
764 fn rule_measure_is_pure() {
765 let rule = Rule::new().title("Hello");
766 let a = rule.measure(Size::new(100, 50));
767 let b = rule.measure(Size::new(100, 50));
768 assert_eq!(a, b);
769 }
770}