Skip to main content

omp_tui/
renderer.rs

1use std::{
2	collections::BTreeMap,
3	fmt::Write as _,
4	io::{self, Write},
5	ops::Range,
6};
7
8use omp_core::CowBytes;
9use smallvec::SmallVec;
10
11use crate::{
12	Graphics, TerminalCaps,
13	escape::esc,
14	frame::{Cell, CellContent, Color, Frame, LinkId, Size, Style, with_link_url},
15	iterm2::{Iterm2Image, Iterm2Viewport, iterm2_output},
16	kitty::{
17		DirectPlacement, append_delete_image, append_direct_placement, append_placement,
18		append_tmux_passthrough, append_transmission, placeholder_cell,
19	},
20	overlay::Layer,
21	sixel::SixelImage,
22	terminal::terminal_write_all,
23};
24
25const RESET_STYLE: &str = esc!(style_reset);
26const CLEAR_VIEWPORT: &str = esc!(erase_display, cursor_home);
27const SCREEN_TO_SCROLLBACK: &str = esc!(screen_to_scrollback);
28const REBUILD_HISTORY: &str = esc!(cursor_home, erase_scrollback);
29const SYNC_OUTPUT_BEGIN: &str = esc!(sync_output);
30const SYNC_OUTPUT_END: &str = esc!(!sync_output);
31const HIDE_CURSOR: &str = esc!(!cursor_visible);
32const SHOW_CURSOR: &str = esc!(cursor_visible);
33// CUD clamps at the bottom without changing the user's scrollback viewport,
34// unlike an absolute CUP address.
35const VIEWPORT_BOTTOM: &str = esc!(viewport_bottom);
36const DEFAULT_CELL_PIXEL_WIDTH: u16 = 9;
37const DEFAULT_CELL_PIXEL_HEIGHT: u16 = 18;
38#[cfg(any(windows, target_os = "linux", test))]
39const MAX_CONPTY_WRITE_CHUNK_BYTES: usize = 16 * 1024;
40const MAX_OUTPUT_BACKLOG_BYTES: usize = 64 * 1024 * 1024;
41
42/// Health of the renderer's bounded terminal output queue.
43#[derive(Clone, Copy, Debug, Eq, PartialEq)]
44pub enum OutputState {
45	/// Output is still being accepted.
46	Connected,
47	/// Pending terminal output exceeded the safety limit.
48	Disconnected,
49}
50
51#[derive(Default)]
52struct OutputBacklogGuard {
53	bytes: usize,
54}
55
56impl OutputBacklogGuard {
57	const fn queue(&mut self, bytes: usize) -> bool {
58		self.bytes = self.bytes.saturating_add(bytes);
59		self.bytes > MAX_OUTPUT_BACKLOG_BYTES
60	}
61
62	const fn flushed(&mut self) {
63		self.bytes = 0;
64	}
65}
66
67/// Measurements from one native-scrollback paint.
68#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
69pub struct PaintStats {
70	/// Whether this replaced the complete viewport.
71	pub full_repaint:   bool,
72	/// Number of changed cells emitted.
73	pub changed_cells:  usize,
74	/// Number of changed runs or complete rows emitted.
75	pub runs:           usize,
76	/// Number of newly finalized rows committed to native scrollback.
77	pub committed_rows: u16,
78	/// Number of uncommitted logical rows clipped above the live viewport.
79	pub clipped_rows:   u16,
80	/// Number of bytes written to the terminal.
81	pub bytes:          usize,
82}
83
84/// A layer with its band already resolved, ready to composite.
85#[derive(Clone, Copy)]
86pub struct ResolvedLayer<'a> {
87	/// Source frame containing the layer cells.
88	pub(crate) frame:   &'a Frame,
89	/// Viewport column of the band's left edge.
90	pub(crate) x:       u16,
91	/// Viewport row of the band's top edge.
92	pub(crate) y:       u16,
93	/// First source-frame row in the band.
94	pub(crate) src_top: u16,
95	/// Number of source rows in the band.
96	pub(crate) rows:    u16,
97	/// Whether this layer owns the keyboard and hardware cursor.
98	pub(crate) active:  bool,
99}
100
101struct StoredLayer {
102	frame:           Frame,
103	x:               u16,
104	document_y:      u16,
105	src_top:         u16,
106	rows:            u16,
107	active:          bool,
108	source_address:  usize,
109	source_id:       u64,
110	source_revision: u64,
111}
112
113impl StoredLayer {
114	#[inline(always)]
115	const fn contains(&self, y: u16, x: u16) -> bool {
116		y >= self.document_y
117			&& y < self.document_y.saturating_add(self.rows)
118			&& x >= self.x
119			&& x < self.x.saturating_add(self.frame.size().width)
120			&& y - self.document_y + self.src_top < self.frame.size().height
121	}
122
123	#[inline(always)]
124	const fn same_cells_and_placement(&self, other: &Self) -> bool {
125		self.x == other.x
126			&& self.document_y == other.document_y
127			&& self.src_top == other.src_top
128			&& self.rows == other.rows
129			&& self.source_address == other.source_address
130			&& self.source_id == other.source_id
131			&& self.source_revision == other.source_revision
132	}
133}
134
135struct ComposedFrame<'a> {
136	base:   &'a Frame,
137	layers: &'a [StoredLayer],
138}
139
140impl ComposedFrame<'_> {
141	#[inline(always)]
142	fn cell_or<'b>(&'b self, y: u16, x: u16, blank: &'b Cell) -> &'b Cell {
143		if self.layers.is_empty() {
144			return self.base.cell_or(y, x, blank);
145		}
146		let layer = self.layer_at(y, x);
147		let cell = match layer {
148			Some(index) => {
149				let layer = &self.layers[index];
150				layer
151					.frame
152					.cell_or(y - layer.document_y + layer.src_top, x - layer.x, blank)
153			},
154			None => self.base.cell_or(y, x, blank),
155		};
156		match &cell.content {
157			CellContent::Grapheme { width, .. } if *width > 1 => {
158				let right = x.saturating_add(*width);
159				if right > self.base.size().width
160					|| (x..right).any(|column| self.layer_at(y, column) != layer)
161				{
162					blank
163				} else {
164					cell
165				}
166			},
167			CellContent::Continuation => {
168				let Some((head_x, width)) = self.grapheme_head(layer, y, x) else {
169					return blank;
170				};
171				let right = head_x.saturating_add(width);
172				if right > self.base.size().width
173					|| (head_x..right).any(|column| self.layer_at(y, column) != layer)
174				{
175					blank
176				} else {
177					cell
178				}
179			},
180			_ => cell,
181		}
182	}
183
184	#[inline(always)]
185	fn layer_at(&self, y: u16, x: u16) -> Option<usize> {
186		match self.layers {
187			[] => None,
188			[layer] => layer.contains(y, x).then_some(0),
189			layers => layers.iter().rposition(|layer| layer.contains(y, x)),
190		}
191	}
192
193	fn grapheme_head(&self, layer: Option<usize>, y: u16, x: u16) -> Option<(u16, u16)> {
194		let (frame, row, left) = match layer {
195			Some(index) => {
196				let layer = &self.layers[index];
197				(&layer.frame, y - layer.document_y + layer.src_top, layer.x)
198			},
199			None => (self.base, y, 0),
200		};
201		let mut column = x;
202		while column > left {
203			column -= 1;
204			let source_x = column - left;
205			match &frame.cell(source_x, row).content {
206				CellContent::Continuation => {},
207				CellContent::Blank => return None,
208				CellContent::Grapheme { width, .. } => return Some((column, *width)),
209				CellContent::Image { .. } => return None,
210			}
211		}
212		None
213	}
214}
215
216#[derive(Clone, Copy)]
217struct Layout {
218	stable_limit: u16,
219	window_top:   u16,
220}
221
222#[derive(Clone, Copy, Eq, PartialEq)]
223struct Window {
224	top:    u16,
225	height: u16,
226}
227
228#[derive(Clone, Copy)]
229struct Run {
230	document_y: u16,
231	screen_y:   u16,
232	start:      u16,
233	end:        u16,
234}
235
236#[derive(Clone, Copy, Debug, Eq, PartialEq)]
237struct ScreenCursor {
238	row: u16,
239	col: u16,
240}
241
242struct RegisteredImage {
243	png:            CowBytes<'static>,
244	uploaded:       bool,
245	/// Cell boxes (`rows`, `cols`) already given a virtual placement.
246	placed:         SmallVec<(u16, u16), 2>,
247	sixel:          Option<SixelImage>,
248	sixel_decoded:  bool,
249	direct_visible: bool,
250}
251
252impl RegisteredImage {
253	const fn new(png: CowBytes<'static>) -> Self {
254		Self {
255			png,
256			uploaded: false,
257			placed: SmallVec::new(),
258			sixel: None,
259			sixel_decoded: false,
260			direct_visible: false,
261		}
262	}
263}
264
265/// Renders an immutable document prefix and a mutable viewport-local suffix.
266///
267/// `stable_rows` declares the leading rows that will never change again. Stable
268/// rows enter native scrollback only when they leave the visible top edge.
269/// Already-clipped stable rows remain protected but deferred until a rebuild,
270/// avoiding a viewport replay that would displace native text selections.
271/// A committed or previously declared-stable mutation is rejected before any
272/// terminal output, because native scrollback has no addressable cells. The
273/// retained physical screen model composes the raw previous frame with its
274/// stored viewport layers.
275pub struct Renderer<W: Write> {
276	writer:               W,
277	previous:             Option<Frame>,
278	layers:               SmallVec<StoredLayer, 4>,
279	layer_scratch:        SmallVec<StoredLayer, 4>,
280	/// Throwaway-screen baseline retained separately from normal-buffer history.
281	preview_previous:     Option<Frame>,
282	preview_layers:       SmallVec<StoredLayer, 4>,
283	preview_window:       Option<Window>,
284	preview_cursor:       Option<ScreenCursor>,
285	/// Reused ANSI cell-diff assembly buffer for steady-state paints.
286	paint_scratch:        String,
287	/// Reused terminal output assembly buffer for steady-state paints.
288	output_scratch:       String,
289	viewport_height:      u16,
290	window_top:           u16,
291	committed_rows:       u16,
292	stable_rows:          u16,
293	cursor:               Option<ScreenCursor>,
294	poisoned:             bool,
295	output_state:         OutputState,
296	backlog:              OutputBacklogGuard,
297	#[cfg(any(windows, target_os = "linux"))]
298	conpty_hosted:        bool,
299	images:               BTreeMap<u32, RegisteredImage>,
300	alt_screen:           bool,
301	graphics:             Graphics,
302	cell_pixel_width:     u16,
303	cell_pixel_height:    u16,
304	tmux_passthrough:     bool,
305	sync_output:          bool,
306	screen_to_scrollback: bool,
307	hyperlinks:           bool,
308	margin_scrollback:    bool,
309}
310
311impl<W: Write> Renderer<W> {
312	/// Creates a renderer whose first document clears only the visible viewport.
313	pub fn new(writer: W) -> Self {
314		Self {
315			writer,
316			previous: None,
317			viewport_height: 0,
318			layers: SmallVec::new(),
319			layer_scratch: SmallVec::new(),
320			preview_previous: None,
321			preview_layers: SmallVec::new(),
322			preview_window: None,
323			preview_cursor: None,
324			paint_scratch: String::new(),
325			output_scratch: String::new(),
326			window_top: 0,
327			committed_rows: 0,
328			stable_rows: 0,
329			cursor: None,
330			poisoned: false,
331			output_state: OutputState::Connected,
332			backlog: OutputBacklogGuard::default(),
333			#[cfg(any(windows, target_os = "linux"))]
334			conpty_hosted: is_conpty_hosted(),
335			images: BTreeMap::new(),
336			alt_screen: crate::terminal::alt_screen_active(),
337			graphics: Graphics::KittyPlaceholders,
338			cell_pixel_width: DEFAULT_CELL_PIXEL_WIDTH,
339			cell_pixel_height: DEFAULT_CELL_PIXEL_HEIGHT,
340			tmux_passthrough: false,
341			sync_output: true,
342			screen_to_scrollback: false,
343			margin_scrollback: false,
344			hyperlinks: false,
345		}
346	}
347
348	/// Configures every capability-driven renderer option from resolved caps.
349	///
350	/// # Errors
351	///
352	/// Rejects zero cell-pixel dimensions.
353	pub fn apply_caps(&mut self, caps: &TerminalCaps) -> io::Result<()> {
354		self.set_graphics(caps.graphics);
355		self.set_sync_output(caps.sync_output);
356		self.set_screen_to_scrollback(caps.screen_to_scrollback);
357		self.set_margin_scrollback(caps.margin_scrollback);
358		self.set_hyperlinks(caps.hyperlinks);
359		self.set_tmux_passthrough(caps.inside_tmux);
360		if let Some((width, height)) = caps.cell_px {
361			self.set_cell_pixel_size(width, height)?;
362		}
363		Ok(())
364	}
365
366	/// Registers PNG bytes for a typed terminal image ID.
367	///
368	/// Protocol encoding is deferred until a presented frame references the
369	/// ID. Re-registering an ID replaces its bytes and protocol cache.
370	///
371	/// # Errors
372	///
373	/// Rejects ID zero and IDs wider than Kitty's 24-bit placeholder encoding.
374	pub fn register_image(
375		&mut self,
376		id: u32,
377		png_bytes: impl Into<CowBytes<'static>>,
378	) -> io::Result<()> {
379		if id == 0 || id > 0x00ff_ffff {
380			return Err(io::Error::new(
381				io::ErrorKind::InvalidInput,
382				"terminal image ID must fit in 24 bits",
383			));
384		}
385		self
386			.images
387			.insert(id, RegisteredImage::new(png_bytes.into()));
388		Ok(())
389	}
390
391	/// Selects how typed image cells are materialized.
392	///
393	/// Set this before the first presentation. [`Graphics::Cells`],
394	/// [`Graphics::Sixel`], and [`Graphics::KittyDirect`] materialize typed
395	/// cells as ordinary blanks; [`Graphics::KittyPlaceholders`] uses Unicode
396	/// placeholders.
397	pub const fn set_graphics(&mut self, graphics: Graphics) {
398		self.graphics = graphics;
399	}
400
401	/// Enables or disables DEC synchronized-output wrapping.
402	///
403	/// Wrapping is enabled by default to preserve the renderer's historical
404	/// behavior. Capability detection should disable it for unsupported
405	/// terminals.
406	pub const fn set_sync_output(&mut self, enabled: bool) {
407		self.sync_output = enabled;
408	}
409
410	/// Enables or disables moving cleared viewport content to native scrollback.
411	///
412	/// When enabled, a full viewport clear first emits Kitty's `CSI 22 J`
413	/// extension. It is disabled by default.
414	pub const fn set_screen_to_scrollback(&mut self, enabled: bool) {
415		self.screen_to_scrollback = enabled;
416	}
417
418	/// Enables committing scrolled-out rows through a top-anchored DECSTBM
419	/// region instead of a whole-screen scroll.
420	///
421	/// Screen rows below the region never move during a commit, and native
422	/// scrollback receives exactly the same history as a whole-screen
423	/// scroll. Whether a terminal-native text selection over the pinned
424	/// rows survives is a separate, terminal-specific property: kitty and
425	/// Alacritty transform selections correctly on region scrolls; ghostty,
426	/// iTerm2, and xterm.js leave them anchored to pre-scroll storage rows,
427	/// so they drift upward — matching what a whole-screen scroll does to
428	/// selections over stationary live content repainted back into place;
429	/// `WezTerm` clears them. Enable this only for terminals that move rows
430	/// scrolled out of a top-anchored region into native scrollback (see
431	/// `TerminalCaps::margin_scrollback`); it is disabled by default.
432	pub const fn set_margin_scrollback(&mut self, enabled: bool) {
433		self.margin_scrollback = enabled;
434	}
435
436	/// Enables or disables OSC 8 hyperlink materialization.
437	///
438	/// Link identities remain attached to frame cells while disabled, but output
439	/// stays byte-for-byte identical to ordinary styled text.
440	pub const fn set_hyperlinks(&mut self, enabled: bool) {
441		self.hyperlinks = enabled;
442	}
443
444	/// Sets the terminal cell size used to scale sixel placements.
445	///
446	/// The default is 9 by 18 pixels per cell, matching pi's nominal terminal
447	/// metrics. Detection code may override it before presentation.
448	///
449	/// # Errors
450	///
451	/// Rejects a zero pixel dimension.
452	pub fn set_cell_pixel_size(&mut self, width: u16, height: u16) -> io::Result<()> {
453		if width == 0 || height == 0 {
454			return Err(io::Error::new(
455				io::ErrorKind::InvalidInput,
456				"cell pixel dimensions must be non-zero",
457			));
458		}
459		self.cell_pixel_width = width;
460		self.cell_pixel_height = height;
461		Ok(())
462	}
463
464	/// Enables tmux DCS passthrough for Kitty and sixel graphics sequences.
465	///
466	/// Cursor movement, synchronized output, and ordinary text styling remain
467	/// direct terminal output.
468	pub const fn set_tmux_passthrough(&mut self, enabled: bool) {
469		self.tmux_passthrough = enabled;
470	}
471
472	/// Paints a logical document with an immutable leading-row boundary.
473	///
474	/// The caller must disable terminal autowrap and keep terminal geometry
475	/// fixed while the renderer is active; the renderer itself re-enables
476	/// DECAWM transiently to join flagged soft-wrap boundaries (see
477	/// [`Frame::set_soft_wrap`]) so native selection and scrollback copy
478	/// them as one unbroken line. Advancing `stable_rows` is permanent,
479	/// and committed history makes the document height a ratchet: between
480	/// rebuilds the document may only grow, so transient rows (pickers, extra
481	/// input lines) must be absorbed by the caller rather than shrinking the
482	/// frame.
483	///
484	/// # Errors
485	///
486	/// Rejects zero or changed geometry, a retreating stable boundary, mutation
487	/// within the prior stable prefix, or a document whose tail shrank below
488	/// committed history. Writer failure poisons the renderer because its
489	/// physical state is unknown.
490	pub fn present(
491		&mut self,
492		next: Frame,
493		viewport_height: u16,
494		stable_rows: u16,
495	) -> io::Result<PaintStats> {
496		self.forget_preview();
497		self.validate_input(&next, viewport_height, stable_rows)?;
498		let stats = if self.previous.is_none() {
499			self.initial_paint(next, viewport_height, stable_rows)?
500		} else {
501			let stats = self.paint_next(&next, viewport_height, stable_rows)?;
502			self.previous = Some(next);
503			stats
504		};
505		self.publish_debug_screen();
506		Ok(stats)
507	}
508
509	/// [`Renderer::present`] without taking the frame: diffs against the
510	/// retained previous frame, then `clone_from`s the borrowed one into
511	/// it — reusing the existing cell allocation instead of copying a
512	/// whole frame per paint. Cost is still O(grid) cell clones per call;
513	/// retained callers that track their own damage should prefer
514	/// [`Renderer::present_damaged`].
515	///
516	/// # Errors
517	/// Same contract as [`Renderer::present`].
518	pub fn present_ref(
519		&mut self,
520		next: &Frame,
521		viewport_height: u16,
522		stable_rows: u16,
523	) -> io::Result<PaintStats> {
524		self.forget_preview();
525		self.validate_input(next, viewport_height, stable_rows)?;
526		let stats = if self.previous.is_none() {
527			self.initial_paint(next.clone(), viewport_height, stable_rows)?
528		} else {
529			let stats = self.paint_next(next, viewport_height, stable_rows)?;
530			self
531				.previous
532				.as_mut()
533				.expect("initial-paint branch checked previous above")
534				.clone_from(next);
535			stats
536		};
537		self.publish_debug_screen();
538		Ok(stats)
539	}
540
541	/// Paints a damaged raw document with declarative viewport-anchored layers.
542	///
543	/// `damaged` follows [`Renderer::present_damaged`]. Layers composite only
544	/// into the live viewport while history commits keep flowing: a row
545	/// leaving the window is repainted from the raw document before it
546	/// scrolls into native scrollback, so layer cells never reach history.
547	/// Direct-drawn sixel, Kitty-direct, and iTerm2 images remain raw and are
548	/// not occluded; Kitty placeholder cells participate in composition.
549	///
550	/// # Errors
551	/// Same contract as [`Renderer::present`].
552	pub fn present_overlaid(
553		&mut self,
554		next: &Frame,
555		damaged: &[(u16, u16)],
556		viewport_height: u16,
557		stable_rows: u16,
558		layers: &[Layer<'_>],
559	) -> io::Result<PaintStats> {
560		let viewport = Size::new(next.size().width, viewport_height);
561		let resolved = resolve_layers(layers, viewport);
562		self.present_resolved(next, damaged, viewport_height, stable_rows, &resolved)
563	}
564
565	/// Paints layers whose viewport bands have already been resolved.
566	pub(crate) fn present_resolved(
567		&mut self,
568		next: &Frame,
569		damaged: &[(u16, u16)],
570		viewport_height: u16,
571		stable_rows: u16,
572		layers: &[ResolvedLayer<'_>],
573	) -> io::Result<PaintStats> {
574		self.forget_preview();
575		self.validate_input(next, viewport_height, stable_rows)?;
576		let stats = if self.previous.is_none() {
577			self.initial_paint_overlaid(next.clone(), viewport_height, stable_rows, layers)?
578		} else {
579			self.validate_damaged_stable_prefix(next, damaged)?;
580			let stats =
581				self.paint_validated_next(next, viewport_height, stable_rows, Some(damaged), layers)?;
582			let previous = self
583				.previous
584				.as_mut()
585				.expect("initial-paint branch checked previous above");
586			previous.resize_height(next.size().height, Style::default());
587			for &(start, end) in damaged {
588				for row in start..end.min(next.size().height) {
589					previous.copy_row_from(next, row);
590				}
591			}
592			previous.sync_soft_wraps(next);
593			stats
594		};
595		self.publish_debug_screen();
596		Ok(stats)
597	}
598
599	/// [`Renderer::present_ref`] with a caller-supplied damage list: only rows
600	/// inside `damaged` `(start, end)` ranges are validated and snapshotted.
601	/// The caller guarantees every changed row is covered; the full grid is
602	/// copied only on the initial paint.
603	///
604	/// # Errors
605	/// Same contract as [`Renderer::present`].
606	pub fn present_damaged(
607		&mut self,
608		next: &Frame,
609		damaged: &[(u16, u16)],
610		viewport_height: u16,
611		stable_rows: u16,
612	) -> io::Result<PaintStats> {
613		self.present_resolved(next, damaged, viewport_height, stable_rows, &[])
614	}
615
616	/// Repaints every composited viewport-layer band from the raw document
617	/// and drops the stored layers.
618	///
619	/// The final inline screen persists into native scrollback once the
620	/// host exits and the shell resumes scrolling, so teardown must not
621	/// leave layer cells composited — [`crate::App`] does this
622	/// automatically, and manual hosts call it before dropping their
623	/// [`crate::Terminal`]. Call it on the main screen (release any
624	/// alternate-screen hold first); with no stored layers, or while the
625	/// alternate screen is active, nothing is written.
626	///
627	/// # Errors
628	/// Propagates writer failures, which poison the renderer.
629	pub fn clear_layers(&mut self) -> io::Result<()> {
630		if self.poisoned || self.layers.is_empty() || crate::terminal::alt_screen_active() {
631			return Ok(());
632		}
633		if self.previous.is_none() {
634			self.layers.clear();
635			return Ok(());
636		}
637		self.sync_screen_buffer();
638		let layers = std::mem::take(&mut self.layers);
639		let window = Window { top: self.window_top, height: self.viewport_height };
640		let mut stats = PaintStats::default();
641		let (output, next_cursor) = {
642			let previous = self
643				.previous
644				.as_ref()
645				.expect("layer-clearing checked previous above");
646			let previous_view = ComposedFrame { base: previous, layers: &layers };
647			let next_view = ComposedFrame { base: previous, layers: &[] };
648			let mut paint = String::new();
649			emit_window_diff(
650				&mut paint,
651				&previous_view,
652				window,
653				&next_view,
654				window,
655				0,
656				self.viewport_height,
657				self.graphics,
658				self.hyperlinks,
659				&mut stats,
660			);
661			let next_cursor = frame_cursor(previous, window);
662			let mut output = String::with_capacity(paint.len().saturating_add(64));
663			if stats.runs > 0 || next_cursor != self.cursor {
664				if self.sync_output {
665					output.push_str(SYNC_OUTPUT_BEGIN);
666				}
667				output.push_str(HIDE_CURSOR);
668				output.push_str(VIEWPORT_BOTTOM);
669				output.push_str(&paint);
670				place_cursor(&mut output, next_cursor, self.viewport_height);
671				if self.sync_output {
672					output.push_str(SYNC_OUTPUT_END);
673				}
674			}
675			(output, next_cursor)
676		};
677		self.write(&output)?;
678		self.cursor = next_cursor;
679		Ok(())
680	}
681
682	fn paint_next(
683		&mut self,
684		next: &Frame,
685		viewport_height: u16,
686		stable_rows: u16,
687	) -> io::Result<PaintStats> {
688		self.validate_stable_prefix(next)?;
689		self.paint_validated_next(next, viewport_height, stable_rows, None, &[])
690	}
691
692	fn validate_stable_prefix(&self, next: &Frame) -> io::Result<()> {
693		let previous = self
694			.previous
695			.as_ref()
696			.expect("callers checked previous before painting");
697		if (0..self.stable_rows).any(|row| !previous.row_equals(row, next, row)) {
698			return Err(Self::stable_mutation_error());
699		}
700		Ok(())
701	}
702
703	fn validate_damaged_stable_prefix(
704		&self,
705		next: &Frame,
706		damaged: &[(u16, u16)],
707	) -> io::Result<()> {
708		let previous = self
709			.previous
710			.as_ref()
711			.expect("callers checked previous before painting");
712		for &(start, end) in damaged {
713			let end = end.min(self.stable_rows);
714			if (start.min(end)..end).any(|row| !previous.row_equals(row, next, row)) {
715				return Err(Self::stable_mutation_error());
716			}
717		}
718		Ok(())
719	}
720
721	fn stable_mutation_error() -> io::Error {
722		contract_error("a previously declared-stable row changed; native history was left untouched")
723	}
724
725	fn paint_validated_next(
726		&mut self,
727		next: &Frame,
728		viewport_height: u16,
729		stable_rows: u16,
730		damaged: Option<&[(u16, u16)]>,
731		layers: &[ResolvedLayer<'_>],
732	) -> io::Result<PaintStats> {
733		self.sync_screen_buffer();
734		let image_prefix = self.image_prefix(next, layers);
735		let mut output = std::mem::take(&mut self.output_scratch);
736		output.clear();
737		output.push_str(&image_prefix);
738		self.prepare_sixels(next);
739		let previous = self
740			.previous
741			.as_ref()
742			.expect("callers checked previous before painting");
743		let layout = layout(next.size().height, viewport_height, stable_rows, self.committed_rows);
744		let previous_window = Window { top: self.window_top, height: viewport_height };
745		let next_window = Window { top: layout.window_top, height: viewport_height };
746		let mut incoming = std::mem::take(&mut self.layer_scratch);
747		store_layers_into(layers, next_window, next.size().width, &mut incoming);
748		let commit_to =
749			scroll_append_to(previous_window, next_window, self.committed_rows, layout.stable_limit);
750		let newly_committed = commit_to - self.committed_rows;
751		let margin_rows = if self.margin_scrollback {
752			stable_rows
753				.saturating_sub(layout.window_top)
754				.max(newly_committed)
755				.max(2)
756		} else {
757			viewport_height
758		};
759		let mut stats = PaintStats {
760			committed_rows: newly_committed,
761			clipped_rows: layout.window_top.saturating_sub(commit_to),
762			..PaintStats::default()
763		};
764		// Direct-drawn image protocols consume the raw document. Only Kitty
765		// placeholder cells are occluded by the composed cell view below.
766		let sixels = self.sixel_output(
767			next,
768			next_window,
769			Some((previous, previous_window)),
770			damaged,
771			previous_window.top != next_window.top,
772		);
773		let kitty_direct = kitty_direct_output(
774			self.graphics,
775			&mut self.images,
776			next,
777			next_window,
778			Some((previous, previous_window)),
779			damaged,
780			false,
781			self.cell_pixel_width,
782			self.cell_pixel_height,
783			self.tmux_passthrough,
784		);
785		let iterm2 = iterm2_output(
786			self.graphics,
787			self
788				.images
789				.iter()
790				.map(|(&id, image)| Iterm2Image { id, png: &image.png }),
791			next,
792			Iterm2Viewport { top: next_window.top, height: next_window.height },
793			Some((previous, Iterm2Viewport {
794				top:    previous_window.top,
795				height: previous_window.height,
796			})),
797			damaged,
798			false,
799			self.tmux_passthrough,
800		);
801
802		let dirty_rows = damaged.and_then(|damaged| {
803			(previous_window.top == next_window.top && previous_window.height == next_window.height)
804				.then(|| changed_screen_rows(damaged, &self.layers, &incoming, next_window))
805		});
806		let previous_view = ComposedFrame { base: previous, layers: &self.layers };
807		let next_view = ComposedFrame { base: next, layers: &incoming };
808		let capacity = usize::from(next.size().width).saturating_mul(usize::from(viewport_height));
809		let mut paint = std::mem::take(&mut self.paint_scratch);
810		paint.clear();
811		paint.reserve(capacity);
812		if newly_committed > 0 {
813			// Scroll only the visible stable rows through a DECSTBM region so
814			// the live window below stays physically pinned. The region must
815			// cover the scrolled rows and span the two rows DECSTBM requires;
816			// a seam at or below the screen bottom leaves nothing to pin and
817			// falls back to the whole-screen scroll.
818			if margin_rows < viewport_height {
819				emit_margin_scroll_append(
820					&mut paint,
821					&previous_view,
822					previous_window,
823					&next_view,
824					next_window,
825					margin_rows,
826					self.graphics,
827					self.hyperlinks,
828					&mut stats,
829				);
830			} else {
831				emit_scroll_append(
832					&mut paint,
833					&previous_view,
834					previous_window,
835					&next_view,
836					next_window,
837					self.graphics,
838					self.hyperlinks,
839					&mut stats,
840				);
841			}
842		} else {
843			emit_window_diff_rows(
844				&mut paint,
845				&previous_view,
846				previous_window,
847				&next_view,
848				next_window,
849				0,
850				viewport_height,
851				dirty_rows.as_deref(),
852				self.graphics,
853				self.hyperlinks,
854				&mut stats,
855			);
856		}
857		// Wrap-boundary metadata has no in-place VT rewrite: boundaries
858		// whose hard/soft state changed are re-emitted surgically — never
859		// via a viewport clear, which scrollback-pushing terminals would
860		// turn into duplicated history.
861		reconcile_wrap_boundaries(
862			&mut paint,
863			&previous_view,
864			previous_window,
865			&next_view,
866			next_window,
867			newly_committed,
868			margin_rows.min(viewport_height),
869			self.graphics,
870			self.hyperlinks,
871			&mut stats,
872		);
873
874		let next_cursor = compose_cursor(next, &incoming, next_window, next.size().width);
875		output.reserve(
876			paint
877				.len()
878				.saturating_add(sixels.len())
879				.saturating_add(kitty_direct.len())
880				.saturating_add(iterm2.len())
881				.saturating_add(64),
882		);
883		if stats.runs > 0
884			|| !sixels.is_empty()
885			|| !kitty_direct.is_empty()
886			|| !iterm2.is_empty()
887			|| next_cursor != self.cursor
888		{
889			if self.sync_output {
890				output.push_str(SYNC_OUTPUT_BEGIN);
891			}
892			output.push_str(HIDE_CURSOR);
893			output.push_str(VIEWPORT_BOTTOM);
894			output.push_str(&paint);
895			output.push_str(&sixels);
896			output.push_str(&kitty_direct);
897			output.push_str(&iterm2);
898			place_cursor(&mut output, next_cursor, viewport_height);
899			if self.sync_output {
900				output.push_str(SYNC_OUTPUT_END);
901			}
902		}
903		let bytes = output.len();
904		let write_result = self.write(&output);
905		self.paint_scratch = paint;
906		self.output_scratch = output;
907		write_result?;
908
909		self.window_top = layout.window_top;
910		self.committed_rows = commit_to;
911		self.stable_rows = stable_rows;
912		self.cursor = next_cursor;
913		stats.bytes = bytes;
914		let previous_layers = std::mem::replace(&mut self.layers, incoming);
915		self.layer_scratch = previous_layers;
916		Ok(stats)
917	}
918
919	/// Paints only the current raw document tail without changing committed
920	/// state.
921	///
922	/// Resize handlers use this on an alternate buffer while normal-buffer
923	/// history remains untouched. Stored overlay layers are deliberately
924	/// ignored; `leading_sequence` is emitted inside the synchronized update,
925	/// before the viewport paint. Overlays go through
926	/// [`Renderer::preview_overlaid`].
927	///
928	/// # Errors
929	///
930	/// Rejects zero geometry. Writer failure poisons the renderer because its
931	/// physical state is unknown.
932	pub fn preview(
933		&mut self,
934		next: &Frame,
935		viewport_height: u16,
936		leading_sequence: &str,
937	) -> io::Result<PaintStats> {
938		self.preview_resolved(next, &[], viewport_height, leading_sequence)
939	}
940
941	/// [`Renderer::preview`] with declarative viewport-anchored layers.
942	///
943	/// The document tail and every visible layer composite into one throwaway
944	/// synchronized paint while committed history and stored layers stay
945	/// untouched. Alternate-screen holders — fullscreen scenes and modal
946	/// overlays — repaint with this on damage or geometry change;
947	/// [`Renderer::present_overlaid`] is the normal-buffer counterpart.
948	///
949	/// # Errors
950	///
951	/// Same contract as [`Renderer::preview`].
952	pub fn preview_overlaid(
953		&mut self,
954		next: &Frame,
955		layers: &[Layer<'_>],
956		viewport_height: u16,
957		leading_sequence: &str,
958	) -> io::Result<PaintStats> {
959		let viewport = Size::new(next.size().width, viewport_height);
960		let resolved = resolve_layers(layers, viewport);
961		self.preview_resolved(next, &resolved, viewport_height, leading_sequence)
962	}
963
964	/// Paints the viewport with pre-resolved layer bands, state-isolated.
965	pub(crate) fn preview_resolved(
966		&mut self,
967		next: &Frame,
968		layers: &[ResolvedLayer<'_>],
969		viewport_height: u16,
970		leading_sequence: &str,
971	) -> io::Result<PaintStats> {
972		self.validate_frame(next, viewport_height)?;
973		if !leading_sequence.is_empty() {
974			self.forget_preview();
975		}
976		self.sync_screen_buffer();
977
978		let paint_cells = usize::from(next.size().width).saturating_mul(usize::from(viewport_height));
979		let window = Window {
980			top:    next.size().height.saturating_sub(viewport_height),
981			height: viewport_height,
982		};
983		let can_diff = leading_sequence.is_empty()
984			&& self.preview_window == Some(window)
985			&& self
986				.preview_previous
987				.as_ref()
988				.is_some_and(|previous| previous.size() == next.size());
989		let composited = store_layers(layers, window, next.size().width);
990		let images = self.image_prefix(next, layers);
991		self.prepare_sixels(next);
992		let sixels = self.sixel_output(next, window, None, None, true);
993		let kitty_direct = kitty_direct_output(
994			self.graphics,
995			&mut self.images,
996			next,
997			window,
998			None,
999			None,
1000			true,
1001			self.cell_pixel_width,
1002			self.cell_pixel_height,
1003			self.tmux_passthrough,
1004		);
1005		let iterm2 = iterm2_output(
1006			self.graphics,
1007			self
1008				.images
1009				.iter()
1010				.map(|(&id, image)| Iterm2Image { id, png: &image.png }),
1011			next,
1012			Iterm2Viewport { top: window.top, height: window.height },
1013			None,
1014			None,
1015			true,
1016			self.tmux_passthrough,
1017		);
1018		let raw = ComposedFrame { base: next, layers: &composited };
1019		let cursor = compose_cursor(next, &composited, window, next.size().width);
1020		let mut stats = PaintStats::default();
1021		let mut paint = String::new();
1022		if can_diff {
1023			let previous = ComposedFrame {
1024				base:   self
1025					.preview_previous
1026					.as_ref()
1027					.expect("preview geometry checked above"),
1028				layers: &self.preview_layers,
1029			};
1030			emit_window_diff(
1031				&mut paint,
1032				&previous,
1033				window,
1034				&raw,
1035				window,
1036				0,
1037				viewport_height,
1038				self.graphics,
1039				self.hyperlinks,
1040				&mut stats,
1041			);
1042		}
1043
1044		let auxiliary =
1045			!images.is_empty() || !sixels.is_empty() || !kitty_direct.is_empty() || !iterm2.is_empty();
1046		let full_repaint = !can_diff;
1047		let mut output = String::with_capacity(
1048			if full_repaint {
1049				paint_cells.saturating_mul(2)
1050			} else {
1051				paint.len()
1052			}
1053			.saturating_add(images.len())
1054			.saturating_add(sixels.len())
1055			.saturating_add(kitty_direct.len())
1056			.saturating_add(iterm2.len())
1057			.saturating_add(64),
1058		);
1059		if full_repaint {
1060			if self.sync_output {
1061				output.push_str(SYNC_OUTPUT_BEGIN);
1062			}
1063			output.push_str(HIDE_CURSOR);
1064			output.push_str(leading_sequence);
1065			// Kitty traffic must follow the staged buffer switch: per-screen
1066			// image stores only keep bytes transmitted on the active screen.
1067			output.push_str(&images);
1068			output.push_str(RESET_STYLE);
1069			output.push_str(esc!(cursor_home));
1070			emit_rows(&mut output, &raw, 0..0, window, self.graphics, self.hyperlinks);
1071			output.push_str(RESET_STYLE);
1072			output.push('\r');
1073			output.push_str(&sixels);
1074			output.push_str(&kitty_direct);
1075			output.push_str(&iterm2);
1076			place_cursor(&mut output, cursor, viewport_height);
1077			if self.sync_output {
1078				output.push_str(SYNC_OUTPUT_END);
1079			}
1080			stats.full_repaint = true;
1081			stats.changed_cells = paint_cells;
1082			stats.runs = usize::from(viewport_height);
1083		} else if stats.runs > 0 || cursor != self.preview_cursor || auxiliary {
1084			if self.sync_output {
1085				output.push_str(SYNC_OUTPUT_BEGIN);
1086			}
1087			output.push_str(HIDE_CURSOR);
1088			output.push_str(&images);
1089			output.push_str(VIEWPORT_BOTTOM);
1090			output.push_str(&paint);
1091			output.push_str(&sixels);
1092			output.push_str(&kitty_direct);
1093			output.push_str(&iterm2);
1094			place_cursor(&mut output, cursor, viewport_height);
1095			if self.sync_output {
1096				output.push_str(SYNC_OUTPUT_END);
1097			}
1098		}
1099
1100		stats.bytes = output.len();
1101		self.write(&output)?;
1102		if crate::debug::publishing() {
1103			// A preview is what the terminal shows right now (alternate
1104			// screen or drag frame); publish its composition, not the
1105			// committed main-screen model.
1106			crate::debug::publish_screen(crate::debug::ScreenSnapshot {
1107				lines:      stored_text(next, &composited, window.top, viewport_height),
1108				cursor:     cursor.map(|cursor| (cursor.row, cursor.col)),
1109				window_top: window.top,
1110				cols:       next.size().width,
1111				rows:       viewport_height,
1112				doc_height: next.size().height,
1113				overlay:    !composited.is_empty(),
1114			});
1115		}
1116		match &mut self.preview_previous {
1117			Some(previous) => previous.clone_from(next),
1118			None => self.preview_previous = Some(next.clone()),
1119		}
1120		self.preview_layers = composited;
1121		self.preview_window = Some(window);
1122		self.preview_cursor = cursor;
1123		Ok(stats)
1124	}
1125
1126	/// Clears and reconstructs native history at new terminal geometry.
1127	///
1128	/// The synchronized update emits `leading_sequence`, clears scrollback once,
1129	/// then writes the stable prefix and current viewport. The reconstructed
1130	/// frame becomes the baseline for subsequent [`Self::present`] calls.
1131	///
1132	/// # Errors
1133	///
1134	/// Rejects zero geometry or a stable boundary beyond the document. Writer
1135	/// failure poisons the renderer because history may be partially rebuilt.
1136	pub fn rebuild(
1137		&mut self,
1138		next: Frame,
1139		viewport_height: u16,
1140		stable_rows: u16,
1141		leading_sequence: &str,
1142	) -> io::Result<PaintStats> {
1143		self.forget_preview();
1144		self.validate_frame(&next, viewport_height)?;
1145		if stable_rows > next.size().height {
1146			return Err(contract_error("stable_rows exceeds the document height"));
1147		}
1148		let stats =
1149			self.full_paint(next, viewport_height, stable_rows, leading_sequence, REBUILD_HISTORY)?;
1150		self.publish_debug_screen();
1151		Ok(stats)
1152	}
1153
1154	/// Publishes the committed screen to the shared debug snapshot when a
1155	/// stream-served `OMP_TUI_DEBUG` host is listening; no-op otherwise.
1156	fn publish_debug_screen(&self) {
1157		if !crate::debug::publishing() {
1158			return;
1159		}
1160		let Some(previous) = &self.previous else {
1161			return;
1162		};
1163		crate::debug::publish_screen(crate::debug::ScreenSnapshot {
1164			lines:      self.screen_text(),
1165			cursor:     self.screen_cursor(),
1166			window_top: self.window_top,
1167			cols:       previous.size().width,
1168			rows:       self.viewport_height,
1169			doc_height: previous.size().height,
1170			overlay:    !self.layers.is_empty(),
1171		});
1172	}
1173
1174	/// Returns the number of finalized rows physically stored above the
1175	/// viewport.
1176	pub const fn committed_rows(&self) -> u16 {
1177		self.committed_rows
1178	}
1179
1180	/// Returns the document row currently shown at the viewport top.
1181	pub const fn window_top(&self) -> u16 {
1182		self.window_top
1183	}
1184
1185	/// Renders the retained physical screen model — the committed frame
1186	/// composed with its stored viewport layers — as visible text, one
1187	/// right-trimmed string per viewport row.
1188	///
1189	/// This is what the terminal currently shows, driving the `OMP_TUI_DEBUG`
1190	/// `text` op. Empty before the first present or rebuild.
1191	pub fn screen_text(&self) -> Vec<String> {
1192		match &self.previous {
1193			Some(previous) => {
1194				stored_text(previous, &self.layers, self.window_top, self.viewport_height)
1195			},
1196			None => Vec::new(),
1197		}
1198	}
1199
1200	/// Screen coordinates (row, column) of the visible hardware cursor, when
1201	/// one was placed by the last present.
1202	pub const fn screen_cursor(&self) -> Option<(u16, u16)> {
1203		match self.cursor {
1204			Some(cursor) => Some((cursor.row, cursor.col)),
1205			None => None,
1206		}
1207	}
1208
1209	/// Returns whether terminal output is connected or was abandoned after its
1210	/// unflushed backlog crossed the safety limit.
1211	pub const fn output_state(&self) -> OutputState {
1212		self.output_state
1213	}
1214
1215	/// Borrows the output writer for terminal session teardown.
1216	pub const fn writer_mut(&mut self) -> &mut W {
1217		&mut self.writer
1218	}
1219
1220	/// Returns the output writer after the renderer is no longer needed.
1221	pub fn into_inner(self) -> W {
1222		self.writer
1223	}
1224
1225	fn validate_frame(&self, next: &Frame, viewport_height: u16) -> io::Result<()> {
1226		if self.poisoned {
1227			return Err(io::Error::other(
1228				"renderer state is unknown after a partial write; restart the terminal session",
1229			));
1230		}
1231		if next.size().width == 0 || viewport_height == 0 {
1232			return Err(io::Error::new(
1233				io::ErrorKind::InvalidInput,
1234				"document width and viewport height must be non-zero",
1235			));
1236		}
1237		Ok(())
1238	}
1239
1240	fn validate_input(
1241		&self,
1242		next: &Frame,
1243		viewport_height: u16,
1244		stable_rows: u16,
1245	) -> io::Result<()> {
1246		self.validate_frame(next, viewport_height)?;
1247		if stable_rows > next.size().height {
1248			return Err(contract_error("stable_rows exceeds the document height"));
1249		}
1250		if stable_rows < self.stable_rows {
1251			return Err(contract_error("stable_rows cannot retreat"));
1252		}
1253		if next.size().height < self.committed_rows {
1254			return Err(contract_error(
1255				"document is shorter than rows already committed to native history",
1256			));
1257		}
1258		if next.size().height.saturating_sub(viewport_height) < self.committed_rows {
1259			return Err(contract_error(
1260				"document tail shrank below committed history; document height must stay monotonic \
1261				 between rebuilds",
1262			));
1263		}
1264		if let Some(previous) = &self.previous
1265			&& (previous.size().width != next.size().width || self.viewport_height != viewport_height)
1266		{
1267			return Err(io::Error::new(
1268				io::ErrorKind::InvalidInput,
1269				"terminal geometry changed; preserving native history requires a new renderer session",
1270			));
1271		}
1272		Ok(())
1273	}
1274
1275	fn initial_paint(
1276		&mut self,
1277		next: Frame,
1278		viewport_height: u16,
1279		stable_rows: u16,
1280	) -> io::Result<PaintStats> {
1281		self.full_paint(next, viewport_height, stable_rows, "", CLEAR_VIEWPORT)
1282	}
1283
1284	fn initial_paint_overlaid(
1285		&mut self,
1286		next: Frame,
1287		viewport_height: u16,
1288		stable_rows: u16,
1289		layers: &[ResolvedLayer<'_>],
1290	) -> io::Result<PaintStats> {
1291		self.paint_full(next, viewport_height, stable_rows, "", CLEAR_VIEWPORT, layers)
1292	}
1293
1294	fn full_paint(
1295		&mut self,
1296		next: Frame,
1297		viewport_height: u16,
1298		stable_rows: u16,
1299		leading_sequence: &str,
1300		clear_sequence: &str,
1301	) -> io::Result<PaintStats> {
1302		self.paint_full(next, viewport_height, stable_rows, leading_sequence, clear_sequence, &[])
1303	}
1304
1305	fn paint_full(
1306		&mut self,
1307		next: Frame,
1308		viewport_height: u16,
1309		stable_rows: u16,
1310		leading_sequence: &str,
1311		clear_sequence: &str,
1312		layers: &[ResolvedLayer<'_>],
1313	) -> io::Result<PaintStats> {
1314		let layout = layout(next.size().height, viewport_height, stable_rows, 0);
1315		let paint_rows = layout.stable_limit.saturating_add(viewport_height);
1316		let paint_cells = usize::from(next.size().width).saturating_mul(usize::from(paint_rows));
1317		let window = Window { top: layout.window_top, height: viewport_height };
1318		let stored_layers = store_layers(layers, window, next.size().width);
1319		let next_cursor = compose_cursor(&next, &stored_layers, window, next.size().width);
1320		self.sync_screen_buffer();
1321		let images = self.image_prefix(&next, layers);
1322		self.prepare_sixels(&next);
1323		let sixels = self.sixel_output(&next, window, None, None, true);
1324		let kitty_direct = kitty_direct_output(
1325			self.graphics,
1326			&mut self.images,
1327			&next,
1328			window,
1329			None,
1330			None,
1331			true,
1332			self.cell_pixel_width,
1333			self.cell_pixel_height,
1334			self.tmux_passthrough,
1335		);
1336		let iterm2 = iterm2_output(
1337			self.graphics,
1338			self
1339				.images
1340				.iter()
1341				.map(|(&id, image)| Iterm2Image { id, png: &image.png }),
1342			&next,
1343			Iterm2Viewport { top: window.top, height: window.height },
1344			None,
1345			None,
1346			true,
1347			self.tmux_passthrough,
1348		);
1349		let mut output = String::with_capacity(
1350			paint_cells
1351				.saturating_mul(2)
1352				.saturating_add(images.len())
1353				.saturating_add(sixels.len())
1354				.saturating_add(kitty_direct.len())
1355				.saturating_add(iterm2.len()),
1356		);
1357		if self.sync_output {
1358			output.push_str(SYNC_OUTPUT_BEGIN);
1359		}
1360		output.push_str(HIDE_CURSOR);
1361		output.push_str(leading_sequence);
1362		output.push_str(RESET_STYLE);
1363		if self.screen_to_scrollback && clear_sequence == CLEAR_VIEWPORT {
1364			output.push_str(SCREEN_TO_SCROLLBACK);
1365		}
1366		output.push_str(clear_sequence);
1367		// Kitty traffic must follow both the staged buffer switch (per-screen
1368		// image stores only keep what arrives on the active screen) and the
1369		// clear, which may drop placements on some implementations.
1370		output.push_str(&images);
1371		let composed = ComposedFrame { base: &next, layers: &stored_layers };
1372		emit_rows(
1373			&mut output,
1374			&composed,
1375			0..layout.stable_limit,
1376			window,
1377			self.graphics,
1378			self.hyperlinks,
1379		);
1380		output.push_str(RESET_STYLE);
1381		output.push('\r');
1382		output.push_str(&sixels);
1383		output.push_str(&kitty_direct);
1384		output.push_str(&iterm2);
1385		place_cursor(&mut output, next_cursor, viewport_height);
1386		if self.sync_output {
1387			output.push_str(SYNC_OUTPUT_END);
1388		}
1389
1390		let bytes = output.len();
1391		self.write(&output)?;
1392		let stats = PaintStats {
1393			full_repaint: true,
1394			changed_cells: paint_cells,
1395			runs: usize::from(paint_rows),
1396			committed_rows: layout.stable_limit,
1397			clipped_rows: layout.window_top.saturating_sub(layout.stable_limit),
1398			bytes,
1399		};
1400		self.previous = Some(next);
1401		self.viewport_height = viewport_height;
1402		self.window_top = layout.window_top;
1403		self.committed_rows = layout.stable_limit;
1404		self.stable_rows = stable_rows;
1405		self.cursor = next_cursor;
1406		self.layers = stored_layers;
1407		Ok(stats)
1408	}
1409
1410	fn forget_preview(&mut self) {
1411		self.preview_previous = None;
1412		self.preview_layers.clear();
1413		self.preview_window = None;
1414		self.preview_cursor = None;
1415	}
1416
1417	/// Reconciles graphics caches with the terminal's current screen buffer.
1418	fn sync_screen_buffer(&mut self) {
1419		self.set_screen_buffer(crate::terminal::alt_screen_active());
1420	}
1421
1422	/// Records which screen buffer subsequent paints target.
1423	///
1424	/// A change drops all terminal-side Kitty graphics state — transmissions,
1425	/// virtual placements, direct placements — because terminals with
1426	/// per-screen image storage (ghostty) do not share them between the main
1427	/// and alternate buffers; the next paint retransmits and re-places.
1428	fn set_screen_buffer(&mut self, alt_screen: bool) {
1429		if alt_screen == self.alt_screen {
1430			return;
1431		}
1432		self.forget_preview();
1433		self.alt_screen = alt_screen;
1434		for image in self.images.values_mut() {
1435			image.uploaded = false;
1436			image.placed.clear();
1437			image.direct_visible = false;
1438		}
1439	}
1440
1441	/// Emits Kitty transmissions and virtual placements for every image
1442	/// referenced by the document or by a composited overlay layer band.
1443	///
1444	/// Each distinct cell box of an image gets its own placement, keyed by
1445	/// [`crate::kitty::placement_id`], so repeated sizes replace instead of
1446	/// accumulating and placeholder cells always resolve their exact grid.
1447	/// IDs unknown to [`Renderer::register_image`] are resolved from the
1448	/// process-wide `<img src>` registry.
1449	fn image_prefix(&mut self, frame: &Frame, layers: &[ResolvedLayer<'_>]) -> String {
1450		if self.graphics != Graphics::KittyPlaceholders
1451			|| (!frame.may_have_images() && layers.iter().all(|layer| !layer.frame.may_have_images()))
1452		{
1453			return String::new();
1454		}
1455		let mut needed: SmallVec<(u32, u16, u16), 8> = SmallVec::new();
1456		let mut collect = |frame: &Frame, y0: u16, y1: u16| {
1457			for y in y0..y1.min(frame.size().height) {
1458				for x in 0..frame.size().width {
1459					if let CellContent::Image { id, rows, cols, .. } = frame.cell(x, y).content
1460						&& rows > 0 && cols > 0
1461						&& !needed.contains(&(id, rows, cols))
1462					{
1463						needed.push((id, rows, cols));
1464					}
1465				}
1466			}
1467		};
1468		collect(frame, 0, frame.size().height);
1469		for layer in layers {
1470			collect(layer.frame, layer.src_top, layer.src_top.saturating_add(layer.rows));
1471		}
1472		let mut output = String::new();
1473		for (id, rows, cols) in needed {
1474			let image = match self.images.entry(id) {
1475				std::collections::btree_map::Entry::Occupied(entry) => entry.into_mut(),
1476				std::collections::btree_map::Entry::Vacant(entry) => {
1477					let Some(png) = crate::imagereg::bytes(id) else {
1478						continue;
1479					};
1480					entry.insert(RegisteredImage::new(png))
1481				},
1482			};
1483			if !image.uploaded {
1484				append_transmission(&mut output, id, &image.png, self.tmux_passthrough);
1485				image.uploaded = true;
1486			}
1487			if !image.placed.contains(&(rows, cols)) {
1488				append_placement(&mut output, id, rows, cols, self.tmux_passthrough);
1489				image.placed.push((rows, cols));
1490			}
1491		}
1492		output
1493	}
1494
1495	fn prepare_sixels(&mut self, frame: &Frame) {
1496		if self.graphics != Graphics::Sixel {
1497			return;
1498		}
1499		for y in 0..frame.size().height {
1500			for x in 0..frame.size().width {
1501				let CellContent::Image { id, .. } = frame.cell(x, y).content else {
1502					continue;
1503				};
1504				let Some(image) = self.images.get_mut(&id) else {
1505					continue;
1506				};
1507				if !image.sixel_decoded {
1508					image.sixel = SixelImage::from_png(&image.png);
1509					image.sixel_decoded = true;
1510				}
1511			}
1512		}
1513	}
1514
1515	fn sixel_output(
1516		&self,
1517		frame: &Frame,
1518		window: Window,
1519		previous: Option<(&Frame, Window)>,
1520		damaged: Option<&[(u16, u16)]>,
1521		force: bool,
1522	) -> String {
1523		if self.graphics != Graphics::Sixel {
1524			return String::new();
1525		}
1526		let mut output = String::new();
1527		let mut cursor_row = window.height - 1;
1528		for (&id, registered) in &self.images {
1529			let Some(image) = &registered.sixel else {
1530				continue;
1531			};
1532			let Some((top, left, rows, cols)) = image_placement(frame, id) else {
1533				continue;
1534			};
1535			let visible_top = top.max(window.top);
1536			let visible_bottom = top
1537				.saturating_add(rows)
1538				.min(window.top.saturating_add(window.height))
1539				.min(frame.size().height);
1540			if visible_top >= visible_bottom {
1541				continue;
1542			}
1543			let needs_emit = force
1544				|| match damaged {
1545					Some(ranges) => ranges
1546						.iter()
1547						.any(|&(start, end)| start < visible_bottom && end > visible_top),
1548					None => match previous {
1549						None => true,
1550						Some((previous, previous_window)) => {
1551							previous_window.top != window.top
1552								|| (visible_top..visible_bottom)
1553									.any(|row| !previous.row_equals(row, frame, row))
1554						},
1555					},
1556				};
1557			if !needs_emit {
1558				continue;
1559			}
1560			let target_width = usize::from(cols).saturating_mul(usize::from(self.cell_pixel_width));
1561			let target_height = usize::from(rows).saturating_mul(usize::from(self.cell_pixel_height));
1562			let y0 = usize::from(visible_top - top).saturating_mul(target_height) / usize::from(rows);
1563			let y1 =
1564				usize::from(visible_bottom - top).saturating_mul(target_height) / usize::from(rows);
1565			let sixel = image.encode_band(target_width, target_height, y0, y1);
1566			if sixel.is_empty() {
1567				continue;
1568			}
1569			move_cursor_row(&mut output, &mut cursor_row, visible_top - window.top);
1570			output.push('\r');
1571			if left > 0 {
1572				let _ = write!(output, esc!(cursor_forward), left);
1573			}
1574			if self.tmux_passthrough {
1575				append_tmux_passthrough(&mut output, &sixel);
1576			} else {
1577				output.push_str(&sixel);
1578			}
1579		}
1580		if !output.is_empty() {
1581			move_cursor_row(&mut output, &mut cursor_row, window.height - 1);
1582			output.push('\r');
1583		}
1584		output
1585	}
1586
1587	fn write(&mut self, output: &str) -> io::Result<()> {
1588		if output.is_empty() {
1589			return Ok(());
1590		}
1591		if self.output_state == OutputState::Disconnected || self.backlog.queue(output.len()) {
1592			self.output_state = OutputState::Disconnected;
1593			self.poisoned = true;
1594			return Err(io::Error::new(
1595				io::ErrorKind::BrokenPipe,
1596				"terminal output backlog exceeded 64 MiB; terminal is disconnected",
1597			));
1598		}
1599		let result = self
1600			.write_output(output.as_bytes())
1601			.and_then(|()| self.writer.flush());
1602		if let Err(error) = result {
1603			self.poisoned = true;
1604			return Err(error);
1605		}
1606		self.backlog.flushed();
1607		Ok(())
1608	}
1609
1610	fn write_output(&mut self, output: &[u8]) -> io::Result<()> {
1611		#[cfg(any(windows, target_os = "linux"))]
1612		if self.conpty_hosted && output.len() > MAX_CONPTY_WRITE_CHUNK_BYTES {
1613			for chunk in ConptyChunks::new(output, MAX_CONPTY_WRITE_CHUNK_BYTES) {
1614				terminal_write_all(&mut self.writer, chunk)?;
1615			}
1616			return Ok(());
1617		}
1618		terminal_write_all(&mut self.writer, output)
1619	}
1620}
1621
1622#[cfg(any(windows, target_os = "linux", test))]
1623struct ConptyChunks<'a> {
1624	bytes: &'a [u8],
1625	pos:   usize,
1626	max:   usize,
1627}
1628
1629#[cfg(any(windows, target_os = "linux", test))]
1630impl<'a> ConptyChunks<'a> {
1631	fn new(bytes: &'a [u8], max: usize) -> Self {
1632		debug_assert!(max > 0);
1633		Self { bytes, pos: 0, max }
1634	}
1635}
1636
1637#[cfg(any(windows, target_os = "linux", test))]
1638impl<'a> Iterator for ConptyChunks<'a> {
1639	type Item = &'a [u8];
1640
1641	fn next(&mut self) -> Option<Self::Item> {
1642		if self.pos == self.bytes.len() {
1643			return None;
1644		}
1645		let start = self.pos;
1646		if self.bytes.len() - start <= self.max {
1647			self.pos = self.bytes.len();
1648			return Some(&self.bytes[start..]);
1649		}
1650
1651		let mut window_end = start + self.max;
1652		while self.bytes[window_end] & 0xc0 == 0x80 {
1653			window_end -= 1;
1654		}
1655		let mut search_end = window_end;
1656		let cut = loop {
1657			let newline = self.bytes[start..search_end]
1658				.iter()
1659				.rposition(|byte| *byte == b'\n')
1660				.map(|index| start + index + 1);
1661			let Some(newline) = newline else {
1662				break escape_end_crossing(self.bytes, start, window_end).unwrap_or(window_end);
1663			};
1664			if escape_end_crossing(self.bytes, start, newline).is_none() {
1665				break newline;
1666			}
1667			search_end = newline - 1;
1668		};
1669		self.pos = cut;
1670		Some(&self.bytes[start..cut])
1671	}
1672}
1673
1674#[cfg(any(windows, target_os = "linux", test))]
1675fn escape_end_crossing(bytes: &[u8], start: usize, cut: usize) -> Option<usize> {
1676	let mut index = start;
1677	while index < cut {
1678		if bytes[index] != b'\x1b' {
1679			index += 1;
1680			continue;
1681		}
1682		let end = escape_sequence_end(bytes, index);
1683		if end > cut {
1684			return Some(end);
1685		}
1686		index = end.max(index + 1);
1687	}
1688	None
1689}
1690
1691#[cfg(any(windows, target_os = "linux", test))]
1692fn escape_sequence_end(bytes: &[u8], start: usize) -> usize {
1693	let Some(&kind) = bytes.get(start + 1) else {
1694		return bytes.len();
1695	};
1696	match kind {
1697		b'[' => {
1698			for (offset, byte) in bytes[start + 2..].iter().enumerate() {
1699				if (0x40..=0x7e).contains(byte) {
1700					return start + 3 + offset;
1701				}
1702			}
1703			bytes.len()
1704		},
1705		b']' => string_escape_end(bytes, start + 2, true),
1706		b'P' | b'X' | b'^' | b'_' => string_escape_end(bytes, start + 2, false),
1707		0x20..=0x2f => {
1708			for (offset, byte) in bytes[start + 2..].iter().enumerate() {
1709				if (0x30..=0x7e).contains(byte) {
1710					return start + 3 + offset;
1711				}
1712			}
1713			bytes.len()
1714		},
1715		_ => (start + 2).min(bytes.len()),
1716	}
1717}
1718
1719#[cfg(any(windows, target_os = "linux", test))]
1720fn string_escape_end(bytes: &[u8], start: usize, bell_terminated: bool) -> usize {
1721	let mut index = start;
1722	while index < bytes.len() {
1723		if bell_terminated && bytes[index] == b'\x07' {
1724			return index + 1;
1725		}
1726		if bytes[index] == b'\x1b' && bytes.get(index + 1) == Some(&b'\\') {
1727			return index + 2;
1728		}
1729		index += 1;
1730	}
1731	bytes.len()
1732}
1733
1734#[cfg(windows)]
1735const fn is_conpty_hosted() -> bool {
1736	true
1737}
1738
1739#[cfg(target_os = "linux")]
1740fn is_conpty_hosted() -> bool {
1741	std::env::var_os("WSL_DISTRO_NAME").is_some() || std::env::var_os("WSL_INTEROP").is_some()
1742}
1743
1744#[allow(clippy::too_many_arguments, reason = "rendering inputs are independent frame state")]
1745fn kitty_direct_output(
1746	graphics: Graphics,
1747	images: &mut BTreeMap<u32, RegisteredImage>,
1748	frame: &Frame,
1749	window: Window,
1750	previous: Option<(&Frame, Window)>,
1751	damaged: Option<&[(u16, u16)]>,
1752	force: bool,
1753	cell_pixel_width: u16,
1754	cell_pixel_height: u16,
1755	tmux_passthrough: bool,
1756) -> String {
1757	if graphics != Graphics::KittyDirect {
1758		return String::new();
1759	}
1760	let mut output = String::new();
1761	let mut cursor_row = window.height - 1;
1762	for (&id, image) in images {
1763		let placement = image_placement(frame, id);
1764		let visible = placement.and_then(|(top, left, rows, cols)| {
1765			let visible_top = top.max(window.top);
1766			let visible_bottom = top
1767				.saturating_add(rows)
1768				.min(window.top.saturating_add(window.height))
1769				.min(frame.size().height);
1770			(visible_top < visible_bottom).then_some((
1771				top,
1772				left,
1773				rows,
1774				cols,
1775				visible_top,
1776				visible_bottom,
1777			))
1778		});
1779		let Some((top, left, rows, cols, visible_top, visible_bottom)) = visible else {
1780			if image.direct_visible {
1781				append_delete_image(&mut output, id, tmux_passthrough);
1782				image.uploaded = false;
1783				image.direct_visible = false;
1784			}
1785			continue;
1786		};
1787
1788		let moved = previous.is_none_or(|(previous_frame, previous_window)| {
1789			image_placement(previous_frame, id) != placement || previous_window.top != window.top
1790		});
1791		let intersects_damage = damaged.is_some_and(|ranges| {
1792			ranges
1793				.iter()
1794				.any(|&(start, end)| start < visible_bottom && end > visible_top)
1795		});
1796		let changed = damaged.is_none()
1797			&& previous.is_some_and(|(previous_frame, _)| {
1798				(visible_top..visible_bottom).any(|row| !previous_frame.row_equals(row, frame, row))
1799			});
1800		let needs_emit =
1801			force || !image.uploaded || !image.direct_visible || moved || intersects_damage || changed;
1802		image.direct_visible = true;
1803		if !needs_emit {
1804			continue;
1805		}
1806		if !image.uploaded {
1807			append_transmission(&mut output, id, &image.png, tmux_passthrough);
1808			image.uploaded = true;
1809		}
1810
1811		let fallback_width = u32::from(cols)
1812			.saturating_mul(u32::from(cell_pixel_width))
1813			.max(1);
1814		let fallback_height = u32::from(rows)
1815			.saturating_mul(u32::from(cell_pixel_height))
1816			.max(1);
1817		let (source_width, source_height) =
1818			png_dimensions(&image.png).unwrap_or((fallback_width, fallback_height));
1819		let row_offset = u64::from(visible_top - top);
1820		let row_end = u64::from(visible_bottom - top);
1821		let source_y = (row_offset.saturating_mul(u64::from(source_height)) / u64::from(rows)) as u32;
1822		let source_bottom =
1823			(row_end.saturating_mul(u64::from(source_height)) / u64::from(rows)) as u32;
1824		let source_height = source_bottom.saturating_sub(source_y).max(1);
1825
1826		move_cursor_row(&mut output, &mut cursor_row, visible_top - window.top);
1827		output.push('\r');
1828		if left > 0 {
1829			let _ = write!(output, esc!(cursor_forward), left);
1830		}
1831		append_direct_placement(
1832			&mut output,
1833			id,
1834			DirectPlacement {
1835				source_x: 0,
1836				source_y,
1837				source_width,
1838				source_height,
1839				rows: visible_bottom - visible_top,
1840				cols,
1841			},
1842			tmux_passthrough,
1843		);
1844	}
1845	if !output.is_empty() {
1846		move_cursor_row(&mut output, &mut cursor_row, window.height - 1);
1847		output.push('\r');
1848	}
1849	output
1850}
1851
1852fn png_dimensions(png: &[u8]) -> Option<(u32, u32)> {
1853	const SIGNATURE: &[u8; 8] = b"\x89PNG\r\n\x1a\n";
1854	if png.get(..8) != Some(SIGNATURE) || png.get(12..16) != Some(b"IHDR") {
1855		return None;
1856	}
1857	let width = u32::from_be_bytes(png.get(16..20)?.try_into().ok()?);
1858	let height = u32::from_be_bytes(png.get(20..24)?.try_into().ok()?);
1859	(width > 0 && height > 0).then_some((width, height))
1860}
1861
1862pub fn image_placement(frame: &Frame, id: u32) -> Option<(u16, u16, u16, u16)> {
1863	for y in 0..frame.size().height {
1864		for x in 0..frame.size().width {
1865			if let CellContent::Image { id: cell_id, row, col, rows, cols } = frame.cell(x, y).content
1866				&& cell_id == id
1867				&& rows > 0
1868				&& cols > 0
1869			{
1870				return Some((y.saturating_sub(row), x.saturating_sub(col), rows, cols));
1871			}
1872		}
1873	}
1874	None
1875}
1876
1877fn contract_error(message: &'static str) -> io::Error {
1878	io::Error::new(io::ErrorKind::InvalidData, message)
1879}
1880
1881fn layout(
1882	document_height: u16,
1883	viewport_height: u16,
1884	stable_rows: u16,
1885	committed_rows: u16,
1886) -> Layout {
1887	let natural_top = document_height.saturating_sub(viewport_height);
1888	let stable_limit = committed_rows.max(stable_rows.min(natural_top));
1889	let window_top = committed_rows.max(natural_top);
1890	Layout { stable_limit, window_top }
1891}
1892
1893/// Resolves declarative layers into z-ordered viewport bands.
1894fn resolve_layers<'a>(layers: &'a [Layer<'_>], viewport: Size) -> SmallVec<ResolvedLayer<'a>, 4> {
1895	let mut ordered: SmallVec<(i16, ResolvedLayer<'a>), 4> = layers
1896		.iter()
1897		.filter_map(|layer| {
1898			let band = layer.band(viewport);
1899			(band.rows > 0).then_some((layer.options.z, ResolvedLayer {
1900				frame:   layer.frame,
1901				x:       band.x,
1902				y:       band.y,
1903				src_top: band.src_top,
1904				rows:    band.rows,
1905				active:  layer.active,
1906			}))
1907		})
1908		.collect();
1909	ordered.sort_by_key(|(z, _)| *z);
1910	ordered.into_iter().map(|(_, layer)| layer).collect()
1911}
1912
1913fn store_layers(
1914	layers: &[ResolvedLayer<'_>],
1915	window: Window,
1916	document_width: u16,
1917) -> SmallVec<StoredLayer, 4> {
1918	let mut stored = SmallVec::new();
1919	store_layers_into(layers, window, document_width, &mut stored);
1920	stored
1921}
1922
1923fn store_layers_into(
1924	layers: &[ResolvedLayer<'_>],
1925	window: Window,
1926	document_width: u16,
1927	stored: &mut SmallVec<StoredLayer, 4>,
1928) {
1929	let mut len = 0;
1930	for layer in layers {
1931		if layer.y >= window.height
1932			|| layer.x >= document_width
1933			|| layer.src_top >= layer.frame.size().height
1934			|| layer.frame.size().width == 0
1935		{
1936			continue;
1937		}
1938		let rows = layer
1939			.rows
1940			.min(window.height - layer.y)
1941			.min(layer.frame.size().height - layer.src_top);
1942		if rows == 0 {
1943			continue;
1944		}
1945		let source_address = std::ptr::from_ref(layer.frame).addr();
1946		let (source_id, source_revision) = layer.frame.source_stamp();
1947		if let Some(slot) = stored.get_mut(len) {
1948			let source_unchanged = slot.source_address == source_address
1949				&& slot.source_id == source_id
1950				&& slot.source_revision == source_revision;
1951			if !source_unchanged && !slot.frame.same_grid(layer.frame) {
1952				slot.frame.clone_from(layer.frame);
1953			}
1954			slot.x = layer.x;
1955			slot.document_y = window.top.saturating_add(layer.y);
1956			slot.src_top = layer.src_top;
1957			slot.rows = rows;
1958			slot.active = layer.active;
1959			slot.source_address = source_address;
1960			slot.source_id = source_id;
1961			slot.source_revision = source_revision;
1962		} else {
1963			stored.push(StoredLayer {
1964				frame: layer.frame.clone(),
1965				x: layer.x,
1966				document_y: window.top.saturating_add(layer.y),
1967				src_top: layer.src_top,
1968				rows,
1969				active: layer.active,
1970				source_address,
1971				source_id,
1972				source_revision,
1973			});
1974		}
1975		len += 1;
1976	}
1977	stored.truncate(len);
1978}
1979
1980fn changed_screen_rows(
1981	damaged: &[(u16, u16)],
1982	previous_layers: &[StoredLayer],
1983	next_layers: &[StoredLayer],
1984	window: Window,
1985) -> SmallVec<(u16, u16), 12> {
1986	let mut rows = SmallVec::new();
1987	let window_end = window.top.saturating_add(window.height);
1988	let mut push_document_rows = |start: u16, end: u16| {
1989		let start = start.max(window.top);
1990		let end = end.min(window_end);
1991		if start < end {
1992			rows.push((start - window.top, end - window.top));
1993		}
1994	};
1995	for &(start, end) in damaged {
1996		push_document_rows(start, end);
1997	}
1998	for index in 0..previous_layers.len().max(next_layers.len()) {
1999		let previous = previous_layers.get(index);
2000		let next = next_layers.get(index);
2001		if previous
2002			.zip(next)
2003			.is_some_and(|(previous, next)| previous.same_cells_and_placement(next))
2004		{
2005			continue;
2006		}
2007		if let Some(layer) = previous {
2008			push_document_rows(layer.document_y, layer.document_y.saturating_add(layer.rows));
2009		}
2010		if let Some(layer) = next {
2011			push_document_rows(layer.document_y, layer.document_y.saturating_add(layer.rows));
2012		}
2013	}
2014	rows
2015}
2016
2017/// One right-trimmed text row per viewport line of `base` under `layers`.
2018fn stored_text(base: &Frame, layers: &[StoredLayer], top: u16, height: u16) -> Vec<String> {
2019	let composed = ComposedFrame { base, layers };
2020	let blank = Cell::blank(Style::default());
2021	let width = base.size().width;
2022	let mut rows = Vec::with_capacity(usize::from(height));
2023	for offset in 0..height {
2024		let y = top.saturating_add(offset);
2025		let mut text = String::new();
2026		for x in 0..width {
2027			match &composed.cell_or(y, x, &blank).content {
2028				CellContent::Blank => text.push(' '),
2029				CellContent::Grapheme { text: glyph, .. } => text.push_str(glyph),
2030				CellContent::Image { .. } => text.push(' '),
2031				CellContent::Continuation => {},
2032			}
2033		}
2034		text.truncate(text.trim_end().len());
2035		rows.push(text);
2036	}
2037	rows
2038}
2039
2040/// Hardware-cursor choice for a composited screen: the layer owning the
2041/// keyboard places — or, without a frame cursor, suppresses — the caret;
2042/// with no active layer the base document's caret shows through passive
2043/// layers.
2044fn compose_cursor(
2045	base: &Frame,
2046	layers: &[StoredLayer],
2047	window: Window,
2048	document_width: u16,
2049) -> Option<ScreenCursor> {
2050	match layers.iter().rev().find(|layer| layer.active) {
2051		Some(layer) => layer_cursor(layer, window, document_width),
2052		None => frame_cursor(base, window),
2053	}
2054}
2055
2056/// Translates a layer frame's cursor into screen coordinates.
2057fn layer_cursor(layer: &StoredLayer, window: Window, document_width: u16) -> Option<ScreenCursor> {
2058	let (col, row) = layer.frame.cursor()?;
2059	if col >= layer.frame.size().width
2060		|| row < layer.src_top
2061		|| row >= layer.src_top.saturating_add(layer.rows)
2062	{
2063		return None;
2064	}
2065	let screen_row = layer
2066		.document_y
2067		.saturating_sub(window.top)
2068		.saturating_add(row - layer.src_top);
2069	let screen_col = layer.x.saturating_add(col);
2070	(screen_row < window.height && screen_col < document_width)
2071		.then_some(ScreenCursor { row: screen_row, col: screen_col })
2072}
2073
2074fn frame_cursor(frame: &Frame, window: Window) -> Option<ScreenCursor> {
2075	let (col, document_row) = frame.cursor()?;
2076	if col >= frame.size().width
2077		|| document_row < window.top
2078		|| document_row >= window.top.saturating_add(window.height)
2079	{
2080		return None;
2081	}
2082	Some(ScreenCursor { row: document_row - window.top, col })
2083}
2084
2085fn place_cursor(output: &mut String, cursor: Option<ScreenCursor>, viewport_height: u16) {
2086	let Some(cursor) = cursor else {
2087		return;
2088	};
2089	let mut row = viewport_height - 1;
2090	move_cursor_row(output, &mut row, cursor.row);
2091	output.push('\r');
2092	if cursor.col > 0 {
2093		let _ = write!(output, esc!(cursor_forward), cursor.col);
2094	}
2095	output.push_str(SHOW_CURSOR);
2096}
2097
2098const fn scroll_append_to(
2099	previous_window: Window,
2100	next_window: Window,
2101	committed_rows: u16,
2102	stable_limit: u16,
2103) -> u16 {
2104	if committed_rows != previous_window.top || next_window.top <= previous_window.top {
2105		return committed_rows;
2106	}
2107	let scroll = next_window.top - previous_window.top;
2108	if scroll >= previous_window.height || next_window.top > stable_limit {
2109		return committed_rows;
2110	}
2111	next_window.top
2112}
2113
2114/// Re-emits live wrap boundaries whose hard/soft state changed since the
2115/// previous paint. VT has no in-place line-attribute rewrite: a boundary
2116/// turning soft re-arms the pending wrap and re-prints its continuation
2117/// row through autowrap; one turning hard erases and re-prints both rows
2118/// (EL resets the attribute on mainstream terminals; frames that may hold
2119/// direct-drawn images overprint without erasing so placements survive).
2120/// Boundaries the commit loop emitted this paint are skipped; `scroll` is
2121/// the number of newly committed rows and `region` the scrolled zone
2122/// height (the full viewport without margin scrollback). The cursor is
2123/// expected on — and is re-parked at — the viewport's bottom row.
2124#[allow(clippy::too_many_arguments, reason = "diff inputs describe two composed viewport slices")]
2125fn reconcile_wrap_boundaries(
2126	output: &mut String,
2127	previous: &ComposedFrame<'_>,
2128	previous_window: Window,
2129	next: &ComposedFrame<'_>,
2130	next_window: Window,
2131	scroll: u16,
2132	region: u16,
2133	graphics: Graphics,
2134	hyperlinks: bool,
2135	stats: &mut PaintStats,
2136) {
2137	let height = next_window.height;
2138	let erase = !next.base.may_have_images();
2139	let mut cursor_row = height - 1;
2140	let mut emitted = false;
2141	for boundary in 0..height.saturating_sub(1) {
2142		let row = next_window.top.saturating_add(boundary);
2143		let wanted = wrap_joinable(next, row);
2144		let painted = if scroll == 0 {
2145			wrap_joinable(previous, previous_window.top.saturating_add(boundary))
2146		} else if boundary.saturating_add(1) < region.saturating_sub(scroll) {
2147			// Retained rows scrolled up with their line attributes intact.
2148			wrap_joinable(previous, next_window.top.saturating_add(boundary))
2149		} else if boundary.saturating_add(1) == region {
2150			// The commit scroll created the region's bottom line fresh.
2151			false
2152		} else if boundary >= region {
2153			// Pinned rows below a margin region never moved.
2154			wrap_joinable(previous, previous_window.top.saturating_add(boundary))
2155		} else {
2156			// The commit loop emits this boundary in its desired state.
2157			continue;
2158		};
2159		if painted == wanted {
2160			continue;
2161		}
2162		emitted = true;
2163		stats.runs += 1;
2164		stats.changed_cells = stats
2165			.changed_cells
2166			.saturating_add(usize::from(next.base.size().width).saturating_mul(2));
2167		move_cursor_row(output, &mut cursor_row, boundary);
2168		if wanted {
2169			output.push_str(esc!(autowrap));
2170			arm_wrap_boundary(output, next, row, graphics, hyperlinks);
2171			// The continuation row's first glyph rides the pending wrap;
2172			// re-printing it whole keeps the screen byte-identical.
2173			encode_frame_row(output, next, row.saturating_add(1), graphics, hyperlinks);
2174			output.push_str(esc!(!autowrap));
2175			cursor_row = boundary + 1;
2176		} else {
2177			output.push('\r');
2178			if erase {
2179				output.push_str(esc!(erase_line));
2180			}
2181			encode_frame_row(output, next, row, graphics, hyperlinks);
2182			move_cursor_row(output, &mut cursor_row, boundary + 1);
2183			output.push('\r');
2184			if erase {
2185				output.push_str(esc!(erase_line));
2186			}
2187			encode_frame_row(output, next, row.saturating_add(1), graphics, hyperlinks);
2188		}
2189	}
2190	if emitted {
2191		output.push_str(RESET_STYLE);
2192		move_cursor_row(output, &mut cursor_row, height - 1);
2193		output.push('\r');
2194	}
2195}
2196fn emit_scroll_append(
2197	output: &mut String,
2198	previous: &ComposedFrame<'_>,
2199	previous_window: Window,
2200	next: &ComposedFrame<'_>,
2201	next_window: Window,
2202	graphics: Graphics,
2203	hyperlinks: bool,
2204	stats: &mut PaintStats,
2205) {
2206	let scroll = next_window.top - previous_window.top;
2207	emit_window_diff(
2208		output,
2209		previous,
2210		Window { top: previous_window.top, height: scroll },
2211		next,
2212		Window { top: next_window.top - scroll, height: scroll },
2213		0,
2214		next_window.height,
2215		graphics,
2216		hyperlinks,
2217		stats,
2218	);
2219	output.push_str(VIEWPORT_BOTTOM);
2220	let first_new = next_window.height - scroll;
2221	let any_join = (first_new..next_window.height).any(|screen_y| {
2222		let row = next_window.top.saturating_add(screen_y);
2223		row > 0 && wrap_joinable(next, row - 1)
2224	});
2225	if any_join {
2226		output.push_str(esc!(autowrap));
2227	}
2228	for screen_y in first_new..next_window.height {
2229		let row = next_window.top.saturating_add(screen_y);
2230		if row > 0 && wrap_joinable(next, row - 1) {
2231			// The first joined row rides a freshly armed pending wrap: the
2232			// bottom line still shows last frame's paint, so its trailing
2233			// glyph is re-printed under DECAWM. Every further full-width
2234			// row printed below arms the pending wrap itself.
2235			if screen_y == first_new {
2236				arm_wrap_boundary(output, next, row - 1, graphics, hyperlinks);
2237			}
2238		} else {
2239			output.push_str("\r\n");
2240		}
2241		encode_frame_row(output, next, row, graphics, hyperlinks);
2242	}
2243	if any_join {
2244		output.push_str(esc!(!autowrap));
2245	}
2246	stats.runs += usize::from(scroll);
2247	stats.changed_cells += usize::from(next.base.size().width).saturating_mul(usize::from(scroll));
2248
2249	let retained_rows = next_window.height - scroll;
2250	emit_window_diff(
2251		output,
2252		previous,
2253		Window { top: previous_window.top.saturating_add(scroll), height: retained_rows },
2254		next,
2255		Window { top: next_window.top, height: retained_rows },
2256		0,
2257		next_window.height,
2258		graphics,
2259		hyperlinks,
2260		stats,
2261	);
2262}
2263
2264/// Commits rows like [`emit_scroll_append`] but scrolls only the top
2265/// `region_rows` screen rows through a top-anchored DECSTBM margin, leaving
2266/// the rows below physically pinned.
2267///
2268/// On terminals that move margin-scrolled rows into native scrollback this
2269/// keeps history identical to a whole-screen scroll while the pinned live
2270/// rows never move on screen; whether a native selection over them survives
2271/// is the terminal's selection-transform property (see
2272/// [`Renderer::set_margin_scrollback`]). Changed pinned cells (spinners,
2273/// streaming text) are diffed in place. The caller guarantees
2274/// `scroll <= region_rows < viewport height`.
2275fn emit_margin_scroll_append(
2276	output: &mut String,
2277	previous: &ComposedFrame<'_>,
2278	previous_window: Window,
2279	next: &ComposedFrame<'_>,
2280	next_window: Window,
2281	region_rows: u16,
2282	graphics: Graphics,
2283	hyperlinks: bool,
2284	stats: &mut PaintStats,
2285) {
2286	let scroll = next_window.top - previous_window.top;
2287	// Finalize the outgoing rows in place so native scrollback receives
2288	// their committed content.
2289	emit_window_diff(
2290		output,
2291		previous,
2292		Window { top: previous_window.top, height: scroll },
2293		next,
2294		Window { top: next_window.top - scroll, height: scroll },
2295		0,
2296		next_window.height,
2297		graphics,
2298		hyperlinks,
2299		stats,
2300	);
2301	// DECSTBM homes the cursor into the region; CUD then parks on the
2302	// bottom margin, where each newline commits the region's top row.
2303	let _ = write!(output, esc!(scroll_region, cursor_down), region_rows, region_rows - 1);
2304	let first_new = region_rows - scroll;
2305	let any_join = (first_new..region_rows).any(|screen_y| {
2306		let row = next_window.top.saturating_add(screen_y);
2307		row > 0 && wrap_joinable(next, row - 1)
2308	});
2309	if any_join {
2310		output.push_str(esc!(autowrap));
2311	}
2312	for screen_y in first_new..region_rows {
2313		let row = next_window.top.saturating_add(screen_y);
2314		if row > 0 && wrap_joinable(next, row - 1) {
2315			if screen_y == first_new {
2316				arm_wrap_boundary(output, next, row - 1, graphics, hyperlinks);
2317			}
2318		} else {
2319			output.push_str("\r\n");
2320		}
2321		encode_frame_row(output, next, row, graphics, hyperlinks);
2322	}
2323	if any_join {
2324		output.push_str(esc!(!autowrap));
2325	}
2326	stats.runs += usize::from(scroll);
2327	stats.changed_cells += usize::from(next.base.size().width).saturating_mul(usize::from(scroll));
2328	// Reset the margins (homing the cursor again) and re-park at the
2329	// viewport bottom for the retained-row diff.
2330	output.push_str(esc!(margins_reset));
2331	output.push_str(VIEWPORT_BOTTOM);
2332	let shifted = region_rows - scroll;
2333	emit_window_diff(
2334		output,
2335		previous,
2336		Window { top: previous_window.top.saturating_add(scroll), height: shifted },
2337		next,
2338		Window { top: next_window.top, height: shifted },
2339		0,
2340		next_window.height,
2341		graphics,
2342		hyperlinks,
2343		stats,
2344	);
2345	// The pinned live rows never moved; repaint their changed cells in place.
2346	let pinned = next_window.height - region_rows;
2347	emit_window_diff(
2348		output,
2349		previous,
2350		Window { top: previous_window.top.saturating_add(region_rows), height: pinned },
2351		next,
2352		Window { top: next_window.top.saturating_add(region_rows), height: pinned },
2353		region_rows,
2354		next_window.height,
2355		graphics,
2356		hyperlinks,
2357		stats,
2358	);
2359}
2360
2361/// Emits `prefix` document rows then the window sequentially from the
2362/// cursor's current line. Hard boundaries advance with `\r\n`; a joinable
2363/// boundary between consecutive document rows is left to terminal
2364/// autowrap, marking the pair as one soft-wrapped line for native copy.
2365fn emit_rows(
2366	output: &mut String,
2367	frame: &ComposedFrame<'_>,
2368	prefix: Range<u16>,
2369	window: Window,
2370	graphics: Graphics,
2371	hyperlinks: bool,
2372) {
2373	let mut any_join = false;
2374	let mut previous: Option<u16> = None;
2375	for row in prefix
2376		.clone()
2377		.chain((0..window.height).map(|screen_y| window.top.saturating_add(screen_y)))
2378	{
2379		if previous.is_some_and(|p| row == p.saturating_add(1) && wrap_joinable(frame, p)) {
2380			any_join = true;
2381			break;
2382		}
2383		previous = Some(row);
2384	}
2385	if any_join {
2386		output.push_str(esc!(autowrap));
2387	}
2388	let mut previous: Option<u16> = None;
2389	for row in prefix.chain((0..window.height).map(|screen_y| window.top.saturating_add(screen_y))) {
2390		if let Some(p) = previous
2391			&& !(row == p.saturating_add(1) && wrap_joinable(frame, p))
2392		{
2393			output.push_str("\r\n");
2394		}
2395		encode_frame_row(output, frame, row, graphics, hyperlinks);
2396		previous = Some(row);
2397	}
2398	if any_join {
2399		output.push_str(esc!(!autowrap));
2400	}
2401}
2402
2403#[inline(always)]
2404fn cells_equal(previous: &Cell, next: &Cell, hyperlinks: bool) -> bool {
2405	previous.content == next.content
2406		&& (previous.style == next.style
2407			|| (!hyperlinks && previous.style.without_link() == next.style.without_link()))
2408}
2409
2410#[inline]
2411fn emit_window_diff(
2412	output: &mut String,
2413	previous: &ComposedFrame<'_>,
2414	previous_window: Window,
2415	next: &ComposedFrame<'_>,
2416	next_window: Window,
2417	screen_top: u16,
2418	screen_height: u16,
2419	graphics: Graphics,
2420	hyperlinks: bool,
2421	stats: &mut PaintStats,
2422) {
2423	emit_window_diff_rows(
2424		output,
2425		previous,
2426		previous_window,
2427		next,
2428		next_window,
2429		screen_top,
2430		screen_height,
2431		None,
2432		graphics,
2433		hyperlinks,
2434		stats,
2435	);
2436}
2437
2438#[allow(clippy::too_many_arguments, reason = "diff inputs describe two composed viewport slices")]
2439fn emit_window_diff_rows(
2440	output: &mut String,
2441	previous: &ComposedFrame<'_>,
2442	previous_window: Window,
2443	next: &ComposedFrame<'_>,
2444	next_window: Window,
2445	screen_top: u16,
2446	screen_height: u16,
2447	dirty_rows: Option<&[(u16, u16)]>,
2448	graphics: Graphics,
2449	hyperlinks: bool,
2450	stats: &mut PaintStats,
2451) {
2452	let blank = Cell::blank(Style::default());
2453	let width = next.base.size().width;
2454	let mut active_style = Style::default();
2455	let mut cursor_row = screen_height - 1;
2456
2457	for screen_y in 0..next_window.height {
2458		if let Some(rows) = dirty_rows
2459			&& !rows
2460				.iter()
2461				.any(|&(start, end)| start <= screen_y && screen_y < end)
2462		{
2463			continue;
2464		}
2465		let previous_y = previous_window.top.saturating_add(screen_y);
2466		let next_y = next_window.top.saturating_add(screen_y);
2467		let mut x = 0;
2468		while x < width {
2469			if cells_equal(
2470				previous.cell_or(previous_y, x, &blank),
2471				next.cell_or(next_y, x, &blank),
2472				hyperlinks,
2473			) {
2474				x += 1;
2475				continue;
2476			}
2477
2478			let mut start = x;
2479			while start > 0
2480				&& matches!(next.cell_or(next_y, start, &blank).content, CellContent::Continuation)
2481			{
2482				start -= 1;
2483			}
2484
2485			let mut end = x + 1;
2486			stats.changed_cells += 1;
2487			while end < width {
2488				let previous_cell = previous.cell_or(previous_y, end, &blank);
2489				let next_cell = next.cell_or(next_y, end, &blank);
2490				if cells_equal(previous_cell, next_cell, hyperlinks) {
2491					break;
2492				}
2493				end += 1;
2494				stats.changed_cells += 1;
2495			}
2496			while end < width
2497				&& matches!(next.cell_or(next_y, end, &blank).content, CellContent::Continuation)
2498			{
2499				end += 1;
2500			}
2501
2502			emit_run(
2503				output,
2504				next,
2505				Run { document_y: next_y, screen_y: screen_top.saturating_add(screen_y), start, end },
2506				&blank,
2507				&mut active_style,
2508				&mut cursor_row,
2509				graphics,
2510				hyperlinks,
2511			);
2512			stats.runs += 1;
2513			x = end;
2514		}
2515	}
2516
2517	if stats.runs > 0 {
2518		output.push_str(RESET_STYLE);
2519		move_cursor_row(output, &mut cursor_row, screen_height - 1);
2520		output.push('\r');
2521	}
2522}
2523
2524pub fn move_cursor_row(output: &mut String, current: &mut u16, target: u16) {
2525	if target < *current {
2526		let _ = write!(output, esc!(cursor_up), *current - target);
2527	} else if target > *current {
2528		let _ = write!(output, esc!(cursor_down), target - *current);
2529	}
2530	*current = target;
2531}
2532
2533fn emit_run(
2534	output: &mut String,
2535	frame: &ComposedFrame<'_>,
2536	run: Run,
2537	blank: &Cell,
2538	active_style: &mut Style,
2539	cursor_row: &mut u16,
2540	graphics: Graphics,
2541	hyperlinks: bool,
2542) {
2543	move_cursor_row(output, cursor_row, run.screen_y);
2544	output.push('\r');
2545	if run.start > 0 {
2546		let _ = write!(output, esc!(cursor_forward), run.start);
2547	}
2548	let mut x = run.start;
2549
2550	while x < run.end {
2551		let cell = frame.cell_or(run.document_y, x, blank);
2552		match &cell.content {
2553			CellContent::Blank => {
2554				emit_cell_style(output, cell.style, active_style, hyperlinks);
2555				output.push(' ');
2556				x += 1;
2557			},
2558			CellContent::Grapheme { text, width } => {
2559				emit_cell_style(output, cell.style, active_style, hyperlinks);
2560				output.push_str(text);
2561				x = x.saturating_add(*width);
2562			},
2563			CellContent::Image { id, row, col, rows, cols } => {
2564				emit_image_cell(
2565					output,
2566					*id,
2567					*row,
2568					*col,
2569					*rows,
2570					*cols,
2571					active_style,
2572					graphics,
2573					hyperlinks,
2574				);
2575				x += 1;
2576			},
2577			CellContent::Continuation => x += 1,
2578		}
2579	}
2580	close_active_link(output, active_style, hyperlinks);
2581}
2582/// Whether the boundary between document rows `row` and `row + 1` may be
2583/// joined by terminal autowrap: the document flagged it as a mid-word soft
2584/// wrap and the row's content truly reaches the final column, so the join
2585/// reproduces the source text exactly in native selection and scrollback
2586/// copies.
2587///
2588/// Deliberately a pure document property: overlay layers composite on top
2589/// without changing it, so band movement never flips boundaries (which
2590/// would force viewport repaints), and the line attribute stays correct
2591/// for the raw rows an overlay only transiently covers.
2592fn wrap_joinable(frame: &ComposedFrame<'_>, row: u16) -> bool {
2593	frame.base.soft_wrap(row) && trailing_glyph_start(frame.base, row).is_some()
2594}
2595
2596/// Returns the head column of the glyph covering `row`'s final cell when
2597/// the row's real content reaches the terminal's last column.
2598fn trailing_glyph_start(frame: &Frame, row: u16) -> Option<u16> {
2599	let width = frame.size().width;
2600	let blank = Cell::blank(Style::default());
2601	let mut x = width.checked_sub(1)?;
2602	loop {
2603		match &frame.cell_or(row, x, &blank).content {
2604			CellContent::Continuation if x > 0 => x -= 1,
2605			CellContent::Grapheme { width: glyph, .. } if x.saturating_add(*glyph) == width => {
2606				return Some(x);
2607			},
2608			_ => return None,
2609		}
2610	}
2611}
2612
2613/// Re-prints the composed cell covering the final column of document row
2614/// `row` on the cursor's current line, arming the terminal's pending-wrap
2615/// state so the next printed glyph soft-wraps onto the following line.
2616/// Emitting the composed view keeps overlay layers intact. Requires DECAWM
2617/// to be enabled.
2618fn arm_wrap_boundary(
2619	output: &mut String,
2620	frame: &ComposedFrame<'_>,
2621	row: u16,
2622	graphics: Graphics,
2623	hyperlinks: bool,
2624) {
2625	let width = frame.base.size().width;
2626	let Some(last) = width.checked_sub(1) else {
2627		return;
2628	};
2629	let blank = Cell::blank(Style::default());
2630	// Walk left over continuation cells so a wide glyph is re-printed
2631	// whole from its head instead of being clobbered mid-cell.
2632	let mut x = last;
2633	let cell = loop {
2634		let cell = frame.cell_or(row, x, &blank);
2635		match &cell.content {
2636			CellContent::Continuation if x > 0 => x -= 1,
2637			_ => break cell,
2638		}
2639	};
2640	output.push('\r');
2641	if x > 0 {
2642		let _ = write!(output, esc!(cursor_forward), x);
2643	}
2644	output.push_str(RESET_STYLE);
2645	let mut active = Style::default();
2646	match &cell.content {
2647		CellContent::Grapheme { text, width: glyph }
2648			if x.saturating_add(*glyph) == width && *glyph > 0 =>
2649		{
2650			emit_cell_style(output, cell.style, &mut active, hyperlinks);
2651			output.push_str(text);
2652		},
2653		CellContent::Image { id, row: img_row, col, rows, cols } if x == last => {
2654			emit_image_cell(
2655				output,
2656				*id,
2657				*img_row,
2658				*col,
2659				*rows,
2660				*cols,
2661				&mut active,
2662				graphics,
2663				hyperlinks,
2664			);
2665		},
2666		_ => {
2667			// Blanks (or anything unprintable) still fill through the
2668			// final column, which is all the pending wrap needs.
2669			emit_cell_style(output, cell.style, &mut active, hyperlinks);
2670			for _ in x..width {
2671				output.push(' ');
2672			}
2673		},
2674	}
2675	close_active_link(output, &mut active, hyperlinks);
2676}
2677fn encode_frame_row(
2678	output: &mut String,
2679	frame: &ComposedFrame<'_>,
2680	row: u16,
2681	graphics: Graphics,
2682	hyperlinks: bool,
2683) {
2684	if row < frame.base.size().height {
2685		encode_row(output, frame, row, graphics, hyperlinks);
2686	} else {
2687		encode_blank_row(output, frame.base.size().width);
2688	}
2689}
2690
2691fn encode_row(
2692	output: &mut String,
2693	frame: &ComposedFrame<'_>,
2694	row: u16,
2695	graphics: Graphics,
2696	hyperlinks: bool,
2697) {
2698	output.push_str(RESET_STYLE);
2699	let blank = Cell::blank(Style::default());
2700	let mut active_style = Style::default();
2701	let mut x = 0;
2702	while x < frame.base.size().width {
2703		let cell = frame.cell_or(row, x, &blank);
2704		match &cell.content {
2705			CellContent::Blank => {
2706				emit_cell_style(output, cell.style, &mut active_style, hyperlinks);
2707				output.push(' ');
2708				x += 1;
2709			},
2710			CellContent::Grapheme { text, width } => {
2711				emit_cell_style(output, cell.style, &mut active_style, hyperlinks);
2712				output.push_str(text);
2713				x = x.saturating_add(*width);
2714			},
2715			CellContent::Image { id, row, col, rows, cols } => {
2716				emit_image_cell(
2717					output,
2718					*id,
2719					*row,
2720					*col,
2721					*rows,
2722					*cols,
2723					&mut active_style,
2724					graphics,
2725					hyperlinks,
2726				);
2727				x += 1;
2728			},
2729			CellContent::Continuation => x += 1,
2730		}
2731	}
2732	close_active_link(output, &mut active_style, hyperlinks);
2733}
2734
2735#[allow(clippy::too_many_arguments, reason = "flat cell emission hot path")]
2736fn emit_image_cell(
2737	output: &mut String,
2738	id: u32,
2739	row: u16,
2740	col: u16,
2741	rows: u16,
2742	cols: u16,
2743	active_style: &mut Style,
2744	graphics: Graphics,
2745	hyperlinks: bool,
2746) {
2747	if graphics != Graphics::KittyPlaceholders {
2748		emit_cell_style(output, Style::default(), active_style, hyperlinks);
2749		output.push(' ');
2750		return;
2751	}
2752	let (placeholder, style) = placeholder_cell(id, row, col, rows, cols);
2753	emit_cell_style(output, style, active_style, hyperlinks);
2754	output.push_str(&placeholder);
2755}
2756
2757fn encode_blank_row(output: &mut String, width: u16) {
2758	output.push_str(RESET_STYLE);
2759	for _ in 0..width {
2760		output.push(' ');
2761	}
2762}
2763
2764fn emit_cell_style(output: &mut String, style: Style, active_style: &mut Style, hyperlinks: bool) {
2765	let link_changed = hyperlinks && active_style.link != style.link;
2766	if link_changed && active_style.link.is_some() {
2767		output.push_str(esc!(osc, "8;;", st));
2768	}
2769	let visual = style.without_link();
2770	if active_style.without_link() != visual {
2771		emit_style(output, visual);
2772	}
2773	if link_changed && let Some(id) = style.link {
2774		emit_link_open(output, id);
2775	}
2776	*active_style = style;
2777}
2778
2779fn close_active_link(output: &mut String, active_style: &mut Style, hyperlinks: bool) {
2780	if hyperlinks && active_style.link.is_some() {
2781		output.push_str(esc!(osc, "8;;", st));
2782	}
2783	active_style.link = None;
2784}
2785
2786fn emit_link_open(output: &mut String, id: LinkId) {
2787	let _ = with_link_url(id, |url| {
2788		let _ = write!(output, esc!(osc, "8;id={};"), id.get());
2789		for ch in url.chars().filter(|ch| !matches!(ch, '\x1b' | '\x07')) {
2790			output.push(ch);
2791		}
2792		output.push_str(esc!(st));
2793	});
2794}
2795
2796fn emit_style(output: &mut String, style: Style) {
2797	let style = style.without_link();
2798	output.push_str(RESET_STYLE);
2799	if style == Style::default() {
2800		return;
2801	}
2802
2803	output.push_str(esc!(csi));
2804	let mut first = true;
2805	push_style_parameters(output, style, &mut first);
2806	output.push('m');
2807}
2808
2809/// Appends the renderer's canonical non-reset SGR parameters.
2810pub fn push_style_parameters(output: &mut String, style: Style, first: &mut bool) {
2811	if style.bold {
2812		push_parameter(output, first, "1");
2813	}
2814	if style.dim {
2815		push_parameter(output, first, "2");
2816	}
2817	if style.italic {
2818		push_parameter(output, first, "3");
2819	}
2820	if style.underline {
2821		push_parameter(output, first, "4");
2822	}
2823	match style.underline_color {
2824		Color::Default => {},
2825		color => {
2826			if !*first {
2827				output.push(';');
2828			}
2829			*first = false;
2830			// Colon sub-parameter form per kitty; ghostty accepts both forms.
2831			match color {
2832				Color::Indexed(index) => {
2833					let _ = write!(output, "58:5:{index}");
2834				},
2835				Color::Rgb(red, green, blue) => {
2836					let _ = write!(output, "58:2::{red}:{green}:{blue}");
2837				},
2838				Color::Default => unreachable!("matched above"),
2839			}
2840		},
2841	}
2842	if style.reverse {
2843		push_parameter(output, first, "7");
2844	}
2845	if style.strikethrough {
2846		push_parameter(output, first, "9");
2847	}
2848	push_color_code(output, first, style.foreground, false);
2849	push_color_code(output, first, style.background, true);
2850}
2851
2852fn push_parameter(output: &mut String, first: &mut bool, parameter: &str) {
2853	if !*first {
2854		output.push(';');
2855	}
2856	output.push_str(parameter);
2857	*first = false;
2858}
2859
2860fn push_color_code(output: &mut String, first: &mut bool, color: Color, background: bool) {
2861	if color == Color::Default {
2862		return;
2863	}
2864	if !*first {
2865		output.push(';');
2866	}
2867	*first = false;
2868
2869	let prefix = if background { 48 } else { 38 };
2870	match color {
2871		Color::Default => unreachable!("default colors returned before emission"),
2872		Color::Indexed(index) => {
2873			let _ = write!(output, "{prefix};5;{index}");
2874		},
2875		Color::Rgb(red, green, blue) => {
2876			let _ = write!(output, "{prefix};2;{red};{green};{blue}");
2877		},
2878	}
2879}
2880
2881#[cfg(test)]
2882mod tests {
2883	use std::io::ErrorKind;
2884
2885	use super::{
2886		ConptyChunks, MAX_CONPTY_WRITE_CHUNK_BYTES, MAX_OUTPUT_BACKLOG_BYTES, OutputBacklogGuard,
2887		REBUILD_HISTORY, ResolvedLayer, SYNC_OUTPUT_BEGIN, SYNC_OUTPUT_END, VIEWPORT_BOTTOM,
2888	};
2889	use crate::{
2890		Color, Frame, Graphics, Renderer, Size, Style,
2891		overlay::{Layer, OverlayAnchor, OverlayOptions},
2892		test_support::TerminalModel,
2893	};
2894
2895	fn document(lines: &[&str]) -> Frame {
2896		let mut frame = Frame::new(Size::new(8, u16::try_from(lines.len()).expect("small fixture")));
2897		for (row, line) in lines.iter().enumerate() {
2898			frame.put(0, u16::try_from(row).expect("small fixture"), line, Style::default());
2899		}
2900		frame
2901	}
2902	/// [`document`] with soft-wrap flags on the given boundary rows.
2903	fn soft_document(lines: &[&str], soft_after: &[u16]) -> Frame {
2904		let mut frame = document(lines);
2905		for &row in soft_after {
2906			frame.set_soft_wrap(row);
2907		}
2908		frame
2909	}
2910
2911	fn apply_paint(renderer: &mut Renderer<Vec<u8>>, terminal: &mut TerminalModel) {
2912		let output = String::from_utf8(std::mem::take(renderer.writer_mut())).expect("ANSI is UTF-8");
2913		terminal.apply(&output);
2914	}
2915
2916	fn without_sync_markers(output: &str) -> String {
2917		output
2918			.replace(SYNC_OUTPUT_BEGIN, "")
2919			.replace(SYNC_OUTPUT_END, "")
2920	}
2921
2922	#[test]
2923	fn layer_appears_once_at_viewport_coordinates() {
2924		let base = document(&["base000", "base111"]);
2925		let mut overlay = Frame::new(Size::new(2, 1));
2926		overlay.put(0, 0, "OV", Style::default());
2927		let layer = ResolvedLayer {
2928			frame:   &overlay,
2929			x:       2,
2930			y:       1,
2931			src_top: 0,
2932			rows:    1,
2933			active:  false,
2934		};
2935		let mut renderer = Renderer::new(Vec::new());
2936		let mut terminal = TerminalModel::new(8, 2);
2937
2938		let first = renderer
2939			.present_resolved(&base, &[], 2, 0, &[layer])
2940			.expect("overlay paint succeeds");
2941		apply_paint(&mut renderer, &mut terminal);
2942		assert!(first.runs > 0);
2943		assert_eq!(terminal.visible_rows(), ["base000", "baOV111"]);
2944
2945		let second = renderer
2946			.present_resolved(&base, &[], 2, 0, &[ResolvedLayer {
2947				frame:   &overlay,
2948				x:       2,
2949				y:       1,
2950				src_top: 0,
2951				rows:    1,
2952				active:  false,
2953			}])
2954			.expect("identical overlay paint succeeds");
2955		assert_eq!(second.runs, 0);
2956		assert_eq!(second.bytes, 0);
2957	}
2958
2959	#[test]
2960	fn declarative_layer_resolves_options_to_a_band() {
2961		let base = document(&["base000", "base111"]);
2962		let mut overlay = Frame::new(Size::new(2, 1));
2963		overlay.put(0, 0, "OV", Style::default());
2964		let options = OverlayOptions::default()
2965			.anchor(OverlayAnchor::TopLeft)
2966			.offset_x(2)
2967			.offset_y(1);
2968		let layer = Layer { frame: &overlay, options: &options, active: false };
2969		let mut renderer = Renderer::new(Vec::new());
2970		let mut terminal = TerminalModel::new(8, 2);
2971
2972		renderer
2973			.present_overlaid(&base, &[], 2, 0, &[layer])
2974			.expect("declarative layer paint succeeds");
2975		apply_paint(&mut renderer, &mut terminal);
2976		assert_eq!(terminal.visible_rows(), ["base000", "baOV111"]);
2977	}
2978
2979	#[test]
2980	fn clearing_overlay_repaints_document_cells() {
2981		let base = document(&["base000", "base111"]);
2982		let mut overlay = Frame::new(Size::new(2, 1));
2983		overlay.put(0, 0, "OV", Style::default());
2984		let mut renderer = Renderer::new(Vec::new());
2985		let mut terminal = TerminalModel::new(8, 2);
2986		renderer
2987			.present(base.clone(), 2, 0)
2988			.expect("base paint succeeds");
2989		apply_paint(&mut renderer, &mut terminal);
2990		renderer
2991			.present_resolved(&base, &[], 2, 0, &[ResolvedLayer {
2992				frame:   &overlay,
2993				x:       2,
2994				y:       1,
2995				src_top: 0,
2996				rows:    1,
2997				active:  false,
2998			}])
2999			.expect("overlay paint succeeds");
3000		apply_paint(&mut renderer, &mut terminal);
3001
3002		let stats = renderer
3003			.present_ref(&base, 2, 0)
3004			.expect("clearing paint succeeds");
3005		apply_paint(&mut renderer, &mut terminal);
3006		assert!(stats.runs > 0);
3007		assert_eq!(terminal.visible_rows(), ["base000", "base111"]);
3008	}
3009
3010	#[test]
3011	fn document_growth_scrolls_raw_rows_to_history_under_open_overlay() {
3012		fn resolved_layer(overlay: &Frame) -> ResolvedLayer<'_> {
3013			ResolvedLayer {
3014				frame:   overlay,
3015				x:       0,
3016				y:       0,
3017				src_top: 0,
3018				rows:    1,
3019				active:  false,
3020			}
3021		}
3022		let mut overlay = Frame::new(Size::new(2, 1));
3023		overlay.put(0, 0, "OV", Style::default());
3024		let mut renderer = Renderer::new(Vec::new());
3025		let mut terminal = TerminalModel::new(8, 2);
3026		renderer
3027			.present(document(&["row00", "row01"]), 2, 2)
3028			.expect("initial paint succeeds");
3029		apply_paint(&mut renderer, &mut terminal);
3030
3031		let first = renderer
3032			.present_resolved(&document(&["row00", "row01", "row02"]), &[(2, 3)], 2, 3, &[
3033				resolved_layer(&overlay),
3034			])
3035			.expect("first growth under the layer succeeds");
3036		apply_paint(&mut renderer, &mut terminal);
3037		assert_eq!(first.committed_rows, 1, "commits keep flowing under an open layer");
3038		assert_eq!(terminal.history, ["row00"]);
3039		assert_eq!(
3040			terminal.visible_rows(),
3041			["OVw01", "row02"],
3042			"the layer stays viewport-anchored after the scroll"
3043		);
3044
3045		let second = renderer
3046			.present_resolved(&document(&["row00", "row01", "row02", "row03"]), &[(3, 4)], 2, 4, &[
3047				resolved_layer(&overlay),
3048			])
3049			.expect("second growth under the layer succeeds");
3050		apply_paint(&mut renderer, &mut terminal);
3051		assert_eq!(second.committed_rows, 1);
3052		assert_eq!(
3053			terminal.history,
3054			["row00", "row01"],
3055			"the row physically under the layer is restored before it scrolls out"
3056		);
3057		assert!(terminal.history.iter().all(|row| !row.contains("OV")));
3058		assert_eq!(terminal.visible_rows(), ["OVw02", "row03"]);
3059
3060		renderer
3061			.present_damaged(&document(&["row00", "row01", "row02", "row03"]), &[], 2, 4)
3062			.expect("clearing the layer succeeds");
3063		apply_paint(&mut renderer, &mut terminal);
3064		assert_eq!(terminal.visible_rows(), ["row02", "row03"]);
3065		assert_eq!(terminal.history, ["row00", "row01"], "clearing commits nothing extra");
3066	}
3067
3068	#[test]
3069	fn clear_layers_restores_raw_cells_without_committing() {
3070		let mut overlay = Frame::new(Size::new(2, 1));
3071		overlay.put(0, 0, "OV", Style::default());
3072		let mut renderer = Renderer::new(Vec::new());
3073		let mut terminal = TerminalModel::new(8, 2);
3074		renderer
3075			.present(document(&["row00", "row01", "row02"]), 2, 3)
3076			.expect("initial paint succeeds");
3077		apply_paint(&mut renderer, &mut terminal);
3078		renderer
3079			.present_resolved(&document(&["row00", "row01", "row02"]), &[], 2, 3, &[ResolvedLayer {
3080				frame:   &overlay,
3081				x:       0,
3082				y:       0,
3083				src_top: 0,
3084				rows:    1,
3085				active:  false,
3086			}])
3087			.expect("layered paint succeeds");
3088		apply_paint(&mut renderer, &mut terminal);
3089		assert_eq!(terminal.visible_rows(), ["OVw01", "row02"]);
3090
3091		renderer.clear_layers().expect("teardown scrub succeeds");
3092		apply_paint(&mut renderer, &mut terminal);
3093		assert_eq!(
3094			terminal.visible_rows(),
3095			["row01", "row02"],
3096			"bands repaint from the raw document"
3097		);
3098		assert_eq!(terminal.history, ["row00"], "the scrub commits nothing");
3099
3100		renderer.clear_layers().expect("layer-free scrub succeeds");
3101		assert!(renderer.writer_mut().is_empty(), "a layer-free scrub writes nothing");
3102	}
3103
3104	#[test]
3105	fn cursor_follows_the_active_layer_and_base_shows_through_passive_ones() {
3106		let mut base = document(&["base000", "base111", "base222"]);
3107		base.set_cursor(0, 0);
3108		let mut overlay = Frame::new(Size::new(2, 2));
3109		overlay.put(0, 0, "aa", Style::default());
3110		overlay.put(0, 1, "bb", Style::default());
3111		overlay.set_cursor(1, 1);
3112		let layer =
3113			|active| ResolvedLayer { frame: &overlay, x: 3, y: 0, src_top: 0, rows: 2, active };
3114		let mut renderer = Renderer::new(Vec::new());
3115		renderer
3116			.present(base.clone(), 3, 0)
3117			.expect("base paint succeeds");
3118		renderer.writer_mut().clear();
3119
3120		// An active layer owns the caret, translated to screen coordinates.
3121		renderer
3122			.present_resolved(&base, &[], 3, 0, &[layer(true)])
3123			.expect("active layer paint succeeds");
3124		let output = String::from_utf8(std::mem::take(renderer.writer_mut())).expect("UTF-8");
3125		assert!(output.contains("\x1b[1A\r\x1b[4C\x1b[?25h"), "{output:?}");
3126
3127		// A passive layer lets the base document's caret show through.
3128		renderer
3129			.present_resolved(&base, &[], 3, 0, &[layer(false)])
3130			.expect("passive layer paint succeeds");
3131		let output = String::from_utf8(std::mem::take(renderer.writer_mut())).expect("UTF-8");
3132		assert!(output.contains("\x1b[2A\r\x1b[?25h"), "base caret at (0,0): {output:?}");
3133
3134		// An active layer without a frame cursor suppresses the base caret.
3135		let mut blank = Frame::new(Size::new(2, 2));
3136		blank.put(0, 0, "cc", Style::default());
3137		renderer
3138			.present_resolved(&base, &[], 3, 0, &[ResolvedLayer {
3139				frame:   &blank,
3140				x:       3,
3141				y:       0,
3142				src_top: 0,
3143				rows:    2,
3144				active:  true,
3145			}])
3146			.expect("cursorless active paint succeeds");
3147		let output = String::from_utf8(std::mem::take(renderer.writer_mut())).expect("UTF-8");
3148		assert!(!output.contains("\x1b[?25h"), "no caret may show: {output:?}");
3149	}
3150
3151	#[test]
3152	fn wide_document_grapheme_cut_by_overlay_is_blank_not_torn() {
3153		let mut base = Frame::new(Size::new(4, 1));
3154		base.put(0, 0, "界x", Style::default());
3155		let mut overlay = Frame::new(Size::new(1, 1));
3156		overlay.put(0, 0, "O", Style::default());
3157		let mut renderer = Renderer::new(Vec::new());
3158		let mut terminal = TerminalModel::new(4, 1);
3159		renderer
3160			.present(base.clone(), 1, 0)
3161			.expect("base paint succeeds");
3162		apply_paint(&mut renderer, &mut terminal);
3163
3164		renderer
3165			.present_resolved(&base, &[], 1, 0, &[ResolvedLayer {
3166				frame:   &overlay,
3167				x:       1,
3168				y:       0,
3169				src_top: 0,
3170				rows:    1,
3171				active:  false,
3172			}])
3173			.expect("wide overlay paint succeeds");
3174		apply_paint(&mut renderer, &mut terminal);
3175		assert_eq!(terminal.visible_rows(), [" Ox"]);
3176	}
3177
3178	#[test]
3179	fn damaged_present_diffs_away_stored_overlay() {
3180		let base = document(&["base000", "base111"]);
3181		let mut overlay = Frame::new(Size::new(2, 1));
3182		overlay.put(0, 0, "OV", Style::default());
3183		let mut renderer = Renderer::new(Vec::new());
3184		let mut terminal = TerminalModel::new(8, 2);
3185		renderer
3186			.present(base.clone(), 2, 0)
3187			.expect("base paint succeeds");
3188		apply_paint(&mut renderer, &mut terminal);
3189		renderer
3190			.present_resolved(&base, &[], 2, 0, &[ResolvedLayer {
3191				frame:   &overlay,
3192				x:       2,
3193				y:       1,
3194				src_top: 0,
3195				rows:    1,
3196				active:  false,
3197			}])
3198			.expect("overlay paint succeeds");
3199		apply_paint(&mut renderer, &mut terminal);
3200
3201		let stats = renderer
3202			.present_damaged(&base, &[], 2, 0)
3203			.expect("damaged clearing paint succeeds");
3204		apply_paint(&mut renderer, &mut terminal);
3205		assert!(stats.runs > 0);
3206		assert_eq!(terminal.visible_rows(), ["base000", "base111"]);
3207	}
3208
3209	#[test]
3210	fn layer_only_kitty_image_still_transmits_and_places() {
3211		let base = document(&["base000", "base111"]);
3212		let mut overlay = Frame::new(Size::new(3, 1));
3213		for col in 0..2 {
3214			overlay.put_image_cell(col, 0, 7, 0, col, 1, 2);
3215		}
3216		let mut renderer = Renderer::new(Vec::new());
3217		renderer.set_graphics(Graphics::KittyPlaceholders);
3218		renderer
3219			.register_image(7, b"\x89PNG\r\n\x1a\nsmall".to_vec())
3220			.expect("image registration succeeds");
3221		renderer
3222			.present(base.clone(), 2, 0)
3223			.expect("base paint succeeds");
3224		renderer.writer_mut().clear();
3225
3226		renderer
3227			.present_resolved(&base, &[], 2, 0, &[ResolvedLayer {
3228				frame:   &overlay,
3229				x:       0,
3230				y:       0,
3231				src_top: 0,
3232				rows:    1,
3233				active:  false,
3234			}])
3235			.expect("overlay image paint succeeds");
3236		let output = String::from_utf8(renderer.writer_mut().clone()).expect("ANSI is UTF-8");
3237		assert!(
3238			output.contains("\x1b_G"),
3239			"an image referenced only by a layer still uploads: {output:?}"
3240		);
3241
3242		renderer.writer_mut().clear();
3243		renderer
3244			.present_resolved(&base, &[], 2, 0, &[ResolvedLayer {
3245				frame:   &overlay,
3246				x:       0,
3247				y:       0,
3248				src_top: 0,
3249				rows:    1,
3250				active:  false,
3251			}])
3252			.expect("steady overlay paint succeeds");
3253		let output = String::from_utf8(renderer.writer_mut().clone()).expect("ANSI is UTF-8");
3254		assert!(!output.contains("\x1b_G"), "uploads happen once: {output:?}");
3255	}
3256
3257	#[test]
3258	fn conpty_chunker_prefers_newlines_within_sixteen_kibibytes() {
3259		let line = "x".repeat(8 * 1024 - 1) + "\n";
3260		let payload = line.repeat(5);
3261		assert_eq!(payload.len(), 40 * 1024);
3262		let chunks =
3263			ConptyChunks::new(payload.as_bytes(), MAX_CONPTY_WRITE_CHUNK_BYTES).collect::<Vec<_>>();
3264		assert!(chunks.len() > 1);
3265		assert!(
3266			chunks
3267				.iter()
3268				.all(|chunk| chunk.len() <= MAX_CONPTY_WRITE_CHUNK_BYTES)
3269		);
3270		assert!(
3271			chunks[..chunks.len() - 1]
3272				.iter()
3273				.all(|chunk| chunk.ends_with(b"\n"))
3274		);
3275		assert_eq!(chunks.concat(), payload.as_bytes());
3276	}
3277
3278	#[test]
3279	fn conpty_chunker_extends_past_an_escape_sequence_without_newlines() {
3280		let mut payload = vec![b'x'; MAX_CONPTY_WRITE_CHUNK_BYTES - 2];
3281		payload.extend_from_slice(b"\x1b]8;;https://example.test/a-very-long-link\x1b\\");
3282		payload.extend(std::iter::repeat_n(b'y', MAX_CONPTY_WRITE_CHUNK_BYTES));
3283		let chunks = ConptyChunks::new(&payload, MAX_CONPTY_WRITE_CHUNK_BYTES).collect::<Vec<_>>();
3284		assert!(chunks[0].len() > MAX_CONPTY_WRITE_CHUNK_BYTES);
3285		assert!(chunks[0].ends_with(b"\x1b\\"));
3286		assert_eq!(chunks.concat(), payload);
3287	}
3288
3289	#[test]
3290	fn backlog_disconnects_only_after_sixty_four_mibibytes() {
3291		let mut guard = OutputBacklogGuard::default();
3292		assert!(!guard.queue(MAX_OUTPUT_BACKLOG_BYTES - 1));
3293		assert!(!guard.queue(1));
3294		assert!(guard.queue(1));
3295		guard.flushed();
3296		assert!(!guard.queue(1));
3297	}
3298	#[test]
3299	fn hyperlink_capability_materializes_only_the_link_label() {
3300		let target = "https://example.test/docs";
3301		let link_style = Style::new().underline().link(target);
3302		let id = link_style.link.expect("non-empty URL is interned").get();
3303		let mut frame = Frame::new(Size::new(12, 1));
3304		frame.put(0, 0, "go ", Style::new());
3305		frame.put(3, 0, "label", link_style);
3306		frame.put(8, 0, " end", Style::new());
3307
3308		let mut renderer = Renderer::new(Vec::new());
3309		renderer.set_hyperlinks(true);
3310		renderer
3311			.present(frame, 1, 0)
3312			.expect("hyperlinked frame paints");
3313		let output = String::from_utf8(renderer.writer_mut().clone()).expect("ANSI is UTF-8");
3314		let linked = format!("\x1b]8;id={id};{target}\x1b\\label\x1b]8;;\x1b\\");
3315		assert!(output.contains(&linked), "{output:?}");
3316		assert_eq!(output.matches("\x1b]8;id=").count(), 1);
3317		assert_eq!(output.matches("\x1b]8;;\x1b\\").count(), 1);
3318	}
3319
3320	#[test]
3321	fn disabled_hyperlinks_are_byte_identical_to_plain_styled_cells() {
3322		let mut linked = Frame::new(Size::new(8, 1));
3323		linked.put(0, 0, "label", Style::new().underline().link("https://example.test"));
3324		let mut plain = Frame::new(Size::new(8, 1));
3325		plain.put(0, 0, "label", Style::new().underline());
3326
3327		let mut linked_renderer = Renderer::new(Vec::new());
3328		let mut plain_renderer = Renderer::new(Vec::new());
3329		linked_renderer
3330			.present(linked, 1, 0)
3331			.expect("disabled hyperlink frame paints");
3332		plain_renderer
3333			.present(plain, 1, 0)
3334			.expect("plain frame paints");
3335		assert_eq!(linked_renderer.writer_mut(), plain_renderer.writer_mut());
3336	}
3337
3338	#[test]
3339	fn iterm2_graphics_dispatches_registered_png_post_pass() {
3340		let mut frame = Frame::new(Size::new(2, 1));
3341		for col in 0..2 {
3342			frame.put_image_cell(col, 0, 7, 0, col, 1, 2);
3343		}
3344		let mut renderer = Renderer::new(Vec::new());
3345		renderer.set_graphics(Graphics::Iterm2);
3346		renderer
3347			.register_image(7, b"\x89PNG\r\n\x1a\nsmall".to_vec())
3348			.expect("image registration succeeds");
3349		renderer.present(frame, 1, 0).expect("iTerm2 frame paints");
3350		let output = String::from_utf8(renderer.writer_mut().clone()).expect("ANSI is UTF-8");
3351		assert!(output.contains("\x1b]1337;File=inline=1;"));
3352	}
3353
3354	#[test]
3355	fn disabled_synchronized_output_only_removes_wrappers_from_every_paint_path() {
3356		let initial = document(&["one", "two", "three"]);
3357		let changed = document(&["one", "TWO", "three"]);
3358		let mut synchronized = Renderer::new(Vec::new());
3359		let mut plain = Renderer::new(Vec::new());
3360		plain.set_sync_output(false);
3361
3362		synchronized
3363			.present(initial.clone(), 2, 1)
3364			.expect("synchronized full paint succeeds");
3365		plain
3366			.present(initial, 2, 1)
3367			.expect("plain full paint succeeds");
3368		let synchronized_output =
3369			String::from_utf8(std::mem::take(synchronized.writer_mut())).expect("ANSI is UTF-8");
3370		let plain_output =
3371			String::from_utf8(std::mem::take(plain.writer_mut())).expect("ANSI is UTF-8");
3372		assert_eq!(without_sync_markers(&synchronized_output), plain_output);
3373		assert!(!plain_output.contains(SYNC_OUTPUT_BEGIN));
3374		assert!(!plain_output.contains(SYNC_OUTPUT_END));
3375
3376		synchronized.writer_mut().clear();
3377		plain.writer_mut().clear();
3378		synchronized
3379			.present(changed.clone(), 2, 1)
3380			.expect("synchronized incremental paint succeeds");
3381		plain
3382			.present(changed.clone(), 2, 1)
3383			.expect("plain incremental paint succeeds");
3384		let synchronized_output =
3385			String::from_utf8(synchronized.writer_mut().clone()).expect("ANSI is UTF-8");
3386		let plain_output = String::from_utf8(plain.writer_mut().clone()).expect("ANSI is UTF-8");
3387		assert_eq!(without_sync_markers(&synchronized_output), plain_output);
3388		assert!(!plain_output.contains(SYNC_OUTPUT_BEGIN));
3389		assert!(!plain_output.contains(SYNC_OUTPUT_END));
3390
3391		synchronized.writer_mut().clear();
3392		plain.writer_mut().clear();
3393		synchronized
3394			.preview(&changed, 2, "\x1b[?1049h")
3395			.expect("synchronized preview succeeds");
3396		plain
3397			.preview(&changed, 2, "\x1b[?1049h")
3398			.expect("plain preview succeeds");
3399		let synchronized_output =
3400			String::from_utf8(synchronized.writer_mut().clone()).expect("ANSI is UTF-8");
3401		let plain_output = String::from_utf8(plain.writer_mut().clone()).expect("ANSI is UTF-8");
3402		assert_eq!(without_sync_markers(&synchronized_output), plain_output);
3403		assert!(!plain_output.contains(SYNC_OUTPUT_BEGIN));
3404		assert!(!plain_output.contains(SYNC_OUTPUT_END));
3405	}
3406
3407	#[test]
3408	fn screen_to_scrollback_precedes_viewport_clear_only_when_enabled() {
3409		let mut ordinary = Renderer::new(Vec::new());
3410		ordinary
3411			.present(document(&["one", "two"]), 2, 0)
3412			.expect("ordinary paint succeeds");
3413		let ordinary_output =
3414			String::from_utf8(ordinary.writer_mut().clone()).expect("ANSI is UTF-8");
3415		assert!(!ordinary_output.contains("\x1b[22J"));
3416
3417		let mut preserving = Renderer::new(Vec::new());
3418		preserving.set_screen_to_scrollback(true);
3419		preserving
3420			.present(document(&["one", "two"]), 2, 0)
3421			.expect("scrollback-preserving paint succeeds");
3422		let preserving_output =
3423			String::from_utf8(preserving.writer_mut().clone()).expect("ANSI is UTF-8");
3424		assert!(preserving_output.contains("\x1b[22J\x1b[2J\x1b[H"));
3425	}
3426
3427	#[test]
3428	fn soft_wrapped_rows_join_on_screen_and_in_history() {
3429		let mut renderer = Renderer::new(Vec::new());
3430		let mut terminal = TerminalModel::new(8, 3);
3431
3432		// "abcdefgh" fills the row exactly and continues mid-word on "ij".
3433		renderer
3434			.present(soft_document(&["abcdefgh", "ij", "tail"], &[0]), 3, 0)
3435			.expect("initial paint succeeds");
3436		apply_paint(&mut renderer, &mut terminal);
3437		assert!(terminal.row_wrapped(1), "the continuation row carries the wrap attribute");
3438		assert_eq!(terminal.visible_rows(), ["abcdefgh", "ij", "tail"]);
3439
3440		// Scrolling the pair into native scrollback keeps the join: copy
3441		// reads one unbroken line.
3442		renderer
3443			.present(soft_document(&["abcdefgh", "ij", "tail", "x", "y"], &[0]), 3, 4)
3444			.expect("growth paint succeeds");
3445		apply_paint(&mut renderer, &mut terminal);
3446		assert_eq!(terminal.history, ["abcdefghij"]);
3447		assert_eq!(terminal.visible_rows(), ["tail", "x", "y"]);
3448	}
3449
3450	#[test]
3451	fn scroll_append_arms_joins_for_committed_pairs() {
3452		let mut renderer = Renderer::new(Vec::new());
3453		let mut terminal = TerminalModel::new(8, 3);
3454		renderer
3455			.present(document(&["one", "two", "three"]), 3, 3)
3456			.expect("initial paint succeeds");
3457		apply_paint(&mut renderer, &mut terminal);
3458
3459		renderer
3460			.present(soft_document(&["one", "two", "three", "abcdefgh", "ij"], &[3]), 3, 5)
3461			.expect("scroll paint succeeds");
3462		let output = String::from_utf8(std::mem::take(renderer.writer_mut())).expect("UTF-8");
3463		terminal.apply(&output);
3464		assert!(output.contains("\x1b[?7h"), "committing a joined pair enables autowrap");
3465		assert!(output.contains("\x1b[?7l"), "autowrap is restored after the commit");
3466		assert_eq!(terminal.history, ["one", "two"]);
3467		assert_eq!(terminal.visible_rows(), ["three", "abcdefgh", "ij"]);
3468		assert!(terminal.row_wrapped(2), "the committed continuation soft-wraps on screen");
3469
3470		renderer
3471			.present(soft_document(&["one", "two", "three", "abcdefgh", "ij", "z", "w"], &[3]), 3, 7)
3472			.expect("second growth succeeds");
3473		apply_paint(&mut renderer, &mut terminal);
3474		assert_eq!(terminal.history, ["one", "two", "three", "abcdefgh"]);
3475
3476		renderer
3477			.present(
3478				soft_document(&["one", "two", "three", "abcdefgh", "ij", "z", "w", "v", "u"], &[3]),
3479				3,
3480				9,
3481			)
3482			.expect("third growth succeeds");
3483		apply_paint(&mut renderer, &mut terminal);
3484		assert_eq!(
3485			terminal.history,
3486			["one", "two", "three", "abcdefghij", "z"],
3487			"the soft pair merges as it scrolls into history"
3488		);
3489		assert_eq!(terminal.visible_rows(), ["w", "v", "u"]);
3490	}
3491
3492	#[test]
3493	fn wrap_boundary_flips_reconcile_in_place() {
3494		let mut renderer = Renderer::new(Vec::new());
3495		let mut terminal = TerminalModel::new(8, 2);
3496		renderer
3497			.present(soft_document(&["abcdefgh", "ij"], &[0]), 2, 0)
3498			.expect("initial paint succeeds");
3499		apply_paint(&mut renderer, &mut terminal);
3500		assert!(terminal.row_wrapped(1));
3501
3502		// Same cells, hard boundary: both rows are erased and re-printed
3503		// in place — never through a viewport clear, which would push
3504		// duplicated history on scrollback-preserving terminals.
3505		renderer
3506			.present(document(&["abcdefgh", "ij"]), 2, 0)
3507			.expect("hardening paint succeeds");
3508		let output = String::from_utf8(std::mem::take(renderer.writer_mut())).expect("UTF-8");
3509		terminal.apply(&output);
3510		assert!(!output.contains("\x1b[H"), "reconciliation never clears the viewport");
3511		assert!(output.contains("\x1b[2K"), "the stale soft rows are erased in place");
3512		assert!(!terminal.row_wrapped(1), "the boundary reads hard again");
3513		assert_eq!(terminal.visible_rows(), ["abcdefgh", "ij"]);
3514
3515		// Flagging it again re-arms the join without a repaint of the
3516		// rest of the viewport.
3517		renderer
3518			.present(soft_document(&["abcdefgh", "ij"], &[0]), 2, 0)
3519			.expect("softening paint succeeds");
3520		apply_paint(&mut renderer, &mut terminal);
3521		assert!(terminal.row_wrapped(1), "the boundary soft-wraps again");
3522		assert_eq!(terminal.visible_rows(), ["abcdefgh", "ij"]);
3523	}
3524
3525	#[test]
3526	fn padded_rows_never_join() {
3527		let mut renderer = Renderer::new(Vec::new());
3528		let mut terminal = TerminalModel::new(8, 2);
3529		// "one" leaves blank padding before the margin, so joining would
3530		// inject those cells into the copied text: the flag is ignored.
3531		let stats = renderer
3532			.present(soft_document(&["one", "two"], &[0]), 2, 0)
3533			.expect("paint succeeds");
3534		let output = String::from_utf8(std::mem::take(renderer.writer_mut())).expect("UTF-8");
3535		terminal.apply(&output);
3536		assert!(!output.contains("\x1b[?7h"));
3537		assert!(!terminal.row_wrapped(1));
3538		assert!(!stats.full_repaint || stats.committed_rows == 0);
3539
3540		// Re-presenting without the flag is not a boundary flip either.
3541		let mut second = Renderer::new(Vec::new());
3542		second
3543			.present(soft_document(&["one", "two"], &[0]), 2, 0)
3544			.expect("paint succeeds");
3545		second.writer_mut().clear();
3546		let stats = second
3547			.present(document(&["one", "two"]), 2, 0)
3548			.expect("unflagged repaint succeeds");
3549		assert_eq!(stats.runs, 0, "an unjoinable flag change emits nothing");
3550	}
3551	#[test]
3552	fn repeated_seam_advances_preserve_one_ordered_history_copy() {
3553		let mut renderer = Renderer::new(Vec::new());
3554		let mut terminal = TerminalModel::new(8, 3);
3555
3556		renderer
3557			.present(document(&["row00", "row01", "work02", "work03", "footer"]), 3, 2)
3558			.expect("initial paint succeeds");
3559		apply_paint(&mut renderer, &mut terminal);
3560
3561		renderer
3562			.present(document(&["row00", "row01", "row02", "work03", "work04", "footer"]), 3, 3)
3563			.expect("first seam advance succeeds");
3564		apply_paint(&mut renderer, &mut terminal);
3565
3566		renderer
3567			.present(
3568				document(&["row00", "row01", "row02", "row03", "work04", "work05", "footer"]),
3569				3,
3570				4,
3571			)
3572			.expect("second seam advance succeeds");
3573		apply_paint(&mut renderer, &mut terminal);
3574
3575		renderer
3576			.present(
3577				document(&["row00", "row01", "row02", "row03", "row04", "work05", "work06", "footer"]),
3578				3,
3579				5,
3580			)
3581			.expect("third seam advance succeeds");
3582		apply_paint(&mut renderer, &mut terminal);
3583
3584		assert_eq!(terminal.history, ["row00", "row01", "row02", "row03", "row04"]);
3585		assert_eq!(terminal.visible_rows(), ["work05", "work06", "footer"]);
3586	}
3587
3588	#[test]
3589	fn initial_paint_commits_only_stable_overflow() {
3590		let mut renderer = Renderer::new(Vec::new());
3591
3592		let stats = renderer
3593			.present(document(&["one", "two", "three", "four"]), 2, 1)
3594			.expect("paint succeeds");
3595
3596		assert!(stats.full_repaint);
3597		assert_eq!(stats.committed_rows, 1);
3598		assert_eq!(stats.clipped_rows, 1);
3599		assert_eq!(renderer.committed_rows(), 1);
3600	}
3601
3602	#[test]
3603	fn clipped_stable_growth_is_deferred_without_replay() {
3604		let mut renderer = Renderer::new(Vec::new());
3605		let mut terminal = TerminalModel::new(8, 2);
3606		renderer
3607			.present(document(&["one", "two", "three", "four", "five"]), 2, 1)
3608			.expect("initial paint succeeds");
3609		apply_paint(&mut renderer, &mut terminal);
3610
3611		let stats = renderer
3612			.present(document(&["one", "two", "three", "FOUR", "five", "six", "seven"]), 2, 2)
3613			.expect("clipped stable growth succeeds");
3614		let output = String::from_utf8(renderer.writer_mut().clone()).expect("ANSI is UTF-8");
3615		terminal.apply(&output);
3616
3617		assert_eq!(stats.committed_rows, 0);
3618		assert_eq!(stats.clipped_rows, 4);
3619		assert_eq!(renderer.committed_rows(), 1);
3620		assert_eq!(output.matches("\r\n").count(), 0);
3621		assert_eq!(terminal.history, ["one"]);
3622		assert_eq!(terminal.visible_rows(), ["six", "seven"]);
3623
3624		renderer.writer_mut().clear();
3625		let error = renderer
3626			.present(document(&["one", "TWO", "three", "FOUR", "five", "six", "seven"]), 2, 2)
3627			.expect_err("deferred stable rows remain immutable");
3628		assert_eq!(error.kind(), ErrorKind::InvalidData);
3629		assert_eq!(renderer.writer_mut().as_slice(), &[] as &[u8]);
3630	}
3631
3632	#[test]
3633	fn visible_mutation_uses_relative_cursor_without_scrolling() {
3634		let mut renderer = Renderer::new(Vec::new());
3635		renderer
3636			.present(document(&["one", "two", "three", "four"]), 2, 2)
3637			.expect("first paint succeeds");
3638		renderer.writer_mut().clear();
3639
3640		let stats = renderer
3641			.present(document(&["one", "two", "THREE", "four"]), 2, 2)
3642			.expect("diff succeeds");
3643		let output = String::from_utf8(renderer.writer_mut().clone()).expect("ANSI is UTF-8");
3644
3645		assert_eq!(stats.committed_rows, 0);
3646		assert!(stats.changed_cells > 0);
3647		assert!(output.contains("\x1b[1A\r"));
3648		assert!(output.contains("\x1b[1B\r"));
3649		assert!(!output.contains("\r\n"));
3650		assert!(!output.contains("\x1b[1;"));
3651		assert!(!output.contains("\x1b[2;"));
3652		assert!(!output.contains("\x1b[2J"));
3653		assert!(!output.contains("\x1b[3J"));
3654	}
3655
3656	#[test]
3657	fn committed_mutation_is_rejected_without_output() {
3658		let mut renderer = Renderer::new(Vec::new());
3659		renderer
3660			.present(document(&["one", "two", "three", "four"]), 2, 2)
3661			.expect("first paint succeeds");
3662		renderer.writer_mut().clear();
3663
3664		let error = renderer
3665			.present(document(&["ONE", "two", "three", "four"]), 2, 2)
3666			.expect_err("stable mutation must fail");
3667
3668		assert_eq!(error.kind(), ErrorKind::InvalidData);
3669		assert_eq!(renderer.writer_mut().as_slice(), &[] as &[u8]);
3670		assert_eq!(renderer.committed_rows(), 2);
3671	}
3672
3673	#[test]
3674	fn damaged_stable_mutation_is_rejected_without_output() {
3675		let mut renderer = Renderer::new(Vec::new());
3676		let initial = document(&["one", "two", "three", "four"]);
3677		renderer
3678			.present_damaged(&initial, &[(0, 4)], 2, 2)
3679			.expect("first paint succeeds");
3680		renderer.writer_mut().clear();
3681
3682		let changed = document(&["ONE", "two", "three", "four"]);
3683		let error = renderer
3684			.present_damaged(&changed, &[(0, 1)], 2, 2)
3685			.expect_err("reported stable mutation must fail");
3686
3687		assert_eq!(error.kind(), ErrorKind::InvalidData);
3688		assert_eq!(renderer.writer_mut().as_slice(), &[] as &[u8]);
3689		assert_eq!(renderer.committed_rows(), 2);
3690	}
3691
3692	#[test]
3693	fn seam_advance_corrects_final_row_then_scrolls_once() {
3694		let mut renderer = Renderer::new(Vec::new());
3695		let mut terminal = TerminalModel::new(8, 2);
3696		renderer
3697			.present(document(&["one", "two", "three", "four"]), 2, 2)
3698			.expect("first paint succeeds");
3699		apply_paint(&mut renderer, &mut terminal);
3700		let stats = renderer
3701			.present(document(&["one", "two", "THREE", "four", "five"]), 2, 3)
3702			.expect("seam commit succeeds");
3703		let output = String::from_utf8(renderer.writer_mut().clone()).expect("ANSI is UTF-8");
3704
3705		assert_eq!(stats.committed_rows, 1);
3706		assert_eq!(renderer.committed_rows(), 3);
3707		assert_eq!(output.matches("\r\n").count(), 1);
3708		assert!(output.contains(VIEWPORT_BOTTOM));
3709		assert!(output.contains("THREE"));
3710		assert!(output.contains("five"));
3711		terminal.apply(&output);
3712		assert_eq!(terminal.history, ["one", "two", "THREE"]);
3713		assert_eq!(terminal.visible_rows(), ["four", "five"]);
3714		assert!(!output.contains("\x1b[2J"));
3715		assert!(!output.contains("\x1b[3J"));
3716	}
3717
3718	#[test]
3719	fn scroll_append_keeps_adjacent_box_edges_separate() {
3720		let mut renderer = Renderer::new(Vec::new());
3721		let mut terminal = TerminalModel::new(8, 5);
3722		renderer
3723			.present(
3724				document(&["old0", "old1", "╰ live─╯", "", "╭──────╮", "│ body │", "footer"]),
3725				5,
3726				2,
3727			)
3728			.expect("initial paint succeeds");
3729		apply_paint(&mut renderer, &mut terminal);
3730
3731		renderer
3732			.present(
3733				document(&["old0", "old1", "╰──────╯", "", "╭──────╮", "│ body │", "new", "footer"]),
3734				5,
3735				3,
3736			)
3737			.expect("box boundary commit succeeds");
3738		let output = String::from_utf8(renderer.writer_mut().clone()).expect("ANSI is UTF-8");
3739		terminal.apply(&output);
3740
3741		assert_eq!(output.matches("\r\n").count(), 1);
3742		assert!(output.contains(VIEWPORT_BOTTOM));
3743		assert_eq!(terminal.history, ["old0", "old1", "╰──────╯"]);
3744		assert_eq!(terminal.visible_rows(), ["", "╭──────╮", "│ body │", "new", "footer"]);
3745	}
3746
3747	#[test]
3748	fn margin_commit_scrolls_history_without_touching_pinned_rows() {
3749		let mut renderer = Renderer::new(Vec::new());
3750		renderer.set_margin_scrollback(true);
3751		let mut terminal = TerminalModel::new(8, 4);
3752		renderer
3753			.present(document(&["old0", "old1", "work2", "editor", "footer"]), 4, 2)
3754			.expect("initial paint succeeds");
3755		apply_paint(&mut renderer, &mut terminal);
3756
3757		let stats = renderer
3758			.present(document(&["old0", "old1", "row2", "row3", "editor", "footer"]), 4, 4)
3759			.expect("margin commit succeeds");
3760		let output = String::from_utf8(renderer.writer_mut().clone()).expect("ANSI is UTF-8");
3761		terminal.apply(&output);
3762
3763		assert_eq!(stats.committed_rows, 1);
3764		assert!(output.contains("\x1b[1;2r"));
3765		assert!(output.contains("\x1b[r"));
3766		assert_eq!(output.matches("\r\n").count(), 1);
3767		assert!(
3768			!output.contains("editor") && !output.contains("footer"),
3769			"pinned rows must never be re-emitted"
3770		);
3771		assert_eq!(terminal.history, ["old0", "old1"]);
3772		assert_eq!(terminal.visible_rows(), ["row2", "row3", "editor", "footer"]);
3773	}
3774
3775	#[test]
3776	fn margin_commit_matches_whole_screen_scroll_end_state() {
3777		let mut margin = Renderer::new(Vec::new());
3778		margin.set_margin_scrollback(true);
3779		let mut plain = Renderer::new(Vec::new());
3780		let mut margin_terminal = TerminalModel::new(8, 4);
3781		let mut plain_terminal = TerminalModel::new(8, 4);
3782
3783		for (renderer, terminal) in
3784			[(&mut margin, &mut margin_terminal), (&mut plain, &mut plain_terminal)]
3785		{
3786			renderer
3787				.present(document(&["old0", "old1", "work2", "editor", "footer"]), 4, 2)
3788				.expect("initial paint succeeds");
3789			apply_paint(renderer, terminal);
3790			renderer
3791				.present(document(&["old0", "old1", "row2", "row3", "editor", "footer"]), 4, 4)
3792				.expect("commit succeeds");
3793			apply_paint(renderer, terminal);
3794		}
3795
3796		assert_eq!(margin_terminal.history, plain_terminal.history);
3797		assert_eq!(margin_terminal.visible_rows(), plain_terminal.visible_rows());
3798	}
3799
3800	#[test]
3801	fn margin_commit_repaints_changed_live_rows_in_place() {
3802		let mut renderer = Renderer::new(Vec::new());
3803		renderer.set_margin_scrollback(true);
3804		let mut terminal = TerminalModel::new(8, 4);
3805		renderer
3806			.present(document(&["old0", "old1", "work2", "spin0", "footer"]), 4, 2)
3807			.expect("initial paint succeeds");
3808		apply_paint(&mut renderer, &mut terminal);
3809
3810		renderer
3811			.present(document(&["old0", "old1", "row2", "row3", "pulse", "footer"]), 4, 4)
3812			.expect("margin commit succeeds");
3813		let output = String::from_utf8(renderer.writer_mut().clone()).expect("ANSI is UTF-8");
3814		terminal.apply(&output);
3815
3816		assert!(output.contains("\x1b[1;2r"), "animated live rows must not shrink the pin");
3817		assert!(output.contains("pulse"), "changed live cells repaint in place");
3818		assert!(!output.contains("footer"), "unchanged pinned rows stay untouched");
3819		assert_eq!(terminal.history, ["old0", "old1"]);
3820		assert_eq!(terminal.visible_rows(), ["row2", "row3", "pulse", "footer"]);
3821	}
3822
3823	#[test]
3824	fn margin_commit_falls_back_when_stable_seam_reaches_screen_bottom() {
3825		let mut renderer = Renderer::new(Vec::new());
3826		renderer.set_margin_scrollback(true);
3827		let mut terminal = TerminalModel::new(8, 4);
3828		renderer
3829			.present(document(&["old0", "old1", "work2", "editor", "footer"]), 4, 2)
3830			.expect("initial paint succeeds");
3831		apply_paint(&mut renderer, &mut terminal);
3832
3833		renderer
3834			.present(document(&["old0", "old1", "row2", "row3", "editor", "footer"]), 4, 6)
3835			.expect("fully stable commit succeeds");
3836		let output = String::from_utf8(renderer.writer_mut().clone()).expect("ANSI is UTF-8");
3837		terminal.apply(&output);
3838
3839		assert!(
3840			!output.contains("\x1b[1;"),
3841			"a seam at the screen bottom must use the whole-screen scroll"
3842		);
3843		assert_eq!(terminal.history, ["old0", "old1"]);
3844		assert_eq!(terminal.visible_rows(), ["row2", "row3", "editor", "footer"]);
3845	}
3846
3847	#[test]
3848	fn growing_mutable_suffix_is_clipped_without_committing_snapshots() {
3849		let mut renderer = Renderer::new(Vec::new());
3850		renderer
3851			.present(document(&["one", "two", "three", "four"]), 2, 2)
3852			.expect("first paint succeeds");
3853		renderer.writer_mut().clear();
3854
3855		let stats = renderer
3856			.present(document(&["one", "two", "three", "four", "five"]), 2, 2)
3857			.expect("virtual shift succeeds");
3858		let output = String::from_utf8(renderer.writer_mut().clone()).expect("ANSI is UTF-8");
3859
3860		assert_eq!(stats.committed_rows, 0);
3861		assert_eq!(stats.clipped_rows, 1);
3862		assert_eq!(renderer.committed_rows(), 2);
3863		assert!(stats.changed_cells > 0);
3864		assert!(output.contains("\x1b[1A\r"));
3865		assert!(output.contains("\x1b[1C"));
3866		assert!(!output.contains("\x1b[1;"));
3867		assert!(!output.contains("\x1b[2;"));
3868		assert!(!output.contains("\r\n"));
3869	}
3870
3871	#[test]
3872	fn live_collapse_below_history_is_rejected_until_rebuild() {
3873		let mut renderer = Renderer::new(Vec::new());
3874		renderer
3875			.present(document(&["one", "two", "three", "four"]), 2, 2)
3876			.expect("first paint succeeds");
3877		renderer.writer_mut().clear();
3878
3879		let error = renderer
3880			.present(document(&["one", "two", "three"]), 2, 2)
3881			.expect_err("a document tail shorter than committed history must be rejected");
3882		assert_eq!(error.kind(), ErrorKind::InvalidData);
3883		assert_eq!(renderer.writer_mut().as_slice(), &[] as &[u8]);
3884
3885		renderer
3886			.rebuild(document(&["one", "two", "three"]), 2, 2, "")
3887			.expect("rebuild accepts the shorter document");
3888		let output = String::from_utf8(renderer.writer_mut().clone()).expect("ANSI is UTF-8");
3889		assert!(output.contains("\x1b[3J"));
3890		assert_eq!(renderer.committed_rows(), 1);
3891	}
3892
3893	#[test]
3894	fn stable_boundary_cannot_retreat() {
3895		let mut renderer = Renderer::new(Vec::new());
3896		renderer
3897			.present(document(&["one", "two", "three"]), 2, 2)
3898			.expect("first paint succeeds");
3899		renderer.writer_mut().clear();
3900
3901		let error = renderer
3902			.present(document(&["one", "two", "three"]), 2, 1)
3903			.expect_err("retreat must fail");
3904
3905		assert_eq!(error.kind(), ErrorKind::InvalidData);
3906		assert_eq!(renderer.writer_mut().as_slice(), &[] as &[u8]);
3907	}
3908
3909	#[test]
3910	fn resize_preview_leaves_normal_renderer_state_untouched() {
3911		let mut renderer = Renderer::new(Vec::new());
3912		renderer
3913			.present(document(&["row00", "row01", "work02", "footer"]), 2, 2)
3914			.expect("initial paint succeeds");
3915		renderer.writer_mut().clear();
3916
3917		let preview = document(&["new00", "new01", "live02", "live03", "footer"]);
3918		let stats = renderer
3919			.preview(&preview, 3, "\x1b[?1049h")
3920			.expect("alternate viewport preview succeeds");
3921		let output = String::from_utf8(renderer.writer_mut().clone()).expect("ANSI is UTF-8");
3922
3923		assert!(stats.full_repaint);
3924		assert_eq!(renderer.committed_rows(), 2);
3925		assert!(output.contains("\x1b[?1049h"));
3926		assert!(output.contains("live02"));
3927		assert!(output.contains("footer"));
3928		assert!(!output.contains("new00"));
3929		assert!(!output.contains("\x1b[3J"));
3930
3931		renderer.writer_mut().clear();
3932		let stats = renderer
3933			.present(document(&["row00", "row01", "work02", "footer"]), 2, 2)
3934			.expect("normal state still matches its pre-preview frame");
3935		assert_eq!(stats.bytes, 0);
3936	}
3937
3938	#[test]
3939	fn settled_resize_clears_and_rebuilds_history_once() {
3940		let mut renderer = Renderer::new(Vec::new());
3941		let mut terminal = TerminalModel::new(8, 3);
3942		renderer
3943			.present(document(&["old00", "old01", "old02", "old03", "old04"]), 3, 4)
3944			.expect("initial paint succeeds");
3945		apply_paint(&mut renderer, &mut terminal);
3946		assert_eq!(terminal.history, ["old00", "old01"]);
3947
3948		terminal.resize(8, 2);
3949		let stats = renderer
3950			.rebuild(document(&["new00", "new01", "new02", "live03", "footer"]), 2, 3, "\x1b[?1049l")
3951			.expect("settled resize rebuild succeeds");
3952		let output = String::from_utf8(renderer.writer_mut().clone()).expect("ANSI is UTF-8");
3953
3954		let sync = output.find(SYNC_OUTPUT_BEGIN).expect("synchronized paint");
3955		let alt_exit = output.find("\x1b[?1049l").expect("alternate-buffer exit");
3956		let clear = output.find(REBUILD_HISTORY).expect("history clear");
3957		assert!(sync < alt_exit && alt_exit < clear);
3958		assert_eq!(output.matches("\x1b[3J").count(), 1);
3959		assert!(!output.contains("\x1b[2J"));
3960		assert!(stats.full_repaint);
3961		assert_eq!(stats.committed_rows, 3);
3962
3963		apply_paint(&mut renderer, &mut terminal);
3964		assert_eq!(terminal.history, ["new00", "new01", "new02"]);
3965		assert_eq!(terminal.visible_rows(), ["live03", "footer"]);
3966
3967		let stats = renderer
3968			.present(document(&["new00", "new01", "new02", "live03", "footer"]), 2, 3)
3969			.expect("incremental rendering resumes from rebuilt state");
3970		assert_eq!(stats.bytes, 0);
3971
3972		let stats = renderer
3973			.present(document(&["new00", "new01", "new02", "new03", "live04", "footer"]), 2, 4)
3974			.expect("immutable seam advances after the rebuild");
3975		let output = String::from_utf8(renderer.writer_mut().clone()).expect("ANSI is UTF-8");
3976		assert_eq!(stats.committed_rows, 1);
3977		assert!(!output.contains("\x1b[3J"));
3978
3979		apply_paint(&mut renderer, &mut terminal);
3980		assert_eq!(terminal.history, ["new00", "new01", "new02", "new03"]);
3981		assert_eq!(terminal.visible_rows(), ["live04", "footer"]);
3982	}
3983
3984	#[test]
3985	fn hardware_cursor_moves_without_repainting_cells() {
3986		let mut renderer = Renderer::new(Vec::new());
3987		let mut first = document(&["one", "two"]);
3988		first.set_cursor(1, 1);
3989		renderer.present(first, 2, 2).expect("first paint succeeds");
3990		renderer.writer_mut().clear();
3991
3992		let mut second = document(&["one", "two"]);
3993		second.set_cursor(4, 0);
3994		let stats = renderer
3995			.present(second, 2, 2)
3996			.expect("cursor move succeeds");
3997		let output = String::from_utf8(renderer.writer_mut().clone()).expect("ANSI is UTF-8");
3998
3999		assert_eq!(stats.changed_cells, 0);
4000		assert_eq!(stats.runs, 0);
4001		assert!(stats.bytes > 0);
4002		assert!(output.contains("\x1b[?25l"));
4003		assert!(output.contains("\x1b[1A\r\x1b[4C\x1b[?25h"));
4004		assert!(!output.contains("\r\n"));
4005		assert!(!output.contains("\x1b[H"));
4006	}
4007
4008	#[test]
4009	fn identical_document_writes_nothing() {
4010		let mut renderer = Renderer::new(Vec::new());
4011		renderer
4012			.present(document(&["one", "two"]), 2, 2)
4013			.expect("first paint succeeds");
4014		renderer.writer_mut().clear();
4015
4016		let stats = renderer
4017			.present(document(&["one", "two"]), 2, 2)
4018			.expect("second paint succeeds");
4019
4020		assert_eq!(stats.bytes, 0);
4021		assert_eq!(renderer.writer_mut().as_slice(), &[] as &[u8]);
4022	}
4023
4024	#[test]
4025	fn kitty_images_upload_place_and_materialize_typed_cells() {
4026		let mut frame = Frame::new(Size::new(3, 2));
4027		frame.put_image_cell(0, 0, 0x12_34_56, 0, 0, 2, 3);
4028		frame.put_image_cell(2, 1, 0x12_34_56, 1, 2, 2, 3);
4029		let mut renderer = Renderer::new(Vec::new());
4030		renderer
4031			.register_image(0x12_34_56, vec![0x5a; 3073])
4032			.unwrap();
4033		renderer.present(frame, 2, 0).unwrap();
4034		let output = String::from_utf8(renderer.into_inner()).unwrap();
4035
4036		// Transmission rides the synchronized paint, after the cursor hide,
4037		// so a staged buffer switch in the leading sequence precedes it.
4038		assert!(output.contains("\x1b_Gf=100,t=d,a=t,i=1193046,q=2,m=1;"));
4039		assert!(output.contains("\x1b_Gm=0;"));
4040		assert!(output.contains("\x1b_Ga=p,U=1,i=1193046,p=1027,r=2,c=3,q=2\x1b\\"));
4041		assert!(output.contains("\u{10eeee}\u{0305}\u{0305}"));
4042		assert!(output.contains("\u{10eeee}\u{030d}\u{030e}"));
4043		// Image ID in the foreground, placement ID (2<<9|3 = 1027) in the
4044		// underline color.
4045		assert!(output.contains("38;2;18;52;86m"));
4046		assert!(output.contains("58:2::0:4:3"));
4047
4048		let packets = output
4049			.split("\x1b\\")
4050			.filter_map(|piece| piece.find("\x1b_G").map(|start| &piece[start..]))
4051			.filter(|packet| packet.contains(';'))
4052			.collect::<Vec<_>>();
4053		assert_eq!(packets.len(), 2);
4054		assert!(
4055			packets
4056				.iter()
4057				.all(|packet| packet.split_once(';').unwrap().1.len() <= 4096)
4058		);
4059	}
4060
4061	#[test]
4062	fn clipped_kitty_image_uses_full_placement_without_scroll_rescaling() {
4063		fn clipped_image(first_row: u16) -> Frame {
4064			let mut frame = Frame::new(Size::new(8, 4));
4065			for row in first_row..4 {
4066				for col in 0..8 {
4067					frame.put_image_cell(col, row, 7, row, col, 4, 8);
4068				}
4069			}
4070			frame
4071		}
4072
4073		let mut renderer = Renderer::new(Vec::new());
4074		renderer.register_image(7, vec![1, 2, 3]).unwrap();
4075		renderer.present(clipped_image(3), 4, 0).unwrap();
4076		let initial = String::from_utf8(std::mem::take(renderer.writer_mut())).unwrap();
4077		assert!(initial.contains("\x1b_Ga=p,U=1,i=7,p=2056,r=4,c=8,q=2\x1b\\"));
4078
4079		for first_row in (0..3).rev() {
4080			renderer.present(clipped_image(first_row), 4, 0).unwrap();
4081			let output = String::from_utf8(std::mem::take(renderer.writer_mut())).unwrap();
4082			assert!(
4083				!output.contains("\x1b_Ga=p"),
4084				"declared 4x8 dimensions must not be re-placed when row {first_row} enters"
4085			);
4086		}
4087	}
4088
4089	#[test]
4090	fn distinct_cell_boxes_of_one_image_place_once_each_with_stable_ids() {
4091		// Two boxes of image 7 in one frame: a 1x2 thumbnail and a 2x4 card.
4092		let mut frame = Frame::new(Size::new(8, 3));
4093		for col in 0..2 {
4094			frame.put_image_cell(col, 0, 7, 0, col, 1, 2);
4095		}
4096		for row in 0..2 {
4097			for col in 0..4 {
4098				frame.put_image_cell(col, 1 + row, 7, row, col, 2, 4);
4099			}
4100		}
4101		let mut renderer = Renderer::new(Vec::new());
4102		renderer
4103			.register_image(7, b"\x89PNG\r\n\x1a\nsmall".to_vec())
4104			.unwrap();
4105		renderer.present(frame.clone(), 3, 0).unwrap();
4106		let output = String::from_utf8(std::mem::take(renderer.writer_mut())).unwrap();
4107		assert_eq!(output.matches("a=t").count(), 1, "one upload serves every box");
4108		assert!(output.contains("\x1b_Ga=p,U=1,i=7,p=514,r=1,c=2,q=2\x1b\\"));
4109		assert!(output.contains("\x1b_Ga=p,U=1,i=7,p=1028,r=2,c=4,q=2\x1b\\"));
4110		// Placeholder cells reference their box's placement via the
4111		// underline color: 514 = 0:2:2, 1028 = 0:4:4.
4112		assert!(output.contains("58:2::0:2:2"));
4113		assert!(output.contains("58:2::0:4:4"));
4114
4115		// Identical re-present: placements are session-cached, never re-sent.
4116		renderer.present(frame, 3, 0).unwrap();
4117		let output = String::from_utf8(std::mem::take(renderer.writer_mut())).unwrap();
4118		assert!(!output.contains("\x1b_G"), "no image traffic on a settled frame: {output:?}");
4119	}
4120
4121	#[test]
4122	fn screen_buffer_switch_retransmits_and_replaces_images() {
4123		// Ghostty stores Kitty images per screen: transmissions and virtual
4124		// placements made on one buffer do not exist on the other. Paints
4125		// resync against the process flag (main under tests), so flipping the
4126		// tracked buffer makes the next paint observe a switch.
4127		let mut frame = Frame::new(Size::new(8, 3));
4128		for col in 0..2 {
4129			frame.put_image_cell(col, 0, 7, 0, col, 1, 2);
4130		}
4131		let mut renderer = Renderer::new(Vec::new());
4132		renderer
4133			.register_image(7, b"\x89PNG\r\n\x1a\nsmall".to_vec())
4134			.unwrap();
4135		renderer.present(frame.clone(), 3, 0).unwrap();
4136		let output = String::from_utf8(std::mem::take(renderer.writer_mut())).unwrap();
4137		assert_eq!(output.matches("a=t").count(), 1, "first paint uploads: {output:?}");
4138
4139		renderer.preview(&frame, 3, "").unwrap();
4140		let output = String::from_utf8(std::mem::take(renderer.writer_mut())).unwrap();
4141		assert!(!output.contains("\x1b_G"), "no retransmit within one buffer: {output:?}");
4142
4143		renderer.set_screen_buffer(true);
4144		renderer.preview(&frame, 3, "").unwrap();
4145		let output = String::from_utf8(std::mem::take(renderer.writer_mut())).unwrap();
4146		assert_eq!(
4147			output.matches("a=t").count(),
4148			1,
4149			"a buffer switch re-uploads to the new screen's store: {output:?}"
4150		);
4151		assert!(
4152			output.contains("\x1b_Ga=p,U=1,i=7,p=514,r=1,c=2,q=2\x1b\\"),
4153			"virtual placements are re-created after a switch: {output:?}"
4154		);
4155
4156		renderer.preview(&frame, 3, "").unwrap();
4157		let output = String::from_utf8(std::mem::take(renderer.writer_mut())).unwrap();
4158		assert!(!output.contains("\x1b_G"), "caches hold once settled: {output:?}");
4159	}
4160
4161	#[test]
4162	fn alt_entry_preview_uploads_overlay_only_images_after_the_switch() {
4163		// The model picker's logos live only in its overlay layer, and its
4164		// alt-screen entry rides the preview's leading sequence: the images
4165		// must be collected from the layers and their Kitty traffic must land
4166		// after `?1049h`, or a per-screen store (ghostty) files them under
4167		// the buffer being left.
4168		let base = document(&["base000", "base111"]);
4169		let mut overlay = Frame::new(Size::new(3, 1));
4170		for col in 0..2 {
4171			overlay.put_image_cell(col, 0, 7, 0, col, 1, 2);
4172		}
4173		let layer = ResolvedLayer {
4174			frame:   &overlay,
4175			x:       0,
4176			y:       0,
4177			src_top: 0,
4178			rows:    1,
4179			active:  false,
4180		};
4181		let mut renderer = Renderer::new(Vec::new());
4182		renderer
4183			.register_image(7, b"\x89PNG\r\n\x1a\nsmall".to_vec())
4184			.unwrap();
4185
4186		renderer
4187			.preview_resolved(&base, &[layer], 2, "\x1b[?1049h")
4188			.unwrap();
4189		let output = String::from_utf8(std::mem::take(renderer.writer_mut())).unwrap();
4190		let switch = output
4191			.find("\x1b[?1049h")
4192			.expect("staged alt entry is emitted");
4193		let upload = output
4194			.find("\x1b_Gf=100,t=d,a=t")
4195			.expect("overlay-only image uploads");
4196		assert!(switch < upload, "upload must follow the buffer switch: {output:?}");
4197		assert!(
4198			output.contains("\x1b_Ga=p,U=1,i=7,p=514,r=1,c=2,q=2\x1b\\"),
4199			"overlay-only image is placed: {output:?}"
4200		);
4201
4202		renderer.preview_resolved(&base, &[layer], 2, "").unwrap();
4203		let output = String::from_utf8(std::mem::take(renderer.writer_mut())).unwrap();
4204		assert!(!output.contains("\x1b_G"), "steady overlay preview stays quiet: {output:?}");
4205
4206		// Leaving the hold flips the tracked buffer again: the exit
4207		// retransmission must follow `?1049l` for the same reason.
4208		renderer.set_screen_buffer(true);
4209		renderer
4210			.preview_resolved(&base, &[layer], 2, "\x1b[?1049l")
4211			.unwrap();
4212		let output = String::from_utf8(std::mem::take(renderer.writer_mut())).unwrap();
4213		let switch = output
4214			.find("\x1b[?1049l")
4215			.expect("staged alt exit is emitted");
4216		let upload = output
4217			.find("\x1b_Gf=100,t=d,a=t")
4218			.expect("the other buffer needs its own upload");
4219		assert!(switch < upload, "re-upload must follow the buffer switch: {output:?}");
4220	}
4221
4222	#[test]
4223	fn staged_buffer_switch_precedes_image_uploads() {
4224		// Per-screen image stores only keep what arrives on the active
4225		// screen, so a staged `1049h`/`1049l` must hit the wire before any
4226		// Kitty upload in the same paint.
4227		let mut frame = Frame::new(Size::new(8, 3));
4228		for col in 0..2 {
4229			frame.put_image_cell(col, 0, 7, 0, col, 1, 2);
4230		}
4231		let mut renderer = Renderer::new(Vec::new());
4232		renderer
4233			.register_image(7, b"\x89PNG\r\n\x1a\nsmall".to_vec())
4234			.unwrap();
4235		renderer.present(frame.clone(), 3, 0).unwrap();
4236		renderer.writer_mut().clear();
4237
4238		renderer.set_screen_buffer(true);
4239		renderer.preview(&frame, 3, "\x1b[?1049h").unwrap();
4240		let output = String::from_utf8(std::mem::take(renderer.writer_mut())).unwrap();
4241		let switch = output.find("\x1b[?1049h").expect("staged entry is emitted");
4242		let upload = output.find("a=t").expect("the new screen needs an upload");
4243		assert!(switch < upload, "upload lands on the freshly entered screen: {output:?}");
4244
4245		// Paints resync the tracked buffer to the process flag (main under
4246		// tests), so a fresh flip is needed to observe the exit switch.
4247		renderer.set_screen_buffer(true);
4248		renderer.rebuild(frame, 3, 0, "\x1b[?1049l").unwrap();
4249		let output = String::from_utf8(std::mem::take(renderer.writer_mut())).unwrap();
4250		let switch = output.find("\x1b[?1049l").expect("staged exit is emitted");
4251		let upload = output
4252			.find("a=t")
4253			.expect("the restored screen needs an upload");
4254		assert!(switch < upload, "upload lands on the restored screen: {output:?}");
4255		let clear = output
4256			.find(REBUILD_HISTORY)
4257			.expect("history clear is emitted");
4258		assert!(clear < upload, "upload must follow the history clear: {output:?}");
4259	}
4260	#[test]
4261	fn kitty_direct_crops_replaces_without_retransmit_and_deletes_offscreen() {
4262		fn png_fixture() -> Vec<u8> {
4263			let mut bytes = Vec::new();
4264			{
4265				let mut encoder = png::Encoder::new(&mut bytes, 2, 4);
4266				encoder.set_color(png::ColorType::Rgb);
4267				encoder.set_depth(png::BitDepth::Eight);
4268				let mut writer = encoder.write_header().unwrap();
4269				writer.write_image_data(&[0x7f; 24]).unwrap();
4270			}
4271			bytes
4272		}
4273
4274		fn image_frame(top: u16) -> Frame {
4275			let mut frame = Frame::new(Size::new(4, 6));
4276			for row in 0..4 {
4277				let y = top + row;
4278				if y >= frame.size().height {
4279					break;
4280				}
4281				frame.put(0, y, "L", Style::default());
4282				for col in 0..2 {
4283					frame.put_image_cell(1 + col, y, 7, row, col, 4, 2);
4284				}
4285				frame.put(3, y, "R", Style::default());
4286			}
4287			frame
4288		}
4289
4290		let mut renderer = Renderer::new(Vec::new());
4291		renderer.set_graphics(Graphics::KittyDirect);
4292		renderer.set_cell_pixel_size(1, 1).unwrap();
4293		renderer.register_image(7, png_fixture()).unwrap();
4294
4295		renderer.present(image_frame(0), 4, 0).unwrap();
4296		let clipped = String::from_utf8(std::mem::take(renderer.writer_mut())).unwrap();
4297		assert_eq!(clipped.matches("a=t").count(), 1);
4298		assert!(
4299			clipped
4300				.contains("\x1b[3A\r\x1b[1C\x1b_Ga=p,q=2,C=1,i=7,p=7,x=0,y=2,w=2,h=2,c=2,r=2\x1b\\")
4301		);
4302		assert!(clipped.contains("L  R"));
4303		assert!(!clipped.contains("\u{10eeee}"));
4304
4305		renderer.present(image_frame(2), 4, 0).unwrap();
4306		let fully_visible = String::from_utf8(std::mem::take(renderer.writer_mut())).unwrap();
4307		assert!(!fully_visible.contains("a=t"));
4308		assert!(
4309			fully_visible
4310				.contains("\x1b[3A\r\x1b[1C\x1b_Ga=p,q=2,C=1,i=7,p=7,x=0,y=0,w=2,h=4,c=2,r=4\x1b\\")
4311		);
4312
4313		renderer.present(Frame::new(Size::new(4, 6)), 4, 0).unwrap();
4314		let offscreen = String::from_utf8(std::mem::take(renderer.writer_mut())).unwrap();
4315		assert!(offscreen.contains("\x1b_Ga=d,d=I,i=7,q=2\x1b\\"));
4316	}
4317
4318	#[test]
4319	fn tmux_passthrough_wraps_kitty_packets_but_not_text_sgr() {
4320		let mut frame = Frame::new(Size::new(2, 1));
4321		frame.put_image_cell(0, 0, 7, 0, 0, 1, 1);
4322		frame.put(1, 0, "X", Style::new().fg(Color::Rgb(1, 2, 3)));
4323		let mut renderer = Renderer::new(Vec::new());
4324		renderer.set_graphics(Graphics::KittyDirect);
4325		renderer.set_tmux_passthrough(true);
4326		renderer.set_cell_pixel_size(1, 1).unwrap();
4327		renderer.register_image(7, [1, 2, 3]).unwrap();
4328
4329		renderer.present(frame, 1, 0).unwrap();
4330		let output = String::from_utf8(renderer.into_inner()).unwrap();
4331		assert!(
4332			output.contains("\x1bPtmux;\x1b\x1b_Gf=100,t=d,a=t,i=7,q=2,m=0;AQID\x1b\x1b\\\x1b\\")
4333		);
4334		assert!(output.contains(
4335			"\x1bPtmux;\x1b\x1b_Ga=p,q=2,C=1,i=7,p=7,x=0,y=0,w=1,h=1,c=1,r=1\x1b\x1b\\\x1b\\"
4336		));
4337		assert_eq!(output.matches("\x1bPtmux;").count(), output.matches("_G").count());
4338		assert!(output.contains("\x1b[38;2;1;2;3mX"));
4339		assert!(!output.contains("\x1bPtmux;\x1b\x1b[38;2;1;2;3m"));
4340	}
4341
4342	#[test]
4343	fn sixel_images_crop_reemit_and_leave_text_cells_intact() {
4344		fn png_fixture() -> Vec<u8> {
4345			let mut bytes = Vec::new();
4346			{
4347				let mut encoder = png::Encoder::new(&mut bytes, 4, 2);
4348				encoder.set_color(png::ColorType::Rgb);
4349				encoder.set_depth(png::BitDepth::Eight);
4350				let mut writer = encoder.write_header().unwrap();
4351				writer
4352					.write_image_data(&[
4353						255, 0, 0, 255, 0, 0, 0, 0, 255, 0, 0, 255, 255, 0, 0, 255, 0, 0, 0, 0, 255, 0,
4354						0, 255,
4355					])
4356					.unwrap();
4357			}
4358			bytes
4359		}
4360
4361		fn image_frame(top: u16, height: u16) -> Frame {
4362			let mut frame = Frame::new(Size::new(10, height));
4363			for row in 0..4 {
4364				let y = top + row;
4365				if y >= frame.size().height {
4366					break;
4367				}
4368				frame.put(0, y, "L", Style::default());
4369				for col in 0..8 {
4370					frame.put_image_cell(1 + col, y, 7, row, col, 4, 8);
4371				}
4372				frame.put(9, y, "R", Style::default());
4373			}
4374			frame
4375		}
4376
4377		let mut renderer = Renderer::new(Vec::new());
4378		renderer.set_graphics(Graphics::Sixel);
4379		renderer.set_cell_pixel_size(1, 1).unwrap();
4380		renderer.register_image(7, png_fixture()).unwrap();
4381
4382		renderer.present(image_frame(0, 6), 4, 0).unwrap();
4383		let clipped = String::from_utf8(std::mem::take(renderer.writer_mut())).unwrap();
4384		assert!(clipped.contains("\x1b[3A\r\x1b[1C\x1bP0;1;0q"));
4385		assert!(clipped.contains("\"1;1;8;2"));
4386		assert!(!clipped.contains("\x1b_G"));
4387		assert!(clipped.contains("L        R"));
4388
4389		let fully_visible = image_frame(4, 8);
4390		renderer.present(fully_visible.clone(), 4, 0).unwrap();
4391		let moved = String::from_utf8(std::mem::take(renderer.writer_mut())).unwrap();
4392		assert!(moved.contains("\x1b[3A\r\x1b[1C\x1bP0;1;0q"));
4393		assert!(moved.contains("\"1;1;8;4"));
4394		assert!(!moved.contains("\x1b_G"));
4395		assert!(moved.contains("L        R"));
4396
4397		let stats = renderer.present(fully_visible, 4, 0).unwrap();
4398		assert_eq!(stats.bytes, 0);
4399		assert_eq!(renderer.writer_mut().as_slice(), &[] as &[u8]);
4400
4401		let mut tmux = Renderer::new(Vec::new());
4402		tmux.set_graphics(Graphics::Sixel);
4403		tmux.set_tmux_passthrough(true);
4404		tmux.set_cell_pixel_size(1, 1).unwrap();
4405		tmux.register_image(7, png_fixture()).unwrap();
4406		tmux.present(image_frame(0, 4), 4, 0).unwrap();
4407		let wrapped = String::from_utf8(tmux.into_inner()).unwrap();
4408		assert!(wrapped.contains("\x1bPtmux;\x1b\x1bP0;1;0q"));
4409		assert!(wrapped.contains("\x1b\x1b\\\x1b\\"));
4410		assert!(!wrapped.contains("\x1b_G"));
4411	}
4412}