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