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        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            let Some(f) = self.children[idx].as_focusable_mut()
259        {
260            f.set_focused(focused);
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
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                !screen.lines.is_empty()
362            {
363                let title_style = if self.title_style == Style::new() {
364                    Style::new().fg(theme.text_primary()).bold()
365                } else {
366                    self.title_style
367                };
368                let indicator = if self.collapsible { "▼ " } else { "" };
369                let label = format!(" {}{} ", indicator, title);
370                let label_styled = crate::theme::stylize(&label, &title_style);
371                let top = &mut screen.lines[0];
372                let start_byte = crate::utils::byte_index_at_visual_pos(top, 2);
373                let end_byte = crate::utils::byte_index_at_visual_pos(
374                    top,
375                    2 + crate::utils::visible_width(&label_styled),
376                );
377                if start_byte < top.len() {
378                    top.replace_range(start_byte..end_byte.min(top.len()), &label_styled);
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            let Event::Key(key) = event &&
392            (key.code == KeyCode::Enter || key.code == KeyCode::Char(' '))
393        {
394            self.collapsed = !self.collapsed;
395            return InputResult::Handled;
396        }
397
398        // When collapsed, don't route input to children.
399        if self.collapsed {
400            return InputResult::Ignored;
401        }
402
403        // Handle Tab / BackTab to cycle focus among children.
404        if let Event::Key(key) = event {
405            if key.code == KeyCode::Tab {
406                return self.cycle_child_focus(1);
407            }
408            if key.code == KeyCode::BackTab {
409                return self.cycle_child_focus(-1);
410            }
411        }
412
413        // Route to the focused child first.
414        if let Some(idx) = self.focused_child &&
415            idx < self.children.len()
416        {
417            let result = self.children[idx].handle_input(event);
418            if result != InputResult::Ignored {
419                return result;
420            }
421        }
422
423        // Fall through to other children.
424        for (i, child) in self.children.iter_mut().enumerate() {
425            if Some(i) == self.focused_child {
426                continue;
427            }
428            let result = child.handle_input(event);
429            if result != InputResult::Ignored {
430                return result;
431            }
432        }
433        InputResult::Ignored
434    }
435
436    fn as_focusable(&self) -> Option<&dyn Focusable> {
437        Some(self)
438    }
439
440    fn as_focusable_mut(&mut self) -> Option<&mut dyn Focusable> {
441        Some(self)
442    }
443}
444
445#[cfg(test)]
446mod tests {
447    use super::*;
448    use crate::{
449        components::Text,
450        layout::Constraint,
451        theme::Theme,
452    };
453
454    #[test]
455    fn div_renders_children() {
456        Theme::with(Theme::Light, || {
457            let div = Div::new(Layout::vertical([
458                Constraint::Length(1),
459                Constraint::Length(1),
460            ]))
461            .child(Box::new(Text::new("top", 0, 0)))
462            .child(Box::new(Text::new("bottom", 0, 0)));
463
464            let rendered = div.render_rect(Rect::new(0, 0, 10, 2)).unwrap();
465            assert_eq!(rendered.lines.len(), 2);
466            assert!(rendered.lines[0].contains("top"));
467            assert!(rendered.lines[1].contains("bottom"));
468        });
469    }
470
471    #[test]
472    fn div_with_border() {
473        Theme::with(Theme::Light, || {
474            let div = Div::new(Layout::vertical([Constraint::Length(1)]))
475                .child(Box::new(Text::new("hi", 0, 0)))
476                .border(Border::ROUNDED);
477
478            let rendered = div.render_rect(Rect::new(0, 0, 6, 3)).unwrap();
479            assert!(rendered.lines[0].contains("╭"));
480            assert!(rendered.lines[2].contains("╰"));
481        });
482    }
483
484    #[test]
485    fn div_with_title() {
486        Theme::with(Theme::Light, || {
487            let div = Div::new(Layout::vertical([Constraint::Length(1)]))
488                .child(Box::new(Text::new("hi", 0, 0)))
489                .border(Border::ROUNDED)
490                .title("Box");
491
492            let rendered = div.render_rect(Rect::new(0, 0, 10, 3)).unwrap();
493            assert!(rendered.lines[0].contains("Box"));
494        });
495    }
496
497    #[test]
498    fn div_with_padding() {
499        Theme::with(Theme::Light, || {
500            let div = Div::new(Layout::vertical([Constraint::Length(1)]))
501                .child(Box::new(Text::new("hi", 0, 0)))
502                .padding(Margin::new(1, 1));
503
504            let rendered = div.render_rect(Rect::new(0, 0, 6, 3)).unwrap();
505            // Padding shifts content down by 1 row and in by 1 col
506            assert!(rendered.lines[1].contains("hi"));
507        });
508    }
509
510    #[test]
511    fn div_focus_propagation() {
512        Theme::with(Theme::Light, || {
513            let mut div = Div::new(Layout::vertical([Constraint::Length(1)])).child(Box::new(
514                crate::components::SelectList::new(vec!["a".into()], 1),
515            ));
516
517            div.set_focused(true);
518            assert!(div.focused());
519        });
520    }
521
522    /// Regression test: nested divs with non-zero rect coordinates must not
523    /// double-offset content.
524    #[test]
525    fn div_nonzero_rect_no_double_offset() {
526        Theme::with(Theme::Light, || {
527            let outer = Div::new(Layout::horizontal([
528                Constraint::Length(10),
529                Constraint::Length(10),
530            ]))
531            .child(Box::new(Text::new("left", 0, 0)))
532            .child(Box::new(
533                Div::new(Layout::vertical([
534                    Constraint::Length(1),
535                    Constraint::Length(1),
536                ]))
537                .child(Box::new(Text::new("a", 0, 0)))
538                .child(Box::new(Text::new("b", 0, 0))),
539            ));
540
541            // Outer rect starts at (0, 2). Inner div gets y = 2 from the layout.
542            let rendered = outer.render_rect(Rect::new(0, 2, 20, 2)).unwrap();
543            // The inner div's content should be at local rows 0 and 1,
544            // NOT shifted down by 2 due to double-offsetting.
545            assert_eq!(
546                rendered.lines.len(),
547                2,
548                "expected 2 lines, got {}",
549                rendered.lines.len()
550            );
551            assert!(rendered.lines[0].contains("left"));
552            assert!(rendered.lines[0].contains("a"));
553            assert!(rendered.lines[1].contains("b"));
554        });
555    }
556
557    /// Border must be drawn at the edges of the local buffer even when the
558    /// parent rect has non-zero coordinates.
559    #[test]
560    fn div_border_with_nonzero_rect() {
561        Theme::with(Theme::Light, || {
562            let div = Div::new(Layout::vertical([Constraint::Length(1)]))
563                .child(Box::new(Text::new("hi", 0, 0)))
564                .border(Border::ROUNDED);
565
566            let rendered = div.render_rect(Rect::new(0, 5, 6, 3)).unwrap();
567            assert!(
568                rendered.lines[0].contains("╭"),
569                "border should be at local row 0"
570            );
571            assert!(
572                rendered.lines[2].contains("╰"),
573                "border should be at local row 2"
574            );
575            // Note: draw_border() currently replaces the entire middle rows,
576            // so child content inside bordered divs is overwritten. This is a
577            // pre-existing issue unrelated to the nonzero-rect fix.
578        });
579    }
580
581    /// Regression: Tab must descend into nested Divs to reach leaf focusables.
582    /// When outer Div's focused_child is a nested Div, Tab should cycle within
583    /// that nested Div instead of immediately returning Ignored.
584    #[test]
585    fn div_tab_descends_into_nested_focusables() {
586        Theme::with(Theme::Light, || {
587            let mut inner = Div::new(Layout::vertical([
588                Constraint::Length(1),
589                Constraint::Length(1),
590            ]));
591            let input1 = crate::components::Input::new();
592            let input2 = crate::components::Input::new();
593            inner.push(Box::new(input1));
594            inner.push(Box::new(input2));
595
596            let mut outer = Div::new(Layout::vertical([Constraint::Length(2)]));
597            outer.push(Box::new(inner));
598
599            outer.set_focused(true);
600            assert_eq!(outer.focused_child, Some(0));
601
602            // With the old code, this Tab would return Ignored because outer
603            // has no next sibling. With the fix, it should descend into inner
604            // and cycle to its first focusable child.
605            let tab = crate::events::Event::Key(crossterm::event::KeyEvent::new(
606                crossterm::event::KeyCode::Tab,
607                crossterm::event::KeyModifiers::empty(),
608            ));
609            let result = outer.handle_input(&tab);
610            assert!(
611                matches!(result, crate::InputResult::Handled),
612                "Tab should descend into nested div and be handled"
613            );
614        });
615    }
616
617    /// Regression: Tab cycling must move between siblings inside nested Divs.
618    #[test]
619    fn div_tab_cycles_across_nested_siblings() {
620        Theme::with(Theme::Light, || {
621            let mut inner = Div::new(Layout::vertical([
622                Constraint::Length(1),
623                Constraint::Length(1),
624            ]));
625            let mut input1 = crate::components::Input::new();
626            let mut input2 = crate::components::Input::new();
627            input1.set_text("first");
628            input2.set_text("second");
629            inner.push(Box::new(input1));
630            inner.push(Box::new(input2));
631
632            let mut outer = Div::new(Layout::vertical([Constraint::Length(2)]));
633            outer.push(Box::new(inner));
634            outer.set_focused(true);
635
636            let tab = crate::events::Event::Key(crossterm::event::KeyEvent::new(
637                crossterm::event::KeyCode::Tab,
638                crossterm::event::KeyModifiers::empty(),
639            ));
640
641            // First Tab: descend into inner, cycle input1 → input2
642            let r1 = outer.handle_input(&tab);
643            assert!(matches!(r1, crate::InputResult::Handled));
644
645            // Second Tab: inner exhausted (input2 has no next sibling).
646            // Outer also has no next sibling → Ignored.
647            let r2 = outer.handle_input(&tab);
648            assert!(matches!(r2, crate::InputResult::Ignored));
649        });
650    }
651
652    #[test]
653    fn div_collapsible_renders_header_when_collapsed() {
654        Theme::with(Theme::Light, || {
655            let div = Div::new(Layout::vertical([Constraint::Length(1)]))
656                .border(Border::ROUNDED)
657                .title("Panel")
658                .collapsible(true)
659                .collapsed(true)
660                .child(Box::new(Text::new("hidden", 0, 0)));
661
662            let rendered = div.render_rect(Rect::new(0, 0, 20, 5)).unwrap();
663            assert_eq!(rendered.lines.len(), 5);
664            assert!(rendered.lines[0].contains("▶"));
665            assert!(rendered.lines[0].contains("Panel"));
666            // Child content should not be visible
667            assert!(!rendered.lines.iter().any(|l| l.contains("hidden")));
668        });
669    }
670
671    #[test]
672    fn div_collapsible_toggles_on_enter() {
673        Theme::with(Theme::Light, || {
674            let mut div = Div::new(Layout::vertical([Constraint::Length(1)]))
675                .border(Border::ROUNDED)
676                .title("Panel")
677                .collapsible(true)
678                .collapsed(true)
679                .child(Box::new(Text::new("content", 0, 0)));
680
681            let enter = crate::events::Event::Key(crossterm::event::KeyEvent::new(
682                crossterm::event::KeyCode::Enter,
683                crossterm::event::KeyModifiers::empty(),
684            ));
685
686            assert!(div.collapsed);
687            let result = div.handle_input(&enter);
688            assert!(matches!(result, crate::InputResult::Handled));
689            assert!(!div.collapsed);
690
691            // Second Enter should collapse again
692            div.handle_input(&enter);
693            assert!(div.collapsed);
694        });
695    }
696
697    #[test]
698    fn div_collapsible_ignores_child_input_when_collapsed() {
699        Theme::with(Theme::Light, || {
700            let mut div = Div::new(Layout::vertical([Constraint::Length(1)]))
701                .collapsible(true)
702                .collapsed(true)
703                .child(Box::new(crate::components::Input::new()));
704
705            let a_key = crate::events::Event::Key(crossterm::event::KeyEvent::new(
706                crossterm::event::KeyCode::Char('a'),
707                crossterm::event::KeyModifiers::empty(),
708            ));
709
710            // Should return Ignored because collapsed div doesn't route to children
711            let result = div.handle_input(&a_key);
712            assert!(matches!(result, crate::InputResult::Ignored));
713        });
714    }
715}