1use 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
46struct OverlayEntry {
48 id: OverlayId,
49 ui: Box<Ui>,
53 options: OverlayOptions,
54 band: OverlayBand,
57 hidden: bool,
58}
59
60impl OverlayEntry {
61 fn visible(&self, viewport: Option<Size>) -> bool {
63 !self.hidden && viewport.is_none_or(|vp| overlay::visible_at(&self.options, vp))
64 }
65}
66
67pub 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 pub(crate) high_water: u16,
76 pub(crate) damage: SmallVec<(u16, u16), 8>,
78 pub(crate) focus: Option<Slot>,
79 pub(crate) hover: Option<(Slot, HitTag)>,
80 pub(crate) pointer: Option<(u16, u16)>,
83 pub(crate) keyboard: bool,
86 pub(crate) hits: Vec<Hit>,
87 drag: Option<Hit>,
88 conds: Vec<CompiledCond>,
89 now: Duration,
91 wakes: Vec<Wake>,
93 resize_hits: Vec<Hit>,
95 resize_wakes: Vec<Wake>,
98 pub(crate) ctx: UiContext,
99 overlays: SmallVec<OverlayEntry, 4>,
102 next_overlay: u32,
104 key_overlay: Option<OverlayId>,
106 viewport: Option<Size>,
108 window_top: u16,
110 overlays_dirty: bool,
112 nested: bool,
114}
115
116impl Ui {
117 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 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 cached.comp_mut().enter(true);
177 }
178 ui.layout_all();
179 ui
180 }
181
182 pub const fn frame(&self) -> &Frame {
184 &self.frame
185 }
186
187 pub const fn context(&self) -> &UiContext {
189 &self.ctx
190 }
191
192 pub fn set_context(&mut self, ctx: UiContext) -> bool {
203 if self.ctx == ctx {
204 return false;
205 }
206 crate::rich::set_jamo_width(ctx.jamo_width);
209 self.apply_context(&ctx);
210 true
211 }
212
213 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 pub const fn height(&self) -> u16 {
238 self.root.rect.height
239 }
240
241 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 pub fn take_frame_damage(&mut self) -> bool {
250 let changed = !self.damage.is_empty();
251 self.damage.clear();
252 changed
253 }
254
255 pub(crate) fn clear_damage(&mut self) {
260 self.damage.clear();
261 self.overlays_dirty |= !self.overlays.is_empty();
262 }
263
264 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 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 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 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 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 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 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 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 pub(crate) fn deliver_image(&mut self, slot: Slot, state: ImgState) -> bool {
420 if self.root.find_slot(slot).is_none() {
421 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 pub fn resize(&mut self, width: u16) {
455 self.width = width;
456 self.refit();
457 }
458
459 pub(crate) fn refit(&mut self) {
469 self.high_water = 0;
470 self.root.invalidate();
471 self.layout_all();
472 }
473
474 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 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 self.layout_all();
504 }
505 true
506 }
507
508 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 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 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 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 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 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 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 pub fn handle_key(&mut self, key: Key) -> UiEvent {
677 self.handle_key_claimed(key).0
678 }
679
680 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 return (UiEvent::Cancel, true);
692 }
693 if !modal
694 && (event == UiEvent::Cancel
695 || (event == UiEvent::None && !had_focus && matches!(key, Key::Esc)))
696 {
697 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 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 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 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 pub fn handle_paste(&mut self, text: &str) -> UiEvent {
756 self.route_paste(text, false)
757 }
758
759 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 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 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 pub fn blur(&mut self) {
835 self.clear_hover();
836 self.assign_focus(None, true);
837 }
838
839 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 pub fn top_overlay(&self) -> Option<OverlayId> {
1159 self.key_target().map(|index| self.overlays[index].id)
1160 }
1161
1162 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 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 pub const fn focused_overlay(&self) -> Option<OverlayId> {
1193 self.key_overlay
1194 }
1195
1196 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 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 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 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 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 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 #[cfg(test)]
1295 pub(crate) const fn focus_slot(&self) -> Option<Slot> {
1296 self.focus
1297 }
1298
1299 #[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 #[cfg(test)]
1309 pub(crate) fn hover_slot(&self) -> Option<Slot> {
1310 self.hover.map(|(slot, _)| slot)
1311 }
1312
1313 #[cfg(test)]
1315 pub(crate) fn hits(&self) -> &[Hit] {
1316 &self.hits
1317 }
1318
1319 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 #[cfg(test)]
1330 pub(crate) const fn root(&self) -> &Cached {
1331 &self.root
1332 }
1333
1334 #[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 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 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 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 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 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 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 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 let _ = self.root.update(slot, |_| ((), true));
1616
1617 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 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#[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 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
2069fn 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 #[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 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 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 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 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 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 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 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 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 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 let bare = Ui::from_markup("<col><text>aa bb</text></col>", 6, UiContext::default()).unwrap();
2668 assert_eq!(frame_text(&bare).len(), 1);
2669 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 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 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 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 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 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 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 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 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 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 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 #[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 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 #[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 #[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 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 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 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 assert!(all.contains('┼'), "table header separator: {all}");
3094 assert!(all.contains("Stage") && all.contains("render"), "table cells: {all}");
3095
3096 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 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 assert!(all.contains("├── src"), "tree branch: {all}");
3115 assert!(all.contains("│ └── markdown"), "tree rail spacing: {all}");
3116
3117 assert!(all.contains('⎛') && all.contains('⎝'), "pmatrix pieces: {all}");
3119
3120 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 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 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 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 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 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 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 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 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 assert!(all.contains("**raw**"), "explicit text is literal: {all}");
3236 }
3237 #[test]
3238 fn implicit_text_is_markdown_anywhere() {
3239 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 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 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 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 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 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(¢ered);
3323 assert!(rows[1].contains("hi"), "content centered in the panel: {rows:?}");
3324 assert_eq!(bg_column(¢ered, x), 3, "still filled: {rows:?}");
3325
3326 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 #[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 assert!(steady.as_nanos() * 100 < rebuild.as_nanos());
3790 }
3791
3792 #[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 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 #[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 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 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 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 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 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 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 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 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 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 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 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 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 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}