Skip to main content

urushi_adapter_ratatui/
widget.rs

1//! Ratatui widgets that draw a resolved Urushi view into a caller-owned buffer.
2//!
3//! These widgets compute no geometry. A target [`Rect`] becomes an
4//! [`Available`] area, the
5//! core layout pass resolves the view once, and each resulting
6//! [`StyledGrapheme`] is written to a cell. Border reservation, padding,
7//! alignment, and dimension resolution live in `urushi` alone, so the Ratatui
8//! output and the ANSI output cannot drift apart.
9
10use ::ratatui::{buffer::Buffer, layout::Rect, widgets::Widget};
11
12use super::RatatuiStyle;
13use urushi::{AnchoredRect, Available, BlockStyle, ResolvedView, StyledGrapheme, View, resolve};
14
15/// A resolved anchor translated into a caller-owned Ratatui area.
16///
17/// [`logical`](Self::logical) preserves the complete signed core rectangle.
18/// [`destination`](Self::destination) is the part that survived every core
19/// clip, translated into `area`, and the source offsets identify where that
20/// visible fragment begins inside the logical rectangle.
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub struct RatatuiAnchor {
23    logical: AnchoredRect,
24    destination: Rect,
25    source_column: usize,
26    source_row: usize,
27}
28
29impl RatatuiAnchor {
30    pub const fn logical(self) -> AnchoredRect {
31        self.logical
32    }
33
34    pub const fn destination(self) -> Rect {
35        self.destination
36    }
37
38    pub const fn source_column(self) -> usize {
39        self.source_column
40    }
41
42    pub const fn source_row(self) -> usize {
43        self.source_row
44    }
45}
46
47/// How an Urushi cell combines with content already present in a Ratatui buffer.
48#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
49pub enum CellWriteMode {
50    /// Applies the Urushi style as a Ratatui patch, preserving unspecified cell properties.
51    #[default]
52    Merge,
53    /// Resets each cell Urushi writes before applying its resolved symbol and style.
54    Replace,
55}
56
57/// A stateless Ratatui widget that draws an Urushi [`View`].
58///
59/// The widget draws into the buffer passed to [`Widget::render`]. It does not
60/// initialize a terminal, read events, or own terminal I/O. Text in the view is
61/// plain; ANSI escape sequences are not interpreted inside a Ratatui buffer.
62///
63/// The target `Rect` supplies the width and height constraints for layout: the
64/// view is resolved under those constraints before its cells are written. Its
65/// flexible dimensions and overflow therefore follow the layout rules for the
66/// target area instead of resolving at the intrinsic size and being cropped
67/// afterward. Any part of the target outside the caller's buffer is masked
68/// separately during cell writing.
69///
70/// The widget keeps the cells and discards the anchors, so an anchor in the
71/// view draws as the blanks it resolved to. A caller that needs them resolves
72/// the view itself and reads [`ResolvedView::anchors`]: they belong to one
73/// resolution, and resolving again here to hand them back would be a second
74/// one.
75#[derive(Debug, Clone, Copy)]
76pub struct ViewWidget<'a> {
77    view: &'a View,
78    cell_write_mode: CellWriteMode,
79}
80
81impl<'a> ViewWidget<'a> {
82    /// Creates a widget that draws `view`.
83    pub const fn new(view: &'a View) -> Self {
84        Self {
85            view,
86            cell_write_mode: CellWriteMode::Merge,
87        }
88    }
89
90    /// Selects how drawn cells combine with content already in the buffer.
91    pub const fn cell_write_mode(mut self, mode: CellWriteMode) -> Self {
92        self.cell_write_mode = mode;
93        self
94    }
95}
96
97impl Widget for ViewWidget<'_> {
98    fn render(self, area: Rect, buffer: &mut Buffer) {
99        draw(self.view, area, buffer, self.cell_write_mode);
100    }
101}
102
103impl Widget for &ViewWidget<'_> {
104    fn render(self, area: Rect, buffer: &mut Buffer) {
105        draw(self.view, area, buffer, self.cell_write_mode);
106    }
107}
108
109/// A stateless Ratatui widget backed by an Urushi [`BlockStyle`].
110///
111/// This is the single-block case of [`ViewWidget`]. It constructs
112/// `Block(style, Text(content, …))`, while this
113/// widget resolves under the target `Rect`'s width and height constraints.
114#[derive(Debug, Clone, Copy)]
115pub struct RatatuiWidget<'a> {
116    content: &'a str,
117    style: &'a BlockStyle,
118    cell_write_mode: CellWriteMode,
119}
120
121impl<'a> RatatuiWidget<'a> {
122    /// Creates a widget that renders `content` with `style`.
123    pub const fn new(content: &'a str, style: &'a BlockStyle) -> Self {
124        Self {
125            content,
126            style,
127            cell_write_mode: CellWriteMode::Merge,
128        }
129    }
130
131    /// Selects how drawn cells combine with content already in the buffer.
132    pub const fn cell_write_mode(mut self, mode: CellWriteMode) -> Self {
133        self.cell_write_mode = mode;
134        self
135    }
136
137    /// Builds the view this widget draws.
138    fn view(&self) -> View {
139        View::block(
140            self.style.clone(),
141            View::text(self.content, self.style.text_style().clone()),
142        )
143    }
144}
145
146/// Extension methods for adapting an Urushi style to Ratatui.
147pub trait RatatuiStyleExt {
148    /// Adapts this style and `content` into a stateless Ratatui widget.
149    fn widget<'a>(&'a self, content: &'a str) -> RatatuiWidget<'a>;
150}
151
152impl RatatuiStyleExt for BlockStyle {
153    fn widget<'a>(&'a self, content: &'a str) -> RatatuiWidget<'a> {
154        RatatuiWidget::new(content, self)
155    }
156}
157
158impl Widget for RatatuiWidget<'_> {
159    fn render(self, area: Rect, buffer: &mut Buffer) {
160        draw(&self.view(), area, buffer, self.cell_write_mode);
161    }
162}
163
164impl Widget for &RatatuiWidget<'_> {
165    fn render(self, area: Rect, buffer: &mut Buffer) {
166        draw(&self.view(), area, buffer, self.cell_write_mode);
167    }
168}
169
170/// Resolves `view` for `area` and writes the resulting rectangle.
171fn draw(view: &View, area: Rect, buffer: &mut Buffer, mode: CellWriteMode) {
172    draw_resolved_with_mode(
173        &resolve(view, available(area)).expect("a ratatui area supplies finite view geometry"),
174        area,
175        buffer,
176        mode,
177    );
178}
179
180/// Writes an already resolved view into `buffer`, anchored at `area`'s origin.
181///
182/// This is the cell-writing path the widgets take, exposed for a caller that
183/// resolves the view itself — a renderer that needs the resolution as well as
184/// the cells, and resolves exactly once per frame. Resolve under
185/// [`available`] to reproduce what the widgets draw.
186///
187/// The part of `area` outside `buffer` masks which cells are written; it never
188/// moves the rectangle. A grapheme that would straddle the mask is dropped
189/// rather than split, matching how the layout pass crops.
190pub fn draw_resolved(resolved: &ResolvedView, area: Rect, buffer: &mut Buffer) {
191    draw_resolved_with_mode(resolved, area, buffer, CellWriteMode::Merge);
192}
193
194/// Writes an already resolved view with the selected cell composition behavior.
195///
196/// [`CellWriteMode::Merge`] matches Ratatui's native style composition.
197/// [`CellWriteMode::Replace`] resets only cells occupied by the resolved view;
198/// it does not clear the rest of `area`.
199pub fn draw_resolved_with_mode(
200    resolved: &ResolvedView,
201    area: Rect,
202    buffer: &mut Buffer,
203    mode: CellWriteMode,
204) {
205    let clip = area.intersection(buffer.area);
206    if clip.is_empty() {
207        return;
208    }
209    write_cells(resolved, area, clip, buffer, mode);
210}
211
212/// Translates a target rectangle into the area the layout pass resolves under.
213pub fn available(area: Rect) -> Available {
214    Available::size(usize::from(area.width), usize::from(area.height))
215}
216
217/// Translates an anchor and its accumulated visible intersection into `area`.
218///
219/// Returns `None` when no part of the anchor survived core layout clipping or
220/// when its translated coordinates are not representable by Ratatui. The
221/// caller still owns the buffer and decides how to draw the foreign content.
222pub fn anchor_placement(anchor: &AnchoredRect, area: Rect) -> Option<RatatuiAnchor> {
223    let visible = anchor.visible()?;
224    let visible_x = usize::try_from(visible.x()).ok()?;
225    let visible_y = usize::try_from(visible.y()).ok()?;
226    let x = offset(area.x, visible_x)?;
227    let y = offset(area.y, visible_y)?;
228    let width = u16::try_from(visible.width())
229        .ok()?
230        .min(area.right().saturating_sub(x));
231    let height = u16::try_from(visible.height())
232        .ok()?
233        .min(area.bottom().saturating_sub(y));
234    let source_column = usize::try_from(visible.x().checked_sub(anchor.x())?).ok()?;
235    let source_row = usize::try_from(visible.y().checked_sub(anchor.y())?).ok()?;
236
237    Some(RatatuiAnchor {
238        logical: *anchor,
239        destination: Rect::new(x, y, width, height),
240        source_column,
241        source_row,
242    })
243}
244
245/// Writes a resolved rectangle, anchored at `area`'s origin, under `clip`.
246fn write_cells(
247    resolved: &ResolvedView,
248    area: Rect,
249    clip: Rect,
250    buffer: &mut Buffer,
251    mode: CellWriteMode,
252) {
253    visit_resolved(resolved, |column, row, grapheme| {
254        let Some(y) = offset(area.y, row) else {
255            return;
256        };
257        if y < clip.top() {
258            return;
259        }
260        if y >= clip.bottom() {
261            return;
262        }
263        let Some(x) = offset(area.x, column) else {
264            return;
265        };
266        write_grapheme(grapheme, x, y, clip, buffer, mode);
267    });
268}
269
270/// Writes one grapheme, leaving the cells a wide grapheme hides reset.
271pub(crate) fn write_grapheme(
272    grapheme: &StyledGrapheme,
273    x: u16,
274    y: u16,
275    clip: Rect,
276    buffer: &mut Buffer,
277    mode: CellWriteMode,
278) {
279    // Zero-width graphemes have no cell of their own, and a grapheme is never
280    // split across the clip boundary.
281    let Ok(width) = u16::try_from(grapheme.width()) else {
282        return;
283    };
284    if width == 0 {
285        return;
286    }
287    let end = x.saturating_add(width);
288    if x < clip.left() || end > clip.right() {
289        return;
290    }
291
292    let style = RatatuiStyle::from(grapheme.style()).into_inner();
293    if let Some(cell) = buffer.cell_mut((x, y)) {
294        if mode == CellWriteMode::Replace {
295            cell.reset();
296        }
297        cell.set_symbol(grapheme.symbol()).set_style(style);
298    }
299    for hidden in x.saturating_add(1)..end {
300        if let Some(cell) = buffer.cell_mut((hidden, y)) {
301            cell.reset();
302        }
303    }
304}
305
306fn offset(origin: u16, cells: usize) -> Option<u16> {
307    u16::try_from(cells)
308        .ok()
309        .and_then(|cells| origin.checked_add(cells))
310}
311
312/// Visits every leading grapheme cell in row-major order.
313fn visit_resolved(resolved: &ResolvedView, mut visit: impl FnMut(usize, usize, &StyledGrapheme)) {
314    for (row, graphemes) in resolved.rows().iter().enumerate() {
315        let mut column = 0;
316        for grapheme in graphemes {
317            visit(column, row, grapheme);
318            column = column.saturating_add(grapheme.width());
319        }
320    }
321}
322
323#[cfg(test)]
324mod tests {
325    use ::ratatui::style::{Color as RatatuiColor, Modifier};
326    use urushi::{
327        Align, Border, Color, Length, Projection, ProjectionBoundary, TextStyle, VerticalAlign,
328        Viewport,
329    };
330
331    use super::*;
332
333    #[test]
334    fn converts_colors_and_every_supported_attribute() {
335        let converted = RatatuiStyle::from(
336            &TextStyle::new()
337                .foreground(Color::Rgb(1, 2, 3))
338                .background(Color::Ansi256(212))
339                .bold()
340                .dim()
341                .italic()
342                .underlined()
343                .blink()
344                .reverse()
345                .hide()
346                .strikethrough(),
347        )
348        .into_inner();
349
350        assert_eq!(converted.fg, Some(RatatuiColor::Rgb(1, 2, 3)));
351        assert_eq!(converted.bg, Some(RatatuiColor::Indexed(212)));
352        assert_eq!(
353            converted.add_modifier,
354            Modifier::BOLD
355                | Modifier::DIM
356                | Modifier::ITALIC
357                | Modifier::UNDERLINED
358                | Modifier::SLOW_BLINK
359                | Modifier::REVERSED
360                | Modifier::HIDDEN
361                | Modifier::CROSSED_OUT
362        );
363    }
364
365    #[test]
366    fn maps_sixteen_color_palette_to_named_colors() {
367        let expected = [
368            RatatuiColor::Black,
369            RatatuiColor::Red,
370            RatatuiColor::Green,
371            RatatuiColor::Yellow,
372            RatatuiColor::Blue,
373            RatatuiColor::Magenta,
374            RatatuiColor::Cyan,
375            RatatuiColor::Gray,
376            RatatuiColor::DarkGray,
377            RatatuiColor::LightRed,
378            RatatuiColor::LightGreen,
379            RatatuiColor::LightYellow,
380            RatatuiColor::LightBlue,
381            RatatuiColor::LightMagenta,
382            RatatuiColor::LightCyan,
383            RatatuiColor::White,
384        ];
385
386        for (index, expected) in expected.into_iter().enumerate() {
387            let converted =
388                RatatuiStyle::from(&TextStyle::new().foreground(Color::Ansi(index as u8)))
389                    .into_inner();
390            assert_eq!(converted.fg, Some(expected));
391        }
392    }
393
394    /// A block's border colors reach the buffer as the border graphemes' own
395    /// text style, so the adapter needs no separate border-style channel.
396    #[test]
397    fn border_colors_arrive_as_the_border_graphemes_text_style() {
398        let style = BlockStyle::new()
399            .border(Border::ROUNDED)
400            .border_foreground(Color::Rgb(10, 20, 30))
401            .border_background(Color::Ansi256(236));
402        let area = Rect::new(0, 0, 3, 3);
403        let mut buffer = Buffer::empty(area);
404
405        style.widget("x").render(area, &mut buffer);
406
407        let corner = buffer.cell((0, 0)).expect("border cell");
408        assert_eq!(corner.symbol(), "╭");
409        assert_eq!(corner.fg, RatatuiColor::Rgb(10, 20, 30));
410        assert_eq!(corner.bg, RatatuiColor::Indexed(236));
411
412        let content = buffer.cell((1, 1)).expect("content cell");
413        assert_eq!(content.fg, RatatuiColor::Reset);
414        assert_eq!(content.bg, RatatuiColor::Reset);
415    }
416
417    #[test]
418    fn widget_preserves_box_model_alignment_and_cjk_width() {
419        let style = BlockStyle::new()
420            .foreground(Color::GREEN)
421            .background(Color::BLUE)
422            .padding((0, 1))
423            .margin((1, 2))
424            .border(Border::ROUNDED)
425            .border_foreground(Color::RED)
426            // The width measures the outer box: two border columns, two
427            // padding columns, and six cells of content.
428            .width(10)
429            .align(Align::Center);
430        let area = Rect::new(0, 0, 14, 6);
431        let mut buffer = Buffer::empty(area);
432
433        style.widget("日本").render(area, &mut buffer);
434
435        assert_eq!(buffer_line(&buffer, 0), "              ");
436        assert_eq!(buffer_line(&buffer, 1), "  ╭────────╮  ");
437        assert_eq!(buffer_line(&buffer, 2), "  │  日本  │  ");
438        assert_eq!(buffer_line(&buffer, 3), "  ╰────────╯  ");
439        let content = buffer.cell((5, 2)).expect("content cell");
440        assert_eq!(content.fg, RatatuiColor::Green);
441        assert_eq!(content.bg, RatatuiColor::Blue);
442        let border = buffer.cell((2, 1)).expect("border cell");
443        assert_eq!(border.fg, RatatuiColor::Red);
444    }
445
446    /// A `Rect` narrower than the block is an area the box resolves under, so
447    /// the frame closes inside it and the wide content reflows. Only one
448    /// content row fits the three-row area, so `本語` falls outside the box.
449    #[test]
450    fn a_narrow_rect_refits_wide_content_and_the_frame_stays_closed() {
451        let area = Rect::new(0, 0, 5, 3);
452        let mut buffer = Buffer::empty(area);
453
454        BlockStyle::new()
455            .border(Border::NORMAL)
456            .widget("日本語")
457            .render(area, &mut buffer);
458
459        assert_eq!(buffer_line(&buffer, 0), "┌───┐");
460        assert_eq!(buffer_line(&buffer, 1), "│日 │");
461        assert_eq!(buffer_line(&buffer, 2), "└───┘");
462    }
463
464    #[test]
465    fn widget_is_safe_for_zero_and_narrow_areas() {
466        let style = BlockStyle::new().border(Border::NORMAL).padding(2);
467        let mut empty = Buffer::empty(Rect::new(0, 0, 0, 0));
468        style
469            .widget("content")
470            .render(Rect::new(0, 0, 0, 0), &mut empty);
471
472        let area = Rect::new(0, 0, 1, 1);
473        let mut narrow = Buffer::empty(area);
474        style.widget("content").render(area, &mut narrow);
475        assert_eq!(buffer_line(&narrow, 0), "┌");
476    }
477
478    #[test]
479    fn widget_styles_only_enabled_border_edges() {
480        let style = BlockStyle::new()
481            .foreground(Color::GREEN)
482            .border(Border::NORMAL)
483            .border_top(false)
484            .border_right(false)
485            .border_bottom(false)
486            .border_foreground(Color::RED);
487        let area = Rect::new(0, 0, 2, 1);
488        let mut buffer = Buffer::empty(area);
489
490        style.widget("x").render(area, &mut buffer);
491
492        assert_eq!(buffer_line(&buffer, 0), "│x");
493        assert_eq!(
494            buffer.cell((0, 0)).expect("border cell").fg,
495            RatatuiColor::Red
496        );
497        assert_eq!(
498            buffer.cell((1, 0)).expect("content cell").fg,
499            RatatuiColor::Green
500        );
501    }
502
503    /// A one-cell area is a degenerate case, and the axes degrade differently:
504    /// the content may vanish vertically, so a horizontal edge takes the cell,
505    /// while the width cannot go below one unsplittable grapheme, so a
506    /// vertical edge leaves the box two cells wide and the safety net keeps
507    /// its left cell.
508    #[test]
509    fn a_one_cell_area_degrades_to_the_frame_or_the_leading_cell() {
510        // (case, the one enabled edge as (top, right, bottom, left), expected)
511        let cases = [
512            ("top edge", (true, false, false, false), "-"),
513            ("left edge", (false, false, false, true), "|"),
514            ("bottom edge", (false, false, true, false), "-"),
515            ("right edge", (false, true, false, false), "x"),
516        ];
517
518        for (case, (top, right, bottom, left), expected) in cases {
519            let style = BlockStyle::new()
520                .border(Border::ASCII)
521                .border_top(top)
522                .border_right(right)
523                .border_bottom(bottom)
524                .border_left(left);
525            let area = Rect::new(0, 0, 1, 1);
526            let mut buffer = Buffer::empty(area);
527
528            style.widget("x").render(area, &mut buffer);
529
530            assert_eq!(buffer_line(&buffer, 0), expected, "{case}");
531        }
532    }
533
534    /// The widget resolves the same block-and-text view as the core pass, so a `Rect`
535    /// at least as large as the block reproduces the direct output verbatim.
536    #[test]
537    fn widget_matches_direct_rendering_for_border_side_combinations() {
538        let styles = [
539            BlockStyle::new().border(Border::ASCII),
540            BlockStyle::new()
541                .border(Border::ASCII)
542                .border_top(false)
543                .border_right(false)
544                .border_bottom(false)
545                .border_left(false),
546            BlockStyle::new()
547                .border(Border::ASCII)
548                .border_right(false)
549                .border_bottom(false)
550                .border_left(false),
551            BlockStyle::new()
552                .border(Border::ASCII)
553                .border_top(false)
554                .border_right(false)
555                .border_bottom(false),
556            BlockStyle::new()
557                .border(Border::ASCII)
558                .border_right(false)
559                .border_bottom(false),
560            BlockStyle::new()
561                .border(Border::ASCII)
562                .border_right(false)
563                .border_left(false),
564            BlockStyle::new()
565                .border(Border::ASCII)
566                .border_top(false)
567                .border_bottom(false),
568        ];
569
570        for (index, style) in styles.into_iter().enumerate() {
571            assert_widget_matches_direct(&style, "x", &format!("border combination {index}"));
572        }
573    }
574
575    #[test]
576    fn widget_matches_direct_rendering_for_fixed_height_content_cases() {
577        let cases = [
578            ("empty", ""),
579            ("single line", "x"),
580            ("multiple lines", "x\ny"),
581            ("CJK", "日本"),
582            ("overflow", "a\nb\nc\nd\ne"),
583        ];
584
585        for (case, content) in cases {
586            let style = BlockStyle::new()
587                .width(6)
588                .height(4)
589                .padding((1, 1))
590                .border(Border::ASCII)
591                .border_top(false);
592            assert_widget_matches_direct(&style, content, case);
593        }
594    }
595
596    #[test]
597    fn widget_matches_direct_vertical_alignment_with_horizontal_alignment_and_cjk() {
598        for horizontal in [Align::Left, Align::Center, Align::Right] {
599            for vertical in [
600                VerticalAlign::Top,
601                VerticalAlign::Center,
602                VerticalAlign::Bottom,
603            ] {
604                let style = BlockStyle::new()
605                    .width(6)
606                    .height(7)
607                    .padding((1, 1))
608                    .align(horizontal)
609                    .vertical_align(vertical);
610                assert_widget_matches_direct(
611                    &style,
612                    "日\nx",
613                    &format!("{horizontal:?} {vertical:?}"),
614                );
615            }
616        }
617    }
618
619    #[test]
620    fn widget_matches_direct_rendering_for_maximum_dimensions() {
621        let style = BlockStyle::new()
622            .width(6)
623            .height(4)
624            .padding((1, 1))
625            .border(Border::ASCII)
626            .margin(1)
627            .max_width(6)
628            .max_height(4);
629
630        assert_widget_matches_direct(&style, "ab", "maximum dimensions");
631    }
632
633    #[test]
634    fn widget_expands_narrow_fixed_width_for_cjk_parity() {
635        // A grapheme wider than the requested width expands the block rather
636        // than being dropped, in both backends.
637        assert_widget_matches_direct(&BlockStyle::new().width(1).height(2), "日本", "wrapped");
638        assert_widget_matches_direct(&BlockStyle::new().width(1), "日", "single grapheme");
639    }
640
641    #[test]
642    fn widget_matches_direct_maximum_width_cropping_of_wide_graphemes() {
643        // Cropping drops a straddling grapheme rather than splitting it, and
644        // the freed cells keep the block rectangular.
645        assert_widget_matches_direct(&BlockStyle::new().max_width(3), "日本語", "CJK");
646        assert_widget_matches_direct(&BlockStyle::new().max_width(3), "👩‍💻x", "ZWJ emoji");
647        assert_widget_matches_direct(
648            &BlockStyle::new().margin((0, 0, 0, 1)).max_width(2),
649            "日",
650            "margin before a wide cell",
651        );
652    }
653
654    /// A `Rect` smaller than the block is the area it resolves under, so the
655    /// box takes the area's height and the alignment places the content inside
656    /// what actually fits.
657    #[test]
658    fn a_rect_smaller_than_the_block_resizes_it() {
659        let style = BlockStyle::new().width(4).height(6);
660        let area = Rect::new(0, 0, 4, 4);
661
662        for (align, expected_row) in [
663            (VerticalAlign::Center, Some(1)),
664            (VerticalAlign::Bottom, Some(3)),
665        ] {
666            let style = style.clone().vertical_align(align);
667            let mut buffer = Buffer::empty(area);
668
669            style.widget("x").render(area, &mut buffer);
670
671            for y in 0..area.height {
672                let expected = if Some(y) == expected_row {
673                    "x   "
674                } else {
675                    "    "
676                };
677                assert_eq!(buffer_line(&buffer, y), expected, "{align:?} row {y}");
678            }
679        }
680    }
681
682    #[test]
683    fn a_shorter_area_closes_the_frame_instead_of_dropping_its_bottom_edge() {
684        let style = BlockStyle::new().width(4).height(6).border(Border::ASCII);
685        let area = Rect::new(0, 0, 6, 4);
686        let mut buffer = Buffer::empty(area);
687
688        style.widget("x").render(area, &mut buffer);
689
690        // The box resolves to 4x4 under the area, so the bottom edge is drawn
691        // at the fourth row instead of falling outside it.
692        assert_eq!(buffer_line(&buffer, 0), "+--+  ");
693        assert_eq!(buffer_line(&buffer, 1), "|x |  ");
694        assert_eq!(buffer_line(&buffer, 2), "|  |  ");
695        assert_eq!(buffer_line(&buffer, 3), "+--+  ");
696    }
697
698    /// A buffer covering only part of the target `Rect` masks which cells are
699    /// written; it never moves the layout's origin.
700    #[test]
701    fn a_partial_buffer_masks_cells_without_moving_the_layout() {
702        let style = BlockStyle::new().width(10);
703        let mut buffer = Buffer::empty(Rect::new(5, 0, 5, 1));
704
705        style
706            .widget("abcdefghij")
707            .render(Rect::new(0, 0, 10, 1), &mut buffer);
708
709        assert_eq!(buffer_line(&buffer, 0), "fghij");
710
711        let style = BlockStyle::new().width(1).height(4);
712        let mut buffer = Buffer::empty(Rect::new(0, 2, 1, 2));
713
714        style
715            .widget("a\nb\nc\nd")
716            .render(Rect::new(0, 0, 1, 4), &mut buffer);
717
718        assert_eq!(buffer.cell((0, 2)).expect("third row").symbol(), "c");
719        assert_eq!(buffer.cell((0, 3)).expect("fourth row").symbol(), "d");
720    }
721
722    #[test]
723    fn view_widget_draws_a_bordered_block_inside_a_row() {
724        let view = View::row(
725            VerticalAlign::Center,
726            [
727                View::text("status: ", TextStyle::new()),
728                View::block(
729                    BlockStyle::new()
730                        .border(Border::ROUNDED)
731                        .border_foreground(Color::GREEN),
732                    View::text("ok", TextStyle::new().foreground(Color::GREEN)),
733                ),
734            ],
735        );
736        let area = Rect::new(0, 0, 12, 3);
737        let mut buffer = Buffer::empty(area);
738
739        ViewWidget::new(&view).render(area, &mut buffer);
740
741        assert_eq!(buffer_line(&buffer, 0), "        ╭──╮");
742        assert_eq!(buffer_line(&buffer, 1), "status: │ok│");
743        assert_eq!(buffer_line(&buffer, 2), "        ╰──╯");
744        assert_eq!(
745            buffer.cell((8, 0)).expect("border cell").fg,
746            RatatuiColor::Green
747        );
748    }
749
750    /// A caller that resolves the view itself — the renderer, which needs the
751    /// resolution as well as the cells — reaches the same buffer the widget
752    /// draws, because both take one cell-writing path.
753    #[test]
754    fn resolving_first_and_drawing_reaches_the_same_cells_as_the_widget() {
755        let view = View::row(
756            VerticalAlign::Center,
757            [
758                View::text("status: ", TextStyle::new()),
759                View::block(
760                    BlockStyle::new()
761                        .border(Border::ROUNDED)
762                        .border_foreground(Color::GREEN),
763                    View::text("日本", TextStyle::new().foreground(Color::GREEN)),
764                ),
765            ],
766        );
767        // A buffer narrower than the target rectangle also masks the cells, so
768        // the two paths have to agree on the crop as well as the content.
769        let area = Rect::new(1, 0, 14, 3);
770        let mut through_widget = Buffer::empty(Rect::new(0, 0, 10, 3));
771        let mut through_resolved = Buffer::empty(Rect::new(0, 0, 10, 3));
772
773        ViewWidget::new(&view).render(area, &mut through_widget);
774        draw_resolved(
775            &resolve(&view, available(area)).expect("a ratatui area supplies finite view geometry"),
776            area,
777            &mut through_resolved,
778        );
779
780        assert_eq!(through_resolved, through_widget);
781        // The comparison is only meaningful because cells were written and the
782        // buffer cropped the block's right half.
783        assert_eq!(buffer_line(&through_widget, 1), " status: │");
784    }
785
786    #[test]
787    fn view_widget_matches_one_shot_viewport_resolution() {
788        let view = View::viewport(
789            Viewport::both(
790                Projection::new(1, ProjectionBoundary::Preserve),
791                Projection::new(1, ProjectionBoundary::Preserve),
792            ),
793            View::text("abcd\nefgh\nijkl", TextStyle::new()),
794        );
795        let area = Rect::new(2, 1, 2, 2);
796        let mut through_widget = Buffer::empty(Rect::new(0, 0, 6, 4));
797        let mut through_resolved = Buffer::empty(Rect::new(0, 0, 6, 4));
798
799        ViewWidget::new(&view).render(area, &mut through_widget);
800        draw_resolved(
801            &resolve(&view, available(area)).expect("finite viewport geometry"),
802            area,
803            &mut through_resolved,
804        );
805
806        assert_eq!(through_widget, through_resolved);
807        assert_eq!(buffer_line(&through_widget, 1), "  fg  ");
808        assert_eq!(buffer_line(&through_widget, 2), "  jk  ");
809    }
810
811    #[test]
812    fn partially_visible_anchor_maps_destination_and_source_offset() {
813        let view = View::viewport(
814            Viewport::horizontal(Projection::new(2, ProjectionBoundary::Preserve)),
815            View::anchor_block(
816                "foreign",
817                BlockStyle::new()
818                    .width(Length::Cells(4))
819                    .height(Length::Cells(1)),
820                View::empty(),
821            ),
822        );
823        let area = Rect::new(10, 5, 3, 1);
824        let resolved = resolve(&view, available(area)).expect("finite viewport geometry");
825        let anchor = resolved.anchors().first().expect("foreign anchor");
826
827        let placement = anchor_placement(anchor, area).expect("partially visible placement");
828
829        assert_eq!(placement.logical().x(), -2);
830        assert_eq!(placement.logical().width(), 4);
831        assert_eq!(placement.destination(), Rect::new(10, 5, 2, 1));
832        assert_eq!(placement.source_column(), 2);
833        assert_eq!(placement.source_row(), 0);
834    }
835
836    /// Asserts the widget reproduces the resolved block in a `Rect` sized to it.
837    fn assert_widget_matches_direct(style: &BlockStyle, content: &str, case: &str) {
838        let view = View::block(
839            style.clone(),
840            View::text(content, style.text_style().clone()),
841        );
842        let direct = resolve(&view, Available::NONE).unwrap();
843        let expected: Vec<String> = direct
844            .rows()
845            .iter()
846            .map(|row| row.iter().map(StyledGrapheme::symbol).collect())
847            .collect();
848        let area = Rect::new(
849            0,
850            0,
851            u16::try_from(direct.size().width()).expect("block width"),
852            u16::try_from(direct.size().height()).expect("block height"),
853        );
854        let mut buffer = Buffer::empty(area);
855
856        style.widget(content).render(area, &mut buffer);
857
858        for (y, expected_line) in expected.into_iter().enumerate() {
859            assert_eq!(buffer_line(&buffer, y as u16), expected_line, "{case}");
860        }
861    }
862
863    fn buffer_line(buffer: &Buffer, y: u16) -> String {
864        let mut line = String::new();
865        let mut x = buffer.area.left();
866        while x < buffer.area.right() {
867            let symbol = buffer.cell((x, y)).expect("cell").symbol();
868            line.push_str(symbol);
869            let width = urushi::PrintableText::new(symbol)
870                .width()
871                .max(1)
872                .min(usize::from(u16::MAX)) as u16;
873            x = x.saturating_add(width);
874        }
875        line
876    }
877}