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
impl Terminal
Sourcepub fn enter(options: TerminalOptions) -> Result<Self>
pub fn enter(options: TerminalOptions) -> Result<Self>
Takes ownership of the controlling terminal and emits one capability-aware entry batch.
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 leave(&mut self) -> Result<()>
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.
Sourcepub fn emergency_restore()
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.
Sourcepub fn captured_stderr(&self) -> &[u8] ⓘ
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.
Sourcepub const fn caps(&self) -> TerminalCaps
pub const fn caps(&self) -> TerminalCaps
Returns the capabilities resolved for this terminal session.
Sourcepub fn edit_keymap(&mut self, edit: impl FnOnce(&mut Keymap))
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.
Sourcepub fn size(&self) -> Result<Size>
pub fn size(&self) -> Result<Size>
Returns the controlling terminal’s current cell dimensions.
Examples found in repository?
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
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}Sourcepub async fn next(&mut self) -> Result<TerminalEvent>
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?
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
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}Sourcepub fn take_resize(&mut self) -> Result<Option<Size>>
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?
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
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 sync_renderer<W: Write>(&self, renderer: &mut Renderer<W>) -> Result<()>
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?
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 const fn cell_pixel_size(&self) -> Option<(u16, u16)>
pub const fn cell_pixel_size(&self) -> Option<(u16, u16)>
Returns the latest terminal-reported cell dimensions in pixels.
Sourcepub fn size_changed(&mut self) -> bool
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.
Sourcepub const fn appearance(&self) -> Option<Appearance>
pub const fn appearance(&self) -> Option<Appearance>
Returns the most recently classified terminal background appearance.
Sourcepub const fn in_band_size(&self) -> Option<Size>
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.
Sourcepub fn on_appearance_change(
&mut self,
callback: impl FnMut(Appearance) + Send + 'static,
)
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.
Sourcepub fn handle_response<W: Write>(
&mut self,
response: &TerminalResponse,
renderer: &mut Renderer<W>,
) -> Result<bool>
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.
Sourcepub fn handle_input_event<W: Write>(
&mut self,
event: &InputEvent,
renderer: &mut Renderer<W>,
) -> Result<bool>
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?
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}Sourcepub const fn take_paste(&mut self) -> Option<Pasted>
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?
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}Sourcepub fn copy_to_clipboard(&mut self, text: &str) -> Result<()>
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.
Sourcepub fn enter_alt(&mut self) -> Result<()>
pub fn enter_alt(&mut self) -> Result<()>
Enters the alternate screen and re-pushes screen-local Kitty keyboard flags. Repeated calls are deduplicated.
Sourcepub fn leave_alt(&mut self) -> Result<()>
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?
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
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}Sourcepub fn with_alt_screen<T>(
&mut self,
operation: impl FnOnce(&mut Self) -> Result<T>,
) -> Result<T>
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.
Sourcepub fn stage_alt_enter(&mut self, purpose: AltScreenUse) -> Option<Str>
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?
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
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 stage_alt_leave(&mut self) -> Option<&'static str>
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?
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 hide_cursor(&mut self) -> Result<()>
pub fn hide_cursor(&mut self) -> Result<()>
Hides the cursor unless its tracked state is already hidden.
Sourcepub fn show_cursor(&mut self) -> Result<()>
pub fn show_cursor(&mut self) -> Result<()>
Shows the cursor unless its tracked state is already visible.
Sourcepub fn set_title(&mut self, title: &str) -> Result<()>
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.
Sourcepub fn set_progress(&mut self, progress: Progress) -> Result<()>
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§
Auto Trait Implementations§
impl !Freeze for Terminal
impl !RefUnwindSafe for Terminal
impl !Sync for Terminal
impl !UnwindSafe for Terminal
impl Send for Terminal
impl Unpin for Terminal
impl UnsafeUnpin for Terminal
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> 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