1use 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
50pub 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 focused_child: Option<usize>,
63 collapsible: bool,
65 collapsed: bool,
67}
68
69impl Div {
70 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 pub fn child(mut self, child: Box<dyn Component>) -> Self {
90 self.children.push(child);
91 self
92 }
93
94 pub fn push(&mut self, child: Box<dyn Component>) {
96 self.children.push(child);
97 }
98
99 pub fn border(mut self, border: Border) -> Self {
101 self.border = Some(border);
102 self
103 }
104
105 pub fn border_styled(mut self, style: Style) -> Self {
107 self.border_style = style;
108 self
109 }
110
111 pub fn padding(mut self, margin: Margin) -> Self {
113 self.padding = margin;
114 self
115 }
116
117 pub fn title(mut self, title: impl Into<String>) -> Self {
119 self.title = Some(title.into());
120 self
121 }
122
123 pub fn title_styled(mut self, style: Style) -> Self {
125 self.title_style = style;
126 self
127 }
128
129 pub fn background(mut self, style: Style) -> Self {
131 self.background = Some(style);
132 self
133 }
134
135 pub fn collapsible(mut self, value: bool) -> Self {
137 self.collapsible = value;
138 self
139 }
140
141 pub fn collapsed(mut self, value: bool) -> Self {
143 self.collapsed = value;
144 self
145 }
146
147 pub fn toggle_collapsed(&mut self) {
149 self.collapsed = !self.collapsed;
150 }
151
152 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 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 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 if delta > 0 && current + 1 >= focusable.len() {
210 return InputResult::Ignored;
212 }
213 if delta < 0 && current == 0 {
214 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 if let Some(f) = self.children[current_idx].as_focusable_mut() {
228 f.set_focused(false);
229 }
230 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 self.focused_child = self
249 .children
250 .iter()
251 .position(|c| c.as_focusable().is_some());
252 }
253 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 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 while screen.lines.len() < rect.height as usize {
297 screen.lines.push(String::new());
298 }
299 return Ok(screen);
300 }
301
302 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 let inner = self.inner_rect(rect);
320
321 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 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 while screen.lines.len() < rect.height as usize {
340 screen.lines.push(String::new());
341 }
342
343 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 crate::layout::draw_border(
352 &mut screen,
353 Rect::new(0, 0, rect.width, rect.height),
354 border,
355 &border_style,
356 );
357
358 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 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 if self.collapsed {
399 return InputResult::Ignored;
400 }
401
402 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 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 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 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 #[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 let rendered = outer.render_rect(Rect::new(0, 2, 20, 2)).unwrap();
542 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 #[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 });
578 }
579
580 #[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 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 #[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 let r1 = outer.handle_input(&tab);
642 assert!(matches!(r1, crate::InputResult::Handled));
643
644 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 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 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 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}