1use retroglyph_core::{Backend, Grid, Rect, Style, Terminal, Tile};
15use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
16
17use crate::draw::{BL, BR, H, TL, TR, V};
18use crate::text::truncate;
19use crate::widget::Widget;
20
21#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
23pub struct Sides {
24 pub top: u16,
26 pub right: u16,
28 pub bottom: u16,
30 pub left: u16,
32}
33
34impl Sides {
35 pub const ZERO: Self = Self {
37 top: 0,
38 right: 0,
39 bottom: 0,
40 left: 0,
41 };
42
43 #[must_use]
45 pub const fn all(n: u16) -> Self {
46 Self {
47 top: n,
48 right: n,
49 bottom: n,
50 left: n,
51 }
52 }
53
54 #[must_use]
57 pub const fn symmetric(vertical: u16, horizontal: u16) -> Self {
58 Self {
59 top: vertical,
60 right: horizontal,
61 bottom: vertical,
62 left: horizontal,
63 }
64 }
65
66 #[must_use]
68 pub const fn top(mut self, top: u16) -> Self {
69 self.top = top;
70 self
71 }
72
73 #[must_use]
75 pub const fn right(mut self, right: u16) -> Self {
76 self.right = right;
77 self
78 }
79
80 #[must_use]
82 pub const fn bottom(mut self, bottom: u16) -> Self {
83 self.bottom = bottom;
84 self
85 }
86
87 #[must_use]
89 pub const fn left(mut self, left: u16) -> Self {
90 self.left = left;
91 self
92 }
93
94 const fn horizontal(self) -> u16 {
95 self.left.saturating_add(self.right)
96 }
97
98 const fn vertical(self) -> u16 {
99 self.top.saturating_add(self.bottom)
100 }
101}
102
103#[derive(Clone, Copy, Debug)]
110pub struct BoxStyle {
111 style: Style,
112 padding: Sides,
113 margin: Sides,
114 border: bool,
115 width: Option<u16>,
116 height: Option<u16>,
117}
118
119impl BoxStyle {
120 #[must_use]
123 pub const fn new(style: Style) -> Self {
124 Self {
125 style,
126 padding: Sides::ZERO,
127 margin: Sides::ZERO,
128 border: false,
129 width: None,
130 height: None,
131 }
132 }
133
134 #[must_use]
136 pub const fn padding(mut self, padding: Sides) -> Self {
137 self.padding = padding;
138 self
139 }
140
141 #[must_use]
143 pub const fn margin(mut self, margin: Sides) -> Self {
144 self.margin = margin;
145 self
146 }
147
148 #[must_use]
150 pub const fn border(mut self, border: bool) -> Self {
151 self.border = border;
152 self
153 }
154
155 #[must_use]
160 pub const fn width(mut self, width: u16) -> Self {
161 self.width = Some(width);
162 self
163 }
164
165 #[must_use]
170 pub const fn height(mut self, height: u16) -> Self {
171 self.height = Some(height);
172 self
173 }
174
175 #[must_use]
191 pub fn render(&self, text: &str) -> Grid {
192 let lines: Vec<&str> = text.split('\n').collect();
193 let content_w = self.width.unwrap_or_else(|| {
194 u16::try_from(lines.iter().map(|l| l.width()).max().unwrap_or(0)).unwrap_or(u16::MAX)
195 });
196 let content_h = self
197 .height
198 .unwrap_or_else(|| u16::try_from(lines.len()).unwrap_or(u16::MAX));
199
200 let (mut grid, content_x, content_y) = self.scaffold(content_w, content_h);
201 for (row, line) in lines.iter().take(usize::from(content_h)).enumerate() {
202 let Ok(row) = u16::try_from(row) else { break };
203 let clipped = truncate(line, usize::from(content_w));
204 let mut col = 0u16;
205 for ch in clipped.chars() {
206 let w = u16::try_from(ch.width().unwrap_or(0)).unwrap_or(u16::MAX);
207 if col.saturating_add(w) > content_w {
208 break;
209 }
210 grid.put(content_x + col, content_y + row, Tile::new(ch, self.style));
211 col = col.saturating_add(w);
212 }
213 }
214 grid
215 }
216
217 #[cfg(feature = "egc")]
228 #[must_use]
229 pub fn render_wrapped(&self, text: &str) -> Grid {
230 use retroglyph_core::Headless;
231 use retroglyph_core::layout::TextLayout;
232 use retroglyph_core::text::{Line, Span};
233
234 let content_w = self.width.unwrap_or_else(|| {
235 u16::try_from(
236 text.split('\n')
237 .map(UnicodeWidthStr::width)
238 .max()
239 .unwrap_or(0),
240 )
241 .unwrap_or(u16::MAX)
242 });
243 let line = Line::from(Span::styled(text, self.style));
244 let content_h = self.height.unwrap_or_else(|| {
245 TextLayout::new(&line)
246 .rect(Rect::new(0, 0, content_w, u16::MAX))
247 .measure()
248 .height
249 });
250
251 let (mut grid, content_x, content_y) = self.scaffold(content_w, content_h);
252
253 let mut scratch = Terminal::new(Headless::new(content_w.max(1), content_h.max(1)));
259 TextLayout::new(&line)
260 .rect(Rect::new(0, 0, content_w, content_h))
261 .render(&mut scratch);
262 let content_rect = Rect::new(0, 0, content_w, content_h);
263 grid.blit(0, scratch.grid(), content_rect, content_x, content_y);
264
265 grid
266 }
267
268 fn scaffold(&self, content_w: u16, content_h: u16) -> (Grid, u16, u16) {
273 let border_wh = u16::from(self.border) * 2;
274 let inner_w = content_w
275 .saturating_add(self.padding.horizontal())
276 .saturating_add(border_wh);
277 let inner_h = content_h
278 .saturating_add(self.padding.vertical())
279 .saturating_add(border_wh);
280 let outer_w = inner_w.saturating_add(self.margin.horizontal()).max(1);
281 let outer_h = inner_h.saturating_add(self.margin.vertical()).max(1);
282
283 let mut grid = Grid::new(outer_w, outer_h);
284 let box_x = self.margin.left;
285 let box_y = self.margin.top;
286
287 fill_rect(&mut grid, box_x, box_y, inner_w, inner_h, self.style);
288 if self.border {
289 draw_border(&mut grid, box_x, box_y, inner_w, inner_h, self.style);
292 }
293
294 let content_x = box_x
295 .saturating_add(u16::from(self.border))
296 .saturating_add(self.padding.left);
297 let content_y = box_y
298 .saturating_add(u16::from(self.border))
299 .saturating_add(self.padding.top);
300 (grid, content_x, content_y)
301 }
302}
303
304#[derive(Clone, Copy, Debug)]
315pub struct Boxed<'a> {
316 style: BoxStyle,
317 text: &'a str,
318}
319
320impl BoxStyle {
321 #[must_use]
323 pub const fn text(self, text: &str) -> Boxed<'_> {
324 Boxed { style: self, text }
325 }
326}
327
328impl<B: Backend> Widget<B> for Boxed<'_> {
329 fn render(self, area: Rect, term: &mut Terminal<B>) {
330 let grid = self.style.render(self.text);
331 crate::block::blit_into(term, &grid, area.left(), area.top());
332 }
333}
334
335fn fill_rect(grid: &mut Grid, x: u16, y: u16, w: u16, h: u16, style: Style) {
337 for dy in 0..h {
338 for dx in 0..w {
339 grid.put(x + dx, y + dy, Tile::new(' ', style));
340 }
341 }
342}
343
344fn draw_border(grid: &mut Grid, x: u16, y: u16, w: u16, h: u16, style: Style) {
347 let right = x + w - 1;
348 let bottom = y + h - 1;
349
350 grid.put(x, y, Tile::new(TL, style));
351 grid.put(right, y, Tile::new(TR, style));
352 grid.put(x, bottom, Tile::new(BL, style));
353 grid.put(right, bottom, Tile::new(BR, style));
354 for cx in (x + 1)..right {
355 grid.put(cx, y, Tile::new(H, style));
356 grid.put(cx, bottom, Tile::new(H, style));
357 }
358 for cy in (y + 1)..bottom {
359 grid.put(x, cy, Tile::new(V, style));
360 grid.put(right, cy, Tile::new(V, style));
361 }
362}
363
364#[cfg(test)]
365mod tests {
366 use super::*;
367
368 fn glyphs(grid: &Grid) -> Vec<String> {
369 (0..grid.height())
370 .map(|y| (0..grid.width()).map(|x| grid.get(x, y).glyph()).collect())
371 .collect()
372 }
373
374 #[test]
375 fn sides_helpers() {
376 assert_eq!(
377 Sides::all(2),
378 Sides {
379 top: 2,
380 right: 2,
381 bottom: 2,
382 left: 2
383 }
384 );
385 assert_eq!(
386 Sides::symmetric(1, 3),
387 Sides {
388 top: 1,
389 right: 3,
390 bottom: 1,
391 left: 3
392 }
393 );
394 }
395
396 #[test]
397 fn sizes_to_content_with_no_padding_or_border() {
398 let grid = BoxStyle::new(Style::default()).render("hi");
399 assert_eq!((grid.width(), grid.height()), (2, 1));
400 assert_eq!(grid.get(0, 0).glyph(), 'h');
401 assert_eq!(grid.get(1, 0).glyph(), 'i');
402 }
403
404 #[test]
405 fn sizes_to_the_widest_of_multiple_lines() {
406 let grid = BoxStyle::new(Style::default()).render("a\nbcd\nef");
407 assert_eq!((grid.width(), grid.height()), (3, 3));
408 assert_eq!(grid.get(0, 0).glyph(), 'a');
409 assert_eq!(grid.get(1, 0).glyph(), ' '); assert_eq!(grid.get(0, 1).glyph(), 'b');
411 assert_eq!(grid.get(2, 1).glyph(), 'd');
412 }
413
414 #[test]
415 fn explicit_width_clips_longer_lines_and_pads_shorter_ones() {
416 let grid = BoxStyle::new(Style::default()).width(3).render("hello");
417 assert_eq!(grid.width(), 3);
418 let row: String = (0..3).map(|x| grid.get(x, 0).glyph()).collect();
419 assert_eq!(row, "hel");
420 }
421
422 #[test]
423 fn explicit_height_drops_extra_lines() {
424 let grid = BoxStyle::new(Style::default()).height(1).render("a\nb\nc");
425 assert_eq!(grid.height(), 1);
426 assert_eq!(grid.get(0, 0).glyph(), 'a');
427 }
428
429 #[test]
430 fn padding_surrounds_content_with_the_box_style() {
431 let grid = BoxStyle::new(Style::default())
432 .padding(Sides::all(1))
433 .render("x");
434 assert_eq!((grid.width(), grid.height()), (3, 3));
436 assert_eq!(grid.get(1, 1).glyph(), 'x');
437 assert_eq!(grid.get(0, 0).glyph(), ' ');
438 }
439
440 #[test]
441 fn border_draws_a_box_around_padding_and_content() {
442 let grid = BoxStyle::new(Style::default()).border(true).render("x");
443 assert_eq!((grid.width(), grid.height()), (3, 3));
445 let rows = glyphs(&grid);
446 assert_eq!(rows[0], "┌─┐");
447 assert_eq!(rows[1], "│x│");
448 assert_eq!(rows[2], "└─┘");
449 }
450
451 #[test]
452 fn margin_is_left_transparent_outside_the_border() {
453 let grid = BoxStyle::new(Style::default())
454 .margin(Sides::all(1))
455 .render("x");
456 assert_eq!((grid.width(), grid.height()), (3, 3));
460 assert!(grid.get(0, 0).is_empty());
461 assert_eq!(grid.get(1, 1).glyph(), 'x');
462 }
463
464 #[test]
465 fn wide_characters_push_later_columns_over_by_their_width() {
466 let grid = BoxStyle::new(Style::default()).render("aあb");
475 assert_eq!(grid.width(), 4);
476 assert_eq!(grid.get(0, 0).glyph(), 'a');
477 assert_eq!(grid.get(1, 0).glyph(), 'あ');
478 assert_eq!(grid.get(3, 0).glyph(), 'b');
479 }
480
481 #[test]
482 fn border_with_empty_content_is_still_at_least_a_2x2_box() {
483 let grid = BoxStyle::new(Style::default()).border(true).render("");
486 assert_eq!((grid.width(), grid.height()), (2, 3));
487 let rows = glyphs(&grid);
488 assert_eq!(rows[0], "┌┐");
489 assert_eq!(rows[2], "└┘");
490 }
491
492 #[test]
493 #[cfg(feature = "egc")]
494 fn render_wrapped_word_wraps_to_the_explicit_width() {
495 let grid = BoxStyle::new(Style::default())
498 .width(10)
499 .render_wrapped("the quick brown fox jumps");
500 assert_eq!(grid.width(), 10);
501 let rows = glyphs(&grid);
502 assert_eq!(rows[0].trim_end(), "the quick");
503 assert_eq!(rows[1].trim_end(), "brown fox");
504 assert_eq!(rows[2].trim_end(), "jumps");
505 }
506
507 #[test]
508 #[cfg(feature = "egc")]
509 fn render_wrapped_without_an_explicit_width_measures_but_does_not_wrap() {
510 let grid = BoxStyle::new(Style::default()).render_wrapped("hi");
513 assert_eq!((grid.width(), grid.height()), (2, 1));
514 assert_eq!(grid.get(0, 0).glyph(), 'h');
515 assert_eq!(grid.get(1, 0).glyph(), 'i');
516 }
517
518 #[test]
519 #[cfg(feature = "egc")]
520 fn render_wrapped_respects_padding_and_border_like_render() {
521 let grid = BoxStyle::new(Style::default())
522 .border(true)
523 .padding(Sides::all(1))
524 .width(3)
525 .render_wrapped("hi");
526 assert_eq!((grid.width(), grid.height()), (7, 5));
529 assert_eq!(grid.get(2, 2).glyph(), 'h');
530 assert_eq!(grid.get(3, 2).glyph(), 'i');
531 }
532
533 #[test]
534 fn boxed_widget_places_the_box_at_the_areas_top_left() {
535 use retroglyph_core::Headless;
536
537 let styled = BoxStyle::new(Style::default()).border(true).text("hi");
538 let mut term = Terminal::new(Headless::new(10, 6));
539 styled.render(Rect::new(2, 1, 10, 6), &mut term);
540
541 assert_eq!(term.grid().get(2, 1).glyph(), '┌');
544 assert_eq!(term.grid().get(3, 2).glyph(), 'h');
545 assert_eq!(term.grid().get(4, 2).glyph(), 'i');
546 assert_eq!(term.grid().get(5, 3).glyph(), '┘');
547 }
548}