Skip to main content

omp_tui/
runtime.rs

1//! Tokio host for retained terminal UIs.
2//!
3//! [`App`] owns capability resolution, terminal entry, input routing, animation
4//! wakes, resize coalescing, and presentation:
5//!
6//! ```no_run
7//! use std::io;
8//!
9//! use omp_tui::{AppOptions, Ui};
10//!
11//! #[tokio::main]
12//! async fn main() -> io::Result<()> {
13//! 	let mut app = AppOptions::new()
14//! 		.start(|env| Ui::from_markup("hello", env.viewport.width, env.ctx).unwrap())
15//! 		.await?;
16//! 	while let Some(event) = app.next().await? {
17//! 		let _ = event;
18//! 	}
19//! 	Ok(())
20//! }
21//! ```
22//!
23//! [`UiHandle`] queues mutations from synchronous threads or asynchronous
24//! tasks. Immediate-mode hosts instead drive [`Terminal::next`] with their
25//! own `tokio::select!`; `examples/chat` is the reference.
26
27use std::{fmt, io, time::Duration};
28
29use omp_core::Str;
30use smallvec::SmallVec;
31use tokio_util::sync::CancellationToken;
32
33use crate::{
34	Appearance, CursorStyle, Graphics, InputEvent, Key, OverlayId, PaintStats, ProbeResults,
35	Renderer, Size, Terminal, TerminalCaps, TerminalOptions, TerminalResponse, Theme, TtyOut, Ui,
36	UiContext, UiEvent,
37	component::Slot,
38	components::ImgState,
39	detect, negotiate_async,
40	paste::{Clipboard, ClipboardRead, Pasted, PastedImage},
41	pump::{DebugOp, DebugQuery, TerminalEvent},
42};
43
44const RESIZE_SETTLE: Duration = Duration::from_millis(120);
45const RESIZE_RECHECK: Duration = Duration::from_millis(25);
46#[cfg(windows)]
47const WINDOWS_RESIZE_POLL: Duration = Duration::from_millis(100);
48/// Ceiling for one background clipboard read. Backend subprocesses cap
49/// themselves at 5–8 s; this covers a hung native handle so queued input
50/// can never stall indefinitely.
51const CLIPBOARD_READ_TIMEOUT: Duration = Duration::from_secs(10);
52
53pub enum Msg {
54	/// App-side mutation from a [`UiHandle`], applied between frames.
55	Update(Box<dyn FnOnce(&mut Ui) + Send>),
56	/// A finished off-thread decode for the `Img` at `slot`.
57	ImageDecoded { slot: Slot, state: ImgState },
58	/// A finished background system-clipboard read, tagged with its
59	/// [`ClipboardGate`] generation; `raw` requests verbatim insertion and
60	/// `None` means the clipboard was empty.
61	Pasted { generation: u64, raw: bool, clipboard: Option<crate::paste::Clipboard> },
62}
63
64/// Ordering discipline for one in-flight background clipboard read.
65///
66/// pi queues keystrokes typed behind an unsettled paste so a trailing Enter
67/// cannot submit before the payload lands (`custom-editor.ts` pending-input
68/// queue); this gate reproduces that contract for [`App`]. Input admitted
69/// while a read is in flight is buffered and replayed in order once the
70/// read settles or expires; quit chords bypass the buffer so a hung backend
71/// can never lock the user in, and results from an expired read are dropped
72/// by generation.
73#[derive(Default)]
74struct ClipboardGate {
75	in_flight:  Option<InFlightRead>,
76	generation: u64,
77	pending:    std::collections::VecDeque<InputEvent>,
78}
79
80struct InFlightRead {
81	generation: u64,
82	deadline:   tokio::time::Instant,
83}
84
85impl ClipboardGate {
86	/// Claims the gate for one read, returning its generation tag; `None`
87	/// while another read is still in flight.
88	fn begin(&mut self, now: tokio::time::Instant) -> Option<u64> {
89		if self.in_flight.is_some() {
90			return None;
91		}
92		self.generation += 1;
93		self.in_flight = Some(InFlightRead {
94			generation: self.generation,
95			deadline:   now + CLIPBOARD_READ_TIMEOUT,
96		});
97		Some(self.generation)
98	}
99
100	/// Admits `event` for immediate dispatch (`Some`) or queues it behind
101	/// the in-flight read (`None`). Quit chords always pass through.
102	fn admit(&mut self, event: InputEvent, quit: &[Key]) -> Option<InputEvent> {
103		if self.in_flight.is_none() {
104			return Some(event);
105		}
106		if let InputEvent::Key(key) = &event
107			&& quit.contains(key)
108		{
109			return Some(event);
110		}
111		self.pending.push_back(event);
112		None
113	}
114
115	/// Accepts a finished read when its generation is still current.
116	fn settle(&mut self, generation: u64) -> bool {
117		if self
118			.in_flight
119			.as_ref()
120			.is_some_and(|read| read.generation == generation)
121		{
122			self.in_flight = None;
123			return true;
124		}
125		false
126	}
127
128	/// Abandons an overdue read; its eventual result no longer settles.
129	const fn expire(&mut self) {
130		self.in_flight = None;
131	}
132
133	/// The in-flight read's expiry instant, when one is running.
134	fn deadline(&self) -> Option<tokio::time::Instant> {
135		self.in_flight.as_ref().map(|read| read.deadline)
136	}
137
138	/// Releases the oldest queued event once no read is in flight.
139	fn drain(&mut self) -> Option<InputEvent> {
140		if self.in_flight.is_some() {
141			return None;
142		}
143		self.pending.pop_front()
144	}
145}
146
147/// Off-thread image decoder used by asynchronous UI hosts.
148#[derive(Clone)]
149pub struct ImageLoader {
150	tx: flume::Sender<Msg>,
151	rx: flume::Receiver<Msg>,
152	rt: tokio::runtime::Handle,
153}
154
155impl ImageLoader {
156	/// Creates a loader attached to the current tokio runtime.
157	///
158	/// # Panics
159	/// Panics outside a tokio runtime.
160	pub fn new() -> Self {
161		let (tx, rx) = flume::unbounded();
162		Self { tx, rx, rt: tokio::runtime::Handle::current() }
163	}
164
165	pub(crate) fn request(
166		&self,
167		slot: Slot,
168		source: Str,
169		width: u16,
170		height: Option<u16>,
171		trim: bool,
172	) {
173		let tx = self.tx.clone();
174		let _task = self.rt.spawn_blocking(move || {
175			let state = crate::components::decode_source(&source, width, height, trim);
176			let _ = tx.send(Msg::ImageDecoded { slot, state });
177		});
178	}
179}
180
181impl Default for ImageLoader {
182	fn default() -> Self {
183		Self::new()
184	}
185}
186
187impl fmt::Debug for ImageLoader {
188	fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
189		formatter.write_str("ImageLoader")
190	}
191}
192
193/// Cloneable remote for mutating the [`Ui`] from any thread or task.
194///
195/// Sends are non-blocking; after the [`App`] is gone they are no-ops.
196#[derive(Clone)]
197pub struct UiHandle {
198	tx:     flume::Sender<Msg>,
199	cancel: CancellationToken,
200}
201
202impl UiHandle {
203	/// Queues a UI mutation to run between rendered frames.
204	pub fn update(&self, update: impl FnOnce(&mut Ui) + Send + 'static) {
205		let _ = self.tx.send(Msg::Update(Box::new(update)));
206	}
207
208	/// Queues replacement text for the component named by `id`.
209	pub fn set_text(&self, id: impl Into<Str>, text: impl Into<Str>) {
210		let id = id.into();
211		let text = text.into();
212		self.update(move |ui| {
213			ui.set_text(&id, text);
214		});
215	}
216
217	/// Queues invalidation of the component named by `id`.
218	pub fn invalidate(&self, id: impl Into<Str>) {
219		let id = id.into();
220		self.update(move |ui| {
221			ui.invalidate(&id);
222		});
223	}
224
225	/// Requests shutdown of the application host.
226	pub fn shutdown(&self) {
227		self.cancel.cancel();
228	}
229}
230
231type GraphicsOverride = Box<dyn FnOnce(&TerminalCaps) -> Option<Graphics> + Send>;
232
233/// Configuration for [`AppOptions::start`].
234///
235/// Defaults to environment detection without probing, Ctrl-C to quit, and
236/// base-tree [`UiEvent::Cancel`] to shut down. A cancel from inside a
237/// visible modal overlay dismisses that layer instead of quitting.
238pub struct AppOptions {
239	probe:          Option<Duration>,
240	graphics:       Option<GraphicsOverride>,
241	cursor_style:   Option<CursorStyle>,
242	quit:           SmallVec<Key, 4>,
243	hotkeys:        SmallVec<Key, 4>,
244	quit_on_cancel: bool,
245	mouse:          bool,
246	hold_alt:       bool,
247}
248
249impl AppOptions {
250	/// Creates the default application configuration.
251	pub fn new() -> Self {
252		let mut quit = SmallVec::new();
253		quit.push(Key::Ctrl('c'));
254		Self {
255			probe: None,
256			graphics: None,
257			cursor_style: None,
258			quit,
259			hotkeys: SmallVec::new(),
260			quit_on_cancel: true,
261			mouse: false,
262			hold_alt: false,
263		}
264	}
265
266	/// Runs the startup capability probe with this timeout.
267	pub const fn probe(mut self, timeout: Duration) -> Self {
268		self.probe = Some(timeout);
269		self
270	}
271
272	/// Resolves an optional forced graphics tier after capability detection.
273	pub fn graphics_with(
274		mut self,
275		forced: impl FnOnce(&TerminalCaps) -> Option<Graphics> + Send + 'static,
276	) -> Self {
277		self.graphics = Some(Box::new(forced));
278		self
279	}
280
281	/// Uses `style` while the application owns the terminal.
282	pub const fn cursor_style(mut self, style: CursorStyle) -> Self {
283		self.cursor_style = Some(style);
284		self
285	}
286
287	/// Enables inline mouse reporting (click, drag, motion, wheel) for the
288	/// whole session.
289	///
290	/// Off by default: an inline app leaves the mouse to the terminal so
291	/// native text selection and scrollback keep working, matching the
292	/// coding agent. Opt in for pointer-driven screens.
293	pub const fn mouse(mut self) -> Self {
294		self.mouse = true;
295		self
296	}
297
298	/// Replaces the quit chords checked before input routing.
299	pub fn quit(mut self, chords: impl IntoIterator<Item = Key>) -> Self {
300		self.quit = chords.into_iter().collect();
301		self
302	}
303
304	/// Reserves chords for the host, checked after the quit chords and
305	/// before widget routing, and surfaced as [`AppEvent::Key`].
306	///
307	/// Use this for scene-level shortcuts that must win over a focused
308	/// widget's own binding — `Ctrl+K` opening a switcher while a text
309	/// input would otherwise kill to end of line. Chords stay reserved
310	/// until [`App::set_hotkeys`] replaces them, so scope them to the
311	/// screen that needs them rather than reserving them globally.
312	pub fn hotkeys(mut self, chords: impl IntoIterator<Item = Key>) -> Self {
313		self.hotkeys = chords.into_iter().collect();
314		self
315	}
316
317	/// Starts with the alternate screen held: the very first frame paints
318	/// there and the inline transcript stays untouched underneath until
319	/// [`App::hold_alt`] releases it. Fullscreen opening scenes — a welcome
320	/// screen, a picker-first flow — use this so the main buffer never
321	/// flashes frame one. A Ui whose initial overlay stack is visible holds
322	/// automatically without this option.
323	pub const fn hold_alt(mut self) -> Self {
324		self.hold_alt = true;
325		self
326	}
327
328	/// Keeps running when the base tree yields [`UiEvent::Cancel`].
329	///
330	/// A cancel from inside a visible modal overlay is unaffected: it always
331	/// dismisses that layer and surfaces [`AppEvent::OverlayClosed`].
332	pub const fn keep_on_cancel(mut self) -> Self {
333		self.quit_on_cancel = false;
334		self
335	}
336
337	/// Negotiates, enters the terminal, builds the [`Ui`], paints the first
338	/// frame, and returns the running host.
339	///
340	/// # Errors
341	///
342	/// Propagates terminal, input, capability, and renderer failures.
343	pub async fn start(self, build: impl FnOnce(AppEnv) -> Ui + Send) -> io::Result<App> {
344		let Self { probe, graphics, cursor_style, quit, hotkeys, quit_on_cancel, mouse, hold_alt } =
345			self;
346		let (base, probe) = match probe {
347			Some(timeout) => negotiate_async(timeout).await,
348			None => (detect(), ProbeResults::default()),
349		};
350		let forced = graphics.and_then(|forced| forced(&base));
351		let caps = TerminalCaps::resolve(base, None, forced);
352		let mut terminal_options = TerminalOptions::new(caps).mouse(mouse).probe_results(probe);
353		if let Some(style) = cursor_style {
354			terminal_options = terminal_options.cursor_style(style);
355		}
356		let mut terminal = Terminal::enter(terminal_options)?;
357		let viewport = terminal.size()?;
358
359		let loader = ImageLoader::new();
360		let msgs = loader.rx.clone();
361		let tx = loader.tx.clone();
362		let mut ctx = UiContext::default().with_terminal_caps(&caps);
363		ctx.loader = Some(loader);
364		let mut ui = build(AppEnv { viewport, caps, ctx });
365
366		let mut renderer = Renderer::new(TtyOut::new()?);
367		renderer.apply_caps(&caps)?;
368
369		// An initial hold — requested or from a visible overlay — paints
370		// frame one on the alternate screen; the main buffer stays untouched
371		// (and unseeded) until release.
372		let initial_hold = hold_alt || ui.has_overlay();
373		let last_stats = if initial_hold {
374			let alt_enter = terminal.stage_alt_enter(crate::AltScreenUse::Interactive);
375			ui.preview(&mut renderer, viewport.height, alt_enter.as_deref().unwrap_or(""))?
376		} else {
377			renderer.rebuild(ui.frame().clone(), viewport.height, 0, "")?
378		};
379		ui.clear_damage();
380		let now = tokio::time::Instant::now();
381		Ok(App {
382			ui,
383			renderer,
384			msgs,
385			tx,
386			cancel: CancellationToken::new(),
387			epoch: now,
388			caps,
389			viewport,
390			quit,
391			hotkeys,
392			quit_on_cancel,
393			#[cfg(unix)]
394			resize_wait: None,
395			#[cfg(windows)]
396			resize_wait: Some(now + WINDOWS_RESIZE_POLL),
397			resize_settle: None,
398			resize_alt: false,
399			alt_hold: initial_hold,
400			hold_request: hold_alt,
401			main_stale: false,
402			held_damage: false,
403			tail_drag: false,
404			needs_rebuild: false,
405			clipboard: ClipboardGate::default(),
406			stable_rows: 0,
407			last_stats,
408			terminal,
409		})
410	}
411}
412
413impl Default for AppOptions {
414	fn default() -> Self {
415		Self::new()
416	}
417}
418
419/// Inputs supplied to the [`AppOptions::start`] UI builder.
420pub struct AppEnv {
421	/// Initial terminal cell dimensions.
422	pub viewport: Size,
423	/// Resolved terminal capabilities.
424	pub caps:     TerminalCaps,
425	/// Capability-aware context with asynchronous image loading installed.
426	pub ctx:      UiContext,
427}
428
429/// Host-level event returned by [`App::next`].
430///
431/// Input has already been routed into the retained tree.
432#[derive(Clone, Debug, Eq, PartialEq)]
433pub enum AppEvent {
434	/// Routed input changed the tree; the next [`App::next`] call presents it.
435	Updated,
436	/// A key no component claimed: it routed through the tree untouched —
437	/// pending damage from animations or other components never masks it —
438	/// and matched no quit or clipboard chord. Hosts use it for scene-level
439	/// hotkeys without intercepting the widget path.
440	Key(Key),
441	/// The focused widget submitted.
442	Submitted,
443	/// An ID-carrying button fired.
444	Pressed(Str),
445	/// A resize settled after [`Ui::resize`] ran.
446	Resized(Size),
447	/// The terminal background flipped between dark and light. The retained
448	/// context has already been restyled; hardcoded colors derived outside
449	/// the theme are the app's to refresh.
450	Appearance(Appearance),
451	/// A cancel from inside a layer dismissed the topmost visible overlay.
452	OverlayClosed(OverlayId),
453	/// An ID-carrying select's cursor rested on a new option.
454	Highlighted {
455		/// The select's `id`.
456		id:    Str,
457		/// Value of the option under the cursor.
458		value: Str,
459	},
460	/// An ID-carrying select committed an option.
461	Changed {
462		/// The select's `id`.
463		id:    Str,
464		/// Value of the committed option.
465		value: Str,
466	},
467	/// An ID-carrying filterable select's query changed.
468	Filtered {
469		/// The select's `id`.
470		id:    Str,
471		/// The new filter query.
472		query: Str,
473		/// Value of the option under the cursor after re-filtering.
474		value: Option<Str>,
475	},
476}
477
478/// Running retained-UI terminal host.
479pub struct App {
480	ui:             Ui,
481	renderer:       Renderer<TtyOut>,
482	msgs:           flume::Receiver<Msg>,
483	tx:             flume::Sender<Msg>,
484	cancel:         CancellationToken,
485	epoch:          tokio::time::Instant,
486	caps:           TerminalCaps,
487	viewport:       Size,
488	quit:           SmallVec<Key, 4>,
489	hotkeys:        SmallVec<Key, 4>,
490	quit_on_cancel: bool,
491	resize_wait:    Option<tokio::time::Instant>,
492	resize_settle:  Option<tokio::time::Instant>,
493	resize_alt:     bool,
494	alt_hold:       bool,
495	hold_request:   bool,
496	main_stale:     bool,
497	held_damage:    bool,
498	tail_drag:      bool,
499	needs_rebuild:  bool,
500	clipboard:      ClipboardGate,
501	stable_rows:    u16,
502	last_stats:     PaintStats,
503	terminal:       Terminal,
504}
505
506impl App {
507	/// Borrows the retained UI.
508	pub const fn ui(&self) -> &Ui {
509		&self.ui
510	}
511
512	/// Mutably borrows the retained UI between host events.
513	pub const fn ui_mut(&mut self) -> &mut Ui {
514		&mut self.ui
515	}
516
517	/// Replaces the reserved host chords, scoping [`AppOptions::hotkeys`]
518	/// to the screen that is actually showing.
519	pub fn set_hotkeys(&mut self, chords: impl IntoIterator<Item = Key>) {
520		self.hotkeys = chords.into_iter().collect();
521	}
522
523	/// Creates a remote that can update or stop this host.
524	pub fn handle(&self) -> UiHandle {
525		UiHandle { tx: self.tx.clone(), cancel: self.cancel.clone() }
526	}
527
528	/// Mutably borrows the renderer for image registration and output policy.
529	pub const fn renderer_mut(&mut self) -> &mut Renderer<TtyOut> {
530		&mut self.renderer
531	}
532
533	/// Mutably borrows the terminal for titles, progress, and appearance hooks.
534	pub const fn terminal_mut(&mut self) -> &mut Terminal {
535		&mut self.terminal
536	}
537
538	/// Returns the resolved terminal capabilities.
539	pub const fn caps(&self) -> TerminalCaps {
540		self.caps
541	}
542
543	/// Returns the latest settled terminal geometry.
544	pub const fn viewport(&self) -> Size {
545		self.viewport
546	}
547
548	/// Returns statistics from the most recent present or rebuild.
549	pub const fn last_stats(&self) -> PaintStats {
550		self.last_stats
551	}
552
553	/// Sets the immutable leading-row boundary used for presentation.
554	pub const fn set_stable_rows(&mut self, rows: u16) {
555		self.stable_rows = rows;
556	}
557
558	/// Requests or releases a persistent alternate-screen hold.
559	///
560	/// Fullscreen scenes — a welcome screen, a pager — hold the alternate
561	/// screen for their lifetime: frames paint there with mouse tracking
562	/// active while the inline transcript stays untouched underneath. Every
563	/// visible modal overlay holds it automatically; this covers scenes
564	/// without one. Non-modal layers ([`crate::OverlayOptions::non_modal`])
565	/// never hold: they composite into the live inline viewport while the
566	/// document keeps committing to native scrollback. Release restores the
567	/// main screen, rebuilding history only when geometry changed while
568	/// held. Takes effect on the next [`App::next`] call.
569	pub const fn hold_alt(&mut self, hold: bool) {
570		self.hold_request = hold;
571	}
572
573	/// Flushes pending damage, waits for one host event, and routes it.
574	///
575	/// `None` means shutdown; subsequent calls continue returning `None`.
576	///
577	/// # Errors
578	///
579	/// Propagates terminal input, geometry, and renderer failures.
580	#[expect(
581		clippy::future_not_send,
582		reason = "terminal UI components are intentionally confined to their owning thread"
583	)]
584	pub async fn next(&mut self) -> io::Result<Option<AppEvent>> {
585		loop {
586			if self.cancel.is_cancelled() {
587				return Ok(None);
588			}
589
590			// Alternate-screen hold: scenes ([`App::hold_alt`]) and visible
591			// modal overlays paint the composited viewport on the alternate
592			// screen while the inline transcript stays untouched underneath.
593			// Non-modal layers ride the inline present instead.
594			let want_hold = self.hold_request || self.ui.has_overlay();
595			if want_hold {
596				if !self.alt_hold {
597					self.alt_hold = true;
598					// A drag borrow or a pending settled rebuild transfers to
599					// the hold: same buffer, but main-screen history stays
600					// stale until release.
601					if self.resize_alt || self.needs_rebuild {
602						self.resize_alt = false;
603						self.needs_rebuild = false;
604						self.main_stale = true;
605					}
606					// A tail-composed drag deferred the document reflow; the
607					// held surface is live UI, so run it now.
608					if self.tail_drag {
609						self.tail_drag = false;
610						self.ui.resize(self.viewport.width);
611					}
612					self.held_damage |= self.ui.has_damage();
613					let alt_enter = self
614						.terminal
615						.stage_alt_enter(crate::AltScreenUse::Interactive);
616					self.last_stats = self.ui.preview(
617						&mut self.renderer,
618						self.viewport.height,
619						alt_enter.as_deref().unwrap_or(""),
620					)?;
621				} else if self.ui.has_damage() {
622					// Previews consume damage that never reached the main
623					// buffer; remember to repaint it on release.
624					self.held_damage = true;
625					self.last_stats = self
626						.ui
627						.preview(&mut self.renderer, self.viewport.height, "")?;
628				}
629			} else if self.alt_hold {
630				self.alt_hold = false;
631				if self.main_stale {
632					// Geometry changed while held: leaving the alternate
633					// screen rides the rebuild's synchronized update so the
634					// buffer switch, history clear, and repaint land as one
635					// atomic frame.
636					self.main_stale = false;
637					self.held_damage = false;
638					self.ui.refit();
639					let alt_exit = self.terminal.stage_alt_leave().unwrap_or("");
640					self.last_stats = self.renderer.rebuild(
641						self.ui.frame().clone(),
642						self.viewport.height,
643						self.stable_rows,
644						alt_exit,
645					)?;
646					self.ui.clear_damage();
647				} else {
648					// The untouched main screen restores byte-exactly, but
649					// changes made during the hold only ever painted the
650					// alternate screen: revalidate every live row without
651					// touching history.
652					self.terminal.leave_alt()?;
653					if self.held_damage || self.ui.has_damage() {
654						self.held_damage = false;
655						self.ui.damage_all();
656						self.last_stats =
657							self
658								.ui
659								.present(&mut self.renderer, self.viewport.height, self.stable_rows)?;
660					}
661				}
662			} else if self.resize_settle.is_some() {
663				// A drag is live: ordinary damage — spinner ticks, streamed
664				// text, cursor moves — rides the same viewport fast path
665				// instead of freezing until settle; the settle rebuild
666				// repaints authoritatively.
667				if self.ui.has_damage() {
668					let tail = if self.tail_drag {
669						self.ui.compose_resize_tail(self.viewport)
670					} else {
671						None
672					};
673					match tail {
674						Some(frame) => {
675							self.renderer.preview(&frame, self.viewport.height, "")?;
676							self.ui.clear_damage();
677						},
678						None => {
679							self.last_stats =
680								self
681									.ui
682									.preview(&mut self.renderer, self.viewport.height, "")?;
683						},
684					}
685				}
686			} else if self.needs_rebuild {
687				// The app's `Resized` handler may have shrunk fixed-height
688				// components; drop the height watermark from the old viewport
689				// so the rebuilt document matches the content, not the
690				// pre-resize maximum.
691				self.ui.refit();
692				// Leaving the borrowed alternate screen rides the rebuild's
693				// synchronized update: buffer switch, history clear, and the
694				// settled repaint land as one atomic frame.
695				let alt_exit = if self.resize_alt {
696					self.resize_alt = false;
697					self.terminal.stage_alt_leave().unwrap_or("")
698				} else {
699					""
700				};
701				self.last_stats = self.renderer.rebuild(
702					self.ui.frame().clone(),
703					self.viewport.height,
704					self.stable_rows,
705					alt_exit,
706				)?;
707				self.ui.clear_damage();
708				self.needs_rebuild = false;
709			} else if self.ui.has_damage() {
710				self.last_stats =
711					self
712						.ui
713						.present(&mut self.renderer, self.viewport.height, self.stable_rows)?;
714			}
715
716			// Replay input queued behind a clipboard read — oldest first, one
717			// event per host turn, before polling for anything new.
718			if let Some(event) = self.clipboard.drain() {
719				match self.dispatch_input(event) {
720					// `dispatch_input` already resolved `Unclaimed` fallbacks.
721					Routed::Continue | Routed::Unclaimed => continue,
722					Routed::Event(event) => return Ok(Some(event)),
723					Routed::Stop => return Ok(None),
724				}
725			}
726
727			let wake = self.ui.next_wake().map(|at| self.epoch + at);
728			let wakeup = tokio::select! {
729				() = self.cancel.cancelled() => Wakeup::Cancelled,
730				message = self.msgs.recv_async() => Wakeup::Message(message),
731				event = self.terminal.next() => Wakeup::Event(event),
732				() = deadline(wake) => Wakeup::Animation,
733				() = deadline(self.clipboard.deadline()) => Wakeup::ClipboardExpired,
734				() = deadline(self.resize_wait) => Wakeup::ResizeCheck,
735				() = deadline(self.resize_settle) => Wakeup::ResizeSettle,
736			};
737
738			match wakeup {
739				Wakeup::Cancelled => return Ok(None),
740				Wakeup::Message(Ok(Msg::Update(update))) => update(&mut self.ui),
741				Wakeup::Message(Ok(Msg::ImageDecoded { slot, state })) => {
742					self.ui.deliver_image(slot, state);
743				},
744				Wakeup::Message(Ok(Msg::Pasted { generation, raw, clipboard })) => {
745					// A result from an expired or superseded read is dropped;
746					// its queued input already replayed without it.
747					if self.clipboard.settle(generation)
748						&& let Some(clipboard) = clipboard
749						&& let Some(event) = self.deliver_clipboard(clipboard, raw)
750					{
751						return Ok(Some(event));
752					}
753				},
754				Wakeup::Message(Err(_)) => {},
755				Wakeup::Event(event) => match event? {
756					TerminalEvent::Resize => {
757						self.resize_wait = Some(tokio::time::Instant::now());
758					},
759					TerminalEvent::Debug(query) => self.answer_debug(query),
760					// `Terminal::next` reports closure as an error.
761					TerminalEvent::Closed => return Ok(None),
762					TerminalEvent::Input(event) => {
763						let in_band_resize =
764							matches!(&event, InputEvent::Response(TerminalResponse::InBandResize { .. }));
765						if self
766							.terminal
767							.handle_input_event(&event, &mut self.renderer)?
768						{
769							if in_band_resize {
770								self.resize_wait = Some(tokio::time::Instant::now());
771							}
772							if let Some(event) = self.sync_appearance() {
773								return Ok(Some(event));
774							}
775							if let Some(pasted) = self.terminal.take_paste()
776								&& let Some(event) = self.deliver_pasted(pasted)
777							{
778								return Ok(Some(event));
779							}
780							continue;
781						}
782						if let Some(event) = self.clipboard.admit(event, &self.quit) {
783							match self.dispatch_input(event) {
784								// `dispatch_input` already resolved `Unclaimed` fallbacks.
785								Routed::Continue | Routed::Unclaimed => {},
786								Routed::Event(event) => return Ok(Some(event)),
787								Routed::Stop => return Ok(None),
788							}
789						}
790					},
791				},
792				Wakeup::Animation => {
793					self.ui.tick(self.epoch.elapsed());
794				},
795				Wakeup::ClipboardExpired => self.clipboard.expire(),
796				Wakeup::ResizeCheck => {
797					self.resize_wait = None;
798					let now = tokio::time::Instant::now();
799					let consumed = match self.terminal.take_resize()? {
800						// A same-size report outside a drag is an echo — some
801						// terminals re-report geometry whenever the alternate
802						// screen toggles — and starting a preview for it would
803						// loop the borrow forever.
804						Some(viewport) if viewport != self.viewport || self.resize_settle.is_some() => {
805							self.begin_resize(viewport, now)?;
806							true
807						},
808						// A consumed echo still ends the recheck loop below.
809						Some(_) => true,
810						None => false,
811					};
812					#[cfg(unix)]
813					if !consumed {
814						// A multiplexer burst is still inside its debounce
815						// window; keep polling until geometry is released.
816						self.resize_wait = Some(now + RESIZE_RECHECK);
817					}
818					#[cfg(windows)]
819					{
820						let _ = consumed;
821						self.resize_wait = Some(now + WINDOWS_RESIZE_POLL);
822					}
823				},
824				Wakeup::ResizeSettle => {
825					self.resize_settle = None;
826					if self.tail_drag {
827						self.tail_drag = false;
828						// The drag composed viewport tails only; reflow the
829						// whole document once, before the app's `Resized`
830						// handler and the settled rebuild observe layout.
831						self.ui.resize(self.viewport.width);
832					}
833					// While held, main-screen history is already flagged stale
834					// and rebuilds on release; only inline sessions rebuild at
835					// settle.
836					self.needs_rebuild = !self.alt_hold;
837					return Ok(Some(AppEvent::Resized(self.viewport)));
838				},
839			}
840		}
841	}
842
843	/// Applies a terminal-reported dark/light flip to the retained context.
844	///
845	/// A stock palette follows the flip; a custom theme is preserved so the
846	/// app can restyle it in response to [`AppEvent::Appearance`].
847	fn sync_appearance(&mut self) -> Option<AppEvent> {
848		let appearance = self.terminal.appearance()?;
849		let current = self.ui.context();
850		if current.appearance == appearance {
851			return None;
852		}
853		let mut ctx = current.clone();
854		if ctx.theme == Theme::for_appearance(ctx.appearance) {
855			ctx.theme = Theme::for_appearance(appearance);
856		}
857		ctx.appearance = appearance;
858		self.ui.set_context(ctx);
859		Some(AppEvent::Appearance(appearance))
860	}
861
862	/// Routes paste text into the focused component, mapping the outcome
863	/// like terminal-delivered bracketed paste. `raw` requests verbatim
864	/// insertion ([`Ui::handle_paste_raw`]).
865	fn route_paste(&mut self, text: &str, raw: bool) -> Option<AppEvent> {
866		let event = if raw {
867			self.ui.handle_paste_raw(text)
868		} else {
869			self.ui.handle_paste(text)
870		};
871		match select_event(event) {
872			Ok(event) => Some(event),
873			Err(_) if self.ui.has_damage() => Some(AppEvent::Updated),
874			Err(_) => None,
875		}
876	}
877
878	/// Persists a pasted image to a temp file and routes its path like a
879	/// file drop, which [`crate::components::EditInput`] stages as an
880	/// attachment chip.
881	fn route_image(&mut self, image: &PastedImage) -> Option<AppEvent> {
882		let path = image.persist().ok()?;
883		self.route_paste(path.to_str()?, false)
884	}
885
886	/// Dispatches a completed OSC 5522 enhanced-paste payload.
887	fn deliver_pasted(&mut self, pasted: Pasted) -> Option<AppEvent> {
888		match pasted {
889			Pasted::Text(text) => self.route_paste(&text, false),
890			Pasted::Image(image) => self.route_image(&image),
891		}
892	}
893
894	/// Dispatches a finished background clipboard read. `raw` preserves the
895	/// Ctrl+Shift+V contract end to end: text inserts verbatim instead of
896	/// collapsing to chips or classifying as a drop.
897	fn deliver_clipboard(&mut self, clipboard: Clipboard, raw: bool) -> Option<AppEvent> {
898		match clipboard {
899			Clipboard::Text(text) => self.route_paste(&text, raw),
900			Clipboard::Image(image) => self.route_image(&image),
901			Clipboard::Paths(paths) => {
902				// Quoted so paths containing spaces survive the editor's
903				// drop classification intact.
904				let mut joined = String::new();
905				for path in &paths {
906					if !joined.is_empty() {
907						joined.push(' ');
908					}
909					joined.push('"');
910					joined.push_str(path);
911					joined.push('"');
912				}
913				self.route_paste(&joined, false)
914			},
915		}
916	}
917
918	fn route_key(&mut self, key: Key) -> Routed {
919		let routed =
920			route_key_event(&mut self.ui, key, &self.quit, &self.hotkeys, self.quit_on_cancel);
921		if matches!(routed, Routed::Stop) {
922			self.cancel.cancel();
923		}
924		routed
925	}
926
927	/// Starts one background system-clipboard read; the result returns
928	/// through the message bus as [`Msg::Pasted`] tagged with the gate
929	/// generation. [`ClipboardRead::Text`] carries the Ctrl+Shift+V
930	/// contract through as `raw`: verbatim insertion of a text-only read.
931	///
932	/// The read rides [`crate::paste::spawn_clipboard_read`]'s detached
933	/// thread; a result arriving after the gate expired is dropped by
934	/// generation. A channel closed without a value (the reader thread
935	/// never spawned) settles the gate immediately so queued input is not
936	/// held until the deadline.
937	fn begin_clipboard_read(&mut self, scope: ClipboardRead) {
938		let Some(generation) = self.clipboard.begin(tokio::time::Instant::now()) else {
939			return;
940		};
941		let rx = crate::paste::spawn_clipboard_read(scope);
942		let raw = scope == ClipboardRead::Text;
943		let tx = self.tx.clone();
944		tokio::spawn(async move {
945			let clipboard = rx.await.unwrap_or(None);
946			let _ = tx.send(Msg::Pasted { generation, raw, clipboard });
947		});
948	}
949
950	/// Routes one decoded input event into the retained tree, mapping the
951	/// outcome exactly like the inline dispatch it replaced.
952	fn dispatch_input(&mut self, event: InputEvent) -> Routed {
953		// Input lands on the real clock: transitions started by this event
954		// must not begin on a stale animation tick.
955		self.ui.tick(self.epoch.elapsed());
956		match event {
957			InputEvent::Key(key) => match self.route_key(key) {
958				// A key nobody claimed falls back to the host: unclaimed
959				// paste chords start a clipboard read, everything else
960				// surfaces as [`AppEvent::Key`].
961				Routed::Unclaimed => {
962					if let Some(scope) = ClipboardRead::for_key(key) {
963						self.begin_clipboard_read(scope);
964						Routed::Continue
965					} else {
966						Routed::Event(AppEvent::Key(key))
967					}
968				},
969				routed => routed,
970			},
971			InputEvent::Mouse(report) => {
972				let offset = self.ui.height().saturating_sub(self.viewport.height);
973				let event =
974					self
975						.ui
976						.handle_mouse(report.col, report.row.saturating_add(offset), report.kind);
977				match select_event(event) {
978					Ok(event) => Routed::Event(event),
979					Err(UiEvent::Submit) => Routed::Event(AppEvent::Submitted),
980					Err(UiEvent::Pressed(id)) => Routed::Event(AppEvent::Pressed(id)),
981					Err(_) if self.ui.has_damage() => Routed::Event(AppEvent::Updated),
982					Err(_) => Routed::Continue,
983				}
984			},
985			InputEvent::Paste(text) => {
986				// An empty bracketed paste is how some terminals announce an
987				// image-only pasteboard (macOS Cmd+V); answer it with a
988				// clipboard read instead.
989				if text.is_empty() {
990					self.begin_clipboard_read(ClipboardRead::Smart);
991					Routed::Continue
992				} else {
993					self
994						.route_paste(&text, false)
995						.map_or(Routed::Continue, Routed::Event)
996				}
997			},
998			InputEvent::Focus(_) | InputEvent::Response(_) => Routed::Continue,
999		}
1000	}
1001
1002	/// Applies new terminal geometry: relayout, drag preview, settle timer.
1003	///
1004	/// Shared by the SIGWINCH-driven recheck and the `OMP_TUI_DEBUG` `resize`
1005	/// op, which bypasses [`Terminal::take_resize`] because no resize signal
1006	/// reaches an `OMP_TTY` override device.
1007	fn begin_resize(&mut self, viewport: Size, now: tokio::time::Instant) -> io::Result<()> {
1008		let width_changed = viewport.width != self.viewport.width;
1009		self.viewport = viewport;
1010		if self.alt_hold {
1011			// The held surface is live UI: relayout immediately and repaint
1012			// in place; the main-screen rebuild waits for release.
1013			self.main_stale = true;
1014			self.ui.resize(viewport.width);
1015			self.ui.preview(&mut self.renderer, viewport.height, "")?;
1016		} else {
1017			// Only a width change borrows the alternate screen: the normal
1018			// buffer rewraps under width churn, while height-only resizes
1019			// repaint in place — and alt toggling on a height echo can
1020			// self-sustain (terminals re-report size across the toggle).
1021			// Multiplexer panes repaint in place: the host terminal owns
1022			// the real screen. The settled rebuild leaves a borrow
1023			// atomically.
1024			let alt_enter = if self.caps.inside_multiplexer {
1025				None
1026			} else if width_changed && !self.resize_alt {
1027				let staged = self.terminal.stage_alt_enter(crate::AltScreenUse::Resize);
1028				self.resize_alt |= staged.is_some();
1029				staged
1030			} else {
1031				None
1032			};
1033			let leading = alt_enter.as_deref().unwrap_or("");
1034			// Drag frames compose one viewport tail bottom-up at the new
1035			// width; the O(document) reflow is deferred to settle.
1036			if let Some(frame) = self.ui.compose_resize_tail(viewport) {
1037				self.tail_drag = true;
1038				self.renderer.preview(&frame, viewport.height, leading)?;
1039			} else {
1040				self.ui.resize(viewport.width);
1041				self
1042					.ui
1043					.preview(&mut self.renderer, viewport.height, leading)?;
1044			}
1045		}
1046		self.resize_settle = Some(now + RESIZE_SETTLE);
1047		Ok(())
1048	}
1049
1050	/// Answers one retained-state debug query routed through the event
1051	/// loop, keyed by the query id.
1052	fn answer_debug(&self, query: DebugQuery) {
1053		use serde_json::json;
1054		let response = match query.op {
1055			DebugOp::Frame => {
1056				let frame = self.ui.frame();
1057				let lines: Vec<String> = (0..frame.size().height)
1058					.map(|row| crate::test_support::frame_row_text(frame, row))
1059					.collect();
1060				json!({ "ok": true, "lines": lines })
1061			},
1062			DebugOp::Tree => json!({ "ok": true, "tree": self.ui.debug_tree() }),
1063			DebugOp::Values => json!({ "ok": true, "values": self.ui.values() }),
1064			// Terminal-owned ops are answered inside `Terminal::next` and
1065			// never surface here.
1066			DebugOp::Info | DebugOp::Text | DebugOp::Resize | DebugOp::Quit => return,
1067		};
1068		crate::debug::respond_debug_query(query.id, response);
1069	}
1070}
1071
1072impl Drop for App {
1073	fn drop(&mut self) {
1074		// The final inline screen persists into native scrollback once the
1075		// shell resumes, so composited layer cells must not survive
1076		// teardown: release any alternate-screen hold, then repaint layer
1077		// bands from the raw document. Best effort — teardown cannot fail.
1078		let _ = self.terminal.leave_alt();
1079		let _ = self.renderer.clear_layers();
1080	}
1081}
1082
1083/// Applies the host key policy after `key` routes into the retained tree.
1084///
1085/// Any [`UiEvent::Cancel`] surfacing from a visible modal overlay — Escape
1086/// or a `<button cancel>` — dismisses that layer before the quit policy runs,
1087/// following the layered-dismissal contract on [`Key::Esc`].
1088fn route_key_event(
1089	ui: &mut Ui,
1090	key: Key,
1091	quit: &[Key],
1092	hotkeys: &[Key],
1093	quit_on_cancel: bool,
1094) -> Routed {
1095	if quit.contains(&key) {
1096		return Routed::Stop;
1097	}
1098	// Reserved before routing: a focused widget must not shadow a scene
1099	// shortcut the host claimed.
1100	if hotkeys.contains(&key) {
1101		return Routed::Event(AppEvent::Key(key));
1102	}
1103	let (event, claimed) = ui.handle_key_claimed(key);
1104	match event {
1105		UiEvent::Cancel if ui.has_overlay() => {
1106			let id = ui
1107				.close_active_overlay()
1108				.expect("a visible modal overlay routed this key");
1109			Routed::Event(AppEvent::OverlayClosed(id))
1110		},
1111		UiEvent::Cancel if quit_on_cancel => Routed::Stop,
1112		UiEvent::Submit => Routed::Event(AppEvent::Submitted),
1113		UiEvent::Pressed(id) => Routed::Event(AppEvent::Pressed(id)),
1114		event
1115		@ (UiEvent::Highlighted { .. } | UiEvent::Changed { .. } | UiEvent::Filtered { .. }) => {
1116			Routed::Event(select_event(event).expect("select events map to app events"))
1117		},
1118		// The claim bit, not global damage, decides whether the key falls
1119		// through: animation ticks leave damage pending on every frame and
1120		// must not swallow the host's scene keys.
1121		UiEvent::None | UiEvent::Cancel if !claimed => Routed::Unclaimed,
1122		UiEvent::None | UiEvent::Cancel if ui.has_damage() => Routed::Event(AppEvent::Updated),
1123		UiEvent::None | UiEvent::Cancel => Routed::Continue,
1124	}
1125}
1126
1127/// Maps a select-originated [`UiEvent`] to its [`AppEvent`]; other events
1128/// come back unchanged for the caller's own routing.
1129fn select_event(event: UiEvent) -> Result<AppEvent, UiEvent> {
1130	match event {
1131		UiEvent::Highlighted { id, value } => Ok(AppEvent::Highlighted { id, value }),
1132		UiEvent::Changed { id, value } => Ok(AppEvent::Changed { id, value }),
1133		UiEvent::Filtered { id, query, value } => Ok(AppEvent::Filtered { id, query, value }),
1134		other => Err(other),
1135	}
1136}
1137
1138#[derive(Debug, Eq, PartialEq)]
1139enum Routed {
1140	/// The tree consumed the input; nothing to surface.
1141	Continue,
1142	/// No component claimed the key; [`App::dispatch_input`] resolves the
1143	/// host fallback (clipboard chord or [`AppEvent::Key`]).
1144	Unclaimed,
1145	/// Surface this event to the host.
1146	Event(AppEvent),
1147	/// A quit chord (or quit-policy cancel) ends the app.
1148	Stop,
1149}
1150
1151enum Wakeup {
1152	Cancelled,
1153	Message(Result<Msg, flume::RecvError>),
1154	Event(io::Result<TerminalEvent>),
1155	Animation,
1156	ClipboardExpired,
1157	ResizeCheck,
1158	ResizeSettle,
1159}
1160
1161/// Sleeps until `at`; `None` is a disabled select branch.
1162async fn deadline(at: Option<tokio::time::Instant>) {
1163	match at {
1164		Some(at) => tokio::time::sleep_until(at).await,
1165		None => std::future::pending().await,
1166	}
1167}
1168
1169#[cfg(test)]
1170mod tests {
1171	use std::time::Duration;
1172
1173	/// Flag routing the re-executed test binary into the PTY helper below.
1174	#[cfg(unix)]
1175	const HOLD_HELPER_FLAG: &str = "OMP_TUI_TEST_HOLD_HELPER";
1176
1177	/// Ctrl+V followed immediately by Enter must not submit before the
1178	/// clipboard payload lands: input admitted behind the in-flight read
1179	/// queues and replays in order only after the read settles.
1180	#[test]
1181	fn clipboard_gate_orders_input_behind_the_read() {
1182		use super::{CLIPBOARD_READ_TIMEOUT, ClipboardGate};
1183		use crate::{InputEvent, Key};
1184
1185		let mut gate = ClipboardGate::default();
1186		let now = tokio::time::Instant::now();
1187		let generation = gate.begin(now).expect("gate idle");
1188		assert_eq!(gate.begin(now), None, "one read at a time");
1189		let quit = [Key::Ctrl('c')];
1190		assert_eq!(gate.admit(InputEvent::Key(Key::Enter), &quit), None, "Enter queues");
1191		assert_eq!(gate.admit(InputEvent::Key(Key::Char('x')), &quit), None);
1192		// Quit chords bypass the queue so a hung read cannot trap the user.
1193		assert_eq!(
1194			gate.admit(InputEvent::Key(Key::Ctrl('c')), &quit),
1195			Some(InputEvent::Key(Key::Ctrl('c')))
1196		);
1197		assert_eq!(gate.drain(), None, "queue holds while the read runs");
1198		assert_eq!(gate.deadline(), Some(now + CLIPBOARD_READ_TIMEOUT));
1199		assert!(gate.settle(generation));
1200		assert_eq!(gate.drain(), Some(InputEvent::Key(Key::Enter)));
1201		assert_eq!(gate.drain(), Some(InputEvent::Key(Key::Char('x'))));
1202		assert_eq!(gate.drain(), None);
1203		assert_eq!(gate.deadline(), None);
1204	}
1205
1206	/// An overdue read releases its queue, and its late result is dropped
1207	/// by generation — even after a newer read begins.
1208	#[test]
1209	fn clipboard_gate_drops_expired_and_superseded_results() {
1210		use super::ClipboardGate;
1211		use crate::{InputEvent, Key};
1212
1213		let mut gate = ClipboardGate::default();
1214		let now = tokio::time::Instant::now();
1215		let first = gate.begin(now).expect("gate idle");
1216		assert_eq!(gate.admit(InputEvent::Key(Key::Enter), &[]), None);
1217		gate.expire();
1218		assert_eq!(gate.drain(), Some(InputEvent::Key(Key::Enter)), "expiry releases queued input");
1219		let second = gate.begin(now).expect("gate idle again");
1220		assert!(!gate.settle(first), "stale result is dropped");
1221		assert!(gate.settle(second));
1222	}
1223
1224	/// Child half of `hold_alt_start_holds_without_overlay_and_releases`:
1225	/// a no-op unless re-executed with [`HOLD_HELPER_FLAG`], so terminal
1226	/// globals and the `OMP_TTY` override live in a dedicated process.
1227	#[cfg(unix)]
1228	#[test]
1229	fn hold_alt_pty_helper() {
1230		use super::AppOptions;
1231		use crate::Ui;
1232
1233		if std::env::var_os(HOLD_HELPER_FLAG).is_none() {
1234			return;
1235		}
1236		tokio::runtime::Builder::new_multi_thread()
1237			.enable_all()
1238			.build()
1239			.expect("helper runtime builds")
1240			.block_on(async {
1241				let mut app = AppOptions::new()
1242					.hold_alt()
1243					.start(|env| {
1244						Ui::from_markup("<text>inline</text>", env.viewport.width, env.ctx).unwrap()
1245					})
1246					.await
1247					.expect("helper app starts on the override device");
1248				tokio::time::sleep(Duration::from_millis(250)).await;
1249				app.hold_alt(false);
1250				let _ = tokio::time::timeout(Duration::from_millis(200), app.next()).await;
1251				drop(app);
1252			});
1253	}
1254
1255	/// An `AppOptions::hold_alt()` start with no overlay paints frame one on
1256	/// the alternate screen and `App::hold_alt(false)` releases it cleanly.
1257	/// Runs the scenario in a re-executed child process: `OMP_TTY` and the
1258	/// terminal's process-wide state never leak into this parallel harness.
1259	#[cfg(unix)]
1260	#[test]
1261	fn hold_alt_start_holds_without_overlay_and_releases() {
1262		use std::io::Read as _;
1263
1264		let winsize = nix::pty::Winsize { ws_row: 12, ws_col: 40, ws_xpixel: 0, ws_ypixel: 0 };
1265		let pty = nix::pty::openpty(Some(&winsize), None).expect("openpty succeeds");
1266		let device = nix::unistd::ttyname(&pty.slave).expect("the pty slave has a device path");
1267		let mut master = std::fs::File::from(pty.master);
1268		nix::fcntl::fcntl(&master, nix::fcntl::FcntlArg::F_SETFL(nix::fcntl::OFlag::O_NONBLOCK))
1269			.expect("master goes nonblocking");
1270
1271		let exe = std::env::current_exe().expect("test binary path");
1272		let mut child = std::process::Command::new(exe)
1273			.args(["runtime::tests::hold_alt_pty_helper", "--exact", "--test-threads=1"])
1274			.env(HOLD_HELPER_FLAG, "1")
1275			.env(crate::tty::TTY_OVERRIDE, &device)
1276			.stdout(std::process::Stdio::null())
1277			.stderr(std::process::Stdio::null())
1278			.spawn()
1279			.expect("helper process spawns");
1280
1281		let mut stream = Vec::new();
1282		let mut buffer = [0_u8; 4096];
1283		let deadline = std::time::Instant::now() + Duration::from_secs(20);
1284		loop {
1285			match master.read(&mut buffer) {
1286				Ok(0) => break,
1287				Ok(read) => stream.extend_from_slice(&buffer[..read]),
1288				Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {
1289					if let Some(status) = child.try_wait().expect("helper status readable") {
1290						assert!(status.success(), "helper scenario passes");
1291						// One final drain after exit.
1292						while let Ok(read) = master.read(&mut buffer) {
1293							if read == 0 {
1294								break;
1295							}
1296							stream.extend_from_slice(&buffer[..read]);
1297						}
1298						break;
1299					}
1300					assert!(std::time::Instant::now() < deadline, "helper finishes in time");
1301					std::thread::sleep(Duration::from_millis(20));
1302				},
1303				Err(_) => break,
1304			}
1305		}
1306		drop(child.kill());
1307
1308		let position = |needle: &[u8]| {
1309			stream
1310				.windows(needle.len())
1311				.position(|window| window == needle)
1312		};
1313		let entry = position(b"\x1b[?1049h").expect("frame one stages the alternate screen");
1314		let content = position(b"inline").expect("frame one paints the tree");
1315		assert!(entry < content, "the buffer switch precedes the first paint");
1316		let exit = position(b"\x1b[?1049l").expect("hold_alt(false) restores the main screen");
1317		assert!(content < exit, "release follows the held frames");
1318		assert!(
1319			position(b"\x1b[3J").is_none(),
1320			"an alt-first start and clean release never touch main history"
1321		);
1322	}
1323
1324	#[test]
1325	fn overlay_cancel_dismisses_the_visible_layer_before_quit_policy() {
1326		use super::{AppEvent, Routed, route_key_event};
1327		use crate::{Key, OverlayOptions, dom};
1328
1329		let mut ui = Ui::from_markup("<input id=base/>", 40, UiContext::default()).unwrap();
1330		let lower = ui.show_overlay(dom! { <text>{"lower"}</text> }, OverlayOptions::default());
1331		let upper = ui.show_overlay(dom! { <text>{"upper"}</text> }, OverlayOptions::default());
1332		assert!(ui.set_overlay_hidden(upper, true));
1333
1334		// A quit chord outranks any open layer.
1335		let quit = [Key::Ctrl('c')];
1336		assert_eq!(route_key_event(&mut ui, Key::Ctrl('c'), &quit, &[], true), Routed::Stop);
1337
1338		// Escape targets the visible layer, not the hidden stack top.
1339		assert_eq!(
1340			route_key_event(&mut ui, Key::Esc, &quit, &[], true),
1341			Routed::Event(AppEvent::OverlayClosed(lower)),
1342		);
1343		assert!(ui.overlay(lower).is_none(), "the dismissed layer is gone");
1344		assert!(ui.overlay(upper).is_some(), "the hidden layer is untouched");
1345
1346		// Every remaining layer is hidden, so Escape falls back to the policy.
1347		assert_eq!(route_key_event(&mut ui, Key::Esc, &quit, &[], true), Routed::Stop);
1348		assert_eq!(
1349			route_key_event(&mut ui, Key::Esc, &quit, &[], false),
1350			Routed::Unclaimed,
1351			"a swallowed cancel falls back to the host as an unclaimed key",
1352		);
1353
1354		// A `<button cancel>` inside a dialog dismisses the dialog, never the
1355		// application, regardless of the quit policy.
1356		let dialog =
1357			ui.show_overlay(dom! { <button cancel>{"Cancel"}</button> }, OverlayOptions::default());
1358		assert_eq!(
1359			route_key_event(&mut ui, Key::Enter, &quit, &[], true),
1360			Routed::Event(AppEvent::OverlayClosed(dialog)),
1361		);
1362		assert!(ui.overlay(dialog).is_none());
1363	}
1364
1365	/// `Ctrl+K` is an input's kill-to-end-of-line; a host that reserves it
1366	/// for a scene shortcut must win, or the chord dies at the focused
1367	/// widget.
1368	#[test]
1369	fn a_reserved_hotkey_outranks_the_focused_widgets_own_binding() {
1370		use super::{AppEvent, Routed, route_key_event};
1371		use crate::Key;
1372
1373		let mut ui =
1374			Ui::from_markup("<input id=composer value=hello/>", 40, UiContext::default()).unwrap();
1375		ui.handle_key(Key::Home);
1376		let quit = [Key::Ctrl('c')];
1377
1378		assert_eq!(
1379			route_key_event(&mut ui, Key::Ctrl('k'), &quit, &[Key::Ctrl('k')], true),
1380			Routed::Event(AppEvent::Key(Key::Ctrl('k'))),
1381		);
1382		assert_eq!(ui.values()["composer"], "hello", "the input never saw the reserved chord");
1383
1384		// Unreserved, the same chord stays the input's kill-line.
1385		assert_eq!(
1386			route_key_event(&mut ui, Key::Ctrl('k'), &quit, &[], true),
1387			Routed::Event(AppEvent::Updated)
1388		);
1389		assert_eq!(ui.values()["composer"], "");
1390	}
1391
1392	/// Animation ticks leave damage pending on every frame; a scene key no
1393	/// widget claimed must still reach the host instead of dissolving into
1394	/// [`AppEvent::Updated`].
1395	#[test]
1396	fn an_unclaimed_key_surfaces_despite_pending_damage() {
1397		use super::{Routed, route_key_event};
1398		use crate::Key;
1399
1400		let mut ui =
1401			Ui::from_markup("<text id=status>idle</text>", 40, UiContext::default()).unwrap();
1402		ui.set_text("status", "running");
1403		assert!(ui.has_damage(), "the text write left damage pending");
1404
1405		let quit = [Key::Ctrl('c')];
1406		assert_eq!(route_key_event(&mut ui, Key::Char('m'), &quit, &[], true), Routed::Unclaimed);
1407	}
1408
1409	#[test]
1410	fn cancel_with_only_a_non_modal_layer_follows_the_quit_policy() {
1411		use super::{Routed, route_key_event};
1412		use crate::{Key, OverlayOptions, dom};
1413
1414		let mut ui = Ui::from_markup("<input id=base/>", 40, UiContext::default()).unwrap();
1415		let rail =
1416			ui.show_overlay(dom! { <text>{"rail"}</text> }, OverlayOptions::default().non_modal());
1417		let quit = [Key::Ctrl('c')];
1418		assert_eq!(
1419			route_key_event(&mut ui, Key::Esc, &quit, &[], true),
1420			Routed::Stop,
1421			"a non-modal layer never soaks up the cancel",
1422		);
1423		assert!(ui.overlay(rail).is_some(), "the rail is not dismissed");
1424	}
1425
1426	#[test]
1427	fn cancel_dismisses_the_modal_beneath_a_higher_z_non_modal_layer() {
1428		use super::{AppEvent, Routed, route_key_event};
1429		use crate::{Key, OverlayOptions, dom};
1430
1431		let mut ui = Ui::from_markup("<input id=base/>", 40, UiContext::default()).unwrap();
1432		let rail = ui
1433			.show_overlay(dom! { <text>{"rail"}</text> }, OverlayOptions::default().non_modal().z(10));
1434		let dialog = ui.show_overlay(dom! { <text>{"confirm"}</text> }, OverlayOptions::default());
1435		let quit = [Key::Ctrl('c')];
1436		assert_eq!(
1437			route_key_event(&mut ui, Key::Esc, &quit, &[], true),
1438			Routed::Event(AppEvent::OverlayClosed(dialog)),
1439			"the cancel dismisses the modal that routed it, not the stack top",
1440		);
1441		assert!(ui.overlay(rail).is_some(), "the higher-z pane survives");
1442		assert!(ui.overlay(dialog).is_none());
1443	}
1444
1445	use super::{ImageLoader, Msg, UiHandle};
1446	use crate::{
1447		Cached, Component, Elements, Prop, Props, Ui, UiContext, components::Img,
1448		test_support::frame_row_text,
1449	};
1450
1451	#[expect(
1452		clippy::future_not_send,
1453		reason = "this helper runs only in current-thread Tokio tests with thread-confined UI \
1454		          components"
1455	)]
1456	async fn receive_image<'a>(loader: &'a ImageLoader, ui: &'a mut Ui) {
1457		let message = tokio::time::timeout(Duration::from_secs(5), loader.rx.recv_async())
1458			.await
1459			.expect("image decode completes")
1460			.expect("image bus remains connected");
1461		let Msg::ImageDecoded { slot, state } = message else {
1462			panic!("image loader only emits decode messages");
1463		};
1464		assert!(ui.deliver_image(slot, state));
1465	}
1466
1467	#[tokio::test(flavor = "current_thread")]
1468	async fn image_decode_delivers_without_blocking_initial_layout() {
1469		let dir = std::env::temp_dir().join(format!("omp-tui-runtime-image-{}", std::process::id()));
1470		std::fs::create_dir_all(&dir).unwrap();
1471		let path = dir.join("async.ppm");
1472		let mut ppm = b"P6\n4 4\n255\n".to_vec();
1473		for y in 0..4 {
1474			for _ in 0..4 {
1475				ppm.extend(if y < 2 { [255, 0, 0] } else { [0, 0, 255] });
1476			}
1477		}
1478		std::fs::write(&path, ppm).unwrap();
1479
1480		let loader = ImageLoader::new();
1481		let ctx = UiContext { loader: Some(loader.clone()), ..UiContext::default() };
1482		let mut ui = Ui::from_markup(format!("<img src={} w=4/>", path.display()), 10, ctx).unwrap();
1483		let initial_rows = (0..ui.height())
1484			.map(|row| frame_row_text(ui.frame(), row))
1485			.collect::<Vec<_>>();
1486		assert_eq!(ui.height(), 3, "loading uses the fixed box placeholder");
1487		assert!(initial_rows[0].contains('┌'));
1488		assert!(!initial_rows.iter().any(|row| row.contains('▀')));
1489
1490		receive_image(&loader, &mut ui).await;
1491		assert_eq!(ui.height(), 2, "4px source relayouts to two half-block rows");
1492		assert!((0..ui.height()).any(|row| frame_row_text(ui.frame(), row).contains('▀')));
1493
1494		let elements = Elements::builder()
1495			.with("logo", |_: &str, props: Props, _: Vec<Cached>| {
1496				let source = props.str_of(Prop::Src).map_or("", |value| value.as_str());
1497				Box::new(Img::new().with_str(Prop::Src, source).with(Prop::W, 4_u16))
1498					as Box<dyn Component>
1499			})
1500			.build();
1501		let custom_loader = ImageLoader::new();
1502		let mut custom_ctx = UiContext { elements, ..UiContext::default() };
1503		custom_ctx.loader = Some(custom_loader.clone());
1504		let mut custom_ui =
1505			Ui::from_markup(format!("<logo src={}/>", path.display()), 10, custom_ctx).unwrap();
1506		receive_image(&custom_loader, &mut custom_ui).await;
1507		assert!(
1508			(0..custom_ui.height()).any(|row| frame_row_text(custom_ui.frame(), row).contains('▀'))
1509		);
1510
1511		std::fs::remove_file(path).unwrap();
1512		std::fs::remove_dir(dir).unwrap();
1513	}
1514
1515	#[test]
1516	fn ui_handle_applies_sync_thread_update_between_frames() {
1517		let (tx, rx) = flume::unbounded();
1518		let handle = UiHandle { tx, cancel: tokio_util::sync::CancellationToken::new() };
1519		let mut ui =
1520			Ui::from_markup(r#"<text id="message">before</text>"#, 20, UiContext::default()).unwrap();
1521		let before = frame_row_text(ui.frame(), 0);
1522
1523		std::thread::spawn(move || handle.set_text("message", "after"))
1524			.join()
1525			.expect("update thread finishes");
1526		let Msg::Update(update) = rx.recv().expect("thread queues one mutation") else {
1527			panic!("UiHandle only emits update messages");
1528		};
1529		update(&mut ui);
1530
1531		let after = frame_row_text(ui.frame(), 0);
1532		assert_ne!(after, before);
1533		assert_eq!(after, "after");
1534	}
1535}