1#![forbid(unsafe_code)]
2
3use crate::block::Alignment;
9use crate::borders::BorderType;
10use crate::measurable::{MeasurableWidget, SizeConstraints};
11use crate::{Widget, apply_style, 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 return;
148 }
149
150 let y = area.y;
151 let width = area.width;
152
153 match self.title {
154 None => {
155 self.fill_rule_char(&mut frame.buffer, y, area.x, area.right());
157 }
158 Some("") => self.fill_rule_char(&mut frame.buffer, y, area.x, area.right()),
159 Some(title) => {
160 let title_width = display_width(title) as u16;
161
162 let min_width_for_title = title_width.saturating_add(2);
166 if width < min_width_for_title || width < 3 {
167 if title_width > width {
170 self.fill_rule_char(&mut frame.buffer, y, area.x, area.right());
172 } else {
173 let ts = self.title_style.unwrap_or(self.style);
175 draw_text_span(frame, area.x, y, title, ts, area.right());
176 let after = area.x.saturating_add(title_width);
178 self.fill_rule_char(&mut frame.buffer, y, after, area.right());
179 }
180 return;
181 }
182
183 let max_title_width = width.saturating_sub(2);
185 let display_width = title_width.min(max_title_width);
186
187 let title_block_width = display_width + 2; let title_block_x = match self.title_alignment {
190 Alignment::Left => area.x,
191 Alignment::Center => area
192 .x
193 .saturating_add((width.saturating_sub(title_block_width)) / 2),
194 Alignment::Right => area.right().saturating_sub(title_block_width),
195 };
196
197 self.fill_rule_char(&mut frame.buffer, y, area.x, title_block_x);
199
200 let pad_x = title_block_x;
202 if let Some(cell) = frame.buffer.get_mut(pad_x, y) {
203 *cell = Cell::from_char(' ');
204 apply_style(cell, self.style);
205 }
206
207 let ts = self.title_style.unwrap_or(self.style);
209 let title_x = pad_x.saturating_add(1);
210 let title_end = title_x.saturating_add(display_width);
211 draw_text_span(frame, title_x, y, title, ts, title_end);
212
213 let right_pad_x = title_end;
215 if right_pad_x < area.right()
216 && let Some(cell) = frame.buffer.get_mut(right_pad_x, y)
217 {
218 *cell = Cell::from_char(' ');
219 apply_style(cell, self.style);
220 }
221
222 let right_rule_start = right_pad_x.saturating_add(1);
224 self.fill_rule_char(&mut frame.buffer, y, right_rule_start, area.right());
225 }
226 }
227 }
228}
229
230impl MeasurableWidget for Rule<'_> {
231 fn measure(&self, _available: Size) -> SizeConstraints {
232 let min_width = 1u16;
235
236 let preferred_width = if let Some(title) = self.title {
237 let title_width = display_width(title) as u16;
239 title_width.saturating_add(4) } else {
241 1 };
243
244 SizeConstraints {
245 min: Size::new(min_width, 1),
246 preferred: Size::new(preferred_width, 1),
247 max: Some(Size::new(u16::MAX, 1)), }
249 }
250
251 fn has_intrinsic_size(&self) -> bool {
252 true
254 }
255}
256
257#[cfg(test)]
258mod tests {
259 use super::*;
260 use ftui_render::grapheme_pool::GraphemePool;
261
262 fn row_chars(buf: &Buffer, y: u16, width: u16) -> Vec<char> {
264 (0..width)
265 .map(|x| {
266 buf.get(x, y)
267 .and_then(|c| c.content.as_char())
268 .unwrap_or(' ')
269 })
270 .collect()
271 }
272
273 fn row_string(buf: &Buffer, y: u16, width: u16) -> String {
275 let chars: String = row_chars(buf, y, width).into_iter().collect();
276 chars.trim_end().to_string()
277 }
278
279 #[test]
282 fn no_title_fills_width() {
283 let rule = Rule::new();
284 let area = Rect::new(0, 0, 10, 1);
285 let mut pool = GraphemePool::new();
286 let mut frame = Frame::new(10, 1, &mut pool);
287 rule.render(area, &mut frame);
288
289 let row = row_chars(&frame.buffer, 0, 10);
290 assert!(
291 row.iter().all(|&c| c == '─'),
292 "Expected all ─, got: {row:?}"
293 );
294 }
295
296 #[test]
297 fn no_title_heavy_border() {
298 let rule = Rule::new().border_type(BorderType::Heavy);
299 let area = Rect::new(0, 0, 5, 1);
300 let mut pool = GraphemePool::new();
301 let mut frame = Frame::new(5, 1, &mut pool);
302 rule.render(area, &mut frame);
303
304 let row = row_chars(&frame.buffer, 0, 5);
305 assert!(
306 row.iter().all(|&c| c == '━'),
307 "Expected all ━, got: {row:?}"
308 );
309 }
310
311 #[test]
312 fn no_title_double_border() {
313 let rule = Rule::new().border_type(BorderType::Double);
314 let area = Rect::new(0, 0, 5, 1);
315 let mut pool = GraphemePool::new();
316 let mut frame = Frame::new(5, 1, &mut pool);
317 rule.render(area, &mut frame);
318
319 let row = row_chars(&frame.buffer, 0, 5);
320 assert!(
321 row.iter().all(|&c| c == '═'),
322 "Expected all ═, got: {row:?}"
323 );
324 }
325
326 #[test]
327 fn no_title_ascii_border() {
328 let rule = Rule::new().border_type(BorderType::Ascii);
329 let area = Rect::new(0, 0, 5, 1);
330 let mut pool = GraphemePool::new();
331 let mut frame = Frame::new(5, 1, &mut pool);
332 rule.render(area, &mut frame);
333
334 let row = row_chars(&frame.buffer, 0, 5);
335 assert!(
336 row.iter().all(|&c| c == '-'),
337 "Expected all -, got: {row:?}"
338 );
339 }
340
341 #[test]
344 fn title_center_default() {
345 let rule = Rule::new().title("Hi");
346 let area = Rect::new(0, 0, 20, 1);
347 let mut pool = GraphemePool::new();
348 let mut frame = Frame::new(20, 1, &mut pool);
349 rule.render(area, &mut frame);
350
351 let s = row_string(&frame.buffer, 0, 20);
352 assert!(
353 s.contains(" Hi "),
354 "Expected centered title with spaces, got: '{s}'"
355 );
356 assert!(s.contains('─'), "Expected rule chars, got: '{s}'");
357 }
358
359 #[test]
360 fn title_left_aligned() {
361 let rule = Rule::new().title("Hi").title_alignment(Alignment::Left);
362 let area = Rect::new(0, 0, 20, 1);
363 let mut pool = GraphemePool::new();
364 let mut frame = Frame::new(20, 1, &mut pool);
365 rule.render(area, &mut frame);
366
367 let s = row_string(&frame.buffer, 0, 20);
368 assert!(
369 s.starts_with(" Hi "),
370 "Left-aligned should start with ' Hi ', got: '{s}'"
371 );
372 }
373
374 #[test]
375 fn title_right_aligned() {
376 let rule = Rule::new().title("Hi").title_alignment(Alignment::Right);
377 let area = Rect::new(0, 0, 20, 1);
378 let mut pool = GraphemePool::new();
379 let mut frame = Frame::new(20, 1, &mut pool);
380 rule.render(area, &mut frame);
381
382 let s = row_string(&frame.buffer, 0, 20);
383 assert!(
384 s.ends_with(" Hi"),
385 "Right-aligned should end with ' Hi', got: '{s}'"
386 );
387 }
388
389 #[test]
390 fn title_truncated_at_narrow_width() {
391 let rule = Rule::new().title("Hello");
393 let area = Rect::new(0, 0, 7, 1);
394 let mut pool = GraphemePool::new();
395 let mut frame = Frame::new(7, 1, &mut pool);
396 rule.render(area, &mut frame);
397
398 let s = row_string(&frame.buffer, 0, 7);
399 assert!(s.contains("Hello"), "Title should be present, got: '{s}'");
400 }
401
402 #[test]
403 fn title_too_wide_falls_back_to_rule() {
404 let rule = Rule::new().title("VeryLongTitle");
406 let area = Rect::new(0, 0, 5, 1);
407 let mut pool = GraphemePool::new();
408 let mut frame = Frame::new(5, 1, &mut pool);
409 rule.render(area, &mut frame);
410
411 let row = row_chars(&frame.buffer, 0, 5);
412 assert!(
414 row.iter().all(|&c| c == '─'),
415 "Expected fallback to rule, got: {row:?}"
416 );
417 }
418
419 #[test]
420 fn empty_title_same_as_no_title() {
421 let rule = Rule::new().title("");
422 let area = Rect::new(0, 0, 10, 1);
423 let mut pool = GraphemePool::new();
424 let mut frame = Frame::new(10, 1, &mut pool);
425 rule.render(area, &mut frame);
426
427 let row = row_chars(&frame.buffer, 0, 10);
428 assert!(
429 row.iter().all(|&c| c == '─'),
430 "Empty title should be plain rule, got: {row:?}"
431 );
432 }
433
434 #[test]
437 fn zero_width_no_panic() {
438 let rule = Rule::new().title("Test");
439 let area = Rect::new(0, 0, 0, 0);
440 let mut pool = GraphemePool::new();
441 let mut frame = Frame::new(1, 1, &mut pool);
442 rule.render(area, &mut frame);
443 }
445
446 #[test]
447 fn width_one_no_title() {
448 let rule = Rule::new();
449 let area = Rect::new(0, 0, 1, 1);
450 let mut pool = GraphemePool::new();
451 let mut frame = Frame::new(1, 1, &mut pool);
452 rule.render(area, &mut frame);
453
454 assert_eq!(frame.buffer.get(0, 0).unwrap().content.as_char(), Some('─'));
455 }
456
457 #[test]
458 fn width_two_with_title() {
459 let rule = Rule::new().title("X");
461 let area = Rect::new(0, 0, 2, 1);
462 let mut pool = GraphemePool::new();
463 let mut frame = Frame::new(2, 1, &mut pool);
464 rule.render(area, &mut frame);
465
466 let s = row_string(&frame.buffer, 0, 2);
468 assert!(!s.is_empty(), "Should render something, got empty");
469 }
470
471 #[test]
472 fn offset_area() {
473 let rule = Rule::new();
475 let area = Rect::new(5, 3, 10, 1);
476 let mut pool = GraphemePool::new();
477 let mut frame = Frame::new(20, 5, &mut pool);
478 rule.render(area, &mut frame);
479
480 assert_ne!(frame.buffer.get(4, 3).unwrap().content.as_char(), Some('─'));
482 assert_eq!(frame.buffer.get(5, 3).unwrap().content.as_char(), Some('─'));
484 assert_eq!(
485 frame.buffer.get(14, 3).unwrap().content.as_char(),
486 Some('─')
487 );
488 assert_ne!(
490 frame.buffer.get(15, 3).unwrap().content.as_char(),
491 Some('─')
492 );
493 }
494
495 #[test]
496 fn style_applied_to_rule_chars() {
497 use ftui_render::cell::PackedRgba;
498
499 let fg = PackedRgba::rgb(255, 0, 0);
500 let rule = Rule::new().style(Style::new().fg(fg));
501 let area = Rect::new(0, 0, 5, 1);
502 let mut pool = GraphemePool::new();
503 let mut frame = Frame::new(5, 1, &mut pool);
504 rule.render(area, &mut frame);
505
506 for x in 0..5 {
507 assert_eq!(frame.buffer.get(x, 0).unwrap().fg, fg);
508 }
509 }
510
511 #[test]
512 fn title_style_distinct_from_rule_style() {
513 use ftui_render::cell::PackedRgba;
514
515 let rule_fg = PackedRgba::rgb(255, 0, 0);
516 let title_fg = PackedRgba::rgb(0, 255, 0);
517 let rule = Rule::new()
518 .title("AB")
519 .title_alignment(Alignment::Center)
520 .style(Style::new().fg(rule_fg))
521 .title_style(Style::new().fg(title_fg));
522 let area = Rect::new(0, 0, 20, 1);
523 let mut pool = GraphemePool::new();
524 let mut frame = Frame::new(20, 1, &mut pool);
525 rule.render(area, &mut frame);
526
527 let mut found_title = false;
529 for x in 0..20u16 {
530 if let Some(cell) = frame.buffer.get(x, 0)
531 && cell.content.as_char() == Some('A')
532 {
533 assert_eq!(cell.fg, title_fg, "Title char should have title_fg");
534 found_title = true;
535 }
536 }
537 assert!(found_title, "Should have found title character 'A'");
538
539 let first = frame.buffer.get(0, 0).unwrap();
541 assert_eq!(first.content.as_char(), Some('─'));
542 assert_eq!(first.fg, rule_fg, "Rule char should have rule_fg");
543 }
544
545 #[test]
548 fn unicode_title() {
549 let rule = Rule::new().title("日本");
551 let area = Rect::new(0, 0, 20, 1);
552 let mut pool = GraphemePool::new();
553 let mut frame = Frame::new(20, 1, &mut pool);
554 rule.render(area, &mut frame);
555
556 let s = row_string(&frame.buffer, 0, 20);
557 assert!(s.contains('─'), "Should contain rule chars, got: '{s}'");
558 let mut found_wide = false;
562 for x in 0..20u16 {
563 if let Some(cell) = frame.buffer.get(x, 0)
564 && !cell.is_empty()
565 && cell.content.width() > 1
566 {
567 found_wide = true;
568 break;
569 }
570 }
571 assert!(found_wide, "Should have rendered unicode title (wide char)");
572 }
573
574 #[test]
577 fn degradation_essential_only_skips_entirely() {
578 use ftui_render::budget::DegradationLevel;
579
580 let rule = Rule::new();
581 let area = Rect::new(0, 0, 10, 1);
582 let mut pool = GraphemePool::new();
583 let mut frame = Frame::new(10, 1, &mut pool);
584 frame.buffer.degradation = DegradationLevel::EssentialOnly;
585 rule.render(area, &mut frame);
586
587 for x in 0..10u16 {
589 assert!(
590 frame.buffer.get(x, 0).unwrap().is_empty(),
591 "cell at x={x} should be empty at EssentialOnly"
592 );
593 }
594 }
595
596 #[test]
597 fn degradation_skeleton_skips_entirely() {
598 use ftui_render::budget::DegradationLevel;
599
600 let rule = Rule::new();
601 let area = Rect::new(0, 0, 10, 1);
602 let mut pool = GraphemePool::new();
603 let mut frame = Frame::new(10, 1, &mut pool);
604 frame.buffer.degradation = DegradationLevel::Skeleton;
605 rule.render(area, &mut frame);
606
607 for x in 0..10u16 {
608 assert!(
609 frame.buffer.get(x, 0).unwrap().is_empty(),
610 "cell at x={x} should be empty at Skeleton"
611 );
612 }
613 }
614
615 #[test]
616 fn degradation_simple_borders_uses_ascii() {
617 use ftui_render::budget::DegradationLevel;
618
619 let rule = Rule::new().border_type(BorderType::Square);
620 let area = Rect::new(0, 0, 10, 1);
621 let mut pool = GraphemePool::new();
622 let mut frame = Frame::new(10, 1, &mut pool);
623 frame.buffer.degradation = DegradationLevel::SimpleBorders;
624 rule.render(area, &mut frame);
625
626 let row = row_chars(&frame.buffer, 0, 10);
628 assert!(
629 row.iter().all(|&c| c == '-'),
630 "Expected all -, got: {row:?}"
631 );
632 }
633
634 #[test]
635 fn degradation_full_uses_unicode() {
636 use ftui_render::budget::DegradationLevel;
637
638 let rule = Rule::new().border_type(BorderType::Square);
639 let area = Rect::new(0, 0, 10, 1);
640 let mut pool = GraphemePool::new();
641 let mut frame = Frame::new(10, 1, &mut pool);
642 frame.buffer.degradation = DegradationLevel::Full;
643 rule.render(area, &mut frame);
644
645 let row = row_chars(&frame.buffer, 0, 10);
646 assert!(
647 row.iter().all(|&c| c == '─'),
648 "Expected all ─, got: {row:?}"
649 );
650 }
651
652 use crate::MeasurableWidget;
655 use ftui_core::geometry::Size;
656
657 #[test]
658 fn measure_no_title() {
659 let rule = Rule::new();
660 let constraints = rule.measure(Size::MAX);
661
662 assert_eq!(constraints.min, Size::new(1, 1));
664 assert_eq!(constraints.preferred, Size::new(1, 1));
665 assert_eq!(constraints.max, Some(Size::new(u16::MAX, 1)));
666 }
667
668 #[test]
669 fn measure_with_title() {
670 let rule = Rule::new().title("Test");
671 let constraints = rule.measure(Size::MAX);
672
673 assert_eq!(constraints.min, Size::new(1, 1));
675 assert_eq!(constraints.preferred, Size::new(8, 1));
676 assert_eq!(constraints.max.unwrap().height, 1);
677 }
678
679 #[test]
680 fn measure_with_long_title() {
681 let rule = Rule::new().title("Very Long Title");
682 let constraints = rule.measure(Size::MAX);
683
684 assert_eq!(constraints.preferred, Size::new(19, 1));
686 }
687
688 #[test]
689 fn measure_fixed_height() {
690 let rule = Rule::new().title("Hi");
691 let constraints = rule.measure(Size::MAX);
692
693 assert_eq!(constraints.min.height, 1);
695 assert_eq!(constraints.preferred.height, 1);
696 assert_eq!(constraints.max.unwrap().height, 1);
697 }
698
699 #[test]
700 fn rule_has_intrinsic_size() {
701 let rule = Rule::new();
702 assert!(rule.has_intrinsic_size());
703 }
704
705 #[test]
706 fn rule_measure_is_pure() {
707 let rule = Rule::new().title("Hello");
708 let a = rule.measure(Size::new(100, 50));
709 let b = rule.measure(Size::new(100, 50));
710 assert_eq!(a, b);
711 }
712}