Skip to main content

Renderer

Struct Renderer 

Source
pub struct Renderer<W: Write> { /* private fields */ }
Expand description

Renders an immutable document prefix and a mutable viewport-local suffix.

stable_rows declares the leading rows that will never change again. Stable rows enter native scrollback only when they leave the visible top edge. Already-clipped stable rows remain protected but deferred until a rebuild, avoiding a viewport replay that would displace native text selections. A committed or previously declared-stable mutation is rejected before any terminal output, because native scrollback has no addressable cells. The retained physical screen model composes the raw previous frame with its stored viewport layers.

Implementations§

Source§

impl<W: Write> Renderer<W>

Source

pub fn new(writer: W) -> Self

Creates a renderer whose first document clears only the visible viewport.

Examples found in repository?
examples/footers.rs (line 57)
53async fn main() -> io::Result<()> {
54	let caps = detect();
55	let charset = UiContext::default().with_terminal_caps(&caps).charset;
56	let mut terminal = Terminal::enter(TerminalOptions::new(caps).mouse(true))?;
57	let mut renderer = Renderer::new(TtyOut::new()?);
58	renderer.apply_caps(&caps)?;
59	match run(&mut terminal, &mut renderer, charset).await {
60		Ok(()) => terminal.leave_alt(),
61		Err(error) => {
62			let _ = terminal.leave_alt();
63			Err(error)
64		},
65	}
66}
More examples
Hide additional examples
examples/chat/main.rs (line 60)
53async fn main() -> io::Result<()> {
54	let caps = detect();
55	// One detected presentation context — charset, graphics, appearance —
56	// threads through every scene instead of per-module hardcodes.
57	let ctx = UiContext::default().with_terminal_caps(&caps);
58	let mut terminal =
59		Terminal::enter(TerminalOptions::new(caps).cursor_style(CursorStyle::BlinkingBar))?;
60	let mut renderer = Renderer::new(TtyOut::new()?);
61	renderer.apply_caps(&caps)?;
62	run(&mut terminal, &mut renderer, &ctx).await
63}
Source

pub fn apply_caps(&mut self, caps: &TerminalCaps) -> Result<()>

Configures every capability-driven renderer option from resolved caps.

§Errors

Rejects zero cell-pixel dimensions.

Examples found in repository?
examples/footers.rs (line 58)
53async fn main() -> io::Result<()> {
54	let caps = detect();
55	let charset = UiContext::default().with_terminal_caps(&caps).charset;
56	let mut terminal = Terminal::enter(TerminalOptions::new(caps).mouse(true))?;
57	let mut renderer = Renderer::new(TtyOut::new()?);
58	renderer.apply_caps(&caps)?;
59	match run(&mut terminal, &mut renderer, charset).await {
60		Ok(()) => terminal.leave_alt(),
61		Err(error) => {
62			let _ = terminal.leave_alt();
63			Err(error)
64		},
65	}
66}
More examples
Hide additional examples
examples/chat/main.rs (line 61)
53async fn main() -> io::Result<()> {
54	let caps = detect();
55	// One detected presentation context — charset, graphics, appearance —
56	// threads through every scene instead of per-module hardcodes.
57	let ctx = UiContext::default().with_terminal_caps(&caps);
58	let mut terminal =
59		Terminal::enter(TerminalOptions::new(caps).cursor_style(CursorStyle::BlinkingBar))?;
60	let mut renderer = Renderer::new(TtyOut::new()?);
61	renderer.apply_caps(&caps)?;
62	run(&mut terminal, &mut renderer, &ctx).await
63}
Source

pub fn register_image( &mut self, id: u32, png_bytes: impl Into<CowBytes<'static>>, ) -> Result<()>

Registers PNG bytes for a typed terminal image ID.

Protocol encoding is deferred until a presented frame references the ID. Re-registering an ID replaces its bytes and protocol cache.

§Errors

Rejects ID zero and IDs wider than Kitty’s 24-bit placeholder encoding.

Examples found in repository?
examples/companies.rs (lines 202-205)
192async fn main() -> io::Result<()> {
193	let mut app = AppOptions::new()
194		.mouse()
195		.probe(Duration::from_millis(150))
196		.graphics_with(forced_from_args)
197		.start(|env| build_ui(env.viewport, env.ctx))
198		.await?;
199	if app.caps().graphics != Graphics::Cells {
200		for (index, provider) in PROVIDERS.iter().enumerate() {
201			let png = tokio::fs::read(format!("{ASSET_DIR}/{}.png", provider.id)).await?;
202			app.renderer_mut().register_image(
203				u32::try_from(index + 1).expect("provider count fits image IDs"),
204				png,
205			)?;
206		}
207		app.ui_mut().invalidate(SCROLL_ID);
208	}
209	show_stats(&mut app, None);
210	let mut chosen: Option<String> = None;
211	while let Some(event) = app.next().await? {
212		match event {
213			AppEvent::Resized(viewport) => {
214				app.ui_mut().set_height(SCROLL_ID, scroll_height(viewport));
215			},
216			AppEvent::Pressed(id) => chosen = Some(id.to_string()),
217			_ => {},
218		}
219		show_stats(&mut app, chosen.as_deref());
220	}
221	Ok(())
222}
Source

pub const fn set_graphics(&mut self, graphics: Graphics)

Selects how typed image cells are materialized.

Set this before the first presentation. Graphics::Cells, Graphics::Sixel, and Graphics::KittyDirect materialize typed cells as ordinary blanks; Graphics::KittyPlaceholders uses Unicode placeholders.

Source

pub const fn set_sync_output(&mut self, enabled: bool)

Enables or disables DEC synchronized-output wrapping.

Wrapping is enabled by default to preserve the renderer’s historical behavior. Capability detection should disable it for unsupported terminals.

Source

pub const fn set_screen_to_scrollback(&mut self, enabled: bool)

Enables or disables moving cleared viewport content to native scrollback.

When enabled, a full viewport clear first emits Kitty’s CSI 22 J extension. It is disabled by default.

Source

pub const fn set_margin_scrollback(&mut self, enabled: bool)

Enables committing scrolled-out rows through a top-anchored DECSTBM region instead of a whole-screen scroll.

Screen rows below the region never move during a commit, and native scrollback receives exactly the same history as a whole-screen scroll. Whether a terminal-native text selection over the pinned rows survives is a separate, terminal-specific property: kitty and Alacritty transform selections correctly on region scrolls; ghostty, iTerm2, and xterm.js leave them anchored to pre-scroll storage rows, so they drift upward — matching what a whole-screen scroll does to selections over stationary live content repainted back into place; WezTerm clears them. Enable this only for terminals that move rows scrolled out of a top-anchored region into native scrollback (see TerminalCaps::margin_scrollback); it is disabled by default.

Enables or disables OSC 8 hyperlink materialization.

Link identities remain attached to frame cells while disabled, but output stays byte-for-byte identical to ordinary styled text.

Source

pub fn set_cell_pixel_size(&mut self, width: u16, height: u16) -> Result<()>

Sets the terminal cell size used to scale sixel placements.

The default is 9 by 18 pixels per cell, matching pi’s nominal terminal metrics. Detection code may override it before presentation.

§Errors

Rejects a zero pixel dimension.

Source

pub const fn set_tmux_passthrough(&mut self, enabled: bool)

Enables tmux DCS passthrough for Kitty and sixel graphics sequences.

Cursor movement, synchronized output, and ordinary text styling remain direct terminal output.

Source

pub fn present( &mut self, next: Frame, viewport_height: u16, stable_rows: u16, ) -> Result<PaintStats>

Paints a logical document with an immutable leading-row boundary.

The caller must disable terminal autowrap and keep terminal geometry fixed while the renderer is active; the renderer itself re-enables DECAWM transiently to join flagged soft-wrap boundaries (see Frame::set_soft_wrap) so native selection and scrollback copy them as one unbroken line. Advancing stable_rows is permanent, and committed history makes the document height a ratchet: between rebuilds the document may only grow, so transient rows (pickers, extra input lines) must be absorbed by the caller rather than shrinking the frame.

§Errors

Rejects zero or changed geometry, a retreating stable boundary, mutation within the prior stable prefix, or a document whose tail shrank below committed history. Writer failure poisons the renderer because its physical state is unknown.

Source

pub fn present_ref( &mut self, next: &Frame, viewport_height: u16, stable_rows: u16, ) -> Result<PaintStats>

Renderer::present without taking the frame: diffs against the retained previous frame, then clone_froms the borrowed one into it — reusing the existing cell allocation instead of copying a whole frame per paint. Cost is still O(grid) cell clones per call; retained callers that track their own damage should prefer Renderer::present_damaged.

§Errors

Same contract as Renderer::present.

Source

pub fn present_overlaid( &mut self, next: &Frame, damaged: &[(u16, u16)], viewport_height: u16, stable_rows: u16, layers: &[Layer<'_>], ) -> Result<PaintStats>

Paints a damaged raw document with declarative viewport-anchored layers.

damaged follows Renderer::present_damaged. Layers composite only into the live viewport while history commits keep flowing: a row leaving the window is repainted from the raw document before it scrolls into native scrollback, so layer cells never reach history. Direct-drawn sixel, Kitty-direct, and iTerm2 images remain raw and are not occluded; Kitty placeholder cells participate in composition.

§Errors

Same contract as Renderer::present.

Examples found in repository?
examples/chat/main.rs (lines 413-419)
90async fn chat<'a>(
91	terminal: &'a mut Terminal,
92	renderer: &'a mut Renderer<TtyOut>,
93	ctx: &'a UiContext,
94) -> io::Result<()> {
95	let mut viewport = terminal.size()?;
96	if !run_welcome(terminal, renderer, ctx.charset, &mut viewport).await? {
97		return Ok(());
98	}
99	// The welcome scene held the alternate screen; releasing it restores the
100	// untouched shell and the chat pushes inline from a clean slate.
101	terminal.leave_alt()?;
102
103	let mut demo = Demo::new(ctx);
104	let mut overlay: Option<Overlay> = None;
105	let mut current_model = 0_usize;
106	let started = Instant::now();
107	let mut sidebar = Sidebar::new(MODELS[current_model].name, ctx);
108	demo.set_right_inset(sidebar.reserved(viewport));
109	{
110		let rendered = demo.render(viewport);
111		let layers = rail_layers(&mut sidebar, viewport, started.elapsed());
112		present(renderer, rendered, viewport, &layers)?;
113	}
114
115	// Alternate-screen ownership for the chat scene: a resize gesture borrows
116	// it for throwaway drag frames, an open overlay holds it for its lifetime.
117	let mut drag_alt = false;
118	let mut overlay_stale = false;
119	let mut resize = None;
120	// At most one in-flight background clipboard read (Ctrl+V/Ctrl+Shift+V).
121	let mut paste_read: Option<PasteRead> = None;
122	let mut next_frame = Instant::now() + FRAME_INTERVAL;
123	loop {
124		let paste_deadline = paste_read.as_ref().map(|read| read.abandon_at);
125		tokio::select! {
126			// The terminal branch pauses while a clipboard read is in flight:
127			// the event mailbox buffers input in order, so an Enter typed
128			// right after Ctrl+V lands *after* the paste instead of
129			// submitting an empty prompt. The read below is bounded, so the
130			// pause is too; retained App hosts get the finer-grained
131			// per-event queue instead.
132			event = terminal.next(), if paste_read.is_none() => match event? {
133				TerminalEvent::Resize => {
134					let now = Instant::now();
135					let resized = observe_resize(terminal, &mut viewport, &mut resize, now)?;
136					demo.set_right_inset(sidebar.reserved(viewport));
137					if overlay.is_some() && resized {
138						overlay_stale = true;
139					}
140				},
141				TerminalEvent::Debug(_) => {},
142				TerminalEvent::Closed => return Ok(()),
143				TerminalEvent::Input(event) => {
144					let Some(event) = user_event(terminal, renderer, event)? else {
145						continue;
146					};
147					match event {
148					InputEvent::Key(key) => {
149						if overlay.is_some() {
150							if key == Key::Ctrl('c') {
151								break;
152							}
153							let event = overlay
154								.as_mut()
155								.expect("overlay checked above")
156								.handle_key(key);
157							if apply_overlay_event(
158								event,
159								&mut overlay,
160								&mut current_model,
161								terminal,
162								renderer,
163								&mut demo,
164								&mut sidebar,
165								viewport,
166								started.elapsed(),
167								&mut overlay_stale,
168								&mut resize,
169								ctx,
170							)? {
171								break;
172							}
173						} else if key == Key::Ctrl('b') {
174							sidebar.toggle();
175							demo.set_right_inset(sidebar.reserved(viewport));
176						} else if key == Key::Ctrl('k') {
177							overlay = Some(Overlay::Palette(CommandPalette::open(ctx)));
178							open_overlay(
179								terminal,
180								renderer,
181								&mut demo,
182								overlay.as_mut().expect("palette just opened"),
183								&mut sidebar,
184								viewport,
185								started.elapsed(),
186								&mut drag_alt,
187								&mut overlay_stale,
188								&mut resize,
189							)?;
190						} else if sidebar.focused() {
191							if key == Key::Ctrl('c') {
192								break;
193							}
194							sidebar.handle_key(key);
195						} else if key == Key::Ctrl('p') || key == Key::Alt('p') {
196							overlay = Some(Overlay::Picker(ModelPicker::open(current_model, ctx)));
197							open_overlay(
198								terminal,
199								renderer,
200								&mut demo,
201								overlay.as_mut().expect("picker just opened"),
202								&mut sidebar,
203								viewport,
204								started.elapsed(),
205								&mut drag_alt,
206								&mut overlay_stale,
207								&mut resize,
208							)?;
209						} else if let Some(scope) = ClipboardRead::for_key(key) {
210							// The terminal did not claim the chord; read the
211							// system clipboard off-thread, preferring images
212							// unless the raw spelling asked for text only. A
213							// failed spawn closes the channel, so the receive
214							// branch below recovers input immediately.
215							paste_read = Some(PasteRead::start(scope));
216						} else {
217							let quit = demo.handle_key(key);
218							if demo.take_switch_request() {
219								overlay = Some(Overlay::Picker(ModelPicker::open(current_model, ctx)));
220								open_overlay(
221									terminal,
222									renderer,
223									&mut demo,
224									overlay.as_mut().expect("picker just opened"),
225									&mut sidebar,
226									viewport,
227									started.elapsed(),
228									&mut drag_alt,
229									&mut overlay_stale,
230									&mut resize,
231								)?;
232							}
233							if quit {
234								break;
235							}
236						}
237						next_frame = Instant::now();
238					},
239					InputEvent::Paste(text) => {
240						let event = overlay.as_mut().map(|active| active.handle_paste(&text));
241						match event {
242							Some(event) => {
243								if apply_overlay_event(
244									event,
245									&mut overlay,
246									&mut current_model,
247									terminal,
248									renderer,
249									&mut demo,
250									&mut sidebar,
251									viewport,
252									started.elapsed(),
253									&mut overlay_stale,
254									&mut resize,
255									ctx,
256								)? {
257									break;
258								}
259							},
260							None if sidebar.focused() => {},
261							None => demo.handle_paste(&text),
262						}
263						next_frame = Instant::now();
264					},
265					InputEvent::Mouse(report) => {
266						// An open overlay owns pointer input — hover, wheel,
267						// and clicks route through the compositor's band,
268						// never the occluded editor beneath.
269						let event = overlay
270							.as_mut()
271							.map(|active| active.handle_mouse(report.col, report.row, report.kind, viewport));
272						match event {
273							Some(event) => {
274								if apply_overlay_event(
275									event,
276									&mut overlay,
277									&mut current_model,
278									terminal,
279									renderer,
280									&mut demo,
281									&mut sidebar,
282									viewport,
283									started.elapsed(),
284									&mut overlay_stale,
285									&mut resize,
286									ctx,
287								)? {
288									break;
289								}
290							},
291							None => {
292								if !sidebar.handle_mouse(report.col, report.row, report.kind, viewport)
293								{
294									demo.handle_mouse(&report);
295								}
296							},
297						}
298						next_frame = Instant::now();
299					},
300					InputEvent::Focus(_) | InputEvent::Response(_) => {},
301				}
302				},
303			},
304			clipboard = async { (&mut paste_read.as_mut().expect("branch gated on Some").clipboard).await },
305				if paste_read.is_some() =>
306			{
307				let read = paste_read.take().expect("branch gated on Some");
308				// A closed channel (the reader thread never spawned) reads
309				// as an empty clipboard.
310				if let Ok(Some(clipboard)) = clipboard
311					&& let Some(text) = clipboard_paste_text(clipboard)
312					&& overlay.is_none()
313					&& !sidebar.focused()
314				{
315					// Ctrl+Shift+V inserts verbatim: no attachment staging,
316					// no large-paste collapse.
317					match read.scope {
318						ClipboardRead::Text => demo.handle_paste_raw(&text),
319						ClipboardRead::Smart => demo.handle_paste(&text),
320					}
321					next_frame = Instant::now();
322				}
323			},
324			// The deadline is absolute, so the frame tick recreating this
325			// branch's future cannot reset it: a hung reader is abandoned and
326			// terminal input re-enables. Dropping the receiver makes the
327			// reader's eventual send fail; the detached thread dies with the
328			// process instead of stalling shutdown.
329			() = deadline(paste_deadline) => {
330				paste_read = None;
331			},
332			() = deadline(Some(next_frame)) => {
333				let now = Instant::now();
334				let resized = observe_resize(terminal, &mut viewport, &mut resize, now)?;
335				demo.set_right_inset(sidebar.reserved(viewport));
336				if overlay.is_some() && resized {
337					overlay_stale = true;
338				}
339				if resize.is_some() {
340					// Drag frames compose exactly one viewport tail at the
341					// new geometry — O(viewport) per frame; the O(history)
342					// transcript reflow waits for the settle rebuild (or the
343					// overlay close). Without an overlay, a width change
344					// borrows the alternate screen (the inline transcript
345					// rewraps underneath) while height-only churn repaints
346					// in place — alt toggling on a height echo can
347					// self-sustain.
348					let preview = demo.render_resize_preview(viewport);
349					if let Some(active) = overlay.as_mut() {
350								  let mut layers = rail_layers(&mut sidebar, viewport, started.elapsed());
351								  layers.push(active.layer(viewport));
352								  renderer.preview_overlaid(&preview, &layers, viewport.height, "")?;
353							  } else {
354								  let width_changed =
355									  resize.is_some_and(|state| state.width_changed);
356								  let alt_enter = if drag_alt || !width_changed {
357									  None
358								  } else {
359									  let staged = terminal.stage_alt_enter(AltScreenUse::Resize);
360									  drag_alt = staged.is_some();
361									  staged
362								  };
363								  let layers = rail_layers(&mut sidebar, viewport, started.elapsed());
364								  renderer.preview_overlaid(
365									  &preview,
366									  &layers,
367									  viewport.height,
368									  alt_enter.as_deref().unwrap_or(""),
369								  )?;
370							  }
371				} else if let Some(active) = overlay.as_mut() {
372					let rendered = demo.render(viewport);
373					let mut layers = rail_layers(&mut sidebar, viewport, started.elapsed());
374					layers.push(active.layer(viewport));
375					renderer.preview_overlaid(rendered.frame, &layers, viewport.height, "")?;
376				} else {
377					let rendered = demo.render(viewport);
378					let layers = rail_layers(&mut sidebar, viewport, started.elapsed());
379					present(renderer, rendered, viewport, &layers)?;
380				}
381				next_frame = now + FRAME_INTERVAL;
382			},
383			() = deadline(resize.map(ResizeState::deadline)) => {
384				let now = Instant::now();
385				if !resize.is_some_and(|state| state.settled(now)) {
386					continue;
387				}
388				if overlay.is_some() {
389					// The overlay keeps holding the alternate screen; the
390					// transcript reflows once at close.
391					overlay_stale = true;
392					resize = None;
393					continue;
394				}
395				demo.set_right_inset(sidebar.reserved(viewport));
396				let rendered = demo.render(viewport);
397				let alt_exit = if drag_alt {
398					drag_alt = false;
399					terminal.stage_alt_leave().unwrap_or("")
400				} else {
401					""
402				};
403				renderer.rebuild(
404					rendered.frame.clone(),
405					viewport.height,
406					rendered.stable_rows,
407					alt_exit,
408				)?;
409				// The rebuild repainted the raw document; recomposite the
410				// rail on top without touching the fresh history.
411				let layers = rail_layers(&mut sidebar, viewport, started.elapsed());
412				if !layers.is_empty() {
413					renderer.present_overlaid(
414						rendered.frame,
415						&[],
416						viewport.height,
417						rendered.stable_rows,
418						&layers,
419					)?;
420				}
421				resize = None;
422				next_frame = now + FRAME_INTERVAL;
423			},
424		}
425	}
426	Ok(())
427}
428
429/// Animates the welcome card until the user resumes into the chat demo
430/// (`Ok(true)`) or quits (`Ok(false)`), keeping `viewport` current across
431/// resizes.
432///
433/// The scene owns the alternate screen for its whole lifetime: entry rides
434/// the first card paint, mouse tracking is active throughout, and every
435/// geometry change repaints in place immediately. The main screen stays
436/// untouched underneath — the caller releases the hold on scene exit.
437async fn run_welcome<'a>(
438	terminal: &'a mut Terminal,
439	renderer: &'a mut Renderer<TtyOut>,
440	charset: Charset,
441	viewport: &'a mut Size,
442) -> io::Result<bool> {
443	let mut alt_enter = terminal.stage_alt_enter(AltScreenUse::Interactive);
444	let mut welcome = Welcome::new(charset);
445	let started = Instant::now();
446	let mut next_frame = Instant::now();
447	loop {
448		tokio::select! {
449			event = terminal.next() => match event? {
450				TerminalEvent::Resize => {
451					if let Some(size) = terminal.take_resize()? {
452						*viewport = size;
453					}
454				},
455				TerminalEvent::Debug(_) => {},
456				TerminalEvent::Closed => return Ok(false),
457				TerminalEvent::Input(event) => {
458					let Some(event) = user_event(terminal, renderer, event)? else {
459						continue;
460					};
461					match event {
462					InputEvent::Key(Key::Enter) => return Ok(true),
463					InputEvent::Key(Key::Esc | Key::Ctrl('c')) => return Ok(false),
464					InputEvent::Mouse(report) if matches!(report.kind, Mouse::Move | Mouse::Drag) => {
465						welcome.point_at(report.col, report.row);
466					},
467					InputEvent::Key(_)
468					| InputEvent::Mouse(_)
469					| InputEvent::Paste(_)
470					| InputEvent::Focus(_)
471					| InputEvent::Response(_) => {},
472				}
473				},
474			},
475			() = deadline(Some(next_frame)) => {
476				let now = Instant::now();
477				if let Some(size) = terminal.take_resize()? {
478					*viewport = size;
479				}
480				let frame = welcome.render(*viewport, started.elapsed());
481				renderer.preview(
482					frame,
483					viewport.height,
484					alt_enter.take().as_deref().unwrap_or(""),
485				)?;
486				next_frame = now + FRAME_INTERVAL;
487			},
488		}
489	}
490}
491
492#[derive(Clone, Copy)]
493struct ResizeState {
494	last_event:    Instant,
495	/// Whether any report in this gesture changed the width; only then does
496	/// the drag borrow the alternate screen.
497	width_changed: bool,
498}
499
500impl ResizeState {
501	const fn new(last_event: Instant, width_changed: bool) -> Self {
502		Self { last_event, width_changed }
503	}
504
505	const fn observe(&mut self, observed_at: Instant, width_changed: bool) {
506		self.last_event = observed_at;
507		self.width_changed |= width_changed;
508	}
509
510	fn deadline(self) -> Instant {
511		self.last_event + RESIZE_SETTLE
512	}
513
514	fn settled(self, now: Instant) -> bool {
515		now >= self.deadline()
516	}
517}
518
519/// Consumes the latest resize — SIGWINCH or DEC 2048 in-band — and (re)arms
520/// the settle window. Same-size reports outside a gesture are echoes
521/// (terminals re-reporting geometry across alternate-screen toggles) and are
522/// swallowed without arming a rebuild.
523fn observe_resize(
524	terminal: &mut Terminal,
525	viewport: &mut Size,
526	resize: &mut Option<ResizeState>,
527	observed_at: Instant,
528) -> io::Result<bool> {
529	let Some(size) = terminal.take_resize()? else {
530		return Ok(false);
531	};
532	if size == *viewport && resize.is_none() {
533		return Ok(false);
534	}
535	let width_changed = size.width != viewport.width;
536	*viewport = size;
537	match resize {
538		Some(state) => state.observe(observed_at, width_changed),
539		None => *resize = Some(ResizeState::new(observed_at, width_changed)),
540	}
541	Ok(true)
542}
543
544fn user_event(
545	terminal: &mut Terminal,
546	renderer: &mut Renderer<TtyOut>,
547	event: InputEvent,
548) -> io::Result<Option<InputEvent>> {
549	if terminal.handle_input_event(&event, renderer)? {
550		// A consumed response may have completed an enhanced-paste (OSC
551		// 5522) conversation; re-inject its payload as ordinary paste input
552		// so the normal routing below stages images and text alike.
553		return Ok(terminal.take_paste().and_then(|pasted| {
554			let text = match pasted {
555				Pasted::Text(text) => text,
556				Pasted::Image(image) => image.persist().ok()?.display().to_string().into(),
557			};
558			Some(InputEvent::Paste(text))
559		}));
560	}
561	Ok(Some(event))
562}
563
564/// Flattens a background clipboard read into paste text: images persist to
565/// a temp file whose path routes like a file drop, and copied file paths
566/// are quoted so spaces survive drop classification.
567fn clipboard_paste_text(clipboard: Clipboard) -> Option<String> {
568	match clipboard {
569		Clipboard::Text(text) => Some(text),
570		Clipboard::Image(image) => Some(image.persist().ok()?.display().to_string()),
571		Clipboard::Paths(paths) => {
572			let mut joined = String::new();
573			for path in &paths {
574				if !joined.is_empty() {
575					joined.push(' ');
576				}
577				joined.push('"');
578				joined.push_str(path);
579				joined.push('"');
580			}
581			Some(joined)
582		},
583	}
584}
585
586fn present(
587	renderer: &mut Renderer<TtyOut>,
588	rendered: RenderedFrame<'_>,
589	viewport: Size,
590	layers: &[Layer<'_>],
591) -> io::Result<()> {
592	renderer
593		.present_overlaid(
594			rendered.frame,
595			rendered.damage.as_slice(),
596			viewport.height,
597			rendered.stable_rows,
598			layers,
599		)
600		.map(|_| ())
601}
602
603/// The session rail as a layer slice for this frame: empty when toggled
604/// off or gated out by a small viewport, so callers composite it
605/// unconditionally.
606fn rail_layers(sidebar: &mut Sidebar, viewport: Size, elapsed: Duration) -> SmallVec<Layer<'_>, 2> {
607	sidebar.layer(viewport, elapsed).into_iter().collect()
608}
609
610/// The modal scene overlay holding the alternate screen: at most one is
611/// open at a time, and a palette action can swap it for the picker in
612/// place — the hold transfers without leaving the alternate screen.
613enum Overlay {
614	Picker(ModelPicker),
615	Palette(CommandPalette),
616}
617
618/// One routed overlay outcome, unified across overlay kinds.
619enum OverlayEvent {
620	/// Input handled; the overlay stays open.
621	Consumed,
622	/// Dismissed without effect.
623	Close,
624	/// The picker chose a model.
625	Pick(usize),
626	/// The palette activated an entry.
627	Run(PaletteAction),
628}
629
630impl From<PickerEvent> for OverlayEvent {
631	fn from(event: PickerEvent) -> Self {
632		match event {
633			PickerEvent::Consumed => Self::Consumed,
634			PickerEvent::Close => Self::Close,
635			PickerEvent::Pick(index) => Self::Pick(index),
636		}
637	}
638}
639
640impl From<PaletteEvent> for OverlayEvent {
641	fn from(event: PaletteEvent) -> Self {
642		match event {
643			PaletteEvent::Consumed => Self::Consumed,
644			PaletteEvent::Close => Self::Close,
645			PaletteEvent::Run(action) => Self::Run(action),
646		}
647	}
648}
649
650impl Overlay {
651	fn handle_key(&mut self, key: Key) -> OverlayEvent {
652		match self {
653			Self::Picker(picker) => picker.handle_key(key).into(),
654			Self::Palette(palette) => palette.handle_key(key).into(),
655		}
656	}
657
658	fn handle_paste(&mut self, text: &str) -> OverlayEvent {
659		match self {
660			Self::Picker(picker) => picker.handle_paste(text).into(),
661			Self::Palette(palette) => palette.handle_paste(text).into(),
662		}
663	}
664
665	fn handle_mouse(&mut self, col: u16, row: u16, kind: Mouse, viewport: Size) -> OverlayEvent {
666		match self {
667			Self::Picker(picker) => picker.handle_mouse(col, row, kind, viewport).into(),
668			Self::Palette(palette) => palette.handle_mouse(col, row, kind, viewport).into(),
669		}
670	}
671
672	fn layer(&mut self, viewport: Size) -> Layer<'_> {
673		match self {
674			Self::Picker(picker) => picker.layer(viewport),
675			Self::Palette(palette) => palette.layer(viewport),
676		}
677	}
678}
679
680/// Takes the alternate screen for the overlay's lifetime: entry rides the
681/// first composited paint, and a drag borrow already in flight simply
682/// transfers ownership (its settled rebuild then waits for close).
683#[expect(clippy::too_many_arguments, reason = "immediate-mode example threads its scene state")]
684fn open_overlay(
685	terminal: &mut Terminal,
686	renderer: &mut Renderer<TtyOut>,
687	demo: &mut Demo,
688	overlay: &mut Overlay,
689	sidebar: &mut Sidebar,
690	viewport: Size,
691	elapsed: Duration,
692	drag_alt: &mut bool,
693	overlay_stale: &mut bool,
694	resize: &mut Option<ResizeState>,
695) -> io::Result<()> {
696	if *drag_alt || resize.take().is_some() {
697		*drag_alt = false;
698		*overlay_stale = true;
699	}
700	let alt_enter = terminal.stage_alt_enter(AltScreenUse::Interactive);
701	let rendered = demo.render(viewport);
702	let mut layers = rail_layers(sidebar, viewport, elapsed);
703	layers.push(overlay.layer(viewport));
704	renderer
705		.preview_overlaid(
706			rendered.frame,
707			&layers,
708			viewport.height,
709			alt_enter.as_deref().unwrap_or(""),
710		)
711		.map(|_| ())
712}
713
714/// Releases the overlay's alternate-screen hold. Geometry churn while held
715/// rebuilds native history inside the same synchronized update as the buffer
716/// switch; otherwise the untouched main screen restores byte-exactly and one
717/// full-viewport present revalidates changes that only ever painted the
718/// alternate screen (streamed demo rows, the picked model).
719#[expect(clippy::too_many_arguments, reason = "immediate-mode example threads its scene state")]
720fn close_overlay(
721	terminal: &mut Terminal,
722	renderer: &mut Renderer<TtyOut>,
723	demo: &mut Demo,
724	sidebar: &mut Sidebar,
725	viewport: Size,
726	elapsed: Duration,
727	overlay_stale: &mut bool,
728	resize: &mut Option<ResizeState>,
729) -> io::Result<()> {
730	*resize = None;
731	let rendered = demo.render(viewport);
732	let layers = rail_layers(sidebar, viewport, elapsed);
733	if *overlay_stale {
734		*overlay_stale = false;
735		let alt_exit = terminal.stage_alt_leave().unwrap_or("");
736		renderer.rebuild(rendered.frame.clone(), viewport.height, rendered.stable_rows, alt_exit)?;
737		if !layers.is_empty() {
738			// The rebuild repainted the raw document; recomposite the rail
739			// without touching the fresh history.
740			renderer.present_overlaid(
741				rendered.frame,
742				&[],
743				viewport.height,
744				rendered.stable_rows,
745				&layers,
746			)?;
747		}
748	} else {
749		terminal.leave_alt()?;
750		renderer.present_overlaid(
751			rendered.frame,
752			&[(0, rendered.frame.size().height)],
753			viewport.height,
754			rendered.stable_rows,
755			&layers,
756		)?;
757	}
758	Ok(())
759}
Source

pub fn present_damaged( &mut self, next: &Frame, damaged: &[(u16, u16)], viewport_height: u16, stable_rows: u16, ) -> Result<PaintStats>

Renderer::present_ref with a caller-supplied damage list: only rows inside damaged (start, end) ranges are validated and snapshotted. The caller guarantees every changed row is covered; the full grid is copied only on the initial paint.

§Errors

Same contract as Renderer::present.

Source

pub fn clear_layers(&mut self) -> Result<()>

Repaints every composited viewport-layer band from the raw document and drops the stored layers.

The final inline screen persists into native scrollback once the host exits and the shell resumes scrolling, so teardown must not leave layer cells composited — crate::App does this automatically, and manual hosts call it before dropping their crate::Terminal. Call it on the main screen (release any alternate-screen hold first); with no stored layers, or while the alternate screen is active, nothing is written.

§Errors

Propagates writer failures, which poison the renderer.

Examples found in repository?
examples/chat/main.rs (line 82)
76async fn run<'a>(
77	terminal: &'a mut Terminal,
78	renderer: &'a mut Renderer<TtyOut>,
79	ctx: &'a UiContext,
80) -> io::Result<()> {
81	let result = chat(terminal, renderer, ctx).await;
82	let scrub = terminal.leave_alt().and_then(|()| renderer.clear_layers());
83	result.and(scrub)
84}
Source

pub fn preview( &mut self, next: &Frame, viewport_height: u16, leading_sequence: &str, ) -> Result<PaintStats>

Paints only the current raw document tail without changing committed state.

Resize handlers use this on an alternate buffer while normal-buffer history remains untouched. Stored overlay layers are deliberately ignored; leading_sequence is emitted inside the synchronized update, before the viewport paint. Overlays go through Renderer::preview_overlaid.

§Errors

Rejects zero geometry. Writer failure poisons the renderer because its physical state is unknown.

Examples found in repository?
examples/chat/main.rs (lines 481-485)
437async fn run_welcome<'a>(
438	terminal: &'a mut Terminal,
439	renderer: &'a mut Renderer<TtyOut>,
440	charset: Charset,
441	viewport: &'a mut Size,
442) -> io::Result<bool> {
443	let mut alt_enter = terminal.stage_alt_enter(AltScreenUse::Interactive);
444	let mut welcome = Welcome::new(charset);
445	let started = Instant::now();
446	let mut next_frame = Instant::now();
447	loop {
448		tokio::select! {
449			event = terminal.next() => match event? {
450				TerminalEvent::Resize => {
451					if let Some(size) = terminal.take_resize()? {
452						*viewport = size;
453					}
454				},
455				TerminalEvent::Debug(_) => {},
456				TerminalEvent::Closed => return Ok(false),
457				TerminalEvent::Input(event) => {
458					let Some(event) = user_event(terminal, renderer, event)? else {
459						continue;
460					};
461					match event {
462					InputEvent::Key(Key::Enter) => return Ok(true),
463					InputEvent::Key(Key::Esc | Key::Ctrl('c')) => return Ok(false),
464					InputEvent::Mouse(report) if matches!(report.kind, Mouse::Move | Mouse::Drag) => {
465						welcome.point_at(report.col, report.row);
466					},
467					InputEvent::Key(_)
468					| InputEvent::Mouse(_)
469					| InputEvent::Paste(_)
470					| InputEvent::Focus(_)
471					| InputEvent::Response(_) => {},
472				}
473				},
474			},
475			() = deadline(Some(next_frame)) => {
476				let now = Instant::now();
477				if let Some(size) = terminal.take_resize()? {
478					*viewport = size;
479				}
480				let frame = welcome.render(*viewport, started.elapsed());
481				renderer.preview(
482					frame,
483					viewport.height,
484					alt_enter.take().as_deref().unwrap_or(""),
485				)?;
486				next_frame = now + FRAME_INTERVAL;
487			},
488		}
489	}
490}
More examples
Hide additional examples
examples/footers.rs (line 120)
68async fn run<'a>(
69	terminal: &'a mut Terminal,
70	renderer: &'a mut Renderer<TtyOut>,
71	charset: Charset,
72) -> io::Result<()> {
73	let started = Instant::now();
74	let mut viewport = terminal.size()?;
75	let mut scroll: u16 = 0;
76	let mut alt_enter = terminal.stage_alt_enter(AltScreenUse::Interactive);
77	loop {
78		tokio::select! {
79			event = terminal.next() => match event? {
80				TerminalEvent::Input(event) => {
81					match event {
82						InputEvent::Key(key) => match key {
83							Key::Char('q') | Key::Esc | Key::Ctrl('c') => return Ok(()),
84							Key::Up | Key::Char('k') => scroll = scroll.saturating_sub(1),
85							Key::Down | Key::Char('j') => scroll = scroll.saturating_add(1),
86							Key::PageUp => scroll = scroll.saturating_sub(viewport.height),
87							Key::PageDown => scroll = scroll.saturating_add(viewport.height),
88							Key::Home => scroll = 0,
89							Key::End => scroll = u16::MAX,
90							_ => {},
91						},
92						InputEvent::Mouse(report) => match report.kind {
93							Mouse::WheelUp => scroll = scroll.saturating_sub(2),
94							Mouse::WheelDown => scroll = scroll.saturating_add(2),
95							_ => {},
96						},
97						InputEvent::Paste(_) | InputEvent::Focus(_) | InputEvent::Response(_) => {},
98					}
99					terminal.sync_renderer(renderer)?;
100				},
101				TerminalEvent::Resize => {
102					if let Some(size) = terminal.take_resize()? {
103						viewport = size;
104					}
105				},
106				TerminalEvent::Debug(_) => {},
107				TerminalEvent::Closed => return Ok(()),
108			},
109			() = tokio::time::sleep(FRAME_INTERVAL) => {},
110		}
111		if viewport.width == 0 || viewport.height == 0 {
112			continue;
113		}
114		let scene = Scene { charset, width: viewport.width, elapsed: started.elapsed() };
115		let document = compose(&scene);
116		scroll = scroll.min(document.size().height.saturating_sub(viewport.height));
117		let mut screen = Frame::new(viewport);
118		screen.fill(Rect::new(0, 0, viewport.width, viewport.height), ink(TEXT));
119		screen.blit(&document, scroll, viewport.height, 0, 0);
120		renderer.preview(&screen, viewport.height, alt_enter.take().as_deref().unwrap_or(""))?;
121	}
122}
Source

pub fn preview_overlaid( &mut self, next: &Frame, layers: &[Layer<'_>], viewport_height: u16, leading_sequence: &str, ) -> Result<PaintStats>

Renderer::preview with declarative viewport-anchored layers.

The document tail and every visible layer composite into one throwaway synchronized paint while committed history and stored layers stay untouched. Alternate-screen holders — fullscreen scenes and modal overlays — repaint with this on damage or geometry change; Renderer::present_overlaid is the normal-buffer counterpart.

§Errors

Same contract as Renderer::preview.

Examples found in repository?
examples/chat/main.rs (line 352)
90async fn chat<'a>(
91	terminal: &'a mut Terminal,
92	renderer: &'a mut Renderer<TtyOut>,
93	ctx: &'a UiContext,
94) -> io::Result<()> {
95	let mut viewport = terminal.size()?;
96	if !run_welcome(terminal, renderer, ctx.charset, &mut viewport).await? {
97		return Ok(());
98	}
99	// The welcome scene held the alternate screen; releasing it restores the
100	// untouched shell and the chat pushes inline from a clean slate.
101	terminal.leave_alt()?;
102
103	let mut demo = Demo::new(ctx);
104	let mut overlay: Option<Overlay> = None;
105	let mut current_model = 0_usize;
106	let started = Instant::now();
107	let mut sidebar = Sidebar::new(MODELS[current_model].name, ctx);
108	demo.set_right_inset(sidebar.reserved(viewport));
109	{
110		let rendered = demo.render(viewport);
111		let layers = rail_layers(&mut sidebar, viewport, started.elapsed());
112		present(renderer, rendered, viewport, &layers)?;
113	}
114
115	// Alternate-screen ownership for the chat scene: a resize gesture borrows
116	// it for throwaway drag frames, an open overlay holds it for its lifetime.
117	let mut drag_alt = false;
118	let mut overlay_stale = false;
119	let mut resize = None;
120	// At most one in-flight background clipboard read (Ctrl+V/Ctrl+Shift+V).
121	let mut paste_read: Option<PasteRead> = None;
122	let mut next_frame = Instant::now() + FRAME_INTERVAL;
123	loop {
124		let paste_deadline = paste_read.as_ref().map(|read| read.abandon_at);
125		tokio::select! {
126			// The terminal branch pauses while a clipboard read is in flight:
127			// the event mailbox buffers input in order, so an Enter typed
128			// right after Ctrl+V lands *after* the paste instead of
129			// submitting an empty prompt. The read below is bounded, so the
130			// pause is too; retained App hosts get the finer-grained
131			// per-event queue instead.
132			event = terminal.next(), if paste_read.is_none() => match event? {
133				TerminalEvent::Resize => {
134					let now = Instant::now();
135					let resized = observe_resize(terminal, &mut viewport, &mut resize, now)?;
136					demo.set_right_inset(sidebar.reserved(viewport));
137					if overlay.is_some() && resized {
138						overlay_stale = true;
139					}
140				},
141				TerminalEvent::Debug(_) => {},
142				TerminalEvent::Closed => return Ok(()),
143				TerminalEvent::Input(event) => {
144					let Some(event) = user_event(terminal, renderer, event)? else {
145						continue;
146					};
147					match event {
148					InputEvent::Key(key) => {
149						if overlay.is_some() {
150							if key == Key::Ctrl('c') {
151								break;
152							}
153							let event = overlay
154								.as_mut()
155								.expect("overlay checked above")
156								.handle_key(key);
157							if apply_overlay_event(
158								event,
159								&mut overlay,
160								&mut current_model,
161								terminal,
162								renderer,
163								&mut demo,
164								&mut sidebar,
165								viewport,
166								started.elapsed(),
167								&mut overlay_stale,
168								&mut resize,
169								ctx,
170							)? {
171								break;
172							}
173						} else if key == Key::Ctrl('b') {
174							sidebar.toggle();
175							demo.set_right_inset(sidebar.reserved(viewport));
176						} else if key == Key::Ctrl('k') {
177							overlay = Some(Overlay::Palette(CommandPalette::open(ctx)));
178							open_overlay(
179								terminal,
180								renderer,
181								&mut demo,
182								overlay.as_mut().expect("palette just opened"),
183								&mut sidebar,
184								viewport,
185								started.elapsed(),
186								&mut drag_alt,
187								&mut overlay_stale,
188								&mut resize,
189							)?;
190						} else if sidebar.focused() {
191							if key == Key::Ctrl('c') {
192								break;
193							}
194							sidebar.handle_key(key);
195						} else if key == Key::Ctrl('p') || key == Key::Alt('p') {
196							overlay = Some(Overlay::Picker(ModelPicker::open(current_model, ctx)));
197							open_overlay(
198								terminal,
199								renderer,
200								&mut demo,
201								overlay.as_mut().expect("picker just opened"),
202								&mut sidebar,
203								viewport,
204								started.elapsed(),
205								&mut drag_alt,
206								&mut overlay_stale,
207								&mut resize,
208							)?;
209						} else if let Some(scope) = ClipboardRead::for_key(key) {
210							// The terminal did not claim the chord; read the
211							// system clipboard off-thread, preferring images
212							// unless the raw spelling asked for text only. A
213							// failed spawn closes the channel, so the receive
214							// branch below recovers input immediately.
215							paste_read = Some(PasteRead::start(scope));
216						} else {
217							let quit = demo.handle_key(key);
218							if demo.take_switch_request() {
219								overlay = Some(Overlay::Picker(ModelPicker::open(current_model, ctx)));
220								open_overlay(
221									terminal,
222									renderer,
223									&mut demo,
224									overlay.as_mut().expect("picker just opened"),
225									&mut sidebar,
226									viewport,
227									started.elapsed(),
228									&mut drag_alt,
229									&mut overlay_stale,
230									&mut resize,
231								)?;
232							}
233							if quit {
234								break;
235							}
236						}
237						next_frame = Instant::now();
238					},
239					InputEvent::Paste(text) => {
240						let event = overlay.as_mut().map(|active| active.handle_paste(&text));
241						match event {
242							Some(event) => {
243								if apply_overlay_event(
244									event,
245									&mut overlay,
246									&mut current_model,
247									terminal,
248									renderer,
249									&mut demo,
250									&mut sidebar,
251									viewport,
252									started.elapsed(),
253									&mut overlay_stale,
254									&mut resize,
255									ctx,
256								)? {
257									break;
258								}
259							},
260							None if sidebar.focused() => {},
261							None => demo.handle_paste(&text),
262						}
263						next_frame = Instant::now();
264					},
265					InputEvent::Mouse(report) => {
266						// An open overlay owns pointer input — hover, wheel,
267						// and clicks route through the compositor's band,
268						// never the occluded editor beneath.
269						let event = overlay
270							.as_mut()
271							.map(|active| active.handle_mouse(report.col, report.row, report.kind, viewport));
272						match event {
273							Some(event) => {
274								if apply_overlay_event(
275									event,
276									&mut overlay,
277									&mut current_model,
278									terminal,
279									renderer,
280									&mut demo,
281									&mut sidebar,
282									viewport,
283									started.elapsed(),
284									&mut overlay_stale,
285									&mut resize,
286									ctx,
287								)? {
288									break;
289								}
290							},
291							None => {
292								if !sidebar.handle_mouse(report.col, report.row, report.kind, viewport)
293								{
294									demo.handle_mouse(&report);
295								}
296							},
297						}
298						next_frame = Instant::now();
299					},
300					InputEvent::Focus(_) | InputEvent::Response(_) => {},
301				}
302				},
303			},
304			clipboard = async { (&mut paste_read.as_mut().expect("branch gated on Some").clipboard).await },
305				if paste_read.is_some() =>
306			{
307				let read = paste_read.take().expect("branch gated on Some");
308				// A closed channel (the reader thread never spawned) reads
309				// as an empty clipboard.
310				if let Ok(Some(clipboard)) = clipboard
311					&& let Some(text) = clipboard_paste_text(clipboard)
312					&& overlay.is_none()
313					&& !sidebar.focused()
314				{
315					// Ctrl+Shift+V inserts verbatim: no attachment staging,
316					// no large-paste collapse.
317					match read.scope {
318						ClipboardRead::Text => demo.handle_paste_raw(&text),
319						ClipboardRead::Smart => demo.handle_paste(&text),
320					}
321					next_frame = Instant::now();
322				}
323			},
324			// The deadline is absolute, so the frame tick recreating this
325			// branch's future cannot reset it: a hung reader is abandoned and
326			// terminal input re-enables. Dropping the receiver makes the
327			// reader's eventual send fail; the detached thread dies with the
328			// process instead of stalling shutdown.
329			() = deadline(paste_deadline) => {
330				paste_read = None;
331			},
332			() = deadline(Some(next_frame)) => {
333				let now = Instant::now();
334				let resized = observe_resize(terminal, &mut viewport, &mut resize, now)?;
335				demo.set_right_inset(sidebar.reserved(viewport));
336				if overlay.is_some() && resized {
337					overlay_stale = true;
338				}
339				if resize.is_some() {
340					// Drag frames compose exactly one viewport tail at the
341					// new geometry — O(viewport) per frame; the O(history)
342					// transcript reflow waits for the settle rebuild (or the
343					// overlay close). Without an overlay, a width change
344					// borrows the alternate screen (the inline transcript
345					// rewraps underneath) while height-only churn repaints
346					// in place — alt toggling on a height echo can
347					// self-sustain.
348					let preview = demo.render_resize_preview(viewport);
349					if let Some(active) = overlay.as_mut() {
350								  let mut layers = rail_layers(&mut sidebar, viewport, started.elapsed());
351								  layers.push(active.layer(viewport));
352								  renderer.preview_overlaid(&preview, &layers, viewport.height, "")?;
353							  } else {
354								  let width_changed =
355									  resize.is_some_and(|state| state.width_changed);
356								  let alt_enter = if drag_alt || !width_changed {
357									  None
358								  } else {
359									  let staged = terminal.stage_alt_enter(AltScreenUse::Resize);
360									  drag_alt = staged.is_some();
361									  staged
362								  };
363								  let layers = rail_layers(&mut sidebar, viewport, started.elapsed());
364								  renderer.preview_overlaid(
365									  &preview,
366									  &layers,
367									  viewport.height,
368									  alt_enter.as_deref().unwrap_or(""),
369								  )?;
370							  }
371				} else if let Some(active) = overlay.as_mut() {
372					let rendered = demo.render(viewport);
373					let mut layers = rail_layers(&mut sidebar, viewport, started.elapsed());
374					layers.push(active.layer(viewport));
375					renderer.preview_overlaid(rendered.frame, &layers, viewport.height, "")?;
376				} else {
377					let rendered = demo.render(viewport);
378					let layers = rail_layers(&mut sidebar, viewport, started.elapsed());
379					present(renderer, rendered, viewport, &layers)?;
380				}
381				next_frame = now + FRAME_INTERVAL;
382			},
383			() = deadline(resize.map(ResizeState::deadline)) => {
384				let now = Instant::now();
385				if !resize.is_some_and(|state| state.settled(now)) {
386					continue;
387				}
388				if overlay.is_some() {
389					// The overlay keeps holding the alternate screen; the
390					// transcript reflows once at close.
391					overlay_stale = true;
392					resize = None;
393					continue;
394				}
395				demo.set_right_inset(sidebar.reserved(viewport));
396				let rendered = demo.render(viewport);
397				let alt_exit = if drag_alt {
398					drag_alt = false;
399					terminal.stage_alt_leave().unwrap_or("")
400				} else {
401					""
402				};
403				renderer.rebuild(
404					rendered.frame.clone(),
405					viewport.height,
406					rendered.stable_rows,
407					alt_exit,
408				)?;
409				// The rebuild repainted the raw document; recomposite the
410				// rail on top without touching the fresh history.
411				let layers = rail_layers(&mut sidebar, viewport, started.elapsed());
412				if !layers.is_empty() {
413					renderer.present_overlaid(
414						rendered.frame,
415						&[],
416						viewport.height,
417						rendered.stable_rows,
418						&layers,
419					)?;
420				}
421				resize = None;
422				next_frame = now + FRAME_INTERVAL;
423			},
424		}
425	}
426	Ok(())
427}
428
429/// Animates the welcome card until the user resumes into the chat demo
430/// (`Ok(true)`) or quits (`Ok(false)`), keeping `viewport` current across
431/// resizes.
432///
433/// The scene owns the alternate screen for its whole lifetime: entry rides
434/// the first card paint, mouse tracking is active throughout, and every
435/// geometry change repaints in place immediately. The main screen stays
436/// untouched underneath — the caller releases the hold on scene exit.
437async fn run_welcome<'a>(
438	terminal: &'a mut Terminal,
439	renderer: &'a mut Renderer<TtyOut>,
440	charset: Charset,
441	viewport: &'a mut Size,
442) -> io::Result<bool> {
443	let mut alt_enter = terminal.stage_alt_enter(AltScreenUse::Interactive);
444	let mut welcome = Welcome::new(charset);
445	let started = Instant::now();
446	let mut next_frame = Instant::now();
447	loop {
448		tokio::select! {
449			event = terminal.next() => match event? {
450				TerminalEvent::Resize => {
451					if let Some(size) = terminal.take_resize()? {
452						*viewport = size;
453					}
454				},
455				TerminalEvent::Debug(_) => {},
456				TerminalEvent::Closed => return Ok(false),
457				TerminalEvent::Input(event) => {
458					let Some(event) = user_event(terminal, renderer, event)? else {
459						continue;
460					};
461					match event {
462					InputEvent::Key(Key::Enter) => return Ok(true),
463					InputEvent::Key(Key::Esc | Key::Ctrl('c')) => return Ok(false),
464					InputEvent::Mouse(report) if matches!(report.kind, Mouse::Move | Mouse::Drag) => {
465						welcome.point_at(report.col, report.row);
466					},
467					InputEvent::Key(_)
468					| InputEvent::Mouse(_)
469					| InputEvent::Paste(_)
470					| InputEvent::Focus(_)
471					| InputEvent::Response(_) => {},
472				}
473				},
474			},
475			() = deadline(Some(next_frame)) => {
476				let now = Instant::now();
477				if let Some(size) = terminal.take_resize()? {
478					*viewport = size;
479				}
480				let frame = welcome.render(*viewport, started.elapsed());
481				renderer.preview(
482					frame,
483					viewport.height,
484					alt_enter.take().as_deref().unwrap_or(""),
485				)?;
486				next_frame = now + FRAME_INTERVAL;
487			},
488		}
489	}
490}
491
492#[derive(Clone, Copy)]
493struct ResizeState {
494	last_event:    Instant,
495	/// Whether any report in this gesture changed the width; only then does
496	/// the drag borrow the alternate screen.
497	width_changed: bool,
498}
499
500impl ResizeState {
501	const fn new(last_event: Instant, width_changed: bool) -> Self {
502		Self { last_event, width_changed }
503	}
504
505	const fn observe(&mut self, observed_at: Instant, width_changed: bool) {
506		self.last_event = observed_at;
507		self.width_changed |= width_changed;
508	}
509
510	fn deadline(self) -> Instant {
511		self.last_event + RESIZE_SETTLE
512	}
513
514	fn settled(self, now: Instant) -> bool {
515		now >= self.deadline()
516	}
517}
518
519/// Consumes the latest resize — SIGWINCH or DEC 2048 in-band — and (re)arms
520/// the settle window. Same-size reports outside a gesture are echoes
521/// (terminals re-reporting geometry across alternate-screen toggles) and are
522/// swallowed without arming a rebuild.
523fn observe_resize(
524	terminal: &mut Terminal,
525	viewport: &mut Size,
526	resize: &mut Option<ResizeState>,
527	observed_at: Instant,
528) -> io::Result<bool> {
529	let Some(size) = terminal.take_resize()? else {
530		return Ok(false);
531	};
532	if size == *viewport && resize.is_none() {
533		return Ok(false);
534	}
535	let width_changed = size.width != viewport.width;
536	*viewport = size;
537	match resize {
538		Some(state) => state.observe(observed_at, width_changed),
539		None => *resize = Some(ResizeState::new(observed_at, width_changed)),
540	}
541	Ok(true)
542}
543
544fn user_event(
545	terminal: &mut Terminal,
546	renderer: &mut Renderer<TtyOut>,
547	event: InputEvent,
548) -> io::Result<Option<InputEvent>> {
549	if terminal.handle_input_event(&event, renderer)? {
550		// A consumed response may have completed an enhanced-paste (OSC
551		// 5522) conversation; re-inject its payload as ordinary paste input
552		// so the normal routing below stages images and text alike.
553		return Ok(terminal.take_paste().and_then(|pasted| {
554			let text = match pasted {
555				Pasted::Text(text) => text,
556				Pasted::Image(image) => image.persist().ok()?.display().to_string().into(),
557			};
558			Some(InputEvent::Paste(text))
559		}));
560	}
561	Ok(Some(event))
562}
563
564/// Flattens a background clipboard read into paste text: images persist to
565/// a temp file whose path routes like a file drop, and copied file paths
566/// are quoted so spaces survive drop classification.
567fn clipboard_paste_text(clipboard: Clipboard) -> Option<String> {
568	match clipboard {
569		Clipboard::Text(text) => Some(text),
570		Clipboard::Image(image) => Some(image.persist().ok()?.display().to_string()),
571		Clipboard::Paths(paths) => {
572			let mut joined = String::new();
573			for path in &paths {
574				if !joined.is_empty() {
575					joined.push(' ');
576				}
577				joined.push('"');
578				joined.push_str(path);
579				joined.push('"');
580			}
581			Some(joined)
582		},
583	}
584}
585
586fn present(
587	renderer: &mut Renderer<TtyOut>,
588	rendered: RenderedFrame<'_>,
589	viewport: Size,
590	layers: &[Layer<'_>],
591) -> io::Result<()> {
592	renderer
593		.present_overlaid(
594			rendered.frame,
595			rendered.damage.as_slice(),
596			viewport.height,
597			rendered.stable_rows,
598			layers,
599		)
600		.map(|_| ())
601}
602
603/// The session rail as a layer slice for this frame: empty when toggled
604/// off or gated out by a small viewport, so callers composite it
605/// unconditionally.
606fn rail_layers(sidebar: &mut Sidebar, viewport: Size, elapsed: Duration) -> SmallVec<Layer<'_>, 2> {
607	sidebar.layer(viewport, elapsed).into_iter().collect()
608}
609
610/// The modal scene overlay holding the alternate screen: at most one is
611/// open at a time, and a palette action can swap it for the picker in
612/// place — the hold transfers without leaving the alternate screen.
613enum Overlay {
614	Picker(ModelPicker),
615	Palette(CommandPalette),
616}
617
618/// One routed overlay outcome, unified across overlay kinds.
619enum OverlayEvent {
620	/// Input handled; the overlay stays open.
621	Consumed,
622	/// Dismissed without effect.
623	Close,
624	/// The picker chose a model.
625	Pick(usize),
626	/// The palette activated an entry.
627	Run(PaletteAction),
628}
629
630impl From<PickerEvent> for OverlayEvent {
631	fn from(event: PickerEvent) -> Self {
632		match event {
633			PickerEvent::Consumed => Self::Consumed,
634			PickerEvent::Close => Self::Close,
635			PickerEvent::Pick(index) => Self::Pick(index),
636		}
637	}
638}
639
640impl From<PaletteEvent> for OverlayEvent {
641	fn from(event: PaletteEvent) -> Self {
642		match event {
643			PaletteEvent::Consumed => Self::Consumed,
644			PaletteEvent::Close => Self::Close,
645			PaletteEvent::Run(action) => Self::Run(action),
646		}
647	}
648}
649
650impl Overlay {
651	fn handle_key(&mut self, key: Key) -> OverlayEvent {
652		match self {
653			Self::Picker(picker) => picker.handle_key(key).into(),
654			Self::Palette(palette) => palette.handle_key(key).into(),
655		}
656	}
657
658	fn handle_paste(&mut self, text: &str) -> OverlayEvent {
659		match self {
660			Self::Picker(picker) => picker.handle_paste(text).into(),
661			Self::Palette(palette) => palette.handle_paste(text).into(),
662		}
663	}
664
665	fn handle_mouse(&mut self, col: u16, row: u16, kind: Mouse, viewport: Size) -> OverlayEvent {
666		match self {
667			Self::Picker(picker) => picker.handle_mouse(col, row, kind, viewport).into(),
668			Self::Palette(palette) => palette.handle_mouse(col, row, kind, viewport).into(),
669		}
670	}
671
672	fn layer(&mut self, viewport: Size) -> Layer<'_> {
673		match self {
674			Self::Picker(picker) => picker.layer(viewport),
675			Self::Palette(palette) => palette.layer(viewport),
676		}
677	}
678}
679
680/// Takes the alternate screen for the overlay's lifetime: entry rides the
681/// first composited paint, and a drag borrow already in flight simply
682/// transfers ownership (its settled rebuild then waits for close).
683#[expect(clippy::too_many_arguments, reason = "immediate-mode example threads its scene state")]
684fn open_overlay(
685	terminal: &mut Terminal,
686	renderer: &mut Renderer<TtyOut>,
687	demo: &mut Demo,
688	overlay: &mut Overlay,
689	sidebar: &mut Sidebar,
690	viewport: Size,
691	elapsed: Duration,
692	drag_alt: &mut bool,
693	overlay_stale: &mut bool,
694	resize: &mut Option<ResizeState>,
695) -> io::Result<()> {
696	if *drag_alt || resize.take().is_some() {
697		*drag_alt = false;
698		*overlay_stale = true;
699	}
700	let alt_enter = terminal.stage_alt_enter(AltScreenUse::Interactive);
701	let rendered = demo.render(viewport);
702	let mut layers = rail_layers(sidebar, viewport, elapsed);
703	layers.push(overlay.layer(viewport));
704	renderer
705		.preview_overlaid(
706			rendered.frame,
707			&layers,
708			viewport.height,
709			alt_enter.as_deref().unwrap_or(""),
710		)
711		.map(|_| ())
712}
Source

pub fn rebuild( &mut self, next: Frame, viewport_height: u16, stable_rows: u16, leading_sequence: &str, ) -> Result<PaintStats>

Clears and reconstructs native history at new terminal geometry.

The synchronized update emits leading_sequence, clears scrollback once, then writes the stable prefix and current viewport. The reconstructed frame becomes the baseline for subsequent Self::present calls.

§Errors

Rejects zero geometry or a stable boundary beyond the document. Writer failure poisons the renderer because history may be partially rebuilt.

Examples found in repository?
examples/chat/main.rs (lines 403-408)
90async fn chat<'a>(
91	terminal: &'a mut Terminal,
92	renderer: &'a mut Renderer<TtyOut>,
93	ctx: &'a UiContext,
94) -> io::Result<()> {
95	let mut viewport = terminal.size()?;
96	if !run_welcome(terminal, renderer, ctx.charset, &mut viewport).await? {
97		return Ok(());
98	}
99	// The welcome scene held the alternate screen; releasing it restores the
100	// untouched shell and the chat pushes inline from a clean slate.
101	terminal.leave_alt()?;
102
103	let mut demo = Demo::new(ctx);
104	let mut overlay: Option<Overlay> = None;
105	let mut current_model = 0_usize;
106	let started = Instant::now();
107	let mut sidebar = Sidebar::new(MODELS[current_model].name, ctx);
108	demo.set_right_inset(sidebar.reserved(viewport));
109	{
110		let rendered = demo.render(viewport);
111		let layers = rail_layers(&mut sidebar, viewport, started.elapsed());
112		present(renderer, rendered, viewport, &layers)?;
113	}
114
115	// Alternate-screen ownership for the chat scene: a resize gesture borrows
116	// it for throwaway drag frames, an open overlay holds it for its lifetime.
117	let mut drag_alt = false;
118	let mut overlay_stale = false;
119	let mut resize = None;
120	// At most one in-flight background clipboard read (Ctrl+V/Ctrl+Shift+V).
121	let mut paste_read: Option<PasteRead> = None;
122	let mut next_frame = Instant::now() + FRAME_INTERVAL;
123	loop {
124		let paste_deadline = paste_read.as_ref().map(|read| read.abandon_at);
125		tokio::select! {
126			// The terminal branch pauses while a clipboard read is in flight:
127			// the event mailbox buffers input in order, so an Enter typed
128			// right after Ctrl+V lands *after* the paste instead of
129			// submitting an empty prompt. The read below is bounded, so the
130			// pause is too; retained App hosts get the finer-grained
131			// per-event queue instead.
132			event = terminal.next(), if paste_read.is_none() => match event? {
133				TerminalEvent::Resize => {
134					let now = Instant::now();
135					let resized = observe_resize(terminal, &mut viewport, &mut resize, now)?;
136					demo.set_right_inset(sidebar.reserved(viewport));
137					if overlay.is_some() && resized {
138						overlay_stale = true;
139					}
140				},
141				TerminalEvent::Debug(_) => {},
142				TerminalEvent::Closed => return Ok(()),
143				TerminalEvent::Input(event) => {
144					let Some(event) = user_event(terminal, renderer, event)? else {
145						continue;
146					};
147					match event {
148					InputEvent::Key(key) => {
149						if overlay.is_some() {
150							if key == Key::Ctrl('c') {
151								break;
152							}
153							let event = overlay
154								.as_mut()
155								.expect("overlay checked above")
156								.handle_key(key);
157							if apply_overlay_event(
158								event,
159								&mut overlay,
160								&mut current_model,
161								terminal,
162								renderer,
163								&mut demo,
164								&mut sidebar,
165								viewport,
166								started.elapsed(),
167								&mut overlay_stale,
168								&mut resize,
169								ctx,
170							)? {
171								break;
172							}
173						} else if key == Key::Ctrl('b') {
174							sidebar.toggle();
175							demo.set_right_inset(sidebar.reserved(viewport));
176						} else if key == Key::Ctrl('k') {
177							overlay = Some(Overlay::Palette(CommandPalette::open(ctx)));
178							open_overlay(
179								terminal,
180								renderer,
181								&mut demo,
182								overlay.as_mut().expect("palette just opened"),
183								&mut sidebar,
184								viewport,
185								started.elapsed(),
186								&mut drag_alt,
187								&mut overlay_stale,
188								&mut resize,
189							)?;
190						} else if sidebar.focused() {
191							if key == Key::Ctrl('c') {
192								break;
193							}
194							sidebar.handle_key(key);
195						} else if key == Key::Ctrl('p') || key == Key::Alt('p') {
196							overlay = Some(Overlay::Picker(ModelPicker::open(current_model, ctx)));
197							open_overlay(
198								terminal,
199								renderer,
200								&mut demo,
201								overlay.as_mut().expect("picker just opened"),
202								&mut sidebar,
203								viewport,
204								started.elapsed(),
205								&mut drag_alt,
206								&mut overlay_stale,
207								&mut resize,
208							)?;
209						} else if let Some(scope) = ClipboardRead::for_key(key) {
210							// The terminal did not claim the chord; read the
211							// system clipboard off-thread, preferring images
212							// unless the raw spelling asked for text only. A
213							// failed spawn closes the channel, so the receive
214							// branch below recovers input immediately.
215							paste_read = Some(PasteRead::start(scope));
216						} else {
217							let quit = demo.handle_key(key);
218							if demo.take_switch_request() {
219								overlay = Some(Overlay::Picker(ModelPicker::open(current_model, ctx)));
220								open_overlay(
221									terminal,
222									renderer,
223									&mut demo,
224									overlay.as_mut().expect("picker just opened"),
225									&mut sidebar,
226									viewport,
227									started.elapsed(),
228									&mut drag_alt,
229									&mut overlay_stale,
230									&mut resize,
231								)?;
232							}
233							if quit {
234								break;
235							}
236						}
237						next_frame = Instant::now();
238					},
239					InputEvent::Paste(text) => {
240						let event = overlay.as_mut().map(|active| active.handle_paste(&text));
241						match event {
242							Some(event) => {
243								if apply_overlay_event(
244									event,
245									&mut overlay,
246									&mut current_model,
247									terminal,
248									renderer,
249									&mut demo,
250									&mut sidebar,
251									viewport,
252									started.elapsed(),
253									&mut overlay_stale,
254									&mut resize,
255									ctx,
256								)? {
257									break;
258								}
259							},
260							None if sidebar.focused() => {},
261							None => demo.handle_paste(&text),
262						}
263						next_frame = Instant::now();
264					},
265					InputEvent::Mouse(report) => {
266						// An open overlay owns pointer input — hover, wheel,
267						// and clicks route through the compositor's band,
268						// never the occluded editor beneath.
269						let event = overlay
270							.as_mut()
271							.map(|active| active.handle_mouse(report.col, report.row, report.kind, viewport));
272						match event {
273							Some(event) => {
274								if apply_overlay_event(
275									event,
276									&mut overlay,
277									&mut current_model,
278									terminal,
279									renderer,
280									&mut demo,
281									&mut sidebar,
282									viewport,
283									started.elapsed(),
284									&mut overlay_stale,
285									&mut resize,
286									ctx,
287								)? {
288									break;
289								}
290							},
291							None => {
292								if !sidebar.handle_mouse(report.col, report.row, report.kind, viewport)
293								{
294									demo.handle_mouse(&report);
295								}
296							},
297						}
298						next_frame = Instant::now();
299					},
300					InputEvent::Focus(_) | InputEvent::Response(_) => {},
301				}
302				},
303			},
304			clipboard = async { (&mut paste_read.as_mut().expect("branch gated on Some").clipboard).await },
305				if paste_read.is_some() =>
306			{
307				let read = paste_read.take().expect("branch gated on Some");
308				// A closed channel (the reader thread never spawned) reads
309				// as an empty clipboard.
310				if let Ok(Some(clipboard)) = clipboard
311					&& let Some(text) = clipboard_paste_text(clipboard)
312					&& overlay.is_none()
313					&& !sidebar.focused()
314				{
315					// Ctrl+Shift+V inserts verbatim: no attachment staging,
316					// no large-paste collapse.
317					match read.scope {
318						ClipboardRead::Text => demo.handle_paste_raw(&text),
319						ClipboardRead::Smart => demo.handle_paste(&text),
320					}
321					next_frame = Instant::now();
322				}
323			},
324			// The deadline is absolute, so the frame tick recreating this
325			// branch's future cannot reset it: a hung reader is abandoned and
326			// terminal input re-enables. Dropping the receiver makes the
327			// reader's eventual send fail; the detached thread dies with the
328			// process instead of stalling shutdown.
329			() = deadline(paste_deadline) => {
330				paste_read = None;
331			},
332			() = deadline(Some(next_frame)) => {
333				let now = Instant::now();
334				let resized = observe_resize(terminal, &mut viewport, &mut resize, now)?;
335				demo.set_right_inset(sidebar.reserved(viewport));
336				if overlay.is_some() && resized {
337					overlay_stale = true;
338				}
339				if resize.is_some() {
340					// Drag frames compose exactly one viewport tail at the
341					// new geometry — O(viewport) per frame; the O(history)
342					// transcript reflow waits for the settle rebuild (or the
343					// overlay close). Without an overlay, a width change
344					// borrows the alternate screen (the inline transcript
345					// rewraps underneath) while height-only churn repaints
346					// in place — alt toggling on a height echo can
347					// self-sustain.
348					let preview = demo.render_resize_preview(viewport);
349					if let Some(active) = overlay.as_mut() {
350								  let mut layers = rail_layers(&mut sidebar, viewport, started.elapsed());
351								  layers.push(active.layer(viewport));
352								  renderer.preview_overlaid(&preview, &layers, viewport.height, "")?;
353							  } else {
354								  let width_changed =
355									  resize.is_some_and(|state| state.width_changed);
356								  let alt_enter = if drag_alt || !width_changed {
357									  None
358								  } else {
359									  let staged = terminal.stage_alt_enter(AltScreenUse::Resize);
360									  drag_alt = staged.is_some();
361									  staged
362								  };
363								  let layers = rail_layers(&mut sidebar, viewport, started.elapsed());
364								  renderer.preview_overlaid(
365									  &preview,
366									  &layers,
367									  viewport.height,
368									  alt_enter.as_deref().unwrap_or(""),
369								  )?;
370							  }
371				} else if let Some(active) = overlay.as_mut() {
372					let rendered = demo.render(viewport);
373					let mut layers = rail_layers(&mut sidebar, viewport, started.elapsed());
374					layers.push(active.layer(viewport));
375					renderer.preview_overlaid(rendered.frame, &layers, viewport.height, "")?;
376				} else {
377					let rendered = demo.render(viewport);
378					let layers = rail_layers(&mut sidebar, viewport, started.elapsed());
379					present(renderer, rendered, viewport, &layers)?;
380				}
381				next_frame = now + FRAME_INTERVAL;
382			},
383			() = deadline(resize.map(ResizeState::deadline)) => {
384				let now = Instant::now();
385				if !resize.is_some_and(|state| state.settled(now)) {
386					continue;
387				}
388				if overlay.is_some() {
389					// The overlay keeps holding the alternate screen; the
390					// transcript reflows once at close.
391					overlay_stale = true;
392					resize = None;
393					continue;
394				}
395				demo.set_right_inset(sidebar.reserved(viewport));
396				let rendered = demo.render(viewport);
397				let alt_exit = if drag_alt {
398					drag_alt = false;
399					terminal.stage_alt_leave().unwrap_or("")
400				} else {
401					""
402				};
403				renderer.rebuild(
404					rendered.frame.clone(),
405					viewport.height,
406					rendered.stable_rows,
407					alt_exit,
408				)?;
409				// The rebuild repainted the raw document; recomposite the
410				// rail on top without touching the fresh history.
411				let layers = rail_layers(&mut sidebar, viewport, started.elapsed());
412				if !layers.is_empty() {
413					renderer.present_overlaid(
414						rendered.frame,
415						&[],
416						viewport.height,
417						rendered.stable_rows,
418						&layers,
419					)?;
420				}
421				resize = None;
422				next_frame = now + FRAME_INTERVAL;
423			},
424		}
425	}
426	Ok(())
427}
428
429/// Animates the welcome card until the user resumes into the chat demo
430/// (`Ok(true)`) or quits (`Ok(false)`), keeping `viewport` current across
431/// resizes.
432///
433/// The scene owns the alternate screen for its whole lifetime: entry rides
434/// the first card paint, mouse tracking is active throughout, and every
435/// geometry change repaints in place immediately. The main screen stays
436/// untouched underneath — the caller releases the hold on scene exit.
437async fn run_welcome<'a>(
438	terminal: &'a mut Terminal,
439	renderer: &'a mut Renderer<TtyOut>,
440	charset: Charset,
441	viewport: &'a mut Size,
442) -> io::Result<bool> {
443	let mut alt_enter = terminal.stage_alt_enter(AltScreenUse::Interactive);
444	let mut welcome = Welcome::new(charset);
445	let started = Instant::now();
446	let mut next_frame = Instant::now();
447	loop {
448		tokio::select! {
449			event = terminal.next() => match event? {
450				TerminalEvent::Resize => {
451					if let Some(size) = terminal.take_resize()? {
452						*viewport = size;
453					}
454				},
455				TerminalEvent::Debug(_) => {},
456				TerminalEvent::Closed => return Ok(false),
457				TerminalEvent::Input(event) => {
458					let Some(event) = user_event(terminal, renderer, event)? else {
459						continue;
460					};
461					match event {
462					InputEvent::Key(Key::Enter) => return Ok(true),
463					InputEvent::Key(Key::Esc | Key::Ctrl('c')) => return Ok(false),
464					InputEvent::Mouse(report) if matches!(report.kind, Mouse::Move | Mouse::Drag) => {
465						welcome.point_at(report.col, report.row);
466					},
467					InputEvent::Key(_)
468					| InputEvent::Mouse(_)
469					| InputEvent::Paste(_)
470					| InputEvent::Focus(_)
471					| InputEvent::Response(_) => {},
472				}
473				},
474			},
475			() = deadline(Some(next_frame)) => {
476				let now = Instant::now();
477				if let Some(size) = terminal.take_resize()? {
478					*viewport = size;
479				}
480				let frame = welcome.render(*viewport, started.elapsed());
481				renderer.preview(
482					frame,
483					viewport.height,
484					alt_enter.take().as_deref().unwrap_or(""),
485				)?;
486				next_frame = now + FRAME_INTERVAL;
487			},
488		}
489	}
490}
491
492#[derive(Clone, Copy)]
493struct ResizeState {
494	last_event:    Instant,
495	/// Whether any report in this gesture changed the width; only then does
496	/// the drag borrow the alternate screen.
497	width_changed: bool,
498}
499
500impl ResizeState {
501	const fn new(last_event: Instant, width_changed: bool) -> Self {
502		Self { last_event, width_changed }
503	}
504
505	const fn observe(&mut self, observed_at: Instant, width_changed: bool) {
506		self.last_event = observed_at;
507		self.width_changed |= width_changed;
508	}
509
510	fn deadline(self) -> Instant {
511		self.last_event + RESIZE_SETTLE
512	}
513
514	fn settled(self, now: Instant) -> bool {
515		now >= self.deadline()
516	}
517}
518
519/// Consumes the latest resize — SIGWINCH or DEC 2048 in-band — and (re)arms
520/// the settle window. Same-size reports outside a gesture are echoes
521/// (terminals re-reporting geometry across alternate-screen toggles) and are
522/// swallowed without arming a rebuild.
523fn observe_resize(
524	terminal: &mut Terminal,
525	viewport: &mut Size,
526	resize: &mut Option<ResizeState>,
527	observed_at: Instant,
528) -> io::Result<bool> {
529	let Some(size) = terminal.take_resize()? else {
530		return Ok(false);
531	};
532	if size == *viewport && resize.is_none() {
533		return Ok(false);
534	}
535	let width_changed = size.width != viewport.width;
536	*viewport = size;
537	match resize {
538		Some(state) => state.observe(observed_at, width_changed),
539		None => *resize = Some(ResizeState::new(observed_at, width_changed)),
540	}
541	Ok(true)
542}
543
544fn user_event(
545	terminal: &mut Terminal,
546	renderer: &mut Renderer<TtyOut>,
547	event: InputEvent,
548) -> io::Result<Option<InputEvent>> {
549	if terminal.handle_input_event(&event, renderer)? {
550		// A consumed response may have completed an enhanced-paste (OSC
551		// 5522) conversation; re-inject its payload as ordinary paste input
552		// so the normal routing below stages images and text alike.
553		return Ok(terminal.take_paste().and_then(|pasted| {
554			let text = match pasted {
555				Pasted::Text(text) => text,
556				Pasted::Image(image) => image.persist().ok()?.display().to_string().into(),
557			};
558			Some(InputEvent::Paste(text))
559		}));
560	}
561	Ok(Some(event))
562}
563
564/// Flattens a background clipboard read into paste text: images persist to
565/// a temp file whose path routes like a file drop, and copied file paths
566/// are quoted so spaces survive drop classification.
567fn clipboard_paste_text(clipboard: Clipboard) -> Option<String> {
568	match clipboard {
569		Clipboard::Text(text) => Some(text),
570		Clipboard::Image(image) => Some(image.persist().ok()?.display().to_string()),
571		Clipboard::Paths(paths) => {
572			let mut joined = String::new();
573			for path in &paths {
574				if !joined.is_empty() {
575					joined.push(' ');
576				}
577				joined.push('"');
578				joined.push_str(path);
579				joined.push('"');
580			}
581			Some(joined)
582		},
583	}
584}
585
586fn present(
587	renderer: &mut Renderer<TtyOut>,
588	rendered: RenderedFrame<'_>,
589	viewport: Size,
590	layers: &[Layer<'_>],
591) -> io::Result<()> {
592	renderer
593		.present_overlaid(
594			rendered.frame,
595			rendered.damage.as_slice(),
596			viewport.height,
597			rendered.stable_rows,
598			layers,
599		)
600		.map(|_| ())
601}
602
603/// The session rail as a layer slice for this frame: empty when toggled
604/// off or gated out by a small viewport, so callers composite it
605/// unconditionally.
606fn rail_layers(sidebar: &mut Sidebar, viewport: Size, elapsed: Duration) -> SmallVec<Layer<'_>, 2> {
607	sidebar.layer(viewport, elapsed).into_iter().collect()
608}
609
610/// The modal scene overlay holding the alternate screen: at most one is
611/// open at a time, and a palette action can swap it for the picker in
612/// place — the hold transfers without leaving the alternate screen.
613enum Overlay {
614	Picker(ModelPicker),
615	Palette(CommandPalette),
616}
617
618/// One routed overlay outcome, unified across overlay kinds.
619enum OverlayEvent {
620	/// Input handled; the overlay stays open.
621	Consumed,
622	/// Dismissed without effect.
623	Close,
624	/// The picker chose a model.
625	Pick(usize),
626	/// The palette activated an entry.
627	Run(PaletteAction),
628}
629
630impl From<PickerEvent> for OverlayEvent {
631	fn from(event: PickerEvent) -> Self {
632		match event {
633			PickerEvent::Consumed => Self::Consumed,
634			PickerEvent::Close => Self::Close,
635			PickerEvent::Pick(index) => Self::Pick(index),
636		}
637	}
638}
639
640impl From<PaletteEvent> for OverlayEvent {
641	fn from(event: PaletteEvent) -> Self {
642		match event {
643			PaletteEvent::Consumed => Self::Consumed,
644			PaletteEvent::Close => Self::Close,
645			PaletteEvent::Run(action) => Self::Run(action),
646		}
647	}
648}
649
650impl Overlay {
651	fn handle_key(&mut self, key: Key) -> OverlayEvent {
652		match self {
653			Self::Picker(picker) => picker.handle_key(key).into(),
654			Self::Palette(palette) => palette.handle_key(key).into(),
655		}
656	}
657
658	fn handle_paste(&mut self, text: &str) -> OverlayEvent {
659		match self {
660			Self::Picker(picker) => picker.handle_paste(text).into(),
661			Self::Palette(palette) => palette.handle_paste(text).into(),
662		}
663	}
664
665	fn handle_mouse(&mut self, col: u16, row: u16, kind: Mouse, viewport: Size) -> OverlayEvent {
666		match self {
667			Self::Picker(picker) => picker.handle_mouse(col, row, kind, viewport).into(),
668			Self::Palette(palette) => palette.handle_mouse(col, row, kind, viewport).into(),
669		}
670	}
671
672	fn layer(&mut self, viewport: Size) -> Layer<'_> {
673		match self {
674			Self::Picker(picker) => picker.layer(viewport),
675			Self::Palette(palette) => palette.layer(viewport),
676		}
677	}
678}
679
680/// Takes the alternate screen for the overlay's lifetime: entry rides the
681/// first composited paint, and a drag borrow already in flight simply
682/// transfers ownership (its settled rebuild then waits for close).
683#[expect(clippy::too_many_arguments, reason = "immediate-mode example threads its scene state")]
684fn open_overlay(
685	terminal: &mut Terminal,
686	renderer: &mut Renderer<TtyOut>,
687	demo: &mut Demo,
688	overlay: &mut Overlay,
689	sidebar: &mut Sidebar,
690	viewport: Size,
691	elapsed: Duration,
692	drag_alt: &mut bool,
693	overlay_stale: &mut bool,
694	resize: &mut Option<ResizeState>,
695) -> io::Result<()> {
696	if *drag_alt || resize.take().is_some() {
697		*drag_alt = false;
698		*overlay_stale = true;
699	}
700	let alt_enter = terminal.stage_alt_enter(AltScreenUse::Interactive);
701	let rendered = demo.render(viewport);
702	let mut layers = rail_layers(sidebar, viewport, elapsed);
703	layers.push(overlay.layer(viewport));
704	renderer
705		.preview_overlaid(
706			rendered.frame,
707			&layers,
708			viewport.height,
709			alt_enter.as_deref().unwrap_or(""),
710		)
711		.map(|_| ())
712}
713
714/// Releases the overlay's alternate-screen hold. Geometry churn while held
715/// rebuilds native history inside the same synchronized update as the buffer
716/// switch; otherwise the untouched main screen restores byte-exactly and one
717/// full-viewport present revalidates changes that only ever painted the
718/// alternate screen (streamed demo rows, the picked model).
719#[expect(clippy::too_many_arguments, reason = "immediate-mode example threads its scene state")]
720fn close_overlay(
721	terminal: &mut Terminal,
722	renderer: &mut Renderer<TtyOut>,
723	demo: &mut Demo,
724	sidebar: &mut Sidebar,
725	viewport: Size,
726	elapsed: Duration,
727	overlay_stale: &mut bool,
728	resize: &mut Option<ResizeState>,
729) -> io::Result<()> {
730	*resize = None;
731	let rendered = demo.render(viewport);
732	let layers = rail_layers(sidebar, viewport, elapsed);
733	if *overlay_stale {
734		*overlay_stale = false;
735		let alt_exit = terminal.stage_alt_leave().unwrap_or("");
736		renderer.rebuild(rendered.frame.clone(), viewport.height, rendered.stable_rows, alt_exit)?;
737		if !layers.is_empty() {
738			// The rebuild repainted the raw document; recomposite the rail
739			// without touching the fresh history.
740			renderer.present_overlaid(
741				rendered.frame,
742				&[],
743				viewport.height,
744				rendered.stable_rows,
745				&layers,
746			)?;
747		}
748	} else {
749		terminal.leave_alt()?;
750		renderer.present_overlaid(
751			rendered.frame,
752			&[(0, rendered.frame.size().height)],
753			viewport.height,
754			rendered.stable_rows,
755			&layers,
756		)?;
757	}
758	Ok(())
759}
Source

pub const fn committed_rows(&self) -> u16

Returns the number of finalized rows physically stored above the viewport.

Source

pub const fn window_top(&self) -> u16

Returns the document row currently shown at the viewport top.

Source

pub fn screen_text(&self) -> Vec<String>

Renders the retained physical screen model — the committed frame composed with its stored viewport layers — as visible text, one right-trimmed string per viewport row.

This is what the terminal currently shows, driving the OMP_TUI_DEBUG text op. Empty before the first present or rebuild.

Source

pub const fn screen_cursor(&self) -> Option<(u16, u16)>

Screen coordinates (row, column) of the visible hardware cursor, when one was placed by the last present.

Source

pub const fn output_state(&self) -> OutputState

Returns whether terminal output is connected or was abandoned after its unflushed backlog crossed the safety limit.

Source

pub const fn writer_mut(&mut self) -> &mut W

Borrows the output writer for terminal session teardown.

Source

pub fn into_inner(self) -> W

Returns the output writer after the renderer is no longer needed.

Auto Trait Implementations§

§

impl<W> Freeze for Renderer<W>
where W: Freeze,

§

impl<W> RefUnwindSafe for Renderer<W>
where W: RefUnwindSafe,

§

impl<W> Send for Renderer<W>
where W: Send,

§

impl<W> Sync for Renderer<W>
where W: Sync,

§

impl<W> Unpin for Renderer<W>
where W: Unpin,

§

impl<W> UnsafeUnpin for Renderer<W>
where W: UnsafeUnpin,

§

impl<W> UnwindSafe for Renderer<W>
where W: UnwindSafe,

Blanket Implementations§

Source§

impl<T> Any for T
where T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<T> Downcast for T
where T: Any,

Source§

fn into_any(self: Box<T>) -> Box<dyn Any>

Convert Box<dyn Trait> (where Trait: Downcast) to Box<dyn Any>. Box<dyn Any> can then be further downcast into Box<ConcreteType> where ConcreteType implements Trait.
Source§

fn into_any_rc(self: Rc<T>) -> Rc<dyn Any>

Convert Rc<Trait> (where Trait: Downcast) to Rc<Any>. Rc<Any> can then be further downcast into Rc<ConcreteType> where ConcreteType implements Trait.
Source§

fn as_any(&self) -> &(dyn Any + 'static)

Convert &Trait (where Trait: Downcast) to &Any. This is needed since Rust cannot generate &Any’s vtable from &Trait’s.
Source§

fn as_any_mut(&mut self) -> &mut (dyn Any + 'static)

Convert &mut Trait (where Trait: Downcast) to &Any. This is needed since Rust cannot generate &mut Any’s vtable from &mut Trait’s.
Source§

impl<T> DowncastSync for T
where T: Any + Send + Sync,

Source§

fn into_any_arc(self: Arc<T>) -> Arc<dyn Any + Send + Sync>

Convert Arc<Trait> (where Trait: Downcast) to Arc<Any>. Arc<Any> can then be further downcast into Arc<ConcreteType> where ConcreteType implements Trait.
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

Source§

impl<T, U> Into<U> for T
where U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

That is, this conversion is whatever the implementation of From<T> for U chooses to do.

Source§

impl<T> IntoEither for T

Source§

fn into_either(self, into_left: bool) -> Either<Self, Self>

Converts self into a Left variant of Either<Self, Self> if into_left is true. Converts self into a Right variant of Either<Self, Self> otherwise. Read more
Source§

fn into_either_with<F>(self, into_left: F) -> Either<Self, Self>
where F: FnOnce(&Self) -> bool,

Converts self into a Left variant of Either<Self, Self> if into_left(&self) returns true. Converts self into a Right variant of Either<Self, Self> otherwise. Read more
Source§

impl<T, U> TryFrom<U> for T
where U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.