Skip to main content

Terminal

Struct Terminal 

Source
pub struct Terminal { /* private fields */ }
Expand description

Owns raw mode and every terminal mode enabled for an interactive session.

Only one Terminal may be active in a process. Normal teardown is idempotent, and panic plus fatal-signal handlers perform an allocation-free blind restore when ordinary unwinding cannot run.

Implementations§

Source§

impl Terminal

Source

pub fn enter(options: TerminalOptions) -> Result<Self>

Takes ownership of the controlling terminal and emits one capability-aware entry batch.

Examples found in repository?
examples/footers.rs (line 56)
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 59)
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 leave(&mut self) -> Result<()>

Restores every mode enabled by Terminal::enter and raw mode.

Keyboard enhancement, mouse reporting, and bracketed paste are disabled before input is drained, preventing late key-release or mouse-motion reports from reaching the parent shell. Calling this method more than once is harmless.

Source

pub fn emergency_restore()

Immediately performs the blind, async-signal-safe terminal restore.

This is intended for crash paths. It bypasses buffered output and writes directly to the active controlling-terminal descriptor.

Source

pub fn captured_stderr(&self) -> &[u8]

Returns stderr bytes captured while this terminal owned the viewport.

The slice is finalized by Terminal::leave. While active it contains bytes drained by the event pump so far. Capture retains the newest 64 KiB.

Source

pub const fn caps(&self) -> TerminalCaps

Returns the capabilities resolved for this terminal session.

Source

pub const fn keymap(&self) -> &Keymap

Returns the active chord-to-key map.

Source

pub fn edit_keymap(&mut self, edit: impl FnOnce(&mut Keymap))

Edits the chord-to-key map; changes reach the event actor’s decoder before the next decoded chord.

Source

pub fn size(&self) -> Result<Size>

Returns the controlling terminal’s current cell dimensions.

Examples found in repository?
examples/footers.rs (line 74)
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}
More examples
Hide additional examples
examples/chat/main.rs (line 95)
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}
Source

pub async fn next(&mut self) -> Result<TerminalEvent>

Waits for the next terminal event.

One async mailbox carries everything in arrival order: decoded input (real terminal bytes and OMP_TUI_DEBUG injections alike), debug queries, and closure. Resize rides a watch side channel and this biased select observes it before any queued input backlog; resolve the geometry with Terminal::take_resize.

Terminal-owned debug queries (text, info, resize, quit) are answered here when dequeued — after every previously injected event — and never surface; a quit acknowledgement returns as C-c input. Retained-tree queries (crate::DebugOp::Frame/Tree/Values) surface as [TerminalEvent::Debug] for hosts that can answer them.

Terminal response events are returned like any input; forward them to Terminal::handle_input_event so appearance, geometry, and pixel-size state stay current.

Cancel-safe: events stay queued until returned.

§Errors

Fails once the terminal input closed.

Examples found in repository?
examples/footers.rs (line 79)
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}
More examples
Hide additional examples
examples/chat/main.rs (line 132)
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}
Source

pub fn take_resize(&mut self) -> Result<Option<Size>>

Takes the latest resize notification and returns its authoritative size.

SIGWINCH and DEC 2048 in-band geometry share this channel. A resize is reported once; operating-system geometry wins when it is available.

Examples found in repository?
examples/chat/main.rs (line 451)
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}
More examples
Hide additional examples
examples/footers.rs (line 102)
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 sync_renderer<W: Write>(&self, renderer: &mut Renderer<W>) -> Result<()>

Applies the latest DEC 2048 cell-pixel geometry to a renderer.

Examples found in repository?
examples/footers.rs (line 99)
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 const fn cell_pixel_size(&self) -> Option<(u16, u16)>

Returns the latest terminal-reported cell dimensions in pixels.

Source

pub fn size_changed(&mut self) -> bool

Consumes a SIGWINCH-backed resize notification.

Multiplexers often deliver a burst of intermediate sizes; there this method returns true only after the observed generation has remained unchanged for 50 ms. Callers should continue polling while it returns false after a resize signal.

Source

pub const fn appearance(&self) -> Option<Appearance>

Returns the most recently classified terminal background appearance.

Source

pub const fn in_band_size(&self) -> Option<Size>

Returns the effective geometry from the latest in-band resize report.

The operating-system size replaces reported dimensions when they disagree.

Source

pub fn on_appearance_change( &mut self, callback: impl FnMut(Appearance) + Send + 'static, )

Registers a callback for dark/light appearance flips.

A callback registered after initial OSC 11 detection is immediately invoked with the current appearance.

Source

pub fn handle_response<W: Write>( &mut self, response: &TerminalResponse, renderer: &mut Renderer<W>, ) -> Result<bool>

Applies a decoded terminal response to appearance and image geometry.

Returns true when the response was consumed by terminal state plumbing.

Source

pub fn handle_input_event<W: Write>( &mut self, event: &InputEvent, renderer: &mut Renderer<W>, ) -> Result<bool>

Applies terminal-response events while leaving user input untouched.

Returns true only for a response consumed by Terminal::handle_response.

Examples found in repository?
examples/chat/main.rs (line 549)
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}
Source

pub const fn take_paste(&mut self) -> Option<Pasted>

Consumes a completed OSC 5522 enhanced-paste payload.

Terminals supporting DEC mode 5522 (see TerminalCaps::paste_events) deliver terminal-level pastes as out-of-band clipboard offers instead of bracketed paste, which is how an image paste reaches the application. The offer conversation runs inside Terminal::handle_response; once it completes, the assembled Pasted payload waits here for the host — mirroring Terminal::take_resize.

Examples found in repository?
examples/chat/main.rs (line 553)
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}
Source

pub fn copy_to_clipboard(&mut self, text: &str) -> Result<()>

Copies text to the system clipboard.

Writes OSC 52 to the terminal first (works over SSH and multiplexers that forward it), then spawns a detached best-effort native write via crate::paste::write_clipboard_text for local sessions whose terminal ignores OSC 52.

Source

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

Enters the alternate screen and re-pushes screen-local Kitty keyboard flags. Repeated calls are deduplicated.

Source

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

Pops screen-local Kitty keyboard flags and leaves the alternate screen. Repeated calls are deduplicated.

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}
85
86#[expect(
87	clippy::future_not_send,
88	reason = "chat components are deliberately confined to their terminal event-loop thread"
89)]
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}
More examples
Hide additional examples
examples/footers.rs (line 60)
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}
Source

pub fn with_alt_screen<T>( &mut self, operation: impl FnOnce(&mut Self) -> Result<T>, ) -> Result<T>

Runs an operation while the alternate screen is active, restoring the main screen even when the operation returns an error.

Source

pub fn stage_alt_enter(&mut self, purpose: AltScreenUse) -> Option<Str>

Flips alternate-screen bookkeeping on and returns the entry sequence — buffer switch, screen-local Kitty flag push, and, for an AltScreenUse::Interactive hold in an inline-mouse-off session, mouse tracking — for the caller to embed at the head of its next synchronized paint, keeping the switch atomic with the first frame drawn there. None when the alternate screen is already active.

Renderer::preview and Renderer::preview_overlaid accept the sequence as their leading sequence. A passive AltScreenUse::Resize borrow never touches mouse modes: motion reports would flood input mid-drag. Teardown and emergency restore treat the alternate screen as active immediately, so the sequence must reach the terminal promptly.

Examples found in repository?
examples/footers.rs (line 76)
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}
More examples
Hide additional examples
examples/chat/main.rs (line 359)
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 stage_alt_leave(&mut self) -> Option<&'static str>

Counterpart of Terminal::stage_alt_enter: flips bookkeeping off and returns the exit sequence — mouse tracking off when this alt ownership enabled it, Kitty flag pop, buffer switch — so leaving the alternate screen and repainting the main screen land in one synchronized update (see Renderer::rebuild). None when already on the main screen.

Examples found in repository?
examples/chat/main.rs (line 399)
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 hide_cursor(&mut self) -> Result<()>

Hides the cursor unless its tracked state is already hidden.

Source

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

Shows the cursor unless its tracked state is already visible.

Source

pub fn set_title(&mut self, title: &str) -> Result<()>

Sets both the terminal window title and icon name with OSC 0.

Control characters are removed so untrusted text cannot terminate the OSC or inject another terminal command. Entry pushes the previous title with XTGETTITLE’s title-stack operation and teardown pops it; terminals without a title stack safely ignore those operations.

Source

pub fn set_progress(&mut self, progress: Progress) -> Result<()>

Updates the host’s OSC 9;4 progress indicator.

Percentages are clamped to 0..=100. Every non-clear state is refreshed once per second for terminals that expire stale indicators.

Trait Implementations§

Source§

impl Drop for Terminal

Source§

fn drop(&mut self)

Executes the destructor for this type. Read more
Source§

fn pin_drop(self: Pin<&mut Self>)

🔬This is a nightly-only experimental API. (pin_ergonomics)
Execute the destructor for this type, but different to Drop::drop, it requires self to be pinned. Read more

Auto Trait Implementations§

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> 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.