Skip to main content

omp_tui/
rich.rs

1//! Push-based styled rich-text storage and rendering adapters.
2
3use std::sync::{
4	LazyLock,
5	atomic::{AtomicU8, AtomicU64, Ordering},
6};
7
8use omp_core::Str;
9use smallvec::SmallVec;
10use xutf::{Text, width_char};
11
12use crate::{
13	escape::esc,
14	frame::{Color, Style},
15	renderer::push_style_parameters,
16};
17
18/// A reusable static space buffer for allocation-free padding.
19pub const SPACES: &str = "                                                                 ";
20const HANGUL_COMPAT_JAMO_START: char = '\u{3131}';
21const HANGUL_COMPAT_JAMO_END: char = '\u{318e}';
22const HANGUL_FILLER: char = '\u{3164}';
23const JAMO_PLATFORM: u8 = 0;
24const JAMO_UNICODE: u8 = 1;
25const JAMO_NARROW: u8 = 2;
26const JAMO_WIDE: u8 = 3;
27
28static JAMO_WIDTH: AtomicU8 = AtomicU8::new(JAMO_PLATFORM);
29static WIDTH_CONFIG_EPOCH: AtomicU64 = AtomicU64::new(0);
30
31/// Hangul Compatibility Jamo width policy currently used by [`cell_width`].
32pub fn jamo_width() -> crate::context::JamoWidth {
33	match JAMO_WIDTH.load(Ordering::Relaxed) {
34		JAMO_UNICODE => crate::context::JamoWidth::Unicode,
35		JAMO_NARROW => crate::context::JamoWidth::Narrow,
36		JAMO_WIDE => crate::context::JamoWidth::Wide,
37		_ => crate::context::JamoWidth::Platform,
38	}
39}
40
41/// Sets the process-wide Hangul Compatibility Jamo width policy.
42///
43/// Returns whether the policy changed. A change advances
44/// [`width_config_epoch`] so geometry and wrapping memos can discard widths
45/// measured under the previous policy.
46pub fn set_jamo_width(width: crate::context::JamoWidth) -> bool {
47	let encoded = match width {
48		crate::context::JamoWidth::Platform => JAMO_PLATFORM,
49		crate::context::JamoWidth::Unicode => JAMO_UNICODE,
50		crate::context::JamoWidth::Narrow => JAMO_NARROW,
51		crate::context::JamoWidth::Wide => JAMO_WIDE,
52	};
53	if JAMO_WIDTH.swap(encoded, Ordering::Relaxed) == encoded {
54		return false;
55	}
56	WIDTH_CONFIG_EPOCH.fetch_add(1, Ordering::Relaxed);
57	true
58}
59
60/// Monotonic generation for every process-wide width-affecting setting.
61///
62/// Any memo derived from [`cell_width`] must include this value in its key.
63pub fn width_config_epoch() -> u64 {
64	WIDTH_CONFIG_EPOCH.load(Ordering::Relaxed)
65}
66
67/// Visible width saturated to `u16::MAX`.
68///
69/// `xutf` supplies cluster-aware Unicode widths and treats East Asian
70/// Ambiguous characters as narrow. Compatibility Jamo are corrected by the
71/// delta from `xutf`'s own per-character classification to the active terminal
72/// policy; U+3164 HANGUL FILLER always remains zero-width.
73pub fn cell_width(text: &str) -> u16 {
74	if text.is_ascii() && text.bytes().all(|byte| !byte.is_ascii_control()) {
75		return u16::try_from(text.len()).unwrap_or(u16::MAX);
76	}
77	let mut width = text.visible_width();
78	let policy = jamo_width();
79	for character in text
80		.chars()
81		.filter(|character| (HANGUL_COMPAT_JAMO_START..=HANGUL_COMPAT_JAMO_END).contains(character))
82	{
83		let unicode_width = width_char(character);
84		let target = if character == HANGUL_FILLER {
85			0
86		} else {
87			match policy {
88				crate::context::JamoWidth::Unicode => unicode_width,
89				crate::context::JamoWidth::Narrow => 1,
90				crate::context::JamoWidth::Wide => 2,
91				crate::context::JamoWidth::Platform if cfg!(target_os = "macos") => 1,
92				crate::context::JamoWidth::Platform => unicode_width,
93			}
94		};
95		width = width.saturating_sub(unicode_width).saturating_add(target);
96	}
97	u16::try_from(width).unwrap_or(u16::MAX)
98}
99
100fn emit_spaces(sink: &mut dyn RichSink, style: Style, mut count: u16) {
101	while count != 0 {
102		let take = usize::from(count.min(SPACES.len() as u16));
103		sink.run(style, &SPACES[..take]);
104		count -= take as u16;
105	}
106}
107
108/// Receives styled runs and row breaks. `run` text contains neither newlines
109/// nor escapes; external ANSI text must cross [`decompose`] exactly once.
110pub trait RichSink {
111	/// Appends a styled text run to the current row.
112	fn run(&mut self, style: Style, text: &str);
113
114	/// Completes the current row and starts another logical row.
115	fn newline(&mut self);
116
117	/// Completes the current row at a mid-word soft wrap: the text continues
118	/// on the next row purely because it hit the layout width, with no
119	/// whitespace collapsed at the break.
120	///
121	/// Provenance-tracking sinks keep the boundary joinable so the renderer
122	/// can re-join it with terminal autowrap; the default treats it as a
123	/// [`RichSink::newline`].
124	fn soft_wrap(&mut self) {
125		self.newline();
126	}
127}
128
129/// ANSI-materializing boundary sink.
130///
131/// Each run carries a complete style prefix, so a `String` has no hidden style
132/// state. Text passed to this sink is escape-free by the [`RichSink`] contract.
133impl RichSink for String {
134	fn run(&mut self, style: Style, text: &str) {
135		self.push_str(esc!(style_prefix));
136		let mut first = false;
137		push_style_parameters(self, style, &mut first);
138		self.push('m');
139		self.push_str(text);
140	}
141
142	fn newline(&mut self) {
143		self.push_str(esc!(style_reset, "\n"));
144	}
145}
146
147/// Decomposes external terminal text into maximal escape-free styled slices.
148///
149/// This is the single ANSI ingress for the rich pipeline: external producers
150/// parse once here, and every downstream component may assume its text contains
151/// neither escape sequences nor newlines.
152pub fn decompose(input: &str, sink: &mut dyn RichSink) {
153	let bytes = input.as_bytes();
154	let mut style = Style::new();
155	let mut clean_start = 0;
156	let mut index = 0;
157
158	while index < bytes.len() {
159		match bytes[index] {
160			b'\n' => {
161				emit_clean(input, clean_start, index, style, sink);
162				sink.newline();
163				index += 1;
164				clean_start = index;
165			},
166			b'\r' => {
167				emit_clean(input, clean_start, index, style, sink);
168				index += 1;
169				if bytes.get(index) == Some(&b'\n') {
170					sink.newline();
171					index += 1;
172				}
173				clean_start = index;
174			},
175			b'\x1b' => {
176				emit_clean(input, clean_start, index, style, sink);
177				index = consume_escape(input, index, &mut style);
178				clean_start = index;
179			},
180			_ => index += 1,
181		}
182	}
183	emit_clean(input, clean_start, index, style, sink);
184}
185
186fn emit_clean(input: &str, start: usize, end: usize, style: Style, sink: &mut dyn RichSink) {
187	if start != end {
188		sink.run(style, &input[start..end]);
189	}
190}
191
192fn consume_escape(input: &str, start: usize, style: &mut Style) -> usize {
193	let bytes = input.as_bytes();
194	let Some(&kind) = bytes.get(start + 1) else {
195		return bytes.len();
196	};
197	match kind {
198		b'[' => {
199			let mut end = start + 2;
200			while let Some(&byte) = bytes.get(end) {
201				if (0x40..=0x7e).contains(&byte) {
202					if byte == b'm' {
203						apply_sgr(&input[start + 2..end], style);
204					}
205					return end + 1;
206				}
207				end += 1;
208			}
209			bytes.len()
210		},
211		b']' => {
212			let mut end = start + 2;
213			while let Some(&byte) = bytes.get(end) {
214				if byte == b'\x07' {
215					return end + 1;
216				}
217				if byte == b'\x1b' && bytes.get(end + 1) == Some(&b'\\') {
218					return end + 2;
219				}
220				end += 1;
221			}
222			bytes.len()
223		},
224		_ => {
225			let length = input[start + 1..]
226				.chars()
227				.next()
228				.expect("escape kind exists")
229				.len_utf8();
230			start + 1 + length
231		},
232	}
233}
234
235fn apply_sgr(parameters: &str, style: &mut Style) {
236	let mut parameters = parameters.split(';');
237	while let Some(parameter) = parameters.next() {
238		let code = if parameter.is_empty() {
239			0
240		} else if let Ok(code) = parameter.parse::<u16>() {
241			code
242		} else {
243			continue;
244		};
245		match code {
246			0 => *style = Style::new(),
247			1 => style.bold = true,
248			2 => style.dim = true,
249			3 => style.italic = true,
250			4 => style.underline = true,
251			7 => style.reverse = true,
252			9 => style.strikethrough = true,
253			22 => {
254				style.bold = false;
255				style.dim = false;
256			},
257			23 => style.italic = false,
258			24 => style.underline = false,
259			27 => style.reverse = false,
260			29 => style.strikethrough = false,
261			30..=37 => style.foreground = Color::Indexed((code - 30) as u8),
262			39 => style.foreground = Color::Default,
263			40..=47 => style.background = Color::Indexed((code - 40) as u8),
264			49 => style.background = Color::Default,
265			90..=97 => style.foreground = Color::Indexed((code - 90 + 8) as u8),
266			100..=107 => style.background = Color::Indexed((code - 100 + 8) as u8),
267			38 | 48 => {
268				let background = code == 48;
269				let color = match parameters
270					.next()
271					.and_then(|value| value.parse::<u16>().ok())
272				{
273					Some(5) => parameters
274						.next()
275						.and_then(|value| value.parse::<u8>().ok())
276						.map(Color::Indexed),
277					Some(2) => {
278						let red = parameters.next().and_then(|value| value.parse::<u8>().ok());
279						let green = parameters.next().and_then(|value| value.parse::<u8>().ok());
280						let blue = parameters.next().and_then(|value| value.parse::<u8>().ok());
281						red.zip(green)
282							.zip(blue)
283							.map(|((red, green), blue)| Color::Rgb(red, green, blue))
284					},
285					_ => None,
286				};
287				if let Some(color) = color {
288					if background {
289						style.background = color;
290					} else {
291						style.foreground = color;
292					}
293				}
294			},
295			_ => {},
296		}
297	}
298}
299
300#[derive(Clone, Debug)]
301struct Run {
302	end:   u32,
303	style: Style,
304}
305
306#[derive(Clone, Debug)]
307struct RowMeta {
308	run_end: u32,
309	width:   u16,
310	/// The row ended at a mid-word soft wrap and joins onto the next row.
311	soft:    bool,
312}
313
314/// Flat rendered rich text with coalesced styled runs and row metadata.
315#[derive(Clone, Debug, Default)]
316pub struct RichText {
317	text:          String,
318	runs:          Vec<Run>,
319	rows:          Vec<RowMeta>,
320	current_width: u16,
321	open:          bool,
322}
323
324impl RichText {
325	/// Clears all content while retaining allocated storage.
326	pub fn clear(&mut self) {
327		self.text.clear();
328		self.runs.clear();
329		self.rows.clear();
330		self.current_width = 0;
331		self.open = false;
332	}
333
334	/// Returns retained arena capacities for steady-state allocation tests.
335	#[cfg(test)]
336	pub(crate) const fn capacities(&self) -> (usize, usize, usize) {
337		(self.text.capacity(), self.runs.capacity(), self.rows.capacity())
338	}
339
340	/// Returns the number of rendered rows, including a non-empty partial row.
341	pub fn rows(&self) -> u16 {
342		u16::try_from(self.rows.len())
343			.unwrap_or(u16::MAX)
344			.saturating_add(u16::from(self.open))
345	}
346
347	/// Returns the precomputed terminal-cell width of `row`.
348	pub fn row_width(&self, row: u16) -> u16 {
349		let index = usize::from(row);
350		if let Some(meta) = self.rows.get(index) {
351			meta.width
352		} else if self.open && index == self.rows.len() {
353			self.current_width
354		} else {
355			0
356		}
357	}
358
359	fn row_run_bounds(&self, row: u16) -> (usize, usize) {
360		let index = usize::from(row);
361		if index >= usize::from(self.rows()) {
362			return (0, 0);
363		}
364		let start = index
365			.checked_sub(1)
366			.and_then(|previous| self.rows.get(previous))
367			.map_or(0, |meta| meta.run_end as usize);
368		let end = self
369			.rows
370			.get(index)
371			.map_or(self.runs.len(), |meta| meta.run_end as usize);
372		(start, end)
373	}
374
375	/// Returns the plain text in `row`.
376	pub fn row_text(&self, row: u16) -> &str {
377		let (run_start, run_end) = self.row_run_bounds(row);
378		let byte_start = run_start
379			.checked_sub(1)
380			.and_then(|index| self.runs.get(index))
381			.map_or(0, |run| run.end as usize);
382		let byte_end = run_end
383			.checked_sub(1)
384			.and_then(|index| self.runs.get(index))
385			.map_or(byte_start, |run| run.end as usize);
386		&self.text[byte_start..byte_end]
387	}
388
389	/// Iterates the styled runs in `row`.
390	pub fn row_runs(
391		&self,
392		row: u16,
393	) -> impl DoubleEndedIterator<Item = (Style, &str)>
394	+ ExactSizeIterator
395	+ Clone
396	+ std::iter::FusedIterator
397	+ '_ {
398		let (start, end) = self.row_run_bounds(row);
399		self.runs[start..end]
400			.iter()
401			.enumerate()
402			.map(move |(offset, run)| {
403				let index = start + offset;
404				let byte_start = index
405					.checked_sub(1)
406					.and_then(|previous| self.runs.get(previous))
407					.map_or(0, |previous| previous.end as usize);
408				(run.style, &self.text[byte_start..run.end as usize])
409			})
410	}
411
412	/// Whether `row` soft-wraps onto the following row: it was broken
413	/// mid-word by width alone, so the pair forms one logical line.
414	pub fn row_soft_wrap(&self, row: u16) -> bool {
415		self
416			.rows
417			.get(usize::from(row))
418			.is_some_and(|meta| meta.soft)
419	}
420
421	/// Replays every row, preserving completed versus trailing partial rows.
422	pub fn replay(&self, sink: &mut dyn RichSink) {
423		for row in 0..self.rows() {
424			self.replay_row(row, sink);
425			if usize::from(row) < self.rows.len() {
426				if self.row_soft_wrap(row) {
427					sink.soft_wrap();
428				} else {
429					sink.newline();
430				}
431			}
432		}
433	}
434
435	/// Replays the runs of one row without appending a newline.
436	pub fn replay_row(&self, row: u16, sink: &mut dyn RichSink) {
437		for (style, text) in self.row_runs(row) {
438			sink.run(style, text);
439		}
440	}
441
442	/// Returns the widest rendered row.
443	pub fn widest(&self) -> u16 {
444		self
445			.rows
446			.iter()
447			.map(|row| row.width)
448			.chain(self.open.then_some(self.current_width))
449			.max()
450			.unwrap_or(0)
451	}
452}
453
454impl RichSink for RichText {
455	fn run(&mut self, style: Style, text: &str) {
456		if text.is_empty() {
457			return;
458		}
459		self.text.push_str(text);
460		let end = u32::try_from(self.text.len()).expect("rich text exceeds four gigabytes");
461		if self.open && self.runs.last().is_some_and(|run| run.style == style) {
462			self.runs.last_mut().expect("last run exists").end = end;
463		} else {
464			self.runs.push(Run { end, style });
465		}
466		self.current_width = self.current_width.saturating_add(cell_width(text));
467		self.open = true;
468	}
469
470	fn newline(&mut self) {
471		self.end_row(false);
472	}
473
474	fn soft_wrap(&mut self) {
475		self.end_row(true);
476	}
477}
478impl RichText {
479	fn end_row(&mut self, soft: bool) {
480		self.rows.push(RowMeta {
481			run_end: u32::try_from(self.runs.len()).expect("rich text has too many runs"),
482			width: self.current_width,
483			soft,
484		});
485		self.current_width = 0;
486		self.open = false;
487	}
488}
489
490/// Counts rendered rows and widths without storing their text.
491#[derive(Default)]
492pub struct Measure {
493	/// Number of rows observed, including a non-empty partial row.
494	pub rows:   u16,
495	/// Width of the widest row observed.
496	pub widest: u16,
497	current:    u16,
498	open:       bool,
499}
500
501impl RichSink for Measure {
502	fn run(&mut self, _style: Style, text: &str) {
503		if text.is_empty() {
504			return;
505		}
506		if !self.open {
507			self.rows = self.rows.saturating_add(1);
508			self.open = true;
509		}
510		self.current = self.current.saturating_add(cell_width(text));
511		self.widest = self.widest.max(self.current);
512	}
513
514	fn newline(&mut self) {
515		if !self.open {
516			self.rows = self.rows.saturating_add(1);
517		}
518		self.widest = self.widest.max(self.current);
519		self.current = 0;
520		self.open = false;
521	}
522}
523
524/// An owned styled hanging prefix.
525#[derive(Clone, Debug, Default)]
526pub struct Prefix {
527	text:  String,
528	runs:  SmallVec<(u32, Style), 2>,
529	width: u16,
530}
531
532impl Prefix {
533	/// Appends a styled run to this prefix.
534	pub fn push(&mut self, style: Style, text: &str) {
535		if text.is_empty() {
536			return;
537		}
538		self.text.push_str(text);
539		let end = u32::try_from(self.text.len()).expect("prefix exceeds four gigabytes");
540		if self
541			.runs
542			.last()
543			.is_some_and(|(_, run_style)| *run_style == style)
544		{
545			self.runs.last_mut().expect("last prefix run exists").0 = end;
546		} else {
547			self.runs.push((end, style));
548		}
549		self.width = self.width.saturating_add(cell_width(text));
550	}
551
552	/// Returns the prefix width in terminal cells.
553	pub const fn width(&self) -> u16 {
554		self.width
555	}
556
557	/// Emits this prefix into `sink`.
558	pub fn emit(&self, sink: &mut dyn RichSink) {
559		let mut start = 0;
560		for (end, style) in &self.runs {
561			sink.run(*style, &self.text[start..*end as usize]);
562			start = *end as usize;
563		}
564	}
565
566	/// Returns whether the prefix contains no text.
567	pub const fn is_empty(&self) -> bool {
568		self.text.is_empty()
569	}
570
571	/// Returns a shared empty prefix.
572	pub fn empty_ref() -> &'static Self {
573		static EMPTY: LazyLock<Prefix> = LazyLock::new(Prefix::default);
574		&EMPTY
575	}
576
577	fn emit_clipped(&self, width: u16, sink: &mut dyn RichSink) -> bool {
578		let mut used = 0_u16;
579		let mut start = 0;
580		let mut emitted = false;
581		'outer: for (end, style) in &self.runs {
582			for grapheme in self.text[start..*end as usize].graphemes() {
583				let grapheme_width = cell_width(grapheme);
584				if used.saturating_add(grapheme_width) > width {
585					break 'outer;
586				}
587				sink.run(*style, grapheme);
588				emitted = true;
589				used = used.saturating_add(grapheme_width);
590			}
591			start = *end as usize;
592		}
593		emitted
594	}
595}
596
597impl<S: RichSink + ?Sized> RichSink for &mut S {
598	fn run(&mut self, style: Style, text: &str) {
599		(**self).run(style, text);
600	}
601
602	fn newline(&mut self) {
603		(**self).newline();
604	}
605
606	fn soft_wrap(&mut self) {
607		(**self).soft_wrap();
608	}
609}
610
611/// Functional adapters available on every rich sink.
612pub trait Pipeline: RichSink + Sized {
613	/// Hard-clips each row to `width`, optionally replacing its final cell with
614	/// a marker.
615	fn clip(self, width: u16, marker: Option<char>) -> Clip<Self> {
616		Clip::new(self, width, marker)
617	}
618
619	/// Limits output to at most `max` rows.
620	fn rows(self, max: u16) -> Rows<Self> {
621		Rows { inner: self, max, seen: 0, truncated: false }
622	}
623
624	/// Word-wraps output without prefixes.
625	fn wrap(self, width: u16) -> Wrap<'static, Self> {
626		self.wrap_prefixed(width, Prefix::empty_ref(), Prefix::empty_ref())
627	}
628	/// Flows output grapheme-exact to `width` like a bare terminal: every
629	/// width break is a byte-preserving [`RichSink::soft_wrap`], so joined
630	/// rows reproduce the source exactly in native copy.
631	fn wrap_chars(self, width: u16) -> CharWrap<Self> {
632		CharWrap { inner: self, width: width.max(1), used: 0 }
633	}
634
635	/// Word-wraps output using first-row and continuation prefixes.
636	fn wrap_prefixed<'p>(self, width: u16, first: &'p Prefix, cont: &'p Prefix) -> Wrap<'p, Self> {
637		Wrap::new(self, width, first, cont)
638	}
639
640	/// Copies output into `copy` while forwarding it.
641	fn tee(self, copy: &mut RichText) -> Tee<'_, Self> {
642		Tee { inner: self, copy }
643	}
644
645	/// Maps every incoming style.
646	fn restyle<F: Fn(Style) -> Style>(self, map: F) -> Restyle<Self, F> {
647		Restyle { inner: self, map }
648	}
649
650	/// Adds a prefix at the start of every row without wrapping.
651	fn prefixed<'p>(self, first: &'p Prefix, cont: &'p Prefix) -> Prefixed<'p, Self> {
652		Prefixed { inner: self, first, cont, row: 0, at_start: true }
653	}
654}
655
656impl<S: RichSink> Pipeline for S {}
657/// A terminal-exact wrapping sink adapter.
658///
659/// Graphemes flow to the exact width with all whitespace preserved and
660/// every width break emitted as a soft wrap — the wrapping a bare terminal
661/// performs, so the renderer can re-join rows byte-for-byte.
662pub struct CharWrap<S: RichSink> {
663	inner: S,
664	width: u16,
665	used:  u16,
666}
667
668impl<S: RichSink> RichSink for CharWrap<S> {
669	fn run(&mut self, style: Style, text: &str) {
670		for grapheme in text.graphemes() {
671			let grapheme_width = cell_width(grapheme);
672			if grapheme_width > self.width {
673				continue;
674			}
675			if grapheme_width > 0 && self.used.saturating_add(grapheme_width) > self.width {
676				self.inner.soft_wrap();
677				self.used = 0;
678			}
679			self.inner.run(style, grapheme);
680			self.used = self.used.saturating_add(grapheme_width);
681		}
682	}
683
684	fn newline(&mut self) {
685		self.inner.newline();
686		self.used = 0;
687	}
688
689	fn soft_wrap(&mut self) {
690		self.inner.soft_wrap();
691		self.used = 0;
692	}
693}
694
695/// A word-wrapping rich sink adapter.
696pub struct Wrap<'p, S: RichSink> {
697	inner:        S,
698	width:        u16,
699	first:        &'p Prefix,
700	cont:         &'p Prefix,
701	word:         String,
702	word_runs:    SmallVec<(u32, Style), 4>,
703	word_width:   u16,
704	gap_text:     String,
705	gap_runs:     SmallVec<(u32, Style), 2>,
706	gap_width:    u16,
707	line_width:   u16,
708	emitted:      bool,
709	content:      bool,
710	continuation: bool,
711}
712
713impl<'p, S: RichSink> Wrap<'p, S> {
714	const fn new(inner: S, width: u16, first: &'p Prefix, cont: &'p Prefix) -> Self {
715		Self {
716			inner,
717			width,
718			first,
719			cont,
720			word: String::new(),
721			word_runs: SmallVec::new(),
722			word_width: 0,
723			gap_text: String::new(),
724			gap_runs: SmallVec::new(),
725			gap_width: 0,
726			line_width: 0,
727			emitted: false,
728			content: false,
729			continuation: false,
730		}
731	}
732
733	fn start_row(&mut self) {
734		if self.emitted {
735			return;
736		}
737		let prefix = if self.continuation {
738			self.cont
739		} else {
740			self.first
741		};
742		prefix.emit_clipped(self.width, &mut self.inner);
743		self.line_width = prefix.width().min(self.width);
744		self.emitted = true;
745	}
746
747	fn break_row(&mut self, soft: bool) {
748		self.start_row();
749		// A prefixed continuation never starts at the break column, so the
750		// boundary is only joinable when continuation rows are bare.
751		if soft && self.cont.is_empty() {
752			self.inner.soft_wrap();
753		} else {
754			self.inner.newline();
755		}
756		self.line_width = 0;
757		self.emitted = false;
758		self.content = false;
759		self.continuation = true;
760	}
761
762	fn append_word_grapheme(&mut self, style: Style, grapheme: &str) {
763		self.word.push_str(grapheme);
764		let end = u32::try_from(self.word.len()).expect("wrapped word exceeds four gigabytes");
765		if self
766			.word_runs
767			.last()
768			.is_some_and(|(_, run_style)| *run_style == style)
769		{
770			self.word_runs.last_mut().expect("last word run exists").0 = end;
771		} else {
772			self.word_runs.push((end, style));
773		}
774		self.word_width = self.word_width.saturating_add(cell_width(grapheme));
775	}
776
777	fn append_gap_grapheme(&mut self, style: Style, grapheme: &str) {
778		self.gap_text.push_str(grapheme);
779		let end = u32::try_from(self.gap_text.len()).expect("wrapped gap exceeds four gigabytes");
780		if self
781			.gap_runs
782			.last()
783			.is_some_and(|(_, run_style)| *run_style == style)
784		{
785			self.gap_runs.last_mut().expect("last gap run exists").0 = end;
786		} else {
787			self.gap_runs.push((end, style));
788		}
789		self.gap_width = self.gap_width.saturating_add(cell_width(grapheme));
790	}
791
792	fn emit_gap(&mut self) {
793		if self.gap_text.is_empty() {
794			emit_spaces(&mut self.inner, Style::new(), 1);
795			return;
796		}
797		let mut start = 0;
798		for (end, style) in &self.gap_runs {
799			self.inner.run(*style, &self.gap_text[start..*end as usize]);
800			start = *end as usize;
801		}
802	}
803
804	fn clear_gap(&mut self) {
805		self.gap_text.clear();
806		self.gap_runs.clear();
807		self.gap_width = 0;
808	}
809
810	fn flush_word(&mut self) {
811		if self.word.is_empty() {
812			return;
813		}
814		let word = std::mem::take(&mut self.word);
815		let runs = std::mem::take(&mut self.word_runs);
816		let word_width = std::mem::take(&mut self.word_width);
817		self.start_row();
818		let join_gap = self.gap_width.max(1);
819		if self.content
820			&& self
821				.line_width
822				.saturating_add(join_gap)
823				.saturating_add(word_width)
824				> self.width
825		{
826			self.break_row(false);
827			self.start_row();
828		} else if self.content {
829			self.emit_gap();
830			self.line_width = self.line_width.saturating_add(join_gap);
831		}
832
833		let mut start = 0;
834		for (end, style) in &runs {
835			for grapheme in word[start..*end as usize].graphemes() {
836				let grapheme_width = cell_width(grapheme);
837				if self.content && self.line_width.saturating_add(grapheme_width) > self.width {
838					self.break_row(true);
839					self.start_row();
840				}
841				if self.line_width.saturating_add(grapheme_width) <= self.width || self.width != 0 {
842					self.inner.run(*style, grapheme);
843					self.line_width = self.line_width.saturating_add(grapheme_width);
844					self.content = true;
845				}
846			}
847			start = *end as usize;
848		}
849		self.clear_gap();
850		self.word = word;
851		self.word.clear();
852		self.word_runs = runs;
853		self.word_runs.clear();
854	}
855
856	/// Flushes the trailing word and partial row, returning the downstream sink.
857	pub fn finish(mut self) -> S {
858		self.flush_word();
859		self.start_row();
860		self.inner.newline();
861		self.inner
862	}
863}
864
865impl<S: RichSink> RichSink for Wrap<'_, S> {
866	fn run(&mut self, style: Style, text: &str) {
867		for grapheme in text.graphemes() {
868			if grapheme != "\u{a0}" && grapheme.chars().all(char::is_whitespace) {
869				self.flush_word();
870				self.append_gap_grapheme(style, grapheme);
871			} else {
872				self.append_word_grapheme(style, grapheme);
873			}
874		}
875	}
876
877	fn newline(&mut self) {
878		self.flush_word();
879		self.break_row(false);
880		self.clear_gap();
881	}
882}
883
884/// A per-row hard-clipping sink adapter.
885pub struct Clip<S: RichSink> {
886	inner:   S,
887	width:   u16,
888	marker:  Option<char>,
889	used:    u16,
890	done:    bool,
891	pending: Option<(Style, Str, u16)>,
892}
893
894impl<S: RichSink> Clip<S> {
895	const fn new(inner: S, width: u16, marker: Option<char>) -> Self {
896		Self { inner, width, marker, used: 0, done: false, pending: None }
897	}
898
899	fn flush_pending(&mut self) {
900		if let Some((style, text, _)) = self.pending.take() {
901			self.inner.run(style, text.as_str());
902		}
903	}
904
905	fn truncate(&mut self, fallback_style: Style) {
906		self.done = true;
907		let style = self
908			.pending
909			.as_ref()
910			.map_or(fallback_style, |pending| pending.0);
911		if let Some((_, _, width)) = self.pending.take() {
912			self.used = self.used.saturating_sub(width);
913		}
914		if let Some(marker) = self.marker {
915			let mut encoded = [0_u8; 4];
916			let marker = marker.encode_utf8(&mut encoded);
917			let marker_width = cell_width(marker);
918			if marker_width != 0 && self.used.saturating_add(marker_width) <= self.width {
919				self.inner.run(style, marker);
920				self.used = self.used.saturating_add(marker_width);
921			}
922		}
923	}
924}
925
926impl<S: RichSink> RichSink for Clip<S> {
927	fn run(&mut self, style: Style, text: &str) {
928		if self.done || text.is_empty() {
929			return;
930		}
931		for grapheme in text.graphemes() {
932			let grapheme_width = cell_width(grapheme);
933			if self.used.saturating_add(grapheme_width) > self.width {
934				self.truncate(style);
935				break;
936			}
937			if self.marker.is_some() {
938				self.flush_pending();
939				self.pending = Some((style, Str::new(grapheme), grapheme_width));
940			} else {
941				self.inner.run(style, grapheme);
942			}
943			self.used = self.used.saturating_add(grapheme_width);
944		}
945	}
946
947	fn newline(&mut self) {
948		if !self.done {
949			self.flush_pending();
950		}
951		self.inner.newline();
952		self.used = 0;
953		self.done = false;
954		self.pending = None;
955	}
956
957	fn soft_wrap(&mut self) {
958		if !self.done {
959			self.flush_pending();
960		}
961		self.inner.soft_wrap();
962		self.used = 0;
963		self.done = false;
964		self.pending = None;
965	}
966}
967
968impl<S: RichSink> Drop for Clip<S> {
969	fn drop(&mut self) {
970		if !self.done {
971			self.flush_pending();
972		}
973	}
974}
975
976/// A row-limiting sink adapter.
977pub struct Rows<S: RichSink> {
978	inner:     S,
979	max:       u16,
980	seen:      u16,
981	truncated: bool,
982}
983
984impl<S: RichSink> Rows<S> {
985	/// Returns whether any output was swallowed after the row limit.
986	pub const fn truncated(&self) -> bool {
987		self.truncated
988	}
989}
990
991impl<S: RichSink> RichSink for Rows<S> {
992	fn run(&mut self, style: Style, text: &str) {
993		if self.seen < self.max {
994			self.inner.run(style, text);
995		} else if !text.is_empty() {
996			self.truncated = true;
997		}
998	}
999
1000	fn newline(&mut self) {
1001		if self.seen < self.max {
1002			self.inner.newline();
1003			self.seen = self.seen.saturating_add(1);
1004		} else {
1005			self.truncated = true;
1006		}
1007	}
1008
1009	fn soft_wrap(&mut self) {
1010		if self.seen < self.max {
1011			self.inner.soft_wrap();
1012			self.seen = self.seen.saturating_add(1);
1013		} else {
1014			self.truncated = true;
1015		}
1016	}
1017}
1018
1019/// A sink adapter that forwards and copies all output.
1020pub struct Tee<'b, S: RichSink> {
1021	inner: S,
1022	copy:  &'b mut RichText,
1023}
1024
1025impl<S: RichSink> RichSink for Tee<'_, S> {
1026	fn run(&mut self, style: Style, text: &str) {
1027		self.inner.run(style, text);
1028		self.copy.run(style, text);
1029	}
1030
1031	fn newline(&mut self) {
1032		self.inner.newline();
1033		self.copy.newline();
1034	}
1035
1036	fn soft_wrap(&mut self) {
1037		self.inner.soft_wrap();
1038		self.copy.soft_wrap();
1039	}
1040}
1041
1042/// A sink adapter that transforms every style.
1043pub struct Restyle<S: RichSink, F: Fn(Style) -> Style> {
1044	inner: S,
1045	map:   F,
1046}
1047
1048impl<S: RichSink, F: Fn(Style) -> Style> RichSink for Restyle<S, F> {
1049	fn run(&mut self, style: Style, text: &str) {
1050		self.inner.run((self.map)(style), text);
1051	}
1052
1053	fn newline(&mut self) {
1054		self.inner.newline();
1055	}
1056
1057	fn soft_wrap(&mut self) {
1058		self.inner.soft_wrap();
1059	}
1060}
1061
1062/// A sink adapter that emits first-row and continuation prefixes.
1063pub struct Prefixed<'p, S: RichSink> {
1064	inner:    S,
1065	first:    &'p Prefix,
1066	cont:     &'p Prefix,
1067	row:      u16,
1068	at_start: bool,
1069}
1070
1071impl<S: RichSink> Prefixed<'_, S> {
1072	fn prefix(&mut self) {
1073		if !self.at_start {
1074			return;
1075		}
1076		if self.row == 0 {
1077			self.first.emit(&mut self.inner);
1078		} else {
1079			self.cont.emit(&mut self.inner);
1080		}
1081		self.at_start = false;
1082	}
1083}
1084
1085impl<S: RichSink> RichSink for Prefixed<'_, S> {
1086	fn run(&mut self, style: Style, text: &str) {
1087		if text.is_empty() {
1088			return;
1089		}
1090		self.prefix();
1091		self.inner.run(style, text);
1092	}
1093
1094	fn newline(&mut self) {
1095		self.prefix();
1096		self.inner.newline();
1097		self.row = self.row.saturating_add(1);
1098		self.at_start = true;
1099	}
1100	// A prefixed continuation row can never be byte-joined to its
1101	// predecessor, so a soft wrap degrades to a hard row break.
1102}
1103
1104#[cfg(test)]
1105mod tests {
1106	use super::*;
1107
1108	fn texts(rich: &RichText) -> Vec<&str> {
1109		(0..rich.rows()).map(|row| rich.row_text(row)).collect()
1110	}
1111
1112	#[test]
1113	fn decompose_splits_bash_ansi_into_clean_styled_runs() {
1114		let mut output = RichText::default();
1115		decompose("plain \x1b[31;1mred\x1b[0m!\n\x1b[38;2;1;2;3mrgb", &mut output);
1116
1117		assert_eq!(texts(&output), ["plain red!", "rgb"]);
1118		assert!(texts(&output).iter().all(|text| !text.contains('\x1b')));
1119		assert_eq!(output.row_runs(0).collect::<Vec<_>>(), [
1120			(Style::new(), "plain "),
1121			(Style::new().fg(Color::Indexed(1)).bold(), "red"),
1122			(Style::new(), "!"),
1123		]);
1124		assert_eq!(output.row_runs(1).collect::<Vec<_>>(), [(
1125			Style::new().fg(Color::Rgb(1, 2, 3)),
1126			"rgb"
1127		)]);
1128	}
1129
1130	#[test]
1131	fn string_sink_emits_a_complete_style_for_every_run() {
1132		let mut output = String::new();
1133		output.run(Style::new(), "a");
1134		output.run(Style::new().bold().fg(Color::Rgb(1, 2, 3)), "b");
1135		output.newline();
1136
1137		assert_eq!(output, "\x1b[0ma\x1b[0;1;38;2;1;2;3mb\x1b[0m\n");
1138	}
1139
1140	#[test]
1141	fn ansi_materialization_round_trips_run_structure() {
1142		let mut first = RichText::default();
1143		decompose("a\x1b[3;48;5;12mb\n\x1b[9;94mc", &mut first);
1144		let mut ansi = String::new();
1145		first.replay(&mut ansi);
1146		let mut second = RichText::default();
1147		decompose(&ansi, &mut second);
1148
1149		assert_eq!(texts(&first), texts(&second));
1150		for row in 0..RichText::rows(&first) {
1151			assert_eq!(
1152				first.row_runs(row).collect::<Vec<_>>(),
1153				second.row_runs(row).collect::<Vec<_>>()
1154			);
1155		}
1156	}
1157
1158	#[test]
1159	fn decompose_strips_osc_and_non_sgr_csi() {
1160		let mut output = RichText::default();
1161		decompose("a\x1b]0;title\x07b\x1b[2Ac\x1b]ignored\x1b\\d", &mut output);
1162		assert_eq!(texts(&output), ["abcd"]);
1163	}
1164
1165	#[test]
1166	fn decompose_measure_counts_only_visible_cells() {
1167		let mut measure = Measure::default();
1168		decompose("a\x1b[31m界\x1b[0m\x1b]0;title\x07\r\nxy\x1b[2A", &mut measure);
1169		assert_eq!(measure.rows, 2);
1170		assert_eq!(measure.widest, 3);
1171	}
1172
1173	#[test]
1174	fn chains_row_limit_and_clipping() {
1175		let mut output = RichText::default();
1176		{
1177			let mut sink = (&mut output).rows(2).clip(3, Some('…'));
1178			for _ in 0..3 {
1179				sink.run(Style::new(), "abcdef");
1180				sink.newline();
1181			}
1182		}
1183		assert_eq!(texts(&output), ["ab…", "ab…"]);
1184	}
1185
1186	#[test]
1187	fn tee_makes_identical_copies() {
1188		let mut forwarded = RichText::default();
1189		let mut copied = RichText::default();
1190		{
1191			let mut sink = (&mut forwarded).tee(&mut copied);
1192			sink.run(Style::new(), "one");
1193			sink.newline();
1194			sink.run(Style::new().bold(), "two");
1195		}
1196		assert_eq!(texts(&forwarded), texts(&copied));
1197		for row in 0..RichText::rows(&forwarded) {
1198			assert_eq!(
1199				forwarded.row_runs(row).collect::<Vec<_>>(),
1200				copied.row_runs(row).collect::<Vec<_>>()
1201			);
1202		}
1203	}
1204
1205	#[test]
1206	fn prefixes_first_and_continuation_rows() {
1207		let mut first = Prefix::default();
1208		first.push(Style::new(), "> ");
1209		let mut cont = Prefix::default();
1210		cont.push(Style::new(), "  ");
1211		let mut output = RichText::default();
1212		{
1213			let mut sink = (&mut output).prefixed(&first, &cont);
1214			sink.run(Style::new(), "a");
1215			sink.newline();
1216			sink.run(Style::new(), "b");
1217		}
1218		assert_eq!(texts(&output), ["> a", "  b"]);
1219	}
1220
1221	#[test]
1222	fn mid_word_overflow_records_soft_rows() {
1223		let mut output = RichText::default();
1224		let mut wrap = (&mut output).wrap(3);
1225		wrap.run(Style::new(), "abcdef gh");
1226		wrap.finish();
1227		assert_eq!(texts(&output), ["abc", "def", "gh"]);
1228		assert!(output.row_soft_wrap(0), "a width break inside a word is soft");
1229		assert!(!output.row_soft_wrap(1), "a word-boundary break collapsed whitespace");
1230		assert!(!output.row_soft_wrap(2));
1231	}
1232
1233	#[test]
1234	fn prefixed_continuations_never_record_soft_rows() {
1235		let mut cont = Prefix::default();
1236		cont.push(Style::new(), "> ");
1237		let mut output = RichText::default();
1238		let mut wrap = (&mut output).wrap_prefixed(4, Prefix::empty_ref(), &cont);
1239		wrap.run(Style::new(), "abcdefgh");
1240		wrap.finish();
1241		assert!(RichText::rows(&output) > 1);
1242		assert!((0..RichText::rows(&output)).all(|row| !output.row_soft_wrap(row)));
1243	}
1244
1245	#[test]
1246	fn char_wrap_flows_exact_and_preserves_whitespace() {
1247		let mut output = RichText::default();
1248		{
1249			let mut wrap = (&mut output).wrap_chars(3);
1250			wrap.run(Style::new(), "ab cdef");
1251		}
1252		assert_eq!(texts(&output), ["ab ", "cde", "f"]);
1253		assert!(output.row_soft_wrap(0) && output.row_soft_wrap(1));
1254	}
1255
1256	#[test]
1257	fn replay_preserves_soft_rows() {
1258		let mut original = RichText::default();
1259		{
1260			let mut wrap = (&mut original).wrap_chars(3);
1261			wrap.run(Style::new(), "abcdef");
1262		}
1263		let mut copy = RichText::default();
1264		original.replay(&mut copy);
1265		assert_eq!(texts(&copy), texts(&original));
1266		assert!(copy.row_soft_wrap(0));
1267	}
1268	#[test]
1269	fn restyle_composes_with_wrap() {
1270		let changed = Style::new().fg(Color::Indexed(4));
1271		let mut output = RichText::default();
1272		let mut wrap = (&mut output).restyle(|_| changed).wrap(3);
1273		wrap.run(Style::new(), "ab cd");
1274		wrap.finish();
1275		assert_eq!(texts(&output), ["ab", "cd"]);
1276		assert!(output.row_runs(0).all(|(style, _)| style == changed));
1277	}
1278
1279	#[test]
1280	fn dynamic_sink_can_be_chained() {
1281		let mut output = RichText::default();
1282		let dynamic: &mut dyn RichSink = &mut output;
1283		{
1284			let mut sink = dynamic.rows(1).clip(2, None);
1285			sink.run(Style::new(), "abc");
1286			sink.newline();
1287		}
1288		assert_eq!(texts(&output), ["ab"]);
1289	}
1290	#[test]
1291	fn jamo_profiles_filler_and_ambiguous_widths() {
1292		let original = jamo_width();
1293		let jamo = "ㅁㄴㅇㅂ";
1294
1295		set_jamo_width(crate::context::JamoWidth::Unicode);
1296		assert_eq!(cell_width(jamo), 8);
1297		assert_eq!(cell_width("\u{3164}"), 0);
1298
1299		set_jamo_width(crate::context::JamoWidth::Narrow);
1300		assert_eq!(cell_width(jamo), 4);
1301		assert_eq!(cell_width("\u{3164}"), 0);
1302
1303		set_jamo_width(crate::context::JamoWidth::Wide);
1304		assert_eq!(cell_width(jamo), 8);
1305		assert_eq!(cell_width("\u{3164}"), 0);
1306
1307		set_jamo_width(crate::context::JamoWidth::Platform);
1308		assert_eq!(cell_width(jamo), if cfg!(target_os = "macos") { 4 } else { 8 });
1309		assert_eq!(cell_width("\u{3164}"), 0);
1310		assert_eq!(cell_width("©"), 1, "East Asian Ambiguous characters stay narrow");
1311
1312		set_jamo_width(original);
1313	}
1314}