1use ratatui::layout::{Constraint, Direction, Layout, Rect};
41use std::collections::{HashMap, HashSet};
42
43use super::themes::Theme;
44use super::widgets::widget_ids;
45
46pub struct LayoutContext<'a> {
48 pub frame_area: Rect,
50 pub show_throbber: bool,
52 pub input_visual_lines: usize,
54 pub theme: &'a Theme,
56 pub active_widgets: HashSet<&'static str>,
58}
59
60pub struct WidgetSizes {
62 pub heights: HashMap<&'static str, u16>,
64 pub is_active: HashMap<&'static str, bool>,
66}
67
68impl WidgetSizes {
69 pub fn height(&self, id: &str) -> u16 {
71 self.heights.get(id).copied().unwrap_or(0)
72 }
73
74 pub fn is_active(&self, id: &str) -> bool {
76 self.is_active.get(id).copied().unwrap_or(false)
77 }
78}
79
80#[derive(Default)]
82pub struct LayoutResult {
83 pub widget_areas: HashMap<&'static str, Rect>,
85 pub render_order: Vec<&'static str>,
87 pub status_bar_area: Option<Rect>,
89 pub input_area: Option<Rect>,
91}
92
93pub trait LayoutProvider: Send + Sync + 'static {
97 fn compute(
99 &self,
100 ctx: &LayoutContext,
101 sizes: &WidgetSizes,
102 ) -> LayoutResult;
103}
104
105pub type LayoutFn = Box<dyn Fn(Rect, &LayoutContext, &WidgetSizes) -> LayoutResult + Send + Sync>;
107
108pub enum LayoutTemplate {
113 Standard(StandardOptions),
115
116 Sidebar(SidebarOptions),
118
119 Split(SplitOptions),
121
122 Minimal(MinimalOptions),
124
125 Custom(Box<dyn LayoutProvider>),
127
128 CustomFn(LayoutFn),
130}
131
132#[derive(Clone)]
136pub struct StandardOptions {
137 pub main_widget_id: &'static str,
139 pub input_widget_id: &'static str,
141 pub panel_widget_ids: Vec<&'static str>,
143 pub popup_widget_ids: Vec<&'static str>,
145 pub overlay_widget_ids: Vec<&'static str>,
147 pub min_main_height: u16,
149 pub fixed_input_height: Option<u16>,
151 pub show_status_bar: bool,
153 pub status_bar_height: u16,
155}
156
157impl Default for StandardOptions {
158 fn default() -> Self {
159 Self {
160 main_widget_id: widget_ids::CHAT_VIEW,
161 input_widget_id: widget_ids::TEXT_INPUT,
162 panel_widget_ids: vec![
163 widget_ids::PERMISSION_PANEL,
164 widget_ids::QUESTION_PANEL,
165 ],
166 popup_widget_ids: vec![widget_ids::SLASH_POPUP],
167 overlay_widget_ids: vec![
168 widget_ids::THEME_PICKER,
169 widget_ids::SESSION_PICKER,
170 ],
171 min_main_height: 5,
172 fixed_input_height: None,
173 show_status_bar: true,
174 status_bar_height: 2,
175 }
176 }
177}
178
179#[derive(Clone)]
183pub struct SidebarOptions {
184 pub main_options: StandardOptions,
186 pub sidebar_widget_id: &'static str,
188 pub sidebar_width: SidebarWidth,
190 pub sidebar_position: SidebarPosition,
192}
193
194#[derive(Clone)]
196pub enum SidebarWidth {
197 Fixed(u16),
199 Percentage(u16),
201 Min(u16),
203}
204
205impl From<u16> for SidebarWidth {
206 fn from(width: u16) -> Self {
207 Self::Fixed(width)
208 }
209}
210
211#[derive(Clone, Copy, Default)]
213pub enum SidebarPosition {
214 Left,
215 #[default]
216 Right,
217}
218
219impl Default for SidebarOptions {
220 fn default() -> Self {
221 Self {
222 main_options: StandardOptions::default(),
223 sidebar_widget_id: "sidebar",
224 sidebar_width: SidebarWidth::Fixed(30),
225 sidebar_position: SidebarPosition::Right,
226 }
227 }
228}
229
230#[derive(Clone)]
234pub struct SplitOptions {
235 pub direction: Direction,
237 pub first_widget_id: &'static str,
239 pub second_widget_id: &'static str,
241 pub split: SplitRatio,
243 pub input_widget_id: &'static str,
245 pub show_status_bar: bool,
247}
248
249#[derive(Clone)]
251pub enum SplitRatio {
252 Equal,
254 Percentage(u16),
256 FirstFixed(u16),
258 SecondFixed(u16),
260}
261
262impl Default for SplitOptions {
263 fn default() -> Self {
264 Self {
265 direction: Direction::Horizontal,
266 first_widget_id: widget_ids::CHAT_VIEW,
267 second_widget_id: "secondary",
268 split: SplitRatio::Equal,
269 input_widget_id: widget_ids::TEXT_INPUT,
270 show_status_bar: true,
271 }
272 }
273}
274
275#[derive(Clone)]
279pub struct MinimalOptions {
280 pub main_widget_id: &'static str,
282 pub input_widget_id: &'static str,
284 pub fixed_input_height: Option<u16>,
286}
287
288impl Default for MinimalOptions {
289 fn default() -> Self {
290 Self {
291 main_widget_id: widget_ids::CHAT_VIEW,
292 input_widget_id: widget_ids::TEXT_INPUT,
293 fixed_input_height: None,
294 }
295 }
296}
297
298impl LayoutTemplate {
301 pub fn standard() -> Self {
305 Self::Standard(StandardOptions::default())
306 }
307
308 pub fn with_panels() -> Self {
310 Self::Standard(StandardOptions::default())
311 }
312
313 pub fn with_sidebar(sidebar_widget_id: &'static str, width: impl Into<SidebarWidth>) -> Self {
315 Self::Sidebar(SidebarOptions {
316 sidebar_widget_id,
317 sidebar_width: width.into(),
318 ..Default::default()
319 })
320 }
321
322 pub fn minimal() -> Self {
324 Self::Minimal(MinimalOptions::default())
325 }
326
327 pub fn split_horizontal(
329 left_widget_id: &'static str,
330 right_widget_id: &'static str,
331 ) -> Self {
332 Self::Split(SplitOptions {
333 direction: Direction::Horizontal,
334 first_widget_id: left_widget_id,
335 second_widget_id: right_widget_id,
336 ..Default::default()
337 })
338 }
339
340 pub fn split_vertical(
342 top_widget_id: &'static str,
343 bottom_widget_id: &'static str,
344 ) -> Self {
345 Self::Split(SplitOptions {
346 direction: Direction::Vertical,
347 first_widget_id: top_widget_id,
348 second_widget_id: bottom_widget_id,
349 ..Default::default()
350 })
351 }
352
353 pub fn custom<P: LayoutProvider>(provider: P) -> Self {
355 Self::Custom(Box::new(provider))
356 }
357
358 pub fn custom_fn<F>(f: F) -> Self
360 where
361 F: Fn(Rect, &LayoutContext, &WidgetSizes) -> LayoutResult + Send + Sync + 'static,
362 {
363 Self::CustomFn(Box::new(f))
364 }
365
366 pub fn compute(&self, ctx: &LayoutContext, sizes: &WidgetSizes) -> LayoutResult {
370 match self {
371 Self::Standard(opts) => Self::compute_standard(ctx, sizes, opts),
372 Self::Sidebar(opts) => Self::compute_sidebar(ctx, sizes, opts),
373 Self::Split(opts) => Self::compute_split(ctx, sizes, opts),
374 Self::Minimal(opts) => Self::compute_minimal(ctx, sizes, opts),
375 Self::Custom(provider) => provider.compute(ctx, sizes),
376 Self::CustomFn(f) => f(ctx.frame_area, ctx, sizes),
377 }
378 }
379
380 fn compute_standard(
381 ctx: &LayoutContext,
382 sizes: &WidgetSizes,
383 opts: &StandardOptions,
384 ) -> LayoutResult {
385 let mut result = LayoutResult::default();
386 let area = ctx.frame_area;
387
388 let input_height = opts.fixed_input_height.unwrap_or_else(|| {
390 if ctx.show_throbber {
391 3
392 } else {
393 (ctx.input_visual_lines as u16).max(1) + 2
394 }
395 });
396
397 let panel_height: u16 = opts
399 .panel_widget_ids
400 .iter()
401 .filter(|id| sizes.is_active(id))
402 .map(|id| sizes.height(id))
403 .sum();
404
405 let popup_height: u16 = opts
407 .popup_widget_ids
408 .iter()
409 .filter(|id| sizes.is_active(id))
410 .map(|id| sizes.height(id))
411 .sum();
412
413 let mut constraints = vec![Constraint::Min(opts.min_main_height)]; if panel_height > 0 {
417 constraints.push(Constraint::Length(panel_height));
418 }
419
420 if popup_height > 0 {
421 constraints.push(Constraint::Length(popup_height));
422 }
423
424 constraints.push(Constraint::Length(input_height)); if opts.show_status_bar {
427 constraints.push(Constraint::Length(opts.status_bar_height));
428 }
429
430 let chunks = Layout::default()
432 .direction(Direction::Vertical)
433 .constraints(constraints)
434 .split(area);
435
436 let mut chunk_idx = 0;
438
439 result.widget_areas.insert(opts.main_widget_id, chunks[chunk_idx]);
441 result.render_order.push(opts.main_widget_id);
442 chunk_idx += 1;
443
444 if panel_height > 0 {
446 let active_panels: Vec<_> = opts
447 .panel_widget_ids
448 .iter()
449 .filter(|id| sizes.is_active(id))
450 .collect();
451
452 if active_panels.len() == 1 {
453 result.widget_areas.insert(active_panels[0], chunks[chunk_idx]);
454 result.render_order.push(active_panels[0]);
455 } else if !active_panels.is_empty() {
456 let panel_constraints: Vec<_> = active_panels
458 .iter()
459 .map(|id| Constraint::Length(sizes.height(id)))
460 .collect();
461 let panel_chunks = Layout::default()
462 .direction(Direction::Vertical)
463 .constraints(panel_constraints)
464 .split(chunks[chunk_idx]);
465
466 for (i, id) in active_panels.iter().enumerate() {
467 result.widget_areas.insert(id, panel_chunks[i]);
468 result.render_order.push(id);
469 }
470 }
471 chunk_idx += 1;
472 }
473
474 if popup_height > 0 {
476 let active_popups: Vec<_> = opts
477 .popup_widget_ids
478 .iter()
479 .filter(|id| sizes.is_active(id))
480 .collect();
481
482 for id in active_popups {
483 result.widget_areas.insert(id, chunks[chunk_idx]);
484 result.render_order.push(id);
485 }
486 chunk_idx += 1;
487 }
488
489 result.widget_areas.insert(opts.input_widget_id, chunks[chunk_idx]);
491 result.input_area = Some(chunks[chunk_idx]);
492 result.render_order.push(opts.input_widget_id);
493 chunk_idx += 1;
494
495 if opts.show_status_bar {
497 result.status_bar_area = Some(chunks[chunk_idx]);
498 }
499
500 for id in &opts.overlay_widget_ids {
502 if sizes.is_active(id) {
503 result.widget_areas.insert(id, area);
504 result.render_order.push(id);
505 }
506 }
507
508 result
509 }
510
511 fn compute_sidebar(
512 ctx: &LayoutContext,
513 sizes: &WidgetSizes,
514 opts: &SidebarOptions,
515 ) -> LayoutResult {
516 let area = ctx.frame_area;
517
518 let sidebar_constraint = match opts.sidebar_width {
520 SidebarWidth::Fixed(w) => Constraint::Length(w),
521 SidebarWidth::Percentage(p) => Constraint::Percentage(p),
522 SidebarWidth::Min(w) => Constraint::Min(w),
523 };
524
525 let h_constraints = match opts.sidebar_position {
527 SidebarPosition::Left => vec![sidebar_constraint, Constraint::Min(1)],
528 SidebarPosition::Right => vec![Constraint::Min(1), sidebar_constraint],
529 };
530
531 let h_chunks = Layout::default()
532 .direction(Direction::Horizontal)
533 .constraints(h_constraints)
534 .split(area);
535
536 let (main_area, sidebar_area) = match opts.sidebar_position {
537 SidebarPosition::Left => (h_chunks[1], h_chunks[0]),
538 SidebarPosition::Right => (h_chunks[0], h_chunks[1]),
539 };
540
541 let main_ctx = LayoutContext {
543 frame_area: main_area,
544 show_throbber: ctx.show_throbber,
545 input_visual_lines: ctx.input_visual_lines,
546 theme: ctx.theme,
547 active_widgets: ctx.active_widgets.clone(),
548 };
549 let mut result = Self::compute_standard(&main_ctx, sizes, &opts.main_options);
550
551 result.widget_areas.insert(opts.sidebar_widget_id, sidebar_area);
553 result.render_order.insert(0, opts.sidebar_widget_id);
555
556 result
557 }
558
559 fn compute_split(
560 ctx: &LayoutContext,
561 _sizes: &WidgetSizes,
562 opts: &SplitOptions,
563 ) -> LayoutResult {
564 let mut result = LayoutResult::default();
565 let area = ctx.frame_area;
566
567 let input_height = if ctx.show_throbber {
569 3
570 } else {
571 (ctx.input_visual_lines as u16).max(1) + 2
572 };
573
574 let status_height = if opts.show_status_bar { 2 } else { 0 };
575
576 let v_chunks = Layout::default()
578 .direction(Direction::Vertical)
579 .constraints([
580 Constraint::Min(5),
581 Constraint::Length(input_height),
582 Constraint::Length(status_height),
583 ])
584 .split(area);
585
586 let content_area = v_chunks[0];
587 result.input_area = Some(v_chunks[1]);
588 result.widget_areas.insert(opts.input_widget_id, v_chunks[1]);
589
590 if opts.show_status_bar {
591 result.status_bar_area = Some(v_chunks[2]);
592 }
593
594 let split_constraint = match opts.split {
596 SplitRatio::Equal => Constraint::Percentage(50),
597 SplitRatio::Percentage(p) => Constraint::Percentage(p),
598 SplitRatio::FirstFixed(w) => Constraint::Length(w),
599 SplitRatio::SecondFixed(_) => Constraint::Min(1), };
601
602 let second_constraint = match opts.split {
603 SplitRatio::SecondFixed(w) => Constraint::Length(w),
604 _ => Constraint::Min(1),
605 };
606
607 let content_chunks = Layout::default()
608 .direction(opts.direction)
609 .constraints([split_constraint, second_constraint])
610 .split(content_area);
611
612 result.widget_areas.insert(opts.first_widget_id, content_chunks[0]);
613 result.widget_areas.insert(opts.second_widget_id, content_chunks[1]);
614
615 result.render_order = vec![
616 opts.first_widget_id,
617 opts.second_widget_id,
618 opts.input_widget_id,
619 ];
620
621 result
622 }
623
624 fn compute_minimal(
625 ctx: &LayoutContext,
626 _sizes: &WidgetSizes,
627 opts: &MinimalOptions,
628 ) -> LayoutResult {
629 let mut result = LayoutResult::default();
630 let area = ctx.frame_area;
631
632 let input_height = opts.fixed_input_height.unwrap_or_else(|| {
633 if ctx.show_throbber {
634 3
635 } else {
636 (ctx.input_visual_lines as u16).max(1) + 2
637 }
638 });
639
640 let chunks = Layout::default()
641 .direction(Direction::Vertical)
642 .constraints([Constraint::Min(1), Constraint::Length(input_height)])
643 .split(area);
644
645 result.widget_areas.insert(opts.main_widget_id, chunks[0]);
646 result.widget_areas.insert(opts.input_widget_id, chunks[1]);
647 result.input_area = Some(chunks[1]);
648
649 result.render_order = vec![opts.main_widget_id, opts.input_widget_id];
650
651 result
652 }
653}
654
655impl Default for LayoutTemplate {
656 fn default() -> Self {
657 Self::with_panels()
658 }
659}
660
661pub mod helpers {
665 use super::*;
666
667 pub fn vstack(area: Rect, constraints: &[Constraint]) -> Vec<Rect> {
669 Layout::default()
670 .direction(Direction::Vertical)
671 .constraints(constraints)
672 .split(area)
673 .to_vec()
674 }
675
676 pub fn hstack(area: Rect, constraints: &[Constraint]) -> Vec<Rect> {
678 Layout::default()
679 .direction(Direction::Horizontal)
680 .constraints(constraints)
681 .split(area)
682 .to_vec()
683 }
684
685 pub fn centered(area: Rect, width: u16, height: u16) -> Rect {
687 let x = area.x + (area.width.saturating_sub(width)) / 2;
688 let y = area.y + (area.height.saturating_sub(height)) / 2;
689 Rect::new(x, y, width.min(area.width), height.min(area.height))
690 }
691
692 pub fn with_margin(area: Rect, margin: u16) -> Rect {
694 Rect::new(
695 area.x + margin,
696 area.y + margin,
697 area.width.saturating_sub(margin * 2),
698 area.height.saturating_sub(margin * 2),
699 )
700 }
701}
702
703#[cfg(test)]
704mod tests {
705 use super::*;
706
707 fn test_context(area: Rect) -> LayoutContext<'static> {
708 static THEME: std::sync::LazyLock<Theme> = std::sync::LazyLock::new(Theme::default);
709 LayoutContext {
710 frame_area: area,
711 show_throbber: false,
712 input_visual_lines: 1,
713 theme: &THEME,
714 active_widgets: HashSet::new(),
715 }
716 }
717
718 fn test_sizes() -> WidgetSizes {
719 WidgetSizes {
720 heights: HashMap::new(),
721 is_active: HashMap::new(),
722 }
723 }
724
725 #[test]
726 fn test_standard_layout() {
727 let area = Rect::new(0, 0, 80, 24);
728 let ctx = test_context(area);
729 let sizes = test_sizes();
730
731 let result = LayoutTemplate::standard().compute(&ctx, &sizes);
732
733 assert!(result.widget_areas.contains_key(widget_ids::CHAT_VIEW));
734 assert!(result.widget_areas.contains_key(widget_ids::TEXT_INPUT));
735 assert!(result.status_bar_area.is_some());
736 }
737
738 #[test]
739 fn test_minimal_layout() {
740 let area = Rect::new(0, 0, 80, 24);
741 let ctx = test_context(area);
742 let sizes = test_sizes();
743
744 let result = LayoutTemplate::minimal().compute(&ctx, &sizes);
745
746 assert!(result.widget_areas.contains_key(widget_ids::CHAT_VIEW));
747 assert!(result.widget_areas.contains_key(widget_ids::TEXT_INPUT));
748 assert!(result.status_bar_area.is_none());
749 }
750
751 #[test]
752 fn test_sidebar_layout() {
753 let area = Rect::new(0, 0, 100, 24);
754 let ctx = test_context(area);
755 let sizes = test_sizes();
756
757 let result = LayoutTemplate::with_sidebar("file_browser", 30).compute(&ctx, &sizes);
758
759 assert!(result.widget_areas.contains_key(widget_ids::CHAT_VIEW));
760 assert!(result.widget_areas.contains_key("file_browser"));
761
762 let sidebar_area = result.widget_areas.get("file_browser").unwrap();
763 assert_eq!(sidebar_area.width, 30);
764 }
765
766 #[test]
767 fn test_custom_fn_layout() {
768 let area = Rect::new(0, 0, 80, 24);
769 let ctx = test_context(area);
770 let sizes = test_sizes();
771
772 let template = LayoutTemplate::custom_fn(|area, _ctx, _sizes| {
773 let chunks = helpers::vstack(area, &[
774 Constraint::Percentage(80),
775 Constraint::Percentage(20),
776 ]);
777
778 let mut result = LayoutResult::default();
779 result.widget_areas.insert("custom_main", chunks[0]);
780 result.widget_areas.insert("custom_footer", chunks[1]);
781 result.render_order = vec!["custom_main", "custom_footer"];
782 result
783 });
784
785 let result = template.compute(&ctx, &sizes);
786
787 assert!(result.widget_areas.contains_key("custom_main"));
788 assert!(result.widget_areas.contains_key("custom_footer"));
789 }
790}