agent_core/tui/
layout.rs

1//! Layout system for TUI widgets
2//!
3//! This module provides a flexible layout system that allows agents to customize
4//! how widgets are arranged in the terminal UI.
5//!
6//! # Layout Templates
7//!
8//! The easiest way to define a layout is using a template:
9//!
10//! ```ignore
11//! // Standard layout (chat + input + status bar)
12//! core.set_layout(LayoutTemplate::standard());
13//!
14//! // With sidebar
15//! core.set_layout(LayoutTemplate::with_sidebar("file_browser", 40));
16//!
17//! // Minimal (no status bar)
18//! core.set_layout(LayoutTemplate::minimal());
19//! ```
20//!
21//! # Custom Layouts
22//!
23//! For full control, use a closure or implement `LayoutProvider`:
24//!
25//! ```ignore
26//! core.set_layout(LayoutTemplate::custom_fn(|area, ctx, sizes| {
27//!     // Use ratatui Layout directly
28//!     let chunks = Layout::default()
29//!         .direction(Direction::Vertical)
30//!         .constraints([Constraint::Min(1), Constraint::Length(5)])
31//!         .split(area);
32//!
33//!     LayoutResult {
34//!         widget_areas: [(widget_ids::CHAT_VIEW, chunks[0])].into(),
35//!         ..Default::default()
36//!     }
37//! }));
38//! ```
39
40use ratatui::layout::{Constraint, Direction, Layout, Rect};
41use std::collections::{HashMap, HashSet};
42
43use super::themes::Theme;
44use super::widgets::widget_ids;
45
46/// Context available during layout computation
47pub struct LayoutContext<'a> {
48    /// Total frame area
49    pub frame_area: Rect,
50    /// Whether the throbber/spinner is showing
51    pub show_throbber: bool,
52    /// Number of visual lines in the input widget
53    pub input_visual_lines: usize,
54    /// Current theme
55    pub theme: &'a Theme,
56    /// Set of currently active widget IDs
57    pub active_widgets: HashSet<&'static str>,
58}
59
60/// Pre-computed widget size information
61pub struct WidgetSizes {
62    /// Required heights for each widget (from Widget::required_height)
63    pub heights: HashMap<&'static str, u16>,
64    /// Whether each widget is currently active
65    pub is_active: HashMap<&'static str, bool>,
66}
67
68impl WidgetSizes {
69    /// Get the required height for a widget
70    pub fn height(&self, id: &str) -> u16 {
71        self.heights.get(id).copied().unwrap_or(0)
72    }
73
74    /// Check if a widget is active
75    pub fn is_active(&self, id: &str) -> bool {
76        self.is_active.get(id).copied().unwrap_or(false)
77    }
78}
79
80/// Result of layout computation
81#[derive(Default)]
82pub struct LayoutResult {
83    /// Area assigned to each widget (by widget ID)
84    pub widget_areas: HashMap<&'static str, Rect>,
85    /// Order to render widgets (first = bottom layer)
86    pub render_order: Vec<&'static str>,
87    /// Area for the status bar (special, not a widget)
88    pub status_bar_area: Option<Rect>,
89    /// Area for the input/throbber (special handling)
90    pub input_area: Option<Rect>,
91}
92
93/// Trait for custom layout providers
94///
95/// Implement this trait to create reusable, testable layout logic.
96pub trait LayoutProvider: Send + Sync + 'static {
97    /// Compute layout areas for widgets
98    fn compute(
99        &self,
100        ctx: &LayoutContext,
101        sizes: &WidgetSizes,
102    ) -> LayoutResult;
103}
104
105/// Type alias for layout closure
106pub type LayoutFn = Box<dyn Fn(Rect, &LayoutContext, &WidgetSizes) -> LayoutResult + Send + Sync>;
107
108/// Layout templates with customization options
109///
110/// Templates provide common layout patterns. Use `Custom` or `CustomFn`
111/// for full control with ratatui.
112pub enum LayoutTemplate {
113    /// Standard vertical layout: chat (fills), panels, input, status bar
114    Standard(StandardOptions),
115
116    /// Sidebar layout: main content + sidebar
117    Sidebar(SidebarOptions),
118
119    /// Split layout: two main areas side by side or stacked
120    Split(SplitOptions),
121
122    /// Minimal layout: just chat and input, no status bar
123    Minimal(MinimalOptions),
124
125    /// Custom layout using a LayoutProvider implementation
126    Custom(Box<dyn LayoutProvider>),
127
128    /// Custom layout using a closure
129    CustomFn(LayoutFn),
130}
131
132// ============ Standard Layout Options ============
133
134/// Options for the standard vertical layout
135#[derive(Clone)]
136pub struct StandardOptions {
137    /// Widget ID for the main content area (default: CHAT_VIEW)
138    pub main_widget_id: &'static str,
139    /// Widget ID for the input area (default: TEXT_INPUT)
140    pub input_widget_id: &'static str,
141    /// Widget IDs for panel widgets (shown between main and input when active)
142    pub panel_widget_ids: Vec<&'static str>,
143    /// Widget IDs for popup widgets (shown above input when active)
144    pub popup_widget_ids: Vec<&'static str>,
145    /// Widget IDs for overlay widgets (rendered on top of everything)
146    pub overlay_widget_ids: Vec<&'static str>,
147    /// Minimum height for the main content area
148    pub min_main_height: u16,
149    /// Fixed input height (None = auto-size from content)
150    pub fixed_input_height: Option<u16>,
151    /// Whether to show the status bar
152    pub show_status_bar: bool,
153    /// Height of the status bar
154    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// ============ Sidebar Layout Options ============
180
181/// Options for sidebar layout
182#[derive(Clone)]
183pub struct SidebarOptions {
184    /// Options for the main content area
185    pub main_options: StandardOptions,
186    /// Widget ID for the sidebar
187    pub sidebar_widget_id: &'static str,
188    /// Width of the sidebar
189    pub sidebar_width: SidebarWidth,
190    /// Position of the sidebar
191    pub sidebar_position: SidebarPosition,
192}
193
194/// Sidebar width specification
195#[derive(Clone)]
196pub enum SidebarWidth {
197    /// Fixed width in columns
198    Fixed(u16),
199    /// Percentage of total width
200    Percentage(u16),
201    /// Minimum width (sidebar gets this, main gets rest)
202    Min(u16),
203}
204
205impl From<u16> for SidebarWidth {
206    fn from(width: u16) -> Self {
207        Self::Fixed(width)
208    }
209}
210
211/// Sidebar position
212#[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// ============ Split Layout Options ============
231
232/// Options for split layout (two main areas)
233#[derive(Clone)]
234pub struct SplitOptions {
235    /// Direction of the split
236    pub direction: Direction,
237    /// Widget ID for the first (left/top) area
238    pub first_widget_id: &'static str,
239    /// Widget ID for the second (right/bottom) area
240    pub second_widget_id: &'static str,
241    /// How to split the space
242    pub split: SplitRatio,
243    /// Widget ID for input (shared below both areas)
244    pub input_widget_id: &'static str,
245    /// Whether to show status bar
246    pub show_status_bar: bool,
247}
248
249/// Split ratio specification
250#[derive(Clone)]
251pub enum SplitRatio {
252    /// Equal split (50/50)
253    Equal,
254    /// Percentage for first area (remainder goes to second)
255    Percentage(u16),
256    /// Fixed size for first area
257    FirstFixed(u16),
258    /// Fixed size for second area
259    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// ============ Minimal Layout Options ============
276
277/// Options for minimal layout (no status bar, no panels)
278#[derive(Clone)]
279pub struct MinimalOptions {
280    /// Widget ID for the main content area
281    pub main_widget_id: &'static str,
282    /// Widget ID for the input area
283    pub input_widget_id: &'static str,
284    /// Fixed input height (None = auto-size)
285    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
298// ============ LayoutTemplate Implementation ============
299
300impl LayoutTemplate {
301    // --- Constructors ---
302
303    /// Create a standard layout with default options
304    pub fn standard() -> Self {
305        Self::Standard(StandardOptions::default())
306    }
307
308    /// Create a standard layout with panels (permission, question, slash popup)
309    pub fn with_panels() -> Self {
310        Self::Standard(StandardOptions::default())
311    }
312
313    /// Create a sidebar layout
314    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    /// Create a minimal layout (no status bar, no panels)
323    pub fn minimal() -> Self {
324        Self::Minimal(MinimalOptions::default())
325    }
326
327    /// Create a horizontal split layout
328    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    /// Create a vertical split layout
341    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    /// Create a custom layout using a LayoutProvider
354    pub fn custom<P: LayoutProvider>(provider: P) -> Self {
355        Self::Custom(Box::new(provider))
356    }
357
358    /// Create a custom layout using a closure
359    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    // --- Compute Layout ---
367
368    /// Compute the layout for the given context
369    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        // Calculate heights for dynamic elements
389        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        // Calculate panel height (active panels only)
398        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        // Calculate popup height (active popups only)
406        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        // Build constraints
414        let mut constraints = vec![Constraint::Min(opts.min_main_height)]; // Main
415
416        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)); // Input
425
426        if opts.show_status_bar {
427            constraints.push(Constraint::Length(opts.status_bar_height));
428        }
429
430        // Apply layout
431        let chunks = Layout::default()
432            .direction(Direction::Vertical)
433            .constraints(constraints)
434            .split(area);
435
436        // Map chunks to widgets
437        let mut chunk_idx = 0;
438
439        // Main content
440        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        // Panels (split evenly if multiple active)
445        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                // Split panel area among active panels
457                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        // Popups
475        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        // Input
490        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        // Status bar
496        if opts.show_status_bar {
497            result.status_bar_area = Some(chunks[chunk_idx]);
498        }
499
500        // Overlays (use full frame area, added last to render on top)
501        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        // Compute sidebar width constraint
519        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        // Split horizontally
526        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        // Compute main area layout using standard layout
542        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        // Add sidebar
552        result.widget_areas.insert(opts.sidebar_widget_id, sidebar_area);
553        // Insert sidebar at beginning of render order (renders first, behind main)
554        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        // Calculate input height
568        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        // First split: main content vs input/status
577        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        // Split content area
595        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), // Second gets fixed below
600        };
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
661// ============ Layout Helper Functions ============
662
663/// Helper functions for building custom layouts with ratatui
664pub mod helpers {
665    use super::*;
666
667    /// Create a vertical stack layout
668    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    /// Create a horizontal stack layout
677    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    /// Create a centered area within a parent area
686    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    /// Create margin around an area
693    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}