Skip to main content

omp_tui/
frame.rs

1use std::sync::{
2	LazyLock,
3	atomic::{AtomicU64, Ordering},
4};
5
6use omp_core::Str;
7use parking_lot::Mutex;
8use smol_bitmap::SmolBitmap;
9use xutf::{Text, width_char};
10
11static NEXT_FRAME_ID: AtomicU64 = AtomicU64::new(1);
12
13/// Terminal dimensions measured in character cells.
14#[derive(Clone, Copy, Debug, Eq, PartialEq)]
15pub struct Size {
16	/// Number of columns.
17	pub width:  u16,
18	/// Number of rows.
19	pub height: u16,
20}
21
22impl Size {
23	/// Creates terminal dimensions from a column and row count.
24	pub const fn new(width: u16, height: u16) -> Self {
25		Self { width, height }
26	}
27
28	fn area(self) -> usize {
29		usize::from(self.width) * usize::from(self.height)
30	}
31}
32
33/// A rectangular cell region clipped by drawing operations to the frame.
34#[derive(Clone, Copy, Debug, Eq, PartialEq)]
35pub struct Rect {
36	/// Leftmost column.
37	pub x:      u16,
38	/// Topmost row.
39	pub y:      u16,
40	/// Region width in cells.
41	pub width:  u16,
42	/// Region height in cells.
43	pub height: u16,
44}
45
46impl Rect {
47	/// Creates a cell rectangle.
48	pub const fn new(x: u16, y: u16, width: u16, height: u16) -> Self {
49		Self { x, y, width, height }
50	}
51
52	const fn right(self) -> u16 {
53		self.x.saturating_add(self.width)
54	}
55
56	const fn bottom(self) -> u16 {
57		self.y.saturating_add(self.height)
58	}
59}
60
61/// A terminal foreground or background color.
62#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
63pub enum Color {
64	/// The terminal's configured default color.
65	#[default]
66	Default,
67	/// An indexed color from the terminal's 256-color palette.
68	Indexed(u8),
69	/// A 24-bit RGB color.
70	Rgb(u8, u8, u8),
71}
72
73impl Color {
74	/// Parses any CSS color and lowers it to a cell color without
75	/// context: fully transparent values and `currentcolor` become
76	/// [`Color::Default`] (the terminal's pass-through color),
77	/// translucent values keep their color unblended, and system
78	/// colors — which need a theme — return `None`. Parse a
79	/// [`CssColor`](crate::CssColor) instead when alpha, `currentcolor`,
80	/// or system colors must survive to a context-aware lowering.
81	///
82	/// # Example
83	/// ```
84	/// use omp_tui::Color;
85	/// assert_eq!(Color::parse("rebeccapurple"), Some(Color::Rgb(0x66, 0x33, 0x99)));
86	/// assert_eq!(Color::parse("hsl(120 100% 50%)"), Some(Color::Rgb(0, 255, 0)));
87	/// assert_eq!(Color::parse("transparent"), Some(Color::Default));
88	/// assert_eq!(Color::parse("rgb(255 0 0 / 0)"), Some(Color::Default));
89	/// assert_eq!(Color::parse("Canvas"), None);
90	/// ```
91	pub fn parse(value: &str) -> Option<Self> {
92		use crate::color::CssColor;
93		match CssColor::parse(value)? {
94			CssColor::Rgba(_, _, _, alpha) if alpha <= 0.0 => Some(Self::Default),
95			CssColor::Rgba(red, green, blue, _) => Some(Self::Rgb(red, green, blue)),
96			CssColor::Current => Some(Self::Default),
97			CssColor::System(_) => None,
98		}
99	}
100}
101/// A two-stop terminal color ramp.
102///
103/// Zero degrees runs left-to-right; 90 degrees runs top-to-bottom.
104#[derive(Clone, Copy, Debug, Eq, PartialEq)]
105pub struct Gradient {
106	start: Color,
107	end:   Color,
108	angle: u16,
109}
110
111impl Gradient {
112	/// Creates a ramp with an angle normalized by the markup parser.
113	pub(crate) const fn new(start: Color, end: Color, angle: u16) -> Self {
114		Self { start, end, angle }
115	}
116
117	fn projection(self, bounds: Rect) -> GradientProjection {
118		let (horizontal, vertical) = match self.angle % 360 {
119			0 => (1.0, 0.0),
120			90 => (0.0, 1.0),
121			180 => (-1.0, 0.0),
122			270 => (0.0, -1.0),
123			angle => {
124				let radians = f32::from(angle).to_radians();
125				(radians.cos(), radians.sin())
126			},
127		};
128		let width = f32::from(bounds.width.saturating_sub(1));
129		let height = f32::from(bounds.height.saturating_sub(1));
130		let horizontal_end = horizontal * width;
131		let vertical_end = vertical * height;
132		let min = 0.0_f32
133			.min(horizontal_end)
134			.min(vertical_end)
135			.min(horizontal_end + vertical_end);
136		let max = 0.0_f32
137			.max(horizontal_end)
138			.max(vertical_end)
139			.max(horizontal_end + vertical_end);
140		GradientProjection {
141			start: self.start,
142			end: self.end,
143			horizontal,
144			vertical,
145			origin_x: bounds.x,
146			origin_y: bounds.y,
147			min,
148			span: max - min,
149		}
150	}
151}
152
153#[derive(Clone, Copy)]
154struct GradientProjection {
155	start:      Color,
156	end:        Color,
157	horizontal: f32,
158	vertical:   f32,
159	origin_x:   u16,
160	origin_y:   u16,
161	min:        f32,
162	span:       f32,
163}
164
165impl GradientProjection {
166	fn color_at(self, x: u16, y: u16) -> Color {
167		let (Color::Rgb(red, green, blue), Color::Rgb(end_red, end_green, end_blue)) =
168			(self.start, self.end)
169		else {
170			return self.start;
171		};
172		if self.span <= f32::EPSILON {
173			return self.start;
174		}
175		let position = self.vertical.mul_add(
176			f32::from(y) - f32::from(self.origin_y),
177			self.horizontal * (f32::from(x) - f32::from(self.origin_x)),
178		);
179		let amount = ((position - self.min) / self.span).clamp(0.0, 1.0);
180		let channel = |start: u8, end: u8| {
181			f32::mul_add(f32::from(end) - f32::from(start), amount, f32::from(start))
182				.round()
183				.clamp(0.0, 255.0) as u8
184		};
185		Color::Rgb(channel(red, end_red), channel(green, end_green), channel(blue, end_blue))
186	}
187}
188/// Stable process-local identity for one terminal hyperlink target.
189///
190/// IDs are interned from URLs so copied rich-text styles and frame cells carry
191/// only a compact typed handle. The renderer resolves the handle immediately
192/// before materializing OSC 8.
193#[derive(Clone, Copy, Debug, Eq, PartialEq)]
194pub struct LinkId(u32);
195impl LinkId {
196	pub(crate) const fn get(self) -> u32 {
197		self.0
198	}
199}
200
201#[derive(Default)]
202struct LinkRegistry {
203	urls: Vec<Str>,
204}
205
206impl LinkRegistry {
207	fn intern(&mut self, url: &str) -> LinkId {
208		if let Some(index) = self.urls.iter().position(|known| known == url) {
209			return LinkId(u32::try_from(index + 1).expect("hyperlink registry exceeds u32"));
210		}
211		self.urls.push(Str::new(url));
212		LinkId(u32::try_from(self.urls.len()).expect("hyperlink registry exceeds u32"))
213	}
214
215	fn get(&self, id: LinkId) -> Option<&str> {
216		let index = usize::try_from(id.0).ok()?.checked_sub(1)?;
217		self.urls.get(index).map(Str::as_str)
218	}
219}
220
221static LINKS: LazyLock<Mutex<LinkRegistry>> = LazyLock::new(|| Mutex::new(LinkRegistry::default()));
222
223pub fn with_link_url<T>(id: LinkId, use_url: impl FnOnce(&str) -> T) -> Option<T> {
224	let links = LINKS.lock();
225	links.get(id).map(use_url)
226}
227
228fn intern_link(url: &str) -> Option<LinkId> {
229	if url.is_empty() || !url.bytes().any(|byte| !matches!(byte, b'\x1b' | b'\x07')) {
230		return None;
231	}
232	let mut links = LINKS.lock();
233	Some(links.intern(url))
234}
235
236/// Canonical visual attributes for one or more cells.
237#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
238pub struct Style {
239	pub(super) foreground:      Color,
240	pub(super) background:      Color,
241	pub(super) bold:            bool,
242	pub(super) dim:             bool,
243	pub(super) italic:          bool,
244	pub(super) underline:       bool,
245	/// Underline color (SGR 58); also carries the Kitty placeholder
246	/// placement-ID reference on typed image cells.
247	pub(super) underline_color: Color,
248	pub(super) reverse:         bool,
249	pub(super) strikethrough:   bool,
250	pub(super) link:            Option<LinkId>,
251}
252
253impl Style {
254	/// Creates an unstyled terminal style.
255	pub const fn new() -> Self {
256		Self {
257			foreground:      Color::Default,
258			background:      Color::Default,
259			bold:            false,
260			dim:             false,
261			italic:          false,
262			underline:       false,
263			underline_color: Color::Default,
264			reverse:         false,
265			strikethrough:   false,
266			link:            None,
267		}
268	}
269
270	/// Sets the foreground color.
271	pub const fn fg(mut self, color: Color) -> Self {
272		self.foreground = color;
273		self
274	}
275
276	/// Sets the background color.
277	pub const fn bg(mut self, color: Color) -> Self {
278		self.background = color;
279		self
280	}
281
282	/// Enables bold intensity.
283	pub const fn bold(mut self) -> Self {
284		self.bold = true;
285		self
286	}
287
288	/// Enables faint intensity.
289	pub const fn dim(mut self) -> Self {
290		self.dim = true;
291		self
292	}
293
294	/// Enables italics.
295	pub const fn italic(mut self) -> Self {
296		self.italic = true;
297		self
298	}
299
300	/// Enables underlining.
301	pub const fn underline(mut self) -> Self {
302		self.underline = true;
303		self
304	}
305
306	/// Sets the underline color (SGR 58); [`Color::Default`] leaves the
307	/// terminal's underline color untouched.
308	pub const fn underline_color(mut self, color: Color) -> Self {
309		self.underline_color = color;
310		self
311	}
312
313	/// Enables reverse video.
314	pub const fn reverse(mut self) -> Self {
315		self.reverse = true;
316		self
317	}
318
319	/// Enables strikethrough.
320	pub const fn strikethrough(mut self) -> Self {
321		self.strikethrough = true;
322		self
323	}
324
325	/// Attaches a terminal hyperlink target to this style.
326	///
327	/// The URL is interned once and only its typed identity rides on rich-text
328	/// runs and frame cells. Empty targets are ignored.
329	pub fn link(mut self, url: &str) -> Self {
330		self.link = intern_link(url);
331		self
332	}
333
334	pub(crate) const fn without_link(mut self) -> Self {
335		self.link = None;
336		self
337	}
338
339	/// CSS-like cascade: unset properties adopt the parent's. A
340	/// `Color::Default` foreground counts as unset and attribute flags OR
341	/// together. The background never inherits — ancestor fills reach
342	/// descendants through the paint underlay instead.
343	pub const fn inherit(mut self, parent: Self) -> Self {
344		if matches!(self.foreground, Color::Default) {
345			self.foreground = parent.foreground;
346		}
347		self.bold |= parent.bold;
348		self.dim |= parent.dim;
349		self.italic |= parent.italic;
350		self.underline |= parent.underline;
351		if matches!(self.underline_color, Color::Default) {
352			self.underline_color = parent.underline_color;
353		}
354		self.reverse |= parent.reverse;
355		self.strikethrough |= parent.strikethrough;
356		if self.link.is_none() {
357			self.link = parent.link;
358		}
359		self
360	}
361
362	/// The foreground color, for callers deriving accents from a style.
363	pub const fn foreground_color(&self) -> Color {
364		self.foreground
365	}
366
367	/// The background color, for callers deciding whether to fill a region.
368	pub const fn background_color(&self) -> Color {
369		self.background
370	}
371}
372
373/// Stored glyph data for a declarative cell.
374#[derive(Clone, Debug, Eq, PartialEq)]
375pub enum CellContent {
376	/// A one-cell space without owned text.
377	Blank,
378	Grapheme {
379		text:  Str,
380		width: u16,
381	},
382	/// A Kitty Unicode-placeholder cell, materialized only by the renderer.
383	Image {
384		id:   u32,
385		row:  u16,
386		col:  u16,
387		rows: u16,
388		cols: u16,
389	},
390	Continuation,
391}
392
393/// One styled cell in a frame's internal grid.
394#[derive(Clone, Debug, Eq, PartialEq)]
395pub struct Cell {
396	pub(super) content: CellContent,
397	pub(super) style:   Style,
398}
399
400impl Cell {
401	pub(super) const fn blank(style: Style) -> Self {
402		Self { content: CellContent::Blank, style }
403	}
404
405	#[cfg(test)]
406	fn is_default_blank(&self) -> bool {
407		matches!(&self.content, CellContent::Blank) && self.style == Style::default()
408	}
409}
410
411/// A complete declarative terminal viewport.
412///
413/// Each frame owns a fixed cell grid. Wide graphemes reserve continuation
414/// cells, so overwriting either half cannot leave a stale terminal cell behind.
415#[derive(Clone, Debug)]
416pub struct Frame {
417	size:            Size,
418	cells:           Vec<Cell>,
419	cursor:          Option<(u16, u16)>,
420	may_have_images: bool,
421	source_id:       u64,
422	revision:        u64,
423	/// Soft row boundaries: bit `y` set means row `y` wraps onto row
424	/// `y + 1` mid-word, forming one logical line broken only by width.
425	soft_wraps:      SmolBitmap,
426}
427
428impl Frame {
429	/// Creates a blank frame using the terminal's default colors.
430	pub fn new(size: Size) -> Self {
431		Self {
432			size,
433			cells: vec![Cell::blank(Style::default()); size.area()],
434			cursor: None,
435			may_have_images: false,
436			source_id: NEXT_FRAME_ID.fetch_add(1, Ordering::Relaxed),
437			revision: 0,
438			soft_wraps: SmolBitmap::new(),
439		}
440	}
441
442	/// Changes the document height, preserving retained rows and filling growth
443	/// with styled blanks.
444	pub fn resize_height(&mut self, height: u16, style: Style) {
445		if height == self.size.height {
446			return;
447		}
448		self.touch();
449		let area = usize::from(self.size.width).saturating_mul(usize::from(height));
450		self.cells.resize(area, Cell::blank(style));
451		// Boundary flags at and beyond the new final row are meaningless;
452		// drop them so a later regrowth cannot resurrect stale joins.
453		let first_invalid = usize::from(height.saturating_sub(1));
454		self.soft_wraps.retain(|index| index < first_invalid);
455		self.size.height = height;
456		if self.cursor.is_some_and(|(_, y)| y >= height) {
457			self.cursor = None;
458		}
459	}
460
461	/// Flags row `y` as soft-wrapping onto row `y + 1`: the pair renders as
462	/// one logical line broken mid-word only by the frame width. The
463	/// renderer may join the boundary with terminal autowrap so native
464	/// selection copies it unbroken, provided the row's content truly
465	/// reaches the final column.
466	///
467	/// Ignored unless both rows exist. Cleared by [`Frame::clear`],
468	/// [`Frame::resize_height`] shrinkage, and every rebuild — the flag is
469	/// layout metadata, not cell content.
470	pub fn set_soft_wrap(&mut self, y: u16) {
471		if y.saturating_add(1) >= self.size.height {
472			return;
473		}
474		self.touch();
475		self.soft_wraps.insert(usize::from(y));
476	}
477
478	/// Whether row `y` was flagged as soft-wrapping onto row `y + 1`.
479	#[inline]
480	pub fn soft_wrap(&self, y: u16) -> bool {
481		y.saturating_add(1) < self.size.height && self.soft_wraps.get(usize::from(y))
482	}
483
484	#[inline]
485	const fn touch(&mut self) {
486		self.revision = self.revision.wrapping_add(1);
487	}
488
489	#[inline]
490	pub(crate) const fn source_stamp(&self) -> (u64, u64) {
491		(self.source_id, self.revision)
492	}
493
494	#[inline]
495	pub(crate) const fn may_have_images(&self) -> bool {
496		self.may_have_images
497	}
498
499	/// Returns the frame dimensions.
500	pub const fn size(&self) -> Size {
501		self.size
502	}
503
504	/// Places the terminal's hardware cursor at a document cell.
505	///
506	/// The renderer hides the cursor when this cell falls outside the live
507	/// viewport.
508	pub const fn set_cursor(&mut self, x: u16, y: u16) {
509		self.touch();
510		self.cursor = Some((x, y));
511	}
512
513	/// Replaces every cell with a styled blank.
514	pub fn clear(&mut self, style: Style) {
515		self.touch();
516		self.cells.fill(Cell::blank(style));
517		self.soft_wraps.clear();
518	}
519
520	/// Fills a clipped rectangle with styled blanks.
521	pub fn fill(&mut self, rect: Rect, style: Style) {
522		let left = rect.x.min(self.size.width);
523		let right = rect.right().min(self.size.width);
524		let top = rect.y.min(self.size.height);
525		let bottom = rect.bottom().min(self.size.height);
526		if left >= right || top >= bottom {
527			return;
528		}
529		self.touch();
530		// Blanking any part of a row invalidates its exact joinability;
531		// painters re-flag when they redraw.
532		self.clear_soft_wraps_touching(top, bottom);
533
534		let blank = Cell::blank(style);
535		for y in top..bottom {
536			self.clear_glyph_at(left, y);
537			if right - left > 1 {
538				self.clear_glyph_at(right - 1, y);
539			}
540			let start = self.index(left, y);
541			let end = self.index(right - 1, y) + 1;
542			self.cells[start..end].fill(blank.clone());
543		}
544	}
545
546	/// Paints `color` behind a clipped rectangle: cells still on the
547	/// terminal's default background adopt it, cells that named their own
548	/// keep it. Runs after a subtree paints, so glyph styles — which
549	/// replace the whole cell — never punch holes in a container's fill.
550	pub fn underlay(&mut self, rect: Rect, color: Color) {
551		self.touch();
552		let right = rect.right().min(self.size.width);
553		let bottom = rect.bottom().min(self.size.height);
554		for y in rect.y.min(self.size.height)..bottom {
555			for x in rect.x.min(self.size.width)..right {
556				let index = self.index(x, y);
557				if self.cells[index].style.background == Color::Default {
558					self.cells[index].style.background = color;
559				}
560			}
561		}
562	}
563
564	/// Recolors one cell's foreground in place — the chrome-glow primitive:
565	/// composite passes shift color without re-shaping glyphs.
566	pub(crate) fn recolor_fg(&mut self, x: u16, y: u16, recolor: impl FnOnce(Color) -> Color) {
567		if x >= self.size.width || y >= self.size.height {
568			return;
569		}
570		self.touch();
571		let index = self.index(x, y);
572		let style = &mut self.cells[index].style;
573		style.foreground = recolor(style.foreground);
574	}
575
576	/// Paints a gradient behind cells that did not name their own background.
577	pub(crate) fn underlay_gradient(&mut self, rect: Rect, gradient: Gradient, bounds: Rect) {
578		self.touch();
579		let projection = gradient.projection(bounds);
580		let right = rect.right().min(self.size.width);
581		let bottom = rect.bottom().min(self.size.height);
582		for y in rect.y.min(self.size.height)..bottom {
583			let mut x = rect.x.min(self.size.width);
584			while x < right {
585				let index = self.index(x, y);
586				let width = match &self.cells[index].content {
587					CellContent::Blank => 1,
588					CellContent::Grapheme { width, .. } => *width,
589					CellContent::Image { .. } => 1,
590					CellContent::Continuation => {
591						x += 1;
592						continue;
593					},
594				};
595				if self.cells[index].style.background == Color::Default {
596					let color = projection.color_at(x, y);
597					let end = x.saturating_add(width).min(right);
598					for column in x..end {
599						let index = self.index(column, y);
600						if self.cells[index].style.background == Color::Default {
601							self.cells[index].style.background = color;
602						}
603					}
604				}
605				x = x.saturating_add(width.max(1));
606			}
607		}
608	}
609
610	/// Tints visible glyphs that inherit their foreground from this node.
611	pub(crate) fn gradient_foreground(&mut self, rect: Rect, gradient: Gradient, bounds: Rect) {
612		self.touch();
613		let projection = gradient.projection(bounds);
614		let right = rect.right().min(self.size.width);
615		let bottom = rect.bottom().min(self.size.height);
616		for y in rect.y.min(self.size.height)..bottom {
617			let mut x = rect.x.min(self.size.width);
618			while x < right {
619				let index = self.index(x, y);
620				let (width, visible) = match &self.cells[index].content {
621					CellContent::Blank => (1, false),
622					CellContent::Grapheme { text, width } => (*width, text != " "),
623					CellContent::Image { .. } => (1, true),
624					CellContent::Continuation => {
625						x += 1;
626						continue;
627					},
628				};
629				if visible && self.cells[index].style.foreground == Color::Default {
630					let color = projection.color_at(x, y);
631					let end = x.saturating_add(width).min(right);
632					for column in x..end {
633						let index = self.index(column, y);
634						if self.cells[index].style.foreground == Color::Default {
635							self.cells[index].style.foreground = color;
636						}
637					}
638				}
639				x = x.saturating_add(width.max(1));
640			}
641		}
642	}
643
644	/// Places one typed Kitty image cell.
645	pub fn put_image_cell(
646		&mut self,
647		x: u16,
648		y: u16,
649		id: u32,
650		row: u16,
651		col: u16,
652		rows: u16,
653		cols: u16,
654	) {
655		if x >= self.size.width || y >= self.size.height {
656			return;
657		}
658		self.touch();
659		self.may_have_images = true;
660		self.clear_glyph_at(x, y);
661		let index = self.index(x, y);
662		self.cells[index] = Cell {
663			content: CellContent::Image { id, row, col, rows, cols },
664			style:   Style::default(),
665		};
666	}
667
668	/// Draws printable graphemes until a newline or the right frame edge.
669	///
670	/// Control characters are ignored. A wide grapheme that would be clipped is
671	/// omitted rather than leaving a half-cell artifact.
672	pub fn put(&mut self, x: u16, y: u16, text: &str, style: Style) -> u16 {
673		let width = self.size.width.saturating_sub(x);
674		self.put_clipped(x, y, width, text, style)
675	}
676
677	/// Draws printable graphemes within `width` cells.
678	///
679	/// The cell bound is also clipped to the frame edge. A wide grapheme that
680	/// crosses either bound is omitted rather than leaving a half-cell artifact.
681	pub fn put_clipped(&mut self, x: u16, y: u16, width: u16, text: &str, style: Style) -> u16 {
682		if y >= self.size.height {
683			return x;
684		}
685		self.touch();
686
687		let right = x.saturating_add(width).min(self.size.width);
688		let mut column = x;
689		if text.is_ascii() {
690			for &byte in text.as_bytes() {
691				if matches!(byte, b'\n' | b'\r') {
692					break;
693				}
694				if byte.is_ascii_control() {
695					continue;
696				}
697				if column >= right {
698					break;
699				}
700				self.set_ascii(column, y, byte, style);
701				column += 1;
702			}
703			return column;
704		}
705		if let Some(character) = text
706			.chars()
707			.next()
708			.filter(|character| character.len_utf8() == text.len())
709		{
710			if character.is_control() {
711				return column;
712			}
713			let glyph_width = u16::try_from(width_char(character)).unwrap_or(u16::MAX);
714			if glyph_width == 0 || column >= right || glyph_width > right - column {
715				return column;
716			}
717			self.set_grapheme(column, y, text, glyph_width, style);
718			return column + glyph_width;
719		}
720
721		for grapheme in text.graphemes() {
722			if grapheme == "\n" || grapheme == "\r" {
723				break;
724			}
725			if grapheme.chars().any(char::is_control) {
726				continue;
727			}
728
729			let width = u16::try_from(grapheme.visible_width()).unwrap_or(u16::MAX);
730			if width == 0 {
731				continue;
732			}
733			if column >= right || width > right - column {
734				break;
735			}
736
737			self.set_grapheme(column, y, grapheme, width, style);
738			column += width;
739		}
740		column
741	}
742
743	pub(super) const fn cursor(&self) -> Option<(u16, u16)> {
744		self.cursor
745	}
746
747	#[inline(always)]
748	pub(super) fn cell(&self, x: u16, y: u16) -> &Cell {
749		&self.cells[self.index(x, y)]
750	}
751
752	#[inline(always)]
753	pub(super) fn cell_or<'a>(&'a self, row: u16, column: u16, blank: &'a Cell) -> &'a Cell {
754		if row >= self.size.height || column >= self.size.width {
755			blank
756		} else {
757			self.cell(column, row)
758		}
759	}
760
761	pub(crate) fn same_grid(&self, other: &Self) -> bool {
762		self.size == other.size
763			&& self.cursor == other.cursor
764			&& self.soft_wraps == other.soft_wraps
765			&& self.cells == other.cells
766	}
767
768	pub(super) fn row_equals(&self, row: u16, other: &Self, other_row: u16) -> bool {
769		if self.size.width != other.size.width
770			|| row >= self.size.height
771			|| other_row >= other.size.height
772		{
773			return false;
774		}
775		let width = usize::from(self.size.width);
776		let start = usize::from(row) * width;
777		let other_start = usize::from(other_row) * width;
778		self.cells[start..start + width] == other.cells[other_start..other_start + width]
779			&& self.soft_wrap(row) == other.soft_wrap(other_row)
780	}
781
782	/// Copies one row's cells from `src` (same width required). The
783	/// damage-snapshot primitive: presenters copy only rows a caller
784	/// reported dirty instead of cloning the whole grid.
785	pub(crate) fn copy_row_from(&mut self, src: &Self, row: u16) {
786		if row >= self.size.height || row >= src.size.height || self.size.width != src.size.width {
787			return;
788		}
789		self.touch();
790		let width = usize::from(self.size.width);
791		let start = usize::from(row) * width;
792		self.cells[start..start + width].clone_from_slice(&src.cells[start..start + width]);
793		self.may_have_images |= src.may_have_images;
794	}
795
796	/// Mirrors `src`'s soft-wrap boundary flags wholesale — the snapshot
797	/// primitive for damage-based presenters: flags are layout metadata
798	/// that can change on rows no cell damage covers.
799	pub(crate) fn sync_soft_wraps(&mut self, src: &Self) {
800		if self.soft_wraps != src.soft_wraps {
801			self.touch();
802			self.soft_wraps.clone_from(&src.soft_wraps);
803		}
804	}
805
806	/// Copies a cell region from `src` into this frame — the scroll
807	/// viewport blit, and the way an embedder composites a sub-document
808	/// (e.g. a [`crate::Ui`]-rendered message) into a hand-painted frame.
809	/// `src` rows `[src_top, src_top + rows)` land at `(dst_x, dst_y)`,
810	/// clipped to both frames. Wide glyphs whose lead cell falls outside
811	/// the copied span degrade to blanks rather than leaving orphan
812	/// continuations.
813	/// A cursor set on `src` inside the copied region is translated into
814	/// this frame's coordinates; outside it, this frame's cursor is kept.
815	pub fn blit(&mut self, src: &Self, src_top: u16, rows: u16, dst_x: u16, dst_y: u16) {
816		self.touch();
817		let width = src.size.width.min(self.size.width.saturating_sub(dst_x));
818		if let Some((cx, cy)) = src.cursor
819			&& cx < width
820			&& cy >= src_top
821			&& cy < src_top.saturating_add(rows)
822		{
823			let to_y = dst_y.saturating_add(cy - src_top);
824			if to_y < self.size.height {
825				self.cursor = Some((dst_x.saturating_add(cx), to_y));
826			}
827		}
828		let mut copied = 0u16;
829		for row in 0..rows {
830			let from_y = src_top.saturating_add(row);
831			let to_y = dst_y.saturating_add(row);
832			if from_y >= src.size.height || to_y >= self.size.height {
833				break;
834			}
835			copied = row + 1;
836			let mut x = 0u16;
837			while x < width {
838				let cell = src.cell(x, from_y);
839				match &cell.content {
840					CellContent::Blank => {
841						let style = cell.style;
842						self.clear_glyph_at(dst_x + x, to_y);
843						let index = self.index(dst_x + x, to_y);
844						self.cells[index] = Cell::blank(style);
845						x += 1;
846					},
847					CellContent::Grapheme { text, width: glyph_w } => {
848						if x + glyph_w <= width {
849							let text = text.clone();
850							let style = cell.style;
851							let w = *glyph_w;
852							self.set_grapheme(dst_x + x, to_y, &text, w, style);
853							x += w;
854						} else {
855							let style = cell.style;
856							let index = self.index(dst_x + x, to_y);
857							self.clear_glyph_at(dst_x + x, to_y);
858							self.cells[index] = Cell::blank(style);
859							x += 1;
860						}
861					},
862					CellContent::Image { id, row, col, rows, cols } => {
863						self.put_image_cell(dst_x + x, to_y, *id, *row, *col, *rows, *cols);
864						x += 1;
865					},
866					CellContent::Continuation => {
867						// lead cell was left of the copy origin: blank
868						let style = cell.style;
869						self.clear_glyph_at(dst_x + x, to_y);
870						let index = self.index(dst_x + x, to_y);
871						self.cells[index] = Cell::blank(style);
872						x += 1;
873					},
874				}
875			}
876		}
877		// Wrap boundaries: a full-width copy carries its interior
878		// boundaries; anything else conservatively hardens the touched
879		// rows, since exact joinability cannot survive a partial rewrite.
880		self.clear_soft_wraps_touching(dst_y, dst_y.saturating_add(copied));
881		if dst_x == 0 && width == self.size.width && width == src.size.width {
882			for offset in 0..copied.saturating_sub(1) {
883				if src.soft_wrap(src_top.saturating_add(offset)) {
884					self.set_soft_wrap(dst_y.saturating_add(offset));
885				}
886			}
887		}
888	}
889
890	/// Drops every wrap boundary touching rows `[top, bottom)`: bit `y`
891	/// spans rows `y` and `y + 1`, so the range widens one row upward.
892	fn clear_soft_wraps_touching(&mut self, top: u16, bottom: u16) {
893		for index in usize::from(top.saturating_sub(1))..usize::from(bottom.min(self.size.height)) {
894			self.soft_wraps.set(index, false);
895		}
896	}
897
898	#[inline(always)]
899	fn index(&self, x: u16, y: u16) -> usize {
900		usize::from(y) * usize::from(self.size.width) + usize::from(x)
901	}
902
903	#[inline(always)]
904	fn set_ascii(&mut self, x: u16, y: u16, byte: u8, style: Style) {
905		let lead = self.index(x, y);
906		let existing = &self.cells[lead];
907		if existing.style == style {
908			match &existing.content {
909				CellContent::Blank if byte == b' ' => return,
910				CellContent::Grapheme { text, width: 1 }
911					if text.len() == 1 && text.as_bytes()[0] == byte =>
912				{
913					return;
914				},
915				_ => {},
916			}
917		}
918		if !matches!(
919			&self.cells[lead].content,
920			CellContent::Blank | CellContent::Grapheme { width: 1, .. } | CellContent::Image { .. }
921		) {
922			self.clear_glyph_at(x, y);
923		}
924		let content = if byte == b' ' {
925			CellContent::Blank
926		} else {
927			let bytes = [byte];
928			// SAFETY: `byte` came from a string already known to be ASCII.
929			let text = unsafe { str::from_utf8_unchecked(&bytes) };
930			CellContent::Grapheme { text: Str::new_inline(text), width: 1 }
931		};
932		self.cells[lead] = Cell { content, style };
933	}
934
935	fn set_grapheme(&mut self, x: u16, y: u16, grapheme: &str, width: u16, style: Style) {
936		if width == 1 {
937			let lead = self.index(x, y);
938			let existing = &self.cells[lead];
939			if existing.style == style {
940				match &existing.content {
941					CellContent::Blank if grapheme == " " => return,
942					CellContent::Grapheme { text, width: 1 } if text == grapheme => return,
943					_ => {},
944				}
945			}
946			if !matches!(
947				&self.cells[lead].content,
948				CellContent::Blank | CellContent::Grapheme { width: 1, .. } | CellContent::Image { .. }
949			) {
950				self.clear_glyph_at(x, y);
951			}
952			let content = if grapheme == " " {
953				CellContent::Blank
954			} else {
955				CellContent::Grapheme { text: Str::new(grapheme), width }
956			};
957			self.cells[lead] = Cell { content, style };
958			return;
959		}
960
961		for column in x..x + width {
962			self.clear_glyph_at(column, y);
963		}
964
965		let lead = self.index(x, y);
966		self.cells[lead] =
967			Cell { content: CellContent::Grapheme { text: Str::new(grapheme), width }, style };
968		for column in x + 1..x + width {
969			let index = self.index(column, y);
970			self.cells[index] = Cell { content: CellContent::Continuation, style };
971		}
972	}
973
974	fn clear_glyph_at(&mut self, x: u16, y: u16) {
975		let mut start = x;
976		while start > 0 && matches!(self.cell(start, y).content, CellContent::Continuation) {
977			start -= 1;
978		}
979
980		let span = match self.cell(start, y).content {
981			CellContent::Blank => 1,
982			CellContent::Grapheme { width, .. } => width,
983			CellContent::Image { .. } => 1,
984			CellContent::Continuation => 1,
985		};
986		let end = start.saturating_add(span).min(self.size.width);
987		for column in start..end {
988			let index = self.index(column, y);
989			let style = self.cells[index].style;
990			self.cells[index] = Cell::blank(style);
991		}
992	}
993}
994
995#[cfg(test)]
996mod tests {
997	use super::{CellContent, Frame, Rect, Size, Style};
998
999	#[test]
1000	fn overwriting_wide_grapheme_clears_its_continuation() {
1001		let mut frame = Frame::new(Size::new(4, 1));
1002		frame.put(0, 0, "界", Style::default());
1003		frame.put(0, 0, "a", Style::default());
1004
1005		assert!(matches!(frame.cell(1, 0).content, CellContent::Blank));
1006	}
1007
1008	#[test]
1009	fn clipped_wide_grapheme_is_not_drawn() {
1010		let mut frame = Frame::new(Size::new(2, 1));
1011		let end = frame.put(1, 0, "界", Style::default());
1012
1013		assert_eq!(end, 1);
1014		assert!(frame.cell(1, 0).is_default_blank());
1015	}
1016
1017	#[test]
1018	fn clipped_text_preserves_cells_beyond_its_bound() {
1019		let mut frame = Frame::new(Size::new(4, 1));
1020		frame.put(0, 0, "xxxx", Style::default());
1021		let end = frame.put_clipped(1, 0, 2, "ab界", Style::default());
1022
1023		assert_eq!(end, 3);
1024		assert!(matches!(
1025			frame.cell(3, 0).content,
1026			CellContent::Grapheme { ref text, width: 1 } if text == "x"
1027		));
1028	}
1029
1030	#[test]
1031	fn ascii_text_skips_controls_and_clips_without_unicode_segmentation() {
1032		let mut frame = Frame::new(Size::new(4, 1));
1033		let end = frame.put_clipped(0, 0, 3, "a\tbcd", Style::default());
1034
1035		assert_eq!(end, 3);
1036		assert!(matches!(
1037			frame.cell(2, 0).content,
1038			CellContent::Grapheme { ref text, width: 1 } if text == "c"
1039		));
1040		assert!(frame.cell(3, 0).is_default_blank());
1041	}
1042	#[test]
1043	fn resizing_height_preserves_rows_and_initializes_growth() {
1044		let mut frame = Frame::new(Size::new(3, 1));
1045		frame.put(0, 0, "a", Style::default());
1046		let fill = Style::new().bold();
1047
1048		frame.resize_height(3, fill);
1049
1050		assert!(matches!(
1051			frame.cell(0, 0).content,
1052			CellContent::Grapheme { ref text, width: 1 } if text == "a"
1053		));
1054		assert_eq!(frame.cell(0, 2).style, fill);
1055		frame.set_cursor(0, 2);
1056		frame.resize_height(1, Style::default());
1057		assert_eq!(frame.cursor(), None);
1058	}
1059
1060	#[test]
1061	fn soft_wrap_flags_are_layout_metadata() {
1062		let mut frame = Frame::new(Size::new(4, 3));
1063		frame.set_soft_wrap(0);
1064		frame.set_soft_wrap(2);
1065		assert!(frame.soft_wrap(0));
1066		assert!(!frame.soft_wrap(2), "a flag without a following row is ignored");
1067
1068		let mut other = frame.clone();
1069		assert!(frame.row_equals(0, &other, 0));
1070		other.clear(Style::default());
1071		assert!(!other.soft_wrap(0), "clear drops boundary flags");
1072		assert!(!frame.row_equals(0, &other, 0), "row equality includes the boundary bit");
1073
1074		frame.resize_height(1, Style::default());
1075		assert!(!frame.soft_wrap(0), "shrinking drops stale flags");
1076		frame.resize_height(3, Style::default());
1077		assert!(!frame.soft_wrap(0), "regrowth does not resurrect them");
1078	}
1079
1080	#[test]
1081	fn fill_clears_wrap_boundaries_it_touches() {
1082		let mut frame = Frame::new(Size::new(4, 4));
1083		frame.set_soft_wrap(0);
1084		frame.set_soft_wrap(2);
1085		// Filling row 3 also drops the boundary reaching it from row 2,
1086		// while the untouched pair above keeps its flag.
1087		frame.fill(Rect::new(0, 3, 4, 1), Style::default());
1088		assert!(frame.soft_wrap(0));
1089		assert!(!frame.soft_wrap(2));
1090	}
1091
1092	#[test]
1093	fn blit_carries_full_width_wrap_boundaries_and_hardens_partial_copies() {
1094		let mut source = Frame::new(Size::new(4, 3));
1095		source.put(0, 0, "abcd", Style::default());
1096		source.put(0, 1, "ef", Style::default());
1097		source.set_soft_wrap(0);
1098
1099		let mut full = Frame::new(Size::new(4, 4));
1100		full.set_soft_wrap(2);
1101		full.blit(&source, 0, 3, 0, 1);
1102		assert!(full.soft_wrap(1), "a full-width blit carries interior boundaries");
1103		assert!(!full.soft_wrap(2), "boundaries under the copy are replaced, not kept");
1104
1105		let mut partial = Frame::new(Size::new(6, 4));
1106		partial.set_soft_wrap(1);
1107		partial.blit(&source, 0, 3, 1, 1);
1108		assert!(!partial.soft_wrap(1), "an offset copy hardens the rows it rewrites");
1109	}
1110	#[test]
1111	fn blit_translates_cursor_from_copied_region() {
1112		let mut source = Frame::new(Size::new(8, 6));
1113		source.set_cursor(3, 4);
1114		let mut destination = Frame::new(Size::new(12, 8));
1115
1116		destination.blit(&source, 2, 3, 5, 1);
1117
1118		assert_eq!(destination.cursor(), Some((8, 3)));
1119	}
1120
1121	#[test]
1122	fn blit_keeps_cursor_when_source_cursor_cannot_be_copied() {
1123		let mut source = Frame::new(Size::new(8, 6));
1124		let mut destination = Frame::new(Size::new(6, 4));
1125		destination.set_cursor(1, 1);
1126
1127		source.set_cursor(6, 3);
1128		destination.blit(&source, 2, 3, 2, 0);
1129		assert_eq!(destination.cursor(), Some((1, 1)), "cursor past copied width");
1130
1131		source.set_cursor(2, 5);
1132		destination.blit(&source, 2, 3, 0, 0);
1133		assert_eq!(destination.cursor(), Some((1, 1)), "cursor past copied rows");
1134
1135		source.set_cursor(2, 3);
1136		destination.blit(&source, 2, 2, 0, 3);
1137		assert_eq!(destination.cursor(), Some((1, 1)), "translated cursor past destination height");
1138	}
1139}