Skip to main content

eye_declare/components/
view.rs

1//! Unified layout container with optional borders, padding, and background.
2//!
3//! [`View`] consolidates vertical/horizontal layout, borders, padding, and
4//! background styling into a single component. It replaces the need to
5//! manually combine [`VStack`](crate::VStack)/[`HStack`](crate::HStack),
6//! [`Column`](crate::Column), and hand-drawn borders.
7//!
8//! # Examples
9//!
10//! ```ignore
11//! use eye_declare::{element, View, Direction, BorderType, WidthConstraint};
12//!
13//! // Simple vertical container (default)
14//! element! {
15//!     View {
16//!         "Line one"
17//!         "Line two"
18//!     }
19//! }
20//!
21//! // Bordered card with title and padding
22//! element! {
23//!     View(border: BorderType::Rounded, title: "My Card".into(), padding: 1u16) {
24//!         "Card content"
25//!     }
26//! }
27//!
28//! // Horizontal layout with fixed-width sidebar
29//! element! {
30//!     View(direction: Direction::Row) {
31//!         View(width: WidthConstraint::Fixed(20), border: BorderType::Plain) {
32//!             "Sidebar"
33//!         }
34//!         View {
35//!             "Main content"
36//!         }
37//!     }
38//! }
39//! ```
40
41use ratatui_core::buffer::Buffer;
42use ratatui_core::layout::Rect;
43use ratatui_core::style::Style;
44use ratatui_core::text::Line;
45use ratatui_core::widgets::Widget;
46use ratatui_widgets::block::{Block, Padding};
47use ratatui_widgets::borders::{BorderType, Borders};
48
49use crate::cells::Cells;
50use crate::component::Component;
51use crate::impl_slot_children;
52use crate::insets::Insets;
53use crate::node::{Layout, WidthConstraint};
54
55/// Layout direction for a [`View`].
56#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
57pub enum Direction {
58    /// Children stack top-to-bottom, each receiving the full parent width.
59    #[default]
60    Column,
61    /// Children lay out left-to-right, widths allocated by [`WidthConstraint`].
62    Row,
63}
64
65/// A unified layout container with optional borders, padding, and background.
66///
67/// See the [module-level docs](self) for examples.
68#[derive(typed_builder::TypedBuilder)]
69pub struct View {
70    /// Layout direction. Defaults to [`Direction::Column`] (vertical).
71    #[builder(default, setter(into))]
72    pub direction: Direction,
73
74    /// Border type. `None` means no border (default).
75    #[builder(default, setter(into))]
76    pub border: Option<BorderType>,
77
78    /// Style applied to the border lines.
79    #[builder(default, setter(into))]
80    pub border_style: Style,
81
82    /// Title rendered at the top of the View. Most useful with a border.
83    #[builder(default, setter(into))]
84    pub title: Option<String>,
85
86    /// Title rendered at the bottom of the View. Most useful with a border.
87    #[builder(default, setter(into))]
88    pub title_bottom: Option<String>,
89
90    /// Style applied to the top title text.
91    #[builder(default, setter(into))]
92    pub title_style: Style,
93
94    /// Style applied to the bottom title text.
95    #[builder(default, setter(into))]
96    pub title_bottom_style: Style,
97
98    /// Base padding applied to all sides (default 0). Each side uses this
99    /// value unless overridden by a side-specific field (`padding_top`, etc.).
100    ///
101    /// Accepts bare integer literals in the `element!` macro: `padding: 1`.
102    #[builder(default, setter(into))]
103    pub padding: Cells,
104
105    /// Padding above content. Overrides `padding` for the top side.
106    #[builder(default, setter(into))]
107    pub padding_top: Option<Cells>,
108
109    /// Padding below content. Overrides `padding` for the bottom side.
110    #[builder(default, setter(into))]
111    pub padding_bottom: Option<Cells>,
112
113    /// Padding left of content. Overrides `padding` for the left side.
114    #[builder(default, setter(into))]
115    pub padding_left: Option<Cells>,
116
117    /// Padding right of content. Overrides `padding` for the right side.
118    #[builder(default, setter(into))]
119    pub padding_right: Option<Cells>,
120
121    /// Width constraint for this View when inside a [`Direction::Row`] parent.
122    #[builder(default, setter(into))]
123    pub width: WidthConstraint,
124
125    /// Background/foreground style applied to the entire View area.
126    #[builder(default, setter(into))]
127    pub style: Style,
128}
129
130impl Default for View {
131    fn default() -> Self {
132        Self {
133            direction: Direction::Column,
134            border: None,
135            border_style: Style::default(),
136            title: None,
137            title_bottom: None,
138            title_style: Style::default(),
139            title_bottom_style: Style::default(),
140            padding: Cells::ZERO,
141            padding_top: None,
142            padding_bottom: None,
143            padding_left: None,
144            padding_right: None,
145            width: WidthConstraint::Fill,
146            style: Style::default(),
147        }
148    }
149}
150
151impl View {
152    /// Compute the effective padding for each side, in raw u16 cells.
153    fn effective_padding(&self) -> (u16, u16, u16, u16) {
154        let base = self.padding.0;
155        (
156            self.padding_top.map_or(base, |c| c.0),
157            self.padding_right.map_or(base, |c| c.0),
158            self.padding_bottom.map_or(base, |c| c.0),
159            self.padding_left.map_or(base, |c| c.0),
160        )
161    }
162
163    /// Build the ratatui Block for rendering.
164    fn build_block(&self) -> Block<'_> {
165        let mut block = Block::new().style(self.style);
166
167        if let Some(border_type) = self.border {
168            block = block
169                .borders(Borders::ALL)
170                .border_type(border_type)
171                .border_style(self.border_style);
172        }
173
174        if let Some(ref title) = self.title {
175            block = block.title_top(Line::from(title.as_str()).style(self.title_style));
176        }
177
178        if let Some(ref title) = self.title_bottom {
179            block = block.title_bottom(Line::from(title.as_str()).style(self.title_bottom_style));
180        }
181
182        let (pt, pr, pb, pl) = self.effective_padding();
183        if pt > 0 || pr > 0 || pb > 0 || pl > 0 {
184            block = block.padding(Padding::new(pl, pr, pt, pb));
185        }
186
187        block
188    }
189}
190
191impl Component for View {
192    type State = ();
193
194    fn render(&self, area: Rect, buf: &mut Buffer, _state: &()) {
195        self.build_block().render(area, buf);
196    }
197
198    fn content_inset(&self, _state: &()) -> Insets {
199        let has_border = self.border.is_some();
200        let border: u16 = if has_border { 1 } else { 0 };
201        let (pt, pr, pb, pl) = self.effective_padding();
202
203        Insets {
204            top: border + pt,
205            right: border + pr,
206            bottom: border + pb,
207            left: border + pl,
208        }
209    }
210
211    fn layout(&self) -> Layout {
212        match self.direction {
213            Direction::Column => Layout::Vertical,
214            Direction::Row => Layout::Horizontal,
215        }
216    }
217
218    fn width_constraint(&self) -> WidthConstraint {
219        self.width
220    }
221}
222
223impl_slot_children!(View);
224
225#[cfg(test)]
226mod tests {
227    use super::*;
228
229    #[test]
230    fn default_view_is_vertical_no_border() {
231        let v = View::default();
232        assert_eq!(v.direction, Direction::Column);
233        assert!(v.border.is_none());
234        assert_eq!(v.padding, Cells::ZERO);
235        assert_eq!(v.layout(), Layout::Vertical);
236        assert_eq!(v.content_inset(&()), Insets::ZERO);
237    }
238
239    #[test]
240    fn row_direction_maps_to_horizontal_layout() {
241        let v = View {
242            direction: Direction::Row,
243            ..View::default()
244        };
245        assert_eq!(v.layout(), Layout::Horizontal);
246    }
247
248    #[test]
249    fn border_adds_one_cell_inset_per_side() {
250        let v = View {
251            border: Some(BorderType::Plain),
252            ..View::default()
253        };
254        let insets = v.content_inset(&());
255        assert_eq!(insets, Insets::all(1));
256    }
257
258    #[test]
259    fn border_plus_padding() {
260        let v = View {
261            border: Some(BorderType::Rounded),
262            padding: Cells(2),
263            ..View::default()
264        };
265        let insets = v.content_inset(&());
266        // 1 (border) + 2 (padding) = 3 on each side
267        assert_eq!(insets, Insets::all(3));
268    }
269
270    #[test]
271    fn padding_without_border() {
272        let v = View {
273            padding: Cells(1),
274            ..View::default()
275        };
276        let insets = v.content_inset(&());
277        assert_eq!(insets, Insets::all(1));
278    }
279
280    #[test]
281    fn side_specific_padding_overrides_general() {
282        let v = View {
283            padding: Cells(1),
284            padding_left: Some(Cells(3)),
285            padding_top: Some(Cells(0)),
286            ..View::default()
287        };
288        let insets = v.content_inset(&());
289        assert_eq!(
290            insets,
291            Insets {
292                top: 0,
293                right: 1,
294                bottom: 1,
295                left: 3,
296            }
297        );
298    }
299
300    #[test]
301    fn side_specific_padding_with_border() {
302        let v = View {
303            border: Some(BorderType::Plain),
304            padding: Cells(1),
305            padding_left: Some(Cells(2)),
306            ..View::default()
307        };
308        let insets = v.content_inset(&());
309        assert_eq!(
310            insets,
311            Insets {
312                top: 2,    // 1 border + 1 padding
313                right: 2,  // 1 border + 1 padding
314                bottom: 2, // 1 border + 1 padding
315                left: 3,   // 1 border + 2 padding_left
316            }
317        );
318    }
319
320    #[test]
321    fn width_constraint_passthrough() {
322        let v = View {
323            width: WidthConstraint::Fixed(20),
324            ..View::default()
325        };
326        assert_eq!(v.width_constraint(), WidthConstraint::Fixed(20));
327    }
328
329    #[test]
330    fn render_plain_border() {
331        let v = View {
332            border: Some(BorderType::Plain),
333            ..View::default()
334        };
335        let area = Rect::new(0, 0, 10, 5);
336        let mut buf = Buffer::empty(area);
337        v.render(area, &mut buf, &());
338
339        // Top-left corner should be the plain border character
340        let tl = buf.cell((0, 0)).unwrap();
341        assert_eq!(tl.symbol(), "┌");
342
343        // Top-right corner
344        let tr = buf.cell((9, 0)).unwrap();
345        assert_eq!(tr.symbol(), "┐");
346
347        // Bottom-left corner
348        let bl = buf.cell((0, 4)).unwrap();
349        assert_eq!(bl.symbol(), "└");
350
351        // Bottom-right corner
352        let br = buf.cell((9, 4)).unwrap();
353        assert_eq!(br.symbol(), "┘");
354    }
355
356    #[test]
357    fn render_with_title() {
358        let v = View {
359            border: Some(BorderType::Plain),
360            title: Some("Test".into()),
361            ..View::default()
362        };
363        let area = Rect::new(0, 0, 20, 5);
364        let mut buf = Buffer::empty(area);
365        v.render(area, &mut buf, &());
366
367        // Title should appear in top border
368        let t = buf.cell((1, 0)).unwrap();
369        assert_eq!(t.symbol(), "T");
370    }
371
372    #[test]
373    fn render_with_title_bottom() {
374        let v = View {
375            border: Some(BorderType::Plain),
376            title_bottom: Some("Bottom".into()),
377            ..View::default()
378        };
379        let area = Rect::new(0, 0, 20, 5);
380        let mut buf = Buffer::empty(area);
381        v.render(area, &mut buf, &());
382
383        // Title should appear in bottom border row
384        let b = buf.cell((1, 4)).unwrap();
385        assert_eq!(b.symbol(), "B");
386    }
387
388    #[test]
389    fn render_no_border_produces_empty_buffer() {
390        let v = View::default();
391        let area = Rect::new(0, 0, 10, 5);
392        let mut buf = Buffer::empty(area);
393        v.render(area, &mut buf, &());
394
395        // All cells should be default (space)
396        for y in 0..5 {
397            for x in 0..10 {
398                assert_eq!(buf.cell((x, y)).unwrap().symbol(), " ");
399            }
400        }
401    }
402}