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}