1use 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#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
57pub enum Direction {
58 #[default]
60 Column,
61 Row,
63}
64
65#[derive(typed_builder::TypedBuilder)]
69pub struct View {
70 #[builder(default, setter(into))]
72 pub direction: Direction,
73
74 #[builder(default, setter(into))]
76 pub border: Option<BorderType>,
77
78 #[builder(default, setter(into))]
80 pub border_style: Style,
81
82 #[builder(default, setter(into))]
84 pub title: Option<String>,
85
86 #[builder(default, setter(into))]
88 pub title_bottom: Option<String>,
89
90 #[builder(default, setter(into))]
92 pub title_style: Style,
93
94 #[builder(default, setter(into))]
96 pub title_bottom_style: Style,
97
98 #[builder(default, setter(into))]
103 pub padding: Cells,
104
105 #[builder(default, setter(into))]
107 pub padding_top: Option<Cells>,
108
109 #[builder(default, setter(into))]
111 pub padding_bottom: Option<Cells>,
112
113 #[builder(default, setter(into))]
115 pub padding_left: Option<Cells>,
116
117 #[builder(default, setter(into))]
119 pub padding_right: Option<Cells>,
120
121 #[builder(default, setter(into))]
123 pub width: WidthConstraint,
124
125 #[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 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 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 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, right: 2, bottom: 2, left: 3, }
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 let tl = buf.cell((0, 0)).unwrap();
341 assert_eq!(tl.symbol(), "┌");
342
343 let tr = buf.cell((9, 0)).unwrap();
345 assert_eq!(tr.symbol(), "┐");
346
347 let bl = buf.cell((0, 4)).unwrap();
349 assert_eq!(bl.symbol(), "└");
350
351 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 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 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 for y in 0..5 {
397 for x in 0..10 {
398 assert_eq!(buf.cell((x, y)).unwrap().symbol(), " ");
399 }
400 }
401 }
402}