Skip to main content

omp_tui/
component.rs

1//! Retained component model: identity, cached geometry, events, and element
2//! factories.
3
4use std::{
5	any::Any,
6	f32::consts::{PI, TAU},
7	fmt,
8	sync::{
9		Arc,
10		atomic::{AtomicU32, Ordering},
11	},
12	time::Duration,
13};
14
15use omp_core::Str;
16use smallvec::SmallVec;
17use xutf::Text as _;
18
19use crate::{
20	anim::{self, Easing, Lerp, Tween},
21	components::Markdown,
22	context::UiContext,
23	frame::{Color, Frame, Gradient, Rect, Style},
24	input::{Key, Mouse, UiEvent},
25	markup::{Align, Dim},
26	props::{Prop, PropValue, Props},
27};
28
29/// Stable component identity. A slot is never an arena index.
30pub type Slot = u32;
31
32static NEXT_SLOT: AtomicU32 = AtomicU32::new(1);
33
34/// Allocates a fresh [`Slot`] — every component constructor (including
35/// external [`Component`] implementations) takes its identity from here.
36pub fn next_slot() -> Slot {
37	NEXT_SLOT.fetch_add(1, Ordering::Relaxed)
38}
39
40/// Result of routing an input event through a component.
41#[derive(Clone, Debug, Eq, PartialEq)]
42pub enum Flow {
43	/// The component did not consume the event.
44	Skip,
45	/// The component consumed the event without emitting an application event.
46	Consumed,
47	/// The component consumed the event and emitted an application event.
48	Event(UiEvent),
49}
50
51/// A vertical stack exposed by [`Component::resize_tail`] for bottom-up
52/// viewport composition during a resize drag.
53pub struct ResizeTail<'a> {
54	/// Stacked children, last entry bottommost.
55	pub children: &'a mut [Cached],
56	/// Vertical gap between adjacent children.
57	pub gap:      u16,
58}
59
60/// A retained UI component.
61pub trait Component: Any {
62	/// Component properties.
63	fn props(&self) -> &Props;
64	/// Mutable component properties.
65	fn props_mut(&mut self) -> &mut Props;
66	/// Stable identity.
67	fn slot(&self) -> Slot;
68	/// Concrete implementation name for debug tooling; defaults to the
69	/// type's full path via [`std::any::type_name`]. The `OMP_TUI_DEBUG`
70	/// tree dump trims it to the trailing path segment.
71	fn kind(&self) -> &'static str {
72		std::any::type_name::<Self>()
73	}
74	/// Every owned child, including embedded inactive subtrees.
75	fn children(&self) -> &[Cached] {
76		&[]
77	}
78	/// Every owned child, including embedded inactive subtrees.
79	fn children_mut(&mut self) -> &mut [Cached] {
80		&mut []
81	}
82	/// Content width bounds, exclusive of this component's chrome.
83	fn measure(&mut self, ctx: &UiContext) -> (u16, u16);
84	/// Content height at the supplied content width.
85	fn height(&mut self, ctx: &UiContext, width: u16) -> u16;
86	/// Places children inside the supplied content rectangle.
87	fn place(&mut self, ctx: &UiContext, content: Rect) {
88		let _ = (ctx, content);
89	}
90	/// Paints content inside the supplied content rectangle.
91	fn paint(&mut self, pc: &mut PaintCtx<'_>, rect: Rect);
92	/// Whether [`Cached`] should paint this component's border property as
93	/// chrome.
94	fn paints_border(&self) -> bool {
95		true
96	}
97	/// Whether [`Cached`] should paint this component's background property
98	/// across its rectangle. Components that own a partial background fill opt
99	/// out and paint it themselves.
100	fn paints_background(&self) -> bool {
101		true
102	}
103	/// A narrowed projection frame for two-stop `fg=`/`bg=` ramps, given the
104	/// painted content rectangle. `None` projects across the full chrome;
105	/// content-sized leaves ([`crate::components::Pre`]) return their authored
106	/// extent so the ramp completes across the glyphs instead of the
107	/// stretched layout width.
108	fn gradient_bounds(&self, content: Rect) -> Option<Rect> {
109		let _ = content;
110		None
111	}
112	/// Opt-in viewport-tail provider for resize drags: children a drag
113	/// frame may compose bottom-up, with the vertical gap between them.
114	/// `None` — the default — renders this component whole.
115	///
116	/// [`crate::Ui::compose_resize_tail`] walks providers backward at the
117	/// new width, laying out and painting only enough children to fill one
118	/// screen per drag frame; full-document reflow waits for the settled
119	/// [`crate::Ui::resize`]. Only chrome-neutral vertical flows may opt
120	/// in: the walk skips the provider's own background, border, and
121	/// alignment, so styled containers must return `None` and render
122	/// normally ([`crate::components::Col`] gates itself accordingly).
123	fn resize_tail(&mut self) -> Option<ResizeTail<'_>> {
124		None
125	}
126	/// A validation error blocking submission — an unmet `required` or
127	/// `match=` constraint owned by this component's internal records;
128	/// `None` when valid. Component-level props are checked by the caller.
129	fn validation_error(&self) -> Option<String> {
130		None
131	}
132	/// Whether a row should stretch this component across its cross axis.
133	fn stretch_in_row(&self) -> bool {
134		false
135	}
136	/// Whether this component contributes its own slot to the focus ring.
137	/// The default honors the `focus` flag, so any container can opt into
138	/// keyboard navigation from markup.
139	fn focusable(&self) -> bool {
140		self.props().flag(Prop::Focus)
141	}
142	/// Positions internal selection when focus enters from either direction.
143	fn enter(&mut self, forward: bool) {
144		let _ = forward;
145	}
146	/// Appends focusable slots in document order.
147	fn ring(&self, out: &mut Vec<Slot>) {
148		if self.focusable() {
149			out.push(self.slot());
150		}
151		for child in self.children().iter().filter(|child| child.visible) {
152			child.comp.ring(out);
153		}
154	}
155	/// Handles a keyboard event.
156	fn key(&mut self, ec: &mut EventCtx<'_>, key: Key) -> Flow {
157		let _ = (ec, key);
158		Flow::Skip
159	}
160	/// Handles a mouse gesture over one of this component's hit regions.
161	fn mouse(
162		&mut self,
163		ec: &mut EventCtx<'_>,
164		tag: HitTag,
165		at: (u16, u16),
166		rect: Rect,
167		mouse: Mouse,
168	) -> Flow {
169		let _ = (ec, tag, at, rect, mouse);
170		Flow::Skip
171	}
172	/// Pastes text into this component: [`Flow::Consumed`] repaints,
173	/// [`Flow::Event`] additionally surfaces a [`crate::UiEvent`], and
174	/// [`Flow::Skip`] leaves the paste unhandled.
175	fn paste(&mut self, ec: &mut EventCtx<'_>, text: &str) -> Flow {
176		let _ = (ec, text);
177		Flow::Skip
178	}
179	/// Pastes text verbatim, bypassing any smart-paste interpretation the
180	/// component applies in [`Component::paste`] (drop classification,
181	/// large-paste collapse). Defaults to [`Component::paste`] for
182	/// components without such interpretation.
183	fn paste_raw(&mut self, ec: &mut EventCtx<'_>, text: &str) -> Flow {
184		self.paste(ec, text)
185	}
186	/// Adds this component's named value to an output object.
187	fn value(&self, out: &mut serde_json::Map<String, serde_json::Value>) {
188		let _ = out;
189	}
190	/// Replaces text content where supported.
191	fn set_text(&mut self, ctx: &UiContext, text: Str) -> bool {
192		let _ = (ctx, text);
193		false
194	}
195}
196impl dyn Component {
197	pub(crate) fn is<T: Component>(&self) -> bool {
198		(self as &dyn Any).is::<T>()
199	}
200
201	#[cfg(test)]
202	pub(crate) fn downcast_ref<T: Component>(&self) -> Option<&T> {
203		(self as &dyn Any).downcast_ref()
204	}
205
206	pub(crate) fn downcast_mut<T: Component>(&mut self) -> Option<&mut T> {
207		(self as &mut dyn Any).downcast_mut()
208	}
209}
210
211/// Memo key for context-derived output: component version, process-wide
212/// width-config epoch, and the owning [`UiContext`]'s cache revision.
213///
214/// Any memo holding output that depends on the context (theme, charset,
215/// glyph widths) compares against a freshly captured key, so a revision
216/// bump from [`crate::Ui::set_context`] or a width-policy change discards
217/// it.
218#[derive(Clone, Copy, PartialEq, Eq)]
219pub struct MemoKey {
220	version:     u64,
221	width_epoch: u64,
222	revision:    u64,
223}
224
225impl MemoKey {
226	/// Captures the key for a component at `version` under `ctx`.
227	pub(crate) fn new(version: u64, ctx: &UiContext) -> Self {
228		Self { version, width_epoch: crate::rich::width_config_epoch(), revision: ctx.revision }
229	}
230}
231
232/// A component with memoized geometry, its last placed rectangle, and any
233/// in-flight property transitions.
234pub struct Cached {
235	comp:        Box<dyn Component>,
236	/// Last outer rectangle assigned by layout.
237	pub rect:    Rect,
238	/// Whether the component participates in layout, paint, and focus.
239	pub visible: bool,
240	version:     u64,
241	measured:    Option<(MemoKey, (u16, u16))>,
242	laid:        Option<(MemoKey, u16, u16)>,
243	anim:        Option<Box<AnimState>>,
244}
245
246impl Cached {
247	/// Wraps a component with empty geometry caches.
248	pub fn new(comp: Box<dyn Component>) -> Self {
249		Self {
250			comp,
251			rect: Rect::new(0, 0, 0, 0),
252			visible: true,
253			version: 0,
254			measured: None,
255			laid: None,
256			anim: None,
257		}
258	}
259
260	/// Returns memoized outer width bounds, including padding and border.
261	///
262	/// The process-wide width epoch and the context revision are part of the
263	/// memo key, so changing the terminal's Jamo policy or swapping the
264	/// presentation context remeasures unchanged component trees.
265	pub fn measure(&mut self, ctx: &UiContext) -> (u16, u16) {
266		let key = MemoKey::new(self.version, ctx);
267		if let Some((cached, measured)) = self.measured
268			&& cached == key
269		{
270			return measured;
271		}
272		let (mut min, mut nat) = self.comp.measure(ctx);
273		let extra = horizontal_inset(self.comp.props(), self.comp.paints_border()).saturating_mul(2);
274		min = min.saturating_add(extra);
275		nat = nat.saturating_add(extra).max(min);
276		let measured = (min, nat);
277		self.measured = Some((key, measured));
278		measured
279	}
280
281	/// Returns memoized outer height at `width`, including padding and border.
282	pub fn height(&mut self, ctx: &UiContext, width: u16) -> u16 {
283		let key = MemoKey::new(self.version, ctx);
284		if let Some((cached, laid_width, height)) = self.laid
285			&& cached == key
286			&& laid_width == width
287		{
288			return height;
289		}
290		let fixed = self.sampled_h(ctx);
291		let paints_border = self.comp.paints_border();
292		let x_inset = horizontal_inset(self.comp.props(), paints_border);
293		let y_inset = vertical_inset(self.comp.props(), paints_border);
294		let height = if let Some(fixed) = fixed {
295			fixed
296		} else {
297			let content_width = width.saturating_sub(x_inset.saturating_mul(2));
298			let minimum = self
299				.measure(ctx)
300				.0
301				.saturating_sub(x_inset.saturating_mul(2));
302			self
303				.comp
304				.height(ctx, content_width.max(minimum).max(1))
305				.saturating_add(y_inset.saturating_mul(2))
306		};
307		// `lift` reserves headroom above the resting chrome.
308		let height = height.saturating_add(self.comp.props().lift());
309		if self.size_settled(ctx.now) {
310			self.laid = Some((key, width, height));
311		}
312		height
313	}
314
315	/// Stores the outer rectangle and places children inside its content box.
316	///
317	/// A `lift` component rests at the bottom of its rectangle; the reserved
318	/// headroom above is where the chrome rises while hovered.
319	pub fn place(&mut self, ctx: &UiContext, rect: Rect) {
320		self.rect = rect;
321		let props = self.comp.props();
322		let chrome = lifted_rect(rect, props.lift(), 0);
323		let content = content_rect(chrome, props, self.comp.paints_border());
324		self.comp.place(ctx, content);
325	}
326
327	/// Paints border and content, then restores this component's background.
328	///
329	/// When `anim`/`spin` properties are live, sampled solid colors are
330	/// swapped into the props for the duration of this call and restored
331	/// afterward, so content and chrome both paint the mid-flight value.
332	/// Hover chrome follows the pointer or keyboard focus: a ramped `hover`
333	/// under a live pointer renders as a tracking glow over the resting
334	/// chrome, under keyboard focus it blooms from the chrome's center
335	/// until it blankets the ring, every other hovered case swaps into the
336	/// border slot, and `lift` raises the chrome into the headroom its
337	/// rectangle reserves, leaving a shadow.
338	pub fn paint(&mut self, pc: &mut PaintCtx<'_>) {
339		let own = self.comp.slot();
340		let decorated = self.comp.props().hover_decorated();
341		let pointer_hovered = decorated && pc.hover.is_some_and(|(slot, _)| self.contains_slot(slot));
342		let hovered = pointer_hovered || (decorated && pc.keyboard && pc.focus == Some(own));
343		let mut glow = if pointer_hovered {
344			self.border_glow(pc)
345		} else if hovered {
346			self.focus_glow(pc)
347		} else {
348			None
349		};
350		let hover_swap = if hovered && glow.is_none() {
351			self.swap_hover_chrome()
352		} else {
353			None
354		};
355		let anim = self.begin_paint(pc.ctx, pc.now);
356		let chrome_anim = anim
357			.as_ref()
358			.map_or_else(ChromeAnim::default, |paint| paint.chrome);
359		let rect = self.rect;
360		if decorated {
361			// The zone spans the resting rectangle no matter the current
362			// rise, so hover cannot oscillate at the lifted edge.
363			pc.hits.push(Hit { rect, slot: own, tag: HitTag::Zone });
364		}
365		let lift = self.comp.props().lift();
366		let (risen, rise) = if lift == 0 {
367			(0, f32::from(u8::from(hovered)))
368		} else {
369			self.lift_rise(pc, hovered, lift)
370		};
371		let chrome = lifted_rect(rect, lift, risen);
372		if let Some(glow) = glow.as_mut()
373			&& glow.focus
374		{
375			// The keyboard bloom keeps the declared pace so the spread
376			// stays legible even when the lift hops at keyboard speed,
377			// radiating from the chrome as placed this frame.
378			glow.strength = self.focus_bloom(pc);
379			glow.pointer =
380				(chrome.x.saturating_add(chrome.width / 2), chrome.y.saturating_add(chrome.height / 2));
381		} else {
382			if let Some(glow) = glow.as_mut() {
383				// The pointer glow lands on the elevation's own ease.
384				glow.strength = rise;
385			}
386			if let Some(state) = self.anim.as_deref_mut() {
387				// Whenever the focus glow is not the active chrome the bloom
388				// resets, so the next keyboard focus spreads from zero again.
389				state.bloom = None;
390			}
391		}
392		let paints_border = self.comp.paints_border();
393		if lift > 0 {
394			// The chrome moved off its resting placement: children follow.
395			self
396				.comp
397				.place(pc.ctx, content_rect(chrome, self.comp.props(), paints_border));
398		}
399		let props = self.comp.props();
400		if props.border().is_some() && paints_border {
401			paint_border(pc, chrome, props, chrome_anim, glow);
402		}
403		let content = content_rect(chrome, props, paints_border);
404		// A fixed height is a hard budget: content taller than the box —
405		// including every mid-flight sample of an animated `h` — clips at
406		// the content bottom instead of overpainting the border and the
407		// rows below.
408		let outer_clip = pc.clip;
409		if props.h().is_some() {
410			pc.clip = pc.clip.min(content.y.saturating_add(content.height));
411		}
412		self.comp.paint(pc, content);
413		pc.clip = outer_clip;
414		paint_gradients(
415			pc,
416			chrome,
417			self.comp.gradient_bounds(content),
418			self.comp.props(),
419			paints_border,
420			self.comp.paints_background(),
421			chrome_anim,
422		);
423		if risen > 0 {
424			paint_lift_shadow(pc, chrome, rect);
425		}
426		if glow.is_some() && pointer_hovered {
427			// The pointer glow shimmers and re-reads the cursor between
428			// events; the keyboard bloom rides the finite lift tween and
429			// settles silent, so focus never pins a repaint loop.
430			pc.wake(own, pc.now.saturating_add(anim::FRAME));
431		}
432		if let Some(anim) = anim {
433			self.end_paint(pc, anim);
434		}
435		if let Some((prop, displaced)) = hover_swap {
436			match displaced {
437				Some(value) => self.comp.props_mut().set(prop, value),
438				None => self.comp.props_mut().unset(prop),
439			}
440		}
441	}
442
443	/// Invalidates both geometry memos.
444	pub const fn invalidate(&mut self) {
445		self.version = self.version.wrapping_add(1);
446		self.measured = None;
447		self.laid = None;
448	}
449
450	/// Mutates a component selected by slot and invalidates a dirty ancestor
451	/// path.
452	pub fn update<R>(&mut self, slot: Slot, f: impl FnOnce(&mut Self) -> (R, bool)) -> Option<R> {
453		let mut f = Some(f);
454		self
455			.update_where(&|cached| cached.comp.slot() == slot, &mut f)
456			.map(|(value, _)| value)
457	}
458
459	/// Mutates a component selected by `id` and invalidates a dirty ancestor
460	/// path.
461	pub fn update_id<R>(&mut self, id: &str, f: impl FnOnce(&mut Self) -> (R, bool)) -> Option<R> {
462		let mut f = Some(f);
463		self
464			.update_where(
465				&|cached| {
466					cached
467						.comp
468						.props()
469						.id()
470						.is_some_and(|candidate| candidate == id)
471				},
472				&mut f,
473			)
474			.map(|(value, _)| value)
475	}
476
477	fn update_where<R, P, F>(&mut self, predicate: &P, f: &mut Option<F>) -> Option<(R, bool)>
478	where
479		P: Fn(&Self) -> bool,
480		F: FnOnce(&mut Self) -> (R, bool),
481	{
482		if predicate(self) {
483			let (value, dirty) = f.take().expect("update closure reused")(self);
484			if dirty {
485				self.invalidate();
486			}
487			return Some((value, dirty));
488		}
489		let result = self
490			.comp
491			.children_mut()
492			.iter_mut()
493			.find_map(|child| child.update_where(predicate, f));
494		if result.as_ref().is_some_and(|(_, dirty)| *dirty) {
495			self.invalidate();
496		}
497		result
498	}
499
500	/// Finds a component by slot without invalidating geometry.
501	pub fn find_slot(&mut self, slot: Slot) -> Option<&mut Self> {
502		if self.comp.slot() == slot {
503			return Some(self);
504		}
505		for child in self.comp.children_mut() {
506			if let Some(found) = child.find_slot(slot) {
507				return Some(found);
508			}
509		}
510		None
511	}
512
513	/// Borrows the wrapped component.
514	pub fn comp(&self) -> &dyn Component {
515		self.comp.as_ref()
516	}
517
518	/// Borrows the wrapped component mutably; callers must invalidate after
519	/// mutation.
520	pub fn comp_mut(&mut self) -> &mut dyn Component {
521		self.comp.as_mut()
522	}
523
524	/// Consumes the cache and returns its wrapped component.
525	pub(crate) fn into_comp(self) -> Box<dyn Component> {
526		self.comp
527	}
528
529	/// The component's style with any mid-flight color transition applied —
530	/// what [`crate::Ui`] clears a subtree rectangle with before repainting.
531	pub(crate) fn fill_style(&mut self, ctx: &UiContext, now: Duration) -> Style {
532		let paint = self.begin_paint(ctx, now);
533		let style = self.comp.props().style(&ctx.theme);
534		if let Some(paint) = paint {
535			self.restore_props(paint.saved);
536		}
537		style
538	}
539
540	/// The width request row layout consumes, sampling an active size
541	/// transition. Unit changes (cells ↔ percent) snap.
542	pub(crate) fn w(&mut self, ctx: &UiContext) -> Option<Dim> {
543		let target = self.comp.props().w();
544		let Some((duration, easing)) = self.anim_spec() else {
545			return target;
546		};
547		let state = self.anim.get_or_insert_default();
548		let Some(target) = target else {
549			state.w = None;
550			return None;
551		};
552		let (pct, goal) = match target {
553			Dim::Pct(percent) => (true, u16::from(percent)),
554			Dim::Cells(cells) => (false, cells),
555		};
556		let tween = match &mut state.w {
557			Some((unit, tween)) if *unit == pct => tween,
558			slot => &mut slot.insert((pct, Tween::settled(goal))).1,
559		};
560		tween.retarget(ctx.now, goal, duration, easing);
561		let sampled = tween.sample(ctx.now);
562		Some(if pct {
563			Dim::Pct(sampled.min(100) as u8)
564		} else {
565			Dim::Cells(sampled)
566		})
567	}
568
569	/// The fixed height request, sampling an active size transition.
570	fn sampled_h(&mut self, ctx: &UiContext) -> Option<u16> {
571		let target = self.comp.props().h();
572		let Some((duration, easing)) = self.anim_spec() else {
573			return target;
574		};
575		let state = self.anim.get_or_insert_default();
576		let Some(target) = target else {
577			state.h = None;
578			return None;
579		};
580		let tween = state.h.get_or_insert_with(|| Tween::settled(target));
581		tween.retarget(ctx.now, target, duration, easing);
582		Some(tween.sample(ctx.now))
583	}
584
585	/// Whether no size transition is mid-flight — geometry memos are only
586	/// trustworthy while sizes hold still.
587	fn size_settled(&self, now: Duration) -> bool {
588		self.anim.as_deref().is_none_or(|state| {
589			state.h.is_none_or(|tween| tween.is_settled(now))
590				&& state.w.is_none_or(|(_, tween)| tween.is_settled(now))
591		})
592	}
593
594	/// The `anim` transition spec, when declared.
595	fn anim_spec(&self) -> Option<(Duration, Easing)> {
596		let props = self.comp.props();
597		Some((props.anim()?, props.ease()))
598	}
599
600	/// Retargets every animatable channel at `now` and captures this paint's
601	/// sampled overrides. `None` means fully settled: nothing swapped, no
602	/// wake owed.
603	fn begin_paint(&mut self, ctx: &UiContext, now: Duration) -> Option<PaintAnim> {
604		let props = self.comp.props();
605		let spec = props.anim().map(|duration| (duration, props.ease()));
606		let spin = props.spin();
607		if spec.is_none() && spin.is_none() && self.anim.is_none() {
608			return None;
609		}
610		if spec.is_some() {
611			// Sizes retarget here too: layout only consults them through
612			// row solving and fixed heights, so paint is the change
613			// detector that starts (and keeps) the layout-wake chain.
614			let _ = self.sampled_h(ctx);
615			let _ = self.w(ctx);
616		}
617		let props = self.comp.props();
618		let mut paint = PaintAnim::default();
619
620		// Spin is pure phase arithmetic on the shared clock: no retained
621		// state, and stepping stays aligned no matter when it is sampled.
622		if let Some(period) = spin
623			&& (props.gradient_of(Prop::Fg).is_some()
624				|| props.gradient_of(Prop::Bg).is_some()
625				|| props.gradient_of(Prop::On).is_some()
626				|| props.gradient_of(bc_slot(props)).is_some())
627		{
628			let nanos = period.as_nanos().max(1);
629			paint.chrome.angle = ((now.as_nanos() % nanos) * 360 / nanos) as u16;
630			let step = Duration::from_nanos((nanos / 360) as u64).max(anim::FRAME);
631			paint.merge_wake(now.saturating_add(step));
632		}
633
634		if let Some((duration, easing)) = spec {
635			let bg_prop = if props.get(Prop::Bg).is_some() {
636				Prop::Bg
637			} else {
638				Prop::On
639			};
640			let bc_prop = bc_slot(props);
641			let fg_target = color_target(ctx, props, Prop::Fg);
642			let bg_target = color_target(ctx, props, bg_prop);
643			let bc_target = color_target(ctx, props, bc_prop);
644			let state = self.anim.get_or_insert_default();
645			state.fg.retarget(now, fg_target, duration, easing);
646			state.bg.retarget(now, bg_target, duration, easing);
647			state.bc.retarget(now, bc_target, duration, easing);
648			paint.chrome.fg = paint.apply(self.comp.as_mut(), &state.fg, Prop::Fg, now);
649			paint.chrome.bg = paint.apply(self.comp.as_mut(), &state.bg, bg_prop, now);
650			paint.chrome.bc = paint.apply(self.comp.as_mut(), &state.bc, bc_prop, now);
651			for settles in
652				[state.h.map(|tween| tween.settles_at()), state.w.map(|(_, tween)| tween.settles_at())]
653					.into_iter()
654					.flatten()
655					.filter(|&settles| settles > now)
656			{
657				paint.relayout = true;
658				paint.merge_wake(settles.min(now.saturating_add(anim::FRAME)));
659			}
660		} else {
661			// `anim` was removed mid-flight: drop transition state and snap.
662			self.anim = None;
663		}
664
665		if paint.wake.is_none() {
666			None
667		} else {
668			Some(paint)
669		}
670	}
671
672	/// Restores swapped targets and requests the next animation frame.
673	fn end_paint(&mut self, pc: &mut PaintCtx<'_>, paint: PaintAnim) {
674		let PaintAnim { saved, wake, relayout, .. } = paint;
675		self.restore_props(saved);
676		if let Some(at) = wake {
677			let slot = self.comp.slot();
678			if relayout {
679				pc.wake_layout(slot, at);
680			} else {
681				pc.wake(slot, at);
682			}
683		}
684	}
685
686	fn restore_props(&mut self, saved: SmallVec<(Prop, PropValue), 3>) {
687		for (prop, value) in saved {
688			self.comp.props_mut().set(prop, value);
689		}
690	}
691
692	/// Whether this component or any descendant owns `slot`.
693	pub(crate) fn contains_slot(&self, slot: Slot) -> bool {
694		self.comp.slot() == slot
695			|| self
696				.comp
697				.children()
698				.iter()
699				.any(|child| child.contains_slot(slot))
700	}
701
702	/// Swaps the `hover` chrome into the border-color slot for this paint:
703	/// borders, `anim` transitions, and `spin` all read the hovered value
704	/// through the ordinary property paths. Returns the displaced slot for
705	/// restore once the paint completes.
706	fn swap_hover_chrome(&mut self) -> Option<(Prop, Option<PropValue>)> {
707		let props = self.comp.props();
708		let hover = props.get(Prop::Hover).cloned()?;
709		let slot = bc_slot(props);
710		let displaced = props.get(slot).cloned();
711		self.comp.props_mut().set(slot, hover);
712		Some((slot, displaced))
713	}
714
715	/// The pointer-tracking border glow: a ramped `hover` under a live
716	/// pointer, resolved to its endpoint colors.
717	fn border_glow(&self, pc: &PaintCtx<'_>) -> Option<BorderGlow> {
718		let pointer = pc.pointer?;
719		let (start, end) = self.hover_ramp(pc)?;
720		Some(BorderGlow { pointer, start, end, strength: 1.0, focus: false })
721	}
722
723	/// The keyboard-focus border glow: the same ramp anchored at the chrome
724	/// center (filled in once this frame's rise is known), blooming outward
725	/// with the eased rise instead of hugging a pointer.
726	fn focus_glow(&self, pc: &PaintCtx<'_>) -> Option<BorderGlow> {
727		let (start, end) = self.hover_ramp(pc)?;
728		Some(BorderGlow { pointer: (0, 0), start, end, strength: 1.0, focus: true })
729	}
730
731	/// The `hover` ramp's resolved endpoint colors, when `hover` is a ramp.
732	fn hover_ramp(&self, pc: &PaintCtx<'_>) -> Option<(Color, Color)> {
733		let value = self.comp.props().gradient_of(Prop::Hover)?;
734		let (start, end) = value.split_once("..")?;
735		let resolve = |color: &str| pc.ctx.theme.token(color).or_else(|| Color::parse(color));
736		Some((resolve(start)?, resolve(end)?))
737	}
738
739	/// The rows the chrome currently rises above its resting position and
740	/// the eased rise fraction: driven by the `anim` clock when declared,
741	/// snapped otherwise. Keyboard focus hops echo input — half the
742	/// declared duration (capped at [`KEY_SNAP`]) leading with velocity —
743	/// while pointer hovers keep the declared pace; the pace in force when
744	/// the target flips wins, since a matching retarget is a no-op.
745	fn lift_rise(&mut self, pc: &mut PaintCtx<'_>, hovered: bool, lift: u16) -> (u16, f32) {
746		let target = if hovered { f32::from(lift) } else { 0.0 };
747		let Some((duration, easing)) = self.anim_spec() else {
748			return if hovered { (lift, 1.0) } else { (0, 0.0) };
749		};
750		let (duration, easing) = if pc.keyboard {
751			((duration / 2).min(KEY_SNAP), Easing::EaseOut)
752		} else {
753			(duration, easing)
754		};
755		let state = self.anim.get_or_insert_default();
756		let tween = state.lift.get_or_insert_with(|| Tween::settled(0.0));
757		tween.retarget(pc.now, target, duration, easing);
758		let sample = tween.sample(pc.now).clamp(0.0, f32::from(lift));
759		if !tween.is_settled(pc.now) {
760			let at = tween.settles_at().min(pc.now.saturating_add(anim::FRAME));
761			pc.wake(self.comp.slot(), at);
762		}
763		(sample.round() as u16, sample / f32::from(lift))
764	}
765
766	/// The keyboard bloom's eased strength toward full coverage, on the
767	/// declared `anim` pace — deliberately not the snappy keyboard lift
768	/// pace, so the spread reads as motion rather than a swap. Snaps to
769	/// full without `anim`.
770	fn focus_bloom(&mut self, pc: &mut PaintCtx<'_>) -> f32 {
771		let Some((duration, easing)) = self.anim_spec() else {
772			return 1.0;
773		};
774		let state = self.anim.get_or_insert_default();
775		let tween = state.bloom.get_or_insert_with(|| Tween::settled(0.0));
776		tween.retarget(pc.now, 1.0, duration, easing);
777		let sample = tween.sample(pc.now);
778		if !tween.is_settled(pc.now) {
779			let at = tween.settles_at().min(pc.now.saturating_add(anim::FRAME));
780			pc.wake(self.comp.slot(), at);
781		}
782		sample
783	}
784}
785
786/// Transition state for one component's animatable properties.
787///
788/// Owned by [`Cached`] and allocated lazily on the first animated pass, so
789/// components without `anim` pay one null pointer. Solid color samples are
790/// swapped into the component's props for the duration of a paint, ramp
791/// samples and the spin offset feed [`paint_gradients`] directly, and sizes
792/// are sampled during layout via [`UiContext::now`].
793#[derive(Default)]
794struct AnimState {
795	fg:    Channel,
796	bg:    Channel,
797	bc:    Channel,
798	/// Width tween in the current [`Dim`]'s own unit; the flag records
799	/// whether that unit is percent.
800	w:     Option<(bool, Tween<u16>)>,
801	h:     Option<Tween<u16>>,
802	/// Hover elevation tween in rows.
803	lift:  Option<Tween<f32>>,
804	/// Keyboard-focus glow strength tween toward full ring coverage.
805	bloom: Option<Tween<f32>>,
806}
807
808/// One animatable color slot: a solid or a two-stop ramp on the shared
809/// transition clock. Only like-for-like changes tween — changing kind
810/// (solid ↔ gradient, set ↔ unset) snaps, mirroring [`Color`]'s own
811/// unblendable-endpoint rule.
812#[derive(Clone, Copy, Default)]
813enum Channel {
814	/// The property is unset; nothing to animate.
815	#[default]
816	Empty,
817	Solid(Tween<Color>),
818	Ramp(Tween<(Color, Color)>),
819}
820
821impl Channel {
822	/// Steers the channel toward `target`, snapping on kind changes.
823	fn retarget(
824		&mut self,
825		now: Duration,
826		target: ChannelTarget,
827		duration: Duration,
828		easing: Easing,
829	) {
830		match (self, target) {
831			(Self::Solid(tween), ChannelTarget::Solid(color)) => {
832				tween.retarget(now, color, duration, easing);
833			},
834			(Self::Ramp(tween), ChannelTarget::Ramp(start, end)) => {
835				tween.retarget(now, (start, end), duration, easing);
836			},
837			(slot, ChannelTarget::None) => *slot = Self::Empty,
838			(slot, ChannelTarget::Solid(color)) => *slot = Self::Solid(Tween::settled(color)),
839			(slot, ChannelTarget::Ramp(start, end)) => {
840				*slot = Self::Ramp(Tween::settled((start, end)));
841			},
842		}
843	}
844}
845
846/// A color property's resolved animation target at one paint.
847#[derive(Clone, Copy)]
848enum ChannelTarget {
849	/// Unset or unresolvable — nothing to animate toward.
850	None,
851	Solid(Color),
852	Ramp(Color, Color),
853}
854
855/// Resolves a color property into its animation target, mirroring how
856/// [`Props::style`] and [`resolve_gradient`] will read it at paint time.
857fn color_target(ctx: &UiContext, props: &Props, prop: Prop) -> ChannelTarget {
858	match props.get(prop) {
859		Some(PropValue::Color(color)) => ChannelTarget::Solid(*color),
860		Some(PropValue::Token(token)) => ctx
861			.theme
862			.token(token)
863			.map_or(ChannelTarget::None, ChannelTarget::Solid),
864		Some(PropValue::Gradient(value)) => {
865			let resolve = |color: &str| ctx.theme.token(color).or_else(|| Color::parse(color));
866			value
867				.split_once("..")
868				.and_then(|(start, end)| Some((resolve(start)?, resolve(end)?)))
869				.map_or(ChannelTarget::None, |(start, end)| ChannelTarget::Ramp(start, end))
870		},
871		_ => ChannelTarget::None,
872	}
873}
874
875/// Per-frame gradient overrides supplied by an active animation.
876#[derive(Clone, Copy, Default)]
877pub struct ChromeAnim {
878	/// Sampled foreground ramp mid-transition.
879	fg:    Option<(Color, Color)>,
880	/// Sampled background ramp mid-transition.
881	bg:    Option<(Color, Color)>,
882	/// Sampled border ramp mid-transition.
883	bc:    Option<(Color, Color)>,
884	/// Degrees `spin` adds to the authored gradient angle.
885	angle: u16,
886}
887
888/// Sampled paint state for one animated frame: swapped-out prop targets to
889/// restore, gradient overrides, and the earliest wake owed.
890#[derive(Default)]
891struct PaintAnim {
892	saved:    SmallVec<(Prop, PropValue), 3>,
893	chrome:   ChromeAnim,
894	wake:     Option<Duration>,
895	relayout: bool,
896}
897
898impl PaintAnim {
899	/// Applies one channel's unsettled sample: solids swap into the props
900	/// (saving the target for restore), ramps return a gradient override.
901	fn apply(
902		&mut self,
903		comp: &mut dyn Component,
904		channel: &Channel,
905		prop: Prop,
906		now: Duration,
907	) -> Option<(Color, Color)> {
908		match channel {
909			Channel::Empty => None,
910			Channel::Solid(tween) => {
911				if !tween.is_settled(now)
912					&& let Some(saved) = comp.props().get(prop).cloned()
913				{
914					self.merge_wake(tween.settles_at().min(now.saturating_add(anim::FRAME)));
915					comp.props_mut().set(prop, tween.sample(now));
916					self.saved.push((prop, saved));
917				}
918				None
919			},
920			Channel::Ramp(tween) => {
921				if tween.is_settled(now) {
922					return None;
923				}
924				self.merge_wake(tween.settles_at().min(now.saturating_add(anim::FRAME)));
925				Some(tween.sample(now))
926			},
927		}
928	}
929
930	fn merge_wake(&mut self, at: Duration) {
931		self.wake = Some(self.wake.map_or(at, |wake| wake.min(at)));
932	}
933}
934
935pub fn horizontal_inset(props: &Props, paints_border: bool) -> u16 {
936	let (_, pad_x) = props.pad();
937	pad_x.saturating_add(u16::from(paints_border && props.border().is_some()))
938}
939
940pub fn vertical_inset(props: &Props, paints_border: bool) -> u16 {
941	let (pad_y, _) = props.pad();
942	pad_y.saturating_add(u16::from(paints_border && props.border().is_some()))
943}
944
945fn content_rect(rect: Rect, props: &Props, paints_border: bool) -> Rect {
946	let x_inset = horizontal_inset(props, paints_border);
947	let y_inset = vertical_inset(props, paints_border);
948	Rect::new(
949		rect.x.saturating_add(x_inset),
950		rect.y.saturating_add(y_inset),
951		rect.width.saturating_sub(x_inset.saturating_mul(2)),
952		rect.height.saturating_sub(y_inset.saturating_mul(2)),
953	)
954}
955
956/// The border-color slot every chrome path reads: `bc` when set, else `edge`.
957fn bc_slot(props: &Props) -> Prop {
958	if props.get(Prop::Bc).is_some() {
959		Prop::Bc
960	} else {
961		Prop::Edge
962	}
963}
964
965/// The chrome rectangle inside a lift-reserving outer rectangle: the
966/// drawable box sits `lift` rows below the top at rest and rises by
967/// `risen` rows while hovered.
968fn lifted_rect(rect: Rect, lift: u16, risen: u16) -> Rect {
969	let lift = lift.min(rect.height.saturating_sub(1));
970	Rect::new(rect.x, rect.y.saturating_add(lift - risen.min(lift)), rect.width, rect.height - lift)
971}
972
973/// A soft shadow hugging the underside of risen chrome, in the theme's
974/// shadow tint over whatever the parent painted beneath. Tiers without a
975/// shadow glyph ([`Charset::shadow`]) skip it entirely.
976fn paint_lift_shadow(pc: &mut PaintCtx<'_>, chrome: Rect, rect: Rect) {
977	let y = chrome.y.saturating_add(chrome.height);
978	let Some(glyph) = pc.ctx.charset.shadow() else {
979		return;
980	};
981	if y >= rect.y.saturating_add(rect.height) || y >= pc.clip || rect.width < 3 {
982		return;
983	}
984	let style = Style::new().fg(pc.ctx.theme.shadow);
985	for x in rect.x.saturating_add(1)..rect.x.saturating_add(rect.width - 1) {
986		pc.frame.put(x, y, glyph, style);
987	}
988}
989
990/// Keyboard focus pace ceiling: hops read as input echo, so the lift ease
991/// runs at half its declared duration capped near one reaction beat.
992const KEY_SNAP: Duration = Duration::from_millis(120);
993
994/// A border glow: the `hover` ramp sampled as a seamless color wheel
995/// around the chrome, strongest near its anchor, scaled by the elevation's
996/// eased rise. Anchored under the pointer while tracking it, or at the
997/// chrome's center under keyboard focus.
998#[derive(Clone, Copy)]
999struct BorderGlow {
1000	pointer:  (u16, u16),
1001	start:    Color,
1002	end:      Color,
1003	strength: f32,
1004	/// Keyboard-focus bloom: the halo radiates from the center and grows
1005	/// until the ramp blankets the whole ring at full strength.
1006	focus:    bool,
1007}
1008
1009impl BorderGlow {
1010	/// The blended color for one border cell, or `None` outside the
1011	/// pointer's halo. `phase` drifts the ramp for the resting shimmer.
1012	fn color_at(self, x: u16, y: u16, rect: Rect, base: Color, phase: f32) -> Option<Color> {
1013		// Columns are roughly half as wide as rows are tall.
1014		let dx = (f32::from(x) - f32::from(self.pointer.0)) * 0.5;
1015		let dy = f32::from(y) - f32::from(self.pointer.1);
1016		let radius = if self.focus {
1017			// The bloom spreads from the chrome's center with the eased
1018			// rise until it reaches well past the corners.
1019			let corner_x = f32::from(rect.width) * 0.25;
1020			let corner_y = f32::from(rect.height) * 0.5;
1021			corner_x.hypot(corner_y) * self.strength.mul_add(1.6, 0.4)
1022		} else {
1023			// The halo hugs small chrome and blooms out with the eased
1024			// rise, so the glow visibly grows from under the cursor.
1025			(f32::from(rect.width).mul_add(0.5, f32::from(rect.height)) * 0.3).clamp(2.5, 6.0)
1026				* self.strength.mul_add(0.65, 0.35)
1027		};
1028		let amount = self.strength * (-dx.mul_add(dx, dy * dy) / (radius * radius)).exp();
1029		if amount < 0.02 {
1030			return None;
1031		}
1032		let center_x = f32::from(rect.x) + f32::from(rect.width) / 2.0;
1033		let center_y = f32::from(rect.y) + f32::from(rect.height) / 2.0;
1034		let cell = (f32::from(y) - center_y).atan2((f32::from(x) - center_x) * 0.5);
1035		let cursor =
1036			(f32::from(self.pointer.1) - center_y).atan2((f32::from(self.pointer.0) - center_x) * 0.5);
1037		// Sample the ramp by angular distance from the pointer: the start
1038		// color sits under the cursor, the end color wraps the far side,
1039		// and the fold drifts on the shared clock.
1040		let mut delta = (cell - cursor).rem_euclid(TAU);
1041		if delta > PI {
1042			delta = TAU - delta;
1043		}
1044		let wave = phase.mul_add(0.15, delta / PI);
1045		let wheel = 1.0 - (1.0 - wave.rem_euclid(2.0)).abs();
1046		Some(base.lerp(self.start.lerp(self.end, wheel), amount.min(1.0)))
1047	}
1048}
1049
1050/// Applies the glow to one already-painted border cell.
1051fn glow_cell(frame: &mut Frame, x: u16, y: u16, rect: Rect, glow: BorderGlow, phase: f32) {
1052	frame.recolor_fg(x, y, |base| glow.color_at(x, y, rect, base, phase).unwrap_or(base));
1053}
1054pub fn paint_gradients(
1055	pc: &mut PaintCtx<'_>,
1056	bounds: Rect,
1057	projection: Option<Rect>,
1058	props: &Props,
1059	paints_border: bool,
1060	paints_background: bool,
1061	chrome: ChromeAnim,
1062) {
1063	let angle = (props.angle() + chrome.angle) % 360;
1064	let bottom = bounds.y.saturating_add(bounds.height).min(pc.clip);
1065	let painted = Rect::new(bounds.x, bounds.y, bounds.width, bottom.saturating_sub(bounds.y));
1066	if paints_background {
1067		let background_bounds = if paints_border && props.border().is_some() && !props.bleed() {
1068			Rect::new(
1069				bounds.x.saturating_add(1),
1070				bounds.y.saturating_add(1),
1071				bounds.width.saturating_sub(2),
1072				bounds.height.saturating_sub(2),
1073			)
1074		} else {
1075			bounds
1076		};
1077		let background_bottom = background_bounds
1078			.y
1079			.saturating_add(background_bounds.height)
1080			.min(pc.clip);
1081		let background = Rect::new(
1082			background_bounds.x,
1083			background_bounds.y,
1084			background_bounds.width,
1085			background_bottom.saturating_sub(background_bounds.y),
1086		);
1087		let bg_prop = if props.get(Prop::Bg).is_some() {
1088			Prop::Bg
1089		} else {
1090			Prop::On
1091		};
1092		let gradient = chrome
1093			.bg
1094			.map(|(start, end)| Gradient::new(start, end, angle))
1095			.or_else(|| resolve_gradient(pc.ctx, props, bg_prop, angle));
1096		if let Some(gradient) = gradient {
1097			pc.frame
1098				.underlay_gradient(background, gradient, projection.unwrap_or(background_bounds));
1099		} else {
1100			let bg = props.style(&pc.ctx.theme).background_color();
1101			if bg != Color::Default {
1102				pc.frame.underlay(background, bg);
1103			}
1104		}
1105	}
1106	let gradient = chrome
1107		.fg
1108		.map(|(start, end)| Gradient::new(start, end, angle))
1109		.or_else(|| resolve_gradient(pc.ctx, props, Prop::Fg, angle));
1110	if let Some(gradient) = gradient {
1111		pc.frame
1112			.gradient_foreground(painted, gradient, projection.unwrap_or(bounds));
1113	}
1114}
1115
1116fn resolve_gradient(ctx: &UiContext, props: &Props, prop: Prop, angle: u16) -> Option<Gradient> {
1117	let value = props.gradient_of(prop)?;
1118	let (start, end) = value.split_once("..")?;
1119	let resolve = |color: &str| ctx.theme.token(color).or_else(|| Color::parse(color));
1120	Some(Gradient::new(resolve(start)?, resolve(end)?, angle))
1121}
1122
1123fn assemble_border_line(
1124	line: &mut SmallVec<u8, 256>,
1125	left: char,
1126	horizontal: char,
1127	right: char,
1128	inner: usize,
1129) {
1130	line.clear();
1131	let mut left_bytes = [0; 4];
1132	line.extend_from_slice(left.encode_utf8(&mut left_bytes).as_bytes());
1133	let mut horizontal_bytes = [0; 4];
1134	let horizontal = horizontal.encode_utf8(&mut horizontal_bytes).as_bytes();
1135	for _ in 0..inner {
1136		line.extend_from_slice(horizontal);
1137	}
1138	let mut right_bytes = [0; 4];
1139	line.extend_from_slice(right.encode_utf8(&mut right_bytes).as_bytes());
1140}
1141
1142fn paint_border(
1143	pc: &mut PaintCtx<'_>,
1144	rect: Rect,
1145	props: &Props,
1146	chrome: ChromeAnim,
1147	glow: Option<BorderGlow>,
1148) {
1149	if rect.width < 2 || rect.height < 2 {
1150		return;
1151	}
1152	let border = props.border().unwrap_or_default();
1153	let (tl, tr, bl, br, horizontal, vertical) = pc.ctx.charset.border(border);
1154	let style = props.style(&pc.ctx.theme);
1155	let base = if props.bleed() {
1156		style
1157	} else {
1158		style.bg(Color::Default)
1159	};
1160	let angle = (props.angle() + chrome.angle) % 360;
1161	let ramp = chrome
1162		.bc
1163		.map(|(start, end)| Gradient::new(start, end, angle))
1164		.or_else(|| resolve_gradient(pc.ctx, props, bc_slot(props), angle));
1165	// Ramped glyphs paint on the inherited foreground and are tinted after
1166	// the ring lands; solid edges resolve directly; with only `fg=` the
1167	// frame stays a dimmed echo of the node style; an unstyled border
1168	// falls back to the theme's border tone.
1169	let edge = if ramp.is_some() {
1170		base.fg(Color::Default)
1171	} else if let Some(color) = props.edge(&pc.ctx.theme) {
1172		base.fg(color)
1173	} else if props.get(Prop::Fg).is_some() {
1174		base.dim()
1175	} else {
1176		base.fg(pc.ctx.theme.border)
1177	};
1178	let inner = usize::from(rect.width) - 2;
1179	assemble_border_line(&mut pc.border_scratch, tl, horizontal, tr, inner);
1180	if rect.y < pc.clip {
1181		let top = std::str::from_utf8(&pc.border_scratch)
1182			.expect("border glyph assembly only appends valid UTF-8");
1183		pc.frame.put(rect.x, rect.y, top, edge);
1184	}
1185	let bottom_y = rect.y.saturating_add(rect.height - 1);
1186	if bottom_y < pc.clip {
1187		assemble_border_line(&mut pc.border_scratch, bl, horizontal, br, inner);
1188		let bottom = std::str::from_utf8(&pc.border_scratch)
1189			.expect("border glyph assembly only appends valid UTF-8");
1190		pc.frame.put(rect.x, bottom_y, bottom, edge);
1191	}
1192	let mut vertical_bytes = [0; 4];
1193	let vertical = vertical.encode_utf8(&mut vertical_bytes);
1194	for y in rect.y.saturating_add(1)..bottom_y.min(pc.clip) {
1195		pc.frame.put(rect.x, y, &*vertical, edge);
1196		pc.frame
1197			.put(rect.x.saturating_add(rect.width - 1), y, &*vertical, edge);
1198	}
1199	if let Some(gradient) = ramp {
1200		let side_top = rect.y.saturating_add(1);
1201		let side_rows = bottom_y.min(pc.clip).saturating_sub(side_top);
1202		let strips = [
1203			(rect.y < pc.clip).then(|| Rect::new(rect.x, rect.y, rect.width, 1)),
1204			(bottom_y < pc.clip).then(|| Rect::new(rect.x, bottom_y, rect.width, 1)),
1205			(side_rows > 0).then(|| Rect::new(rect.x, side_top, 1, side_rows)),
1206			(side_rows > 0)
1207				.then(|| Rect::new(rect.x.saturating_add(rect.width - 1), side_top, 1, side_rows)),
1208		];
1209		for strip in strips.into_iter().flatten() {
1210			pc.frame.gradient_foreground(strip, gradient, rect);
1211		}
1212	}
1213	if let Some(glow) = glow {
1214		// The wheel drifts slowly so the glow shimmers while the pointer
1215		// rests; the wake in [`Cached::paint`] keeps frames coming.
1216		let phase = pc.now.as_secs_f32() * 0.5;
1217		let right = rect.x.saturating_add(rect.width - 1);
1218		if rect.y < pc.clip {
1219			for x in rect.x..=right {
1220				glow_cell(pc.frame, x, rect.y, rect, glow, phase);
1221			}
1222		}
1223		if bottom_y < pc.clip {
1224			for x in rect.x..=right {
1225				glow_cell(pc.frame, x, bottom_y, rect, glow, phase);
1226			}
1227		}
1228		for y in rect.y.saturating_add(1)..bottom_y.min(pc.clip) {
1229			glow_cell(pc.frame, rect.x, y, rect, glow, phase);
1230			glow_cell(pc.frame, right, y, rect, glow, phase);
1231		}
1232	}
1233	if rect.y < pc.clip
1234		&& let Some(title) = props.title()
1235	{
1236		border_label(pc, rect, rect.y, title, props.title_align(), base, true);
1237	}
1238	if bottom_y < pc.clip
1239		&& let Some(footer) = props.footer()
1240	{
1241		border_label(pc, rect, bottom_y, footer, props.footer_align(), base, false);
1242	}
1243}
1244
1245/// Paints one border label — title or footer — padded by one space per side
1246/// so the frame line breaks around it. `base` already carries the node's
1247/// background only under `bleed`, keeping the label transparent otherwise.
1248///
1249/// [`Frame::put`] clips at the frame edge, not the rect, so the label is
1250/// truncated by cell width to the border's interior first — an overlong
1251/// title would otherwise run over the right corner into neighboring cells.
1252fn border_label(
1253	pc: &mut PaintCtx<'_>,
1254	rect: Rect,
1255	y: u16,
1256	text: &str,
1257	align: Align,
1258	base: Style,
1259	bold: bool,
1260) {
1261	// Interior cells between the corners, minus the two pad spaces.
1262	let fit = rect.width.saturating_sub(4);
1263	if fit == 0 {
1264		return;
1265	}
1266	// Widths accumulate per grapheme exactly as `Frame::put` paints them,
1267	// so the cutoff never lands mid-grapheme or half a wide cell short.
1268	let mut width: u16 = 0;
1269	let mut end = 0usize;
1270	for grapheme in text.graphemes() {
1271		if grapheme == "\n" || grapheme == "\r" {
1272			break;
1273		}
1274		let cells = u16::try_from(grapheme.visible_width()).unwrap_or(u16::MAX);
1275		if width.saturating_add(cells) > fit {
1276			break;
1277		}
1278		width += cells;
1279		end += grapheme.len();
1280	}
1281	if width == 0 {
1282		return;
1283	}
1284	let text = &text[..end];
1285	let total = width + 2;
1286	let x = match align {
1287		Align::Start => rect.x.saturating_add(2),
1288		Align::Center => rect.x.saturating_add(rect.width.saturating_sub(total) / 2),
1289		Align::End => rect
1290			.x
1291			.saturating_add(rect.width.saturating_sub(2).saturating_sub(total)),
1292	}
1293	.clamp(
1294		rect.x.saturating_add(1),
1295		rect
1296			.x
1297			.saturating_add(rect.width.saturating_sub(1).saturating_sub(total)),
1298	);
1299	let end = pc.frame.put(x, y, " ", base);
1300	let end = pc
1301		.frame
1302		.put(end, y, text, if bold { base.bold() } else { base });
1303	pc.frame.put(end, y, " ", base);
1304}
1305
1306/// State shared by component painters for one frame.
1307pub struct PaintCtx<'a> {
1308	/// Destination frame.
1309	pub frame:        &'a mut Frame,
1310	/// First document row outside the paint region.
1311	pub clip:         u16,
1312	/// Immutable presentation context.
1313	pub ctx:          &'a UiContext,
1314	/// Mouse hit regions produced during paint.
1315	pub hits:         &'a mut Vec<Hit>,
1316	/// Focused component slot.
1317	pub focus:        Option<Slot>,
1318	/// Hovered component slot and hit tag.
1319	pub hover:        Option<(Slot, HitTag)>,
1320	/// Last pointer cell in this frame's coordinates, for chrome that
1321	/// tracks the mouse between hit changes.
1322	pub pointer:      Option<(u16, u16)>,
1323	/// Whether the keyboard was the most recent input modality. The chrome
1324	/// cursor is singular: focus renders hover/lift chrome only while the
1325	/// keyboard owns it, and pointer motion takes it back.
1326	pub keyboard:     bool,
1327	/// Presentation clock: time since the UI's epoch for this paint pass.
1328	pub now:          Duration,
1329	/// Animation wake requests collected during paint.
1330	pub(crate) wakes: &'a mut Vec<Wake>,
1331	/// Inline scratch for border rows, reused by every bordered component.
1332	border_scratch:   SmallVec<u8, 256>,
1333}
1334
1335/// A pending animation wake collected during paint.
1336#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1337pub struct Wake {
1338	pub slot:   Slot,
1339	pub at:     Duration,
1340	/// Whether the wake must relayout — an animated size moved geometry,
1341	/// so repainting in place is not enough.
1342	pub layout: bool,
1343}
1344
1345impl<'a> PaintCtx<'a> {
1346	/// A full-frame paint pass with idle interaction state: the clip covers
1347	/// the whole frame, nothing is focused, hovered, or pointed at, the
1348	/// modality is mouse-neutral, and the clock sits at zero. Callers layer
1349	/// their state onto the public fields.
1350	pub(crate) const fn new(
1351		frame: &'a mut Frame,
1352		ctx: &'a UiContext,
1353		hits: &'a mut Vec<Hit>,
1354		wakes: &'a mut Vec<Wake>,
1355	) -> Self {
1356		let clip = frame.size().height;
1357		Self {
1358			frame,
1359			clip,
1360			ctx,
1361			hits,
1362			focus: None,
1363			hover: None,
1364			pointer: None,
1365			keyboard: false,
1366			now: Duration::ZERO,
1367			border_scratch: SmallVec::new(),
1368			wakes,
1369		}
1370	}
1371
1372	/// A nested pass into `frame` — a scratch or overlay surface — that
1373	/// inherits this pass's interaction state and clock under a new clip.
1374	/// The caller remaps coordinate-bound state (`pointer`) itself.
1375	pub(crate) const fn nested<'b>(&'b mut self, frame: &'b mut Frame, clip: u16) -> PaintCtx<'b> {
1376		PaintCtx {
1377			frame,
1378			clip,
1379			ctx: self.ctx,
1380			hits: self.hits,
1381			focus: self.focus,
1382			hover: self.hover,
1383			pointer: self.pointer,
1384			keyboard: self.keyboard,
1385			now: self.now,
1386			border_scratch: SmallVec::new(),
1387			wakes: self.wakes,
1388		}
1389	}
1390
1391	/// Schedules a repaint of `slot` at time `at`; the earliest request per
1392	/// slot wins. Requests are consumed by [`crate::Ui::tick`] and rebuilt on
1393	/// every paint, so a component that stops asking stops animating.
1394	pub fn wake(&mut self, slot: Slot, at: Duration) {
1395		self.request(slot, at, false);
1396	}
1397
1398	/// Schedules a relayout of `slot` at time `at` — for animations that
1399	/// move geometry, not just pixels.
1400	pub(crate) fn wake_layout(&mut self, slot: Slot, at: Duration) {
1401		self.request(slot, at, true);
1402	}
1403
1404	fn request(&mut self, slot: Slot, at: Duration, layout: bool) {
1405		match self.wakes.iter_mut().find(|wake| wake.slot == slot) {
1406			Some(wake) => {
1407				wake.at = wake.at.min(at);
1408				wake.layout |= layout;
1409			},
1410			None => self.wakes.push(Wake { slot, at, layout }),
1411		}
1412	}
1413}
1414
1415/// Context supplied while routing an input event.
1416pub struct EventCtx<'a> {
1417	/// Immutable presentation context.
1418	pub ctx:           &'a UiContext,
1419	/// Placed content width.
1420	pub width:         u16,
1421	/// Visible content rows.
1422	pub view_rows:     u16,
1423	/// Whether the handler requested a relayout; see
1424	/// [`EventCtx::request_layout`].
1425	pub(crate) layout: bool,
1426}
1427
1428impl<'a> EventCtx<'a> {
1429	/// Creates an event context for one routed input event.
1430	pub const fn new(ctx: &'a UiContext, width: u16, view_rows: u16) -> Self {
1431		Self { ctx, width, view_rows, layout: false }
1432	}
1433
1434	/// Requests a full relayout after this event.
1435	///
1436	/// For handlers whose consumed event changed geometry outside their own
1437	/// subtree through shared state — e.g. [`crate::components::EditInput`]
1438	/// growing its pane's attachment band from a collapsed paste.
1439	pub const fn request_layout(&mut self) {
1440		self.layout = true;
1441	}
1442}
1443
1444/// Converts a component-like value into a boxed component.
1445pub trait IntoComponent {
1446	/// Performs the conversion.
1447	fn into_component(self) -> Box<dyn Component>;
1448}
1449
1450impl<T: Component + 'static> IntoComponent for T {
1451	fn into_component(self) -> Box<dyn Component> {
1452		Box::new(self)
1453	}
1454}
1455impl IntoComponent for Box<dyn Component> {
1456	fn into_component(self) -> Box<dyn Component> {
1457		self
1458	}
1459}
1460impl IntoComponent for &str {
1461	fn into_component(self) -> Box<dyn Component> {
1462		Box::new(Markdown::text_of(self))
1463	}
1464}
1465impl IntoComponent for String {
1466	fn into_component(self) -> Box<dyn Component> {
1467		Box::new(Markdown::text_of(self))
1468	}
1469}
1470impl IntoComponent for Str {
1471	fn into_component(self) -> Box<dyn Component> {
1472		Box::new(Markdown::text_of(self))
1473	}
1474}
1475
1476/// Flattens child-builder inputs into cached children.
1477pub trait IntoChildren {
1478	/// Appends all represented children to `out`.
1479	fn extend_children(self, out: &mut Vec<Cached>);
1480}
1481
1482impl<T: IntoComponent> IntoChildren for T {
1483	fn extend_children(self, out: &mut Vec<Cached>) {
1484		out.push(Cached::new(self.into_component()));
1485	}
1486}
1487impl IntoChildren for () {
1488	fn extend_children(self, _out: &mut Vec<Cached>) {}
1489}
1490impl<T: IntoChildren> IntoChildren for Option<T> {
1491	fn extend_children(self, out: &mut Vec<Cached>) {
1492		if let Some(children) = self {
1493			children.extend_children(out);
1494		}
1495	}
1496}
1497impl<T: IntoChildren> IntoChildren for Vec<T> {
1498	fn extend_children(self, out: &mut Vec<Cached>) {
1499		for children in self {
1500			children.extend_children(out);
1501		}
1502	}
1503}
1504impl<T: IntoChildren, const N: usize> IntoChildren for [T; N] {
1505	fn extend_children(self, out: &mut Vec<Cached>) {
1506		for children in self {
1507			children.extend_children(out);
1508		}
1509	}
1510}
1511impl<T: IntoChildren, const N: usize> IntoChildren for SmallVec<T, N> {
1512	fn extend_children(self, out: &mut Vec<Cached>) {
1513		for children in self {
1514			children.extend_children(out);
1515		}
1516	}
1517}
1518impl IntoChildren for Cached {
1519	fn extend_children(self, out: &mut Vec<Cached>) {
1520		out.push(self);
1521	}
1522}
1523
1524/// Builds the component behind an unknown element tag.
1525pub trait ElementFactory: Send + Sync {
1526	/// Builds an element for `name`, parsed properties, and retained children.
1527	fn build(&self, name: &str, props: Props, children: Vec<Cached>) -> Box<dyn Component>;
1528}
1529
1530impl<F> ElementFactory for F
1531where
1532	F: Fn(&str, Props, Vec<Cached>) -> Box<dyn Component> + Send + Sync,
1533{
1534	fn build(&self, name: &str, props: Props, children: Vec<Cached>) -> Box<dyn Component> {
1535		self(name, props, children)
1536	}
1537}
1538
1539/// Immutable registry of custom element factories.
1540#[derive(Clone, Default)]
1541pub struct Elements(Arc<Vec<(Str, Box<dyn ElementFactory>)>>);
1542
1543impl fmt::Debug for Elements {
1544	fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
1545		formatter
1546			.debug_struct("Elements")
1547			.field("len", &self.0.len())
1548			.finish()
1549	}
1550}
1551
1552impl Elements {
1553	/// Starts a registry builder.
1554	pub fn builder() -> ElementsBuilder {
1555		ElementsBuilder::default()
1556	}
1557
1558	pub(crate) fn get(&self, name: &str) -> Option<&dyn ElementFactory> {
1559		self
1560			.0
1561			.iter()
1562			.find(|(candidate, _)| candidate == name)
1563			.map(|(_, factory)| factory.as_ref())
1564	}
1565
1566	pub(crate) fn ptr_eq(&self, other: &Self) -> bool {
1567		Arc::ptr_eq(&self.0, &other.0)
1568	}
1569}
1570
1571/// Mutable builder for an immutable [`Elements`] registry.
1572#[derive(Default)]
1573pub struct ElementsBuilder {
1574	factories: Vec<(Str, Box<dyn ElementFactory>)>,
1575}
1576
1577impl ElementsBuilder {
1578	/// Registers or replaces the factory for `name`.
1579	pub fn with(mut self, name: impl Into<Str>, factory: impl ElementFactory + 'static) -> Self {
1580		let name = name.into();
1581		if let Some((_, stored)) = self
1582			.factories
1583			.iter_mut()
1584			.find(|(candidate, _)| candidate == &name)
1585		{
1586			*stored = Box::new(factory);
1587		} else {
1588			self.factories.push((name, Box::new(factory)));
1589		}
1590		self
1591	}
1592
1593	/// Freezes this registry for sharing through [`UiContext`].
1594	pub fn build(self) -> Elements {
1595		Elements(Arc::new(self.factories))
1596	}
1597}
1598
1599/// Meaning attached to a mouse hit rectangle.
1600#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1601pub enum HitTag {
1602	/// Row `i` of a select, tree, or form.
1603	Row(u16),
1604	/// Dropdown row `i` of an open form submenu.
1605	Sub(u16),
1606	/// Chip `i` of a segment or tab bar.
1607	Chip(u16),
1608	/// Button face or input line.
1609	Press,
1610	/// Scroll viewport row.
1611	Wheel,
1612	/// The one-cell scrollbar column of a scroll viewport: click or drag
1613	/// jumps the offset.
1614	Scrollbar,
1615	/// Pointer zone of a hover-decorated component; carries no press action.
1616	Zone,
1617}
1618
1619/// A clickable region in document coordinates.
1620#[derive(Clone, Copy, Debug)]
1621pub struct Hit {
1622	/// Clickable rectangle.
1623	pub rect: Rect,
1624	/// Owning component slot.
1625	pub slot: Slot,
1626	/// Meaning of the region to its owner.
1627	pub tag:  HitTag,
1628}
1629
1630#[cfg(test)]
1631mod tests {
1632	use std::{cell::Cell, rc::Rc};
1633
1634	use parking_lot::{Mutex, MutexGuard};
1635
1636	use super::*;
1637
1638	struct Probe {
1639		props:    Props,
1640		slot:     Slot,
1641		children: Vec<Cached>,
1642		measures: Rc<Cell<u32>>,
1643	}
1644
1645	impl Probe {
1646		fn new(measures: Rc<Cell<u32>>, children: Vec<Cached>) -> Self {
1647			Self { props: Props::new(), slot: next_slot(), children, measures }
1648		}
1649	}
1650
1651	/// `rich::set_jamo_width` bumps a process-global measurement epoch that
1652	/// invalidates every [`Cached`] measure memo, so tests observing memo
1653	/// hit counts and tests flipping the epoch must not overlap.
1654	static WIDTH_EPOCH: Mutex<()> = Mutex::new(());
1655
1656	fn width_epoch_guard() -> MutexGuard<'static, ()> {
1657		WIDTH_EPOCH.lock()
1658	}
1659
1660	impl Component for Probe {
1661		fn props(&self) -> &Props {
1662			&self.props
1663		}
1664
1665		fn props_mut(&mut self) -> &mut Props {
1666			&mut self.props
1667		}
1668
1669		fn slot(&self) -> Slot {
1670			self.slot
1671		}
1672
1673		fn children(&self) -> &[Cached] {
1674			&self.children
1675		}
1676
1677		fn children_mut(&mut self) -> &mut [Cached] {
1678			&mut self.children
1679		}
1680
1681		fn measure(&mut self, _ctx: &UiContext) -> (u16, u16) {
1682			self.measures.set(self.measures.get() + 1);
1683			(1, 2)
1684		}
1685
1686		fn height(&mut self, _ctx: &UiContext, _width: u16) -> u16 {
1687			1
1688		}
1689
1690		fn paint(&mut self, _pc: &mut PaintCtx<'_>, _rect: Rect) {}
1691	}
1692
1693	#[test]
1694	fn dirty_update_invalidates_only_ancestor_path() {
1695		let _epoch = width_epoch_guard();
1696		let target_count = Rc::new(Cell::new(0));
1697		let sibling_count = Rc::new(Cell::new(0));
1698		let root_count = Rc::new(Cell::new(0));
1699		let target = Cached::new(Box::new(Probe::new(target_count.clone(), Vec::new())));
1700		let target_slot = target.comp().slot();
1701		let sibling = Cached::new(Box::new(Probe::new(sibling_count.clone(), Vec::new())));
1702		let mut root = Cached::new(Box::new(Probe::new(root_count.clone(), vec![target, sibling])));
1703		let ctx = UiContext::default();
1704		root.measure(&ctx);
1705		root.height(&ctx, 8);
1706		for child in root.comp.children_mut() {
1707			child.measure(&ctx);
1708			child.height(&ctx, 8);
1709		}
1710		root.update(target_slot, |_| ((), true)).unwrap();
1711		assert!(root.measured.is_none());
1712		assert!(root.laid.is_none());
1713		let children = root.comp.children();
1714		assert!(children[0].measured.is_none());
1715		assert!(children[0].laid.is_none());
1716		assert!(children[1].measured.is_some());
1717		assert!(children[1].laid.is_some());
1718		root.measure(&ctx);
1719		root.height(&ctx, 8);
1720		root.comp.children_mut()[0].measure(&ctx);
1721		root.comp.children_mut()[0].height(&ctx, 8);
1722		root.comp.children_mut()[1].measure(&ctx);
1723		root.comp.children_mut()[1].height(&ctx, 8);
1724		assert_eq!(root_count.get(), 2);
1725		assert_eq!(target_count.get(), 2);
1726		assert_eq!(sibling_count.get(), 1);
1727
1728		root.update(target_slot, |_| ((), false)).unwrap();
1729		assert!(root.measured.is_some());
1730		assert!(root.laid.is_some());
1731		assert!(root.comp.children()[0].measured.is_some());
1732		assert!(root.comp.children()[0].laid.is_some());
1733		assert!(root.comp.children()[1].measured.is_some());
1734		assert!(root.comp.children()[1].laid.is_some());
1735	}
1736
1737	#[test]
1738	fn into_children_flattens_supported_inputs() {
1739		let mut children = Vec::new();
1740		().extend_children(&mut children);
1741		Some("one").extend_children(&mut children);
1742		vec!["two", "three"].extend_children(&mut children);
1743		["four", "five"].extend_children(&mut children);
1744		assert_eq!(children.len(), 5);
1745	}
1746
1747	#[test]
1748	fn elements_builder_resolves_registered_factory() {
1749		let elements = Elements::builder()
1750			.with("card", |_name: &str, _props: Props, _children: Vec<Cached>| {
1751				Box::new(Markdown::text_of("made")) as Box<dyn Component>
1752			})
1753			.build();
1754		let mut built = elements
1755			.get("card")
1756			.unwrap()
1757			.build("card", Props::new(), Vec::new());
1758		assert!(built.measure(&UiContext::default()).1 > 0);
1759		assert!(elements.get("missing").is_none());
1760	}
1761	#[test]
1762	fn width_epoch_invalidates_cached_measurement() {
1763		let _epoch = width_epoch_guard();
1764		let original = crate::rich::jamo_width();
1765		let next = if original == crate::context::JamoWidth::Narrow {
1766			crate::context::JamoWidth::Wide
1767		} else {
1768			crate::context::JamoWidth::Narrow
1769		};
1770		let measures = Rc::new(Cell::new(0));
1771		let mut cached = Cached::new(Box::new(Probe::new(measures.clone(), Vec::new())));
1772		let ctx = UiContext::default();
1773
1774		assert_eq!(cached.measure(&ctx), (1, 2));
1775		assert_eq!(cached.measure(&ctx), (1, 2));
1776		assert_eq!(measures.get(), 1);
1777
1778		assert!(crate::rich::set_jamo_width(next));
1779		assert_eq!(cached.measure(&ctx), (1, 2));
1780		assert_eq!(measures.get(), 2);
1781
1782		crate::rich::set_jamo_width(original);
1783	}
1784}