Skip to main content

urushi/view/
model.rs

1//! Renderer-neutral terminal output.
2
3use crate::{Align, BlockStyle, Canvas, GridStyle, Key, StyledText, TextStyle, VerticalAlign};
4
5/// How a requested projection origin behaves at a content boundary.
6#[derive(Debug, Clone, Copy, PartialEq, Eq)]
7pub enum ProjectionBoundary {
8    /// Uses the requested origin exactly, including negative origins and
9    /// origins beyond the content extent.
10    Preserve,
11    /// Constrains the origin so content fills the viewport when it can.
12    Clamp,
13}
14
15/// One projected axis of a [`Viewport`].
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub struct Projection {
18    origin: i64,
19    boundary: ProjectionBoundary,
20}
21
22impl Projection {
23    pub const fn new(origin: i64, boundary: ProjectionBoundary) -> Self {
24        Self { origin, boundary }
25    }
26
27    pub const fn origin(&self) -> i64 {
28        self.origin
29    }
30
31    pub const fn boundary(&self) -> ProjectionBoundary {
32        self.boundary
33    }
34}
35
36/// The axes one child view projects into its finite layout allocation.
37///
38/// Construction requires at least one projected axis. An absent axis follows
39/// the child's ordinary layout rules.
40#[derive(Debug, Clone, Copy, PartialEq, Eq)]
41pub struct Viewport {
42    horizontal: Option<Projection>,
43    vertical: Option<Projection>,
44}
45
46impl Viewport {
47    pub const fn horizontal(projection: Projection) -> Self {
48        Self {
49            horizontal: Some(projection),
50            vertical: None,
51        }
52    }
53
54    pub const fn vertical(projection: Projection) -> Self {
55        Self {
56            horizontal: None,
57            vertical: Some(projection),
58        }
59    }
60
61    pub const fn both(horizontal: Projection, vertical: Projection) -> Self {
62        Self {
63            horizontal: Some(horizontal),
64            vertical: Some(vertical),
65        }
66    }
67
68    pub const fn horizontal_projection(&self) -> Option<Projection> {
69        self.horizontal
70    }
71
72    pub const fn vertical_projection(&self) -> Option<Projection> {
73        self.vertical
74    }
75}
76
77/// One styled line embedded in a block's top border.
78///
79/// A title is content owned by a block, not part of its [`BlockStyle`]. Its
80/// text carries complete styles, and its alignment and padding apply only
81/// within the top edge between enabled side borders.
82///
83/// ```
84/// use urushi::{Align, BlockTitle, Color, StyledText, TextSpan, TextStyle};
85///
86/// let title = StyledText::try_from_spans([
87///     TextSpan::new("F", TextStyle::new().foreground(Color::CYAN)),
88///     TextSpan::from("iles"),
89/// ])
90/// .unwrap();
91/// let title = BlockTitle::new(title).align(Align::Center).padding(0);
92///
93/// assert_eq!(title.text().as_str(), "Files");
94/// ```
95#[derive(Debug, Clone, PartialEq, Eq)]
96pub struct BlockTitle {
97    text: StyledText,
98    align: Align,
99    padding: u16,
100}
101
102impl BlockTitle {
103    /// Creates a left-aligned title with one blank cell on each side.
104    ///
105    /// # Panics
106    ///
107    /// Panics when `text` contains a line break. A border title occupies
108    /// exactly one row and never wraps.
109    pub fn new(text: impl Into<StyledText>) -> Self {
110        let text = text.into();
111        assert!(
112            !text.as_str().contains('\n'),
113            "a block title must be exactly one line"
114        );
115        Self {
116            text,
117            align: Align::Left,
118            padding: 1,
119        }
120    }
121
122    /// Sets the title's alignment between the block's enabled side borders.
123    #[must_use]
124    pub const fn align(mut self, align: Align) -> Self {
125        self.align = align;
126        self
127    }
128
129    /// Sets the preferred blank cells on both sides of the title.
130    ///
131    /// A narrow border gives space to title text before this padding.
132    #[must_use]
133    pub const fn padding(mut self, padding: u16) -> Self {
134        self.padding = padding;
135        self
136    }
137
138    /// Returns the title's single styled line.
139    pub const fn text(&self) -> &StyledText {
140        &self.text
141    }
142
143    /// Returns the title's alignment between enabled side borders.
144    pub const fn alignment(&self) -> Align {
145        self.align
146    }
147
148    /// Returns the preferred blank cells on both sides of the title.
149    pub const fn horizontal_padding(&self) -> u16 {
150        self.padding
151    }
152}
153
154impl From<&str> for BlockTitle {
155    fn from(text: &str) -> Self {
156        Self::new(text)
157    }
158}
159
160impl From<String> for BlockTitle {
161    fn from(text: String) -> Self {
162        Self::new(text)
163    }
164}
165
166impl From<StyledText> for BlockTitle {
167    fn from(text: StyledText) -> Self {
168        Self::new(text)
169    }
170}
171
172/// A fully composed, renderer-neutral terminal view.
173///
174/// A view tree combines text, boxes, linear and grid layout, and finite Canvas
175/// drawing surfaces. Every node resolves to a rectangle, so a bordered block
176/// or Canvas composes inside a row the same way a word does. An anchor is a box
177/// that also reports where its content landed, for a caller that draws there
178/// something this crate does not produce.
179///
180/// Components return a `View`; output adapters resolve it once
181/// ([`resolve`](crate::resolve)) and serialize the resulting
182/// [`ResolvedView`](crate::ResolvedView).
183///
184/// ```
185/// use urushi::{Align, BlockStyle, Border, TextStyle, VerticalAlign, View, measure};
186///
187/// let badge = View::block(
188///     BlockStyle::new().border(Border::ROUNDED),
189///     View::text("ok", TextStyle::new()),
190/// );
191/// let row = View::row(VerticalAlign::Center, [View::text("status: ", TextStyle::new()), badge]);
192///
193/// assert_eq!(measure(&row).height(), 3);
194/// ```
195#[derive(Debug, Clone, PartialEq)]
196pub enum View {
197    /// One text flow whose grapheme-aligned segments carry complete styles.
198    /// Source tabs are replaced under its layout policy before measurement;
199    /// escape sequences and cursor movement remain invalid.
200    Text(StyledText),
201    /// One [`BlockStyle`] and optional [`BlockTitle`] around exactly one child.
202    Block(BlockStyle, Option<BlockTitle>, Box<View>),
203    /// Children placed side by side, aligned vertically.
204    Row(VerticalAlign, Vec<View>),
205    /// Children stacked, aligned horizontally.
206    Column(Align, Vec<View>),
207    /// A rectangle of cells sharing one width per column.
208    ///
209    /// Every row holds the same number of cells: a grid has no style to fill
210    /// an invented one with, so whatever composes it supplies the empty cell.
211    /// Debug builds panic on a ragged grid; release builds resolve a missing
212    /// cell as an empty view.
213    Grid(GridStyle, Vec<Vec<View>>),
214    /// A finite free-positioned drawing surface.
215    Canvas(Canvas),
216    /// One child projected from local content coordinates into a finite area.
217    Viewport(Viewport, Box<View>),
218    /// A block that also reports where it landed, named by a key.
219    ///
220    /// It is a [`Block`](Self::Block) in every respect layout cares about —
221    /// the same one child, the same style, the same sizing — and the name says
222    /// so. [`resolve`](crate::resolve) reports its content rectangle beside
223    /// the resolved rows, for the caller that knows what belongs there.
224    AnchorBlock(Key, BlockStyle, Option<BlockTitle>, Box<View>),
225}
226
227impl Default for View {
228    fn default() -> Self {
229        Self::empty()
230    }
231}
232
233impl View {
234    /// Creates a text leaf.
235    ///
236    /// The text may contain newline and horizontal tab. Tabs use the default
237    /// four-space layout policy; construct a [`StyledText`] to select another
238    /// policy. Escape sequences and cursor movement break the contract and
239    /// panic during construction in every build profile. Raw ANSI is not a
240    /// valid `Text` payload.
241    pub fn text(text: impl Into<String>, style: TextStyle) -> Self {
242        Self::Text(StyledText::new(text, style))
243    }
244
245    /// Creates a text leaf carrying multiple styled segments in one flow.
246    pub const fn styled_text(text: StyledText) -> Self {
247        Self::Text(text)
248    }
249
250    /// Wraps one child in a block.
251    pub fn block(style: BlockStyle, child: Self) -> Self {
252        Self::Block(style, None, Box::new(child))
253    }
254
255    /// Wraps one child in a block with a styled title in its top border.
256    ///
257    /// The title participates in automatic width demand but never increases
258    /// the box past an explicit or available width. It is clipped without
259    /// wrapping when the top edge is narrower than its text and padding.
260    ///
261    /// ```
262    /// use urushi::{BlockStyle, Border, View, measure};
263    ///
264    /// let panel = View::titled_block(
265    ///     BlockStyle::new().border(Border::NORMAL),
266    ///     "Files",
267    ///     View::empty(),
268    /// );
269    ///
270    /// assert_eq!(measure(&panel).width(), 9);
271    /// ```
272    ///
273    /// # Panics
274    ///
275    /// Panics when `style` has no top border edge.
276    pub fn titled_block(style: BlockStyle, title: impl Into<BlockTitle>, child: Self) -> Self {
277        assert_title_edge(&style);
278        Self::Block(style, Some(title.into()), Box::new(child))
279    }
280
281    /// Places children side by side.
282    pub fn row(align: VerticalAlign, children: impl IntoIterator<Item = Self>) -> Self {
283        Self::Row(align, children.into_iter().collect())
284    }
285
286    /// Stacks children.
287    pub fn column(align: Align, children: impl IntoIterator<Item = Self>) -> Self {
288        Self::Column(align, children.into_iter().collect())
289    }
290
291    /// Wraps one child in a block that reports where its content landed.
292    ///
293    /// An anchor carries no geometry of its own: it is a block, so its size is
294    /// whatever `style` and its content decide, by the rules every other block
295    /// follows. What the key adds is a report — an
296    /// [`AnchoredRect`](crate::AnchoredRect) of the rectangle inside the frame
297    /// — for a caller that draws there something this crate does not produce.
298    ///
299    /// The key is opaque here; this crate never looks at what belongs in the
300    /// region. One key names one region: two anchors carrying the same key are
301    /// a contract violation, which debug builds assert.
302    ///
303    /// ```
304    /// use urushi::{Available, BlockStyle, Length, View, resolve};
305    ///
306    /// // A region for a foreign renderer: the box states the size, and the
307    /// // empty content resolves to the blanks a backend without one draws.
308    /// let chart = View::anchor_block(
309    ///     "chart",
310    ///     BlockStyle::new().width(Length::Cells(20)).height(Length::Cells(8)),
311    ///     View::empty(),
312    /// );
313    /// let resolved = resolve(&chart, Available::NONE).unwrap();
314    ///
315    /// let region = resolved.anchor("chart").expect("the anchor resolved");
316    /// assert_eq!((region.width(), region.height()), (20, 8));
317    /// ```
318    pub fn anchor_block(key: impl Into<Key>, style: BlockStyle, child: Self) -> Self {
319        Self::AnchorBlock(key.into(), style, None, Box::new(child))
320    }
321
322    /// Wraps one child in a titled block and reports its content rectangle.
323    ///
324    /// Geometry and title behavior are identical to [`titled_block`](Self::titled_block);
325    /// the key adds only the same report as [`anchor_block`](Self::anchor_block).
326    ///
327    /// # Panics
328    ///
329    /// Panics when `style` has no top border edge.
330    pub fn titled_anchor_block(
331        key: impl Into<Key>,
332        style: BlockStyle,
333        title: impl Into<BlockTitle>,
334        child: Self,
335    ) -> Self {
336        assert_title_edge(&style);
337        Self::AnchorBlock(key.into(), style, Some(title.into()), Box::new(child))
338    }
339
340    /// Creates an anchor with no box around it: an empty region, named.
341    ///
342    /// This is the cursor case of [`anchor_block`](Self::anchor_block). The
343    /// region covers no cells, so it changes no layout and draws nothing; what
344    /// the caller reads is its origin.
345    ///
346    /// ```
347    /// use urushi::{Available, TextStyle, VerticalAlign, View, resolve};
348    ///
349    /// let prompt = View::row(
350    ///     VerticalAlign::Top,
351    ///     [View::text("> ", TextStyle::new()), View::anchor("cursor")],
352    /// );
353    /// let resolved = resolve(&prompt, Available::NONE).unwrap();
354    ///
355    /// let cursor = resolved.anchor("cursor").expect("the anchor resolved");
356    /// assert_eq!((cursor.x(), cursor.y()), (2, 0));
357    /// assert!(cursor.is_empty());
358    /// ```
359    pub fn anchor(key: impl Into<Key>) -> Self {
360        Self::anchor_block(key, BlockStyle::new(), Self::empty())
361    }
362
363    /// Lines cells up in shared columns.
364    ///
365    /// Every row must hold the same number of cells; see [`View::Grid`].
366    pub fn grid<R>(style: GridStyle, rows: impl IntoIterator<Item = R>) -> Self
367    where
368        R: IntoIterator<Item = Self>,
369    {
370        Self::Grid(
371            style,
372            rows.into_iter()
373                .map(|row| row.into_iter().collect())
374                .collect(),
375        )
376    }
377
378    /// Creates a finite drawing surface from ordered, owned items.
379    pub const fn canvas(canvas: Canvas) -> Self {
380        Self::Canvas(canvas)
381    }
382
383    /// Projects one child's settled content into the finite extent layout
384    /// supplies on each selected axis.
385    pub fn viewport(viewport: Viewport, child: impl Into<View>) -> Self {
386        Self::Viewport(viewport, Box::new(child.into()))
387    }
388
389    /// Creates a view that resolves to an empty rectangle.
390    pub const fn empty() -> Self {
391        Self::Column(Align::Left, Vec::new())
392    }
393}
394
395fn assert_title_edge(style: &BlockStyle) {
396    assert!(
397        style.get_border().is_some() && style.get_border_top(),
398        "a titled block requires an enabled top border"
399    );
400}