1#![forbid(unsafe_code)]
2
3use crate::block::Alignment;
6use crate::borders::{BorderSet, BorderType, Borders};
7use crate::{Widget, apply_style, draw_text_span, set_style_area};
8use ftui_core::geometry::{Rect, Sides};
9use ftui_render::buffer::Buffer;
10use ftui_render::cell::Cell;
11use ftui_render::frame::Frame;
12use ftui_style::Style;
13use ftui_text::{display_width, grapheme_width};
14use unicode_segmentation::UnicodeSegmentation;
15
16#[derive(Debug, Clone)]
18pub struct Panel<'a, W> {
19 child: W,
20 borders: Borders,
21 border_style: Style,
22 border_type: BorderType,
23 title: Option<&'a str>,
24 title_alignment: Alignment,
25 title_style: Style,
26 subtitle: Option<&'a str>,
27 subtitle_alignment: Alignment,
28 subtitle_style: Style,
29 style: Style,
30 padding: Sides,
31}
32
33impl<'a, W> Panel<'a, W> {
34 pub fn new(child: W) -> Self {
36 Self {
37 child,
38 borders: Borders::ALL,
39 border_style: Style::default(),
40 border_type: BorderType::Square,
41 title: None,
42 title_alignment: Alignment::Left,
43 title_style: Style::default(),
44 subtitle: None,
45 subtitle_alignment: Alignment::Left,
46 subtitle_style: Style::default(),
47 style: Style::default(),
48 padding: Sides::default(),
49 }
50 }
51
52 #[must_use]
54 pub fn borders(mut self, borders: Borders) -> Self {
55 self.borders = borders;
56 self
57 }
58
59 #[must_use]
61 pub fn border_style(mut self, style: Style) -> Self {
62 self.border_style = style;
63 self
64 }
65
66 #[must_use]
68 pub fn border_type(mut self, border_type: BorderType) -> Self {
69 self.border_type = border_type;
70 self
71 }
72
73 #[must_use]
75 pub fn title(mut self, title: &'a str) -> Self {
76 self.title = Some(title);
77 self
78 }
79
80 #[must_use]
82 pub fn title_alignment(mut self, alignment: Alignment) -> Self {
83 self.title_alignment = alignment;
84 self
85 }
86
87 #[must_use]
89 pub fn title_style(mut self, style: Style) -> Self {
90 self.title_style = style;
91 self
92 }
93
94 #[must_use]
96 pub fn subtitle(mut self, subtitle: &'a str) -> Self {
97 self.subtitle = Some(subtitle);
98 self
99 }
100
101 #[must_use]
103 pub fn subtitle_alignment(mut self, alignment: Alignment) -> Self {
104 self.subtitle_alignment = alignment;
105 self
106 }
107
108 #[must_use]
110 pub fn subtitle_style(mut self, style: Style) -> Self {
111 self.subtitle_style = style;
112 self
113 }
114
115 #[must_use]
117 pub fn style(mut self, style: Style) -> Self {
118 self.style = style;
119 self
120 }
121
122 #[must_use]
124 pub fn padding(mut self, padding: impl Into<Sides>) -> Self {
125 self.padding = padding.into();
126 self
127 }
128
129 pub fn inner(&self, area: Rect) -> Rect {
131 let mut inner = area;
132
133 if self.borders.contains(Borders::LEFT) {
134 inner.x = inner.x.saturating_add(1);
135 inner.width = inner.width.saturating_sub(1);
136 }
137 if self.borders.contains(Borders::TOP) {
138 inner.y = inner.y.saturating_add(1);
139 inner.height = inner.height.saturating_sub(1);
140 }
141 if self.borders.contains(Borders::RIGHT) {
142 inner.width = inner.width.saturating_sub(1);
143 }
144 if self.borders.contains(Borders::BOTTOM) {
145 inner.height = inner.height.saturating_sub(1);
146 }
147
148 inner
149 }
150
151 fn border_cell(&self, c: char) -> Cell {
152 let mut cell = Cell::from_char(c);
153 apply_style(&mut cell, self.border_style);
154 cell
155 }
156
157 fn pick_border_set(&self, buf: &Buffer) -> BorderSet {
158 let deg = buf.degradation;
159 if !deg.use_unicode_borders() {
160 return BorderSet::ASCII;
161 }
162 self.border_type.to_border_set()
163 }
164
165 fn render_borders(&self, area: Rect, buf: &mut Buffer, set: BorderSet) {
166 if area.is_empty() {
167 return;
168 }
169
170 if self.borders.contains(Borders::LEFT) {
172 for y in area.y..area.bottom() {
173 buf.set_fast(area.x, y, self.border_cell(set.vertical));
174 }
175 }
176 if self.borders.contains(Borders::RIGHT) {
177 let x = area.right() - 1;
178 for y in area.y..area.bottom() {
179 buf.set_fast(x, y, self.border_cell(set.vertical));
180 }
181 }
182 if self.borders.contains(Borders::TOP) {
183 for x in area.x..area.right() {
184 buf.set_fast(x, area.y, self.border_cell(set.horizontal));
185 }
186 }
187 if self.borders.contains(Borders::BOTTOM) {
188 let y = area.bottom().saturating_sub(1);
189 for x in area.x..area.right() {
190 buf.set_fast(x, y, self.border_cell(set.horizontal));
191 }
192 }
193
194 if self.borders.contains(Borders::LEFT | Borders::TOP) {
196 buf.set_fast(area.x, area.y, self.border_cell(set.top_left));
197 }
198 if self.borders.contains(Borders::RIGHT | Borders::TOP) {
199 buf.set_fast(area.right() - 1, area.y, self.border_cell(set.top_right));
200 }
201 if self.borders.contains(Borders::LEFT | Borders::BOTTOM) {
202 buf.set_fast(area.x, area.bottom() - 1, self.border_cell(set.bottom_left));
203 }
204 if self.borders.contains(Borders::RIGHT | Borders::BOTTOM) {
205 buf.set_fast(
206 area.right() - 1,
207 area.bottom() - 1,
208 self.border_cell(set.bottom_right),
209 );
210 }
211 }
212
213 fn ellipsize<'s>(&self, s: &'s str, max_width: usize) -> std::borrow::Cow<'s, str> {
214 let total = display_width(s);
215 if total <= max_width {
216 return std::borrow::Cow::Borrowed(s);
217 }
218 if max_width == 0 {
219 return std::borrow::Cow::Borrowed("");
220 }
221
222 if max_width == 1 {
224 return std::borrow::Cow::Borrowed("…");
225 }
226
227 let mut out = String::new();
228 let mut used = 0usize;
229 let target = max_width - 1;
230
231 for g in s.graphemes(true) {
232 let w = grapheme_width(g);
233 if w == 0 {
234 continue;
235 }
236 if used + w > target {
237 break;
238 }
239 out.push_str(g);
240 used += w;
241 }
242
243 out.push('…');
244 std::borrow::Cow::Owned(out)
245 }
246
247 fn render_top_text(
248 &self,
249 area: Rect,
250 frame: &mut Frame,
251 text: &str,
252 alignment: Alignment,
253 style: Style,
254 ) {
255 if area.width < 2 {
256 return;
257 }
258
259 let available_width = area.width.saturating_sub(2) as usize;
260 let text = self.ellipsize(text, available_width);
261 let text_width = display_width(text.as_ref()).min(available_width);
262
263 let x = match alignment {
264 Alignment::Left => area.x.saturating_add(1),
265 Alignment::Center => area
266 .x
267 .saturating_add(1)
268 .saturating_add(((available_width.saturating_sub(text_width)) / 2) as u16),
269 Alignment::Right => area
270 .right()
271 .saturating_sub(1)
272 .saturating_sub(text_width as u16),
273 };
274
275 let max_x = area.right().saturating_sub(1);
276 draw_text_span(frame, x, area.y, text.as_ref(), style, max_x);
277 }
278
279 fn render_bottom_text(
280 &self,
281 area: Rect,
282 frame: &mut Frame,
283 text: &str,
284 alignment: Alignment,
285 style: Style,
286 ) {
287 if area.height < 1 || area.width < 2 {
288 return;
289 }
290
291 let available_width = area.width.saturating_sub(2) as usize;
292 let text = self.ellipsize(text, available_width);
293 let text_width = display_width(text.as_ref()).min(available_width);
294
295 let x = match alignment {
296 Alignment::Left => area.x.saturating_add(1),
297 Alignment::Center => area
298 .x
299 .saturating_add(1)
300 .saturating_add(((available_width.saturating_sub(text_width)) / 2) as u16),
301 Alignment::Right => area
302 .right()
303 .saturating_sub(1)
304 .saturating_sub(text_width as u16),
305 };
306
307 let y = area.bottom().saturating_sub(1);
308 let max_x = area.right().saturating_sub(1);
309 draw_text_span(frame, x, y, text.as_ref(), style, max_x);
310 }
311}
312
313struct ScissorGuard<'a, 'pool> {
314 frame: &'a mut Frame<'pool>,
315}
316
317impl<'a, 'pool> ScissorGuard<'a, 'pool> {
318 fn new(frame: &'a mut Frame<'pool>, rect: Rect) -> Self {
319 frame.buffer.push_scissor(rect);
320 Self { frame }
321 }
322}
323
324impl Drop for ScissorGuard<'_, '_> {
325 fn drop(&mut self) {
326 self.frame.buffer.pop_scissor();
327 }
328}
329
330impl<W: Widget> Widget for Panel<'_, W> {
331 fn render(&self, area: Rect, frame: &mut Frame) {
332 #[cfg(feature = "tracing")]
333 let _span = tracing::debug_span!(
334 "widget_render",
335 widget = "Panel",
336 x = area.x,
337 y = area.y,
338 w = area.width,
339 h = area.height
340 )
341 .entered();
342
343 if area.is_empty() {
344 return;
345 }
346
347 let deg = frame.buffer.degradation;
348
349 if !deg.render_content() {
351 frame.buffer.fill(area, Cell::default());
352 return;
353 }
354
355 if deg.apply_styling() {
357 set_style_area(&mut frame.buffer, area, self.style);
358 }
359
360 if deg.render_decorative() {
362 let set = self.pick_border_set(&frame.buffer);
363 self.render_borders(area, &mut frame.buffer, set);
364
365 if self.borders.contains(Borders::TOP)
366 && let Some(title) = self.title
367 {
368 let title_style = if deg.apply_styling() {
369 self.title_style.merge(&self.border_style)
370 } else {
371 Style::default()
372 };
373 self.render_top_text(area, frame, title, self.title_alignment, title_style);
374 }
375
376 if self.borders.contains(Borders::BOTTOM)
377 && let Some(subtitle) = self.subtitle
378 {
379 let subtitle_style = if deg.apply_styling() {
380 self.subtitle_style.merge(&self.border_style)
381 } else {
382 Style::default()
383 };
384 self.render_bottom_text(
385 area,
386 frame,
387 subtitle,
388 self.subtitle_alignment,
389 subtitle_style,
390 );
391 }
392 }
393
394 let mut content_area = self.inner(area);
396 content_area = content_area.inner(self.padding);
397 if content_area.is_empty() {
398 return;
399 }
400
401 let guard = ScissorGuard::new(frame, content_area);
402 self.child.render(content_area, guard.frame);
403 }
404
405 fn is_essential(&self) -> bool {
406 self.child.is_essential()
407 }
408}
409
410#[cfg(test)]
411mod tests {
412 use super::*;
413 use ftui_render::frame::Frame;
414 use ftui_render::grapheme_pool::GraphemePool;
415
416 fn panel_stub() -> Panel<'static, crate::block::Block<'static>> {
417 Panel::new(crate::block::Block::default())
418 }
419
420 fn cell_char(frame: &Frame, x: u16, y: u16) -> Option<char> {
421 frame.buffer.get(x, y).and_then(|c| c.content.as_char())
422 }
423
424 #[test]
427 fn ellipsize_short_is_borrowed() {
428 let panel = panel_stub();
429 let out = panel.ellipsize("abc", 3);
430 assert!(matches!(out, std::borrow::Cow::Borrowed(_)));
431 assert_eq!(out, "abc");
432 }
433
434 #[test]
435 fn ellipsize_truncates_with_ellipsis() {
436 let panel = panel_stub();
437 let out = panel.ellipsize("abcdef", 4);
438 assert_eq!(out, "abc…");
439 }
440
441 #[test]
442 fn ellipsize_zero_width_returns_empty() {
443 let panel = panel_stub();
444 let out = panel.ellipsize("abc", 0);
445 assert_eq!(out, "");
446 }
447
448 #[test]
449 fn ellipsize_width_one_returns_ellipsis() {
450 let panel = panel_stub();
451 let out = panel.ellipsize("abc", 1);
452 assert_eq!(out, "…");
453 }
454
455 #[test]
456 fn ellipsize_exact_fit_is_borrowed() {
457 let panel = panel_stub();
458 let out = panel.ellipsize("hello", 5);
459 assert!(matches!(out, std::borrow::Cow::Borrowed(_)));
460 assert_eq!(out, "hello");
461 }
462
463 #[test]
464 fn ellipsize_one_over_truncates() {
465 let panel = panel_stub();
466 let out = panel.ellipsize("hello", 4);
467 assert_eq!(out, "hel…");
468 }
469
470 #[test]
473 fn inner_all_borders() {
474 let panel = panel_stub().borders(Borders::ALL);
475 let area = Rect::new(0, 0, 10, 10);
476 assert_eq!(panel.inner(area), Rect::new(1, 1, 8, 8));
477 }
478
479 #[test]
480 fn inner_no_borders() {
481 let panel = panel_stub().borders(Borders::NONE);
482 let area = Rect::new(0, 0, 10, 10);
483 assert_eq!(panel.inner(area), area);
484 }
485
486 #[test]
487 fn inner_top_and_left_only() {
488 let panel = panel_stub().borders(Borders::TOP | Borders::LEFT);
489 let area = Rect::new(0, 0, 10, 10);
490 assert_eq!(panel.inner(area), Rect::new(1, 1, 9, 9));
491 }
492
493 #[test]
494 fn inner_right_and_bottom_only() {
495 let panel = panel_stub().borders(Borders::RIGHT | Borders::BOTTOM);
496 let area = Rect::new(0, 0, 10, 10);
497 assert_eq!(panel.inner(area), Rect::new(0, 0, 9, 9));
498 }
499
500 #[test]
501 fn inner_with_offset_area() {
502 let panel = panel_stub().borders(Borders::ALL);
503 let area = Rect::new(5, 3, 10, 8);
504 assert_eq!(panel.inner(area), Rect::new(6, 4, 8, 6));
505 }
506
507 #[test]
508 fn inner_zero_size_saturates() {
509 let panel = panel_stub().borders(Borders::ALL);
510 let area = Rect::new(0, 0, 1, 1);
511 let inner = panel.inner(area);
512 assert_eq!(inner.width, 0);
513 assert_eq!(inner.height, 0);
514 }
515
516 #[test]
519 fn render_borders_square() {
520 let child = crate::block::Block::default();
521 let panel = Panel::new(child)
522 .borders(Borders::ALL)
523 .border_type(BorderType::Square);
524 let area = Rect::new(0, 0, 5, 3);
525 let mut pool = GraphemePool::new();
526 let mut frame = Frame::new(5, 3, &mut pool);
527
528 panel.render(area, &mut frame);
529
530 assert_eq!(cell_char(&frame, 0, 0), Some('┌'));
531 assert_eq!(cell_char(&frame, 4, 0), Some('┐'));
532 assert_eq!(cell_char(&frame, 0, 2), Some('└'));
533 assert_eq!(cell_char(&frame, 4, 2), Some('┘'));
534 assert_eq!(cell_char(&frame, 2, 0), Some('─'));
535 assert_eq!(cell_char(&frame, 0, 1), Some('│'));
536 }
537
538 #[test]
539 fn render_borders_rounded() {
540 let child = crate::block::Block::default();
541 let panel = Panel::new(child)
542 .borders(Borders::ALL)
543 .border_type(BorderType::Rounded);
544 let area = Rect::new(0, 0, 5, 3);
545 let mut pool = GraphemePool::new();
546 let mut frame = Frame::new(5, 3, &mut pool);
547
548 panel.render(area, &mut frame);
549
550 assert_eq!(cell_char(&frame, 0, 0), Some('╭'));
551 assert_eq!(cell_char(&frame, 4, 0), Some('╮'));
552 assert_eq!(cell_char(&frame, 0, 2), Some('╰'));
553 assert_eq!(cell_char(&frame, 4, 2), Some('╯'));
554 }
555
556 #[test]
557 fn render_empty_area_does_not_panic() {
558 let panel = panel_stub().borders(Borders::ALL);
559 let area = Rect::new(0, 0, 0, 0);
560 let mut pool = GraphemePool::new();
561 let mut frame = Frame::new(1, 1, &mut pool);
562 panel.render(area, &mut frame);
563 }
564
565 #[test]
568 fn render_title_left_aligned() {
569 let child = crate::block::Block::default();
570 let panel = Panel::new(child)
571 .borders(Borders::ALL)
572 .border_type(BorderType::Square)
573 .title("Hi")
574 .title_alignment(Alignment::Left);
575 let area = Rect::new(0, 0, 10, 3);
576 let mut pool = GraphemePool::new();
577 let mut frame = Frame::new(10, 3, &mut pool);
578
579 panel.render(area, &mut frame);
580
581 assert_eq!(cell_char(&frame, 1, 0), Some('H'));
583 assert_eq!(cell_char(&frame, 2, 0), Some('i'));
584 }
585
586 #[test]
587 fn render_title_right_aligned() {
588 let child = crate::block::Block::default();
589 let panel = Panel::new(child)
590 .borders(Borders::ALL)
591 .border_type(BorderType::Square)
592 .title("Hi")
593 .title_alignment(Alignment::Right);
594 let area = Rect::new(0, 0, 10, 3);
595 let mut pool = GraphemePool::new();
596 let mut frame = Frame::new(10, 3, &mut pool);
597
598 panel.render(area, &mut frame);
599
600 assert_eq!(cell_char(&frame, 7, 0), Some('H'));
603 assert_eq!(cell_char(&frame, 8, 0), Some('i'));
604 }
605
606 #[test]
607 fn render_title_center_aligned() {
608 let child = crate::block::Block::default();
609 let panel = Panel::new(child)
610 .borders(Borders::ALL)
611 .border_type(BorderType::Square)
612 .title("AB")
613 .title_alignment(Alignment::Center);
614 let area = Rect::new(0, 0, 10, 3);
615 let mut pool = GraphemePool::new();
616 let mut frame = Frame::new(10, 3, &mut pool);
617
618 panel.render(area, &mut frame);
619
620 assert_eq!(cell_char(&frame, 4, 0), Some('A'));
623 assert_eq!(cell_char(&frame, 5, 0), Some('B'));
624 }
625
626 #[test]
627 fn render_title_no_top_border_skips_title() {
628 let child = crate::block::Block::default();
629 let panel = Panel::new(child)
630 .borders(Borders::LEFT | Borders::RIGHT | Borders::BOTTOM)
631 .title("Hi");
632 let area = Rect::new(0, 0, 10, 3);
633 let mut pool = GraphemePool::new();
634 let mut frame = Frame::new(10, 3, &mut pool);
635
636 panel.render(area, &mut frame);
637
638 assert_ne!(cell_char(&frame, 1, 0), Some('H'));
640 }
641
642 #[test]
643 fn render_title_truncated_with_ellipsis() {
644 let child = crate::block::Block::default();
645 let panel = Panel::new(child)
646 .borders(Borders::ALL)
647 .border_type(BorderType::Square)
648 .title("LongTitle")
649 .title_alignment(Alignment::Left);
650 let area = Rect::new(0, 0, 6, 3);
652 let mut pool = GraphemePool::new();
653 let mut frame = Frame::new(6, 3, &mut pool);
654
655 panel.render(area, &mut frame);
656
657 assert_eq!(cell_char(&frame, 1, 0), Some('L'));
658 assert_eq!(cell_char(&frame, 2, 0), Some('o'));
659 assert_eq!(cell_char(&frame, 3, 0), Some('n'));
660 assert_eq!(cell_char(&frame, 4, 0), Some('…'));
661 }
662
663 #[test]
666 fn render_subtitle_left_aligned() {
667 let child = crate::block::Block::default();
668 let panel = Panel::new(child)
669 .borders(Borders::ALL)
670 .border_type(BorderType::Square)
671 .subtitle("Lo")
672 .subtitle_alignment(Alignment::Left);
673 let area = Rect::new(0, 0, 10, 3);
674 let mut pool = GraphemePool::new();
675 let mut frame = Frame::new(10, 3, &mut pool);
676
677 panel.render(area, &mut frame);
678
679 assert_eq!(cell_char(&frame, 1, 2), Some('L'));
681 assert_eq!(cell_char(&frame, 2, 2), Some('o'));
682 }
683
684 #[test]
685 fn render_subtitle_no_bottom_border_skips() {
686 let child = crate::block::Block::default();
687 let panel = Panel::new(child)
688 .borders(Borders::TOP | Borders::LEFT | Borders::RIGHT)
689 .subtitle("Lo");
690 let area = Rect::new(0, 0, 10, 3);
691 let mut pool = GraphemePool::new();
692 let mut frame = Frame::new(10, 3, &mut pool);
693
694 panel.render(area, &mut frame);
695
696 assert_ne!(cell_char(&frame, 1, 2), Some('L'));
698 }
699
700 #[test]
703 fn inner_with_padding_reduces_area() {
704 let panel = panel_stub().borders(Borders::ALL).padding(Sides::all(1));
705 let area = Rect::new(0, 0, 10, 10);
706 let inner_from_borders = panel.inner(area);
708 let padded = inner_from_borders.inner(Sides::all(1));
709 assert_eq!(padded, Rect::new(2, 2, 6, 6));
710 }
711
712 struct MarkerWidget;
716
717 impl Widget for MarkerWidget {
718 fn render(&self, area: Rect, frame: &mut Frame) {
719 if !area.is_empty() {
720 let mut cell = Cell::from_char('X');
721 apply_style(&mut cell, Style::default());
722 frame.buffer.set(area.x, area.y, cell);
723 }
724 }
725 }
726
727 #[test]
728 fn child_rendered_inside_borders() {
729 let panel = Panel::new(MarkerWidget).borders(Borders::ALL);
730 let area = Rect::new(0, 0, 5, 5);
731 let mut pool = GraphemePool::new();
732 let mut frame = Frame::new(5, 5, &mut pool);
733
734 panel.render(area, &mut frame);
735
736 assert_eq!(cell_char(&frame, 1, 1), Some('X'));
738 }
739
740 #[test]
741 fn child_rendered_with_padding_offset() {
742 let panel = Panel::new(MarkerWidget)
743 .borders(Borders::ALL)
744 .padding(Sides::new(1, 1, 0, 1));
745 let area = Rect::new(0, 0, 10, 10);
746 let mut pool = GraphemePool::new();
747 let mut frame = Frame::new(10, 10, &mut pool);
748
749 panel.render(area, &mut frame);
750
751 assert_eq!(cell_char(&frame, 2, 2), Some('X'));
753 }
754
755 #[test]
756 fn child_not_rendered_when_padding_consumes_all_space() {
757 let panel = Panel::new(MarkerWidget)
758 .borders(Borders::ALL)
759 .padding(Sides::all(10));
760 let area = Rect::new(0, 0, 5, 5);
761 let mut pool = GraphemePool::new();
762 let mut frame = Frame::new(5, 5, &mut pool);
763
764 panel.render(area, &mut frame);
766 }
767
768 #[test]
771 fn builder_chain_compiles() {
772 let _panel = Panel::new(crate::block::Block::default())
773 .borders(Borders::ALL)
774 .border_type(BorderType::Double)
775 .border_style(Style::new().bold())
776 .title("Title")
777 .title_alignment(Alignment::Center)
778 .title_style(Style::new().italic())
779 .subtitle("Sub")
780 .subtitle_alignment(Alignment::Right)
781 .subtitle_style(Style::new())
782 .style(Style::new())
783 .padding(Sides::all(1));
784 }
785}