Skip to main content

omp_tui/components/
text.rs

1use std::time::Duration;
2
3use omp_core::{Str, StrMut};
4use smallvec::SmallVec;
5use xutf::Text;
6
7use crate::{
8	anim,
9	component::{Component, MemoKey, PaintCtx, Slot, next_slot},
10	frame::{Rect, Style},
11	markup::Truncate,
12	props::{Prop, PropValue, Props},
13	rich::{Pipeline, RichSink, RichText, cell_width},
14};
15
16/// Wrapped or truncated text backing the `<text>` markup tag.
17pub struct TextLeaf {
18	props:        Props,
19	slot:         Slot,
20	text:         Str,
21	rich:         RichText,
22	version:      u64,
23	cached_width: u16,
24	cached:       Option<MemoKey>,
25	/// Resolved style baked into `rich` — part of the memo key so prop or
26	/// theme changes (including animated swaps) re-render the runs.
27	cached_style: Style,
28	/// Byte end of the rendered slice of `text` — the whole text without
29	/// `reveal`, the shown prefix under it. Part of the memo key so a
30	/// moving reveal cursor re-renders the runs.
31	cached_end:   usize,
32	/// Reveal cursor and grapheme memos; allocated on first paced render.
33	reveal:       Option<Box<RevealState>>,
34}
35
36impl TextLeaf {
37	/// Creates an empty text leaf.
38	pub fn new() -> Self {
39		Self {
40			props:        Props::new(),
41			slot:         next_slot(),
42			text:         Str::default(),
43			rich:         RichText::default(),
44			version:      1,
45			cached_width: 0,
46			cached:       None,
47			cached_style: Style::new(),
48			cached_end:   0,
49			reveal:       None,
50		}
51	}
52
53	/// Sets one text property.
54	pub fn with(mut self, prop: Prop, value: impl Into<PropValue>) -> Self {
55		self.props.set(prop, value);
56		self.version = self.version.wrapping_add(1);
57		self
58	}
59
60	/// Sets one text property from a string.
61	pub fn with_str(self, prop: Prop, value: &str) -> Self {
62		self.with(prop, value)
63	}
64
65	/// Appends plain text content.
66	pub fn text(mut self, text: impl Into<Str>) -> Self {
67		append(&mut self.text, text.into());
68		self.version = self.version.wrapping_add(1);
69		self
70	}
71
72	fn render(&mut self, ctx: &crate::UiContext, width: u16) {
73		let width = width.max(1);
74		let style = self.props.style(&ctx.theme);
75		let key = MemoKey::new(self.version, ctx);
76		let end = if let Some(horizon) = self.props.reveal() {
77			let reveal = self.reveal.get_or_insert_default();
78			reveal.sync(&self.text);
79			reveal.advance(ctx.now, horizon)
80		} else {
81			// Dropping the prop drops the cursor, so re-enabling
82			// reveals from the start again.
83			self.reveal = None;
84			self.text.len()
85		};
86		if self.cached_width == width
87			&& self.cached == Some(key)
88			&& self.cached_style == style
89			&& self.cached_end == end
90		{
91			return;
92		}
93		let visible = &self.text[..end];
94		self.rich.clear();
95		match self.props.truncate() {
96			Some(Truncate::End) => {
97				let mut clip = (&mut self.rich).clip(width, Some('…'));
98				for (index, line) in visible.split('\n').enumerate() {
99					if index > 0 {
100						clip.run(style, " ");
101					}
102					clip.run(style, line);
103				}
104			},
105			Some(Truncate::Start) => {
106				let mut runs: SmallVec<(Style, Str), 8> = SmallVec::new();
107				for (index, line) in visible.split('\n').enumerate() {
108					if index > 0 {
109						runs.push((style, Str::new_static(" ")));
110					}
111					if !line.is_empty() {
112						runs.push((style, self.text.slice_ref(line)));
113					}
114				}
115				clip_start_runs(&mut self.rich, width, &runs);
116			},
117			None if self.props.wrap_chars() => {
118				// Terminal-exact flow: rows break grapheme-exact at the
119				// width and every boundary stays byte-joinable for copy.
120				let mut wrap = (&mut self.rich).wrap_chars(width);
121				for (index, line) in visible.split('\n').enumerate() {
122					if index > 0 {
123						wrap.newline();
124					}
125					if !line.is_empty() {
126						wrap.run(style, line);
127					}
128				}
129			},
130			None => {
131				let mut wrap = (&mut self.rich).wrap(width);
132				// text is escape-free by contract: ANSI is parsed only at the
133				// external ingress (rich::decompose), never inside components
134				for (index, line) in visible.split('\n').enumerate() {
135					if index > 0 {
136						wrap.newline();
137					}
138					if !line.is_empty() {
139						wrap.run(style, line);
140					}
141				}
142				wrap.finish();
143			},
144		}
145		self.cached_width = width;
146		self.cached = Some(key);
147		self.cached_style = style;
148		self.cached_end = end;
149	}
150}
151
152impl Default for TextLeaf {
153	fn default() -> Self {
154		Self::new()
155	}
156}
157
158impl Component for TextLeaf {
159	fn props(&self) -> &Props {
160		&self.props
161	}
162
163	fn props_mut(&mut self) -> &mut Props {
164		&mut self.props
165	}
166
167	fn slot(&self) -> Slot {
168		self.slot
169	}
170
171	fn measure(&mut self, _ctx: &crate::UiContext) -> (u16, u16) {
172		let mut widest_word = 0;
173		let mut total = 0u16;
174		for word in self.text.split_whitespace() {
175			let width = cell_width(word);
176			widest_word = widest_word.max(width);
177			total = total.saturating_add(width).saturating_add(1);
178		}
179		let natural = total.saturating_sub(1);
180		// Truncation can always collapse to a lone ellipsis, and char-wrap
181		// flows at any width, so neither blocks a column from shrinking.
182		if self.props.truncate().is_some() || self.props.wrap_chars() {
183			return (natural.min(1), natural);
184		}
185		(widest_word, natural)
186	}
187
188	fn height(&mut self, ctx: &crate::UiContext, width: u16) -> u16 {
189		self.render(ctx, width);
190		RichText::rows(&self.rich)
191	}
192
193	fn paint(&mut self, pc: &mut PaintCtx<'_>, rect: Rect) {
194		self.render(pc.ctx, rect.width);
195		match self.props.shimmer() {
196			Some(period) => {
197				paint_rich_shimmer(pc, rect, &self.rich, self.props.align(), period);
198				pc.wake(self.slot, pc.now.saturating_add(anim::FRAME));
199			},
200			None => paint_rich(pc, rect, &self.rich, self.props.align()),
201		}
202		// An unsettled reveal moves geometry as rows fill, so its frame
203		// cadence must relayout, not just repaint.
204		if let Some(reveal) = self.reveal.as_deref()
205			&& !reveal.is_settled()
206		{
207			pc.wake_layout(self.slot, pc.now.saturating_add(anim::FRAME));
208		}
209	}
210
211	fn set_text(&mut self, _ctx: &crate::UiContext, text: Str) -> bool {
212		if self.text == text {
213			return false;
214		}
215		self.text = text;
216		self.version = self.version.wrapping_add(1);
217		true
218	}
219}
220
221impl TextLeaf {
222	/// Verbatim content for single-line flattening by grid cells.
223	pub(crate) const fn content(&self) -> &Str {
224		&self.text
225	}
226}
227
228/// Reveal bookkeeping for one leaf: the pacing cursor plus grapheme-cluster
229/// memos over its append-only text. Counting resumes from the final counted
230/// cluster and slicing from the last shown cluster — an append can extend
231/// those clusters but never earlier ones — so each streamed chunk and each
232/// cursor step re-segments only the suffix it touched.
233#[derive(Default)]
234struct RevealState {
235	pace:        anim::Reveal,
236	/// The text the memos below describe (O(1) clone of the leaf's text).
237	seen:        Str,
238	/// Grapheme clusters in `seen`.
239	total:       usize,
240	/// Byte start of the final cluster of `seen`.
241	tail:        usize,
242	/// Clusters currently shown.
243	shown_units: usize,
244	/// Byte end of the shown prefix.
245	shown_end:   usize,
246	/// Byte start of the final shown cluster.
247	shown_from:  usize,
248}
249
250impl RevealState {
251	/// Reconciles the memos with the leaf's current text: an extension
252	/// recounts from the final counted cluster, anything else recounts in
253	/// full and restarts the cursor.
254	fn sync(&mut self, text: &Str) {
255		if self.seen == *text {
256			return;
257		}
258		if text.len() > self.seen.len() && text.starts_with(self.seen.as_str()) {
259			let (count, tail) = count_clusters(text, self.tail);
260			self.total = if self.total == 0 {
261				count
262			} else {
263				self.total - 1 + count
264			};
265			self.tail = tail;
266		} else {
267			let (count, tail) = count_clusters(text, 0);
268			self.total = count;
269			self.tail = tail;
270			self.pace.reset();
271			self.shown_units = 0;
272			self.shown_end = 0;
273			self.shown_from = 0;
274		}
275		self.seen = text.clone();
276	}
277
278	/// Advances the cursor at `now` and returns the byte end of the shown
279	/// prefix. Always re-walks from the final shown cluster, so an append
280	/// that extended it is picked up even when the cursor held still.
281	fn advance(&mut self, now: Duration, horizon: Duration) -> usize {
282		let units = self.pace.advance(now, self.total, horizon);
283		if units >= self.total {
284			self.shown_units = self.total;
285			self.shown_end = self.seen.len();
286			self.shown_from = self.tail;
287			return self.shown_end;
288		}
289		if units == 0 {
290			self.shown_units = 0;
291			self.shown_end = 0;
292			self.shown_from = 0;
293			return 0;
294		}
295		let (start, need, base) = if self.shown_units > 0 && units >= self.shown_units {
296			(self.shown_from, units - self.shown_units + 1, self.shown_units - 1)
297		} else {
298			(0, units, 0)
299		};
300		let mut offset = start;
301		let mut last = start;
302		let mut walked = 0;
303		for cluster in xutf::graphemes_str(&self.seen[start..]) {
304			last = offset;
305			offset += cluster.len();
306			walked += 1;
307			if walked == need {
308				break;
309			}
310		}
311		self.shown_units = base + walked;
312		self.shown_from = last;
313		self.shown_end = offset;
314		self.shown_end
315	}
316
317	/// Whether the shown prefix covers the whole text.
318	const fn is_settled(&self) -> bool {
319		self.shown_units >= self.total
320	}
321}
322
323/// Counts grapheme clusters of `text` from byte offset `start`, also
324/// reporting the byte start of the final cluster (where an append could
325/// extend it). Empty input reports `(0, start)`.
326fn count_clusters(text: &str, start: usize) -> (usize, usize) {
327	let mut count = 0;
328	let mut tail = start;
329	let mut offset = start;
330	for cluster in xutf::graphemes_str(&text[start..]) {
331		count += 1;
332		tail = offset;
333		offset += cluster.len();
334	}
335	(count, tail)
336}
337
338/// Preformatted text backing the `<pre>` markup tag.
339pub struct Pre {
340	props: Props,
341	slot:  Slot,
342	text:  Str,
343}
344
345impl Pre {
346	/// Creates an empty preformatted block.
347	pub fn new() -> Self {
348		Self { props: Props::new(), slot: next_slot(), text: Str::default() }
349	}
350
351	/// Sets one property.
352	pub fn with(mut self, prop: Prop, value: impl Into<PropValue>) -> Self {
353		self.props.set(prop, value);
354		self
355	}
356
357	/// Sets one property from a string.
358	pub fn with_str(self, prop: Prop, value: &str) -> Self {
359		self.with(prop, value)
360	}
361
362	/// Appends preformatted text content.
363	pub fn text(mut self, text: impl Into<Str>) -> Self {
364		append(&mut self.text, text.into());
365		self
366	}
367
368	/// Verbatim content for single-line flattening by grid cells.
369	pub(crate) const fn content(&self) -> &Str {
370		&self.text
371	}
372}
373
374impl Default for Pre {
375	fn default() -> Self {
376		Self::new()
377	}
378}
379
380impl Component for Pre {
381	fn props(&self) -> &Props {
382		&self.props
383	}
384
385	fn props_mut(&mut self) -> &mut Props {
386		&mut self.props
387	}
388
389	fn slot(&self) -> Slot {
390		self.slot
391	}
392
393	fn measure(&mut self, _ctx: &crate::UiContext) -> (u16, u16) {
394		let width = self.text.lines().map(cell_width).max().unwrap_or(0);
395		(width, width)
396	}
397
398	fn height(&mut self, _ctx: &crate::UiContext, _width: u16) -> u16 {
399		u16::try_from(self.text.lines().count()).unwrap_or(u16::MAX)
400	}
401
402	fn paint(&mut self, pc: &mut PaintCtx<'_>, rect: Rect) {
403		let width = self.text.lines().map(cell_width).max().unwrap_or(0);
404		let slack = rect.width.saturating_sub(width.min(rect.width));
405		let x = rect
406			.x
407			.saturating_add(alignment_slack(self.props.align(), slack));
408		let right = rect.x.saturating_add(rect.width);
409		let style = self.props.style(&pc.ctx.theme);
410		let clip = pc.clip.min(rect.y.saturating_add(rect.height));
411		for (row, line) in self.text.lines().enumerate() {
412			let y = rect
413				.y
414				.saturating_add(u16::try_from(row).unwrap_or(u16::MAX));
415			if y >= clip {
416				break;
417			}
418			put_clipped(pc.frame, x, y, right, line, style);
419		}
420	}
421
422	fn gradient_bounds(&self, content: Rect) -> Option<Rect> {
423		let width = self
424			.text
425			.lines()
426			.map(cell_width)
427			.max()
428			.unwrap_or(0)
429			.min(content.width);
430		let slack = content.width.saturating_sub(width);
431		let x = content
432			.x
433			.saturating_add(alignment_slack(self.props.align(), slack));
434		let height = u16::try_from(self.text.lines().count())
435			.unwrap_or(u16::MAX)
436			.min(content.height);
437		Some(Rect::new(x, content.y, width, height))
438	}
439
440	fn set_text(&mut self, _ctx: &crate::UiContext, text: Str) -> bool {
441		if self.text == text {
442			return false;
443		}
444		self.text = text;
445		true
446	}
447}
448
449pub(super) fn append(target: &mut Str, suffix: Str) {
450	if target.is_empty() {
451		*target = suffix;
452		return;
453	}
454	let mut joined = StrMut::with_capacity(target.len().saturating_add(suffix.len()));
455	joined.push_str(target);
456	joined.push_str(&suffix);
457	*target = joined.freeze();
458}
459
460/// Emits `runs` as one line clipped to `width` cells keeping the tail: a
461/// leading ellipsis replaces however much of the head does not fit.
462pub(super) fn clip_start_runs(rich: &mut RichText, width: u16, runs: &[(Style, Str)]) {
463	let width = width.max(1);
464	let total = runs
465		.iter()
466		.fold(0_u16, |sum, (_, text)| sum.saturating_add(cell_width(text)));
467	if total <= width {
468		for (style, text) in runs {
469			rich.run(*style, text);
470		}
471		return;
472	}
473	// Reserve one cell for the ellipsis, then walk graphemes forward until
474	// the dropped prefix frees enough room for the remaining tail.
475	let budget = width - 1;
476	let mut drop = total.saturating_add(1).saturating_sub(width);
477	let marker = runs.first().map_or(Style::new(), |(style, _)| *style);
478	rich.run(marker, "…");
479	// The clip pipeline guards the exact edge (a wide grapheme straddling
480	// the boundary), so the tail can never overrun the cell budget.
481	let mut clip = (&mut *rich).clip(budget.saturating_add(1), None);
482	for (style, text) in runs {
483		if drop == 0 {
484			clip.run(*style, text);
485			continue;
486		}
487		let run_width = cell_width(text);
488		if run_width <= drop {
489			drop -= run_width;
490			continue;
491		}
492		let mut cut = text.len();
493		let mut walked = 0_u16;
494		for (offset, grapheme) in text.as_str().grapheme_indices() {
495			if walked >= drop {
496				cut = offset;
497				break;
498			}
499			walked = walked.saturating_add(cell_width(grapheme));
500		}
501		drop = 0;
502		clip.run(*style, &text.as_str()[cut..]);
503	}
504}
505
506pub(super) fn truncate_rich(
507	rich: &mut RichText,
508	width: u16,
509	fallback: Style,
510	truncate: Option<Truncate>,
511) {
512	let Some(mode) = truncate else { return };
513	if RichText::rows(rich) <= 1 {
514		return;
515	}
516	match mode {
517		Truncate::End => {
518			let row: SmallVec<(Style, Str), 4> = rich
519				.row_runs(0)
520				.map(|(style, text)| (style, Str::new(text)))
521				.collect();
522			rich.clear();
523			{
524				let mut clip = (&mut *rich).clip(width.saturating_sub(1), None);
525				for (style, text) in &row {
526					clip.run(*style, text);
527				}
528			}
529			let style = rich.row_runs(0).last().map_or(fallback, |(style, _)| style);
530			rich.run(style, "…");
531		},
532		Truncate::Start => {
533			// Rejoin the wrapped rows with single spaces and keep the tail.
534			let mut joined: SmallVec<(Style, Str), 8> = SmallVec::new();
535			for row in 0..RichText::rows(rich) {
536				if row > 0 {
537					let style = joined.last().map_or(fallback, |(style, _)| *style);
538					joined.push((style, Str::new_static(" ")));
539				}
540				for (style, text) in rich.row_runs(row) {
541					joined.push((style, Str::new(text)));
542				}
543			}
544			rich.clear();
545			clip_start_runs(rich, width, &joined);
546		},
547	}
548}
549
550pub(super) const fn alignment_slack(align: crate::markup::Align, slack: u16) -> u16 {
551	match align {
552		crate::markup::Align::Start => 0,
553		crate::markup::Align::Center => slack / 2,
554		crate::markup::Align::End => slack,
555	}
556}
557
558pub(super) fn put_clipped(
559	frame: &mut crate::Frame,
560	x: u16,
561	y: u16,
562	right: u16,
563	text: &str,
564	style: Style,
565) -> u16 {
566	let room = right.saturating_sub(x);
567	if room == 0 {
568		return x;
569	}
570	let visible = text.truncate_width(usize::from(room));
571	frame.put(x, y, visible, style)
572}
573
574pub(super) fn paint_rich(
575	pc: &mut PaintCtx<'_>,
576	rect: Rect,
577	rich: &RichText,
578	align: crate::markup::Align,
579) {
580	let right = rect.x.saturating_add(rect.width);
581	let clip = pc.clip.min(rect.y.saturating_add(rect.height));
582	// Only rows spanning the whole physical line can byte-join through
583	// terminal autowrap; narrower or offset rects keep hard boundaries.
584	let full_row = rect.x == 0 && rect.width == pc.frame.size().width;
585	for row in 0..RichText::rows(rich) {
586		let y = rect.y.saturating_add(row);
587		if y >= clip {
588			break;
589		}
590		if full_row && row > 0 && rich.row_soft_wrap(row - 1) {
591			pc.frame.set_soft_wrap(y - 1);
592		}
593		let slack = rect.width.saturating_sub(rich.row_width(row));
594		let mut x = rect.x.saturating_add(alignment_slack(align, slack));
595		for (style, text) in rich.row_runs(row) {
596			x = put_clipped(pc.frame, x, y, right, text, style);
597			if x >= right {
598				break;
599			}
600		}
601	}
602}
603
604/// [`paint_rich`] under a `shimmer` crest: every cell restyles by its
605/// distance from the sweep, and each row rides the same phase.
606fn paint_rich_shimmer(
607	pc: &mut PaintCtx<'_>,
608	rect: Rect,
609	rich: &RichText,
610	align: crate::markup::Align,
611	period: std::time::Duration,
612) {
613	let right = rect.x.saturating_add(rect.width);
614	let clip = pc.clip.min(rect.y.saturating_add(rect.height));
615	let full_row = rect.x == 0 && rect.width == pc.frame.size().width;
616	for row in 0..RichText::rows(rich) {
617		let y = rect.y.saturating_add(row);
618		if y >= clip {
619			break;
620		}
621		if full_row && row > 0 && rich.row_soft_wrap(row - 1) {
622			pc.frame.set_soft_wrap(y - 1);
623		}
624		let slack = rect.width.saturating_sub(rich.row_width(row));
625		let start = rect.x.saturating_add(alignment_slack(align, slack));
626		let shimmer = anim::Shimmer::new(pc.now, period, rich.row_width(row));
627		let mut x = start;
628		'runs: for (style, text) in rich.row_runs(row) {
629			for grapheme in xutf::graphemes_str(text) {
630				if x >= right {
631					break 'runs;
632				}
633				let next = pc
634					.frame
635					.put(x, y, grapheme, shimmer.style_at(x - start, style));
636				if next == x {
637					break 'runs;
638				}
639				x = next;
640			}
641		}
642	}
643}
644
645#[cfg(test)]
646mod tests {
647	use super::*;
648	use crate::{
649		UiContext,
650		component::{Component, PaintCtx},
651		components::{Callout, Icon, Latex, Markdown},
652		frame::{Frame, Rect, Size},
653		test_support::frame_row_text,
654		ui::Ui,
655	};
656
657	fn paint(component: &mut dyn Component, width: u16, height: u16) -> Frame {
658		let ctx = UiContext::default();
659		let mut frame = Frame::new(Size::new(width, height));
660		let mut hits = Vec::new();
661		let mut wakes = Vec::new();
662		let mut pc = PaintCtx::new(&mut frame, &ctx, &mut hits, &mut wakes);
663		component.paint(&mut pc, Rect::new(0, 0, width, height));
664		frame
665	}
666
667	#[test]
668	fn full_width_overflow_marks_soft_wrap_boundaries() {
669		let mut text = TextLeaf::new().text("abcdefghij");
670		let frame = paint(&mut text, 8, 2);
671		assert!(frame.soft_wrap(0), "a mid-word wrap at full width is joinable");
672	}
673
674	#[test]
675	fn char_wrap_prop_flows_terminal_exact() {
676		let mut text = TextLeaf::new().with(Prop::Wrap, "char").text("ab cdefgh x");
677		let frame = paint(&mut text, 8, 2);
678		assert_eq!(frame_row_text(&frame, 0), "ab cdefg");
679		assert_eq!(frame_row_text(&frame, 1), "h x");
680		assert!(frame.soft_wrap(0));
681	}
682
683	#[test]
684	fn offset_rects_keep_hard_boundaries() {
685		let ctx = UiContext::default();
686		let mut frame = Frame::new(Size::new(9, 2));
687		let mut hits = Vec::new();
688		let mut wakes = Vec::new();
689		let mut pc = PaintCtx::new(&mut frame, &ctx, &mut hits, &mut wakes);
690		let mut text = TextLeaf::new().text("abcdefghij");
691		text.paint(&mut pc, Rect::new(1, 0, 8, 2));
692		drop(pc);
693		assert!(!frame.soft_wrap(0), "offset text cannot byte-join through autowrap");
694	}
695	#[test]
696	fn text_wraps_and_aligns_rows() {
697		let mut text = TextLeaf::new()
698			.with(Prop::Align, "center")
699			.text("one two three");
700		let frame = paint(&mut text, 7, 2);
701		assert_eq!(frame_row_text(&frame, 0), "one two");
702		assert_eq!(frame_row_text(&frame, 1), " three");
703	}
704
705	#[test]
706	fn pre_paints_verbatim_rows() {
707		let mut pre = Pre::new().text("A\n B");
708		let frame = paint(&mut pre, 4, 2);
709		assert_eq!(frame_row_text(&frame, 0), "A");
710		assert_eq!(frame_row_text(&frame, 1), " B");
711	}
712
713	#[test]
714	fn markdown_paints_paragraph_and_fenced_code() {
715		let mut markdown = Markdown::new().text("paragraph\n\n```rust\nlet x = 1;\n```");
716		let frame = paint(&mut markdown, 24, 8);
717		let rows = (0..8)
718			.map(|row| frame_row_text(&frame, row))
719			.collect::<Vec<_>>();
720		assert!(rows.iter().any(|row| row.contains("paragraph")));
721		assert!(rows.iter().any(|row| row.contains("let x = 1;")));
722	}
723
724	#[test]
725	fn latex_paints_inline_when_block_layout_is_unavailable() {
726		let mut latex = Latex::new().text(r"\unknown{x}");
727		let frame = paint(&mut latex, 20, 3);
728		assert!((0..3).any(|row| !frame_row_text(&frame, row).is_empty()));
729	}
730
731	#[test]
732	fn callout_paints_header_and_body_rail() {
733		let mut callout = Callout::new()
734			.with(Prop::Title, "Advisor")
735			.with(Prop::Badge, "1")
736			.text("body");
737		let frame = paint(&mut callout, 20, 3);
738		assert!(frame_row_text(&frame, 0).contains("Advisor"));
739		assert!(frame_row_text(&frame, 1).contains("body"));
740		assert!(frame_row_text(&frame, 1).starts_with('▎'));
741	}
742
743	#[test]
744	fn icon_measure_matches_painted_glyph_width() {
745		let ctx = UiContext::default();
746		let mut icon = Icon::named("folder");
747		let (min, natural) = icon.measure(&ctx);
748		assert_eq!(min, natural);
749		let frame = paint(&mut icon, min.max(1), 1);
750		assert_eq!(cell_width(&frame_row_text(&frame, 0)), min);
751	}
752
753	#[test]
754	fn reveal_types_out_streamed_appends_and_settles() {
755		let mut ui = Ui::from_root(
756			TextLeaf::new()
757				.with(Prop::Reveal, true)
758				.with(Prop::Id, "stream")
759				.text("abcdef"),
760			20,
761			UiContext::default(),
762		);
763		// The construction paint arms the cursor without revealing anything
764		// and schedules the first frame.
765		assert_eq!(frame_row_text(ui.frame(), 0), "");
766		assert_eq!(ui.next_wake(), Some(Duration::from_millis(33)));
767
768		// Each tick earns one 33ms frame at the 90 clusters/s floor: 2.97.
769		assert!(ui.tick(Duration::from_millis(34)));
770		assert_eq!(frame_row_text(ui.frame(), 0), "ab");
771		ui.tick(Duration::from_millis(68));
772		assert_eq!(frame_row_text(ui.frame(), 0), "abcde");
773		ui.tick(Duration::from_millis(102));
774		assert_eq!(frame_row_text(ui.frame(), 0), "abcdef");
775		assert_eq!(ui.next_wake(), None, "a settled reveal stops waking");
776
777		// An append resumes from the cursor instead of jumping or resetting.
778		assert!(ui.set_text("stream", "abcdefghijkl"));
779		assert_eq!(frame_row_text(ui.frame(), 0), "abcdef");
780		assert!(ui.next_wake().is_some(), "new backlog re-arms the frame cadence");
781		ui.tick(Duration::from_millis(136));
782		assert_eq!(frame_row_text(ui.frame(), 0), "abcdefgh");
783		ui.tick(Duration::from_millis(170));
784		assert_eq!(frame_row_text(ui.frame(), 0), "abcdefghijk");
785		ui.tick(Duration::from_millis(204));
786		assert_eq!(frame_row_text(ui.frame(), 0), "abcdefghijkl");
787
788		// A replacement restarts the typewriter from nothing.
789		assert!(ui.set_text("stream", "xyz"));
790		assert_eq!(frame_row_text(ui.frame(), 0), "");
791		ui.tick(Duration::from_millis(238));
792		assert_eq!(frame_row_text(ui.frame(), 0), "xy");
793		ui.tick(Duration::from_millis(272));
794		assert_eq!(frame_row_text(ui.frame(), 0), "xyz");
795		assert_eq!(ui.next_wake(), None);
796	}
797
798	#[test]
799	fn reveal_grows_height_as_rows_fill() {
800		let mut ui = Ui::from_root(
801			TextLeaf::new().with(Prop::Reveal, true).text("aaa bbb"),
802			3,
803			UiContext::default(),
804		);
805		assert_eq!(ui.height(), 1, "an empty reveal holds the blank row a bare leaf has");
806		ui.tick(Duration::from_millis(34));
807		assert_eq!(ui.height(), 1, "two clusters still fit the first row");
808		ui.tick(Duration::from_millis(67));
809		assert_eq!(ui.height(), 2, "the fifth cluster wraps onto a second row");
810		ui.tick(Duration::from_millis(100));
811		assert_eq!(frame_row_text(ui.frame(), 0), "aaa");
812		assert_eq!(frame_row_text(ui.frame(), 1), "bbb");
813	}
814
815	#[test]
816	fn reveal_state_extends_counts_across_cluster_boundaries() {
817		let mut state = RevealState::default();
818		state.sync(&Str::new("e"));
819		assert_eq!(state.total, 1);
820		// The appended combining mark extends the final cluster in place.
821		state.sync(&Str::new("e\u{301}"));
822		assert_eq!(state.total, 1);
823		state.sync(&Str::new("e\u{301}f"));
824		assert_eq!(state.total, 2);
825		// Replacement restarts the cursor from nothing.
826		state.sync(&Str::new("zz"));
827		assert_eq!(state.total, 2);
828		assert_eq!(state.advance(Duration::ZERO, Duration::from_millis(250)), 0);
829	}
830
831	#[test]
832	fn reveal_state_reslices_the_boundary_cluster_after_an_append() {
833		let mut state = RevealState::default();
834		state.sync(&Str::new("ab"));
835		// A zero horizon snaps the cursor to everything counted so far.
836		assert_eq!(state.advance(Duration::ZERO, Duration::ZERO), 2);
837		state.sync(&Str::new("ab\u{301}c"));
838		// The shown boundary cluster grew; the slice re-walks it before
839		// advancing rather than splitting the combining mark off.
840		let end = state.advance(Duration::from_millis(1), Duration::from_millis(250));
841		assert_eq!(&state.seen[..end], "ab\u{301}");
842		assert!(!state.is_settled());
843	}
844}