Skip to main content

presentar_terminal/widgets/
border.rs

1//! Box/Border widget for framing content.
2//!
3//! Provides Unicode box-drawing borders around content areas.
4
5use presentar_core::{
6    Brick, BrickAssertion, BrickBudget, BrickVerification, Canvas, Color, Constraints, Event,
7    LayoutResult, Point, Rect, Size, TextStyle, TypeId, Widget,
8};
9use std::any::Any;
10use std::time::Duration;
11
12/// Border style using Unicode box-drawing characters.
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
14pub enum BorderStyle {
15    /// Single line: ┌─┐│└─┘
16    #[default]
17    Single,
18    /// Double line: ╔═╗║╚═╝
19    Double,
20    /// Rounded corners: ╭─╮│╰─╯
21    Rounded,
22    /// Heavy/thick: ┏━┓┃┗━┛
23    Heavy,
24    /// ASCII only: +-+|+-+
25    Ascii,
26    /// No border
27    None,
28}
29
30impl BorderStyle {
31    /// Get border characters: (`top_left`, top, `top_right`, left, right, `bottom_left`, bottom, `bottom_right`)
32    #[must_use]
33    pub const fn chars(&self) -> (char, char, char, char, char, char, char, char) {
34        match self {
35            Self::Single => ('┌', '─', '┐', '│', '│', '└', '─', '┘'),
36            Self::Double => ('╔', '═', '╗', '║', '║', '╚', '═', '╝'),
37            Self::Rounded => ('╭', '─', '╮', '│', '│', '╰', '─', '╯'),
38            Self::Heavy => ('┏', '━', '┓', '┃', '┃', '┗', '━', '┛'),
39            Self::Ascii => ('+', '-', '+', '|', '|', '+', '-', '+'),
40            Self::None => (' ', ' ', ' ', ' ', ' ', ' ', ' ', ' '),
41        }
42    }
43}
44
45/// A bordered box widget.
46pub struct Border {
47    /// Title displayed at top.
48    title: Option<String>,
49    /// Border style.
50    style: BorderStyle,
51    /// Border color.
52    color: Color,
53    /// Title color.
54    title_color: Color,
55    /// Fill background.
56    fill: bool,
57    /// Background color (if fill is true).
58    background: Color,
59    /// Cached bounds.
60    bounds: Rect,
61    /// Left-align title (ttop style) instead of centering.
62    title_left_aligned: bool,
63    /// Child widget (for composition).
64    child: Option<Box<dyn Widget>>,
65}
66
67impl Default for Border {
68    fn default() -> Self {
69        Self::new()
70    }
71}
72
73impl Border {
74    /// Create a new border.
75    #[must_use]
76    pub fn new() -> Self {
77        Self {
78            title: None,
79            style: BorderStyle::default(),
80            color: Color::new(0.4, 0.5, 0.6, 1.0),
81            title_color: Color::new(0.8, 0.9, 1.0, 1.0),
82            fill: false,
83            background: Color::new(0.1, 0.1, 0.1, 1.0),
84            bounds: Rect::default(),
85            title_left_aligned: false,
86            child: None,
87        }
88    }
89
90    /// Create a border with rounded corners and a title.
91    #[must_use]
92    pub fn rounded(title: impl Into<String>) -> Self {
93        Self::new()
94            .with_style(BorderStyle::Rounded)
95            .with_title(title)
96            .with_title_left_aligned()
97    }
98
99    /// Set a child widget to render inside the border.
100    #[must_use]
101    pub fn child(mut self, widget: impl Widget + 'static) -> Self {
102        self.child = Some(Box::new(widget));
103        self
104    }
105
106    /// Enable left-aligned title (ttop style) instead of centered.
107    #[must_use]
108    pub fn with_title_left_aligned(mut self) -> Self {
109        self.title_left_aligned = true;
110        self
111    }
112
113    /// Set the title.
114    #[must_use]
115    pub fn with_title(mut self, title: impl Into<String>) -> Self {
116        self.title = Some(title.into());
117        self
118    }
119
120    /// Set the border style.
121    #[must_use]
122    pub fn with_style(mut self, style: BorderStyle) -> Self {
123        self.style = style;
124        self
125    }
126
127    /// Set the border color.
128    #[must_use]
129    pub fn with_color(mut self, color: Color) -> Self {
130        self.color = color;
131        self
132    }
133
134    /// Set the title color.
135    #[must_use]
136    pub fn with_title_color(mut self, color: Color) -> Self {
137        self.title_color = color;
138        self
139    }
140
141    /// Enable background fill.
142    #[must_use]
143    pub fn with_fill(mut self, fill: bool) -> Self {
144        self.fill = fill;
145        self
146    }
147
148    /// Set the background color.
149    #[must_use]
150    pub fn with_background(mut self, color: Color) -> Self {
151        self.background = color;
152        self
153    }
154
155    /// Get the inner content area (excluding border).
156    #[must_use]
157    pub fn inner_rect(&self) -> Rect {
158        if matches!(self.style, BorderStyle::None) {
159            self.bounds
160        } else {
161            Rect::new(
162                self.bounds.x + 1.0,
163                self.bounds.y + 1.0,
164                (self.bounds.width - 2.0).max(0.0),
165                (self.bounds.height - 2.0).max(0.0),
166            )
167        }
168    }
169
170    /// Smart truncation: prefer section boundaries (│) to avoid mid-word cuts.
171    fn truncate_title_smart(title: &str, max_len: usize) -> std::borrow::Cow<'_, str> {
172        let title_len = title.chars().count();
173        if title_len <= max_len {
174            return std::borrow::Cow::Borrowed(title);
175        }
176        let truncate_to = max_len.saturating_sub(1);
177        let chars_vec: Vec<char> = title.chars().collect();
178        // Find section boundaries (│)
179        let mut section_ends: Vec<usize> = vec![0];
180        for (i, &ch) in chars_vec.iter().enumerate() {
181            if ch == '│' {
182                let mut end = i;
183                while end > 0 && chars_vec[end - 1] == ' ' {
184                    end -= 1;
185                }
186                if end > 0 {
187                    section_ends.push(end);
188                }
189            }
190        }
191        // Find best split point
192        let mut best_split = truncate_to;
193        for &end in section_ends.iter().rev() {
194            if end <= truncate_to && end > 0 {
195                best_split = end;
196                break;
197            }
198        }
199        // Fall back to word boundary
200        if best_split == truncate_to || best_split == 0 {
201            let search_start = truncate_to.saturating_sub(truncate_to / 3);
202            for i in (search_start..truncate_to).rev() {
203                if i < chars_vec.len() && chars_vec[i] == ' ' {
204                    best_split = i;
205                    break;
206                }
207            }
208        }
209        let truncated: String = chars_vec.iter().take(best_split).collect();
210        std::borrow::Cow::Owned(format!("{}…", truncated.trim_end()))
211    }
212
213    /// Draw top border with optional title.
214    fn draw_top_border(
215        &self,
216        canvas: &mut dyn Canvas,
217        width: usize,
218        chars: (char, char, char, char, char, char, char, char),
219        style: &TextStyle,
220    ) {
221        let (tl, top, tr, _, _, _, _, _) = chars;
222        let mut top_line = String::with_capacity(width);
223        top_line.push(tl);
224
225        if let Some(ref title) = self.title {
226            let ttop_available = width.saturating_sub(3);
227            let display_title = Self::truncate_title_smart(title, ttop_available);
228            let display_len = display_title.chars().count();
229            if display_len > 0 && ttop_available > 0 {
230                let title_style = TextStyle {
231                    color: self.title_color,
232                    ..Default::default()
233                };
234                if self.title_left_aligned {
235                    self.draw_left_aligned_title(
236                        canvas,
237                        &display_title,
238                        display_len,
239                        width,
240                        top,
241                        tr,
242                        style,
243                        &title_style,
244                    );
245                } else {
246                    self.draw_centered_title(
247                        canvas,
248                        &display_title,
249                        display_len,
250                        width,
251                        top,
252                        tr,
253                        style,
254                        &title_style,
255                    );
256                }
257                return;
258            }
259        }
260        // No title or too small
261        for _ in 0..(width - 2) {
262            top_line.push(top);
263        }
264        top_line.push(tr);
265        canvas.draw_text(&top_line, Point::new(self.bounds.x, self.bounds.y), style);
266    }
267
268    /// Draw left-aligned title.
269    #[allow(clippy::too_many_arguments)]
270    fn draw_left_aligned_title(
271        &self,
272        canvas: &mut dyn Canvas,
273        title: &str,
274        title_len: usize,
275        width: usize,
276        top: char,
277        tr: char,
278        style: &TextStyle,
279        title_style: &TextStyle,
280    ) {
281        let (tl, _, _, _, _, _, _, _) = self.style.chars();
282        canvas.draw_text(
283            &tl.to_string(),
284            Point::new(self.bounds.x, self.bounds.y),
285            style,
286        );
287        canvas.draw_text(
288            &format!(" {title}"),
289            Point::new(self.bounds.x + 1.0, self.bounds.y),
290            title_style,
291        );
292        let after_title = 1 + title_len + 1;
293        let remaining = width.saturating_sub(after_title + 1);
294        let mut rest = String::new();
295        for _ in 0..remaining {
296            rest.push(top);
297        }
298        rest.push(tr);
299        canvas.draw_text(
300            &rest,
301            Point::new(self.bounds.x + after_title as f32, self.bounds.y),
302            style,
303        );
304    }
305
306    /// Draw centered title.
307    #[allow(clippy::too_many_arguments)]
308    fn draw_centered_title(
309        &self,
310        canvas: &mut dyn Canvas,
311        title: &str,
312        title_len: usize,
313        width: usize,
314        top: char,
315        tr: char,
316        style: &TextStyle,
317        title_style: &TextStyle,
318    ) {
319        let (tl, _, _, _, _, _, _, _) = self.style.chars();
320        let available = width.saturating_sub(4);
321        let padding = (available.saturating_sub(title_len)) / 2;
322        let mut top_line = String::new();
323        top_line.push(tl);
324        for _ in 0..padding {
325            top_line.push(top);
326        }
327        canvas.draw_text(&top_line, Point::new(self.bounds.x, self.bounds.y), style);
328        canvas.draw_text(
329            &format!(" {title} "),
330            Point::new(self.bounds.x + 1.0 + padding as f32, self.bounds.y),
331            title_style,
332        );
333        let after_title = padding + title_len + 2;
334        let remaining = width.saturating_sub(after_title + 1);
335        let mut rest = String::new();
336        for _ in 0..remaining {
337            rest.push(top);
338        }
339        rest.push(tr);
340        canvas.draw_text(
341            &rest,
342            Point::new(self.bounds.x + after_title as f32, self.bounds.y),
343            style,
344        );
345    }
346}
347
348impl Brick for Border {
349    fn brick_name(&self) -> &'static str {
350        "border"
351    }
352
353    fn assertions(&self) -> &[BrickAssertion] {
354        static ASSERTIONS: &[BrickAssertion] = &[BrickAssertion::max_latency_ms(16)];
355        ASSERTIONS
356    }
357
358    fn budget(&self) -> BrickBudget {
359        BrickBudget::uniform(16)
360    }
361
362    fn verify(&self) -> BrickVerification {
363        BrickVerification {
364            passed: self.assertions().to_vec(),
365            failed: vec![],
366            verification_time: Duration::from_micros(10),
367        }
368    }
369
370    fn to_html(&self) -> String {
371        String::new()
372    }
373
374    fn to_css(&self) -> String {
375        String::new()
376    }
377}
378
379impl Widget for Border {
380    fn type_id(&self) -> TypeId {
381        TypeId::of::<Self>()
382    }
383
384    fn measure(&self, constraints: Constraints) -> Size {
385        constraints.constrain(Size::new(
386            constraints.max_width.min(20.0),
387            constraints.max_height.min(5.0),
388        ))
389    }
390
391    fn layout(&mut self, bounds: Rect) -> LayoutResult {
392        self.bounds = bounds;
393
394        // Layout child in inner rect (calculate inner rect first to avoid borrow conflict)
395        let inner = self.inner_rect();
396        if let Some(ref mut child) = self.child {
397            child.layout(inner);
398        }
399
400        LayoutResult {
401            size: Size::new(bounds.width, bounds.height),
402        }
403    }
404
405    fn paint(&self, canvas: &mut dyn Canvas) {
406        let width = self.bounds.width as usize;
407        let height = self.bounds.height as usize;
408        if width < 2 || height < 2 {
409            return;
410        }
411
412        // Fill background if enabled
413        if self.fill {
414            canvas.fill_rect(self.bounds, self.background);
415        }
416        if matches!(self.style, BorderStyle::None) {
417            return;
418        }
419
420        let chars = self.style.chars();
421        let (_, _, _, left, right, bl, bottom, br) = chars;
422        let style = TextStyle {
423            color: self.color,
424            ..Default::default()
425        };
426
427        // Top border with title
428        self.draw_top_border(canvas, width, chars, &style);
429
430        // Side borders
431        for y in 1..(height - 1) {
432            canvas.draw_text(
433                &left.to_string(),
434                Point::new(self.bounds.x, self.bounds.y + y as f32),
435                &style,
436            );
437            canvas.draw_text(
438                &right.to_string(),
439                Point::new(self.bounds.x + (width - 1) as f32, self.bounds.y + y as f32),
440                &style,
441            );
442        }
443
444        // Bottom border
445        let mut bottom_line = String::with_capacity(width);
446        bottom_line.push(bl);
447        for _ in 0..(width - 2) {
448            bottom_line.push(bottom);
449        }
450        bottom_line.push(br);
451        canvas.draw_text(
452            &bottom_line,
453            Point::new(self.bounds.x, self.bounds.y + (height - 1) as f32),
454            &style,
455        );
456
457        // Paint child widget
458        if let Some(ref child) = self.child {
459            let inner = self.inner_rect();
460            canvas.push_clip(inner);
461            child.paint(canvas);
462            canvas.pop_clip();
463        }
464    }
465
466    fn event(&mut self, event: &Event) -> Option<Box<dyn Any + Send>> {
467        // Propagate events to child
468        if let Some(ref mut child) = self.child {
469            if let Some(result) = child.event(event) {
470                return Some(result);
471            }
472        }
473        None
474    }
475
476    fn children(&self) -> &[Box<dyn Widget>] {
477        // Can't return reference to Option contents
478        &[]
479    }
480
481    fn children_mut(&mut self) -> &mut [Box<dyn Widget>] {
482        &mut []
483    }
484}
485
486#[cfg(test)]
487mod tests {
488    use super::*;
489
490    struct MockCanvas {
491        texts: Vec<(String, Point)>,
492        rects: Vec<Rect>,
493    }
494
495    impl MockCanvas {
496        fn new() -> Self {
497            Self {
498                texts: vec![],
499                rects: vec![],
500            }
501        }
502    }
503
504    impl Canvas for MockCanvas {
505        fn fill_rect(&mut self, rect: Rect, _color: Color) {
506            self.rects.push(rect);
507        }
508        fn stroke_rect(&mut self, _rect: Rect, _color: Color, _width: f32) {}
509        fn draw_text(&mut self, text: &str, position: Point, _style: &TextStyle) {
510            self.texts.push((text.to_string(), position));
511        }
512        fn draw_line(&mut self, _from: Point, _to: Point, _color: Color, _width: f32) {}
513        fn fill_circle(&mut self, _center: Point, _radius: f32, _color: Color) {}
514        fn stroke_circle(&mut self, _center: Point, _radius: f32, _color: Color, _width: f32) {}
515        fn fill_arc(&mut self, _c: Point, _r: f32, _s: f32, _e: f32, _color: Color) {}
516        fn draw_path(&mut self, _points: &[Point], _color: Color, _width: f32) {}
517        fn fill_polygon(&mut self, _points: &[Point], _color: Color) {}
518        fn push_clip(&mut self, _rect: Rect) {}
519        fn pop_clip(&mut self) {}
520        fn push_transform(&mut self, _transform: presentar_core::Transform2D) {}
521        fn pop_transform(&mut self) {}
522    }
523
524    #[test]
525    fn test_border_creation() {
526        let border = Border::new();
527        assert!(border.title.is_none());
528        assert_eq!(border.style, BorderStyle::Single);
529    }
530
531    #[test]
532    fn test_border_with_title() {
533        let border = Border::new().with_title("Test");
534        assert_eq!(border.title, Some("Test".to_string()));
535    }
536
537    #[test]
538    fn test_border_with_style() {
539        let border = Border::new().with_style(BorderStyle::Double);
540        assert_eq!(border.style, BorderStyle::Double);
541    }
542
543    #[test]
544    fn test_border_with_color() {
545        let border = Border::new().with_color(Color::RED);
546        assert_eq!(border.color, Color::RED);
547    }
548
549    #[test]
550    fn test_border_with_fill() {
551        let border = Border::new().with_fill(true);
552        assert!(border.fill);
553    }
554
555    #[test]
556    fn test_border_style_chars() {
557        let (tl, _, tr, _, _, bl, _, br) = BorderStyle::Single.chars();
558        assert_eq!(tl, '┌');
559        assert_eq!(tr, '┐');
560        assert_eq!(bl, '└');
561        assert_eq!(br, '┘');
562    }
563
564    #[test]
565    fn test_border_style_rounded() {
566        let (tl, _, tr, _, _, bl, _, br) = BorderStyle::Rounded.chars();
567        assert_eq!(tl, '╭');
568        assert_eq!(tr, '╮');
569        assert_eq!(bl, '╰');
570        assert_eq!(br, '╯');
571    }
572
573    #[test]
574    fn test_border_style_double() {
575        let (tl, _, _, _, _, _, _, _) = BorderStyle::Double.chars();
576        assert_eq!(tl, '╔');
577    }
578
579    #[test]
580    fn test_border_style_heavy() {
581        let (tl, _, _, _, _, _, _, _) = BorderStyle::Heavy.chars();
582        assert_eq!(tl, '┏');
583    }
584
585    #[test]
586    fn test_border_style_ascii() {
587        let (tl, _, _, _, _, _, _, _) = BorderStyle::Ascii.chars();
588        assert_eq!(tl, '+');
589    }
590
591    #[test]
592    fn test_border_inner_rect() {
593        let mut border = Border::new();
594        border.bounds = Rect::new(0.0, 0.0, 20.0, 10.0);
595        let inner = border.inner_rect();
596        assert_eq!(inner.x, 1.0);
597        assert_eq!(inner.y, 1.0);
598        assert_eq!(inner.width, 18.0);
599        assert_eq!(inner.height, 8.0);
600    }
601
602    #[test]
603    fn test_border_inner_rect_no_border() {
604        let mut border = Border::new().with_style(BorderStyle::None);
605        border.bounds = Rect::new(5.0, 5.0, 20.0, 10.0);
606        let inner = border.inner_rect();
607        assert_eq!(inner, border.bounds);
608    }
609
610    #[test]
611    fn test_border_paint() {
612        let mut border = Border::new();
613        border.bounds = Rect::new(0.0, 0.0, 10.0, 5.0);
614        let mut canvas = MockCanvas::new();
615        border.paint(&mut canvas);
616        assert!(!canvas.texts.is_empty());
617    }
618
619    #[test]
620    fn test_border_paint_with_title() {
621        let mut border = Border::new().with_title("CPU");
622        border.bounds = Rect::new(0.0, 0.0, 20.0, 5.0);
623        let mut canvas = MockCanvas::new();
624        border.paint(&mut canvas);
625        assert!(canvas.texts.iter().any(|(t, _)| t.contains("CPU")));
626    }
627
628    #[test]
629    fn test_border_paint_with_fill() {
630        let mut border = Border::new().with_fill(true);
631        border.bounds = Rect::new(0.0, 0.0, 10.0, 5.0);
632        let mut canvas = MockCanvas::new();
633        border.paint(&mut canvas);
634        assert!(!canvas.rects.is_empty());
635    }
636
637    #[test]
638    fn test_border_paint_no_style() {
639        let mut border = Border::new().with_style(BorderStyle::None);
640        border.bounds = Rect::new(0.0, 0.0, 10.0, 5.0);
641        let mut canvas = MockCanvas::new();
642        border.paint(&mut canvas);
643        // Should not draw border characters
644    }
645
646    #[test]
647    fn test_border_paint_small() {
648        let mut border = Border::new();
649        border.bounds = Rect::new(0.0, 0.0, 1.0, 1.0);
650        let mut canvas = MockCanvas::new();
651        border.paint(&mut canvas);
652        // Should early return for small bounds
653        assert!(canvas.texts.is_empty());
654    }
655
656    #[test]
657    fn test_border_assertions() {
658        let border = Border::new();
659        assert!(!border.assertions().is_empty());
660    }
661
662    #[test]
663    fn test_border_verify() {
664        let border = Border::new();
665        assert!(border.verify().is_valid());
666    }
667
668    #[test]
669    fn test_border_brick_name() {
670        let border = Border::new();
671        assert_eq!(border.brick_name(), "border");
672    }
673
674    #[test]
675    fn test_border_type_id() {
676        let border = Border::new();
677        assert_eq!(Widget::type_id(&border), TypeId::of::<Border>());
678    }
679
680    #[test]
681    fn test_border_measure() {
682        let border = Border::new();
683        let size = border.measure(Constraints::loose(Size::new(100.0, 100.0)));
684        assert!(size.width > 0.0);
685        assert!(size.height > 0.0);
686    }
687
688    #[test]
689    fn test_border_layout() {
690        let mut border = Border::new();
691        let bounds = Rect::new(5.0, 10.0, 30.0, 15.0);
692        let result = border.layout(bounds);
693        assert_eq!(result.size.width, 30.0);
694        assert_eq!(border.bounds, bounds);
695    }
696
697    #[test]
698    fn test_border_children() {
699        let border = Border::new();
700        assert!(border.children().is_empty());
701    }
702
703    #[test]
704    fn test_border_children_mut() {
705        let mut border = Border::new();
706        assert!(border.children_mut().is_empty());
707    }
708
709    #[test]
710    fn test_border_event() {
711        let mut border = Border::new();
712        let event = Event::KeyDown {
713            key: presentar_core::Key::Enter,
714        };
715        assert!(border.event(&event).is_none());
716    }
717
718    #[test]
719    fn test_border_default() {
720        let border = Border::default();
721        assert!(border.title.is_none());
722    }
723
724    #[test]
725    fn test_border_to_html() {
726        let border = Border::new();
727        assert!(border.to_html().is_empty());
728    }
729
730    #[test]
731    fn test_border_to_css() {
732        let border = Border::new();
733        assert!(border.to_css().is_empty());
734    }
735
736    #[test]
737    fn test_border_budget() {
738        let border = Border::new();
739        let budget = border.budget();
740        assert!(budget.paint_ms > 0);
741    }
742
743    #[test]
744    fn test_border_title_too_long() {
745        let mut border = Border::new().with_title("This is a very long title that won't fit");
746        border.bounds = Rect::new(0.0, 0.0, 10.0, 5.0);
747        let mut canvas = MockCanvas::new();
748        border.paint(&mut canvas);
749        // Should draw plain border without title
750        assert!(!canvas.texts.is_empty());
751    }
752
753    #[test]
754    fn test_border_with_title_color() {
755        let border = Border::new().with_title_color(Color::GREEN);
756        assert_eq!(border.title_color, Color::GREEN);
757    }
758
759    #[test]
760    fn test_border_with_background() {
761        let border = Border::new().with_background(Color::BLUE);
762        assert_eq!(border.background, Color::BLUE);
763    }
764
765    #[test]
766    fn test_border_rounded_helper() {
767        let border = Border::rounded("CPU Panel");
768        assert_eq!(border.style, BorderStyle::Rounded);
769        assert_eq!(border.title, Some("CPU Panel".to_string()));
770        assert!(border.title_left_aligned);
771    }
772
773    #[test]
774    fn test_border_style_none() {
775        let (tl, top, tr, left, right, bl, bottom, br) = BorderStyle::None.chars();
776        assert_eq!(tl, ' ');
777        assert_eq!(top, ' ');
778        assert_eq!(tr, ' ');
779        assert_eq!(left, ' ');
780        assert_eq!(right, ' ');
781        assert_eq!(bl, ' ');
782        assert_eq!(bottom, ' ');
783        assert_eq!(br, ' ');
784    }
785
786    #[test]
787    fn test_border_style_default() {
788        let style = BorderStyle::default();
789        assert_eq!(style, BorderStyle::Single);
790    }
791
792    #[test]
793    fn test_border_paint_with_left_aligned_title() {
794        let mut border = Border::new().with_title("CPU").with_title_left_aligned();
795        border.bounds = Rect::new(0.0, 0.0, 40.0, 5.0);
796        let mut canvas = MockCanvas::new();
797        border.paint(&mut canvas);
798        assert!(canvas.texts.iter().any(|(t, _)| t.contains("CPU")));
799    }
800
801    #[test]
802    fn test_border_paint_centered_title() {
803        let mut border = Border::new().with_title("Memory");
804        // Not left aligned (default)
805        assert!(!border.title_left_aligned);
806        border.bounds = Rect::new(0.0, 0.0, 50.0, 5.0);
807        let mut canvas = MockCanvas::new();
808        border.paint(&mut canvas);
809        assert!(canvas.texts.iter().any(|(t, _)| t.contains("Memory")));
810    }
811
812    #[test]
813    fn test_border_paint_all_styles() {
814        for style in [
815            BorderStyle::Single,
816            BorderStyle::Double,
817            BorderStyle::Rounded,
818            BorderStyle::Heavy,
819            BorderStyle::Ascii,
820        ] {
821            let mut border = Border::new().with_style(style);
822            border.bounds = Rect::new(0.0, 0.0, 20.0, 5.0);
823            let mut canvas = MockCanvas::new();
824            border.paint(&mut canvas);
825            assert!(!canvas.texts.is_empty());
826        }
827    }
828
829    #[test]
830    fn test_border_paint_with_fill_and_title() {
831        let mut border = Border::new()
832            .with_title("Test")
833            .with_fill(true)
834            .with_background(Color::new(0.1, 0.1, 0.1, 1.0));
835        border.bounds = Rect::new(0.0, 0.0, 30.0, 10.0);
836        let mut canvas = MockCanvas::new();
837        border.paint(&mut canvas);
838        assert!(!canvas.texts.is_empty());
839        assert!(!canvas.rects.is_empty());
840    }
841
842    #[test]
843    fn test_border_title_truncation() {
844        // Very long title that needs truncation
845        let mut border = Border::new().with_title(
846            "This is a very long title that will need to be truncated | section2 | section3",
847        );
848        border.bounds = Rect::new(0.0, 0.0, 30.0, 5.0);
849        let mut canvas = MockCanvas::new();
850        border.paint(&mut canvas);
851        // Should handle truncation gracefully
852        assert!(!canvas.texts.is_empty());
853    }
854
855    #[test]
856    fn test_border_title_with_sections() {
857        // Title with section separators
858        let mut border = Border::new().with_title("CPU 45% │ 8 cores │ 3.6GHz");
859        border.bounds = Rect::new(0.0, 0.0, 40.0, 5.0);
860        let mut canvas = MockCanvas::new();
861        border.paint(&mut canvas);
862        assert!(!canvas.texts.is_empty());
863    }
864
865    #[test]
866    fn test_border_inner_rect_minimum_size() {
867        let mut border = Border::new();
868        border.bounds = Rect::new(0.0, 0.0, 2.0, 2.0);
869        let inner = border.inner_rect();
870        // With 2x2 bounds and 1 pixel border, inner should be 0x0
871        assert_eq!(inner.width, 0.0);
872        assert_eq!(inner.height, 0.0);
873    }
874
875    #[test]
876    fn test_border_paint_narrow_width() {
877        let mut border = Border::new().with_title("Test");
878        border.bounds = Rect::new(0.0, 0.0, 5.0, 5.0);
879        let mut canvas = MockCanvas::new();
880        border.paint(&mut canvas);
881        // Should draw something even with narrow width
882    }
883
884    #[test]
885    fn test_border_all_chars_heavy() {
886        let (tl, top, tr, left, right, bl, bottom, br) = BorderStyle::Heavy.chars();
887        assert_eq!(tl, '┏');
888        assert_eq!(top, '━');
889        assert_eq!(tr, '┓');
890        assert_eq!(left, '┃');
891        assert_eq!(right, '┃');
892        assert_eq!(bl, '┗');
893        assert_eq!(bottom, '━');
894        assert_eq!(br, '┛');
895    }
896
897    #[test]
898    fn test_border_all_chars_double() {
899        let (tl, top, tr, left, right, bl, bottom, br) = BorderStyle::Double.chars();
900        assert_eq!(tl, '╔');
901        assert_eq!(top, '═');
902        assert_eq!(tr, '╗');
903        assert_eq!(left, '║');
904        assert_eq!(right, '║');
905        assert_eq!(bl, '╚');
906        assert_eq!(bottom, '═');
907        assert_eq!(br, '╝');
908    }
909
910    #[test]
911    fn test_border_with_child() {
912        use crate::widgets::Text;
913        let border = Border::new().child(Text::new("Hello"));
914        assert!(border.child.is_some());
915    }
916
917    #[test]
918    fn test_border_child_paint() {
919        use crate::widgets::Text;
920        let mut border = Border::new().child(Text::new("Hello"));
921        border.bounds = Rect::new(0.0, 0.0, 20.0, 10.0);
922        border.layout(border.bounds);
923        let mut canvas = MockCanvas::new();
924        border.paint(&mut canvas);
925        // Child should be painted inside border
926    }
927}