1use 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
51pub 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 focused_child: Option<usize>,
64 collapsible: bool,
66 collapsed: bool,
68}
69
70impl Div {
71 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 pub fn child(mut self, child: Box<dyn Component>) -> Self {
91 self.children.push(child);
92 self
93 }
94
95 pub fn push(&mut self, child: Box<dyn Component>) {
97 self.children.push(child);
98 }
99
100 pub fn border(mut self, border: Border) -> Self {
102 self.border = Some(border);
103 self
104 }
105
106 pub fn border_styled(mut self, style: Style) -> Self {
108 self.border_style = style;
109 self
110 }
111
112 pub fn padding(mut self, margin: Margin) -> Self {
114 self.padding = margin;
115 self
116 }
117
118 pub fn title(mut self, title: impl Into<String>) -> Self {
120 self.title = Some(title.into());
121 self
122 }
123
124 pub fn title_styled(mut self, style: Style) -> Self {
126 self.title_style = style;
127 self
128 }
129
130 pub fn background(mut self, style: Style) -> Self {
132 self.background = Some(style);
133 self
134 }
135
136 pub fn collapsible(mut self, value: bool) -> Self {
138 self.collapsible = value;
139 self
140 }
141
142 pub fn collapsed(mut self, value: bool) -> Self {
144 self.collapsed = value;
145 self
146 }
147
148 pub fn toggle_collapsed(&mut self) {
150 self.collapsed = !self.collapsed;
151 }
152
153 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 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 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 if delta > 0 && current + 1 >= focusable.len() {
211 return InputResult::Ignored;
213 }
214 if delta < 0 && current == 0 {
215 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 if let Some(f) = self.children[current_idx].as_focusable_mut() {
229 f.set_focused(false);
230 }
231 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 self.focused_child = self
250 .children
251 .iter()
252 .position(|c| c.as_focusable().is_some());
253 }
254 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 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 while screen.lines.len() < rect.height as usize {
298 screen.lines.push(String::new());
299 }
300 return Ok(screen);
301 }
302
303 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 let inner = self.inner_rect(rect);
321
322 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 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 while screen.lines.len() < rect.height as usize {
341 screen.lines.push(String::new());
342 }
343
344 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 crate::layout::draw_border(
353 &mut screen,
354 Rect::new(0, 0, rect.width, rect.height),
355 border,
356 &border_style,
357 );
358
359 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 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 if self.collapsed {
401 return InputResult::Ignored;
402 }
403
404 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 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 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 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 #[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 let rendered = outer.render_rect(Rect::new(0, 2, 20, 2)).unwrap();
544 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 #[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 });
580 }
581
582 #[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 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 #[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 let r1 = outer.handle_input(&tab);
644 assert!(matches!(r1, crate::InputResult::Handled));
645
646 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 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 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 let result = div.handle_input(&a_key);
713 assert!(matches!(result, crate::InputResult::Ignored));
714 });
715 }
716}