Skip to main content

omp_tui/components/
table.rs

1use std::ops::Range;
2
3use omp_core::Str;
4use smallvec::SmallVec;
5
6use super::{
7	layout::{grid_measure, place_grid_row, solve_columns},
8	text::{Pre, TextLeaf, clip_start_runs, paint_rich},
9};
10use crate::{
11	component::{Cached, Component, IntoChildren, PaintCtx, Slot, next_slot},
12	context::UiContext,
13	frame::{Color, Rect, Style},
14	markup::{Align, Truncate},
15	props::{Prop, PropValue, Props},
16	rich::{Pipeline, RichSink, RichText},
17};
18
19/// A columnar layout backing the `<table>` markup tag.
20///
21/// Cells align vertically: every column is solved once across all rows
22/// (widest cell wins), surplus room goes to `grow` columns, and a deficit
23/// shrinks the widest flexible column first — pairing with cell-level
24/// `truncate` so a name column collapses with an ellipsis while pinned
25/// stat columns keep their alignment. Layout only: rows never capture
26/// input; interactive lists wrap cells in `<select>` options instead.
27pub struct Table {
28	props:    Props,
29	slot:     Slot,
30	/// Row-major cell containers.
31	children: Vec<Cached>,
32	rows:     SmallVec<RowMeta, 8>,
33	/// Per-row vertical bands recorded by `place` for row backgrounds.
34	bands:    SmallVec<(u16, u16), 8>,
35}
36
37struct RowMeta {
38	cells: Range<usize>,
39	props: Props,
40}
41
42impl Table {
43	/// Creates an empty table.
44	pub fn new() -> Self {
45		Self {
46			props:    Props::new(),
47			slot:     next_slot(),
48			children: Vec::new(),
49			rows:     SmallVec::new(),
50			bands:    SmallVec::new(),
51		}
52	}
53
54	/// Sets one table property.
55	pub fn with(mut self, prop: Prop, value: impl Into<PropValue>) -> Self {
56		self.props.set(prop, value);
57		self
58	}
59
60	/// Sets one table property from a string.
61	pub fn with_str(mut self, prop: Prop, value: &str) -> Self {
62		self.props.set(prop, value);
63		self
64	}
65
66	/// Appends one row of cells.
67	pub fn row(mut self, row: TableRow) -> Self {
68		let start = self.children.len();
69		for cell in row.cells {
70			self.children.push(Cached::new(Box::new(cell)));
71		}
72		self
73			.rows
74			.push(RowMeta { cells: start..self.children.len(), props: row.props });
75		self
76	}
77
78	fn spans(&self) -> SmallVec<Range<usize>, 8> {
79		self.rows.iter().map(|row| row.cells.clone()).collect()
80	}
81
82	/// Spacing between columns: the `gap` prop, defaulting to two cells so
83	/// adjacent columns never touch.
84	fn column_gap(&self) -> u16 {
85		if self.props.get(Prop::Gap).is_some() {
86			self.props.gap()
87		} else {
88			2
89		}
90	}
91}
92
93impl Default for Table {
94	fn default() -> Self {
95		Self::new()
96	}
97}
98
99impl Component for Table {
100	fn props(&self) -> &Props {
101		&self.props
102	}
103
104	fn props_mut(&mut self) -> &mut Props {
105		&mut self.props
106	}
107
108	fn slot(&self) -> Slot {
109		self.slot
110	}
111
112	fn children(&self) -> &[Cached] {
113		&self.children
114	}
115
116	fn children_mut(&mut self) -> &mut [Cached] {
117		&mut self.children
118	}
119
120	fn measure(&mut self, ctx: &UiContext) -> (u16, u16) {
121		let spans = self.spans();
122		let gap = self.column_gap();
123		grid_measure(ctx, &mut self.children, &spans, gap)
124	}
125
126	fn height(&mut self, ctx: &UiContext, width: u16) -> u16 {
127		let spans = self.spans();
128		let gap = self.column_gap();
129		let columns = solve_columns(ctx, &mut self.children, &spans, width, gap);
130		let mut height = 0_u16;
131		for span in &spans {
132			let tallest = span
133				.clone()
134				.enumerate()
135				.map(|(column, index)| self.children[index].height(ctx, columns[column].max(1)))
136				.max()
137				.unwrap_or(0)
138				.max(1);
139			height = height.saturating_add(tallest);
140		}
141		height
142	}
143
144	fn place(&mut self, ctx: &UiContext, content: Rect) {
145		let spans = self.spans();
146		let gap = self.column_gap();
147		let columns = solve_columns(ctx, &mut self.children, &spans, content.width, gap);
148		self.bands.clear();
149		let mut y = content.y;
150		for span in spans {
151			let row_height =
152				place_grid_row(ctx, &mut self.children, span, &columns, content.x, y, gap);
153			self.bands.push((y, row_height));
154			y = y.saturating_add(row_height);
155		}
156	}
157
158	fn paint(&mut self, pc: &mut PaintCtx<'_>, rect: Rect) {
159		for (row, &(top, height)) in self.rows.iter().zip(&self.bands) {
160			let background = row.props.style(&pc.ctx.theme).background_color();
161			if background != Color::Default && top < pc.clip {
162				let rows = height.min(pc.clip.saturating_sub(top));
163				pc.frame
164					.fill(Rect::new(rect.x, top, rect.width, rows), Style::new().bg(background));
165			}
166		}
167		for child in self.children.iter_mut().filter(|child| child.visible) {
168			child.paint(pc);
169		}
170	}
171}
172
173/// One `<tr>` under construction: row props (a `bg=` highlight band) plus
174/// its cells in column order.
175#[derive(Default)]
176pub struct TableRow {
177	props: Props,
178	cells: SmallVec<TableCell, 8>,
179}
180
181impl TableRow {
182	/// Creates an empty row.
183	pub fn new() -> Self {
184		Self::default()
185	}
186
187	/// Sets one row property.
188	pub fn with(mut self, prop: Prop, value: impl Into<PropValue>) -> Self {
189		self.props.set(prop, value);
190		self
191	}
192
193	/// Sets one row property from a string.
194	pub fn with_str(mut self, prop: Prop, value: &str) -> Self {
195		self.props.set(prop, value);
196		self
197	}
198
199	/// Appends one cell.
200	pub fn cell(mut self, cell: TableCell) -> Self {
201		self.cells.push(cell);
202		self
203	}
204}
205
206/// An inline-flow grid cell backing the `<td>` markup tag.
207///
208/// Children lay out side by side. With the `truncate` flag the cell
209/// flattens text children (`<pre>`/`<text>`, keeping each child's own
210/// style) into a single line and clips it with one trailing ellipsis at
211/// the cell edge, so multi-toned labels collapse as a unit instead of
212/// per-fragment.
213pub struct TableCell {
214	props:    Props,
215	slot:     Slot,
216	children: Vec<Cached>,
217	rich:     RichText,
218	/// Visible children that received room in the last `place`; paint
219	/// skips the rest so a squeezed cell never overpaints its neighbor.
220	shown:    usize,
221}
222
223impl TableCell {
224	/// Creates an empty cell.
225	pub fn new() -> Self {
226		Self {
227			props:    Props::new(),
228			slot:     next_slot(),
229			children: Vec::new(),
230			rich:     RichText::default(),
231			shown:    0,
232		}
233	}
234
235	/// Sets one cell property.
236	pub fn with(mut self, prop: Prop, value: impl Into<PropValue>) -> Self {
237		self.props.set(prop, value);
238		self
239	}
240
241	/// Sets one cell property from a string.
242	pub fn with_str(mut self, prop: Prop, value: &str) -> Self {
243		self.props.set(prop, value);
244		self
245	}
246
247	/// Appends child components to the cell.
248	pub fn child(mut self, children: impl IntoChildren) -> Self {
249		let first = self.children.len();
250		children.extend_children(&mut self.children);
251		for child in &mut self.children[first..] {
252			child.comp_mut().props_mut().set(Prop::Vertical, true);
253			child.invalidate();
254		}
255		self
256	}
257
258	fn truncates(&self) -> Option<Truncate> {
259		self.props.truncate()
260	}
261
262	/// The `(text, style)` run of a flattenable child; `None` for children
263	/// that cannot join a single-line flatten (images, containers).
264	fn flatten_run<'a>(child: &'a mut Cached, ctx: &UiContext) -> Option<(&'a Str, Style)> {
265		let style = child.comp().props().style(&ctx.theme);
266		let comp = child.comp_mut();
267		if let Some(pre) = comp.downcast_mut::<Pre>() {
268			return Some((pre.content(), style));
269		}
270		if let Some(text) = comp.downcast_mut::<TextLeaf>() {
271			return Some((text.content(), style));
272		}
273		None
274	}
275
276	/// Rebuilds the flattened single-line clip of every text child; `false`
277	/// when a child cannot flatten (the cell falls back to placed children).
278	fn flatten(&mut self, ctx: &UiContext, width: u16) -> bool {
279		let Some(mode) = self.truncates() else {
280			return false;
281		};
282		// Cells are single-line: any hard break renders as a space, and
283		// every child keeps its own style through the shared clip.
284		let mut runs: SmallVec<(Style, Str), 8> = SmallVec::new();
285		for child in self.children.iter_mut().filter(|child| child.visible) {
286			let Some((text, style)) = Self::flatten_run(child, ctx) else {
287				return false;
288			};
289			for (index, line) in text.as_str().split('\n').enumerate() {
290				if index > 0 {
291					runs.push((style, Str::new_static(" ")));
292				}
293				runs.push((style, text.slice_ref(line)));
294			}
295		}
296		self.rich.clear();
297		match mode {
298			Truncate::End => {
299				let mut clip = (&mut self.rich).clip(width.max(1), Some('…'));
300				for (style, text) in &runs {
301					clip.run(*style, text);
302				}
303			},
304			Truncate::Start => clip_start_runs(&mut self.rich, width, &runs),
305		}
306		true
307	}
308}
309
310impl Default for TableCell {
311	fn default() -> Self {
312		Self::new()
313	}
314}
315
316impl Component for TableCell {
317	fn props(&self) -> &Props {
318		&self.props
319	}
320
321	fn props_mut(&mut self) -> &mut Props {
322		&mut self.props
323	}
324
325	fn slot(&self) -> Slot {
326		self.slot
327	}
328
329	fn children(&self) -> &[Cached] {
330		&self.children
331	}
332
333	fn children_mut(&mut self) -> &mut [Cached] {
334		&mut self.children
335	}
336
337	fn measure(&mut self, ctx: &UiContext) -> (u16, u16) {
338		let mut min = 0_u16;
339		let mut natural = 0_u16;
340		for child in self.children.iter_mut().filter(|child| child.visible) {
341			let (child_min, child_natural) = child.measure(ctx);
342			min = min.saturating_add(child_min);
343			natural = natural.saturating_add(child_natural);
344		}
345		if self.truncates().is_some() {
346			// A truncating cell can always collapse to a lone ellipsis.
347			return (natural.min(1), natural);
348		}
349		(min, natural)
350	}
351
352	fn height(&mut self, ctx: &UiContext, width: u16) -> u16 {
353		if self.truncates().is_some() {
354			return 1;
355		}
356		let mut remaining = width;
357		let mut tallest = 1_u16;
358		for child in self.children.iter_mut().filter(|child| child.visible) {
359			if remaining == 0 {
360				break;
361			}
362			let (_, child_natural) = child.measure(ctx);
363			let child_width = child_natural.min(remaining).max(1);
364			tallest = tallest.max(child.height(ctx, child_width));
365			remaining = remaining.saturating_sub(child_width);
366		}
367		tallest
368	}
369
370	fn place(&mut self, ctx: &UiContext, content: Rect) {
371		// First pass: the width each child gets, in order, until the room
372		// runs out — later children are not placed at all, so a squeezed
373		// cell never bleeds into the gap or the next column.
374		let mut widths: SmallVec<u16, 8> = SmallVec::new();
375		let mut remaining = content.width;
376		for child in self.children.iter_mut().filter(|child| child.visible) {
377			if remaining == 0 {
378				break;
379			}
380			let (_, child_natural) = child.measure(ctx);
381			let width = child_natural.min(remaining).max(1);
382			widths.push(width);
383			remaining = remaining.saturating_sub(width);
384		}
385		self.shown = widths.len();
386		// Shorter content aligns inside the shared column width, so stat
387		// columns right-align under a widest-cell-solved table.
388		let slack = remaining;
389		let mut cursor = content.x.saturating_add(match self.props.align() {
390			Align::Start => 0,
391			Align::Center => slack / 2,
392			Align::End => slack,
393		});
394		for (child, &width) in self
395			.children
396			.iter_mut()
397			.filter(|child| child.visible)
398			.zip(&widths)
399		{
400			let height = child.height(ctx, width).min(content.height.max(1));
401			child.place(ctx, Rect::new(cursor, content.y, width, height));
402			cursor = cursor.saturating_add(width);
403		}
404	}
405
406	fn paint(&mut self, pc: &mut PaintCtx<'_>, rect: Rect) {
407		if self.flatten(pc.ctx, rect.width) {
408			paint_rich(pc, rect, &self.rich, self.props.align());
409			return;
410		}
411		let mut placed = self.shown;
412		for child in self.children.iter_mut().filter(|child| child.visible) {
413			if placed == 0 {
414				break;
415			}
416			placed -= 1;
417			child.paint(pc);
418		}
419	}
420}