pub struct Renderer<W: Write> { /* private fields */ }Expand description
Renders an immutable document prefix and a mutable viewport-local suffix.
stable_rows declares the leading rows that will never change again. Stable
rows enter native scrollback only when they leave the visible top edge.
Already-clipped stable rows remain protected but deferred until a rebuild,
avoiding a viewport replay that would displace native text selections.
A committed or previously declared-stable mutation is rejected before any
terminal output, because native scrollback has no addressable cells. The
retained physical screen model composes the raw previous frame with its
stored viewport layers.
Implementations§
Source§impl<W: Write> Renderer<W>
impl<W: Write> Renderer<W>
Sourcepub fn new(writer: W) -> Self
pub fn new(writer: W) -> Self
Creates a renderer whose first document clears only the visible viewport.
Examples found in repository?
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
Sourcepub fn apply_caps(&mut self, caps: &TerminalCaps) -> Result<()>
pub fn apply_caps(&mut self, caps: &TerminalCaps) -> Result<()>
Configures every capability-driven renderer option from resolved caps.
§Errors
Rejects zero cell-pixel dimensions.
Examples found in repository?
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
Sourcepub fn register_image(
&mut self,
id: u32,
png_bytes: impl Into<CowBytes<'static>>,
) -> Result<()>
pub fn register_image( &mut self, id: u32, png_bytes: impl Into<CowBytes<'static>>, ) -> Result<()>
Registers PNG bytes for a typed terminal image ID.
Protocol encoding is deferred until a presented frame references the ID. Re-registering an ID replaces its bytes and protocol cache.
§Errors
Rejects ID zero and IDs wider than Kitty’s 24-bit placeholder encoding.
Examples found in repository?
192async fn main() -> io::Result<()> {
193 let mut app = AppOptions::new()
194 .mouse()
195 .probe(Duration::from_millis(150))
196 .graphics_with(forced_from_args)
197 .start(|env| build_ui(env.viewport, env.ctx))
198 .await?;
199 if app.caps().graphics != Graphics::Cells {
200 for (index, provider) in PROVIDERS.iter().enumerate() {
201 let png = tokio::fs::read(format!("{ASSET_DIR}/{}.png", provider.id)).await?;
202 app.renderer_mut().register_image(
203 u32::try_from(index + 1).expect("provider count fits image IDs"),
204 png,
205 )?;
206 }
207 app.ui_mut().invalidate(SCROLL_ID);
208 }
209 show_stats(&mut app, None);
210 let mut chosen: Option<String> = None;
211 while let Some(event) = app.next().await? {
212 match event {
213 AppEvent::Resized(viewport) => {
214 app.ui_mut().set_height(SCROLL_ID, scroll_height(viewport));
215 },
216 AppEvent::Pressed(id) => chosen = Some(id.to_string()),
217 _ => {},
218 }
219 show_stats(&mut app, chosen.as_deref());
220 }
221 Ok(())
222}Sourcepub const fn set_graphics(&mut self, graphics: Graphics)
pub const fn set_graphics(&mut self, graphics: Graphics)
Selects how typed image cells are materialized.
Set this before the first presentation. Graphics::Cells,
Graphics::Sixel, and Graphics::KittyDirect materialize typed
cells as ordinary blanks; Graphics::KittyPlaceholders uses Unicode
placeholders.
Sourcepub const fn set_sync_output(&mut self, enabled: bool)
pub const fn set_sync_output(&mut self, enabled: bool)
Enables or disables DEC synchronized-output wrapping.
Wrapping is enabled by default to preserve the renderer’s historical behavior. Capability detection should disable it for unsupported terminals.
Sourcepub const fn set_screen_to_scrollback(&mut self, enabled: bool)
pub const fn set_screen_to_scrollback(&mut self, enabled: bool)
Enables or disables moving cleared viewport content to native scrollback.
When enabled, a full viewport clear first emits Kitty’s CSI 22 J
extension. It is disabled by default.
Sourcepub const fn set_margin_scrollback(&mut self, enabled: bool)
pub const fn set_margin_scrollback(&mut self, enabled: bool)
Enables committing scrolled-out rows through a top-anchored DECSTBM region instead of a whole-screen scroll.
Screen rows below the region never move during a commit, and native
scrollback receives exactly the same history as a whole-screen
scroll. Whether a terminal-native text selection over the pinned
rows survives is a separate, terminal-specific property: kitty and
Alacritty transform selections correctly on region scrolls; ghostty,
iTerm2, and xterm.js leave them anchored to pre-scroll storage rows,
so they drift upward — matching what a whole-screen scroll does to
selections over stationary live content repainted back into place;
WezTerm clears them. Enable this only for terminals that move rows
scrolled out of a top-anchored region into native scrollback (see
TerminalCaps::margin_scrollback); it is disabled by default.
Sourcepub const fn set_hyperlinks(&mut self, enabled: bool)
pub const fn set_hyperlinks(&mut self, enabled: bool)
Enables or disables OSC 8 hyperlink materialization.
Link identities remain attached to frame cells while disabled, but output stays byte-for-byte identical to ordinary styled text.
Sourcepub fn set_cell_pixel_size(&mut self, width: u16, height: u16) -> Result<()>
pub fn set_cell_pixel_size(&mut self, width: u16, height: u16) -> Result<()>
Sets the terminal cell size used to scale sixel placements.
The default is 9 by 18 pixels per cell, matching pi’s nominal terminal metrics. Detection code may override it before presentation.
§Errors
Rejects a zero pixel dimension.
Sourcepub const fn set_tmux_passthrough(&mut self, enabled: bool)
pub const fn set_tmux_passthrough(&mut self, enabled: bool)
Enables tmux DCS passthrough for Kitty and sixel graphics sequences.
Cursor movement, synchronized output, and ordinary text styling remain direct terminal output.
Sourcepub fn present(
&mut self,
next: Frame,
viewport_height: u16,
stable_rows: u16,
) -> Result<PaintStats>
pub fn present( &mut self, next: Frame, viewport_height: u16, stable_rows: u16, ) -> Result<PaintStats>
Paints a logical document with an immutable leading-row boundary.
The caller must disable terminal autowrap and keep terminal geometry
fixed while the renderer is active; the renderer itself re-enables
DECAWM transiently to join flagged soft-wrap boundaries (see
Frame::set_soft_wrap) so native selection and scrollback copy
them as one unbroken line. Advancing stable_rows is permanent,
and committed history makes the document height a ratchet: between
rebuilds the document may only grow, so transient rows (pickers, extra
input lines) must be absorbed by the caller rather than shrinking the
frame.
§Errors
Rejects zero or changed geometry, a retreating stable boundary, mutation within the prior stable prefix, or a document whose tail shrank below committed history. Writer failure poisons the renderer because its physical state is unknown.
Sourcepub fn present_ref(
&mut self,
next: &Frame,
viewport_height: u16,
stable_rows: u16,
) -> Result<PaintStats>
pub fn present_ref( &mut self, next: &Frame, viewport_height: u16, stable_rows: u16, ) -> Result<PaintStats>
Renderer::present without taking the frame: diffs against the
retained previous frame, then clone_froms the borrowed one into
it — reusing the existing cell allocation instead of copying a
whole frame per paint. Cost is still O(grid) cell clones per call;
retained callers that track their own damage should prefer
Renderer::present_damaged.
§Errors
Same contract as Renderer::present.
Sourcepub fn present_overlaid(
&mut self,
next: &Frame,
damaged: &[(u16, u16)],
viewport_height: u16,
stable_rows: u16,
layers: &[Layer<'_>],
) -> Result<PaintStats>
pub fn present_overlaid( &mut self, next: &Frame, damaged: &[(u16, u16)], viewport_height: u16, stable_rows: u16, layers: &[Layer<'_>], ) -> Result<PaintStats>
Paints a damaged raw document with declarative viewport-anchored layers.
damaged follows Renderer::present_damaged. Layers composite only
into the live viewport while history commits keep flowing: a row
leaving the window is repainted from the raw document before it
scrolls into native scrollback, so layer cells never reach history.
Direct-drawn sixel, Kitty-direct, and iTerm2 images remain raw and are
not occluded; Kitty placeholder cells participate in composition.
§Errors
Same contract as Renderer::present.
Examples found in repository?
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}Sourcepub fn present_damaged(
&mut self,
next: &Frame,
damaged: &[(u16, u16)],
viewport_height: u16,
stable_rows: u16,
) -> Result<PaintStats>
pub fn present_damaged( &mut self, next: &Frame, damaged: &[(u16, u16)], viewport_height: u16, stable_rows: u16, ) -> Result<PaintStats>
Renderer::present_ref with a caller-supplied damage list: only rows
inside damaged (start, end) ranges are validated and snapshotted.
The caller guarantees every changed row is covered; the full grid is
copied only on the initial paint.
§Errors
Same contract as Renderer::present.
Sourcepub fn clear_layers(&mut self) -> Result<()>
pub fn clear_layers(&mut self) -> Result<()>
Repaints every composited viewport-layer band from the raw document and drops the stored layers.
The final inline screen persists into native scrollback once the
host exits and the shell resumes scrolling, so teardown must not
leave layer cells composited — crate::App does this
automatically, and manual hosts call it before dropping their
crate::Terminal. Call it on the main screen (release any
alternate-screen hold first); with no stored layers, or while the
alternate screen is active, nothing is written.
§Errors
Propagates writer failures, which poison the renderer.
Sourcepub fn preview(
&mut self,
next: &Frame,
viewport_height: u16,
leading_sequence: &str,
) -> Result<PaintStats>
pub fn preview( &mut self, next: &Frame, viewport_height: u16, leading_sequence: &str, ) -> Result<PaintStats>
Paints only the current raw document tail without changing committed state.
Resize handlers use this on an alternate buffer while normal-buffer
history remains untouched. Stored overlay layers are deliberately
ignored; leading_sequence is emitted inside the synchronized update,
before the viewport paint. Overlays go through
Renderer::preview_overlaid.
§Errors
Rejects zero geometry. Writer failure poisons the renderer because its physical state is unknown.
Examples found in repository?
437async fn run_welcome<'a>(
438 terminal: &'a mut Terminal,
439 renderer: &'a mut Renderer<TtyOut>,
440 charset: Charset,
441 viewport: &'a mut Size,
442) -> io::Result<bool> {
443 let mut alt_enter = terminal.stage_alt_enter(AltScreenUse::Interactive);
444 let mut welcome = Welcome::new(charset);
445 let started = Instant::now();
446 let mut next_frame = Instant::now();
447 loop {
448 tokio::select! {
449 event = terminal.next() => match event? {
450 TerminalEvent::Resize => {
451 if let Some(size) = terminal.take_resize()? {
452 *viewport = size;
453 }
454 },
455 TerminalEvent::Debug(_) => {},
456 TerminalEvent::Closed => return Ok(false),
457 TerminalEvent::Input(event) => {
458 let Some(event) = user_event(terminal, renderer, event)? else {
459 continue;
460 };
461 match event {
462 InputEvent::Key(Key::Enter) => return Ok(true),
463 InputEvent::Key(Key::Esc | Key::Ctrl('c')) => return Ok(false),
464 InputEvent::Mouse(report) if matches!(report.kind, Mouse::Move | Mouse::Drag) => {
465 welcome.point_at(report.col, report.row);
466 },
467 InputEvent::Key(_)
468 | InputEvent::Mouse(_)
469 | InputEvent::Paste(_)
470 | InputEvent::Focus(_)
471 | InputEvent::Response(_) => {},
472 }
473 },
474 },
475 () = deadline(Some(next_frame)) => {
476 let now = Instant::now();
477 if let Some(size) = terminal.take_resize()? {
478 *viewport = size;
479 }
480 let frame = welcome.render(*viewport, started.elapsed());
481 renderer.preview(
482 frame,
483 viewport.height,
484 alt_enter.take().as_deref().unwrap_or(""),
485 )?;
486 next_frame = now + FRAME_INTERVAL;
487 },
488 }
489 }
490}More examples
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}Sourcepub fn preview_overlaid(
&mut self,
next: &Frame,
layers: &[Layer<'_>],
viewport_height: u16,
leading_sequence: &str,
) -> Result<PaintStats>
pub fn preview_overlaid( &mut self, next: &Frame, layers: &[Layer<'_>], viewport_height: u16, leading_sequence: &str, ) -> Result<PaintStats>
Renderer::preview with declarative viewport-anchored layers.
The document tail and every visible layer composite into one throwaway
synchronized paint while committed history and stored layers stay
untouched. Alternate-screen holders — fullscreen scenes and modal
overlays — repaint with this on damage or geometry change;
Renderer::present_overlaid is the normal-buffer counterpart.
§Errors
Same contract as Renderer::preview.
Examples found in repository?
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}Sourcepub fn rebuild(
&mut self,
next: Frame,
viewport_height: u16,
stable_rows: u16,
leading_sequence: &str,
) -> Result<PaintStats>
pub fn rebuild( &mut self, next: Frame, viewport_height: u16, stable_rows: u16, leading_sequence: &str, ) -> Result<PaintStats>
Clears and reconstructs native history at new terminal geometry.
The synchronized update emits leading_sequence, clears scrollback once,
then writes the stable prefix and current viewport. The reconstructed
frame becomes the baseline for subsequent Self::present calls.
§Errors
Rejects zero geometry or a stable boundary beyond the document. Writer failure poisons the renderer because history may be partially rebuilt.
Examples found in repository?
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}Sourcepub const fn committed_rows(&self) -> u16
pub const fn committed_rows(&self) -> u16
Returns the number of finalized rows physically stored above the viewport.
Sourcepub const fn window_top(&self) -> u16
pub const fn window_top(&self) -> u16
Returns the document row currently shown at the viewport top.
Sourcepub fn screen_text(&self) -> Vec<String>
pub fn screen_text(&self) -> Vec<String>
Renders the retained physical screen model — the committed frame composed with its stored viewport layers — as visible text, one right-trimmed string per viewport row.
This is what the terminal currently shows, driving the OMP_TUI_DEBUG
text op. Empty before the first present or rebuild.
Sourcepub const fn screen_cursor(&self) -> Option<(u16, u16)>
pub const fn screen_cursor(&self) -> Option<(u16, u16)>
Screen coordinates (row, column) of the visible hardware cursor, when one was placed by the last present.
Sourcepub const fn output_state(&self) -> OutputState
pub const fn output_state(&self) -> OutputState
Returns whether terminal output is connected or was abandoned after its unflushed backlog crossed the safety limit.
Sourcepub const fn writer_mut(&mut self) -> &mut W
pub const fn writer_mut(&mut self) -> &mut W
Borrows the output writer for terminal session teardown.
Sourcepub fn into_inner(self) -> W
pub fn into_inner(self) -> W
Returns the output writer after the renderer is no longer needed.
Auto Trait Implementations§
impl<W> Freeze for Renderer<W>where
W: Freeze,
impl<W> RefUnwindSafe for Renderer<W>where
W: RefUnwindSafe,
impl<W> Send for Renderer<W>where
W: Send,
impl<W> Sync for Renderer<W>where
W: Sync,
impl<W> Unpin for Renderer<W>where
W: Unpin,
impl<W> UnsafeUnpin for Renderer<W>where
W: UnsafeUnpin,
impl<W> UnwindSafe for Renderer<W>where
W: UnwindSafe,
Blanket Implementations§
Source§impl<T> BorrowMut<T> for Twhere
T: ?Sized,
impl<T> BorrowMut<T> for Twhere
T: ?Sized,
Source§fn borrow_mut(&mut self) -> &mut T
fn borrow_mut(&mut self) -> &mut T
Source§impl<T> Downcast for Twhere
T: Any,
impl<T> Downcast for Twhere
T: Any,
Source§fn into_any(self: Box<T>) -> Box<dyn Any>
fn into_any(self: Box<T>) -> Box<dyn Any>
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>
fn into_any_rc(self: Rc<T>) -> Rc<dyn Any>
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)
fn as_any(&self) -> &(dyn Any + 'static)
&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)
fn as_any_mut(&mut self) -> &mut (dyn Any + 'static)
&mut Trait (where Trait: Downcast) to &Any. This is needed since Rust cannot
generate &mut Any’s vtable from &mut Trait’s.Source§impl<T> DowncastSync for T
impl<T> DowncastSync for T
Source§impl<T> IntoEither for T
impl<T> IntoEither for T
Source§fn into_either(self, into_left: bool) -> Either<Self, Self> ⓘ
fn into_either(self, into_left: bool) -> Either<Self, Self> ⓘ
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 moreSource§fn into_either_with<F>(self, into_left: F) -> Either<Self, Self> ⓘ
fn into_either_with<F>(self, into_left: F) -> Either<Self, Self> ⓘ
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