Skip to main content

omp_tui/
ui.rs

1//! Retained component tree layout, painting, updates, and input routing.
2
3use std::time::Duration;
4
5use omp_core::Str;
6use serde_json::Value;
7use smallvec::SmallVec;
8
9use crate::{
10	component::{Cached, EventCtx, Flow, Hit, HitTag, IntoComponent, PaintCtx, Slot, Wake},
11	components::{Img, ImgState, Row, Scroll, Tabs, Wizard},
12	context::UiContext,
13	frame::{Color, Frame, Rect, Size, Style},
14	input::{Key, Mouse, UiEvent},
15	markup::{self, ParseError},
16	overlay::{self, OverlayBand, OverlayId, OverlayOptions},
17	props::{Prop, PropValue},
18	renderer::ResolvedLayer,
19};
20
21#[derive(Clone, Debug)]
22enum Predicate {
23	Equal(Str),
24	NotEqual(Str),
25}
26
27#[derive(Clone, Debug)]
28struct CompiledCond {
29	target:    Slot,
30	source_id: Str,
31	predicate: Predicate,
32}
33
34#[derive(Clone, Copy)]
35struct PathEntry {
36	slot:        Slot,
37	rect:        Rect,
38	visible:     bool,
39	fixed:       bool,
40	row:         bool,
41	paint_owner: bool,
42}
43
44type ComponentPath = SmallVec<PathEntry, 16>;
45
46/// One stacked overlay layer: a nested retained tree plus its placement.
47struct OverlayEntry {
48	id:      OverlayId,
49	/// Boxed: the entry keeps hot placement fields inline while breaking the
50	/// `Ui` -> `OverlayEntry` -> `Ui` size cycle `SmallVec` inline storage
51	/// would otherwise create.
52	ui:      Box<Ui>,
53	options: OverlayOptions,
54	/// Placement resolved by the most recent present; `rows == 0` means the
55	/// layer is not composited (hidden, gated, or never presented).
56	band:    OverlayBand,
57	hidden:  bool,
58}
59
60impl OverlayEntry {
61	/// Whether the layer composites and captures input for `viewport`.
62	fn visible(&self, viewport: Option<Size>) -> bool {
63		!self.hidden && viewport.is_none_or(|vp| overlay::visible_at(&self.options, vp))
64	}
65}
66
67/// A parsed, laid-out, retained component tree painting into a [`Frame`].
68pub struct Ui {
69	#[allow(dead_code, reason = "keeps parsed source storage alive")]
70	pub(crate) source:     Str,
71	pub(crate) root:       Cached,
72	pub(crate) frame:      Frame,
73	pub(crate) width:      u16,
74	/// Max document height since the last width change or [`Self::refit`].
75	pub(crate) high_water: u16,
76	/// Row ranges repainted since the last successful present.
77	pub(crate) damage:     SmallVec<(u16, u16), 8>,
78	pub(crate) focus:      Option<Slot>,
79	pub(crate) hover:      Option<(Slot, HitTag)>,
80	/// Last pointer cell in document coordinates, feeding pointer-tracking
81	/// chrome such as the `hover` glow.
82	pub(crate) pointer:    Option<(u16, u16)>,
83	/// Whether the keyboard was the most recent input modality; gates the
84	/// focus-side hover chrome so only one chrome cursor exists.
85	pub(crate) keyboard:   bool,
86	pub(crate) hits:       Vec<Hit>,
87	drag:                  Option<Hit>,
88	conds:                 Vec<CompiledCond>,
89	/// Presentation clock: the `now` of the most recent [`Ui::tick`].
90	now:                   Duration,
91	/// Pending animation wake requests, rebuilt by every paint.
92	wakes:                 Vec<Wake>,
93	/// Throwaway drag-paint hit storage, retained to avoid per-frame allocation.
94	resize_hits:           Vec<Hit>,
95	/// Throwaway drag-paint wake storage, retained to avoid per-frame
96	/// allocation.
97	resize_wakes:          Vec<Wake>,
98	pub(crate) ctx:        UiContext,
99	/// Stacked overlay layers, bottom to top: ascending (`options.z`, creation
100	/// order).
101	overlays:              SmallVec<OverlayEntry, 4>,
102	/// Monotonic id source for overlay handles.
103	next_overlay:          u32,
104	/// Non-modal layer currently holding the keyboard, if any.
105	key_overlay:           Option<OverlayId>,
106	/// Viewport recorded by the most recent [`Ui::present`].
107	viewport:              Option<Size>,
108	/// Renderer window top recorded by the most recent [`Ui::present`].
109	window_top:            u16,
110	/// The overlay stack changed shape since the last present.
111	overlays_dirty:        bool,
112	/// Marks an overlay-owned tree, which cannot stack overlays of its own.
113	nested:                bool,
114}
115
116impl Ui {
117	/// Parses runtime markup and produces the first fully painted frame.
118	///
119	/// Prefer [`Ui::from_root`] with [`crate::dom!`] when the structure is
120	/// known at compile time; this path is for markup that only exists at
121	/// runtime (configuration, generated text, editable source).
122	///
123	/// # Errors
124	/// Returns [`ParseError`] for malformed markup.
125	pub fn from_markup(
126		source: impl Into<Str>,
127		width: u16,
128		ctx: UiContext,
129	) -> Result<Self, ParseError> {
130		let source = source.into();
131		let root = markup::parse(&source, &ctx)?;
132		Ok(Self::from_cached(source, root, width, ctx))
133	}
134
135	/// Builds a retained UI directly from a component tree.
136	pub fn from_root(root: impl IntoComponent, width: u16, ctx: UiContext) -> Self {
137		Self::from_cached(Str::new(""), Cached::new(root.into_component()), width, ctx)
138	}
139
140	fn from_cached(source: Str, root: Cached, width: u16, ctx: UiContext) -> Self {
141		let mut ui = Self {
142			source,
143			root,
144			frame: Frame::new(Size::new(width, 0)),
145			width,
146			high_water: 0,
147			damage: SmallVec::new(),
148			focus: None,
149			hover: None,
150			pointer: None,
151			keyboard: false,
152			hits: Vec::new(),
153			drag: None,
154			conds: Vec::new(),
155			now: Duration::ZERO,
156			wakes: Vec::new(),
157			resize_hits: Vec::new(),
158			resize_wakes: Vec::new(),
159			ctx,
160			overlays: SmallVec::new(),
161			next_overlay: 0,
162			key_overlay: None,
163			viewport: None,
164			window_top: 0,
165			overlays_dirty: false,
166			nested: false,
167		};
168		ui.compile_conds();
169		ui.apply_conds(false);
170		ui.focus = ui.focus_ring().first().copied();
171		if let Some(slot) = ui.focus
172			&& let Some(cached) = ui.root.find_slot(slot)
173		{
174			// Entering focus positions internal selection (a single select
175			// rests on its chosen option) even before the first key.
176			cached.comp_mut().enter(true);
177		}
178		ui.layout_all();
179		ui
180	}
181
182	/// The retained document frame.
183	pub const fn frame(&self) -> &Frame {
184		&self.frame
185	}
186
187	/// The presentation context this tree renders with.
188	pub const fn context(&self) -> &UiContext {
189		&self.ctx
190	}
191
192	/// Swaps the presentation context and refreshes the whole document.
193	///
194	/// Applies `ctx` to this tree and every stacked overlay, advances the
195	/// cache revision so geometry and render memos discard context-derived
196	/// output, then relays out and repaints. A context that compares equal
197	/// (appearance, charset, graphics, Jamo policy, theme, elements) is a
198	/// no-op returning `false`. The presentation clock is retained, and a
199	/// context without an image loader keeps the installed one. Structure
200	/// parsed from markup is retained: swapping `elements` affects future
201	/// parses only.
202	pub fn set_context(&mut self, ctx: UiContext) -> bool {
203		if self.ctx == ctx {
204			return false;
205		}
206		// Keep the process-wide width policy in sync; a Jamo change also
207		// advances the width epoch geometry memos key on.
208		crate::rich::set_jamo_width(ctx.jamo_width);
209		self.apply_context(&ctx);
210		true
211	}
212
213	/// Installs `ctx`, preserving this tree's clock and loader, then relays
214	/// out and recurses into overlay layers.
215	///
216	/// Every tree advances from its own revision: an overlay swapped
217	/// directly through [`Ui::overlay_mut`] may already sit at the parent's
218	/// next revision, and reusing the parent's number would let memos keyed
219	/// on it survive the swap.
220	fn apply_context(&mut self, ctx: &UiContext) {
221		let revision = self.ctx.revision.wrapping_add(1);
222		let now = self.ctx.now;
223		let loader = self.ctx.loader.take();
224		self.ctx = ctx.clone();
225		self.ctx.revision = revision;
226		self.ctx.now = now;
227		if self.ctx.loader.is_none() {
228			self.ctx.loader = loader;
229		}
230		self.layout_all();
231		for entry in &mut self.overlays {
232			entry.ui.apply_context(ctx);
233		}
234	}
235
236	/// Document height in rows after the last layout.
237	pub const fn height(&self) -> u16 {
238		self.root.rect.height
239	}
240
241	/// Whether a present is needed after the most recent mutation.
242	pub fn has_damage(&self) -> bool {
243		!self.damage.is_empty()
244			|| self.overlays_dirty
245			|| self.overlays.iter().any(|entry| entry.ui.has_damage())
246	}
247
248	/// Consumes raw-frame damage after an embedder copies [`Ui::frame`].
249	pub fn take_frame_damage(&mut self) -> bool {
250		let changed = !self.damage.is_empty();
251		self.damage.clear();
252		changed
253	}
254
255	/// Clears damage after a renderer rebuild supersedes incremental updates.
256	///
257	/// A rebuild repaints the raw document, so any overlay stack must
258	/// recomposite on the next present.
259	pub(crate) fn clear_damage(&mut self) {
260		self.damage.clear();
261		self.overlays_dirty |= !self.overlays.is_empty();
262	}
263
264	/// Marks the whole document damaged so the next present repaints it.
265	///
266	/// Releasing an alternate-screen hold uses this: previews consumed the
267	/// incremental damage without ever painting the main buffer, so the
268	/// first present after restore must revalidate every live row.
269	pub(crate) fn damage_all(&mut self) {
270		self.damage.clear();
271		self.damage.push((0, self.frame.size().height));
272		self.overlays_dirty |= !self.overlays.is_empty();
273	}
274
275	/// Replaces a named component's text and refreshes the smallest safe region.
276	pub fn set_text(&mut self, id: &str, text: impl Into<Str>) -> bool {
277		let Some((slot, old_measure, old_rect, presented)) = self.snapshot_id(id) else {
278			return false;
279		};
280		let text = text.into();
281		let ctx = &self.ctx;
282		let Some(changed) = self.root.update_id(id, |cached| {
283			let changed = cached.comp_mut().set_text(ctx, text);
284			(changed, true)
285		}) else {
286			return false;
287		};
288		if !changed {
289			return false;
290		}
291		if presented {
292			self.refresh_slot(slot, old_measure, old_rect);
293		}
294		self.apply_conds(true);
295		true
296	}
297
298	/// Sets a named component's property and refreshes the smallest safe
299	/// region. Size properties relayout the document; components with an
300	/// `anim` property tween toward the new value from whatever is on
301	/// screen. A matching value is a no-op. Returns `false` for an unknown
302	/// id.
303	///
304	/// # Panics
305	/// Panics when a textual value is invalid for `prop`, matching
306	/// [`Props::set`].
307	pub fn set_prop(&mut self, id: &str, prop: Prop, value: impl Into<PropValue>) -> bool {
308		let Some((slot, old_measure, old_rect, presented)) = self.snapshot_id(id) else {
309			return false;
310		};
311		let value = value.into();
312		let changed = self
313			.root
314			.update(slot, |cached| {
315				let before = cached.comp().props().get(prop).cloned();
316				cached.comp_mut().props_mut().set(prop, value);
317				let changed = cached.comp().props().get(prop) != before.as_ref();
318				(changed, changed)
319			})
320			.unwrap_or(false);
321		if !changed {
322			return true;
323		}
324		match prop {
325			// A size target can move every following sibling.
326			Prop::W | Prop::H => self.layout_all(),
327			_ if presented => self.refresh_slot(slot, old_measure, old_rect),
328			_ => {},
329		}
330		self.apply_conds(true);
331		true
332	}
333
334	/// Shows or hides a named component and relayouts the document; a
335	/// hidden component skips layout, paint, focus, and hit-testing.
336	/// Prefer `when=` conditions for value-driven visibility — this is the
337	/// imperative counterpart for hosts driving visibility from app state
338	/// (a detail pane following a list cursor). Returns `false` for an
339	/// unknown id.
340	pub fn set_visible(&mut self, id: &str, visible: bool) -> bool {
341		let Some(changed) = self.root.update_id(id, |cached| {
342			let changed = cached.visible != visible;
343			cached.visible = visible;
344			(changed, changed)
345		}) else {
346			return false;
347		};
348		if changed {
349			self.layout_all();
350		}
351		true
352	}
353
354	/// Advances the deterministic presentation clock and repaints every
355	/// component whose wake deadline has passed.
356	///
357	/// [`crate::App`] drives this clock in production; tests and custom hosts
358	/// can supply their own monotonic [`Duration`]. Returns whether anything
359	/// repainted.
360	pub fn tick(&mut self, now: Duration) -> bool {
361		self.now = now;
362		self.ctx.now = now;
363		let mut due: SmallVec<Wake, 4> = SmallVec::new();
364		self.wakes.retain(|&wake| {
365			if wake.at <= now {
366				due.push(wake);
367				false
368			} else {
369				true
370			}
371		});
372		for wake in &due {
373			if wake.layout {
374				self.relayout_slot(wake.slot);
375			} else {
376				self.repaint_slot(wake.slot);
377			}
378		}
379		let mut repainted = !due.is_empty();
380		for entry in &mut self.overlays {
381			repainted |= entry.ui.tick(now);
382		}
383		repainted
384	}
385
386	/// Earliest pending animation deadline, if any component is animating.
387	/// [`crate::App`] schedules it; custom hosts may do the same.
388	pub fn next_wake(&self) -> Option<Duration> {
389		let own = self.wakes.iter().map(|wake| wake.at).min();
390		self
391			.overlays
392			.iter()
393			.filter_map(|entry| entry.ui.next_wake())
394			.chain(own)
395			.min()
396	}
397
398	/// Refreshes a named component whose externally shared state changed.
399	///
400	/// The out-of-band companion to event routing: components that read
401	/// application state through interior mutability cannot be reached by a
402	/// key or mouse path, so the owner mutates the state and invalidates the
403	/// component by id. Returns `false` for an unknown id.
404	pub fn invalidate(&mut self, id: &str) -> bool {
405		let Some((slot, old_measure, old_rect, presented)) = self.snapshot_id(id) else {
406			return false;
407		};
408		if presented {
409			self.refresh_slot(slot, old_measure, old_rect);
410		}
411		self.apply_conds(true);
412		true
413	}
414
415	/// Installs a decoded image into the [`Img`] at `slot` and refreshes the
416	/// smallest safe region.
417	///
418	/// Returns `false` when the slot is gone or does not contain an image.
419	pub(crate) fn deliver_image(&mut self, slot: Slot, state: ImgState) -> bool {
420		if self.root.find_slot(slot).is_none() {
421			// The decoder pump is tree-agnostic: the slot may live in an overlay.
422			for entry in &mut self.overlays {
423				if entry.ui.root.find_slot(slot).is_some() {
424					return entry.ui.deliver_image(slot, state);
425				}
426			}
427			return false;
428		}
429		let Some((old_measure, old_rect)) = self
430			.root
431			.find_slot(slot)
432			.map(|cached| (cached.measure(&self.ctx), cached.rect))
433		else {
434			return false;
435		};
436		let Some(delivered) = self.root.update(slot, |cached| {
437			let Some(img) = cached.comp_mut().downcast_mut::<Img>() else {
438				return (false, false);
439			};
440			img.apply_decoded(state);
441			(true, true)
442		}) else {
443			return false;
444		};
445		if !delivered {
446			return false;
447		}
448		self.refresh_slot(slot, old_measure, old_rect);
449		self.apply_conds(true);
450		true
451	}
452
453	/// Relayouts and repaints everything at a new width.
454	pub fn resize(&mut self, width: u16) {
455		self.width = width;
456		self.refit();
457	}
458
459	/// Drops the height watermark and relayouts, shrinking the frame to the
460	/// current content height.
461	///
462	/// The watermark pads the frame so content shrinkage between presents
463	/// cannot move committed rows under the bottom-anchored window. A
464	/// [`Renderer::rebuild`](crate::Renderer::rebuild) re-anchors physical
465	/// state, so the runtime refits first — otherwise stale padding from a
466	/// taller viewport pins the window past the real document and paints
467	/// phantom blank rows.
468	pub(crate) fn refit(&mut self) {
469		self.high_water = 0;
470		self.root.invalidate();
471		self.layout_all();
472	}
473
474	/// Forces this tree's root to a fixed height, relayouting when it
475	/// changes.
476	///
477	/// Fill-height overlays are sized to their viewport band on every
478	/// present; the watermark resets so a shrinking viewport shrinks the
479	/// frame with it.
480	fn set_root_height(&mut self, rows: u16) {
481		if self.root.comp().props().h() == Some(rows) {
482			return;
483		}
484		self.root.comp_mut().props_mut().set(Prop::H, rows);
485		self.high_water = 0;
486		self.root.invalidate();
487		self.layout_all();
488	}
489
490	/// Sets a named component's fixed height.
491	pub fn set_height(&mut self, id: &str, height: u16) -> bool {
492		let Some(changed) = self.root.update_id(id, |cached| {
493			if cached.comp().props().h() == Some(height) {
494				return (false, false);
495			}
496			cached.comp_mut().props_mut().set(Prop::H, height);
497			(true, true)
498		}) else {
499			return false;
500		};
501		if changed {
502			// Changing the boundary itself can move following siblings.
503			self.layout_all();
504		}
505		true
506	}
507
508	/// Resolves every overlay's viewport band for this present, sizing
509	/// fill-height layers to the full available height.
510	fn resolve_overlay_bands(&mut self, viewport: Size) {
511		for entry in &mut self.overlays {
512			if entry.hidden || !overlay::visible_at(&entry.options, viewport) {
513				entry.band = OverlayBand { x: 0, y: 0, src_top: 0, rows: 0 };
514				continue;
515			}
516			let extent = overlay::resolve_extent(&entry.options, viewport);
517			if entry.ui.width != extent.width {
518				entry.ui.resize(extent.width);
519			}
520			if entry.options.fill_height {
521				entry.ui.set_root_height(extent.max_height);
522			}
523			entry.band =
524				overlay::resolve_band(&entry.options, viewport, extent.width, entry.ui.height());
525		}
526	}
527
528	/// The composited layer stack in z order, one entry per placed band.
529	/// The layer receiving keys carries the hardware cursor; passive panes
530	/// let the base document's caret show through.
531	fn resolved_layers(&self) -> SmallVec<ResolvedLayer<'_>, 4> {
532		let active = self.top_overlay();
533		self
534			.overlays
535			.iter()
536			.filter(|entry| entry.band.rows > 0 && entry.visible(self.viewport))
537			.map(|entry| ResolvedLayer {
538				frame:   entry.ui.frame(),
539				x:       entry.band.x,
540				y:       entry.band.y,
541				src_top: entry.band.src_top,
542				rows:    entry.band.rows,
543				active:  Some(entry.id) == active,
544			})
545			.collect()
546	}
547
548	/// Presents the retained frame without copying it, compositing every
549	/// visible overlay above the document for this viewport.
550	///
551	/// # Errors
552	/// Propagates the renderer's contract and writer errors.
553	pub fn present<W: std::io::Write>(
554		&mut self,
555		renderer: &mut crate::Renderer<W>,
556		viewport_height: u16,
557		stable_rows: u16,
558	) -> std::io::Result<crate::PaintStats> {
559		renderer.set_graphics(self.ctx.graphics);
560		let viewport = Size::new(self.width, viewport_height);
561		self.viewport = Some(viewport);
562		self.resolve_overlay_bands(viewport);
563		self.damage.sort_unstable();
564		let mut merged: SmallVec<(u16, u16), 8> = SmallVec::new();
565		for &(start, end) in &self.damage {
566			match merged.last_mut() {
567				Some(last) if start <= last.1 => last.1 = last.1.max(end),
568				_ => merged.push((start, end)),
569			}
570		}
571		let layers = self.resolved_layers();
572		let stats =
573			renderer.present_resolved(&self.frame, &merged, viewport_height, stable_rows, &layers)?;
574		drop(layers);
575		self.window_top = renderer.window_top();
576		self.overlays_dirty = false;
577		self.damage.clear();
578		for entry in &mut self.overlays {
579			entry.ui.damage.clear();
580		}
581		Ok(stats)
582	}
583
584	/// Paints the composited viewport as a throwaway frame, leaving the
585	/// renderer's committed history untouched.
586	///
587	/// Alternate-screen presentation: hosts holding the alternate screen —
588	/// for a modal overlay or a fullscreen scene — repaint with this on every
589	/// damage or geometry change, and `leading_sequence` lets the buffer
590	/// switch ride the same synchronized update (see
591	/// [`Terminal::stage_alt_enter`](crate::Terminal::stage_alt_enter)).
592	/// Damage is consumed exactly like [`Ui::present`].
593	///
594	/// # Errors
595	/// Propagates the renderer's contract and writer errors.
596	pub fn preview<W: std::io::Write>(
597		&mut self,
598		renderer: &mut crate::Renderer<W>,
599		viewport_height: u16,
600		leading_sequence: &str,
601	) -> std::io::Result<crate::PaintStats> {
602		renderer.set_graphics(self.ctx.graphics);
603		let viewport = Size::new(self.width, viewport_height);
604		self.viewport = Some(viewport);
605		self.resolve_overlay_bands(viewport);
606		let layers = self.resolved_layers();
607		let stats =
608			renderer.preview_resolved(&self.frame, &layers, viewport_height, leading_sequence)?;
609		drop(layers);
610		// Hit-testing while held maps against the previewed tail window.
611		self.window_top = self.frame.size().height.saturating_sub(viewport_height);
612		self.overlays_dirty = false;
613		self.damage.clear();
614		for entry in &mut self.overlays {
615			entry.ui.damage.clear();
616		}
617		Ok(stats)
618	}
619
620	/// Composes one screen of throwaway drag content at `viewport` without
621	/// relayouting the retained tree.
622	///
623	/// The root's tail children ([`crate::Component::resize_tail`]) are
624	/// composed bottom-up at the new width until the viewport is full —
625	/// O(viewport) work per drag frame. Nested vertical stacks (including
626	/// the implicit markup root) are walked recursively without ever
627	/// computing their full height, and a leaf taller than the space left
628	/// is sliced to its bottom rows, so work stays bounded by the screen
629	/// rather than document history. `None` means the root has no tail fast
630	/// path; callers fall back to a full [`Ui::resize`]. Child placement is
631	/// transient: the deferred [`Ui::resize`] at settle re-places
632	/// everything.
633	pub fn compose_resize_tail(&mut self, viewport: Size) -> Option<Frame> {
634		if viewport.width == 0 || viewport.height == 0 {
635			return None;
636		}
637		let paints_border = self.root.comp().paints_border();
638		let x_inset = crate::component::horizontal_inset(self.root.comp().props(), paints_border);
639		let y_inset = crate::component::vertical_inset(self.root.comp().props(), paints_border);
640		let content_width = viewport
641			.width
642			.saturating_sub(x_inset.saturating_mul(2))
643			.max(1);
644		let mut frame = Frame::new(viewport);
645		self.resize_hits.clear();
646		self.resize_wakes.clear();
647		let bottom_start = viewport.height.saturating_sub(y_inset);
648		let content_top = compose_tail(
649			&mut frame,
650			&self.ctx,
651			self.now,
652			self.focus,
653			&mut self.resize_hits,
654			&mut self.resize_wakes,
655			self.root.comp_mut(),
656			x_inset,
657			content_width,
658			bottom_start,
659		)?;
660		// Underflow: every child fits above the viewport bottom. Normal
661		// layout top-aligns a short document, so shift the composed band up
662		// instead of letting it jump to the bottom for the drag.
663		if content_top > y_inset && content_top < bottom_start {
664			let rows = bottom_start - content_top;
665			let mut aligned = Frame::new(viewport);
666			aligned.blit(&frame, content_top, rows, 0, y_inset);
667			return Some(aligned);
668		}
669		Some(frame)
670	}
671
672	/// Routes a key to the layer holding the keyboard — the topmost visible
673	/// modal overlay, else the non-modal layer focused through
674	/// [`Ui::focus_overlay`] or a click — falling back to the base tree's
675	/// focused component with focus-ring fallback.
676	pub fn handle_key(&mut self, key: Key) -> UiEvent {
677		self.handle_key_claimed(key).0
678	}
679
680	/// [`Ui::handle_key`] plus whether the key was claimed: consumed by a
681	/// component, or spent moving focus. An unclaimed key routed through
682	/// the tree untouched — pending damage from animations or unrelated
683	/// components never counts as a claim.
684	pub(crate) fn handle_key_claimed(&mut self, key: Key) -> (UiEvent, bool) {
685		if let Some(index) = self.key_target() {
686			let modal = self.overlays[index].options.modal;
687			let had_focus = self.overlays[index].ui.focus.is_some();
688			let (event, claimed) = self.overlays[index].ui.handle_key_claimed(key);
689			if modal && event == UiEvent::None && !had_focus && matches!(key, Key::Esc) {
690				// A focus-free overlay must still be dismissible.
691				return (UiEvent::Cancel, true);
692			}
693			if !modal
694				&& (event == UiEvent::Cancel
695					|| (event == UiEvent::None && !had_focus && matches!(key, Key::Esc)))
696			{
697				// A non-modal layer hands the keyboard back instead of
698				// dismissing: an unconsumed Esc (or a cancel surfacing from
699				// inside it) blurs the layer and the base tree resumes.
700				self.blur_overlay();
701				return (UiEvent::None, true);
702			}
703			return (event, claimed);
704		}
705		self.set_keyboard(true);
706		if let Some((slot, _)) = self.hover.take() {
707			self.hover_repaint(slot);
708		}
709		let Some(focus) = self.focus else {
710			self.move_focus(true);
711			// Seeding the ring is a side effect: only navigation keys are
712			// spent on it, anything else stays the host's to observe.
713			return (UiEvent::None, Self::is_focus_nav(key));
714		};
715		let Some((flow, layout, old_measure, old_rect)) = self.key_component(focus, key) else {
716			self.focus = None;
717			self.move_focus(true);
718			return (UiEvent::None, Self::is_focus_nav(key));
719		};
720		match flow {
721			Flow::Consumed => {
722				self.refresh_routed(focus, layout, old_measure, old_rect);
723				(UiEvent::None, true)
724			},
725			// An event usually follows a state change (a select committing
726			// or re-filtering), so the emitter repaints before surfacing it.
727			Flow::Event(event) => {
728				self.refresh_routed(focus, layout, old_measure, old_rect);
729				(event, true)
730			},
731			Flow::Skip => {
732				match key {
733					Key::Tab | Key::Enter | Key::Right => self.move_focus(true),
734					Key::BackTab | Key::Left => self.move_focus(false),
735					Key::Down => self.move_focus_vertical(true),
736					Key::Up => self.move_focus_vertical(false),
737					Key::Esc => return (UiEvent::Cancel, false),
738					_ => return (UiEvent::None, false),
739				}
740				(UiEvent::None, true)
741			},
742		}
743	}
744
745	/// Whether `key` drives focus-ring navigation when no component takes it.
746	const fn is_focus_nav(key: Key) -> bool {
747		matches!(
748			key,
749			Key::Tab | Key::BackTab | Key::Enter | Key::Left | Key::Right | Key::Up | Key::Down
750		)
751	}
752
753	/// Routes sanitized paste text to the focused component; the returned
754	/// event mirrors [`Ui::handle_key`].
755	pub fn handle_paste(&mut self, text: &str) -> UiEvent {
756		self.route_paste(text, false)
757	}
758
759	/// Routes paste text for verbatim insertion ([`Component::paste_raw`]):
760	/// no drop classification, no large-paste collapse. Backs the
761	/// Ctrl+Shift+V clipboard fallback.
762	pub fn handle_paste_raw(&mut self, text: &str) -> UiEvent {
763		self.route_paste(text, true)
764	}
765
766	fn route_paste(&mut self, text: &str, raw: bool) -> UiEvent {
767		if let Some(index) = self.key_target() {
768			return self.overlays[index].ui.route_paste(text, raw);
769		}
770		self.set_keyboard(true);
771		let Some(focus) = self.focus else {
772			return UiEvent::None;
773		};
774		let ctx = &self.ctx;
775		let Some((flow, layout, old_measure, old_rect)) = self.root.update(focus, |cached| {
776			let old_measure = cached.measure(ctx);
777			let old_rect = cached.rect;
778			let (width, view_rows) = event_size(cached);
779			let mut ec = EventCtx::new(ctx, width, view_rows);
780			let component = cached.comp_mut();
781			let flow = if raw {
782				component.paste_raw(&mut ec, text)
783			} else {
784				component.paste(&mut ec, text)
785			};
786			let dirty = !matches!(flow, Flow::Skip);
787			((flow, ec.layout, old_measure, old_rect), dirty)
788		}) else {
789			return UiEvent::None;
790		};
791		match flow {
792			Flow::Skip => UiEvent::None,
793			Flow::Consumed => {
794				self.refresh_routed(focus, layout, old_measure, old_rect);
795				UiEvent::None
796			},
797			Flow::Event(event) => {
798				self.refresh_routed(focus, layout, old_measure, old_rect);
799				event
800			},
801		}
802	}
803
804	/// Refreshes after one routed, consumed event: an explicit layout
805	/// request from the handler relayouts everything (its geometry changed
806	/// outside its own subtree through shared state), otherwise the smallest
807	/// safe region around the target refreshes.
808	fn refresh_routed(&mut self, slot: Slot, layout: bool, old_measure: (u16, u16), old_rect: Rect) {
809		if layout {
810			self.layout_all();
811		} else {
812			self.refresh_slot(slot, old_measure, old_rect);
813		}
814		self.apply_conds(true);
815	}
816
817	/// Moves focus to the first focusable component when nothing is focused
818	/// yet, activating keyboard chrome.
819	///
820	/// The entry half of a raw-frame layer host's keyboard hand-off;
821	/// [`Ui::blur`] is the exit half. Retained stacks get both through
822	/// [`Ui::focus_overlay`] and [`Ui::blur_overlay`].
823	pub fn focus_first(&mut self) {
824		self.set_keyboard(true);
825		if self.focus.is_none() {
826			self.move_focus(true);
827		}
828	}
829
830	/// Clears this tree's focus, removing focus chrome and the caret.
831	///
832	/// Raw-frame layer hosts call this when the keyboard returns to the
833	/// document, so no stale chrome suggests typing still lands here.
834	pub fn blur(&mut self) {
835		self.clear_hover();
836		self.assign_focus(None, true);
837	}
838
839	/// Routes a mouse gesture in document cell coordinates; visible overlays
840	/// occlude the document within their bounds.
841	pub fn handle_mouse(&mut self, x: u16, y: u16, mouse: Mouse) -> UiEvent {
842		if let Some(event) = self.route_overlay_mouse(x, y, mouse) {
843			return event;
844		}
845		self.pointer = Some((x, y));
846		self.set_keyboard(false);
847		match mouse {
848			Mouse::Move => {
849				self.update_hover(x, y);
850				UiEvent::None
851			},
852			Mouse::Drag => {
853				self.update_hover(x, y);
854				let hit = self
855					.drag_hit()
856					.or_else(|| self.hit_at(x, y, false).or_else(|| self.hit_at(x, y, true)));
857				let Some(hit) = hit else {
858					return UiEvent::None;
859				};
860				self.drag = Some(hit);
861				self.mouse_component(hit, (x, y), mouse).0
862			},
863			Mouse::Release => {
864				self.update_hover(x, y);
865				let hit = self
866					.drag_hit()
867					.or_else(|| self.hit_at(x, y, false).or_else(|| self.hit_at(x, y, true)));
868				self.drag = None;
869				hit.map_or(UiEvent::None, |hit| self.mouse_component(hit, (x, y), mouse).0)
870			},
871			Mouse::Click => {
872				// A click is proof of the pointer's position even without a
873				// preceding motion report: the chrome cursor follows it.
874				self.update_hover(x, y);
875				let Some(hit) = self.hit_at(x, y, false) else {
876					self.drag = None;
877					return UiEvent::None;
878				};
879				self.drag = Some(hit);
880				if self.focus_ring().contains(&hit.slot) {
881					self.assign_focus(Some(hit.slot), true);
882				}
883				self.mouse_component(hit, (x, y), mouse).0
884			},
885			Mouse::RightClick | Mouse::MiddleClick => {
886				self.update_hover(x, y);
887				let Some(hit) = self.hit_at(x, y, false).or_else(|| self.hit_at(x, y, true)) else {
888					self.drag = None;
889					return UiEvent::None;
890				};
891				self.drag = Some(hit);
892				self.mouse_component(hit, (x, y), mouse).0
893			},
894			Mouse::WheelUp | Mouse::WheelDown | Mouse::WheelLeft | Mouse::WheelRight => {
895				for wheel_zone in [true, false] {
896					let Some(hit) = self.hit_at(x, y, wheel_zone) else {
897						continue;
898					};
899					let (event, consumed) = self.mouse_component(hit, (x, y), mouse);
900					if event != UiEvent::None {
901						return event;
902					}
903					if consumed {
904						return UiEvent::None;
905					}
906				}
907				UiEvent::None
908			},
909		}
910	}
911
912	/// Routes a viewport-coordinate mouse gesture into this tree when it is
913	/// composited as a raw [`crate::Layer`] under `options` — the raw-frame
914	/// host counterpart of the overlay stack's own routing
915	/// ([`crate::Renderer::present_overlaid`] instead of
916	/// [`Ui::show_overlay`]). The band is resolved exactly as the
917	/// compositor resolves it, coordinates are translated into this tree's
918	/// local cells, and a drag that started inside stays captured. `None`
919	/// means the gesture fell outside the layer (a `Move` outside also
920	/// clears hover chrome).
921	pub fn handle_mouse_as_layer(
922		&mut self,
923		options: &OverlayOptions,
924		viewport: Size,
925		x: u16,
926		y: u16,
927		mouse: Mouse,
928	) -> Option<UiEvent> {
929		if !overlay::visible_at(options, viewport) {
930			if matches!(mouse, Mouse::Move) {
931				self.clear_hover();
932			}
933			return None;
934		}
935		let extent = overlay::resolve_extent(options, viewport);
936		if self.width != extent.width {
937			self.resize(extent.width);
938		}
939		let band = overlay::resolve_band(options, viewport, extent.width, self.height());
940		let captured = self.drag.is_some() && matches!(mouse, Mouse::Drag | Mouse::Release);
941		let inside = band.rows > 0
942			&& x >= band.x
943			&& x < band.x.saturating_add(self.width)
944			&& y >= band.y
945			&& y < band.y.saturating_add(band.rows);
946		if !inside && !captured {
947			if matches!(mouse, Mouse::Move) {
948				self.clear_hover();
949			}
950			return None;
951		}
952		let local_x = x.saturating_sub(band.x);
953		let local_y = y.saturating_sub(band.y).saturating_add(band.src_top);
954		Some(self.handle_mouse(local_x, local_y, mouse))
955	}
956
957	/// Records which modality drove the last input, repainting the focused
958	/// component's decorated scope when ownership of the chrome flips.
959	fn set_keyboard(&mut self, keyboard: bool) {
960		if self.keyboard == keyboard {
961			return;
962		}
963		self.keyboard = keyboard;
964		if let Some(focus) = self.focus {
965			self.hover_repaint(focus);
966		}
967	}
968
969	/// Collects values from every visible component of the base tree;
970	/// overlay trees report through [`Ui::overlay`].
971	pub fn values(&self) -> Value {
972		let mut values = serde_json::Map::new();
973		collect_values(&self.root, &mut values);
974		Value::Object(values)
975	}
976
977	/// Component-tree snapshot for the `OMP_TUI_DEBUG` protocol: per node the
978	/// component kind, optional `id`, outer rectangle, visibility, and focus,
979	/// plus every overlay layer with its resolved band.
980	pub(crate) fn debug_tree(&self) -> Value {
981		let mut root = serde_json::Map::new();
982		root.insert("root".into(), debug_node(&self.root, self.focus));
983		let overlays: Vec<Value> = self
984			.overlays
985			.iter()
986			.map(|entry| {
987				let mut layer = serde_json::Map::new();
988				layer.insert("overlay".into(), Value::from(entry.id.0));
989				layer.insert("hidden".into(), Value::from(entry.hidden));
990				layer.insert(
991					"band".into(),
992					Value::from(vec![
993						i64::from(entry.band.x),
994						i64::from(entry.band.y),
995						i64::from(entry.band.rows),
996					]),
997				);
998				layer.insert("root".into(), debug_node(&entry.ui.root, entry.ui.focus));
999				Value::Object(layer)
1000			})
1001			.collect();
1002		if !overlays.is_empty() {
1003			root.insert("overlays".into(), Value::from(overlays));
1004		}
1005		Value::Object(root)
1006	}
1007
1008	/// Stacks an overlay tree above the document.
1009	///
1010	/// The overlay is its own retained [`Ui`]: address it through
1011	/// [`Ui::overlay`] / [`Ui::overlay_mut`] for `set_text`, `values`, and
1012	/// friends. Placement follows `options` against the viewport of each
1013	/// [`Ui::present`]; the layer composites above the document and never
1014	/// enters native terminal scrollback. Explicit z orders layers regardless
1015	/// of creation order; later overlays stack on top among equal z. The
1016	/// topmost visible modal overlay receives every key and paste until
1017	/// closed or hidden; a non-modal layer ([`OverlayOptions::non_modal`])
1018	/// leaves the keyboard with the base tree until focused through
1019	/// [`Ui::focus_overlay`] or a click inside its band.
1020	///
1021	/// # Panics
1022	/// Panics when called on an overlay's own tree: overlays stack on the
1023	/// presenting `Ui`.
1024	pub fn show_overlay(&mut self, root: impl IntoComponent, options: OverlayOptions) -> OverlayId {
1025		assert!(!self.nested, "overlays stack on the presenting Ui, not on an overlay tree");
1026		let provisional = self.viewport.unwrap_or_else(|| Size::new(self.width, 1));
1027		let width = overlay::resolve_extent(&options, provisional).width;
1028		let mut ui = Self::from_root(root, width, self.ctx.clone());
1029		ui.nested = true;
1030		if !options.modal {
1031			// A pane starts without the keyboard: no focus chrome or frame
1032			// cursor until it takes it through focus or a click.
1033			ui.blur();
1034		}
1035		let id = OverlayId(self.next_overlay);
1036		self.next_overlay += 1;
1037		let z = options.z;
1038		let at = self
1039			.overlays
1040			.iter()
1041			.rposition(|entry| entry.options.z <= z)
1042			.map_or(0, |index| index + 1);
1043		self.overlays.insert(at, OverlayEntry {
1044			id,
1045			ui: Box::new(ui),
1046			options,
1047			band: OverlayBand { x: 0, y: 0, src_top: 0, rows: 0 },
1048			hidden: false,
1049		});
1050		self.overlays_dirty = true;
1051		id
1052	}
1053
1054	/// Removes an overlay; the next present repaints the document beneath it.
1055	///
1056	/// Returns `false` for an unknown id.
1057	pub fn close_overlay(&mut self, id: OverlayId) -> bool {
1058		let before = self.overlays.len();
1059		self.overlays.retain(|entry| entry.id != id);
1060		let closed = self.overlays.len() != before;
1061		if closed && self.key_overlay == Some(id) {
1062			self.key_overlay = None;
1063		}
1064		self.overlays_dirty |= closed;
1065		closed
1066	}
1067
1068	/// Removes the topmost layer (highest z, most recent among ties), if any.
1069	///
1070	/// This pops the stack regardless of modality; for dismissing the layer
1071	/// that emitted a [`UiEvent::Cancel`], use [`Ui::close_active_overlay`] —
1072	/// the stack top may be a non-modal pane sitting above the modal that
1073	/// routed the key.
1074	pub fn close_top_overlay(&mut self) -> Option<OverlayId> {
1075		let entry = self.overlays.pop()?;
1076		if self.key_overlay == Some(entry.id) {
1077			self.key_overlay = None;
1078		}
1079		self.overlays_dirty = true;
1080		Some(entry.id)
1081	}
1082
1083	/// Closes the layer currently receiving keys — the topmost visible
1084	/// modal overlay, else the focused non-modal pane — returning its id.
1085	///
1086	/// The manual-host counterpart of the [`crate::App`] cancel policy:
1087	/// after a [`UiEvent::Cancel`] surfaces from the overlay stack, this
1088	/// dismisses the layer that emitted it, even when a higher-z non-modal
1089	/// pane stacks above it.
1090	pub fn close_active_overlay(&mut self) -> Option<OverlayId> {
1091		let id = self.top_overlay()?;
1092		self.close_overlay(id);
1093		Some(id)
1094	}
1095
1096	/// Temporarily hides or reshows an overlay without discarding its state.
1097	///
1098	/// Hiding the layer holding the keyboard returns keys to the base tree.
1099	/// Returns `false` for an unknown id.
1100	pub fn set_overlay_hidden(&mut self, id: OverlayId, hidden: bool) -> bool {
1101		let Some(entry) = self.overlays.iter_mut().find(|entry| entry.id == id) else {
1102			return false;
1103		};
1104		if entry.hidden != hidden {
1105			entry.hidden = hidden;
1106			self.overlays_dirty = true;
1107		}
1108		if hidden && self.key_overlay == Some(id) {
1109			self.blur_overlay();
1110		}
1111		true
1112	}
1113
1114	/// Whether an overlay is temporarily hidden; `None` for an unknown id.
1115	pub fn overlay_hidden(&self, id: OverlayId) -> Option<bool> {
1116		self
1117			.overlays
1118			.iter()
1119			.find(|entry| entry.id == id)
1120			.map(|entry| entry.hidden)
1121	}
1122
1123	/// Borrows an overlay's retained tree.
1124	pub fn overlay(&self, id: OverlayId) -> Option<&Self> {
1125		self
1126			.overlays
1127			.iter()
1128			.find(|entry| entry.id == id)
1129			.map(|entry| &*entry.ui)
1130	}
1131
1132	/// Mutably borrows an overlay's retained tree for `set_text` and friends.
1133	pub fn overlay_mut(&mut self, id: OverlayId) -> Option<&mut Self> {
1134		self
1135			.overlays
1136			.iter_mut()
1137			.find(|entry| entry.id == id)
1138			.map(|entry| &mut *entry.ui)
1139	}
1140
1141	/// Whether any modal overlay is currently visible (not hidden or gated).
1142	///
1143	/// While one is, [`crate::App`] holds the terminal's alternate screen
1144	/// (vim/less idiom): the whole composited viewport paints there with
1145	/// mouse tracking active, and the untouched main screen restores when
1146	/// the last visible modal overlay closes. Non-modal layers never hold:
1147	/// they composite into the live inline viewport while the document
1148	/// keeps committing to native scrollback beneath them.
1149	pub fn has_overlay(&self) -> bool {
1150		self
1151			.overlays
1152			.iter()
1153			.any(|entry| entry.options.modal && entry.visible(self.viewport))
1154	}
1155
1156	/// Identity of the layer receiving keys — the topmost visible modal
1157	/// overlay, else the focused non-modal layer.
1158	pub fn top_overlay(&self) -> Option<OverlayId> {
1159		self.key_target().map(|index| self.overlays[index].id)
1160	}
1161
1162	/// Directs keys and paste to a layer until it is blurred, closed, or
1163	/// hidden, or a modal overlay opens above it.
1164	///
1165	/// The layer's focus ring activates so its chrome shows where typing
1166	/// lands. Intended for non-modal layers — a modal overlay already
1167	/// captures the keyboard while topmost. Returns `false` for an unknown
1168	/// id.
1169	pub fn focus_overlay(&mut self, id: OverlayId) -> bool {
1170		let Some(entry) = self.overlays.iter_mut().find(|entry| entry.id == id) else {
1171			return false;
1172		};
1173		entry.ui.focus_first();
1174		self.key_overlay = Some(id);
1175		true
1176	}
1177
1178	/// Returns the keyboard to the base tree, clearing the previously
1179	/// focused layer's own focus so no stale chrome (or hardware caret)
1180	/// suggests typing still lands there. Returns the layer that had key
1181	/// focus.
1182	pub fn blur_overlay(&mut self) -> Option<OverlayId> {
1183		let id = self.key_overlay.take()?;
1184		if let Some(entry) = self.overlays.iter_mut().find(|entry| entry.id == id) {
1185			entry.ui.blur();
1186		}
1187		Some(id)
1188	}
1189
1190	/// The non-modal layer holding the keyboard through
1191	/// [`Ui::focus_overlay`] or a click, if any.
1192	pub const fn focused_overlay(&self) -> Option<OverlayId> {
1193		self.key_overlay
1194	}
1195
1196	/// The layer receiving keys: the topmost visible modal overlay wins,
1197	/// else the explicitly focused layer while visible.
1198	fn key_target(&self) -> Option<usize> {
1199		self
1200			.overlays
1201			.iter()
1202			.rposition(|entry| entry.options.modal && entry.visible(self.viewport))
1203			.or_else(|| {
1204				let id = self.key_overlay?;
1205				self
1206					.overlays
1207					.iter()
1208					.position(|entry| entry.id == id && entry.visible(self.viewport))
1209			})
1210	}
1211
1212	fn overlay_contains(&self, index: usize, x: u16, y: u16) -> bool {
1213		let entry = &self.overlays[index];
1214		let Some(viewport_y) = y.checked_sub(self.window_top) else {
1215			return false;
1216		};
1217		entry.band.rows > 0
1218			&& x >= entry.band.x
1219			&& x < entry.band.x.saturating_add(entry.ui.width)
1220			&& viewport_y >= entry.band.y
1221			&& viewport_y < entry.band.y.saturating_add(entry.band.rows)
1222	}
1223
1224	/// Maps document coordinates into an overlay's local cell space.
1225	fn overlay_local(&self, index: usize, x: u16, y: u16) -> (u16, u16) {
1226		let band = self.overlays[index].band;
1227		let viewport_y = y.saturating_sub(self.window_top);
1228		(
1229			x.saturating_sub(band.x),
1230			viewport_y
1231				.saturating_sub(band.y)
1232				.saturating_add(band.src_top),
1233		)
1234	}
1235
1236	/// Routes a mouse gesture to the overlay stack; `None` falls through to
1237	/// the base tree (the pointer is outside every visible layer).
1238	fn route_overlay_mouse(&mut self, x: u16, y: u16, mouse: Mouse) -> Option<UiEvent> {
1239		if self.overlays.is_empty() {
1240			return None;
1241		}
1242		if matches!(mouse, Mouse::Drag | Mouse::Release)
1243			&& let Some(index) = self
1244				.overlays
1245				.iter()
1246				.position(|entry| entry.ui.drag.is_some())
1247		{
1248			// A drag that started inside an overlay stays captured by it.
1249			let (local_x, local_y) = self.overlay_local(index, x, y);
1250			return Some(
1251				self.overlays[index]
1252					.ui
1253					.handle_mouse(local_x, local_y, mouse),
1254			);
1255		}
1256		let target = (0..self.overlays.len())
1257			.rev()
1258			.filter(|&index| self.overlays[index].visible(self.viewport))
1259			.find(|&index| self.overlay_contains(index, x, y));
1260		if matches!(mouse, Mouse::Move) {
1261			// The pointer rests on one layer at most; stale highlights clear.
1262			if target.is_some() {
1263				self.clear_hover();
1264			}
1265			for index in 0..self.overlays.len() {
1266				if Some(index) != target {
1267					self.overlays[index].ui.clear_hover();
1268				}
1269			}
1270		}
1271		if matches!(mouse, Mouse::Click | Mouse::RightClick | Mouse::MiddleClick) {
1272			// Clicks move the keyboard between panes: into a non-modal
1273			// layer, back to the base tree when landing outside every layer.
1274			match target {
1275				Some(index) if !self.overlays[index].options.modal => {
1276					self.key_overlay = Some(self.overlays[index].id);
1277				},
1278				None => {
1279					self.blur_overlay();
1280				},
1281				Some(_) => {},
1282			}
1283		}
1284		let index = target?;
1285		let (local_x, local_y) = self.overlay_local(index, x, y);
1286		Some(
1287			self.overlays[index]
1288				.ui
1289				.handle_mouse(local_x, local_y, mouse),
1290		)
1291	}
1292
1293	/// Current focus slot, exposed for the in-crate acceptance suite.
1294	#[cfg(test)]
1295	pub(crate) const fn focus_slot(&self) -> Option<Slot> {
1296		self.focus
1297	}
1298
1299	/// Assigns focus with the same enter and repaint bookkeeping as ring
1300	/// navigation.
1301	#[cfg(test)]
1302	pub(crate) fn set_focus_slot(&mut self, focus: Option<Slot>) {
1303		self.clear_hover();
1304		self.assign_focus(focus, true);
1305	}
1306
1307	/// Current hover slot, exposed for the in-crate acceptance suite.
1308	#[cfg(test)]
1309	pub(crate) fn hover_slot(&self) -> Option<Slot> {
1310		self.hover.map(|(slot, _)| slot)
1311	}
1312
1313	/// Paint-collected hit regions, exposed for the in-crate acceptance suite.
1314	#[cfg(test)]
1315	pub(crate) fn hits(&self) -> &[Hit] {
1316		&self.hits
1317	}
1318
1319	/// Computed visible focus ring.
1320	pub(crate) fn focus_ring(&self) -> Vec<Slot> {
1321		let mut ring = Vec::new();
1322		if self.root.visible {
1323			self.root.comp().ring(&mut ring);
1324		}
1325		ring
1326	}
1327
1328	/// Root component cache, exposed for the in-crate acceptance suite.
1329	#[cfg(test)]
1330	pub(crate) const fn root(&self) -> &Cached {
1331		&self.root
1332	}
1333
1334	/// Mutable root component cache, exposed for the in-crate acceptance suite.
1335	#[cfg(test)]
1336	pub(crate) const fn root_mut(&mut self) -> &mut Cached {
1337		&mut self.root
1338	}
1339
1340	fn key_component(&mut self, slot: Slot, key: Key) -> Option<(Flow, bool, (u16, u16), Rect)> {
1341		let ctx = &self.ctx;
1342		self.root.update(slot, |cached| {
1343			let old_measure = cached.measure(ctx);
1344			let old_rect = cached.rect;
1345			let (width, view_rows) = event_size(cached);
1346			let mut ec = EventCtx::new(ctx, width, view_rows);
1347			let flow = cached.comp_mut().key(&mut ec, key);
1348			let dirty = matches!(flow, Flow::Consumed);
1349			((flow, ec.layout, old_measure, old_rect), dirty)
1350		})
1351	}
1352
1353	fn drag_hit(&self) -> Option<Hit> {
1354		let target = self.drag?;
1355		self
1356			.hits
1357			.iter()
1358			.rev()
1359			.find(|hit| hit.slot == target.slot && hit.tag == target.tag)
1360			.copied()
1361			.or(Some(target))
1362	}
1363
1364	fn update_hover(&mut self, x: u16, y: u16) {
1365		let target = self.hit_at(x, y, false).map(|hit| (hit.slot, hit.tag));
1366		let previous = self.hover;
1367		if previous == target {
1368			return;
1369		}
1370		self.hover = target;
1371		let left = previous.map(|(slot, _)| self.hover_scope(slot));
1372		let entered = target.map(|(slot, _)| self.hover_scope(slot));
1373		if let Some(slot) = left {
1374			self.repaint_slot(slot);
1375		}
1376		if let Some(slot) = entered.filter(|slot| left != Some(*slot)) {
1377			self.repaint_slot(slot);
1378		}
1379	}
1380
1381	/// Repaints the component that visually owns a hover change: the
1382	/// outermost hover-decorated ancestor when one exists (its chrome and
1383	/// elevation react to descendants), else the component itself.
1384	fn hover_repaint(&mut self, slot: Slot) {
1385		let scope = self.hover_scope(slot);
1386		self.repaint_slot(scope);
1387	}
1388
1389	fn hover_scope(&self, slot: Slot) -> Slot {
1390		path_to_slot(&self.root, slot).map_or(slot, |path| {
1391			path
1392				.iter()
1393				.find(|entry| {
1394					find_slot_ref(&self.root, entry.slot)
1395						.is_some_and(|cached| cached.comp().props().hover_decorated())
1396				})
1397				.map_or(slot, |entry| entry.slot)
1398		})
1399	}
1400
1401	fn mouse_component(&mut self, hit: Hit, at: (u16, u16), mouse: Mouse) -> (UiEvent, bool) {
1402		let ctx = &self.ctx;
1403		let Some((flow, layout, old_measure, old_rect)) = self.root.update(hit.slot, |cached| {
1404			let old_measure = cached.measure(ctx);
1405			let old_rect = cached.rect;
1406			let (width, view_rows) = event_size(cached);
1407			let mut ec = EventCtx::new(ctx, width, view_rows);
1408			let flow = cached
1409				.comp_mut()
1410				.mouse(&mut ec, hit.tag, at, hit.rect, mouse);
1411			let dirty = matches!(flow, Flow::Consumed);
1412			((flow, ec.layout, old_measure, old_rect), dirty)
1413		}) else {
1414			return (UiEvent::None, false);
1415		};
1416		match flow {
1417			Flow::Skip => (UiEvent::None, false),
1418			Flow::Event(event) => {
1419				// Mirror the key path: the emitter repaints its state
1420				// change before the event surfaces.
1421				self.refresh_routed(hit.slot, layout, old_measure, old_rect);
1422				(event, true)
1423			},
1424			Flow::Consumed => {
1425				self.refresh_routed(hit.slot, layout, old_measure, old_rect);
1426				(UiEvent::None, true)
1427			},
1428		}
1429	}
1430
1431	fn move_focus(&mut self, forward: bool) {
1432		let ring = self.focus_ring();
1433		if ring.is_empty() {
1434			self.assign_focus(None, forward);
1435			return;
1436		}
1437		let next = match self
1438			.focus
1439			.and_then(|slot| ring.iter().position(|item| *item == slot))
1440		{
1441			Some(index) if forward => ring[(index + 1) % ring.len()],
1442			Some(index) => ring[(index + ring.len() - 1) % ring.len()],
1443			None if forward => ring[0],
1444			None => ring[ring.len() - 1],
1445		};
1446		self.assign_focus(Some(next), forward);
1447	}
1448
1449	/// Moves focus to the nearest focusable strictly below (or above) the
1450	/// current one — the row-aware complement to ring order that makes
1451	/// Up/Down walk wrapped grids column-wise. Candidates are compared in
1452	/// their paint owner's coordinate space, so only siblings placed by the
1453	/// same scroll (or the root) are spatially comparable; without a
1454	/// vertical neighbor the move falls back to ring order, preserving
1455	/// plain stacked navigation and cross-owner hops.
1456	fn move_focus_vertical(&mut self, down: bool) {
1457		let Some((focus, (anchor_owner, anchor))) = self
1458			.focus
1459			.and_then(|slot| Some((slot, self.spatial_anchor(slot)?)))
1460		else {
1461			self.move_focus(down);
1462			return;
1463		};
1464		// Center coordinates ×2 keep the comparison in exact integers.
1465		let center = |rect: Rect| {
1466			(
1467				i32::from(rect.x) * 2 + i32::from(rect.width),
1468				i32::from(rect.y) * 2 + i32::from(rect.height),
1469			)
1470		};
1471		let (anchor_x, anchor_y) = center(anchor);
1472		let mut best: Option<(Slot, u32, u32)> = None;
1473		for slot in self.focus_ring() {
1474			if slot == focus {
1475				continue;
1476			}
1477			let Some((owner, rect)) = self.spatial_anchor(slot) else {
1478				continue;
1479			};
1480			if owner != anchor_owner {
1481				continue;
1482			}
1483			let (x, y) = center(rect);
1484			let dy = y - anchor_y;
1485			if if down { dy <= 0 } else { dy >= 0 } {
1486				continue;
1487			}
1488			let key = (dy.unsigned_abs(), (x - anchor_x).unsigned_abs());
1489			if best.is_none_or(|(_, dy, dx)| key < (dy, dx)) {
1490				best = Some((slot, key.0, key.1));
1491			}
1492		}
1493		match best {
1494			Some((slot, ..)) => self.assign_focus(Some(slot), down),
1495			None => self.move_focus(down),
1496		}
1497	}
1498
1499	/// A slot's placed rectangle plus the paint owner whose coordinate
1500	/// space it lives in; rectangles are only comparable within one owner.
1501	fn spatial_anchor(&self, slot: Slot) -> Option<(Option<Slot>, Rect)> {
1502		let path = path_to_slot(&self.root, slot)?;
1503		let (target, ancestors) = path.split_last()?;
1504		let owner = ancestors
1505			.iter()
1506			.rev()
1507			.find(|entry| entry.paint_owner)
1508			.map(|entry| entry.slot);
1509		Some((owner, target.rect))
1510	}
1511
1512	fn assign_focus(&mut self, next: Option<Slot>, forward: bool) {
1513		if self.focus == next {
1514			return;
1515		}
1516		let previous = self.focus;
1517		self.focus = next;
1518		if let Some(slot) = next {
1519			let _ = self.root.update(slot, |cached| {
1520				cached.comp_mut().enter(forward);
1521				((), false)
1522			});
1523		}
1524		if let Some(slot) = next {
1525			self.chase_scrolls(slot);
1526		}
1527		if let Some(slot) = previous {
1528			self.repaint_slot(slot);
1529		}
1530		if let Some(slot) = next {
1531			self.repaint_slot(slot);
1532		}
1533	}
1534
1535	fn chase_scrolls(&mut self, slot: Slot) {
1536		let Some(path) = path_to_slot(&self.root, slot) else {
1537			return;
1538		};
1539		for (index, entry) in path.iter().enumerate() {
1540			if !entry.paint_owner
1541				|| find_slot_ref(&self.root, entry.slot)
1542					.is_none_or(|cached| !cached.comp().is::<Scroll>())
1543			{
1544				continue;
1545			}
1546			// Chase the deepest path entry still placed in this scroll's
1547			// coordinate space: descendants of a nested paint owner live in
1548			// that owner's own scratch frame, but the owner itself is ours.
1549			let scope = &path[index + 1..];
1550			let end = scope
1551				.iter()
1552				.position(|entry| entry.paint_owner)
1553				.map_or(scope.len(), |position| position + 1);
1554			let Some(descendant) = scope[..end].last().map(|entry| entry.rect) else {
1555				continue;
1556			};
1557			let scroll_slot = entry.slot;
1558			let _ = self.root.update(scroll_slot, |cached| {
1559				let view_rows = event_size(cached).1;
1560				let changed = cached
1561					.comp_mut()
1562					.downcast_mut::<Scroll>()
1563					.expect("scroll path entry changed type")
1564					.chase(descendant, view_rows);
1565				(changed, changed)
1566			});
1567		}
1568	}
1569
1570	fn clear_hover(&mut self) {
1571		if let Some((slot, _)) = self.hover.take() {
1572			self.hover_repaint(slot);
1573		}
1574	}
1575
1576	fn hit_at(&self, x: u16, y: u16, wheel_zone: bool) -> Option<Hit> {
1577		self
1578			.hits
1579			.iter()
1580			.rev()
1581			.find(|hit| {
1582				(hit.tag == HitTag::Wheel) == wheel_zone
1583					&& x >= hit.rect.x
1584					&& x < hit.rect.x.saturating_add(hit.rect.width)
1585					&& y >= hit.rect.y
1586					&& y < hit.rect.y.saturating_add(hit.rect.height)
1587			})
1588			.copied()
1589	}
1590
1591	fn snapshot_id(&mut self, id: &str) -> Option<(Slot, (u16, u16), Rect, bool)> {
1592		let path = path_to_id(&self.root, id)?;
1593		let slot = path.last()?.slot;
1594		let presented = path.iter().all(|entry| entry.visible);
1595		let cached = self.root.find_slot(slot)?;
1596		let measure = cached.measure(&self.ctx);
1597		Some((slot, measure, cached.rect, presented))
1598	}
1599
1600	/// Handles a due layout wake: an animated size moved, so the smallest
1601	/// safe region must re-measure and re-place, not just repaint.
1602	fn relayout_slot(&mut self, slot: Slot) {
1603		let Some(path) = path_to_slot(&self.root, slot) else {
1604			return;
1605		};
1606		if path.iter().any(|entry| !entry.visible) {
1607			return;
1608		}
1609		let Some(cached) = self.root.find_slot(slot) else {
1610			return;
1611		};
1612		let old_measure = cached.measure(&self.ctx);
1613		let old_rect = cached.rect;
1614		// Geometry memos along the ancestor path cached the previous sample.
1615		let _ = self.root.update(slot, |_| ((), true));
1616
1617		// An animated width moves siblings without moving this component's
1618		// measure, so the nearest row must re-solve unconditionally — row
1619		// layout consumes `w` directly.
1620		if let Some(row) = path.iter().rev().skip(1).find(|entry| entry.row).copied() {
1621			if let Some(row_cached) = self.root.find_slot(row.slot) {
1622				let height = row_cached.height(&self.ctx, row.rect.width);
1623				if height == row.rect.height {
1624					row_cached
1625						.place(&self.ctx, Rect::new(row.rect.x, row.rect.y, row.rect.width, height));
1626					self.repaint_slot(row.slot);
1627					return;
1628				}
1629			}
1630			self.relayout_above(&path, row.slot);
1631			return;
1632		}
1633		self.refresh_slot(slot, old_measure, old_rect);
1634	}
1635
1636	fn refresh_slot(&mut self, slot: Slot, old_measure: (u16, u16), old_rect: Rect) {
1637		let Some(path) = path_to_slot(&self.root, slot) else {
1638			return;
1639		};
1640		if path.iter().any(|entry| !entry.visible) {
1641			return;
1642		}
1643		let Some(cached) = self.root.find_slot(slot) else {
1644			return;
1645		};
1646		let new_measure = cached.measure(&self.ctx);
1647		let new_height = cached.height(&self.ctx, old_rect.width);
1648		let x_dirty = new_measure != old_measure;
1649
1650		if x_dirty && let Some(row) = path.iter().rev().skip(1).find(|entry| entry.row).copied() {
1651			let Some(cached) = self.root.find_slot(row.slot) else {
1652				return;
1653			};
1654			let height = cached.height(&self.ctx, row.rect.width);
1655			if height == row.rect.height {
1656				cached.place(&self.ctx, Rect::new(row.rect.x, row.rect.y, row.rect.width, height));
1657				self.repaint_slot(row.slot);
1658			} else {
1659				self.relayout_above(&path, row.slot);
1660			}
1661			return;
1662		}
1663
1664		if new_height == old_rect.height {
1665			let Some(cached) = self.root.find_slot(slot) else {
1666				return;
1667			};
1668			cached.place(&self.ctx, Rect::new(old_rect.x, old_rect.y, old_rect.width, new_height));
1669			self.repaint_slot(slot);
1670		} else {
1671			self.relayout_above(&path, slot);
1672		}
1673	}
1674
1675	fn relayout_above(&mut self, path: &[PathEntry], changed: Slot) {
1676		let changed_at = path
1677			.iter()
1678			.position(|entry| entry.slot == changed)
1679			.unwrap_or(path.len());
1680		let boundary = path[..changed_at]
1681			.iter()
1682			.rev()
1683			.find(|entry| entry.fixed)
1684			.copied();
1685		match boundary {
1686			Some(boundary) => self.relayout_fixed(boundary),
1687			None => self.layout_all(),
1688		}
1689	}
1690
1691	fn relayout_fixed(&mut self, boundary: PathEntry) {
1692		let height = {
1693			let Some(cached) = self.root.find_slot(boundary.slot) else {
1694				return;
1695			};
1696			cached.height(&self.ctx, boundary.rect.width)
1697		};
1698		if height != boundary.rect.height {
1699			self.layout_all();
1700			return;
1701		}
1702		if let Some(cached) = self.root.find_slot(boundary.slot) {
1703			cached.place(&self.ctx, boundary.rect);
1704		}
1705		self.repaint_slot(boundary.slot);
1706	}
1707
1708	pub(crate) fn repaint_slot(&mut self, slot: Slot) {
1709		let Some(original_path) = path_to_slot(&self.root, slot) else {
1710			return;
1711		};
1712		let slot = original_path
1713			.iter()
1714			.find(|entry| {
1715				entry.paint_owner
1716					|| find_slot_ref(&self.root, entry.slot).is_some_and(|cached| {
1717						let props = cached.comp().props();
1718						props.gradient_of(Prop::Fg).is_some()
1719							|| props.gradient_of(Prop::Bg).is_some()
1720							|| props.gradient_of(Prop::On).is_some()
1721					})
1722			})
1723			.map_or(slot, |entry| entry.slot);
1724		let path = if original_path.last().is_some_and(|entry| entry.slot == slot) {
1725			original_path
1726		} else {
1727			path_to_slot(&self.root, slot).expect("scroll owner is on the component path")
1728		};
1729		if path.iter().any(|entry| !entry.visible) {
1730			return;
1731		}
1732		if let Some(cached) = find_slot_ref(&self.root, slot) {
1733			self.hits.retain(|hit| !cached.contains_slot(hit.slot));
1734			// The subtree's wake requests are stale the moment it repaints;
1735			// the paint below re-requests whatever is still animating.
1736			self.wakes.retain(|wake| !cached.contains_slot(wake.slot));
1737		}
1738		let parent_background = path
1739			.iter()
1740			.rev()
1741			.skip(1)
1742			.filter_map(|entry| find_slot_ref(&self.root, entry.slot))
1743			.map(|cached| {
1744				cached
1745					.comp()
1746					.props()
1747					.style(&self.ctx.theme)
1748					.background_color()
1749			})
1750			.find(|color| *color != Color::Default);
1751
1752		let focus = self.focus;
1753		let hover = self.hover;
1754		let pointer = self.pointer;
1755		let keyboard = self.keyboard;
1756		let now = self.now;
1757		let ctx = &self.ctx;
1758		let Some(cached) = self.root.find_slot(slot) else {
1759			return;
1760		};
1761		let rect = cached.rect;
1762		let style = cached.fill_style(ctx, self.now);
1763		self.frame.fill(rect, style);
1764		{
1765			let mut pc = PaintCtx::new(&mut self.frame, ctx, &mut self.hits, &mut self.wakes);
1766			pc.clip = rect.y.saturating_add(rect.height);
1767			pc.focus = focus;
1768			pc.hover = hover;
1769			pc.pointer = pointer;
1770			pc.keyboard = keyboard;
1771			pc.now = now;
1772			cached.paint(&mut pc);
1773		}
1774		if let Some(background) = parent_background {
1775			self.frame.underlay(rect, background);
1776		}
1777		self.mark_damage(rect.y, rect.height);
1778	}
1779
1780	fn layout_all(&mut self) {
1781		let _ = self.root.measure(&self.ctx);
1782		let height = self.root.height(&self.ctx, self.width);
1783		self
1784			.root
1785			.place(&self.ctx, Rect::new(0, 0, self.width, height));
1786		self.high_water = self.high_water.max(height);
1787		let target = Size::new(self.width, self.high_water);
1788		if self.frame.size() == target {
1789			self.frame.clear(Style::default());
1790		} else {
1791			self.frame = Frame::new(target);
1792		}
1793		self.hits.clear();
1794		self.wakes.clear();
1795		let mut pc = PaintCtx::new(&mut self.frame, &self.ctx, &mut self.hits, &mut self.wakes);
1796		pc.clip = height;
1797		pc.focus = self.focus;
1798		pc.hover = self.hover;
1799		pc.pointer = self.pointer;
1800		pc.keyboard = self.keyboard;
1801		pc.now = self.now;
1802		if self.root.visible {
1803			self.root.paint(&mut pc);
1804		}
1805		self.mark_damage(0, self.high_water);
1806	}
1807
1808	fn mark_damage(&mut self, y: u16, height: u16) {
1809		if height == 0 {
1810			return;
1811		}
1812		if self.damage.len() >= 32 {
1813			self.damage.clear();
1814			self.damage.push((0, self.frame.size().height));
1815			return;
1816		}
1817		self.damage.push((y, y.saturating_add(height)));
1818	}
1819
1820	fn compile_conds(&mut self) {
1821		self.conds.clear();
1822		compile_cached_conds(&self.root, &mut self.conds);
1823	}
1824
1825	fn apply_conds(&mut self, relayout: bool) {
1826		if self.conds.is_empty() {
1827			return;
1828		}
1829		let Value::Object(values) = self.values() else {
1830			return;
1831		};
1832		let decisions: SmallVec<(Slot, bool), 8> = self
1833			.conds
1834			.iter()
1835			.map(|cond| {
1836				let visible = find_named_value(&values, &cond.source_id)
1837					.is_none_or(|value| predicate_matches(&cond.predicate, value));
1838				(cond.target, visible)
1839			})
1840			.collect();
1841		let mut flipped: SmallVec<Slot, 4> = SmallVec::new();
1842		for (target, visible) in decisions {
1843			if self
1844				.root
1845				.update(target, |cached| {
1846					let changed = cached.visible != visible;
1847					cached.visible = visible;
1848					(changed, changed)
1849				})
1850				.unwrap_or(false)
1851			{
1852				flipped.push(target);
1853			}
1854		}
1855		if !flipped.is_empty() && relayout {
1856			self.normalize_focus();
1857			self.relayout_visibility(&flipped);
1858		}
1859	}
1860
1861	fn relayout_visibility(&mut self, flipped: &[Slot]) {
1862		let mut boundaries: SmallVec<Slot, 4> = SmallVec::new();
1863		for &slot in flipped {
1864			let Some(path) = path_to_slot(&self.root, slot) else {
1865				continue;
1866			};
1867			let Some(boundary) = path[..path.len().saturating_sub(1)]
1868				.iter()
1869				.rev()
1870				.find(|entry| entry.fixed)
1871			else {
1872				self.layout_all();
1873				return;
1874			};
1875			if !boundaries.contains(&boundary.slot) {
1876				boundaries.push(boundary.slot);
1877			}
1878		}
1879		for boundary in boundaries {
1880			if let Some(entry) =
1881				path_to_slot(&self.root, boundary).and_then(|path| path.last().copied())
1882			{
1883				self.relayout_fixed(entry);
1884			}
1885		}
1886	}
1887
1888	fn normalize_focus(&mut self) {
1889		let ring = self.focus_ring();
1890		if self.focus.is_some_and(|slot| !ring.contains(&slot)) {
1891			self.focus = ring.first().copied();
1892		}
1893		if self.hover.is_some_and(|(slot, _)| {
1894			path_to_slot(&self.root, slot).is_none_or(|path| path.iter().any(|entry| !entry.visible))
1895		}) {
1896			self.hover = None;
1897		}
1898	}
1899}
1900
1901/// Walks `comp`'s tail children bottom-up into `frame` above `start_bottom`,
1902/// recursing into nested providers; returns the topmost painted row.
1903///
1904/// Providers ([`crate::Component::resize_tail`]) are transparent — their
1905/// own height is never computed, so a drag frame's work stays bounded by
1906/// the viewport even when markup wraps the whole transcript in one bare
1907/// column. Every non-provider child renders whole with full chrome
1908/// fidelity, and a leaf taller than the space left is sliced to its bottom
1909/// rows through a scratch bounded by that one leaf's height. `None` when
1910/// `comp` is not a provider.
1911#[expect(clippy::too_many_arguments, reason = "one recursive paint cursor, not an API")]
1912fn compose_tail(
1913	frame: &mut Frame,
1914	ctx: &UiContext,
1915	now: std::time::Duration,
1916	focus: Option<Slot>,
1917	hits: &mut Vec<Hit>,
1918	wakes: &mut Vec<Wake>,
1919	comp: &mut dyn crate::component::Component,
1920	x: u16,
1921	width: u16,
1922	start_bottom: u16,
1923) -> Option<u16> {
1924	let tail = comp.resize_tail()?;
1925	let mut bottom = start_bottom;
1926	let mut content_top = start_bottom;
1927	for child in tail.children.iter_mut().rev().filter(|child| child.visible) {
1928		if bottom == 0 {
1929			break;
1930		}
1931		let paints_border = child.comp().paints_border();
1932		let x_inset = crate::component::horizontal_inset(child.comp().props(), paints_border);
1933		let y_inset = crate::component::vertical_inset(child.comp().props(), paints_border);
1934		let nested = compose_tail(
1935			frame,
1936			ctx,
1937			now,
1938			focus,
1939			hits,
1940			wakes,
1941			child.comp_mut(),
1942			x.saturating_add(x_inset),
1943			width.saturating_sub(x_inset.saturating_mul(2)).max(1),
1944			bottom.saturating_sub(y_inset),
1945		);
1946		if let Some(top) = nested {
1947			content_top = top;
1948			bottom = top.saturating_sub(tail.gap);
1949			continue;
1950		}
1951		let height = child.height(ctx, width);
1952		if height == 0 {
1953			continue;
1954		}
1955		if height <= bottom {
1956			bottom -= height;
1957			child.place(ctx, Rect::new(x, bottom, width, height));
1958			let mut pc = PaintCtx::new(frame, ctx, hits, wakes);
1959			pc.now = now;
1960			pc.focus = focus;
1961			child.paint(&mut pc);
1962			content_top = bottom;
1963			bottom = bottom.saturating_sub(tail.gap);
1964		} else {
1965			// Bottom slice of a leaf taller than the space left.
1966			let mut scratch = Frame::new(Size::new(x.saturating_add(width), height));
1967			child.place(ctx, Rect::new(x, 0, width, height));
1968			let mut pc = PaintCtx::new(&mut scratch, ctx, hits, wakes);
1969			pc.now = now;
1970			child.paint(&mut pc);
1971			frame.blit(&scratch, height - bottom, bottom, 0, 0);
1972			content_top = 0;
1973			bottom = 0;
1974		}
1975	}
1976	Some(content_top)
1977}
1978
1979fn event_size(cached: &Cached) -> (u16, u16) {
1980	let (pad_y, pad_x) = cached.comp().props().pad();
1981	let border = u16::from(cached.comp().props().border().is_some());
1982	let width = cached
1983		.rect
1984		.width
1985		.saturating_sub(pad_x.saturating_add(border).saturating_mul(2));
1986	let height = cached
1987		.rect
1988		.height
1989		.saturating_sub(pad_y.saturating_add(border).saturating_mul(2));
1990	(width, height)
1991}
1992
1993fn path_to_slot(root: &Cached, slot: Slot) -> Option<ComponentPath> {
1994	let mut path = SmallVec::new();
1995	if collect_path(root, &|cached| cached.comp().slot() == slot, &mut path) {
1996		Some(path)
1997	} else {
1998		None
1999	}
2000}
2001
2002fn path_to_id(root: &Cached, id: &str) -> Option<ComponentPath> {
2003	let mut path = SmallVec::new();
2004	if collect_path(
2005		root,
2006		&|cached| {
2007			cached
2008				.comp()
2009				.props()
2010				.id()
2011				.is_some_and(|candidate| candidate == id)
2012		},
2013		&mut path,
2014	) {
2015		Some(path)
2016	} else {
2017		None
2018	}
2019}
2020
2021fn collect_path(
2022	cached: &Cached,
2023	predicate: &impl Fn(&Cached) -> bool,
2024	path: &mut ComponentPath,
2025) -> bool {
2026	path.push(PathEntry {
2027		slot:        cached.comp().slot(),
2028		rect:        cached.rect,
2029		visible:     cached.visible,
2030		fixed:       cached.comp().props().h().is_some(),
2031		paint_owner: cached.comp().is::<Scroll>()
2032			|| cached.comp().is::<Tabs>()
2033			|| cached.comp().is::<Wizard>(),
2034		row:         cached.comp().is::<Row>(),
2035	});
2036	if predicate(cached) {
2037		return true;
2038	}
2039	for child in cached.comp().children() {
2040		if collect_path(child, predicate, path) {
2041			return true;
2042		}
2043	}
2044	path.pop();
2045	false
2046}
2047
2048fn find_slot_ref(cached: &Cached, slot: Slot) -> Option<&Cached> {
2049	if cached.comp().slot() == slot {
2050		return Some(cached);
2051	}
2052	cached
2053		.comp()
2054		.children()
2055		.iter()
2056		.find_map(|child| find_slot_ref(child, slot))
2057}
2058
2059fn collect_values(cached: &Cached, out: &mut serde_json::Map<String, Value>) {
2060	if !cached.visible {
2061		return;
2062	}
2063	cached.comp().value(out);
2064	for child in cached.comp().children() {
2065		collect_values(child, out);
2066	}
2067}
2068
2069/// Serializes one cached component for [`Ui::debug_tree`].
2070fn debug_node(cached: &Cached, focus: Option<Slot>) -> Value {
2071	let comp = cached.comp();
2072	let mut node = serde_json::Map::new();
2073	let kind = comp.kind();
2074	node.insert("kind".into(), Value::from(kind.rsplit("::").next().unwrap_or(kind)));
2075	if let Some(id) = comp.props().id() {
2076		node.insert("id".into(), Value::from(id.as_str()));
2077	}
2078	node.insert(
2079		"rect".into(),
2080		Value::from(vec![
2081			i64::from(cached.rect.x),
2082			i64::from(cached.rect.y),
2083			i64::from(cached.rect.width),
2084			i64::from(cached.rect.height),
2085		]),
2086	);
2087	if !cached.visible {
2088		node.insert("hidden".into(), Value::from(true));
2089	}
2090	if comp.focusable() {
2091		node.insert("focusable".into(), Value::from(true));
2092	}
2093	if focus == Some(comp.slot()) {
2094		node.insert("focused".into(), Value::from(true));
2095	}
2096	let children: Vec<Value> = comp
2097		.children()
2098		.iter()
2099		.map(|child| debug_node(child, focus))
2100		.collect();
2101	if !children.is_empty() {
2102		node.insert("children".into(), Value::from(children));
2103	}
2104	Value::Object(node)
2105}
2106
2107fn compile_cached_conds(cached: &Cached, out: &mut Vec<CompiledCond>) {
2108	if let Some(condition) = cached.comp().props().str_of(Prop::When)
2109		&& let Some((source_id, predicate)) = compile_predicate(condition)
2110	{
2111		out.push(CompiledCond { target: cached.comp().slot(), source_id, predicate });
2112	}
2113	for child in cached.comp().children() {
2114		compile_cached_conds(child, out);
2115	}
2116}
2117
2118fn compile_predicate(condition: &Str) -> Option<(Str, Predicate)> {
2119	if let Some((id, expected)) = condition.split_once("!=") {
2120		let id = id.trim();
2121		if id.is_empty() {
2122			return None;
2123		}
2124		return Some((Str::new(id), Predicate::NotEqual(Str::new(expected.trim()))));
2125	}
2126	let (id, expected) = condition.split_once('=')?;
2127	let id = id.trim();
2128	if id.is_empty() {
2129		return None;
2130	}
2131	Some((Str::new(id), Predicate::Equal(Str::new(expected.trim()))))
2132}
2133
2134fn find_named_value<'a>(values: &'a serde_json::Map<String, Value>, id: &str) -> Option<&'a Value> {
2135	if let Some(value) = values.get(id) {
2136		return Some(value);
2137	}
2138	values.values().find_map(|value| match value {
2139		Value::Object(nested) => find_named_value(nested, id),
2140		_ => None,
2141	})
2142}
2143
2144fn predicate_matches(predicate: &Predicate, value: &Value) -> bool {
2145	let expected = match predicate {
2146		Predicate::Equal(expected) | Predicate::NotEqual(expected) => expected,
2147	};
2148	let equal = match value {
2149		Value::String(value) => value == expected,
2150		Value::Bool(value) => (*value && expected == "true") || (!*value && expected == "false"),
2151		Value::Number(value) => value.to_string() == expected.as_str(),
2152		Value::Array(values) => values
2153			.iter()
2154			.any(|value| predicate_value_equal(value, expected)),
2155		Value::Null => expected == "null",
2156		Value::Object(_) => false,
2157	};
2158	match predicate {
2159		Predicate::Equal(_) => equal,
2160		Predicate::NotEqual(_) => !equal,
2161	}
2162}
2163
2164fn predicate_value_equal(value: &Value, expected: &str) -> bool {
2165	match value {
2166		Value::String(value) => value == expected,
2167		Value::Bool(value) => (*value && expected == "true") || (!*value && expected == "false"),
2168		Value::Number(value) => value.to_string() == expected,
2169		Value::Null => expected == "null",
2170		Value::Array(values) => values
2171			.iter()
2172			.any(|value| predicate_value_equal(value, expected)),
2173		Value::Object(_) => false,
2174	}
2175}
2176
2177#[cfg(test)]
2178mod tests {
2179	use super::*;
2180	use crate::{
2181		dom,
2182		frame::CellContent,
2183		props::{Prop, PropValue, Props},
2184		test_support::frame_row_text,
2185	};
2186
2187	/// A consumed key without an explicit layout request must not relayout
2188	/// the tree, even when an ancestor's placed height exceeds its
2189	/// intrinsic height because a row stretched it cross-axis.
2190	#[test]
2191	fn consumed_keys_under_stretched_ancestors_do_not_relayout() {
2192		struct PlaceProbe {
2193			props:  Props,
2194			slot:   Slot,
2195			places: std::rc::Rc<std::cell::Cell<usize>>,
2196		}
2197		impl crate::component::Component for PlaceProbe {
2198			fn props(&self) -> &Props {
2199				&self.props
2200			}
2201
2202			fn props_mut(&mut self) -> &mut Props {
2203				&mut self.props
2204			}
2205
2206			fn slot(&self) -> Slot {
2207				self.slot
2208			}
2209
2210			fn measure(&mut self, _ctx: &UiContext) -> (u16, u16) {
2211				(4, 4)
2212			}
2213
2214			fn height(&mut self, _ctx: &UiContext, _width: u16) -> u16 {
2215				8
2216			}
2217
2218			fn place(&mut self, _ctx: &UiContext, _rect: Rect) {
2219				self.places.set(self.places.get() + 1);
2220			}
2221
2222			fn paint(&mut self, _pc: &mut PaintCtx<'_>, _rect: Rect) {}
2223		}
2224
2225		let places = std::rc::Rc::new(std::cell::Cell::new(0_usize));
2226		// The column's intrinsic height (the 4-row input) is stretched to
2227		// the probe's 8 rows, so its placed rect exceeds its own height().
2228		let mut ui = Ui::from_root(
2229			crate::components::Row::new()
2230				.child(PlaceProbe {
2231					props:  Props::new(),
2232					slot:   crate::component::next_slot(),
2233					places: std::rc::Rc::clone(&places),
2234				})
2235				.child(crate::components::Col::new().child(crate::components::EditInput::new())),
2236			40,
2237			UiContext::default(),
2238		);
2239		ui.focus_first();
2240		let baseline = places.get();
2241		assert_eq!(ui.handle_key(Key::Char('x')), UiEvent::None);
2242		assert_eq!(
2243			places.get(),
2244			baseline,
2245			"a consumed key must refresh the focused leaf only, never re-place stretched siblings"
2246		);
2247		assert!(
2248			(0..ui.height()).any(|row| frame_row_text(ui.frame(), row).contains('x')),
2249			"the key still edits the input"
2250		);
2251	}
2252
2253	const ATTR_FIXTURE: &[&str] = &[
2254		"gap",
2255		"pad",
2256		"pad-x",
2257		"pad-y",
2258		"grow",
2259		"w",
2260		"min",
2261		"max",
2262		"h",
2263		"border",
2264		"bc",
2265		"edge",
2266		"bleed",
2267		"title",
2268		"title-align",
2269		"footer",
2270		"footer-align",
2271		"align",
2272		"valign",
2273		"justify",
2274		"fg",
2275		"bg",
2276		"on",
2277		"bold",
2278		"dim",
2279		"italic",
2280		"underline",
2281		"reverse",
2282		"strike",
2283		"wrap",
2284		"truncate",
2285		"trim",
2286		"id",
2287		"when",
2288		"value",
2289		"options",
2290		"label",
2291		"desc",
2292		"kind",
2293		"step",
2294		"multi",
2295		"filter",
2296		"custom",
2297		"focus",
2298		"guides",
2299		"status",
2300		"mask",
2301		"recommended",
2302		"open",
2303		"required",
2304		"match",
2305		"src",
2306		"icon",
2307		"badge",
2308		"submit",
2309		"cancel",
2310		"confirm",
2311		"placeholder",
2312		"angle",
2313		"accent",
2314		"vertical",
2315		"anim",
2316		"ease",
2317		"spin",
2318		"hover",
2319		"lift",
2320		"shimmer",
2321		"reveal",
2322	];
2323
2324	#[test]
2325	fn resize_tail_top_aligns_underflow_and_slices_overflow() {
2326		// Underflow: a short column stays top-aligned during the drag, like
2327		// normal layout — never jumping to the viewport bottom.
2328		let mut ui = Ui::from_markup(
2329			"<col><text>alpha</text><text>beta</text></col>",
2330			12,
2331			UiContext::default(),
2332		)
2333		.unwrap();
2334		let frame = ui
2335			.compose_resize_tail(Size::new(10, 6))
2336			.expect("a col root has the tail fast path");
2337		assert_eq!(frame_row_text(&frame, 0).trim_end(), "alpha");
2338		assert_eq!(frame_row_text(&frame, 1).trim_end(), "beta");
2339
2340		// Overflow: only the bottom entries compose, newest at the bottom.
2341		let mut ui = Ui::from_markup(
2342			"<col><text>one</text><text>two</text><text>three</text><text>four</text></col>",
2343			12,
2344			UiContext::default(),
2345		)
2346		.unwrap();
2347		let frame = ui
2348			.compose_resize_tail(Size::new(10, 2))
2349			.expect("a col root has the tail fast path");
2350		assert_eq!(frame_row_text(&frame, 0).trim_end(), "three");
2351		assert_eq!(frame_row_text(&frame, 1).trim_end(), "four");
2352
2353		// A single child taller than the viewport is sliced to its bottom
2354		// rows, exactly like pi's tail compose.
2355		let mut ui =
2356			Ui::from_markup("<col><text>aaaa bbbb cccc dddd</text></col>", 5, UiContext::default())
2357				.unwrap();
2358		let frame = ui
2359			.compose_resize_tail(Size::new(5, 2))
2360			.expect("a col root has the tail fast path");
2361		assert_eq!(frame_row_text(&frame, 0).trim_end(), "cccc");
2362		assert_eq!(frame_row_text(&frame, 1).trim_end(), "dddd");
2363
2364		// Markup always roots at a Col, so even a `<row>` document has the
2365		// fast path — it composes as one (sliceable) tail child.
2366		let mut ui = Ui::from_markup("<row><text>x</text></row>", 10, UiContext::default()).unwrap();
2367		assert!(ui.compose_resize_tail(Size::new(10, 4)).is_some());
2368	}
2369
2370	/// One-row leaf counting its `height`/`paint` calls, for proving the
2371	/// drag fast path's work bound.
2372	struct MeteredLeaf {
2373		props:   Props,
2374		slot:    Slot,
2375		label:   String,
2376		heights: std::rc::Rc<std::cell::Cell<usize>>,
2377		paints:  std::rc::Rc<std::cell::Cell<usize>>,
2378	}
2379
2380	impl crate::component::Component for MeteredLeaf {
2381		fn props(&self) -> &Props {
2382			&self.props
2383		}
2384
2385		fn props_mut(&mut self) -> &mut Props {
2386			&mut self.props
2387		}
2388
2389		fn slot(&self) -> Slot {
2390			self.slot
2391		}
2392
2393		fn measure(&mut self, _ctx: &UiContext) -> (u16, u16) {
2394			(1, 1)
2395		}
2396
2397		fn height(&mut self, _ctx: &UiContext, _width: u16) -> u16 {
2398			self.heights.set(self.heights.get() + 1);
2399			1
2400		}
2401
2402		fn paint(&mut self, pc: &mut crate::component::PaintCtx<'_>, rect: Rect) {
2403			self.paints.set(self.paints.get() + 1);
2404			pc.frame.put(rect.x, rect.y, &self.label, Style::default());
2405		}
2406	}
2407
2408	#[test]
2409	fn resize_tail_work_is_bounded_by_the_viewport_not_history() {
2410		// A 10k-entry transcript inside a nested bare column (mirroring the
2411		// implicit markup root wrapper): a drag frame may only measure and
2412		// paint about one viewport of entries.
2413		let heights = std::rc::Rc::new(std::cell::Cell::new(0));
2414		let paints = std::rc::Rc::new(std::cell::Cell::new(0));
2415		let mut transcript = crate::components::Col::new();
2416		for index in 0..10_000 {
2417			transcript = transcript.child(Cached::new(Box::new(MeteredLeaf {
2418				props:   Props::new(),
2419				slot:    crate::component::next_slot(),
2420				label:   format!("entry {index}"),
2421				heights: std::rc::Rc::clone(&heights),
2422				paints:  std::rc::Rc::clone(&paints),
2423			})));
2424		}
2425		let root = crate::components::Col::new().child(Cached::new(Box::new(transcript)));
2426		let mut ui = Ui::from_root(root, 24, UiContext::default());
2427		heights.set(0);
2428		paints.set(0);
2429
2430		let frame = ui
2431			.compose_resize_tail(Size::new(20, 8))
2432			.expect("a bare col chain is a tail provider");
2433		assert_eq!(frame_row_text(&frame, 7).trim_end(), "entry 9999");
2434		assert_eq!(frame_row_text(&frame, 0).trim_end(), "entry 9992");
2435		assert!(paints.get() <= 9, "an 8-row viewport painted {} of 10000 entries", paints.get());
2436		assert!(heights.get() <= 10, "an 8-row viewport measured {} of 10000 entries", heights.get());
2437	}
2438
2439	#[test]
2440	fn resize_tail_renders_styled_containers_with_full_fidelity() {
2441		// A styled column is not a provider: the drag frame renders it whole
2442		// through `Cached::paint`, byte-identical to the settled layout.
2443		let source = "<col><col border=round pad=1><text>boxed</text></col></col>";
2444		let mut ui = Ui::from_markup(source, 12, UiContext::default()).unwrap();
2445		let settled: Vec<String> = (0..ui.height())
2446			.map(|row| frame_row_text(ui.frame(), row))
2447			.collect();
2448
2449		let frame = ui
2450			.compose_resize_tail(Size::new(12, 8))
2451			.expect("the bare root col is a provider");
2452		for (row, expected) in settled.iter().enumerate() {
2453			assert_eq!(
2454				frame_row_text(&frame, row as u16).trim_end(),
2455				expected.trim_end(),
2456				"styled container drag frame diverges at row {row}"
2457			);
2458		}
2459	}
2460
2461	fn frame_text(ui: &Ui) -> Vec<String> {
2462		let size = ui.frame().size();
2463		(0..size.height)
2464			.map(|y| {
2465				let mut row = String::new();
2466				for x in 0..size.width {
2467					match &ui.frame().cell(x, y).content {
2468						CellContent::Blank => row.push(' '),
2469						CellContent::Grapheme { text, .. } => row.push_str(text),
2470						CellContent::Image { .. } | CellContent::Continuation => {},
2471					}
2472				}
2473				row.trim_end().to_string()
2474			})
2475			.collect()
2476	}
2477
2478	#[test]
2479	fn base_changes_during_alt_previews_repaint_on_clean_release() {
2480		// An alternate-screen hold previews the composited viewport while the
2481		// main buffer stays frozen. Base damage consumed by those previews
2482		// must still reach the main screen after a clean release (no resize):
2483		// the runtime re-marks everything via `damage_all` and presents.
2484		let mut ui = Ui::from_markup("<text id=msg>before</text>", 20, UiContext::default()).unwrap();
2485		let mut renderer = crate::Renderer::new(Vec::new());
2486		ui.present(&mut renderer, 4, 0)
2487			.expect("baseline main-buffer present");
2488
2489		let overlay =
2490			ui.show_overlay(dom! { <text>{"modal"}</text> }, crate::OverlayOptions::default());
2491		ui.set_text("msg", "supplanted");
2492		ui.preview(&mut renderer, 4, "\x1b[?1049h")
2493			.expect("held preview consumes the damage");
2494		assert!(!ui.has_damage(), "previews consume damage like presents");
2495
2496		ui.close_overlay(overlay);
2497		renderer.writer_mut().clear();
2498		ui.damage_all();
2499		let stats = ui
2500			.present(&mut renderer, 4, 0)
2501			.expect("release present succeeds");
2502		let output = String::from_utf8(renderer.writer_mut().clone()).expect("ANSI is UTF-8");
2503		assert!(
2504			output.contains("supplanted"),
2505			"held base change reaches the main buffer: {output:?}"
2506		);
2507		assert!(
2508			!output.contains("\x1b[3J"),
2509			"a clean release never clears native scrollback: {output:?}"
2510		);
2511		assert!(stats.changed_cells > 0);
2512	}
2513
2514	fn find_id<'a>(cached: &'a Cached, id: &str) -> Option<&'a Cached> {
2515		if cached
2516			.comp()
2517			.props()
2518			.id()
2519			.is_some_and(|candidate| candidate == id)
2520		{
2521			return Some(cached);
2522		}
2523		cached
2524			.comp()
2525			.children()
2526			.iter()
2527			.find_map(|child| find_id(child, id))
2528	}
2529
2530	fn count_cached(cached: &Cached) -> usize {
2531		1 + cached
2532			.comp()
2533			.children()
2534			.iter()
2535			.map(count_cached)
2536			.sum::<usize>()
2537	}
2538
2539	fn contains_component<T: crate::component::Component>(cached: &Cached) -> bool {
2540		cached.comp().is::<T>() || cached.comp().children().iter().any(contains_component::<T>)
2541	}
2542
2543	#[test]
2544	fn rich_lines_honor_horizontal_alignment() {
2545		fn painted_column(source: &str) -> usize {
2546			frame_text(&Ui::from_markup(source, 11, UiContext::default()).unwrap())[0]
2547				.find("hi")
2548				.expect("rich text painted")
2549		}
2550
2551		assert_eq!(painted_column("<md>hi</md>"), 0);
2552		assert_eq!(painted_column("<md align=center>hi</md>"), 4);
2553		assert_eq!(painted_column("<md align=end>hi</md>"), 9);
2554	}
2555
2556	#[test]
2557	fn parses_and_paints_box_with_text() {
2558		let ui = Ui::from_markup(
2559			r#"<box title="t"><text>hello world</text></box>"#,
2560			20,
2561			UiContext::default(),
2562		)
2563		.unwrap();
2564		let rows = frame_text(&ui);
2565		assert_eq!(rows.len(), 3);
2566		assert!(rows[0].starts_with("┌─ t "));
2567		assert!(rows[1].starts_with("│ hello world") && rows[1].ends_with('│'));
2568		assert!(rows[2].starts_with('└'));
2569	}
2570
2571	#[test]
2572	fn row_border_frames_and_insets_children() {
2573		let src = r#"<row border=round title="r" gap=1><text>ab</text><text>cd</text></row>"#;
2574		let ui = Ui::from_markup(src, 10, UiContext::default()).unwrap();
2575		let rows = frame_text(&ui);
2576		assert_eq!(rows.len(), 3, "one content row plus the frame: {rows:?}");
2577		assert!(rows[0].starts_with("╭─ r ") && rows[0].ends_with('╮'), "{rows:?}");
2578		assert!(rows[1].starts_with("│ab cd") && rows[1].ends_with('│'), "{rows:?}");
2579		assert!(rows[2].starts_with('╰') && rows[2].ends_with('╯'), "{rows:?}");
2580	}
2581
2582	#[test]
2583	fn truncate_clips_text_to_one_line_with_ellipsis() {
2584		let ui = Ui::from_markup("<text truncate>alpha beta gamma</text>", 8, UiContext::default())
2585			.unwrap();
2586		assert_eq!(frame_text(&ui), ["alpha b…"]);
2587		assert_eq!(ui.height(), 1);
2588	}
2589
2590	#[test]
2591	fn dash_border_and_hr_use_dashed_strokes() {
2592		let boxed =
2593			Ui::from_markup("<box border=dash><text>x</text></box>", 6, UiContext::default()).unwrap();
2594		assert_eq!(frame_text(&boxed), ["┌╌╌╌╌┐", "┆ x  ┆", "└╌╌╌╌┘"]);
2595		let hr = Ui::from_markup("<hr border=dash/>", 4, UiContext::default()).unwrap();
2596		assert_eq!(frame_text(&hr), ["╌╌╌╌"]);
2597	}
2598
2599	#[test]
2600	fn spacer_defaults_to_filling_row_slack() {
2601		let ui = Ui::from_markup(
2602			"<row><text>L</text><spacer/><text>R</text></row>",
2603			8,
2604			UiContext::default(),
2605		)
2606		.unwrap();
2607		assert_eq!(frame_text(&ui), ["L      R"]);
2608	}
2609
2610	#[test]
2611	fn row_justify_between_spreads_children_to_both_edges() {
2612		let ui = Ui::from_markup(
2613			"<row justify=between><text>left</text><text>right</text></row>",
2614			16,
2615			UiContext::default(),
2616		)
2617		.unwrap();
2618		assert_eq!(frame_text(&ui), ["left       right"]);
2619	}
2620
2621	#[test]
2622	fn wrapping_row_stacks_only_below_its_minimum_width() {
2623		let source = "<row wrap gap=1><text id=a>alpha</text><text>bravo</text></row>";
2624		let narrow = Ui::from_markup(source, 8, UiContext::default()).unwrap();
2625		assert_eq!(frame_text(&narrow), ["alpha", "bravo"]);
2626		assert_eq!(narrow.height(), 2);
2627
2628		let mut wide = Ui::from_markup(source, 12, UiContext::default()).unwrap();
2629		assert_eq!(frame_text(&wide), ["alpha bravo"]);
2630		assert_eq!(wide.height(), 1);
2631
2632		assert!(wide.set_text("a", "alphabet"));
2633		let fresh =
2634			Ui::from_markup(source.replace("alpha", "alphabet"), 12, UiContext::default()).unwrap();
2635		assert_eq!(frame_text(&wide), frame_text(&fresh));
2636		assert_eq!(frame_text(&wide), ["alphabet", "bravo"]);
2637	}
2638
2639	#[test]
2640	fn wrapping_row_flows_children_into_justified_lines() {
2641		let source =
2642			"<row wrap gap=1 justify=center><text>aa</text><text>bb</text><text>cc</text></row>";
2643		let ui = Ui::from_markup(source, 7, UiContext::default()).unwrap();
2644		assert_eq!(frame_text(&ui), [" aa bb", "  cc"]);
2645		assert_eq!(ui.height(), 2);
2646	}
2647
2648	#[test]
2649	fn col_border_frames_and_insets_children() {
2650		let ui = Ui::from_markup(
2651			"<col border=double><text>hi</text><text>yo</text></col>",
2652			8,
2653			UiContext::default(),
2654		)
2655		.unwrap();
2656		let rows = frame_text(&ui);
2657		assert_eq!(rows.len(), 4, "{rows:?}");
2658		assert!(rows[0].starts_with('╔') && rows[0].ends_with('╗'), "{rows:?}");
2659		assert!(rows[1].starts_with("║hi") && rows[1].ends_with('║'), "{rows:?}");
2660		assert!(rows[2].starts_with("║yo") && rows[2].ends_with('║'), "{rows:?}");
2661		assert!(rows[3].starts_with('╚') && rows[3].ends_with('╝'), "{rows:?}");
2662	}
2663
2664	#[test]
2665	fn bordered_containers_shrink_content_at_constrained_widths() {
2666		// bare col: "aa bb" (5 cells) fits a 6-cell line
2667		let bare = Ui::from_markup("<col><text>aa bb</text></col>", 6, UiContext::default()).unwrap();
2668		assert_eq!(frame_text(&bare).len(), 1);
2669		// the frame eats two columns, so measurement must force a wrap
2670		let framed =
2671			Ui::from_markup("<col border=square><text>aa bb</text></col>", 6, UiContext::default())
2672				.unwrap();
2673		let rows = frame_text(&framed);
2674		assert_eq!(rows.len(), 4, "two wrapped lines plus the frame: {rows:?}");
2675		assert!(rows[1].starts_with("│aa") && rows[1].ends_with('│'), "{rows:?}");
2676		assert!(rows[2].starts_with("│bb") && rows[2].ends_with('│'), "{rows:?}");
2677		// row: the child is placed inside the frame and clipped to it
2678		let row =
2679			Ui::from_markup("<row border=square><text>aaaa</text></row>", 6, UiContext::default())
2680				.unwrap();
2681		let rows = frame_text(&row);
2682		assert_eq!(rows.len(), 3, "{rows:?}");
2683		assert_eq!(rows[1], "│aaaa│", "child fills exactly the inner width");
2684	}
2685
2686	#[test]
2687	fn bc_colors_the_border_and_defaults_use_the_border_token() {
2688		use crate::Color;
2689		let red = Color::Rgb(0xff, 0, 0);
2690		let corner = |ui: &Ui| ui.frame().cell(0, 0).style;
2691		let colored =
2692			Ui::from_markup("<row border=round bc=red><text>x</text></row>", 8, UiContext::default())
2693				.unwrap();
2694		assert_eq!(corner(&colored).foreground_color(), red);
2695		// without bc= the frame takes the theme's border tone, not fg
2696		let plain =
2697			Ui::from_markup("<row border=round><text>x</text></row>", 8, UiContext::default())
2698				.unwrap();
2699		assert_eq!(corner(&plain).foreground_color(), crate::Theme::default().border);
2700		// fg= alone still tints the frame as a dimmed echo of the node style
2701		let inked =
2702			Ui::from_markup("<row border=round fg=red><text>x</text></row>", 8, UiContext::default())
2703				.unwrap();
2704		assert_eq!(corner(&inked).foreground_color(), red);
2705		// bc= works on <box> too
2706		let boxed =
2707			Ui::from_markup("<box bc=red><text>x</text></box>", 8, UiContext::default()).unwrap();
2708		assert_eq!(corner(&boxed).foreground_color(), red);
2709	}
2710
2711	#[test]
2712	fn content_dirty_update_keeps_layout_identical_to_rebuild() {
2713		let src = r"<col><box><text id=a>alpha beta</text></box><text id=b>steady</text></col>";
2714		let mut ui = Ui::from_markup(src, 30, UiContext::default()).unwrap();
2715		ui.set_text("a", "gamma delta");
2716		// ground truth: fresh build with the same content
2717		let fresh =
2718			Ui::from_markup(src.replace("alpha beta", "gamma delta"), 30, UiContext::default())
2719				.unwrap();
2720		assert_eq!(frame_text(&ui), frame_text(&fresh));
2721	}
2722
2723	#[test]
2724	fn size_dirty_update_matches_rebuild() {
2725		let src = r"<col><text id=a>short</text><text id=b>after</text></col>";
2726		let mut ui = Ui::from_markup(src, 12, UiContext::default()).unwrap();
2727		let long = "one two three four five six seven eight";
2728		ui.set_text("a", long);
2729		let fresh = Ui::from_markup(src.replace("short", long), 12, UiContext::default()).unwrap();
2730		assert_eq!(frame_text(&ui), frame_text(&fresh));
2731		assert!(ui.height() > 2);
2732	}
2733
2734	#[test]
2735	fn x_measure_change_resolves_row_and_matches_rebuild() {
2736		// growing `a` must re-solve the row's widths, not overwrite `b`
2737		let src = r"<row><text id=a>a</text><text id=b>bbbb</text></row>";
2738		let mut ui = Ui::from_markup(src, 20, UiContext::default()).unwrap();
2739		ui.set_text("a", "longlong");
2740		let fresh =
2741			Ui::from_markup(src.replace(">a<", ">longlong<"), 20, UiContext::default()).unwrap();
2742		assert_eq!(frame_text(&ui), frame_text(&fresh));
2743		assert!(frame_text(&ui)[0].contains("bbbb"), "sibling intact: {:?}", frame_text(&ui));
2744
2745		// shrink back: also X-dirty, must again match a rebuild
2746		ui.set_text("a", "x");
2747		let fresh = Ui::from_markup(src.replace(">a<", ">x<"), 20, UiContext::default()).unwrap();
2748		assert_eq!(frame_text(&ui), frame_text(&fresh));
2749	}
2750
2751	#[test]
2752	fn x_measure_change_that_grows_row_height_matches_rebuild() {
2753		// the row itself gets taller: escalates past the row to a full
2754		// relayout and still matches ground truth
2755		let src = r"<col><row><text id=a>a</text><text>bbbb</text></row><text>tail</text></col>";
2756		let mut ui = Ui::from_markup(src, 14, UiContext::default()).unwrap();
2757		let long = "aaaaaaa aaaaaaa aaaaaaa";
2758		ui.set_text("a", long);
2759		let fresh =
2760			Ui::from_markup(src.replace(">a<", &format!(">{long}<")), 14, UiContext::default())
2761				.unwrap();
2762		assert_eq!(frame_text(&ui), frame_text(&fresh));
2763	}
2764
2765	#[test]
2766	fn fixed_height_bounds_relayout() {
2767		let src = r"<col><box id=bx h=4><text id=a>x</text></box><text id=b>below</text></col>";
2768		let mut ui = Ui::from_markup(src, 20, UiContext::default()).unwrap();
2769		let below_before = frame_text(&ui);
2770		// grow the text inside the fixed box: document height must not move
2771		ui.set_text("a", "a much longer text that wraps to many lines now");
2772		assert_eq!(ui.height(), below_before.len() as u16);
2773		let after = frame_text(&ui);
2774		assert_eq!(below_before.last(), after.last(), "content below boundary untouched");
2775	}
2776
2777	#[test]
2778	fn row_flex_grows_and_caps() {
2779		let src = r"<row gap=1><text grow>a</text><text grow max=10 id=c>b</text></row>";
2780		let ui = Ui::from_markup(src, 40, UiContext::default()).unwrap();
2781		let c = find_id(ui.root(), "c").expect("id=c");
2782		assert_eq!(c.rect.x.saturating_add(c.rect.width), 40);
2783		assert!(c.rect.width <= 10);
2784	}
2785
2786	#[test]
2787	fn unknown_id_is_rejected() {
2788		let mut ui = Ui::from_markup("<text id=x>y</text>", 10, UiContext::default()).unwrap();
2789		assert!(!ui.set_text("nope", "z"));
2790		assert!(ui.set_text("x", "z"));
2791	}
2792
2793	#[test]
2794	fn markdown_node_renders_and_updates() {
2795		let src =
2796			"<box><md id=doc># Title\n\nbody text here\n\n| a | b |\n|---|---|\n| 1 | 2 |</md></box>";
2797		let mut ui = Ui::from_markup(src, 30, UiContext::default()).unwrap();
2798		let rows = frame_text(&ui);
2799		assert!(rows.iter().any(|r| r.contains("Title")));
2800		assert!(rows.iter().any(|r| r.contains('1') && r.contains('2')));
2801		// update reflows through the same two-tier path
2802		assert!(ui.set_text("doc", "# Other\n\nnew body"));
2803		let rows = frame_text(&ui);
2804		assert!(rows.iter().any(|r| r.contains("Other")));
2805		assert!(!rows.iter().any(|r| r.contains("Title")));
2806	}
2807
2808	#[test]
2809	fn markdown_embedded_box_preserves_document_order() {
2810		let src = concat!(
2811			"<md>before paragraph\n\n",
2812			"<box border=round title=\"T\"><text>inner</text></box>\n",
2813			"after paragraph</md>",
2814		);
2815		let rows = frame_text(&Ui::from_markup(src, 32, UiContext::default()).unwrap());
2816		let before = rows
2817			.iter()
2818			.position(|row| row.contains("before paragraph"))
2819			.unwrap();
2820		let top = rows
2821			.iter()
2822			.position(|row| row.contains('╭') && row.contains('T'))
2823			.unwrap();
2824		let inner = rows.iter().position(|row| row.contains("inner")).unwrap();
2825		let after = rows
2826			.iter()
2827			.position(|row| row.contains("after paragraph"))
2828			.unwrap();
2829		assert!(before < top && top < inner && inner < after, "document order: {rows:?}");
2830		assert!(top > before + 1 && after > inner + 1, "markdown block gaps: {rows:?}");
2831	}
2832
2833	#[test]
2834	fn markdown_embedded_latex_flows_between_paragraphs() {
2835		let src = "<md>before\n<latex>\\frac{a}{b}</latex>\nafter</md>";
2836		let rows = frame_text(&Ui::from_markup(src, 24, UiContext::default()).unwrap());
2837		let before = rows.iter().position(|row| row.contains("before")).unwrap();
2838		let numerator = rows.iter().position(|row| row.trim() == "a").unwrap();
2839		let denominator = rows.iter().position(|row| row.trim() == "b").unwrap();
2840		let after = rows.iter().position(|row| row.contains("after")).unwrap();
2841		assert!(before < numerator && numerator < denominator && denominator < after, "{rows:?}");
2842	}
2843
2844	#[test]
2845	fn plain_markdown_keeps_single_verbatim_leaf() {
2846		let source = "# Title\n\nbody  with  spacing";
2847		let ui = Ui::from_markup(format!("<md>{source}</md>"), 30, UiContext::default()).unwrap();
2848		assert_eq!(frame_text(&ui)[0], "Title");
2849		assert_eq!(contains_component::<crate::components::Markdown>(ui.root()) as usize, 1);
2850		assert!(ui.root().comp().children()[0].comp().children().is_empty());
2851	}
2852
2853	#[test]
2854	fn widget_markup_inside_markdown_is_rejected() {
2855		let Err(error) = Ui::from_markup(
2856			"<md>body\n<select id=x><option>a</option></select>\n</md>",
2857			30,
2858			UiContext::default(),
2859		) else {
2860			panic!("widget markup entered the markdown focus ring");
2861		};
2862		assert!(error.message.contains("<select>"), "{error}");
2863	}
2864
2865	#[test]
2866	fn nested_markdown_inside_embedded_box_renders() {
2867		let src = "<md>outside\n<box border=round><md>**nested body**</md></box>\ntail</md>";
2868		let rows = frame_text(&Ui::from_markup(src, 30, UiContext::default()).unwrap());
2869		let outside = rows.iter().position(|row| row.contains("outside")).unwrap();
2870		let nested = rows
2871			.iter()
2872			.position(|row| row.contains("nested body"))
2873			.unwrap();
2874		let tail = rows.iter().position(|row| row.contains("tail")).unwrap();
2875		assert!(outside < nested && nested < tail, "{rows:?}");
2876		assert!(rows.iter().any(|row| row.contains('╭')), "{rows:?}");
2877	}
2878
2879	#[test]
2880	fn markup_openers_inside_markdown_fences_stay_literal() {
2881		let src = "<md>```text\n<box border=round><text>literal</text></box>\n```\n</md>";
2882		let rows = frame_text(&Ui::from_markup(src, 48, UiContext::default()).unwrap());
2883		let all = rows.join("\n");
2884		assert!(all.contains("<box") && all.contains("literal"), "{all}");
2885		assert!(!all.contains('╭') && !all.contains('╰'), "parsed as a box: {all}");
2886	}
2887
2888	#[test]
2889	fn markup_openers_inside_indented_markdown_code_stay_literal() {
2890		let src = "<md>    <box border=round><text>literal</text></box>\n</md>";
2891		let ui = Ui::from_markup(src, 48, UiContext::default()).unwrap();
2892		assert!(
2893			!contains_component::<crate::components::Boxed>(ui.root()),
2894			"indented code built a box component"
2895		);
2896		let all = frame_text(&ui).join("\n");
2897		assert!(all.contains("<box") && all.contains("literal"), "{all}");
2898		assert!(!all.contains('╭') && !all.contains('╰'), "parsed as a box: {all}");
2899	}
2900
2901	#[test]
2902	fn markdown_node_renders_mermaid_diagram() {
2903		let ui = Ui::from_markup(
2904			"<md>```mermaid\nflowchart LR\n  A[Collect] --> B[Render]\n```</md>",
2905			40,
2906			UiContext::default(),
2907		)
2908		.unwrap();
2909		let rows = frame_text(&ui);
2910		assert!(rows.iter().any(|row| row.contains("Collect")));
2911		assert!(rows.iter().any(|row| row.contains("Render")));
2912		assert!(
2913			!rows
2914				.iter()
2915				.any(|row| row.contains("flowchart") || row.contains("```"))
2916		);
2917	}
2918
2919	#[test]
2920	fn markdown_node_paints_highlighted_code() {
2921		let ui = Ui::from_markup(
2922			"<md>```rust\npub fn main() {\n  let message = \"hi\";\n}\n```</md>",
2923			40,
2924			UiContext::default(),
2925		)
2926		.unwrap();
2927		let rows = frame_text(&ui);
2928		let (keyword_row, keyword_text) = rows
2929			.iter()
2930			.enumerate()
2931			.find(|(_, row)| row.contains("pub fn"))
2932			.expect("rendered keyword row");
2933		let keyword_column = keyword_text.find("pub").expect("keyword column") as u16;
2934		assert_eq!(
2935			ui.frame()
2936				.cell(keyword_column, keyword_row as u16)
2937				.style
2938				.foreground_color(),
2939			ui.ctx.theme.accent,
2940		);
2941
2942		let (string_row, string_text) = rows
2943			.iter()
2944			.enumerate()
2945			.find(|(_, row)| row.contains("\"hi\""))
2946			.expect("rendered string row");
2947		let string_column = string_text.find("hi").expect("string column") as u16;
2948		assert_eq!(
2949			ui.frame()
2950				.cell(string_column, string_row as u16)
2951				.style
2952				.foreground_color(),
2953			ui.ctx.theme.ok,
2954		);
2955	}
2956	/// A theme swap through [`Ui::set_context`] must reach output cached
2957	/// under the old context: markdown's render memo, and every stacked
2958	/// overlay's tree.
2959	#[test]
2960	fn set_context_restyles_cached_markdown_and_overlays() {
2961		use crate::{Appearance, OverlayOptions, Theme, dom};
2962
2963		let mut ui =
2964			Ui::from_markup("<md>```rust\npub fn main() {}\n```</md>", 40, UiContext::default())
2965				.unwrap();
2966		let overlay =
2967			ui.show_overlay(dom! { <text fg=accent>{"layer"}</text> }, OverlayOptions::default());
2968		let rows = frame_text(&ui);
2969		let (keyword_row, keyword_text) = rows
2970			.iter()
2971			.enumerate()
2972			.find(|(_, row)| row.contains("pub fn"))
2973			.expect("rendered keyword row");
2974		let (column, row) =
2975			(keyword_text.find("pub").expect("keyword column") as u16, keyword_row as u16);
2976		let dark_accent = ui.ctx.theme.accent;
2977		assert_eq!(ui.frame().cell(column, row).style.foreground_color(), dark_accent);
2978
2979		let light = UiContext {
2980			appearance: Appearance::Light,
2981			theme: Theme::for_appearance(Appearance::Light),
2982			..UiContext::default()
2983		};
2984		assert!(ui.set_context(light.clone()), "a differing context applies");
2985		let light_accent = ui.ctx.theme.accent;
2986		assert_ne!(light_accent, dark_accent);
2987		// Same text, same width: only the revision bump can discard the
2988		// markdown render memo.
2989		assert_eq!(ui.frame().cell(column, row).style.foreground_color(), light_accent);
2990		let layer = ui.overlay(overlay).expect("overlay retained");
2991		assert_eq!(layer.frame().cell(0, 0).style.foreground_color(), light_accent);
2992		assert!(ui.has_damage(), "the swap repaints");
2993		assert!(!ui.set_context(light), "an equal context is a no-op");
2994	}
2995	/// An overlay swapped directly through [`Ui::overlay_mut`] sits one
2996	/// revision ahead of its parent; a later parent swap must still discard
2997	/// the overlay's render memos instead of reusing that number.
2998	#[test]
2999	fn parent_swap_restyles_an_independently_swapped_overlay() {
3000		use crate::{Appearance, Color, Dim, OverlayOptions, Theme, dom};
3001
3002		let mut ui = Ui::from_markup("<text>base</text>", 40, UiContext::default()).unwrap();
3003		let overlay = ui.show_overlay(
3004			dom! { <md>{"```rust\npub fn main() {}\n```"}</md> },
3005			OverlayOptions::default().width(Dim::Cells(40)),
3006		);
3007		let find_keyword = |layer: &Ui| {
3008			let rows = frame_text(layer);
3009			let (row, text) = rows
3010				.iter()
3011				.enumerate()
3012				.find(|(_, row)| row.contains("pub fn"))
3013				.expect("rendered keyword row");
3014			(text.find("pub").expect("keyword column") as u16, row as u16)
3015		};
3016
3017		let light = UiContext {
3018			appearance: Appearance::Light,
3019			theme: Theme::for_appearance(Appearance::Light),
3020			..UiContext::default()
3021		};
3022		assert!(
3023			ui.overlay_mut(overlay)
3024				.expect("overlay retained")
3025				.set_context(light)
3026		);
3027		let layer = ui.overlay(overlay).expect("overlay retained");
3028		let (column, row) = find_keyword(layer);
3029		assert_eq!(
3030			layer.frame().cell(column, row).style.foreground_color(),
3031			Theme::for_appearance(Appearance::Light).accent,
3032		);
3033
3034		let custom = UiContext {
3035			theme: Theme { accent: Color::Rgb(1, 2, 3), ..Theme::default() },
3036			..UiContext::default()
3037		};
3038		assert!(ui.set_context(custom));
3039		let layer = ui.overlay(overlay).expect("overlay retained");
3040		let (column, row) = find_keyword(layer);
3041		assert_eq!(
3042			layer.frame().cell(column, row).style.foreground_color(),
3043			Color::Rgb(1, 2, 3),
3044			"the parent swap reaches the overlay's markdown memo",
3045		);
3046	}
3047	/// End-to-end mix: markup chrome around a Markdown document exercising
3048	/// inline/display LaTeX, a mermaid diagram, a bordered table, links,
3049	/// swatches, and tree guides, plus a standalone `<latex>` node.
3050	#[test]
3051	fn kitchen_sink_mixes_markup_markdown_latex_and_mermaid() {
3052		let src = concat!(
3053			"<col gap=1>",
3054			"<box border=round title=\"Report\">",
3055			"<md id=doc>",
3056			"# Pipeline ~~v1~~ **v2**\n\n",
3057			"Solved $x^2 + 1 = 0$ over $\\mathbb{C}$ — see [the docs](https://example.com/math) ",
3058			"or https://ci.example.com; accent is #C5FFD6 today.\n\n",
3059			"| Stage | ms |\n|---|---|\n| lex | 12 |\n| render | 3 |\n\n",
3060			"$$\nx = \\frac{-b \\pm \\sqrt{b^2 - 4ac}}{2a}\n$$\n\n",
3061			"```mermaid\nflowchart LR\n  A[Parse] --> B[Layout] --> C[Paint]\n```\n\n",
3062			"├── src\n│   └── markdown\n└── tests\n",
3063			"</md>",
3064			"</box>",
3065			"<callout info title=\"Advisor\" badge=\"1 note\">math path is **hot**</callout>",
3066			"<latex>\\begin{pmatrix}1 & 0 \\\\ 0 & 1\\end{pmatrix}</latex>",
3067			"<hr/>",
3068			"</col>",
3069		);
3070		let ui = Ui::from_markup(src, 64, UiContext::default()).unwrap();
3071		let rows = frame_text(&ui);
3072		let all = rows.join("\n");
3073
3074		// markup chrome: box title, callout header + badge, callout body prose
3075		assert!(all.contains("Report"), "box title: {all}");
3076		assert!(all.contains("Advisor") && all.contains("1 note"), "callout header: {all}");
3077		assert!(all.contains("math path is hot"), "callout markdown body: {all}");
3078
3079		// markdown inline: strike/bold text, math to unicode, blackboard font,
3080		// explicit link with URL parenthetical, bare autolink, swatch chip
3081		assert!(all.contains("Pipeline v1 v2"), "heading inline styles: {all}");
3082		assert!(all.contains("x² + 1 = 0"), "inline math: {all}");
3083		assert!(all.contains('ℂ'), "math font: {all}");
3084		// the parenthetical may wrap to the next row at this width
3085		assert!(
3086			all.contains("the docs") && all.contains("(https://example.com/math)"),
3087			"link: {all}"
3088		);
3089		assert!(all.contains("https://ci.example.com"), "autolink: {all}");
3090		assert!(all.contains("■ #C5FFD6"), "hex swatch chip: {all}");
3091
3092		// table: box-drawn grid with header separator cross and both rows
3093		assert!(all.contains('┼'), "table header separator: {all}");
3094		assert!(all.contains("Stage") && all.contains("render"), "table cells: {all}");
3095
3096		// display math: radical with roof, fraction over "2a", plus-minus
3097		assert!(all.contains('±'), "plus-minus: {all}");
3098		assert!(all.contains("4ac"), "radicand: {all}");
3099		assert!(all.contains("2a"), "denominator: {all}");
3100		assert!(
3101			rows
3102				.iter()
3103				.any(|row| row.contains('√') || row.contains("┌─")),
3104			"radical stem or roof: {all}"
3105		);
3106
3107		// mermaid: node labels rendered, source fence consumed
3108		for label in ["Parse", "Layout", "Paint"] {
3109			assert!(all.contains(label), "mermaid node {label}: {all}");
3110		}
3111		assert!(!all.contains("flowchart") && !all.contains("```"), "no raw fence: {all}");
3112
3113		// tree guides survive verbatim with their rails
3114		assert!(all.contains("├── src"), "tree branch: {all}");
3115		assert!(all.contains("│   └── markdown"), "tree rail spacing: {all}");
3116
3117		// standalone <latex> node: stretched matrix delimiters
3118		assert!(all.contains('⎛') && all.contains('⎝'), "pmatrix pieces: {all}");
3119
3120		// <hr/>: a full-width horizontal divider row outside the box
3121		assert!(
3122			rows
3123				.iter()
3124				.any(|row| row.chars().filter(|c| *c == '─').count() > 40 && !row.contains('│')),
3125			"hr row: {all}"
3126		);
3127	}
3128
3129	#[test]
3130	fn latex_node_lays_out_display_math() {
3131		let ui = Ui::from_markup(r"<latex>\frac{a+b}{c}</latex>", 20, UiContext::default()).unwrap();
3132		let rows = frame_text(&ui);
3133		assert!(rows.len() >= 3, "fraction needs 3 rows, got {rows:?}");
3134		assert!(rows.iter().any(|r| r.contains('─')), "fraction bar present: {rows:?}");
3135	}
3136
3137	#[test]
3138	fn latex_falls_back_to_total_inline() {
3139		let ui = Ui::from_markup(r"<latex>\unknowncmd{x}</latex>", 30, UiContext::default()).unwrap();
3140		let rows = frame_text(&ui);
3141		// graceful degradation: unknown commands keep their bare name, group
3142		// content is preserved — the source is never dropped
3143		assert!(
3144			rows
3145				.iter()
3146				.any(|row| row.contains("unknowncmd") && row.contains('x')),
3147			"unsupported source degrades to inline text: {rows:?}"
3148		);
3149	}
3150	#[test]
3151	fn dynamic_md_regrafts_embedded_markup_and_stays_bounded() {
3152		let mut ui = Ui::from_markup("<md id=doc>plain</md>", 44, UiContext::default()).unwrap();
3153		let baseline = count_cached(ui.root());
3154		assert!(ui.set_text(
3155			"doc",
3156			"before\n\n<box border=round title=\"B\"><text>hi</text></box>\n\nafter"
3157		));
3158		let rows = frame_text(&ui);
3159		assert!(rows.iter().any(|row| row.contains('╭')), "box renders: {rows:?}");
3160		assert!(rows.iter().any(|row| row.contains("hi")));
3161		assert!(rows.iter().any(|row| row.contains("before")));
3162		assert!(rows.iter().any(|row| row.contains("after")));
3163		let grown = count_cached(ui.root());
3164		assert!(grown > baseline, "graft adds cached components");
3165		// live editing recycles slots instead of growing the arena
3166		for tick in 0..20 {
3167			ui.set_text("doc", format!("tick {tick}\n\n<box><text>n{tick}</text></box>"));
3168		}
3169		assert_eq!(count_cached(ui.root()), grown, "replacement graft stays bounded");
3170		// dropping the embed releases the graft and renders plain markdown
3171		ui.set_text("doc", "plain again");
3172		let rows = frame_text(&ui);
3173		assert!(!rows.iter().any(|row| row.contains('╭')), "{rows:?}");
3174		assert!(rows.iter().any(|row| row.contains("plain again")));
3175		assert!(count_cached(ui.root()) <= grown, "dropping the embed drops its subtree");
3176		// a literal `</md>` in dynamic text degrades to plain markdown
3177		ui.set_text("doc", "<box>\nx\n</md>");
3178		assert!(count_cached(ui.root()) <= grown, "degraded text adds no subtree");
3179	}
3180	#[test]
3181	fn pretty_printed_markup_renders_like_its_one_line_form() {
3182		// indentation between tags is structure, not content: Markdown's
3183		// four-space code rule is measured from the enclosing tag's column,
3184		// so nesting past column 4 must not turn children into code
3185		let dense = "<box bg=\"black\"><row gap=\"1\"><col \
3186		             bg=\"blue\">$$\\frac{1}{2}$$</col><hr/><col grow=\"1\" bg=\"red\" \
3187		             align=\"center\" valign=\"center\">Hi!!</col></row></box>";
3188		let spaces = "<box bg=\"black\">\n  <row gap=\"1\">\n    <col \
3189		              bg=\"blue\">$$\\frac{1}{2}$$</col>\n    <hr/>\n    <col grow=\"1\" bg=\"red\" \
3190		              align=\"center\" valign=\"center\">Hi!!</col>\n  </row>\n</box>";
3191		let tabs = spaces.replace("    ", "\t\t").replace("  <row", "\t<row");
3192		let reference = frame_text(&Ui::from_markup(dense, 40, UiContext::default()).unwrap());
3193		assert_eq!(
3194			frame_text(&Ui::from_markup(spaces, 40, UiContext::default()).unwrap()),
3195			reference,
3196			"space indented"
3197		);
3198		assert_eq!(
3199			frame_text(&Ui::from_markup(tabs, 40, UiContext::default()).unwrap()),
3200			reference,
3201			"tab indented"
3202		);
3203		assert!(!reference.iter().any(|row| row.contains("```")), "no code fence: {reference:?}");
3204
3205		// prose nested past column 4 is prose, not an indented code block
3206		let prose =
3207			Ui::from_markup("<box>\n  <col>\n    hello\n  </col>\n</box>", 20, UiContext::default())
3208				.unwrap();
3209		let rows = frame_text(&prose);
3210		assert!(rows.iter().any(|row| row.contains("hello")), "{rows:?}");
3211		assert!(!rows.iter().any(|row| row.contains("```")), "prose stayed prose: {rows:?}");
3212
3213		// but a genuine indented code block, four columns past its own
3214		// container, still renders as code
3215		let code =
3216			Ui::from_markup("<box>text\n\n    <hr/>\n</box>", 20, UiContext::default()).unwrap();
3217		assert!(
3218			frame_text(&code).iter().any(|row| row.contains("<hr/>")),
3219			"indented code stays literal"
3220		);
3221	}
3222	#[test]
3223	fn markdown_context_carries_into_embedded_elements() {
3224		let src = "<md>intro\n\n<box border=round><text id=lit>**raw**</text></box>\n\n<box \
3225		           border=round>**bold** and $$\\frac{1}{2}$$</box></md>";
3226		let ui = Ui::from_markup(src, 40, UiContext::default()).unwrap();
3227		let rows = frame_text(&ui);
3228		let all = rows.join("\n");
3229		// bare body of an md-embedded element stays markdown: emphasis is
3230		// styled away and display math lays out
3231		assert!(all.contains("bold and"), "inline markdown applied: {all}");
3232		assert!(!all.contains("**bold**"), "markers consumed: {all}");
3233		assert!(rows.iter().any(|row| row.contains('─')), "fraction bar: {all}");
3234		// an explicit <text> child stays verbatim
3235		assert!(all.contains("**raw**"), "explicit text is literal: {all}");
3236	}
3237	#[test]
3238	fn implicit_text_is_markdown_anywhere() {
3239		// no <md> wrapper: a plain container body still gets every feature
3240		let src = "<box border=round>**bold** · $x^2$ · #C5FFD6</box>";
3241		let ui = Ui::from_markup(src, 44, UiContext::default()).unwrap();
3242		let all = frame_text(&ui).join("\n");
3243		assert!(all.contains("x²"), "inline math: {all}");
3244		assert!(!all.contains("**bold**"), "emphasis markers consumed: {all}");
3245		assert!(all.contains("■"), "hex swatch chip: {all}");
3246	}
3247
3248	#[test]
3249	fn markup_only_owns_its_own_tags_in_implicit_text() {
3250		// markdown autolinks and markdown-owned HTML stay text
3251		let ui = Ui::from_markup(
3252			"<col>see <https://example.com> now<br>next</col>",
3253			60,
3254			UiContext::default(),
3255		)
3256		.unwrap();
3257		let all = frame_text(&ui).join("\n");
3258		assert!(all.contains("https://example.com"), "autolink survives: {all}");
3259		assert!(all.contains("next"), "<br> is markdown, not markup: {all}");
3260		// a fenced literal markup tag is code, not an element
3261		let fenced =
3262			Ui::from_markup("<col>\n```\n<box>x</box>\n```\n</col>", 40, UiContext::default())
3263				.unwrap();
3264		let rows = frame_text(&fenced);
3265		let all = rows.join("\n");
3266		assert!(all.contains("<box>x</box>"), "fenced markup stays literal: {all}");
3267		assert!(
3268			!rows
3269				.iter()
3270				.any(|row| row.contains('╭') || row.contains('┌')),
3271			"{all}"
3272		);
3273		// every non-OMP tag is Markdown, so implicit text renders exactly
3274		// like the same source inside <md>
3275		let corpus = [
3276			"<em>x</em>",
3277			"<a href='u'>x</a>",
3278			"<bxo>x</bxo>",
3279			"<https://example.com>",
3280			"a<br>b",
3281			"<!-- c -->after",
3282			"`<hr/>`",
3283			"\\<hr/>",
3284			"$x <y> z$",
3285			"see <span>s</span> done",
3286		];
3287		for case in corpus {
3288			let implicit =
3289				Ui::from_markup(format!("<col>{case}</col>"), 44, UiContext::default()).unwrap();
3290			let explicit =
3291				Ui::from_markup(format!("<md>{case}</md>"), 44, UiContext::default()).unwrap();
3292			assert_eq!(frame_text(&implicit), frame_text(&explicit), "differs for {case:?}");
3293		}
3294	}
3295	#[test]
3296	fn row_children_stretch_and_valign_places_their_content() {
3297		use crate::Color;
3298
3299		let red = Color::Rgb(0xff, 0, 0);
3300		let bg_column = |ui: &Ui, x: u16| {
3301			(0..ui.frame().size().height)
3302				.filter(|y| ui.frame().cell(x, *y).style.background_color() == red)
3303				.count()
3304		};
3305
3306		// unset `valign` fills the line like flex `align-items: stretch`, so
3307		// a `bg=` panel covers its whole share of a three-row row
3308		let src = "<row gap=1><md>one\n\ntwo</md><col bg=red><text>hi</text></col></row>";
3309		let stretched = Ui::from_markup(src, 24, UiContext::default()).unwrap();
3310		let rows = frame_text(&stretched);
3311		let x = u16::try_from(rows[0].find("hi").expect("painted")).expect("small");
3312		assert_eq!(rows.len(), 3, "tallest child sets the height: {rows:?}");
3313		assert_eq!(bg_column(&stretched, x), 3, "panel fills every row: {rows:?}");
3314
3315		// the panel's own `valign` then positions what is inside it
3316		let centered = Ui::from_markup(
3317			src.replace("<col bg=red>", "<col bg=red valign=center>"),
3318			24,
3319			UiContext::default(),
3320		)
3321		.unwrap();
3322		let rows = frame_text(&centered);
3323		assert!(rows[1].contains("hi"), "content centered in the panel: {rows:?}");
3324		assert_eq!(bg_column(&centered, x), 3, "still filled: {rows:?}");
3325
3326		// `valign=start` on the row opts back out of stretching
3327		let top =
3328			Ui::from_markup(src.replace("<row ", "<row valign=start "), 24, UiContext::default())
3329				.unwrap();
3330		assert_eq!(bg_column(&top, x), 1, "panel hugs its content");
3331	}
3332
3333	#[test]
3334	fn row_honors_pad_align_and_column_grow_absorbs_fixed_height() {
3335		use crate::Color;
3336		// `pad` and `align` are not box-only: a row indents and can push its
3337		// children to the far edge
3338		let padded =
3339			Ui::from_markup("<row pad=\"1 2\"><text>ab</text></row>", 12, UiContext::default())
3340				.unwrap();
3341		let rows = frame_text(&padded);
3342		assert_eq!(rows.len(), 3, "vertical padding is real: {rows:?}");
3343		assert_eq!(rows[1].find("ab"), Some(2), "horizontal padding indents: {rows:?}");
3344
3345		let right =
3346			Ui::from_markup("<row align=end><text>ab</text></row>", 12, UiContext::default()).unwrap();
3347		assert_eq!(frame_text(&right)[0].trim_end().len(), 12, "flushed right");
3348
3349		// `grow` fills leftover height in a fixed-height column, the way it
3350		// already filled leftover width in a row
3351		let column = Ui::from_markup(
3352			"<col h=5><text>a</text><text grow bg=red>b</text></col>",
3353			8,
3354			UiContext::default(),
3355		)
3356		.unwrap();
3357		let filled = (0..column.frame().size().height)
3358			.filter(|y| column.frame().cell(0, *y).style.background_color() == Color::Rgb(0xff, 0, 0))
3359			.count();
3360		assert_eq!(filled, 4, "grow child absorbs the four leftover rows");
3361	}
3362
3363	#[test]
3364	fn leaf_padding_insets_content_and_reserves_height() {
3365		let ui = Ui::from_markup(r#"<text pad="1 2">ab</text>"#, 8, UiContext::default()).unwrap();
3366		let rows = frame_text(&ui);
3367		assert_eq!(ui.height(), 3, "content row plus vertical padding: {rows:?}");
3368		assert_eq!(rows[0], "", "top padding stays empty: {rows:?}");
3369		assert_eq!(rows[1].find("ab"), Some(2), "horizontal padding indents: {rows:?}");
3370
3371		let narrow =
3372			Ui::from_markup(r#"<text pad="0 9">ab</text>"#, 4, UiContext::default()).unwrap();
3373		assert_eq!(narrow.height(), 1, "oversized padding still lays out safely");
3374	}
3375
3376	#[test]
3377	fn boxes_are_transparent_until_bg_is_named() {
3378		use crate::Color;
3379
3380		/// Background of the cell under `needle`'s first glyph, and of the
3381		/// cell just past it.
3382		fn bg_at(ui: &Ui, needle: &str) -> (Color, Color) {
3383			let rows = frame_text(ui);
3384			let (row, text) = rows
3385				.iter()
3386				.enumerate()
3387				.find(|(_, row)| row.contains(needle))
3388				.expect("needle painted");
3389			let column = text[..text.find(needle).expect("needle")].chars().count();
3390			let cell = |x: usize| {
3391				ui.frame()
3392					.cell(u16::try_from(x).expect("small"), u16::try_from(row).expect("small"))
3393					.style
3394					.background_color()
3395			};
3396			(cell(column), cell(column + needle.chars().count()))
3397		}
3398
3399		let bare =
3400			Ui::from_markup("<box border=round><text>hi</text></box>", 20, UiContext::default())
3401				.unwrap();
3402		assert_eq!(bg_at(&bare, "hi").0, Color::Default, "no fill without bg=");
3403
3404		// an explicit bg reaches every default-bg cell of the subtree, glyphs
3405		// included, while a nested box keeps the one it names
3406		let red = Color::Rgb(0xff, 0, 0);
3407		let blue = Color::Rgb(0, 0, 0xff);
3408		let nested = Ui::from_markup(
3409			"<box bg=red><text>outer</text><box bg=blue><text>inner</text></box></box>",
3410			24,
3411			UiContext::default(),
3412		)
3413		.unwrap();
3414		let (glyph, after) = bg_at(&nested, "outer");
3415		assert_eq!(glyph, red, "glyph cell keeps the fill");
3416		assert_eq!(after, red, "blank cell too");
3417		assert_eq!(bg_at(&nested, "inner").0, blue, "nested bg wins");
3418
3419		// a plain nested box inherits instead of punching a hole
3420		let inherit = Ui::from_markup(
3421			"<box bg=red><text>a</text><box><text>bee</text></box></box>",
3422			24,
3423			UiContext::default(),
3424		)
3425		.unwrap();
3426		assert_eq!(bg_at(&inherit, "bee").0, red);
3427	}
3428	#[test]
3429	fn framed_fill_stops_at_the_border_unless_bleed() {
3430		use crate::Color;
3431
3432		let red = Color::Rgb(0xff, 0, 0);
3433		let bg = |ui: &Ui, x: u16, y: u16| ui.frame().cell(x, y).style.background_color();
3434
3435		// default: the fill stops inside the frame; border cells stay ambient
3436		let inset = Ui::from_markup(
3437			"<box border=round bg=red><text>hi</text></box>",
3438			12,
3439			UiContext::default(),
3440		)
3441		.unwrap();
3442		assert_eq!(bg(&inset, 0, 0), Color::Default, "corner stays ambient");
3443		assert_eq!(bg(&inset, 0, 1), Color::Default, "left rail stays ambient");
3444		assert_eq!(bg(&inset, 1, 1), red, "interior glyph cell filled");
3445		assert_eq!(bg(&inset, 10, 1), red, "interior blank cell filled");
3446
3447		// `bleed` extends the fill behind the frame
3448		let bled = Ui::from_markup(
3449			"<box border=round bg=red bleed><text>hi</text></box>",
3450			12,
3451			UiContext::default(),
3452		)
3453		.unwrap();
3454		assert_eq!(bg(&bled, 0, 0), red, "corner adopts the fill");
3455		assert_eq!(bg(&bled, 0, 1), red, "left rail adopts the fill");
3456		assert_eq!(bg(&bled, 1, 1), red);
3457
3458		// bordered rows inset the same way
3459		let row = Ui::from_markup(
3460			"<row border=square bg=red><text>x</text></row>",
3461			12,
3462			UiContext::default(),
3463		)
3464		.unwrap();
3465		assert_eq!(bg(&row, 0, 0), Color::Default);
3466		assert_eq!(bg(&row, 1, 1), red);
3467	}
3468	#[test]
3469	fn border_titles_and_footers_align_without_bg_leak() {
3470		use crate::Color;
3471
3472		let red = Color::Rgb(0xff, 0, 0);
3473		let bg = |ui: &Ui, x: u16, y: u16| ui.frame().cell(x, y).style.background_color();
3474
3475		// the fill never reaches the title or footer without `bleed`
3476		let inset = Ui::from_markup(
3477			"<box border=round bg=red title=T footer=F><text>hi</text></box>",
3478			12,
3479			UiContext::default(),
3480		)
3481		.unwrap();
3482		assert_eq!(bg(&inset, 3, 0), Color::Default, "title glyph stays ambient");
3483		assert_eq!(bg(&inset, 3, 2), Color::Default, "footer glyph stays ambient");
3484
3485		// `bleed` carries it through, labels included
3486		let bled = Ui::from_markup(
3487			"<box border=round bg=red bleed title=T footer=F><text>hi</text></box>",
3488			12,
3489			UiContext::default(),
3490		)
3491		.unwrap();
3492		assert_eq!(bg(&bled, 3, 0), red, "title adopts the fill");
3493		assert_eq!(bg(&bled, 3, 2), red, "footer adopts the fill");
3494
3495		// alignment places the padded label along the frame line
3496		let aligned = Ui::from_markup(
3497			"<box border=round title=T title-align=center footer=F \
3498			 footer-align=right><text>hi</text></box>",
3499			12,
3500			UiContext::default(),
3501		)
3502		.unwrap();
3503		let top = frame_row_text(aligned.frame(), 0);
3504		let bottom = frame_row_text(aligned.frame(), 2);
3505		assert_eq!(top.chars().position(|c| c == 'T'), Some(5), "centered title: {top}");
3506		assert_eq!(bottom.chars().position(|c| c == 'F'), Some(8), "right footer: {bottom}");
3507
3508		// an overlong label truncates to the border interior instead of
3509		// running over the right corner into a sibling
3510		let tight = Ui::from_markup(
3511			"<row><box border=round w=8 title=abcdefghij><text>x</text></box><text>NEXT</text></row>",
3512			16,
3513			UiContext::default(),
3514		)
3515		.unwrap();
3516		let top = frame_row_text(tight.frame(), 0);
3517		assert!(top.contains("abcd") && !top.contains("abcde"), "truncated: {top}");
3518		assert_eq!(top.chars().nth(7), Some('╮'), "right corner survives: {top}");
3519		assert!(top.contains("NEXT"), "sibling untouched: {top}");
3520
3521		// no interior cell for a glyph: the label is skipped entirely
3522		let narrow = Ui::from_markup(
3523			"<row><box border=round w=4 pad-x=0 title=Z footer=Q><text>x</text></box></row>",
3524			16,
3525			UiContext::default(),
3526		)
3527		.unwrap();
3528		let text: String = (0..3)
3529			.map(|row| frame_row_text(narrow.frame(), row))
3530			.collect();
3531		assert!(!text.contains('Z') && !text.contains('Q'), "labels skipped: {text}");
3532	}
3533	#[test]
3534	fn hr_is_a_vertical_separator_inside_a_row() {
3535		// a row lays children side by side; `<hr/>` between them spans the
3536		// row's height as a one-column divider
3537		let ui = Ui::from_markup(
3538			"<row gap=1><md>one\n\ntwo</md><hr/><text>b</text></row>",
3539			24,
3540			UiContext::default(),
3541		)
3542		.unwrap();
3543		let rows = frame_text(&ui);
3544		assert!(rows.len() >= 3, "tallest child sets the height: {rows:?}");
3545		let column = rows[0]
3546			.chars()
3547			.position(|glyph| glyph == '│')
3548			.expect("separator");
3549		for row in &rows {
3550			assert_eq!(row.chars().nth(column), Some('│'), "spans every row: {rows:?}");
3551		}
3552		assert!(rows[0].contains("one") && rows[0].contains('b'), "side by side: {rows:?}");
3553		// outside a row it stays the usual horizontal divider
3554		let stacked =
3555			Ui::from_markup("<col><text>x</text><hr/></col>", 12, UiContext::default()).unwrap();
3556		assert!(frame_text(&stacked).iter().any(|row| row.contains("────")));
3557		// <pre> keeps whitespace verbatim next to the divider
3558		let mixed =
3559			Ui::from_markup("<col><pre>a  b</pre><hr/></col>", 12, UiContext::default()).unwrap();
3560		let rows = frame_text(&mixed);
3561		assert!(rows.iter().any(|row| row.contains("a  b")), "verbatim run: {rows:?}");
3562		assert!(rows.iter().any(|row| row.contains("────")), "divider row: {rows:?}");
3563	}
3564	#[test]
3565	fn markdown_code_keeps_markup_literal_in_implicit_text() {
3566		// a fence opening immediately after the tag is still a fence
3567		let fenced = Ui::from_markup("<box>```\n<hr/>\n```</box>", 40, UiContext::default()).unwrap();
3568		let rows = frame_text(&fenced);
3569		let all = rows.join("\n");
3570		assert!(all.contains("<hr/>"), "fenced markup stays literal: {all}");
3571		assert!(all.contains("```"), "rendered as a code block, not a rule: {all}");
3572		// inline code spans too, including across a blank line (our
3573		// code_span scans the whole text for an equal backtick run)
3574		let inline = Ui::from_markup("<box>`<hr/>`</box>", 40, UiContext::default()).unwrap();
3575		assert!(frame_text(&inline).join("\n").contains("<hr/>"));
3576		let across = Ui::from_markup("<box>`a\n\n<hr/>`</box>", 40, UiContext::default()).unwrap();
3577		assert!(frame_text(&across).join("\n").contains("<hr/>"));
3578		// an UNMATCHED backtick must not swallow the rest of the body
3579		let unmatched =
3580			Ui::from_markup("<box>note ` here<text>tail</text></box>", 40, UiContext::default())
3581				.unwrap();
3582		let all = frame_text(&unmatched).join("\n");
3583		assert!(all.contains("tail"), "element still parsed: {all}");
3584		assert!(!all.contains("<text>"), "not literal: {all}");
3585		// a run with trailing text does not close the fence (renderer's
3586		// is_closing_fence requires a whitespace-only remainder)
3587		let sticky =
3588			Ui::from_markup("<box>```\n```oops\n<hr/>\n```</box>", 44, UiContext::default()).unwrap();
3589		let all = frame_text(&sticky).join("\n");
3590		assert!(all.contains("<hr/>"), "still inside the fence: {all}");
3591		assert!(all.contains("```oops"), "the false closer is code text: {all}");
3592		// indented (4-space) code is markdown-owned too, and the segment
3593		// keeps its indentation so the code-block path renders it
3594		let indented =
3595			Ui::from_markup("<box>text\n\n    <hr/>\n</box>", 44, UiContext::default()).unwrap();
3596		let rows = frame_text(&indented);
3597		let all = rows.join("\n");
3598		assert!(all.contains("<hr/>"), "indented markup stays literal: {all}");
3599		assert!(all.contains("```"), "rendered through the code-block path: {all}");
3600		// a backslash escapes the angle bracket (markdown escape); an even
3601		// run is a literal backslash and the tag is real markup again
3602		let escaped = Ui::from_markup("<box>\\<hr/></box>", 44, UiContext::default()).unwrap();
3603		assert!(
3604			!contains_component::<crate::components::Hr>(escaped.root()),
3605			"escaped tag builds no element"
3606		);
3607		assert!(frame_text(&escaped).join("\n").contains("<hr/>"));
3608		let unescaped = Ui::from_markup("<box>\\\\<hr/></box>", 44, UiContext::default()).unwrap();
3609		assert!(
3610			contains_component::<crate::components::Hr>(unescaped.root()),
3611			"even backslashes leave the tag as markup"
3612		);
3613		// math spans own their angle brackets; currency is not math, so the
3614		// tag after it is still markup
3615		let math = Ui::from_markup("<box>$x <y> z$</box>", 44, UiContext::default()).unwrap();
3616		assert!(
3617			!contains_component::<crate::components::Hr>(math.root()),
3618			"no element built inside math"
3619		);
3620		assert!(frame_text(&math).join("\n").contains('<'), "math renders literally");
3621		let currency =
3622			Ui::from_markup("<box>costs $5 and <hr/></box>", 44, UiContext::default()).unwrap();
3623		assert!(
3624			contains_component::<crate::components::Hr>(currency.root()),
3625			"`$5` is not a math span"
3626		);
3627		// HTML comments are stripped by Markdown, so tags inside are inert
3628		for src in ["<box><!-- <hr/> --></box>", "<box><!--\n<hr/>\n--></box>"] {
3629			let commented = Ui::from_markup(src, 44, UiContext::default()).unwrap();
3630			assert!(
3631				!contains_component::<crate::components::Hr>(commented.root()),
3632				"comment contents build nothing: {src}"
3633			);
3634			assert!(!frame_text(&commented).join("\n").contains("hr"), "{src}");
3635		}
3636	}
3637
3638	#[test]
3639	fn dynamic_md_rejects_ids_and_conditions_and_scrubs_static_ones() {
3640		// dynamic fragments with id= or when= degrade to literal text
3641		let mut ui = Ui::from_markup("<md id=doc>plain</md>", 44, UiContext::default()).unwrap();
3642		ui.set_text("doc", "<box id=ghost><text>x</text></box>");
3643		assert!(!ui.set_text("ghost", "y"), "dynamic ids never register");
3644		assert!(
3645			frame_text(&ui)
3646				.iter()
3647				.any(|row| row.contains("<box id=ghost>"))
3648		);
3649		ui.set_text("doc", "<box when=\"other=on\"><text>x</text></box>");
3650		assert!(frame_text(&ui).iter().any(|row| row.contains("when")));
3651
3652		// statically embedded ids are scrubbed when their graft is released
3653		let src = "<md id=doc>intro\n\n<box><text id=inner>t</text></box></md>";
3654		let mut ui = Ui::from_markup(src, 44, UiContext::default()).unwrap();
3655		assert!(ui.set_text("inner", "still live"));
3656		ui.set_text("doc", "plain again");
3657		assert!(!ui.set_text("inner", "gone"), "released slot id is unregistered");
3658		assert!(
3659			frame_text(&ui)
3660				.iter()
3661				.any(|row| row.contains("plain again"))
3662		);
3663	}
3664	#[test]
3665	fn container_fg_reaches_implicit_markdown() {
3666		use crate::{Color, Theme};
3667
3668		let ui =
3669			Ui::from_markup("<col fg=#0000ff>plain `code`</col>", 32, UiContext::default()).unwrap();
3670		let fg_at = |needle: &str| {
3671			let rows = frame_text(&ui);
3672			let (row, text) = rows
3673				.iter()
3674				.enumerate()
3675				.find(|(_, row)| row.contains(needle))
3676				.expect("needle painted");
3677			let column = text[..text.find(needle).expect("needle")].chars().count();
3678			ui.frame()
3679				.cell(u16::try_from(column).expect("small"), u16::try_from(row).expect("small"))
3680				.style
3681				.foreground_color()
3682		};
3683
3684		assert_eq!(fg_at("plain"), Color::Rgb(0, 0, 0xff), "prose adopts container fg");
3685		assert_eq!(fg_at("code"), Theme::default().warn, "semantic hue is preserved");
3686	}
3687
3688	#[test]
3689	fn grafted_markdown_inherits_the_host_cascade() {
3690		use crate::Color;
3691
3692		let mut ui =
3693			Ui::from_markup("<col fg=#0000ff><md id=m>x</md></col>", 32, UiContext::default())
3694				.unwrap();
3695		ui.set_text("m", "before\n\n<box><text>inner</text></box>");
3696		let fg_at = |needle: &str| {
3697			let rows = frame_text(&ui);
3698			let (row, text) = rows
3699				.iter()
3700				.enumerate()
3701				.find(|(_, row)| row.contains(needle))
3702				.expect("needle painted");
3703			let column = text[..text.find(needle).expect("needle")].chars().count();
3704			ui.frame()
3705				.cell(u16::try_from(column).expect("small"), u16::try_from(row).expect("small"))
3706				.style
3707				.foreground_color()
3708		};
3709
3710		assert_eq!(fg_at("before"), Color::Rgb(0, 0, 0xff));
3711		assert_eq!(fg_at("inner"), Color::Rgb(0, 0, 0xff));
3712	}
3713
3714	#[test]
3715	fn callout_accent_survives_flag_only_ancestors() {
3716		use crate::{Color, Theme};
3717
3718		let ui = Ui::from_markup(
3719			"<col bold><callout id=e title=T>x</callout></col>",
3720			32,
3721			UiContext::default(),
3722		)
3723		.unwrap();
3724		let rect = find_id(ui.root(), "e").expect("id=e").rect;
3725		assert_eq!(
3726			ui.frame().cell(rect.x, rect.y).style.foreground_color(),
3727			Theme::default().info,
3728			"flag-only ancestor keeps default info accent"
3729		);
3730
3731		let colored = Ui::from_markup(
3732			"<col fg=#0000ff><callout id=e title=T>x</callout></col>",
3733			32,
3734			UiContext::default(),
3735		)
3736		.unwrap();
3737		let rect = find_id(colored.root(), "e").expect("id=e").rect;
3738		assert_eq!(
3739			colored
3740				.frame()
3741				.cell(rect.x, rect.y)
3742				.style
3743				.foreground_color(),
3744			Color::Rgb(0, 0, 0xff),
3745			"inherited fg colors the rail"
3746		);
3747	}
3748
3749	/// Architecture proof, mirrors /tmp/ui-bench scenarios: steady-state
3750	/// updates must be orders of magnitude cheaper than rebuilds. Run:
3751	/// `cargo test -p omp-tui --release -- --ignored perf --nocapture`
3752	#[test]
3753	#[ignore = "release-mode perf smoke, run explicitly"]
3754	fn perf_two_tier_updates() {
3755		use std::{fmt::Write as _, time::Instant};
3756
3757		let mut src = String::from("<col>");
3758		for i in 0..1000 {
3759			let _ = write!(
3760				src,
3761				"<box><row gap=1><text>service unit {i} nominal</text><text id=c{i} min=16>counter \
3762				 0</text></row></box>"
3763			);
3764		}
3765		src.push_str("</col>");
3766
3767		let t0 = Instant::now();
3768		let mut ui = Ui::from_markup(src.clone(), 120, UiContext::default()).unwrap();
3769		let build = t0.elapsed();
3770
3771		let t0 = Instant::now();
3772		const FRAMES: u32 = 2000;
3773		for i in 0..FRAMES {
3774			ui.set_text(&format!("c{}", i % 1000), format!("counter {i}"));
3775		}
3776		let steady = t0.elapsed() / FRAMES;
3777
3778		let t0 = Instant::now();
3779		let rebuilt = Ui::from_markup(src, 120, UiContext::default()).unwrap();
3780		let rebuild = t0.elapsed();
3781		assert_eq!(rebuilt.height(), ui.height());
3782
3783		println!(
3784			"build {build:?}  steady {steady:?}/update  rebuild {rebuild:?}  rows {}",
3785			ui.height()
3786		);
3787		// the architectural claim: steady-state is at least 100x cheaper
3788		// than rebuilding the document
3789		assert!(steady.as_nanos() * 100 < rebuild.as_nanos());
3790	}
3791
3792	/// Presentation must scale with DAMAGE, not document size: one
3793	/// counter tick on a 4x taller document (and a padded high-water
3794	/// frame) presents in comparable time. Run with the perf smoke:
3795	/// `cargo test -p omp-tui --release -- --ignored perf --nocapture`
3796	#[test]
3797	#[ignore = "release-mode perf smoke, run explicitly"]
3798	fn perf_present_scales_with_damage() {
3799		use std::{fmt::Write as _, time::Instant};
3800
3801		use crate::Renderer;
3802
3803		let steady_present = |sections: usize| {
3804			let mut src = String::from("<col>");
3805			for i in 0..sections {
3806				let _ = write!(
3807					src,
3808					"<box><row gap=1><text>service unit {i} nominal</text><text id=c{i} min=16>counter \
3809					 0</text></row></box>"
3810				);
3811			}
3812			src.push_str("</col>");
3813			let mut ui = Ui::from_markup(src, 120, UiContext::default()).unwrap();
3814			let mut renderer = Renderer::new(std::io::sink());
3815			ui.present(&mut renderer, 40, 0).unwrap();
3816			const FRAMES: u32 = 2000;
3817			let t0 = Instant::now();
3818			for i in 0..FRAMES {
3819				ui.set_text(&format!("c{}", i % sections as u32), format!("counter {i}"));
3820				ui.present(&mut renderer, 40, 0).unwrap();
3821			}
3822			(t0.elapsed() / FRAMES, ui.frame().size().height)
3823		};
3824
3825		let (small, small_rows) = steady_present(1000);
3826		let (large, large_rows) = steady_present(4000);
3827		println!(
3828			"present steady: {small:?}/event @ {small_rows} rows vs {large:?}/event @ {large_rows} \
3829			 rows"
3830		);
3831		// 4x the document must NOT cost 4x the event: allow 2x jitter
3832		assert!(
3833			large.as_nanos() < small.as_nanos() * 2,
3834			"presentation is not damage-proportional: {small:?} -> {large:?}"
3835		);
3836	}
3837	#[test]
3838	fn fg_and_bg_gradients_paint_boxes_at_the_requested_angle() {
3839		let plain = Ui::from_markup("<pre>  AB\n C</pre>", 4, UiContext::default()).unwrap();
3840		assert_eq!(frame_text(&plain), ["  AB", " C"]);
3841
3842		let mut ui = Ui::from_markup(r##"<box bg="#000000..#ffffff" fg="#ff0000..#0000ff" angle=90 pad="0 0"><text id=copy>ab</text><text>cd</text></box>"##, 4, UiContext::default())
3843		.unwrap();
3844		let colors = |ui: &Ui, x: u16, y: u16| {
3845			let style = &ui.frame().cell(x, y).style;
3846			(style.foreground_color(), style.background_color())
3847		};
3848		let (top_fg, top_bg) = colors(&ui, 1, 1);
3849		let (bottom_fg, bottom_bg) = colors(&ui, 1, 2);
3850		assert_ne!(top_bg, bottom_bg, "angle=90 makes the box background vertical");
3851		assert_eq!(top_bg, colors(&ui, 2, 1).1);
3852		assert_ne!(top_fg, bottom_fg, "box fg= cascades a vertical text gradient");
3853		assert_eq!(top_fg, colors(&ui, 2, 1).0);
3854
3855		assert!(ui.set_text("copy", "xy"));
3856		assert_eq!(colors(&ui, 1, 1).1, top_bg, "incremental text paint restores the gradient");
3857
3858		let horizontal =
3859			Ui::from_markup(r##"<text fg="#000000..#ffffff">ab</text>"##, 2, UiContext::default())
3860				.unwrap();
3861		assert_ne!(
3862			horizontal.frame().cell(0, 0).style.foreground_color(),
3863			horizontal.frame().cell(1, 0).style.foreground_color(),
3864			"the default angle is horizontal",
3865		);
3866
3867		let diagonal = Ui::from_markup(
3868			r##"<pre fg="#000000..#ffffff" angle=45>ab
3869cd</pre>"##,
3870			2,
3871			UiContext::default(),
3872		)
3873		.unwrap();
3874		let diagonal_colors = |x, y| diagonal.frame().cell(x, y).style.foreground_color();
3875		assert_ne!(diagonal_colors(0, 0), diagonal_colors(1, 1));
3876		assert_eq!(diagonal_colors(1, 0), diagonal_colors(0, 1));
3877
3878		let explicit = Ui::from_markup(
3879			r##"<box fg="#000000..#ffffff" pad="0 0"><text fg=red>x</text></box>"##,
3880			3,
3881			UiContext::default(),
3882		)
3883		.unwrap();
3884		assert_eq!(explicit.frame().cell(1, 1).style.foreground_color(), Color::Rgb(255, 0, 0));
3885	}
3886	#[test]
3887	fn layout_macro_builds_ui_and_preserves_text_props() {
3888		let ui = Ui::from_root(
3889			dom! { <box bg=yellow><text italic>{"hi"}</text></box> },
3890			20,
3891			UiContext::default(),
3892		);
3893		let rows = (0..ui.frame().size().height)
3894			.map(|row| frame_row_text(ui.frame(), row))
3895			.collect::<Vec<_>>();
3896		assert!(rows.iter().any(|row| row.contains("hi")));
3897		let painted = rows.join("\n");
3898		for glyph in ['┌', '┐', '└', '┘', '─', '│'] {
3899			assert!(painted.contains(glyph), "missing box border glyph {glyph:?}");
3900		}
3901
3902		let text = &ui.root.comp().children()[0];
3903		assert_eq!(text.comp().props().get(Prop::Italic), Some(&PropValue::Bool(true)),);
3904	}
3905
3906	#[test]
3907	fn layout_macro_builds_and_paints_representative_tree() {
3908		let x = "hey";
3909		let ui = Ui::from_root(
3910			omp_tui::dom! {
3911				<box bg=yellow><row><col fg=blue><i:new/><text italic> {x} </text></col></row></box>
3912			},
3913			20,
3914			UiContext::default(),
3915		);
3916		let painted = (0..ui.frame().size().height)
3917			.map(|row| frame_row_text(ui.frame(), row))
3918			.collect::<Vec<_>>()
3919			.join("\n");
3920		assert!(painted.contains("hey"));
3921		assert!(painted.contains('┌') && painted.contains('┘'));
3922	}
3923
3924	#[test]
3925	fn layout_macro_control_flow_builds_selected_children() {
3926		let mode = 2;
3927		let labels = ["loop-a", "loop-b"];
3928		let ui = Ui::from_root(
3929			crate::dom! {
3930				<col>
3931					for label in labels {
3932						<text>{label}</text>
3933					}
3934					if mode == 0 {
3935						<text>"if-zero"</text>
3936					} else if mode == 1 {
3937						<text>"if-one"</text>
3938					} else {
3939						<text>"if-many"</text>
3940					}
3941					match mode {
3942						0 => <text>"match-zero"</text>,
3943						1 => <text>"match-one"</text>,
3944						value if value > 1 => {
3945							<text>"match-many"</text>
3946							<text>"match-tail"</text>
3947						},
3948						_ => {},
3949					}
3950				</col>
3951			},
3952			20,
3953			UiContext::default(),
3954		);
3955		let painted = (0..ui.frame().size().height)
3956			.map(|row| frame_row_text(ui.frame(), row))
3957			.collect::<Vec<_>>()
3958			.join("\n");
3959		for expected in ["loop-a", "loop-b", "if-many", "match-many", "match-tail"] {
3960			assert!(painted.contains(expected), "missing selected child {expected:?}");
3961		}
3962		for skipped in ["if-zero", "if-one", "match-zero", "match-one"] {
3963			assert!(!painted.contains(skipped), "rendered skipped child {skipped:?}");
3964		}
3965	}
3966
3967	/// The names contract for the derived `Prop` string mapping: every
3968	/// markup attribute resolves, distinctly, and no variant exists outside
3969	/// this list — a typoed `strum(serialize)` or an undocumented variant
3970	/// fails here rather than silently changing the markup language.
3971	#[test]
3972	fn layout_macro_known_attributes_match_props_name_table() {
3973		let mut resolved: Vec<Prop> = ATTR_FIXTURE
3974			.iter()
3975			.map(|&name| {
3976				Props::prop_of(name).unwrap_or_else(|| panic!("missing Props entry for {name:?}"))
3977			})
3978			.collect();
3979		resolved.sort_by_key(|&prop| prop as usize);
3980		resolved.dedup();
3981		assert_eq!(resolved.len(), ATTR_FIXTURE.len(), "two attribute names hit one property");
3982		assert_eq!(
3983			ATTR_FIXTURE.len(),
3984			<Prop as strum::IntoEnumIterator>::iter().count(),
3985			"a Prop variant is missing from ATTR_FIXTURE"
3986		);
3987	}
3988
3989	/// Paints the 10ms-step counter digit and re-requests wakes until `stop`.
3990	struct Blinker {
3991		props: Props,
3992		slot:  Slot,
3993		stop:  Duration,
3994	}
3995
3996	impl Blinker {
3997		fn until(stop: Duration) -> Self {
3998			Self { props: Props::new(), slot: crate::component::next_slot(), stop }
3999		}
4000	}
4001
4002	impl crate::component::Component for Blinker {
4003		fn props(&self) -> &Props {
4004			&self.props
4005		}
4006
4007		fn props_mut(&mut self) -> &mut Props {
4008			&mut self.props
4009		}
4010
4011		fn slot(&self) -> Slot {
4012			self.slot
4013		}
4014
4015		fn measure(&mut self, _ctx: &UiContext) -> (u16, u16) {
4016			(1, 1)
4017		}
4018
4019		fn height(&mut self, _ctx: &UiContext, _width: u16) -> u16 {
4020			1
4021		}
4022
4023		fn paint(&mut self, pc: &mut PaintCtx<'_>, rect: Rect) {
4024			let step = (pc.now.as_millis() / 10) % 10;
4025			pc.frame
4026				.put(rect.x, rect.y, &step.to_string(), Style::default());
4027			if pc.now < self.stop {
4028				pc.wake(self.slot, pc.now + Duration::from_millis(10));
4029			}
4030		}
4031	}
4032
4033	#[test]
4034	fn tick_repaints_due_animations_until_the_component_stops_asking() {
4035		let mut ui =
4036			Ui::from_root(Blinker::until(Duration::from_millis(20)), 3, UiContext::default());
4037		assert_eq!(frame_text(&ui)[0], "0");
4038		assert_eq!(ui.next_wake(), Some(Duration::from_millis(10)));
4039
4040		assert!(!ui.tick(Duration::from_millis(5)), "nothing is due before the deadline");
4041		assert!(ui.tick(Duration::from_millis(10)));
4042		assert_eq!(frame_text(&ui)[0], "1");
4043
4044		assert!(ui.tick(Duration::from_millis(20)));
4045		assert_eq!(frame_text(&ui)[0], "2");
4046		assert_eq!(ui.next_wake(), None, "the final paint stopped requesting wakes");
4047		assert!(!ui.tick(Duration::from_millis(30)));
4048	}
4049
4050	#[test]
4051	fn resize_rebuilds_the_wake_schedule_without_duplicates() {
4052		let mut ui = Ui::from_root(Blinker::until(Duration::from_secs(1)), 3, UiContext::default());
4053		ui.tick(Duration::from_millis(10));
4054		ui.resize(5);
4055		assert_eq!(ui.next_wake(), Some(Duration::from_millis(20)));
4056		assert_eq!(ui.wakes.len(), 1, "a full relayout replaces the schedule");
4057	}
4058
4059	#[test]
4060	fn wake_requests_keep_the_earliest_deadline_per_slot() {
4061		let mut frame = Frame::new(Size::new(1, 1));
4062		let mut hits = Vec::new();
4063		let mut wakes = Vec::new();
4064		let ctx = UiContext::default();
4065		let mut pc = PaintCtx::new(&mut frame, &ctx, &mut hits, &mut wakes);
4066		pc.wake(7, Duration::from_millis(20));
4067		pc.wake(7, Duration::from_millis(10));
4068		pc.wake(7, Duration::from_millis(30));
4069		pc.wake(9, Duration::from_millis(5));
4070		assert_eq!(wakes, vec![
4071			Wake { slot: 7, at: Duration::from_millis(10), layout: false },
4072			Wake { slot: 9, at: Duration::from_millis(5), layout: false },
4073		]);
4074	}
4075
4076	struct MouseRecorder {
4077		props: Props,
4078		slot:  Slot,
4079		seen:  std::rc::Rc<std::cell::RefCell<Vec<Mouse>>>,
4080	}
4081
4082	impl crate::component::Component for MouseRecorder {
4083		fn props(&self) -> &Props {
4084			&self.props
4085		}
4086
4087		fn props_mut(&mut self) -> &mut Props {
4088			&mut self.props
4089		}
4090
4091		fn slot(&self) -> Slot {
4092			self.slot
4093		}
4094
4095		fn measure(&mut self, _ctx: &UiContext) -> (u16, u16) {
4096			(4, 4)
4097		}
4098
4099		fn height(&mut self, _ctx: &UiContext, _width: u16) -> u16 {
4100			1
4101		}
4102
4103		fn paint(&mut self, pc: &mut PaintCtx<'_>, rect: Rect) {
4104			pc.hits
4105				.push(Hit { rect, slot: self.slot, tag: HitTag::Press });
4106		}
4107
4108		fn mouse(
4109			&mut self,
4110			_ec: &mut EventCtx<'_>,
4111			_tag: HitTag,
4112			_at: (u16, u16),
4113			_rect: Rect,
4114			mouse: Mouse,
4115		) -> Flow {
4116			self.seen.borrow_mut().push(mouse);
4117			Flow::Consumed
4118		}
4119	}
4120
4121	#[test]
4122	fn right_click_reaches_the_hit_component() {
4123		let seen = std::rc::Rc::new(std::cell::RefCell::new(Vec::new()));
4124		let root = MouseRecorder {
4125			props: Props::new(),
4126			slot:  crate::component::next_slot(),
4127			seen:  std::rc::Rc::clone(&seen),
4128		};
4129		let mut ui = Ui::from_root(root, 4, UiContext::default());
4130		let hit = ui.hits()[0];
4131		assert_eq!(ui.handle_mouse(hit.rect.x, hit.rect.y, Mouse::RightClick), UiEvent::None);
4132		ui.handle_mouse(u16::MAX, u16::MAX, Mouse::Release);
4133		ui.handle_mouse(u16::MAX, u16::MAX, Mouse::Release);
4134		assert_eq!(&*seen.borrow(), &[Mouse::RightClick, Mouse::Release]);
4135	}
4136
4137	/// Paints a label read through shared interior mutability.
4138	struct SharedLabel {
4139		props: Props,
4140		slot:  Slot,
4141		text:  std::rc::Rc<std::cell::RefCell<&'static str>>,
4142	}
4143
4144	impl crate::component::Component for SharedLabel {
4145		fn props(&self) -> &Props {
4146			&self.props
4147		}
4148
4149		fn props_mut(&mut self) -> &mut Props {
4150			&mut self.props
4151		}
4152
4153		fn slot(&self) -> Slot {
4154			self.slot
4155		}
4156
4157		fn measure(&mut self, _ctx: &UiContext) -> (u16, u16) {
4158			(8, 8)
4159		}
4160
4161		fn height(&mut self, _ctx: &UiContext, _width: u16) -> u16 {
4162			1
4163		}
4164
4165		fn paint(&mut self, pc: &mut PaintCtx<'_>, rect: Rect) {
4166			pc.frame
4167				.put(rect.x, rect.y, &self.text.borrow(), Style::default());
4168		}
4169	}
4170
4171	#[test]
4172	fn invalidate_refreshes_a_component_reading_shared_state() {
4173		let text = std::rc::Rc::new(std::cell::RefCell::new("before"));
4174		let mut props = Props::new();
4175		props.set(Prop::Id, "label");
4176		let root = SharedLabel {
4177			props,
4178			slot: crate::component::next_slot(),
4179			text: std::rc::Rc::clone(&text),
4180		};
4181		let mut ui = Ui::from_root(root, 8, UiContext::default());
4182		assert_eq!(frame_text(&ui)[0], "before");
4183
4184		*text.borrow_mut() = "after";
4185		assert!(ui.invalidate("label"));
4186		assert_eq!(frame_text(&ui)[0], "after");
4187		assert!(!ui.invalidate("missing"));
4188	}
4189
4190	fn set_prop(ui: &mut Ui, id: &str, prop: Prop, value: PropValue) {
4191		assert!(ui.set_prop(id, prop, value), "component exists");
4192	}
4193
4194	#[test]
4195	fn fixed_height_clips_overflowing_content_to_the_content_box() {
4196		let source = "<col><box h=4><text>l1\nl2\nl3\nl4\nl5</text></box><text>after</text></col>";
4197		let ui = Ui::from_markup(source, 10, UiContext::default()).unwrap();
4198		let rows = frame_text(&ui);
4199		assert_eq!(ui.height(), 5, "the box holds its fixed height");
4200		assert!(rows[1].contains("l1") && rows[2].contains("l2"));
4201		assert!(
4202			rows[3].starts_with('└') || rows[3].starts_with('+'),
4203			"overflow must not eat the bottom border: {rows:?}"
4204		);
4205		assert_eq!(rows[4], "after", "overflow must not spill into siblings: {rows:?}");
4206		assert!(!rows.join("\n").contains("l3"), "rows past the budget clip: {rows:?}");
4207
4208		// Mid-flight height samples clip the same way: shrink with anim on.
4209		let mut ui = Ui::from_markup(
4210			"<col><box id=d h=6 anim=100ms \
4211			 ease=linear><text>l1\nl2\nl3\nl4</text></box><text>after</text></col>",
4212			10,
4213			UiContext::default(),
4214		)
4215		.unwrap();
4216		assert!(ui.set_height("d", 4));
4217		ui.tick(Duration::from_millis(50));
4218		let rows = frame_text(&ui);
4219		// Sampled height 5: top border + 3 content rows + bottom border.
4220		assert!(rows[4].starts_with('└'), "animated border stays on top of content: {rows:?}");
4221		assert_eq!(rows[5], "after");
4222	}
4223
4224	#[test]
4225	fn anim_tweens_a_solid_background_change_through_ticks() {
4226		let mut ui = Ui::from_markup(
4227			"<col id=b bg=#000000 anim=100ms ease=linear><text>x</text></col>",
4228			3,
4229			UiContext::default(),
4230		)
4231		.unwrap();
4232		assert_eq!(ui.frame().cell(0, 0).style.background_color(), Color::Rgb(0, 0, 0));
4233		assert_eq!(ui.next_wake(), None, "a settled transition owes no frames");
4234
4235		set_prop(&mut ui, "b", Prop::Bg, PropValue::Color(Color::Rgb(200, 200, 200)));
4236		// The refresh painted at t=0: still black, but a frame is now owed.
4237		assert_eq!(ui.frame().cell(0, 0).style.background_color(), Color::Rgb(0, 0, 0));
4238		assert!(ui.next_wake().is_some());
4239
4240		assert!(ui.tick(Duration::from_millis(50)));
4241		assert_eq!(ui.frame().cell(0, 0).style.background_color(), Color::Rgb(100, 100, 100));
4242		// The target prop itself is untouched by the mid-flight swap.
4243		let target = ui
4244			.root
4245			.update_id("b", |cached| (cached.comp().props().get(Prop::Bg).cloned(), false))
4246			.unwrap();
4247		assert_eq!(target, Some(PropValue::Color(Color::Rgb(200, 200, 200))));
4248
4249		ui.tick(Duration::from_millis(100));
4250		assert_eq!(ui.frame().cell(0, 0).style.background_color(), Color::Rgb(200, 200, 200));
4251		assert_eq!(ui.next_wake(), None, "settling stops the wake schedule");
4252	}
4253
4254	#[test]
4255	fn anim_tweens_text_foreground_through_the_content_paint() {
4256		let mut ui = Ui::from_markup(
4257			"<text id=t fg=#000000 anim=100ms ease=linear>x</text>",
4258			3,
4259			UiContext::default(),
4260		)
4261		.unwrap();
4262		set_prop(&mut ui, "t", Prop::Fg, PropValue::Color(Color::Rgb(0, 0, 200)));
4263		ui.tick(Duration::from_millis(50));
4264		assert_eq!(ui.frame().cell(0, 0).style.foreground_color(), Color::Rgb(0, 0, 100));
4265		ui.tick(Duration::from_millis(100));
4266		assert_eq!(ui.frame().cell(0, 0).style.foreground_color(), Color::Rgb(0, 0, 200));
4267	}
4268
4269	#[test]
4270	fn anim_tweens_gradient_endpoints_as_a_ramp() {
4271		let mut ui = Ui::from_markup(
4272			"<col id=b bg=#000000..#000000 anim=100ms ease=linear><text>x</text></col>",
4273			3,
4274			UiContext::default(),
4275		)
4276		.unwrap();
4277		set_prop(&mut ui, "b", Prop::Bg, PropValue::from("#646464..#646464"));
4278		ui.tick(Duration::from_millis(50));
4279		assert_eq!(ui.frame().cell(0, 0).style.background_color(), Color::Rgb(50, 50, 50));
4280		ui.tick(Duration::from_millis(100));
4281		assert_eq!(ui.frame().cell(0, 0).style.background_color(), Color::Rgb(100, 100, 100));
4282		assert_eq!(ui.next_wake(), None);
4283	}
4284
4285	#[test]
4286	fn spin_rotates_a_gradient_on_the_shared_clock() {
4287		let mut ui = Ui::from_markup(
4288			"<col bg=#000000..#ffffff spin=360ms><text>ab</text></col>",
4289			2,
4290			UiContext::default(),
4291		)
4292		.unwrap();
4293		assert_eq!(ui.frame().cell(0, 0).style.background_color(), Color::Rgb(0, 0, 0));
4294		assert_eq!(ui.frame().cell(1, 0).style.background_color(), Color::Rgb(255, 255, 255));
4295		assert!(ui.next_wake().is_some(), "a spinning gradient always owes a frame");
4296
4297		// Half a revolution reverses the ramp.
4298		assert!(ui.tick(Duration::from_millis(180)));
4299		assert_eq!(ui.frame().cell(0, 0).style.background_color(), Color::Rgb(255, 255, 255));
4300		assert_eq!(ui.frame().cell(1, 0).style.background_color(), Color::Rgb(0, 0, 0));
4301		assert!(ui.next_wake().is_some(), "spin never settles");
4302	}
4303
4304	#[test]
4305	fn shimmer_sweeps_a_bold_crest_across_text() {
4306		let mut ui = Ui::from_markup(
4307			"<text shimmer=1s fg=#606060>abcdefghijklmnopqrstuvwxyz</text>",
4308			26,
4309			UiContext::default(),
4310		)
4311		.unwrap();
4312		// The crest starts on the runway left of the text: shimmer is
4313		// additive, so every cell rests at the authored color.
4314		let style = |ui: &Ui, x: u16| ui.frame().cell(x, 0).style;
4315		let rest = Color::Rgb(0x60, 0x60, 0x60);
4316		assert_eq!(style(&ui, 0).foreground_color(), rest);
4317		assert!(!style(&ui, 13).bold);
4318		assert!(ui.next_wake().is_some(), "a shimmering text always owes a frame");
4319
4320		// Half a period in, the crest peaks at cell 13 (track = 26 + 2×10
4321		// runway cells): two-fifths toward white and bold at the peak,
4322		// one-fifth on the shoulders, the authored color beyond.
4323		assert!(ui.tick(Duration::from_millis(500)));
4324		assert_eq!(style(&ui, 13).foreground_color(), Color::Rgb(159, 159, 159));
4325		assert!(style(&ui, 13).bold);
4326		assert_eq!(style(&ui, 16).foreground_color(), Color::Rgb(127, 127, 127));
4327		assert!(!style(&ui, 16).bold);
4328		assert_eq!(style(&ui, 0).foreground_color(), rest);
4329		assert_eq!(style(&ui, 25).foreground_color(), rest);
4330		assert!(ui.next_wake().is_some(), "shimmer never settles");
4331	}
4332
4333	#[test]
4334	fn layout_macro_lowers_shimmer_onto_text() {
4335		let mut ui = Ui::from_root(
4336			dom! { <text shimmer="1s">{"abcdefghijklmnopqrstuvwxyz"}</text> },
4337			26,
4338			UiContext::default(),
4339		);
4340		let style = |ui: &Ui, x: u16| ui.frame().cell(x, 0).style;
4341		// Colorless text has no channels to lift: rest stays untouched.
4342		assert!(!style(&ui, 0).dim && !style(&ui, 0).bold);
4343		assert!(ui.next_wake().is_some(), "macro-built shimmer owes a frame");
4344		assert!(ui.tick(Duration::from_millis(500)));
4345		assert!(style(&ui, 13).bold, "only the crest peak bolds");
4346		assert!(!style(&ui, 0).bold && !style(&ui, 0).dim);
4347	}
4348
4349	#[test]
4350	fn anim_tweens_a_fixed_height_through_layout_wakes() {
4351		let mut ui = Ui::from_markup(
4352			"<col id=b h=2 anim=100ms ease=linear><text>x</text></col>",
4353			3,
4354			UiContext::default(),
4355		)
4356		.unwrap();
4357		assert_eq!(ui.height(), 2);
4358
4359		assert!(ui.set_height("b", 6));
4360		assert_eq!(ui.height(), 2, "the transition starts from the on-screen size");
4361		assert!(ui.next_wake().is_some());
4362
4363		ui.tick(Duration::from_millis(50));
4364		assert_eq!(ui.height(), 4);
4365		ui.tick(Duration::from_millis(100));
4366		assert_eq!(ui.height(), 6);
4367		assert_eq!(ui.next_wake(), None);
4368		assert!(!ui.tick(Duration::from_millis(200)));
4369	}
4370
4371	#[test]
4372	fn anim_tweens_a_row_width_by_resolving_the_row() {
4373		let mut ui = Ui::from_markup(
4374			"<row><col id=a w=2 h=1 anim=100ms ease=linear bg=#ff0000></col><col h=1 grow \
4375			 bg=#0000ff></col></row>",
4376			6,
4377			UiContext::default(),
4378		)
4379		.unwrap();
4380		let red_cells = |ui: &Ui| {
4381			(0..6u16)
4382				.filter(|&x| ui.frame().cell(x, 0).style.background_color() == Color::Rgb(255, 0, 0))
4383				.count()
4384		};
4385		assert_eq!(red_cells(&ui), 2);
4386
4387		set_prop(&mut ui, "a", Prop::W, PropValue::U16(4));
4388		assert_eq!(red_cells(&ui), 2, "the transition starts from the on-screen width");
4389		ui.tick(Duration::from_millis(50));
4390		assert_eq!(red_cells(&ui), 3);
4391		ui.tick(Duration::from_millis(100));
4392		assert_eq!(red_cells(&ui), 4);
4393		assert_eq!(ui.next_wake(), None);
4394	}
4395
4396	#[test]
4397	fn refit_drops_the_height_watermark_for_a_post_resize_rebuild() {
4398		// A vertical-only terminal shrink: the runtime relayouts (`resize`,
4399		// width unchanged) before the app's `Resized` handler shrinks the
4400		// fixed-height scroll, so the watermark stays pinned at the old
4401		// document height until the pre-rebuild refit drops it. Without the
4402		// refit the rebuild window bottom-anchors into the phantom padding,
4403		// cutting the document top and painting blank rows at the bottom.
4404		let body = "line\n".repeat(40);
4405		let source = format!(
4406			"<col><text>title</text><scroll id=body \
4407			 h=15><text>{body}</text></scroll><text>hud</text></col>"
4408		);
4409		let mut ui = Ui::from_markup(&source, 30, UiContext::default()).unwrap();
4410		assert_eq!(ui.frame().size().height, 17);
4411
4412		ui.resize(30);
4413		assert!(ui.set_height("body", 9));
4414		assert_eq!(ui.height(), 11, "content shrinks with the scroll");
4415		assert_eq!(ui.frame().size().height, 17, "the watermark still pads the frame");
4416
4417		ui.refit();
4418		assert_eq!(ui.frame().size().height, 11, "a rebuild-bound frame matches content");
4419	}
4420
4421	fn overlay_paint(renderer: &mut crate::Renderer<Vec<u8>>) -> String {
4422		let bytes = std::mem::take(renderer.writer_mut());
4423		String::from_utf8(bytes).expect("renderer output is UTF-8")
4424	}
4425
4426	#[test]
4427	fn overlay_composites_centered_and_close_restores_document() {
4428		use crate::test_support::TerminalModel;
4429
4430		let mut ui = Ui::from_markup(
4431			"<col><text>alpha</text><text>beta</text><text>gamma</text></col>",
4432			11,
4433			UiContext::default(),
4434		)
4435		.unwrap();
4436		let mut renderer = crate::Renderer::new(Vec::new());
4437		let mut terminal = TerminalModel::new(11, 3);
4438		let id = ui.show_overlay(
4439			dom! { <text>{"OV"}</text> },
4440			OverlayOptions::default().width(crate::markup::Dim::Cells(2)),
4441		);
4442		assert!(ui.has_damage(), "showing an overlay schedules a present");
4443		ui.present(&mut renderer, 3, 0).unwrap();
4444		terminal.apply(&overlay_paint(&mut renderer));
4445		assert_eq!(terminal.visible_rows()[1], "betaOV", "centered layer over the middle row");
4446		assert!(ui.has_overlay());
4447
4448		assert!(ui.close_overlay(id));
4449		assert!(ui.has_damage(), "closing an overlay schedules a present");
4450		ui.present(&mut renderer, 3, 0).unwrap();
4451		terminal.apply(&overlay_paint(&mut renderer));
4452		assert_eq!(terminal.visible_rows(), ["alpha", "beta", "gamma"]);
4453		assert!(!ui.has_overlay());
4454	}
4455
4456	#[test]
4457	fn z_orders_layers_regardless_of_creation_order() {
4458		use crate::test_support::TerminalModel;
4459
4460		let mut ui = Ui::from_markup(
4461			"<col><text>alpha</text><text>beta</text><text>gamma</text></col>",
4462			11,
4463			UiContext::default(),
4464		)
4465		.unwrap();
4466		let mut renderer = crate::Renderer::new(Vec::new());
4467		let mut terminal = TerminalModel::new(11, 3);
4468		let a_id = ui.show_overlay(
4469			dom! { <text>{"AA"}</text> },
4470			OverlayOptions::default().width(crate::markup::Dim::Cells(2)),
4471		);
4472		ui.show_overlay(
4473			dom! { <text>{"BB"}</text> },
4474			OverlayOptions::default()
4475				.width(crate::markup::Dim::Cells(2))
4476				.z(-1),
4477		);
4478
4479		ui.present(&mut renderer, 3, 0).unwrap();
4480		terminal.apply(&overlay_paint(&mut renderer));
4481		assert_eq!(terminal.visible_rows()[1], "betaAA");
4482		assert_eq!(ui.top_overlay(), Some(a_id));
4483
4484		// input follows z too: a typed key lands in the top-z tree, not the
4485		// newest one
4486		let mut ui = Ui::from_markup("<input id=base/>", 20, UiContext::default()).unwrap();
4487		let high = ui.show_overlay(dom! { <input id=high/> }, OverlayOptions::default());
4488		let low = ui.show_overlay(dom! { <input id=low/> }, OverlayOptions::default().z(-1));
4489		ui.handle_key(Key::Char('x'));
4490		assert_eq!(
4491			ui.overlay(high).expect("high tree").values()["high"],
4492			"x",
4493			"keys land in the top-z layer"
4494		);
4495		assert_eq!(ui.overlay(low).expect("low tree").values()["low"], "");
4496		assert_eq!(ui.values()["base"], "", "base never saw the key");
4497	}
4498
4499	#[test]
4500	fn overlay_captures_keys_and_base_keeps_focus() {
4501		let mut ui = Ui::from_markup("<input id=base/>", 20, UiContext::default()).unwrap();
4502		ui.handle_key(Key::Char('a'));
4503		let id = ui.show_overlay(dom! { <input id=modal/> }, OverlayOptions::default());
4504		ui.handle_key(Key::Char('b'));
4505		assert_eq!(ui.values()["base"], "a", "overlay keys never reach the base tree");
4506		assert_eq!(ui.overlay(id).expect("overlay tree").values()["modal"], "b");
4507
4508		ui.close_overlay(id);
4509		ui.handle_key(Key::Char('c'));
4510		assert_eq!(ui.values()["base"], "ac", "base focus survives the overlay untouched");
4511	}
4512
4513	#[test]
4514	fn focusless_overlay_escape_surfaces_cancel() {
4515		let mut ui = Ui::from_markup("<input id=base/>", 20, UiContext::default()).unwrap();
4516		ui.show_overlay(dom! { <text>{"note"}</text> }, OverlayOptions::default());
4517		assert_eq!(ui.handle_key(Key::Esc), UiEvent::Cancel);
4518		assert!(ui.close_top_overlay().is_some());
4519		assert_eq!(ui.handle_key(Key::Esc), UiEvent::Cancel, "base fallback still cancels");
4520	}
4521
4522	#[test]
4523	fn overlay_occludes_mouse_within_bounds_and_falls_through_outside() {
4524		let mut ui = Ui::from_markup(
4525			"<row><button id=left>aa</button><button id=right>bb</button></row>",
4526			24,
4527			UiContext::default(),
4528		)
4529		.unwrap();
4530		let right_hit = ui
4531			.hits()
4532			.iter()
4533			.find(|hit| {
4534				find_id(ui.root(), "right").is_some_and(|cached| cached.comp().slot() == hit.slot)
4535			})
4536			.copied()
4537			.expect("right button paints a hit region");
4538		let id = ui.show_overlay(
4539			dom! { <text>{"overlay"}</text> },
4540			OverlayOptions::default()
4541				.width(crate::markup::Dim::Cells(right_hit.rect.x))
4542				.col(crate::markup::Dim::Cells(0))
4543				.row(crate::markup::Dim::Cells(0)),
4544		);
4545		let mut renderer = crate::Renderer::new(Vec::new());
4546		ui.present(&mut renderer, 1, 0).unwrap();
4547
4548		assert_eq!(
4549			ui.handle_mouse(1, 0, Mouse::Click),
4550			UiEvent::None,
4551			"click under the layer is occluded"
4552		);
4553		assert_eq!(
4554			ui.handle_mouse(right_hit.rect.x, right_hit.rect.y, Mouse::Click),
4555			UiEvent::Pressed(Str::from("right")),
4556			"click outside the layer falls through to the base tree"
4557		);
4558		ui.close_overlay(id);
4559	}
4560
4561	#[test]
4562	fn hidden_overlay_releases_input_and_compositing() {
4563		use crate::test_support::TerminalModel;
4564
4565		let mut ui = Ui::from_markup("<input id=base/>", 12, UiContext::default()).unwrap();
4566		let mut renderer = crate::Renderer::new(Vec::new());
4567		let mut terminal = TerminalModel::new(12, 1);
4568		let id = ui.show_overlay(
4569			dom! { <text>{"OV"}</text> },
4570			OverlayOptions::default().width(crate::markup::Dim::Cells(2)),
4571		);
4572		ui.present(&mut renderer, 1, 0).unwrap();
4573		terminal.apply(&overlay_paint(&mut renderer));
4574		assert!(terminal.visible_rows()[0].contains("OV"));
4575
4576		assert!(ui.set_overlay_hidden(id, true));
4577		ui.handle_key(Key::Char('x'));
4578		assert_eq!(ui.values()["base"], "x", "hidden overlays release the keyboard");
4579		ui.present(&mut renderer, 1, 0).unwrap();
4580		terminal.apply(&overlay_paint(&mut renderer));
4581		assert!(!terminal.visible_rows()[0].contains("OV"), "hidden overlays stop compositing");
4582
4583		assert!(ui.set_overlay_hidden(id, false));
4584		ui.handle_key(Key::Char('y'));
4585		assert_eq!(ui.values()["base"], "x", "reshown overlays capture the keyboard again");
4586	}
4587
4588	#[test]
4589	#[should_panic(expected = "overlays stack on the presenting Ui")]
4590	fn overlay_tree_rejects_nested_overlays() {
4591		let mut ui = Ui::from_markup("<text>base</text>", 20, UiContext::default()).unwrap();
4592		let id = ui.show_overlay(dom! { <text>{"layer"}</text> }, OverlayOptions::default());
4593		ui.overlay_mut(id)
4594			.expect("overlay tree")
4595			.show_overlay(dom! { <text>{"nested"}</text> }, OverlayOptions::default());
4596	}
4597
4598	#[test]
4599	fn non_modal_layer_leaves_keyboard_with_base_tree() {
4600		let mut ui = Ui::from_markup("<input id=base/>", 20, UiContext::default()).unwrap();
4601		let rail = ui.show_overlay(dom! { <input id=side/> }, OverlayOptions::default().non_modal());
4602		ui.handle_key(Key::Char('x'));
4603		assert_eq!(ui.values()["base"], "x", "keys stay with the base tree");
4604		assert_eq!(ui.overlay(rail).expect("rail tree").values()["side"], "");
4605		assert!(!ui.has_overlay(), "a non-modal layer never holds the alternate screen");
4606		assert_eq!(ui.top_overlay(), None, "no layer receives keys");
4607	}
4608
4609	#[test]
4610	fn focus_overlay_hands_keys_to_layer_and_blur_returns_them() {
4611		let mut ui = Ui::from_markup("<input id=base/>", 20, UiContext::default()).unwrap();
4612		let rail = ui.show_overlay(dom! { <input id=side/> }, OverlayOptions::default().non_modal());
4613		assert!(ui.focus_overlay(rail));
4614		assert_eq!(ui.top_overlay(), Some(rail));
4615		ui.handle_key(Key::Char('x'));
4616		assert_eq!(ui.overlay(rail).expect("rail tree").values()["side"], "x");
4617		assert_eq!(ui.values()["base"], "");
4618
4619		assert_eq!(ui.blur_overlay(), Some(rail));
4620		assert_eq!(ui.focused_overlay(), None);
4621		ui.handle_key(Key::Char('y'));
4622		assert_eq!(ui.values()["base"], "y", "the base tree resumes typing");
4623	}
4624
4625	#[test]
4626	fn unconsumed_escape_blurs_a_focused_non_modal_layer() {
4627		let mut ui = Ui::from_markup("<input id=base/>", 20, UiContext::default()).unwrap();
4628		let rail = ui.show_overlay(
4629			dom! { <button id=side>{"ok"}</button> },
4630			OverlayOptions::default().non_modal(),
4631		);
4632		ui.focus_overlay(rail);
4633		assert_eq!(ui.handle_key(Key::Esc), UiEvent::None, "the blur consumes the escape");
4634		assert_eq!(ui.focused_overlay(), None);
4635		assert!(ui.overlay(rail).is_some(), "nothing is dismissed");
4636		ui.handle_key(Key::Char('z'));
4637		assert_eq!(ui.values()["base"], "z");
4638	}
4639
4640	#[test]
4641	fn modal_overlay_outranks_a_focused_non_modal_layer() {
4642		let mut ui = Ui::from_markup("<input id=base/>", 20, UiContext::default()).unwrap();
4643		let rail = ui.show_overlay(dom! { <input id=side/> }, OverlayOptions::default().non_modal());
4644		ui.focus_overlay(rail);
4645		let dialog = ui.show_overlay(dom! { <input id=modal/> }, OverlayOptions::default());
4646		assert!(ui.has_overlay());
4647		ui.handle_key(Key::Char('m'));
4648		assert_eq!(ui.overlay(dialog).expect("dialog tree").values()["modal"], "m");
4649		assert_eq!(ui.overlay(rail).expect("rail tree").values()["side"], "");
4650
4651		ui.close_overlay(dialog);
4652		ui.handle_key(Key::Char('r'));
4653		assert_eq!(
4654			ui.overlay(rail).expect("rail tree").values()["side"],
4655			"r",
4656			"the focused pane resumes when the modal closes"
4657		);
4658	}
4659
4660	#[test]
4661	fn click_moves_keyboard_between_pane_and_document() {
4662		let mut ui = Ui::from_markup("<input id=base/>", 20, UiContext::default()).unwrap();
4663		let rail = ui.show_overlay(
4664			dom! { <input id=side/> },
4665			OverlayOptions::default()
4666				.non_modal()
4667				.width(crate::markup::Dim::Cells(6))
4668				.col(crate::markup::Dim::Cells(14))
4669				.row(crate::markup::Dim::Cells(0)),
4670		);
4671		let mut renderer = crate::Renderer::new(Vec::new());
4672		ui.present(&mut renderer, 1, 0).unwrap();
4673
4674		ui.handle_mouse(15, 0, Mouse::Click);
4675		assert_eq!(ui.focused_overlay(), Some(rail), "a click inside the band focuses the pane");
4676		ui.handle_key(Key::Char('x'));
4677		assert_eq!(ui.overlay(rail).expect("rail tree").values()["side"], "x");
4678
4679		ui.handle_mouse(2, 0, Mouse::Click);
4680		assert_eq!(ui.focused_overlay(), None, "a click outside returns the keyboard");
4681		ui.handle_key(Key::Char('y'));
4682		assert_eq!(ui.values()["base"], "y");
4683	}
4684
4685	#[test]
4686	fn hiding_or_closing_the_focused_layer_returns_the_keyboard() {
4687		let mut ui = Ui::from_markup("<input id=base/>", 20, UiContext::default()).unwrap();
4688		let rail = ui.show_overlay(dom! { <input id=side/> }, OverlayOptions::default().non_modal());
4689		ui.focus_overlay(rail);
4690		assert!(ui.set_overlay_hidden(rail, true));
4691		assert_eq!(ui.focused_overlay(), None);
4692		ui.handle_key(Key::Char('x'));
4693		assert_eq!(ui.values()["base"], "x");
4694
4695		assert!(ui.set_overlay_hidden(rail, false));
4696		ui.focus_overlay(rail);
4697		assert!(ui.close_overlay(rail));
4698		assert_eq!(ui.focused_overlay(), None);
4699		ui.handle_key(Key::Char('y'));
4700		assert_eq!(ui.values()["base"], "xy");
4701	}
4702
4703	#[test]
4704	fn fill_height_stretches_layer_to_the_viewport_band() {
4705		use crate::test_support::TerminalModel;
4706
4707		let mut ui = Ui::from_markup(
4708			"<col><text>aaaa</text><text>bbbb</text><text>cccc</text><text>dddd</text></col>",
4709			11,
4710			UiContext::default(),
4711		)
4712		.unwrap();
4713		let mut renderer = crate::Renderer::new(Vec::new());
4714		let mut terminal = TerminalModel::new(11, 4);
4715		ui.show_overlay(
4716			dom! {
4717				<col>
4718					<text>{"A"}</text>
4719					<spacer grow/>
4720					<text>{"Z"}</text>
4721				</col>
4722			},
4723			OverlayOptions::default()
4724				.non_modal()
4725				.fill_height()
4726				.anchor(crate::OverlayAnchor::Right)
4727				.width(crate::markup::Dim::Cells(1)),
4728		);
4729		ui.present(&mut renderer, 4, 0).unwrap();
4730		terminal.apply(&overlay_paint(&mut renderer));
4731		let rows = terminal.visible_rows();
4732		assert_eq!(rows[0], "aaaa      A", "the band spans the full viewport height");
4733		assert_eq!(rows[3], "dddd      Z", "grow pins the tail to the band bottom");
4734	}
4735
4736	#[test]
4737	fn close_active_overlay_targets_the_modal_beneath_a_higher_z_pane() {
4738		let mut ui = Ui::from_markup("<input id=base/>", 20, UiContext::default()).unwrap();
4739		let rail =
4740			ui.show_overlay(dom! { <input id=side/> }, OverlayOptions::default().non_modal().z(10));
4741		let dialog = ui.show_overlay(dom! { <text>{"confirm"}</text> }, OverlayOptions::default());
4742		assert_eq!(ui.top_overlay(), Some(dialog), "keys target the modal, not the stack top");
4743
4744		assert_eq!(ui.handle_key(Key::Esc), UiEvent::Cancel);
4745		assert_eq!(ui.close_active_overlay(), Some(dialog));
4746		assert!(ui.overlay(dialog).is_none());
4747		assert!(ui.overlay(rail).is_some(), "the higher-z pane survives the dismissal");
4748
4749		ui.focus_overlay(rail);
4750		assert_eq!(
4751			ui.close_active_overlay(),
4752			Some(rail),
4753			"with no modal left, the focused pane is the active layer"
4754		);
4755		assert_eq!(ui.close_active_overlay(), None, "an empty stack has no active layer");
4756	}
4757
4758	/// Column of the caret shown by the most recent paint, `None` when the
4759	/// paint left the caret hidden.
4760	fn shown_cursor_col(output: &str) -> Option<u16> {
4761		let show = output.rfind("\x1b[?25h")?;
4762		let segment = &output[..show];
4763		let segment = &segment[segment.rfind('\r')? + 1..];
4764		if segment.is_empty() {
4765			return Some(0);
4766		}
4767		segment
4768			.strip_prefix("\x1b[")?
4769			.strip_suffix('C')?
4770			.parse()
4771			.ok()
4772	}
4773
4774	#[test]
4775	fn hardware_cursor_follows_the_keyboard_between_base_and_pane() {
4776		let mut ui = Ui::from_markup("<input id=base/>", 20, UiContext::default()).unwrap();
4777		let rail = ui.show_overlay(
4778			dom! { <input id=side/> },
4779			OverlayOptions::default()
4780				.non_modal()
4781				.width(crate::markup::Dim::Cells(6))
4782				.col(crate::markup::Dim::Cells(14))
4783				.row(crate::markup::Dim::Cells(0)),
4784		);
4785		let mut renderer = crate::Renderer::new(Vec::new());
4786
4787		ui.present(&mut renderer, 1, 0).unwrap();
4788		let col = shown_cursor_col(&overlay_paint(&mut renderer))
4789			.expect("the base caret shows through a passive pane");
4790		assert!(col < 14, "caret sits in the composer, got column {col}");
4791
4792		ui.focus_overlay(rail);
4793		ui.present(&mut renderer, 1, 0).unwrap();
4794		let col =
4795			shown_cursor_col(&overlay_paint(&mut renderer)).expect("the focused pane owns the caret");
4796		assert!(col >= 14, "caret sits in the pane band, got column {col}");
4797
4798		ui.blur_overlay();
4799		ui.present(&mut renderer, 1, 0).unwrap();
4800		let col = shown_cursor_col(&overlay_paint(&mut renderer))
4801			.expect("blurring returns the caret to the composer");
4802		assert!(col < 14, "caret back in the composer, got column {col}");
4803	}
4804}