Skip to main content

photon_ui/components/
div.rs

1//! A flexible container with optional borders, padding, title, and background.
2//!
3//! `Div` is a general-purpose layout box. It accepts a `Layout` and renders
4//! its children into the layout's areas, then draws optional chrome around the
5//! result. Think of it as the TUI equivalent of an HTML `<div>`.
6//!
7//! # Example
8//!
9//! ```
10//! use photon_ui::{
11//!     components::Div,
12//!     layout::{
13//!         Constraint,
14//!         Layout,
15//!     },
16//! };
17//!
18//! let div = Div::new(Layout::vertical([
19//!     Constraint::Length(1),
20//!     Constraint::Min(3),
21//!     Constraint::Length(1),
22//! ]))
23//! .child(Box::new(photon_ui::components::Text::new("Header", 0, 0)))
24//! .child(Box::new(photon_ui::components::Text::new("Body", 0, 0)))
25//! .child(Box::new(photon_ui::components::Text::new("Footer", 0, 0)))
26//! .border(photon_ui::layout::Border::ROUNDED)
27//! .padding(photon_ui::layout::Margin::new(1, 1))
28//! .title("My Box");
29//! ```
30
31use crate::{
32    Component,
33    Event,
34    Focusable,
35    InputResult,
36    RenderError,
37    Rendered,
38    layout::{
39        Border,
40        Layout,
41        Margin,
42        Rect,
43    },
44    theme::{
45        Style,
46        Theme,
47    },
48};
49
50/// A general-purpose container with optional chrome.
51pub struct Div {
52    layout: Layout,
53    children: Vec<Box<dyn Component>>,
54    border: Option<Border>,
55    border_style: Style,
56    padding: Margin,
57    title: Option<String>,
58    title_style: Style,
59    background: Option<Style>,
60    focused: bool,
61    /// Which child receives keyboard input when this div is focused.
62    focused_child: Option<usize>,
63    /// Whether this div can be collapsed/expanded via keyboard.
64    collapsible: bool,
65    /// Whether this div is currently collapsed.
66    collapsed: bool,
67}
68
69impl Div {
70    /// Create a new `Div` with the given layout.
71    pub fn new(layout: Layout) -> Self {
72        Self {
73            layout,
74            children: Vec::new(),
75            border: None,
76            border_style: Style::new(),
77            padding: Margin::new(0, 0),
78            title: None,
79            title_style: Style::new(),
80            background: None,
81            focused: false,
82            focused_child: None,
83            collapsible: false,
84            collapsed: false,
85        }
86    }
87
88    /// Add a child component (builder style).
89    pub fn child(mut self, child: Box<dyn Component>) -> Self {
90        self.children.push(child);
91        self
92    }
93
94    /// Add a child component (imperative style).
95    pub fn push(&mut self, child: Box<dyn Component>) {
96        self.children.push(child);
97    }
98
99    /// Set the outer border.
100    pub fn border(mut self, border: Border) -> Self {
101        self.border = Some(border);
102        self
103    }
104
105    /// Style the outer border.
106    pub fn border_styled(mut self, style: Style) -> Self {
107        self.border_style = style;
108        self
109    }
110
111    /// Set inner padding.
112    pub fn padding(mut self, margin: Margin) -> Self {
113        self.padding = margin;
114        self
115    }
116
117    /// Set a title rendered in the top border.
118    pub fn title(mut self, title: impl Into<String>) -> Self {
119        self.title = Some(title.into());
120        self
121    }
122
123    /// Style the title.
124    pub fn title_styled(mut self, style: Style) -> Self {
125        self.title_style = style;
126        self
127    }
128
129    /// Fill the entire div area with a background style.
130    pub fn background(mut self, style: Style) -> Self {
131        self.background = Some(style);
132        self
133    }
134
135    /// Make this div collapsible via Enter/Space when focused.
136    pub fn collapsible(mut self, value: bool) -> Self {
137        self.collapsible = value;
138        self
139    }
140
141    /// Set the collapsed state (only meaningful when collapsible).
142    pub fn collapsed(mut self, value: bool) -> Self {
143        self.collapsed = value;
144        self
145    }
146
147    /// Toggle the collapsed state.
148    pub fn toggle_collapsed(&mut self) {
149        self.collapsed = !self.collapsed;
150    }
151
152    /// Compute the inner content rect after subtracting border and padding.
153    fn inner_rect(&self, rect: Rect) -> Rect {
154        let mut inner = rect;
155        if self.border.is_some() {
156            inner = inner.inner(Margin::new(1, 1));
157        }
158        inner = inner.inner(self.padding);
159        inner
160    }
161
162    /// Cycle focus to the next/previous focusable child.
163    ///
164    /// Returns `Handled` if focus moved within this div, or `Ignored` if the
165    /// cycle would move past the last/first child so the parent can handle it.
166    fn cycle_child_focus(&mut self, delta: isize) -> InputResult {
167        let focusable: Vec<usize> = self
168            .children
169            .iter()
170            .enumerate()
171            .filter(|(_, c)| c.as_focusable().is_some())
172            .map(|(i, _)| i)
173            .collect();
174
175        if focusable.is_empty() {
176            return InputResult::Ignored;
177        }
178
179        let current = match self
180            .focused_child
181            .and_then(|idx| focusable.iter().position(|&i| i == idx))
182        {
183            | Some(pos) => pos,
184            | None => {
185                self.focused_child = Some(focusable[0]);
186                if let Some(f) = self.children[focusable[0]].as_focusable_mut() {
187                    f.set_focused(true);
188                }
189                return InputResult::Handled;
190            },
191        };
192
193        // Try to cycle within the current child first (recursive descent).
194        let current_idx = focusable[current];
195        let tab_event = Event::Key(crossterm::event::KeyEvent::new(
196            if delta > 0 {
197                crossterm::event::KeyCode::Tab
198            } else {
199                crossterm::event::KeyCode::BackTab
200            },
201            crossterm::event::KeyModifiers::empty(),
202        ));
203        let child_result = self.children[current_idx].handle_input(&tab_event);
204        if child_result != InputResult::Ignored {
205            return InputResult::Handled;
206        }
207
208        // Current child couldn't cycle further, move to next/prev sibling.
209        if delta > 0 && current + 1 >= focusable.len() {
210            // Tab past last child — let parent handle it.
211            return InputResult::Ignored;
212        }
213        if delta < 0 && current == 0 {
214            // BackTab past first child — let parent handle it.
215            return InputResult::Ignored;
216        }
217
218        let new_pos = if delta >= 0 {
219            (current + delta as usize) % focusable.len()
220        } else {
221            let d = (-delta) as usize % focusable.len();
222            (current + focusable.len() - d) % focusable.len()
223        };
224        let new_idx = focusable[new_pos];
225
226        // Unfocus old child
227        if let Some(f) = self.children[current_idx].as_focusable_mut() {
228            f.set_focused(false);
229        }
230        // Focus new child
231        self.focused_child = Some(new_idx);
232        if let Some(f) = self.children[new_idx].as_focusable_mut() {
233            f.set_focused(true);
234        }
235        InputResult::Handled
236    }
237}
238
239impl Focusable for Div {
240    fn focused(&self) -> bool {
241        self.focused
242    }
243
244    fn set_focused(&mut self, focused: bool) {
245        self.focused = focused;
246        if focused && self.focused_child.is_none() {
247            // Auto-focus the first focusable child when this div gains focus.
248            self.focused_child = self
249                .children
250                .iter()
251                .position(|c| c.as_focusable().is_some());
252        }
253        // Propagate focus state ONLY to the focused child.
254        // Setting all children as focused breaks nested focus cycling
255        // (multiple leaf components would think they're focused).
256        if let Some(idx) = self.focused_child &&
257            let Some(f) = self.children[idx].as_focusable_mut()
258        {
259            f.set_focused(focused);
260        }
261    }
262}
263
264impl Component for Div {
265    fn render(&self, width: u16) -> Result<Rendered, RenderError> {
266        let height = self.children.len() as u16 * 3;
267        let rect = Rect::new(0, 0, width, height);
268        self.render_rect(rect)
269    }
270
271    fn render_rect(&self, rect: Rect) -> Result<Rendered, RenderError> {
272        let theme = Theme::palette();
273        let mut screen = Rendered::empty();
274
275        // ── Collapsed state: render only a single-line header ──
276        if self.collapsed {
277            let indicator = if self.collapsible { "▶ " } else { "" };
278            let title_text = self
279                .title
280                .as_ref()
281                .map(|t| format!("{}{}", indicator, t))
282                .unwrap_or_else(|| "▶".into());
283            let header_style = if self.focused {
284                Style::new().fg(theme.accent()).bold()
285            } else {
286                Style::new().fg(theme.text_muted())
287            };
288            let mut header = crate::theme::stylize(&title_text, &header_style);
289            header = crate::utils::truncate_to_width(&header, rect.width, "…");
290            let pad = rect.width as usize - crate::utils::visible_width(&header);
291            if pad > 0 {
292                header.push_str(&" ".repeat(pad));
293            }
294            screen.lines.push(header);
295            // Pad to requested height so parent layout isn't disrupted
296            while screen.lines.len() < rect.height as usize {
297                screen.lines.push(String::new());
298            }
299            return Ok(screen);
300        }
301
302        // Fill background if requested
303        if let Some(ref bg) = self.background {
304            let prefix = bg.prefix(crate::theme::ColorMode::detect());
305            let suffix = bg.suffix();
306            for _ in 0..rect.height {
307                let line = format!(
308                    "{}{:width$}{}",
309                    prefix,
310                    "",
311                    suffix,
312                    width = rect.width as usize
313                );
314                screen.lines.push(line);
315            }
316        }
317
318        // Compute inner rect for children
319        let inner = self.inner_rect(rect);
320
321        // Render children into the inner rect using the layout
322        let areas = self.layout.split(inner);
323        for (child, area) in self.children.iter().zip(areas.iter()) {
324            if let Ok(rendered) = child.render_rect(*area) {
325                // Blit into the local buffer using coordinates relative to this div's origin.
326                // `layout.split()` returns areas in terminal coordinates (they include
327                // rect.x/y), but `screen` is a fresh local buffer whose origin is (0, 0).
328                let rel_area = Rect::new(
329                    area.x.saturating_sub(rect.x),
330                    area.y.saturating_sub(rect.y),
331                    area.width,
332                    area.height,
333                );
334                rendered.blit_into_rect(&mut screen, rel_area);
335            }
336        }
337
338        // Ensure screen has enough lines for the full rect
339        while screen.lines.len() < rect.height as usize {
340            screen.lines.push(String::new());
341        }
342
343        // Draw border if requested
344        if let Some(ref border) = self.border {
345            let border_style = if self.border_style == Style::new() {
346                Style::new().fg(theme.border())
347            } else {
348                self.border_style
349            };
350            // Draw border at the edges of the local buffer, not at absolute coords.
351            crate::layout::draw_border(
352                &mut screen,
353                Rect::new(0, 0, rect.width, rect.height),
354                border,
355                &border_style,
356            );
357
358            // Draw title in the top border if set
359            if let Some(ref title) = self.title &&
360                !screen.lines.is_empty()
361            {
362                let title_style = if self.title_style == Style::new() {
363                    Style::new().fg(theme.text()).bold()
364                } else {
365                    self.title_style
366                };
367                let indicator = if self.collapsible { "▼ " } else { "" };
368                let label = format!(" {}{} ", indicator, title);
369                let label_styled = crate::theme::stylize(&label, &title_style);
370                let top = &mut screen.lines[0];
371                let start_byte = crate::utils::byte_index_at_visual_pos(top, 2);
372                let end_byte = crate::utils::byte_index_at_visual_pos(
373                    top,
374                    2 + crate::utils::visible_width(&label_styled),
375                );
376                if start_byte < top.len() {
377                    top.replace_range(start_byte..end_byte.min(top.len()), &label_styled);
378                }
379            }
380        }
381
382        Ok(screen)
383    }
384
385    fn handle_input(&mut self, event: &Event) -> InputResult {
386        use crossterm::event::KeyCode;
387
388        // Toggle collapsed state on Enter or Space when collapsible.
389        if self.collapsible &&
390            let Event::Key(key) = event &&
391            (key.code == KeyCode::Enter || key.code == KeyCode::Char(' '))
392        {
393            self.collapsed = !self.collapsed;
394            return InputResult::Handled;
395        }
396
397        // When collapsed, don't route input to children.
398        if self.collapsed {
399            return InputResult::Ignored;
400        }
401
402        // Handle Tab / BackTab to cycle focus among children.
403        if let Event::Key(key) = event {
404            if key.code == KeyCode::Tab {
405                return self.cycle_child_focus(1);
406            }
407            if key.code == KeyCode::BackTab {
408                return self.cycle_child_focus(-1);
409            }
410        }
411
412        // Route to the focused child first.
413        if let Some(idx) = self.focused_child &&
414            idx < self.children.len()
415        {
416            let result = self.children[idx].handle_input(event);
417            if result != InputResult::Ignored {
418                return result;
419            }
420        }
421
422        // Fall through to other children.
423        for (i, child) in self.children.iter_mut().enumerate() {
424            if Some(i) == self.focused_child {
425                continue;
426            }
427            let result = child.handle_input(event);
428            if result != InputResult::Ignored {
429                return result;
430            }
431        }
432        InputResult::Ignored
433    }
434
435    fn as_focusable(&self) -> Option<&dyn Focusable> {
436        Some(self)
437    }
438
439    fn as_focusable_mut(&mut self) -> Option<&mut dyn Focusable> {
440        Some(self)
441    }
442}
443
444#[cfg(test)]
445mod tests {
446    use super::*;
447    use crate::{
448        components::Text,
449        layout::Constraint,
450        theme::Theme,
451    };
452
453    #[test]
454    fn div_renders_children() {
455        Theme::with(Theme::Light, || {
456            let div = Div::new(Layout::vertical([
457                Constraint::Length(1),
458                Constraint::Length(1),
459            ]))
460            .child(Box::new(Text::new("top", 0, 0)))
461            .child(Box::new(Text::new("bottom", 0, 0)));
462
463            let rendered = div.render_rect(Rect::new(0, 0, 10, 2)).unwrap();
464            assert_eq!(rendered.lines.len(), 2);
465            assert!(rendered.lines[0].contains("top"));
466            assert!(rendered.lines[1].contains("bottom"));
467        });
468    }
469
470    #[test]
471    fn div_with_border() {
472        Theme::with(Theme::Light, || {
473            let div = Div::new(Layout::vertical([Constraint::Length(1)]))
474                .child(Box::new(Text::new("hi", 0, 0)))
475                .border(Border::ROUNDED);
476
477            let rendered = div.render_rect(Rect::new(0, 0, 6, 3)).unwrap();
478            assert!(rendered.lines[0].contains("╭"));
479            assert!(rendered.lines[2].contains("╰"));
480        });
481    }
482
483    #[test]
484    fn div_with_title() {
485        Theme::with(Theme::Light, || {
486            let div = Div::new(Layout::vertical([Constraint::Length(1)]))
487                .child(Box::new(Text::new("hi", 0, 0)))
488                .border(Border::ROUNDED)
489                .title("Box");
490
491            let rendered = div.render_rect(Rect::new(0, 0, 10, 3)).unwrap();
492            assert!(rendered.lines[0].contains("Box"));
493        });
494    }
495
496    #[test]
497    fn div_with_padding() {
498        Theme::with(Theme::Light, || {
499            let div = Div::new(Layout::vertical([Constraint::Length(1)]))
500                .child(Box::new(Text::new("hi", 0, 0)))
501                .padding(Margin::new(1, 1));
502
503            let rendered = div.render_rect(Rect::new(0, 0, 6, 3)).unwrap();
504            // Padding shifts content down by 1 row and in by 1 col
505            assert!(rendered.lines[1].contains("hi"));
506        });
507    }
508
509    #[test]
510    fn div_focus_propagation() {
511        Theme::with(Theme::Light, || {
512            let mut div = Div::new(Layout::vertical([Constraint::Length(1)])).child(Box::new(
513                crate::components::SelectList::new(vec!["a".into()], 1),
514            ));
515
516            div.set_focused(true);
517            assert!(div.focused());
518        });
519    }
520
521    /// Regression test: nested divs with non-zero rect coordinates must not
522    /// double-offset content.
523    #[test]
524    fn div_nonzero_rect_no_double_offset() {
525        Theme::with(Theme::Light, || {
526            let outer = Div::new(Layout::horizontal([
527                Constraint::Length(10),
528                Constraint::Length(10),
529            ]))
530            .child(Box::new(Text::new("left", 0, 0)))
531            .child(Box::new(
532                Div::new(Layout::vertical([
533                    Constraint::Length(1),
534                    Constraint::Length(1),
535                ]))
536                .child(Box::new(Text::new("a", 0, 0)))
537                .child(Box::new(Text::new("b", 0, 0))),
538            ));
539
540            // Outer rect starts at (0, 2). Inner div gets y = 2 from the layout.
541            let rendered = outer.render_rect(Rect::new(0, 2, 20, 2)).unwrap();
542            // The inner div's content should be at local rows 0 and 1,
543            // NOT shifted down by 2 due to double-offsetting.
544            assert_eq!(
545                rendered.lines.len(),
546                2,
547                "expected 2 lines, got {}",
548                rendered.lines.len()
549            );
550            assert!(rendered.lines[0].contains("left"));
551            assert!(rendered.lines[0].contains("a"));
552            assert!(rendered.lines[1].contains("b"));
553        });
554    }
555
556    /// Border must be drawn at the edges of the local buffer even when the
557    /// parent rect has non-zero coordinates.
558    #[test]
559    fn div_border_with_nonzero_rect() {
560        Theme::with(Theme::Light, || {
561            let div = Div::new(Layout::vertical([Constraint::Length(1)]))
562                .child(Box::new(Text::new("hi", 0, 0)))
563                .border(Border::ROUNDED);
564
565            let rendered = div.render_rect(Rect::new(0, 5, 6, 3)).unwrap();
566            assert!(
567                rendered.lines[0].contains("╭"),
568                "border should be at local row 0"
569            );
570            assert!(
571                rendered.lines[2].contains("╰"),
572                "border should be at local row 2"
573            );
574            // Note: draw_border() currently replaces the entire middle rows,
575            // so child content inside bordered divs is overwritten. This is a
576            // pre-existing issue unrelated to the nonzero-rect fix.
577        });
578    }
579
580    /// Regression: Tab must descend into nested Divs to reach leaf focusables.
581    /// When outer Div's focused_child is a nested Div, Tab should cycle within
582    /// that nested Div instead of immediately returning Ignored.
583    #[test]
584    fn div_tab_descends_into_nested_focusables() {
585        Theme::with(Theme::Light, || {
586            let mut inner = Div::new(Layout::vertical([
587                Constraint::Length(1),
588                Constraint::Length(1),
589            ]));
590            let input1 = crate::components::Input::new();
591            let input2 = crate::components::Input::new();
592            inner.push(Box::new(input1));
593            inner.push(Box::new(input2));
594
595            let mut outer = Div::new(Layout::vertical([Constraint::Length(2)]));
596            outer.push(Box::new(inner));
597
598            outer.set_focused(true);
599            assert_eq!(outer.focused_child, Some(0));
600
601            // With the old code, this Tab would return Ignored because outer
602            // has no next sibling. With the fix, it should descend into inner
603            // and cycle to its first focusable child.
604            let tab = crate::events::Event::Key(crossterm::event::KeyEvent::new(
605                crossterm::event::KeyCode::Tab,
606                crossterm::event::KeyModifiers::empty(),
607            ));
608            let result = outer.handle_input(&tab);
609            assert!(
610                matches!(result, crate::InputResult::Handled),
611                "Tab should descend into nested div and be handled"
612            );
613        });
614    }
615
616    /// Regression: Tab cycling must move between siblings inside nested Divs.
617    #[test]
618    fn div_tab_cycles_across_nested_siblings() {
619        Theme::with(Theme::Light, || {
620            let mut inner = Div::new(Layout::vertical([
621                Constraint::Length(1),
622                Constraint::Length(1),
623            ]));
624            let mut input1 = crate::components::Input::new();
625            let mut input2 = crate::components::Input::new();
626            input1.set_text("first");
627            input2.set_text("second");
628            inner.push(Box::new(input1));
629            inner.push(Box::new(input2));
630
631            let mut outer = Div::new(Layout::vertical([Constraint::Length(2)]));
632            outer.push(Box::new(inner));
633            outer.set_focused(true);
634
635            let tab = crate::events::Event::Key(crossterm::event::KeyEvent::new(
636                crossterm::event::KeyCode::Tab,
637                crossterm::event::KeyModifiers::empty(),
638            ));
639
640            // First Tab: descend into inner, cycle input1 → input2
641            let r1 = outer.handle_input(&tab);
642            assert!(matches!(r1, crate::InputResult::Handled));
643
644            // Second Tab: inner exhausted (input2 has no next sibling).
645            // Outer also has no next sibling → Ignored.
646            let r2 = outer.handle_input(&tab);
647            assert!(matches!(r2, crate::InputResult::Ignored));
648        });
649    }
650
651    #[test]
652    fn div_collapsible_renders_header_when_collapsed() {
653        Theme::with(Theme::Light, || {
654            let div = Div::new(Layout::vertical([Constraint::Length(1)]))
655                .border(Border::ROUNDED)
656                .title("Panel")
657                .collapsible(true)
658                .collapsed(true)
659                .child(Box::new(Text::new("hidden", 0, 0)));
660
661            let rendered = div.render_rect(Rect::new(0, 0, 20, 5)).unwrap();
662            assert_eq!(rendered.lines.len(), 5);
663            assert!(rendered.lines[0].contains("▶"));
664            assert!(rendered.lines[0].contains("Panel"));
665            // Child content should not be visible
666            assert!(!rendered.lines.iter().any(|l| l.contains("hidden")));
667        });
668    }
669
670    #[test]
671    fn div_collapsible_toggles_on_enter() {
672        Theme::with(Theme::Light, || {
673            let mut div = Div::new(Layout::vertical([Constraint::Length(1)]))
674                .border(Border::ROUNDED)
675                .title("Panel")
676                .collapsible(true)
677                .collapsed(true)
678                .child(Box::new(Text::new("content", 0, 0)));
679
680            let enter = crate::events::Event::Key(crossterm::event::KeyEvent::new(
681                crossterm::event::KeyCode::Enter,
682                crossterm::event::KeyModifiers::empty(),
683            ));
684
685            assert!(div.collapsed);
686            let result = div.handle_input(&enter);
687            assert!(matches!(result, crate::InputResult::Handled));
688            assert!(!div.collapsed);
689
690            // Second Enter should collapse again
691            div.handle_input(&enter);
692            assert!(div.collapsed);
693        });
694    }
695
696    #[test]
697    fn div_collapsible_ignores_child_input_when_collapsed() {
698        Theme::with(Theme::Light, || {
699            let mut div = Div::new(Layout::vertical([Constraint::Length(1)]))
700                .collapsible(true)
701                .collapsed(true)
702                .child(Box::new(crate::components::Input::new()));
703
704            let a_key = crate::events::Event::Key(crossterm::event::KeyEvent::new(
705                crossterm::event::KeyCode::Char('a'),
706                crossterm::event::KeyModifiers::empty(),
707            ));
708
709            // Should return Ignored because collapsed div doesn't route to children
710            let result = div.handle_input(&a_key);
711            assert!(matches!(result, crate::InputResult::Ignored));
712        });
713    }
714
715    #[test]
716    fn div_push_and_builders() {
717        let mut div = Div::new(Layout::vertical([Constraint::Length(1)]));
718        div.push(Box::new(Text::new("hi", 0, 0)));
719        let div = div
720            .border(Border::ROUNDED)
721            .border_styled(Style::new().bold())
722            .padding(Margin::new(1, 1))
723            .title("T")
724            .title_styled(Style::new().italic())
725            .background(Style::new().bg(crate::theme::Color::WHITE))
726            .collapsible(true)
727            .collapsed(true);
728        assert!(div.border.is_some());
729        assert!(div.background.is_some());
730        assert!(div.collapsed);
731    }
732
733    #[test]
734    fn div_toggle_collapsed() {
735        let mut div = Div::new(Layout::vertical([Constraint::Length(1)])).collapsed(true);
736        div.toggle_collapsed();
737        assert!(!div.collapsed);
738        div.toggle_collapsed();
739        assert!(div.collapsed);
740    }
741
742    #[test]
743    fn div_collapsed_non_collapsible_indicator() {
744        Theme::with(Theme::Light, || {
745            let div = Div::new(Layout::vertical([Constraint::Length(1)]))
746                .border(Border::ROUNDED)
747                .title("Panel")
748                .collapsed(true)
749                .child(Box::new(Text::new("hidden", 0, 0)));
750
751            let rendered = div.render_rect(Rect::new(0, 0, 20, 1)).unwrap();
752            assert!(rendered.lines[0].contains("Panel"));
753            assert!(!rendered.lines[0].contains("▶"));
754        });
755    }
756
757    #[test]
758    fn div_collapsed_unfocused_style() {
759        Theme::with(Theme::Light, || {
760            let div = Div::new(Layout::vertical([Constraint::Length(1)]))
761                .title("Panel")
762                .collapsible(true)
763                .collapsed(true)
764                .child(Box::new(Text::new("hidden", 0, 0)));
765
766            let rendered = div.render_rect(Rect::new(0, 0, 20, 1)).unwrap();
767            assert!(rendered.lines[0].contains("Panel"));
768        });
769    }
770
771    #[test]
772    fn div_background_fill() {
773        Theme::with(Theme::Light, || {
774            let div = Div::new(Layout::vertical([Constraint::Length(1)]))
775                .background(Style::new().bg(crate::theme::Color::WHITE))
776                .child(Box::new(Text::new("hi", 0, 0)));
777
778            let rendered = div.render_rect(Rect::new(0, 0, 5, 2)).unwrap();
779            assert!(rendered.lines.iter().all(|l| l.contains("\x1b[48")));
780        });
781    }
782
783    #[test]
784    fn div_border_with_custom_style_and_title() {
785        Theme::with(Theme::Light, || {
786            let div = Div::new(Layout::vertical([Constraint::Length(1)]))
787                .border(Border::ROUNDED)
788                .border_styled(Style::new().fg(crate::theme::Color::SUNBEAM_ORANGE))
789                .title("Box")
790                .child(Box::new(Text::new("hi", 0, 0)));
791
792            let rendered = div.render_rect(Rect::new(0, 0, 10, 3)).unwrap();
793            assert!(rendered.lines[0].contains("Box"));
794        });
795    }
796
797    #[test]
798    fn div_handle_input_space_toggles() {
799        Theme::with(Theme::Light, || {
800            let mut div = Div::new(Layout::vertical([Constraint::Length(1)]))
801                .collapsible(true)
802                .collapsed(true)
803                .child(Box::new(Text::new("hi", 0, 0)));
804
805            let space = crate::events::Event::Key(crossterm::event::KeyEvent::new(
806                crossterm::event::KeyCode::Char(' '),
807                crossterm::event::KeyModifiers::empty(),
808            ));
809            let result = div.handle_input(&space);
810            assert!(matches!(result, crate::InputResult::Handled));
811            assert!(!div.collapsed);
812        });
813    }
814
815    #[test]
816    fn div_handle_input_falls_through_children() {
817        Theme::with(Theme::Light, || {
818            let mut div = Div::new(Layout::vertical([Constraint::Length(1)]))
819                .child(Box::new(Text::new("hi", 0, 0)));
820
821            let a_key = crate::events::Event::Key(crossterm::event::KeyEvent::new(
822                crossterm::event::KeyCode::Char('a'),
823                crossterm::event::KeyModifiers::empty(),
824            ));
825            let result = div.handle_input(&a_key);
826            assert!(matches!(result, crate::InputResult::Ignored));
827        });
828    }
829
830    #[test]
831    fn div_focusable_trait_objects() {
832        let div = Div::new(Layout::vertical([Constraint::Length(1)]));
833        assert!(div.as_focusable().is_some());
834        let mut div = Div::new(Layout::vertical([Constraint::Length(1)]));
835        assert!(div.as_focusable_mut().is_some());
836    }
837
838    #[test]
839    fn div_cycle_child_focus_no_focusable_children() {
840        Theme::with(Theme::Light, || {
841            let mut div = Div::new(Layout::vertical([Constraint::Length(1)]))
842                .child(Box::new(Text::new("hi", 0, 0)));
843
844            let tab = crate::events::Event::Key(crossterm::event::KeyEvent::new(
845                crossterm::event::KeyCode::Tab,
846                crossterm::event::KeyModifiers::empty(),
847            ));
848            let result = div.handle_input(&tab);
849            assert!(matches!(result, crate::InputResult::Ignored));
850        });
851    }
852
853    #[test]
854    fn div_render_uses_computed_height() {
855        Theme::with(Theme::Light, || {
856            let div = Div::new(Layout::vertical([
857                Constraint::Length(1),
858                Constraint::Length(1),
859            ]))
860            .child(Box::new(Text::new("a", 0, 0)))
861            .child(Box::new(Text::new("b", 0, 0)));
862
863            let rendered = div.render(10).unwrap();
864            assert_eq!(rendered.lines.len(), 6);
865        });
866    }
867}