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 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 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 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
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 !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 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 if self.collapsed {
400 return InputResult::Ignored;
401 }
402
403 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 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 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 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 #[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 let rendered = outer.render_rect(Rect::new(0, 2, 20, 2)).unwrap();
543 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 #[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 });
579 }
580
581 #[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 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 #[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 let r1 = outer.handle_input(&tab);
643 assert!(matches!(r1, crate::InputResult::Handled));
644
645 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 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 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 let result = div.handle_input(&a_key);
712 assert!(matches!(result, crate::InputResult::Ignored));
713 });
714 }
715}