Skip to main content

urushi/view/
resolve.rs

1//! The one pass: a [`View`] and an [`Available`] area in, one rectangle out.
2//!
3//! Resolution is a pure function of those two inputs — no terminal state, no
4//! capability profile, no escape sequences. Its output is a [`ResolvedView`]:
5//! a size and rows of graphemes carrying logical styles and the widths this
6//! pass decided, which is the single thing both backends draw. That is why the
7//! ANSI string and the Ratatui buffer cannot disagree about geometry.
8//!
9//! The pass is three phases, in the order `docs/design/layout-resolution.md`
10//! fixes and in the only order the dependencies allow. [`width`](super::width)
11//! settles every width, because wrapping needs a width to wrap to.
12//! [`height`](super::height) then fits the text and counts the rows, because a
13//! height is what wrapping produced. [`assemble`](super::assemble) then builds
14//! the rectangle those numbers describe. At a Canvas leaf, each command
15//! rasterizes against the settled dimensions without access to the destination
16//! surface and is immediately passed to the common compositor. This module is
17//! the entry point that runs the phases and the degenerate-case safety net that
18//! bounds the result.
19
20use crate::text::Grapheme;
21use crate::{Key, TextStyle, View};
22
23use super::assemble::{AssemblyCache, assemble, assemble_retained};
24use super::geometry::{Available, Constraint, Size};
25use super::height::{fit, heights};
26use super::width::widths;
27
28/// An axis for which a finite Canvas extent was required.
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub enum Axis {
31    Width,
32    Height,
33}
34
35/// The input whose finite extent is missing.
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37pub enum LayoutErrorKind {
38    CanvasExtent,
39    ViewAllocation,
40    ViewportExtent,
41}
42
43/// A layout request that cannot produce finite geometry.
44#[derive(Debug, Clone, Copy, PartialEq, Eq)]
45pub struct LayoutError {
46    kind: LayoutErrorKind,
47    axis: Axis,
48}
49
50impl LayoutError {
51    pub const fn axis(&self) -> Axis {
52        self.axis
53    }
54    pub const fn kind(&self) -> LayoutErrorKind {
55        self.kind
56    }
57    pub(crate) const fn missing_extent(axis: Axis) -> Self {
58        Self {
59            kind: LayoutErrorKind::CanvasExtent,
60            axis,
61        }
62    }
63    pub(crate) const fn missing_allocation(axis: Axis) -> Self {
64        Self {
65            kind: LayoutErrorKind::ViewAllocation,
66            axis,
67        }
68    }
69    pub(crate) const fn missing_viewport_extent(axis: Axis) -> Self {
70        Self {
71            kind: LayoutErrorKind::ViewportExtent,
72            axis,
73        }
74    }
75}
76
77impl std::fmt::Display for LayoutError {
78    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
79        match self.kind {
80            LayoutErrorKind::CanvasExtent => write!(
81                f,
82                "Canvas requires an explicit {:?} on an unbounded axis",
83                self.axis
84            ),
85            LayoutErrorKind::ViewAllocation => write!(
86                f,
87                "a Canvas View command with Fill requires a finite {:?} allocation",
88                self.axis
89            ),
90            LayoutErrorKind::ViewportExtent => write!(
91                f,
92                "Viewport projection requires a finite {:?} allocation",
93                self.axis
94            ),
95        }
96    }
97}
98
99impl std::error::Error for LayoutError {}
100
101/// One grapheme, the width it occupies, and its logical style.
102///
103/// A renderer cannot split a grapheme cluster or a wide character, because it
104/// never sees text below this granularity, and it cannot disagree with the
105/// layout pass about a width, because the width it needs is in the token.
106#[derive(Debug, Clone, PartialEq, Eq)]
107pub struct StyledGrapheme {
108    symbol: String,
109    width: usize,
110    style: TextStyle,
111}
112
113impl StyledGrapheme {
114    /// Creates one styled grapheme, measuring the cells it occupies.
115    ///
116    /// The width is derived rather than supplied, so a token cannot claim a
117    /// width its symbol does not have. Taking a [`Grapheme`] rather than a
118    /// string closes the other half: a token holds one cluster, which is what
119    /// a backend assumes when it writes the symbol into the cell its width
120    /// starts at.
121    ///
122    /// Where a terminal-dependent measure would enter is
123    /// [`text::width`](crate::text), the crate's one definition, and not this
124    /// constructor. The pass still decides the width once and the renderers
125    /// still read it here rather than measuring again.
126    pub(crate) fn new(symbol: &Grapheme, style: TextStyle) -> Self {
127        Self {
128            symbol: symbol.as_str().to_owned(),
129            width: symbol.width(),
130            style,
131        }
132    }
133
134    pub fn symbol(&self) -> &str {
135        &self.symbol
136    }
137
138    pub const fn width(&self) -> usize {
139        self.width
140    }
141
142    pub const fn style(&self) -> &TextStyle {
143        &self.style
144    }
145
146    /// Creates one cell-wide blank carrying `style`.
147    ///
148    /// Renderers use this when a presentation layer replaces a resolved glyph
149    /// while retaining cell styling such as its background color.
150    pub fn space(style: TextStyle) -> Self {
151        Self::new(Grapheme::space(), style)
152    }
153}
154
155/// The part of an anchor rectangle that survived every enclosing clip.
156///
157/// Coordinates remain relative to the final [`ResolvedView`]. A clipped
158/// rectangle is never moved onto an edge; this value is the true intersection.
159#[derive(Debug, Clone, Copy, PartialEq, Eq)]
160pub struct VisibleRect {
161    x: i64,
162    y: i64,
163    width: usize,
164    height: usize,
165}
166
167impl VisibleRect {
168    pub const fn x(&self) -> i64 {
169        self.x
170    }
171
172    pub const fn y(&self) -> i64 {
173        self.y
174    }
175
176    pub const fn width(&self) -> usize {
177        self.width
178    }
179
180    pub const fn height(&self) -> usize {
181        self.height
182    }
183}
184
185/// One anchor's complete logical rectangle and its visible intersection.
186///
187/// The logical rectangle is stated relative to the resolved view's own
188/// top-left cell. It is translated but never clipped or rounded onto an edge.
189/// [`visible`](Self::visible) separately reports what survived Block, Canvas,
190/// Viewport, and final safety clips.
191#[derive(Debug, Clone, Copy, PartialEq, Eq)]
192pub struct AnchoredRect {
193    key: Key,
194    x: i64,
195    y: i64,
196    width: usize,
197    height: usize,
198    visible: Option<VisibleRect>,
199}
200
201impl AnchoredRect {
202    pub(super) const fn new(key: Key, x: usize, y: usize, width: usize, height: usize) -> Self {
203        Self {
204            key,
205            x: x as i64,
206            y: y as i64,
207            width,
208            height,
209            visible: Some(VisibleRect {
210                x: x as i64,
211                y: y as i64,
212                width,
213                height,
214            }),
215        }
216    }
217
218    /// The key the anchor carried.
219    pub const fn key(&self) -> Key {
220        self.key
221    }
222
223    /// Cells from the resolved view's left edge.
224    pub const fn x(&self) -> i64 {
225        self.x
226    }
227
228    /// Rows from the resolved view's top edge.
229    pub const fn y(&self) -> i64 {
230        self.y
231    }
232
233    pub const fn width(&self) -> usize {
234        self.width
235    }
236
237    pub const fn height(&self) -> usize {
238        self.height
239    }
240
241    /// Returns whether the region covers no cells, as a cursor anchor does.
242    pub const fn is_empty(&self) -> bool {
243        self.width == 0 || self.height == 0
244    }
245
246    /// Returns whether the [`ResolvedView`] this came from contains the whole
247    /// region.
248    ///
249    /// A partially visible or outside region returns false. A zero-sized
250    /// cursor point returns true only when it lies inside every half-open clip;
251    /// a point on the right or bottom edge is outside.
252    pub const fn is_within_resolved_view(&self) -> bool {
253        match self.visible {
254            Some(_) if self.width == 0 && self.height == 0 => true,
255            Some(visible) => {
256                visible.x == self.x
257                    && visible.y == self.y
258                    && visible.width == self.width
259                    && visible.height == self.height
260            }
261            None => false,
262        }
263    }
264
265    /// The part of the logical rectangle that survived every enclosing clip.
266    pub const fn visible(&self) -> Option<VisibleRect> {
267        self.visible
268    }
269
270    /// Settles
271    /// [`is_within_resolved_view`](Self::is_within_resolved_view) against the
272    /// rectangle this is reported with.
273    pub(super) fn locate(mut self, resolved: Size) -> Self {
274        self.clip(0, 0, resolved.width(), resolved.height());
275        self
276    }
277
278    /// Moves the rectangle by the offset a parent nests it at.
279    ///
280    /// This is what assembly applies as it nests a rectangle inside a larger
281    /// one: every offset a parent introduces — padding, a border, a margin, a
282    /// sibling to the left, an alignment gap — moves the anchors within it.
283    pub(super) const fn offset(mut self, x: i64, y: i64) -> Self {
284        self.x = self.x.saturating_add(x);
285        self.y = self.y.saturating_add(y);
286        if let Some(visible) = &mut self.visible {
287            visible.x = visible.x.saturating_add(x);
288            visible.y = visible.y.saturating_add(y);
289        }
290        self
291    }
292
293    pub(super) fn clip(&mut self, x: i64, y: i64, width: usize, height: usize) {
294        let Some(visible) = self.visible else {
295            return;
296        };
297        let right = x.saturating_add(i64::try_from(width).unwrap_or(i64::MAX));
298        let bottom = y.saturating_add(i64::try_from(height).unwrap_or(i64::MAX));
299
300        if visible.width == 0 && visible.height == 0 {
301            if visible.x < x || visible.x >= right || visible.y < y || visible.y >= bottom {
302                self.visible = None;
303            }
304            return;
305        }
306
307        let visible_right = visible
308            .x
309            .saturating_add(i64::try_from(visible.width).unwrap_or(i64::MAX));
310        let visible_bottom = visible
311            .y
312            .saturating_add(i64::try_from(visible.height).unwrap_or(i64::MAX));
313        let left = visible.x.max(x);
314        let top = visible.y.max(y);
315        let clipped_right = visible_right.min(right);
316        let clipped_bottom = visible_bottom.min(bottom);
317        if clipped_right <= left || clipped_bottom <= top {
318            self.visible = None;
319            return;
320        }
321        self.visible = Some(VisibleRect {
322            x: left,
323            y: top,
324            width: usize::try_from(clipped_right - left).unwrap_or(usize::MAX),
325            height: usize::try_from(clipped_bottom - top).unwrap_or(usize::MAX),
326        });
327    }
328}
329
330/// A view resolved to one rectangle of styled graphemes.
331///
332/// Every row's widths sum to `size.width()`, and the row count equals
333/// `size.height()`. Styles are logical: a
334/// [`RenderSettings`](crate::RenderSettings) is applied when a renderer
335/// serializes the rectangle, so capability resolution stays at the output
336/// boundary.
337#[derive(Debug, Clone, PartialEq, Eq)]
338pub struct ResolvedView {
339    size: Size,
340    rows: Vec<Vec<StyledGrapheme>>,
341    anchors: Vec<AnchoredRect>,
342}
343
344/// An opt-in evaluator that reuses unchanged View evaluation across frames.
345///
346/// `Resolver` has the same observable result as [`resolve`]. It retains only
347/// core reconciliation inputs and materialized output; the current View and
348/// available area still decide layout. Application state, clocks, redraw
349/// scheduling, terminal capabilities, and graphics protocol state stay with
350/// the host that composes those independent capabilities.
351///
352/// The free [`resolve`] function remains the ordinary stateless path and does
353/// not construct this cache metadata.
354pub struct Resolver {
355    previous_available: Option<Available>,
356    previous_result: Option<ResolvedView>,
357    assembly: AssemblyCache,
358}
359
360impl Resolver {
361    /// Creates an empty retained evaluator.
362    pub const fn new() -> Self {
363        Self {
364            previous_available: None,
365            previous_result: None,
366            assembly: AssemblyCache::new(),
367        }
368    }
369
370    /// Resolves one immutable View snapshot, reusing unchanged subtree output.
371    ///
372    /// A changed Viewport origin reprojects its retained child. Changes to
373    /// content or to a settled layout input invalidate the affected artifact,
374    /// while independent unchanged subtrees remain reusable.
375    pub fn resolve(
376        &mut self,
377        view: &View,
378        available: Available,
379    ) -> Result<ResolvedView, LayoutError> {
380        if self.assembly.matches_previous_view(view)
381            && self.previous_available == Some(available)
382            && let Some(result) = &self.previous_result
383        {
384            return Ok(result.clone());
385        }
386
387        let fitted = fit(widths(view, available.width()));
388        let sized = heights(&fitted, Constraint::available(available.height()));
389        validate_canvas_extents(&sized)?;
390
391        self.assembly.begin_frame(view);
392        let mut rect = match assemble_retained(&sized, &mut self.assembly) {
393            Ok(rect) => rect,
394            Err(error) => {
395                self.assembly.abort_frame();
396                return Err(error);
397            }
398        };
399        crop_to_available(&mut rect, available);
400        let result = resolved(rect);
401        self.assembly.finish_frame(view);
402        self.previous_available = Some(available);
403        self.previous_result = Some(result.clone());
404        Ok(result)
405    }
406
407    /// Drops every retained evaluation artifact.
408    pub fn clear(&mut self) {
409        self.previous_available = None;
410        self.previous_result = None;
411        self.assembly.clear();
412    }
413}
414
415impl Default for Resolver {
416    fn default() -> Self {
417        Self::new()
418    }
419}
420
421impl ResolvedView {
422    pub(crate) fn new(
423        size: Size,
424        rows: Vec<Vec<StyledGrapheme>>,
425        anchors: Vec<AnchoredRect>,
426    ) -> Self {
427        Self {
428            size,
429            rows,
430            anchors,
431        }
432    }
433
434    pub const fn size(&self) -> Size {
435        self.size
436    }
437
438    pub fn rows(&self) -> &[Vec<StyledGrapheme>] {
439        &self.rows
440    }
441
442    /// Where each anchor in the tree landed, in tree order — a box before
443    /// what it encloses.
444    ///
445    /// A tree carrying no anchor reports nothing, which is every view built
446    /// before anchors existed.
447    pub fn anchors(&self) -> &[AnchoredRect] {
448        &self.anchors
449    }
450
451    /// The region reported under `key`.
452    ///
453    /// This is the ordinary read: a caller filling one region asks for it by
454    /// name rather than scanning [`anchors`](Self::anchors).
455    ///
456    /// ```
457    /// use urushi::{Available, BlockStyle, TextStyle, VerticalAlign, View, resolve};
458    ///
459    /// let view = View::row(
460    ///     VerticalAlign::Top,
461    ///     [View::text("> ", TextStyle::new()), View::anchor("cursor")],
462    /// );
463    /// let resolved = resolve(&view, Available::NONE).unwrap();
464    ///
465    /// assert_eq!(resolved.anchor("cursor").unwrap().x(), 2);
466    /// assert!(resolved.anchor("elsewhere").is_none());
467    /// ```
468    pub fn anchor(&self, key: impl Into<Key>) -> Option<&AnchoredRect> {
469        let key = key.into();
470        self.anchors.iter().find(|anchor| anchor.key == key)
471    }
472}
473
474/// Returns the intrinsic rectangle `view` occupies: its size when no area
475/// bounds it.
476///
477/// This is [`resolve`] under [`Available::NONE`], not a second set of rules —
478/// the same two sizing phases, stopping before the rectangle they describe is
479/// built. No rectangle is allocated.
480pub fn measure(view: &View) -> Size {
481    try_measure(view).expect("intrinsic measurement requires every finite extent to be stated")
482}
483
484/// Tries to measure a view, reporting any required finite extent that is not
485/// established within the tree.
486pub fn try_measure(view: &View) -> Result<Size, LayoutError> {
487    let fitted = fit(widths(view, None));
488    let sized = heights(&fitted, Constraint::unbounded());
489    validate_canvas_extents(&sized)?;
490    Ok(Size::new(sized.width, sized.height))
491}
492
493/// Resolves `view` into one rectangle sized under `available`.
494///
495/// Every node resolves its own size under the area, so a bound reshapes a box
496/// rather than cutting it. The crop below is the degenerate-case safety net:
497/// it fires only when a rectangle could not be made to fit — an area that
498/// cannot hold a frame at all — and it cuts grapheme-atomically.
499pub fn resolve(view: &View, available: Available) -> Result<ResolvedView, LayoutError> {
500    let fitted = fit(widths(view, available.width()));
501    let sized = heights(&fitted, Constraint::available(available.height()));
502    validate_canvas_extents(&sized)?;
503    let mut rect = assemble(&sized)?;
504    crop_to_available(&mut rect, available);
505    Ok(resolved(rect))
506}
507
508fn crop_to_available(rect: &mut super::assemble::Rect, available: Available) {
509    if let Some(width) = available.width() {
510        rect.crop_width(width, &TextStyle::new());
511    }
512    if let Some(height) = available.height() {
513        rect.crop_height(height);
514    }
515}
516
517fn resolved(rect: super::assemble::Rect) -> ResolvedView {
518    debug_assert_unique(&rect.anchors);
519    let size = rect.size();
520    let anchors = rect
521        .anchors
522        .into_iter()
523        .map(|anchor| anchor.locate(size))
524        .collect();
525    ResolvedView::new(size, rect.rows, anchors)
526}
527
528fn validate_canvas_extents(sized: &super::height::Sized<'_>) -> Result<(), LayoutError> {
529    use super::height::SizedNode;
530    match &sized.node {
531        SizedNode::Canvas {
532            canvas,
533            width_bounded,
534            height_bounded,
535            ..
536        } => {
537            if canvas.uses_viewport_sizing() && !width_bounded && canvas.explicit_width().is_none()
538            {
539                return Err(LayoutError::missing_extent(Axis::Width));
540            }
541            if canvas.uses_viewport_sizing()
542                && !height_bounded
543                && canvas.explicit_height().is_none()
544            {
545                return Err(LayoutError::missing_extent(Axis::Height));
546            }
547            Ok(())
548        }
549        SizedNode::Block { child, .. } => validate_canvas_extents(child),
550        SizedNode::Viewport {
551            viewport,
552            width_bounded,
553            height_bounded,
554            child,
555        } => {
556            if viewport.horizontal_projection().is_some() && !width_bounded {
557                return Err(LayoutError::missing_viewport_extent(Axis::Width));
558            }
559            if viewport.vertical_projection().is_some() && !height_bounded {
560                return Err(LayoutError::missing_viewport_extent(Axis::Height));
561            }
562            validate_canvas_extents(child)
563        }
564        SizedNode::Row(_, children) | SizedNode::Column(_, children) => {
565            children.iter().try_for_each(validate_canvas_extents)
566        }
567        SizedNode::Grid { rows, .. } => rows
568            .iter()
569            .flatten()
570            .try_for_each(|cell| validate_canvas_extents(&cell.child)),
571        SizedNode::Text { .. } => Ok(()),
572    }
573}
574
575/// Asserts that no key names two regions.
576///
577/// One key, one region: a caller reads a region by the name it gave, and two
578/// answers to one name is a mistake in the tree rather than something layout
579/// can resolve. As with escape sequences in a `Text` node, this is a contract
580/// violation detected as a development aid — debug builds assert, release
581/// builds report both regions in tree order and leave the caller to whatever
582/// it makes of them.
583fn debug_assert_unique(anchors: &[AnchoredRect]) {
584    debug_assert!(
585        {
586            let mut seen = std::collections::HashSet::with_capacity(anchors.len());
587            anchors.iter().all(|anchor| seen.insert(anchor.key()))
588        },
589        "two anchors carry one key; a key names one region"
590    );
591}