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