Skip to main content

vtcode_ui/design/
panel.rs

1//! Base panel widget primitive.
2//!
3//! `Panel` renders standardized chrome (borders, titles) and returns the inner
4//! area for child widgets. It is decoupled from any specific session or styling
5//! type via the [`PanelStyleProvider`] trait.
6
7use ratatui::{
8    buffer::Buffer,
9    layout::Rect,
10    style::{Modifier, Style},
11    widgets::{Block, BorderType, Widget},
12};
13
14use crate::design::layout::LayoutMode;
15
16/// Trait for providing panel styles. Decouples `Panel` from any specific
17/// session or theme type.
18///
19/// Implementors provide the three core styles needed to render a panel:
20/// - `default_style`: the base style for the panel background
21/// - `accent_style`: the style for active/focused borders
22/// - `border_style`: the style for inactive borders
23pub trait PanelStyleProvider {
24    /// Base style for the panel background.
25    fn default_style(&self) -> Style;
26
27    /// Style for active/focused borders and titles.
28    fn accent_style(&self) -> Style;
29
30    /// Style for inactive borders.
31    fn border_style(&self) -> Style;
32}
33
34/// A consistent panel wrapper that applies standardized chrome (borders, titles).
35///
36/// This widget ensures visual consistency across all panels in the UI by
37/// providing a unified border and title style based on the active theme.
38///
39/// # Example
40/// ```ignore
41/// let inner = Panel::new(&styles)
42///     .title("Transcript")
43///     .active(true)
44///     .mode(layout_mode)
45///     .border_type(BorderType::Rounded)
46///     .render_and_get_inner(area, buf);
47/// // Render child widget into `inner`
48/// ```
49pub(crate) struct Panel<'a, S: PanelStyleProvider> {
50    styles: &'a S,
51    title: Option<&'a str>,
52    active: bool,
53    mode: LayoutMode,
54    border_type: Option<BorderType>,
55}
56
57impl<'a, S: PanelStyleProvider> Panel<'a, S> {
58    /// Create a new panel with required style reference.
59    pub(crate) fn new(styles: &'a S) -> Self {
60        Self {
61            styles,
62            title: None,
63            active: false,
64            mode: LayoutMode::Standard,
65            border_type: None,
66        }
67    }
68
69    /// Set the panel title (displayed in the border).
70    #[must_use]
71    pub(crate) fn title(mut self, title: &'a str) -> Self {
72        self.title = Some(title);
73        self
74    }
75
76    /// Mark the panel as active (highlighted border).
77    #[must_use]
78    pub(crate) fn active(mut self, active: bool) -> Self {
79        self.active = active;
80        self
81    }
82
83    /// Set the layout mode (affects border visibility).
84    #[must_use]
85    pub(crate) fn mode(mut self, mode: LayoutMode) -> Self {
86        self.mode = mode;
87        self
88    }
89
90    /// Override the border type.
91    #[must_use]
92    pub(crate) fn border_type(mut self, border_type: BorderType) -> Self {
93        self.border_type = Some(border_type);
94        self
95    }
96
97    /// Render the panel and return the inner area for child widgets.
98    pub(crate) fn render_and_get_inner(self, area: Rect, buf: &mut Buffer) -> Rect {
99        if area.is_empty() {
100            return area;
101        }
102        if !self.mode.show_borders() {
103            return area;
104        }
105
106        let border_style = if self.active {
107            self.styles.accent_style()
108        } else {
109            self.styles.border_style()
110        };
111
112        let border_type = self.border_type.unwrap_or(BorderType::Plain);
113
114        let mut block = Block::bordered()
115            .border_type(border_type)
116            .style(self.styles.default_style())
117            .border_style(border_style);
118
119        if self.mode.show_titles()
120            && let Some(title) = self.title
121        {
122            let title_style = if self.active {
123                self.styles.accent_style().add_modifier(Modifier::BOLD)
124            } else {
125                self.styles.default_style().add_modifier(Modifier::BOLD)
126            };
127            block = block.title(title).title_style(title_style);
128        }
129
130        let inner = block.inner(area);
131        block.render(area, buf);
132        inner
133    }
134}
135
136/// Extended style methods for panels and visual hierarchy.
137pub trait PanelStyles {
138    /// Style for muted/secondary content.
139    fn muted_style(&self) -> Style;
140
141    /// Style for panel titles.
142    fn title_style(&self) -> Style;
143
144    /// Style for active/focused borders.
145    fn border_active_style(&self) -> Style;
146
147    /// Style for dividers between sections.
148    fn divider_style(&self) -> Style;
149}
150
151#[cfg(test)]
152mod tests {
153    use super::*;
154
155    struct MockStyles {
156        default: Style,
157        accent: Style,
158        border: Style,
159    }
160
161    impl MockStyles {
162        fn new() -> Self {
163            Self {
164                default: Style::default(),
165                accent: Style::default().fg(ratatui::style::Color::Cyan),
166                border: Style::default().fg(ratatui::style::Color::Gray),
167            }
168        }
169    }
170
171    impl PanelStyleProvider for MockStyles {
172        fn default_style(&self) -> Style {
173            self.default
174        }
175        fn accent_style(&self) -> Style {
176            self.accent
177        }
178        fn border_style(&self) -> Style {
179            self.border
180        }
181    }
182
183    #[test]
184    fn zero_area_returns_unchanged_without_painting() {
185        let styles = MockStyles::new();
186        let area = Rect::new(5, 5, 0, 0);
187        let mut buf = Buffer::empty(Rect::new(0, 0, 10, 10));
188        let before = buf.clone();
189        let inner = Panel::new(&styles)
190            .mode(LayoutMode::Standard)
191            .render_and_get_inner(area, &mut buf);
192        assert_eq!(inner, area);
193        assert_eq!(buf, before, "zero-area panel must not touch the buffer");
194    }
195
196    #[test]
197    fn compact_mode_returns_full_area() {
198        let styles = MockStyles::new();
199        let area = Rect::new(0, 0, 40, 10);
200        let mut buf = Buffer::empty(area);
201        let inner = Panel::new(&styles)
202            .mode(LayoutMode::Compact)
203            .render_and_get_inner(area, &mut buf);
204        assert_eq!(inner, area);
205    }
206
207    #[test]
208    fn standard_mode_returns_smaller_inner_area() {
209        let styles = MockStyles::new();
210        let area = Rect::new(0, 0, 80, 24);
211        let mut buf = Buffer::empty(area);
212        let inner = Panel::new(&styles)
213            .mode(LayoutMode::Standard)
214            .render_and_get_inner(area, &mut buf);
215        // Borders reduce inner area by 1 on each side
216        assert_eq!(inner.x, area.x + 1);
217        assert_eq!(inner.y, area.y + 1);
218        assert_eq!(inner.width, area.width - 2);
219        assert_eq!(inner.height, area.height - 2);
220    }
221
222    #[test]
223    fn wide_mode_with_title() {
224        let styles = MockStyles::new();
225        let area = Rect::new(0, 0, 120, 30);
226        let mut buf = Buffer::empty(area);
227        let inner = Panel::new(&styles)
228            .title("Test Panel")
229            .active(true)
230            .mode(LayoutMode::Wide)
231            .render_and_get_inner(area, &mut buf);
232        assert_eq!(inner.x, area.x + 1);
233        assert_eq!(inner.y, area.y + 1);
234        assert_eq!(inner.width, area.width - 2);
235        assert_eq!(inner.height, area.height - 2);
236    }
237
238    #[test]
239    fn active_panel_uses_accent_style() {
240        let styles = MockStyles::new();
241        let area = Rect::new(0, 0, 80, 24);
242        let mut buf = Buffer::empty(area);
243        // Should not panic and should render with accent style
244        Panel::new(&styles)
245            .active(true)
246            .mode(LayoutMode::Standard)
247            .render_and_get_inner(area, &mut buf);
248    }
249}