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 pub fn new() -> Self {
67 Self::default()
68 }
69
70 pub fn title(mut self, title: &'a str) -> Self {
72 self.title = Some(title);
73 self
74 }
75
76 pub fn title_alignment(mut self, alignment: Alignment) -> Self {
78 self.title_alignment = alignment;
79 self
80 }
81
82 pub fn style(mut self, style: Style) -> Self {
84 self.style = style;
85 self
86 }
87
88 pub fn title_style(mut self, style: Style) -> Self {
92 self.title_style = Some(style);
93 self
94 }
95
96 pub fn border_type(mut self, border_type: BorderType) -> Self {
98 self.border_type = border_type;
99 self
100 }
101
102 fn fill_rule_char(&self, buf: &mut Buffer, y: u16, start: u16, end: u16) {
104 let ch = if buf.degradation.use_unicode_borders() {
105 self.border_type.to_border_set().horizontal
106 } else {
107 '-' };
109 let style = if buf.degradation.apply_styling() {
110 self.style
111 } else {
112 Style::default()
113 };
114 for x in start..end {
115 let mut cell = Cell::from_char(ch);
116 apply_style(&mut cell, style);
117 buf.set(x, y, cell);
118 }
119 }
120}
121
122impl Widget for Rule<'_> {
123 fn render(&self, area: Rect, frame: &mut Frame) {
124 #[cfg(feature = "tracing")]
125 let _span = tracing::debug_span!(
126 "widget_render",
127 widget = "Rule",
128 x = area.x,
129 y = area.y,
130 w = area.width,
131 h = area.height
132 )
133 .entered();
134
135 if area.is_empty() {
136 return;
137 }
138
139 if !frame.buffer.degradation.render_decorative() {
141 return;
142 }
143
144 let y = area.y;
145 let width = area.width;
146
147 match self.title {
148 None => {
149 self.fill_rule_char(&mut frame.buffer, y, area.x, area.right());
151 }
152 Some("") => self.fill_rule_char(&mut frame.buffer, y, area.x, area.right()),
153 Some(title) => {
154 let title_width = display_width(title) as u16;
155
156 let min_width_for_title = title_width.saturating_add(2);
160 if width < min_width_for_title || width < 3 {
161 if title_width > width {
164 self.fill_rule_char(&mut frame.buffer, y, area.x, area.right());
166 } else {
167 let ts = self.title_style.unwrap_or(self.style);
169 draw_text_span(frame, area.x, y, title, ts, area.right());
170 let after = area.x.saturating_add(title_width);
172 self.fill_rule_char(&mut frame.buffer, y, after, area.right());
173 }
174 return;
175 }
176
177 let max_title_width = width.saturating_sub(2);
179 let display_width = title_width.min(max_title_width);
180
181 let title_block_width = display_width + 2; let title_block_x = match self.title_alignment {
184 Alignment::Left => area.x,
185 Alignment::Center => area
186 .x
187 .saturating_add((width.saturating_sub(title_block_width)) / 2),
188 Alignment::Right => area.right().saturating_sub(title_block_width),
189 };
190
191 self.fill_rule_char(&mut frame.buffer, y, area.x, title_block_x);
193
194 let pad_x = title_block_x;
196 if let Some(cell) = frame.buffer.get_mut(pad_x, y) {
197 *cell = Cell::from_char(' ');
198 apply_style(cell, self.style);
199 }
200
201 let ts = self.title_style.unwrap_or(self.style);
203 let title_x = pad_x.saturating_add(1);
204 let title_end = title_x.saturating_add(display_width);
205 draw_text_span(frame, title_x, y, title, ts, title_end);
206
207 let right_pad_x = title_end;
209 if right_pad_x < area.right()
210 && let Some(cell) = frame.buffer.get_mut(right_pad_x, y)
211 {
212 *cell = Cell::from_char(' ');
213 apply_style(cell, self.style);
214 }
215
216 let right_rule_start = right_pad_x.saturating_add(1);
218 self.fill_rule_char(&mut frame.buffer, y, right_rule_start, area.right());
219 }
220 }
221 }
222}
223
224impl MeasurableWidget for Rule<'_> {
225 fn measure(&self, _available: Size) -> SizeConstraints {
226 let min_width = 1u16;
229
230 let preferred_width = if let Some(title) = self.title {
231 let title_width = display_width(title) as u16;
233 title_width.saturating_add(4) } else {
235 1 };
237
238 SizeConstraints {
239 min: Size::new(min_width, 1),
240 preferred: Size::new(preferred_width, 1),
241 max: Some(Size::new(u16::MAX, 1)), }
243 }
244
245 fn has_intrinsic_size(&self) -> bool {
246 true
248 }
249}
250
251#[cfg(test)]
252mod tests {
253 use super::*;
254 use ftui_render::grapheme_pool::GraphemePool;
255
256 fn row_chars(buf: &Buffer, y: u16, width: u16) -> Vec<char> {
258 (0..width)
259 .map(|x| {
260 buf.get(x, y)
261 .and_then(|c| c.content.as_char())
262 .unwrap_or(' ')
263 })
264 .collect()
265 }
266
267 fn row_string(buf: &Buffer, y: u16, width: u16) -> String {
269 let chars: String = row_chars(buf, y, width).into_iter().collect();
270 chars.trim_end().to_string()
271 }
272
273 #[test]
276 fn no_title_fills_width() {
277 let rule = Rule::new();
278 let area = Rect::new(0, 0, 10, 1);
279 let mut pool = GraphemePool::new();
280 let mut frame = Frame::new(10, 1, &mut pool);
281 rule.render(area, &mut frame);
282
283 let row = row_chars(&frame.buffer, 0, 10);
284 assert!(
285 row.iter().all(|&c| c == '─'),
286 "Expected all ─, got: {row:?}"
287 );
288 }
289
290 #[test]
291 fn no_title_heavy_border() {
292 let rule = Rule::new().border_type(BorderType::Heavy);
293 let area = Rect::new(0, 0, 5, 1);
294 let mut pool = GraphemePool::new();
295 let mut frame = Frame::new(5, 1, &mut pool);
296 rule.render(area, &mut frame);
297
298 let row = row_chars(&frame.buffer, 0, 5);
299 assert!(
300 row.iter().all(|&c| c == '━'),
301 "Expected all ━, got: {row:?}"
302 );
303 }
304
305 #[test]
306 fn no_title_double_border() {
307 let rule = Rule::new().border_type(BorderType::Double);
308 let area = Rect::new(0, 0, 5, 1);
309 let mut pool = GraphemePool::new();
310 let mut frame = Frame::new(5, 1, &mut pool);
311 rule.render(area, &mut frame);
312
313 let row = row_chars(&frame.buffer, 0, 5);
314 assert!(
315 row.iter().all(|&c| c == '═'),
316 "Expected all ═, got: {row:?}"
317 );
318 }
319
320 #[test]
321 fn no_title_ascii_border() {
322 let rule = Rule::new().border_type(BorderType::Ascii);
323 let area = Rect::new(0, 0, 5, 1);
324 let mut pool = GraphemePool::new();
325 let mut frame = Frame::new(5, 1, &mut pool);
326 rule.render(area, &mut frame);
327
328 let row = row_chars(&frame.buffer, 0, 5);
329 assert!(
330 row.iter().all(|&c| c == '-'),
331 "Expected all -, got: {row:?}"
332 );
333 }
334
335 #[test]
338 fn title_center_default() {
339 let rule = Rule::new().title("Hi");
340 let area = Rect::new(0, 0, 20, 1);
341 let mut pool = GraphemePool::new();
342 let mut frame = Frame::new(20, 1, &mut pool);
343 rule.render(area, &mut frame);
344
345 let s = row_string(&frame.buffer, 0, 20);
346 assert!(
347 s.contains(" Hi "),
348 "Expected centered title with spaces, got: '{s}'"
349 );
350 assert!(s.contains('─'), "Expected rule chars, got: '{s}'");
351 }
352
353 #[test]
354 fn title_left_aligned() {
355 let rule = Rule::new().title("Hi").title_alignment(Alignment::Left);
356 let area = Rect::new(0, 0, 20, 1);
357 let mut pool = GraphemePool::new();
358 let mut frame = Frame::new(20, 1, &mut pool);
359 rule.render(area, &mut frame);
360
361 let s = row_string(&frame.buffer, 0, 20);
362 assert!(
363 s.starts_with(" Hi "),
364 "Left-aligned should start with ' Hi ', got: '{s}'"
365 );
366 }
367
368 #[test]
369 fn title_right_aligned() {
370 let rule = Rule::new().title("Hi").title_alignment(Alignment::Right);
371 let area = Rect::new(0, 0, 20, 1);
372 let mut pool = GraphemePool::new();
373 let mut frame = Frame::new(20, 1, &mut pool);
374 rule.render(area, &mut frame);
375
376 let s = row_string(&frame.buffer, 0, 20);
377 assert!(
378 s.ends_with(" Hi"),
379 "Right-aligned should end with ' Hi', got: '{s}'"
380 );
381 }
382
383 #[test]
384 fn title_truncated_at_narrow_width() {
385 let rule = Rule::new().title("Hello");
387 let area = Rect::new(0, 0, 7, 1);
388 let mut pool = GraphemePool::new();
389 let mut frame = Frame::new(7, 1, &mut pool);
390 rule.render(area, &mut frame);
391
392 let s = row_string(&frame.buffer, 0, 7);
393 assert!(s.contains("Hello"), "Title should be present, got: '{s}'");
394 }
395
396 #[test]
397 fn title_too_wide_falls_back_to_rule() {
398 let rule = Rule::new().title("VeryLongTitle");
400 let area = Rect::new(0, 0, 5, 1);
401 let mut pool = GraphemePool::new();
402 let mut frame = Frame::new(5, 1, &mut pool);
403 rule.render(area, &mut frame);
404
405 let row = row_chars(&frame.buffer, 0, 5);
406 assert!(
408 row.iter().all(|&c| c == '─'),
409 "Expected fallback to rule, got: {row:?}"
410 );
411 }
412
413 #[test]
414 fn empty_title_same_as_no_title() {
415 let rule = Rule::new().title("");
416 let area = Rect::new(0, 0, 10, 1);
417 let mut pool = GraphemePool::new();
418 let mut frame = Frame::new(10, 1, &mut pool);
419 rule.render(area, &mut frame);
420
421 let row = row_chars(&frame.buffer, 0, 10);
422 assert!(
423 row.iter().all(|&c| c == '─'),
424 "Empty title should be plain rule, got: {row:?}"
425 );
426 }
427
428 #[test]
431 fn zero_width_no_panic() {
432 let rule = Rule::new().title("Test");
433 let area = Rect::new(0, 0, 0, 0);
434 let mut pool = GraphemePool::new();
435 let mut frame = Frame::new(1, 1, &mut pool);
436 rule.render(area, &mut frame);
437 }
439
440 #[test]
441 fn width_one_no_title() {
442 let rule = Rule::new();
443 let area = Rect::new(0, 0, 1, 1);
444 let mut pool = GraphemePool::new();
445 let mut frame = Frame::new(1, 1, &mut pool);
446 rule.render(area, &mut frame);
447
448 assert_eq!(frame.buffer.get(0, 0).unwrap().content.as_char(), Some('─'));
449 }
450
451 #[test]
452 fn width_two_with_title() {
453 let rule = Rule::new().title("X");
455 let area = Rect::new(0, 0, 2, 1);
456 let mut pool = GraphemePool::new();
457 let mut frame = Frame::new(2, 1, &mut pool);
458 rule.render(area, &mut frame);
459
460 let s = row_string(&frame.buffer, 0, 2);
462 assert!(!s.is_empty(), "Should render something, got empty");
463 }
464
465 #[test]
466 fn offset_area() {
467 let rule = Rule::new();
469 let area = Rect::new(5, 3, 10, 1);
470 let mut pool = GraphemePool::new();
471 let mut frame = Frame::new(20, 5, &mut pool);
472 rule.render(area, &mut frame);
473
474 assert_ne!(frame.buffer.get(4, 3).unwrap().content.as_char(), Some('─'));
476 assert_eq!(frame.buffer.get(5, 3).unwrap().content.as_char(), Some('─'));
478 assert_eq!(
479 frame.buffer.get(14, 3).unwrap().content.as_char(),
480 Some('─')
481 );
482 assert_ne!(
484 frame.buffer.get(15, 3).unwrap().content.as_char(),
485 Some('─')
486 );
487 }
488
489 #[test]
490 fn style_applied_to_rule_chars() {
491 use ftui_render::cell::PackedRgba;
492
493 let fg = PackedRgba::rgb(255, 0, 0);
494 let rule = Rule::new().style(Style::new().fg(fg));
495 let area = Rect::new(0, 0, 5, 1);
496 let mut pool = GraphemePool::new();
497 let mut frame = Frame::new(5, 1, &mut pool);
498 rule.render(area, &mut frame);
499
500 for x in 0..5 {
501 assert_eq!(frame.buffer.get(x, 0).unwrap().fg, fg);
502 }
503 }
504
505 #[test]
506 fn title_style_distinct_from_rule_style() {
507 use ftui_render::cell::PackedRgba;
508
509 let rule_fg = PackedRgba::rgb(255, 0, 0);
510 let title_fg = PackedRgba::rgb(0, 255, 0);
511 let rule = Rule::new()
512 .title("AB")
513 .title_alignment(Alignment::Center)
514 .style(Style::new().fg(rule_fg))
515 .title_style(Style::new().fg(title_fg));
516 let area = Rect::new(0, 0, 20, 1);
517 let mut pool = GraphemePool::new();
518 let mut frame = Frame::new(20, 1, &mut pool);
519 rule.render(area, &mut frame);
520
521 let mut found_title = false;
523 for x in 0..20u16 {
524 if let Some(cell) = frame.buffer.get(x, 0)
525 && cell.content.as_char() == Some('A')
526 {
527 assert_eq!(cell.fg, title_fg, "Title char should have title_fg");
528 found_title = true;
529 }
530 }
531 assert!(found_title, "Should have found title character 'A'");
532
533 let first = frame.buffer.get(0, 0).unwrap();
535 assert_eq!(first.content.as_char(), Some('─'));
536 assert_eq!(first.fg, rule_fg, "Rule char should have rule_fg");
537 }
538
539 #[test]
542 fn unicode_title() {
543 let rule = Rule::new().title("日本");
545 let area = Rect::new(0, 0, 20, 1);
546 let mut pool = GraphemePool::new();
547 let mut frame = Frame::new(20, 1, &mut pool);
548 rule.render(area, &mut frame);
549
550 let s = row_string(&frame.buffer, 0, 20);
551 assert!(s.contains('─'), "Should contain rule chars, got: '{s}'");
552 let mut found_wide = false;
556 for x in 0..20u16 {
557 if let Some(cell) = frame.buffer.get(x, 0)
558 && !cell.is_empty()
559 && cell.content.width() > 1
560 {
561 found_wide = true;
562 break;
563 }
564 }
565 assert!(found_wide, "Should have rendered unicode title (wide char)");
566 }
567
568 #[test]
571 fn degradation_essential_only_skips_entirely() {
572 use ftui_render::budget::DegradationLevel;
573
574 let rule = Rule::new();
575 let area = Rect::new(0, 0, 10, 1);
576 let mut pool = GraphemePool::new();
577 let mut frame = Frame::new(10, 1, &mut pool);
578 frame.buffer.degradation = DegradationLevel::EssentialOnly;
579 rule.render(area, &mut frame);
580
581 for x in 0..10u16 {
583 assert!(
584 frame.buffer.get(x, 0).unwrap().is_empty(),
585 "cell at x={x} should be empty at EssentialOnly"
586 );
587 }
588 }
589
590 #[test]
591 fn degradation_skeleton_skips_entirely() {
592 use ftui_render::budget::DegradationLevel;
593
594 let rule = Rule::new();
595 let area = Rect::new(0, 0, 10, 1);
596 let mut pool = GraphemePool::new();
597 let mut frame = Frame::new(10, 1, &mut pool);
598 frame.buffer.degradation = DegradationLevel::Skeleton;
599 rule.render(area, &mut frame);
600
601 for x in 0..10u16 {
602 assert!(
603 frame.buffer.get(x, 0).unwrap().is_empty(),
604 "cell at x={x} should be empty at Skeleton"
605 );
606 }
607 }
608
609 #[test]
610 fn degradation_simple_borders_uses_ascii() {
611 use ftui_render::budget::DegradationLevel;
612
613 let rule = Rule::new().border_type(BorderType::Square);
614 let area = Rect::new(0, 0, 10, 1);
615 let mut pool = GraphemePool::new();
616 let mut frame = Frame::new(10, 1, &mut pool);
617 frame.buffer.degradation = DegradationLevel::SimpleBorders;
618 rule.render(area, &mut frame);
619
620 let row = row_chars(&frame.buffer, 0, 10);
622 assert!(
623 row.iter().all(|&c| c == '-'),
624 "Expected all -, got: {row:?}"
625 );
626 }
627
628 #[test]
629 fn degradation_full_uses_unicode() {
630 use ftui_render::budget::DegradationLevel;
631
632 let rule = Rule::new().border_type(BorderType::Square);
633 let area = Rect::new(0, 0, 10, 1);
634 let mut pool = GraphemePool::new();
635 let mut frame = Frame::new(10, 1, &mut pool);
636 frame.buffer.degradation = DegradationLevel::Full;
637 rule.render(area, &mut frame);
638
639 let row = row_chars(&frame.buffer, 0, 10);
640 assert!(
641 row.iter().all(|&c| c == '─'),
642 "Expected all ─, got: {row:?}"
643 );
644 }
645
646 use crate::MeasurableWidget;
649 use ftui_core::geometry::Size;
650
651 #[test]
652 fn measure_no_title() {
653 let rule = Rule::new();
654 let constraints = rule.measure(Size::MAX);
655
656 assert_eq!(constraints.min, Size::new(1, 1));
658 assert_eq!(constraints.preferred, Size::new(1, 1));
659 assert_eq!(constraints.max, Some(Size::new(u16::MAX, 1)));
660 }
661
662 #[test]
663 fn measure_with_title() {
664 let rule = Rule::new().title("Test");
665 let constraints = rule.measure(Size::MAX);
666
667 assert_eq!(constraints.min, Size::new(1, 1));
669 assert_eq!(constraints.preferred, Size::new(8, 1));
670 assert_eq!(constraints.max.unwrap().height, 1);
671 }
672
673 #[test]
674 fn measure_with_long_title() {
675 let rule = Rule::new().title("Very Long Title");
676 let constraints = rule.measure(Size::MAX);
677
678 assert_eq!(constraints.preferred, Size::new(19, 1));
680 }
681
682 #[test]
683 fn measure_fixed_height() {
684 let rule = Rule::new().title("Hi");
685 let constraints = rule.measure(Size::MAX);
686
687 assert_eq!(constraints.min.height, 1);
689 assert_eq!(constraints.preferred.height, 1);
690 assert_eq!(constraints.max.unwrap().height, 1);
691 }
692
693 #[test]
694 fn rule_has_intrinsic_size() {
695 let rule = Rule::new();
696 assert!(rule.has_intrinsic_size());
697 }
698
699 #[test]
700 fn rule_measure_is_pure() {
701 let rule = Rule::new().title("Hello");
702 let a = rule.measure(Size::new(100, 50));
703 let b = rule.measure(Size::new(100, 50));
704 assert_eq!(a, b);
705 }
706}