1use super::*;
5
6use teksilo_canvas::quantize_raster_scale;
7
8struct A11yPaintPrefs {
10 high_contrast: bool,
11 reduced_motion: bool,
12 large_text: bool,
13 text_scale: f32,
16 window_active: bool,
20}
21
22impl WidgetTree {
23 pub fn render(&mut self) -> std::rc::Rc<RenderFrame> {
29 let mut noop = crate::window::NoopWindowOps;
30 self.render_with_ops(&mut noop)
31 }
32
33 pub fn render_with_ops(
38 &mut self,
39 ops: &mut dyn crate::window::WindowOps,
40 ) -> std::rc::Rc<RenderFrame> {
41 self.process_state_changes(&mut *ops);
42
43 let now = std::time::Instant::now();
56 let has_animations = self.animated_quads.has_running();
57 if has_animations {
58 self.animated_quads
59 .tick(now, &self.arena, self.paint_epoch, &self.theme);
60 }
61
62 if !self.arena.any_needs_paint() && self.cached_frame.is_some() {
81 self.arm_frame_tick_for_visible_subscribers();
86
87 if let Some(cached) = self.cached_frame.as_mut() {
88 let frame = std::rc::Rc::make_mut(cached);
89 if has_animations {
90 let src = self.animated_quads.scratch_slice();
91 frame.anim_params.clear();
92 frame.anim_params.extend_from_slice(src);
93 }
94 if !frame.layout_keys.is_empty()
103 && let Some(tb) = &self.text_backend
104 {
105 let mut tb = tb.borrow_mut();
106 for key in &frame.layout_keys {
107 tb.touch_layout(*key);
108 }
109 #[cfg(debug_assertions)]
110 debug_validate_layout_keys(&*tb, &frame.layout_keys, None);
111 }
112 return std::rc::Rc::clone(cached);
113 }
114 }
115
116 self.paint_epoch = self.paint_epoch.saturating_add(1);
117 let paint_epoch = self.paint_epoch;
118
119 let mut frame = RenderFrame::new();
120 let base_theme = if self.is_window_active() {
129 self.effective_theme.clone()
130 } else {
131 self.effective_theme.for_inactive_window()
132 };
133 let base_theme = if self.prefers_high_contrast {
139 base_theme.for_high_contrast()
140 } else {
141 base_theme
142 };
143 let text_backend = self.text_backend.clone();
144 let a11y_prefs = A11yPaintPrefs {
145 high_contrast: self.prefers_high_contrast,
146 reduced_motion: self.prefers_reduced_motion,
147 large_text: self.text_scale_factor > 1.0,
148 text_scale: self.effective_text_scale,
149 window_active: self.is_window_active(),
150 };
151
152 let overlay_skip: std::collections::HashSet<WidgetId> = self
153 .overlay_manager
154 .active_content_ids()
155 .into_iter()
156 .collect();
157
158 let overlay_rects: Vec<Rect> = self.overlay_manager.interactive_rects();
164
165 for root_id in self.arena.roots() {
166 if overlay_skip.contains(&root_id) {
170 continue;
171 }
172 paint_widget_cached(
173 &mut self.arena,
174 root_id,
175 &mut frame,
176 &base_theme,
177 &text_backend,
178 None,
179 &a11y_prefs,
180 paint_epoch,
181 &overlay_skip,
182 &overlay_rects,
183 self.layout_direction,
184 true,
187 1.0,
190 );
191 }
192
193 for content_id in self.overlay_manager.active_content_ids() {
194 paint_widget_cached(
195 &mut self.arena,
196 content_id,
197 &mut frame,
198 &base_theme,
199 &text_backend,
200 None,
201 &a11y_prefs,
202 paint_epoch,
203 &overlay_skip,
204 &overlay_rects,
205 self.layout_direction,
206 true,
211 1.0,
214 );
215 }
216
217 self.arena.fill_active_ids(&mut self.active_ids_scratch);
228 let ids = std::mem::take(&mut self.active_ids_scratch);
229 for &id in &ids {
230 if let Some(node) = self.arena.get_mut(id) {
231 if node.dirty.needs_paint && node.paint_consumed_epoch != paint_epoch {
232 node.cached_paint = None;
233 node.cached_post_paint = None;
234 }
235 node.dirty.needs_paint = false;
236 }
237 }
238 self.active_ids_scratch = ids;
239
240 if has_animations {
241 frame
242 .anim_params
243 .extend_from_slice(self.animated_quads.scratch_slice());
244 }
245 frame.debug_validate_stacks();
246 let rc = std::rc::Rc::new(frame);
247 self.cached_frame = Some(std::rc::Rc::clone(&rc));
248 self.arm_frame_tick_for_visible_subscribers();
254 rc
255 }
256
257 pub(crate) fn arm_frame_tick_for_visible_subscribers(&mut self) {
266 if self
267 .frame_tick_scheduler
268 .should_arm_frame_tick(&self.arena, self.paint_epoch)
269 {
270 self.frame_tick_armed_by_subscribers = true;
271 }
272 }
273}
274
275#[allow(clippy::too_many_arguments)]
295fn paint_widget_cached(
296 arena: &mut WidgetArena,
297 id: WidgetId,
298 frame: &mut RenderFrame,
299 base_theme: &crate::styles::Theme,
300 text_backend: &Option<Rc<RefCell<dyn teksilo_canvas::TextBackend>>>,
301 clip_bounds: Option<Rect>,
302 a11y_prefs: &A11yPaintPrefs,
303 paint_epoch: u64,
304 overlay_skip: &std::collections::HashSet<WidgetId>,
305 overlay_rects: &[Rect],
306 layout_direction: crate::environment::LayoutDirection,
307 parent_effective_enabled: bool,
308 accumulated_raster_scale: f32,
309) {
310 if !arena.is_active(id) {
311 return;
312 }
313
314 let this_effective_enabled = parent_effective_enabled
319 && arena
320 .get(id)
321 .and_then(|n| n.enabled_state.as_ref())
322 .is_none_or(|p| p.get());
323
324 let node = arena.get(id).expect("node id is active (guarded above)");
325 let bounds = node.bounds;
326 if let Some(clip) = clip_bounds {
327 let x0 = bounds.x.max(clip.x);
328 let y0 = bounds.y.max(clip.y);
329 let x1 = bounds.right().min(clip.right());
330 let y1 = bounds.bottom().min(clip.bottom());
331 if x1 <= x0 || y1 <= y0 {
332 return;
337 }
338 }
339
340 if let Some(node_mut) = arena.get_mut(id) {
345 node_mut.last_painted_epoch = paint_epoch;
346 }
347
348 let node = arena.get(id).expect("node id is active (guarded above)");
353 let opacity = node.opacity_prop.as_ref().map(|p| p.get().clamp(0.0, 1.0));
354 if let Some(o) = opacity
355 && o < 1.0 / 512.0
356 {
357 return;
363 }
364
365 let node = arena.get(id).expect("node id is active (guarded above)");
373 let blur_radius = node
374 .blur_prop
375 .as_ref()
376 .map(|p| p.get())
377 .filter(|r| *r >= 0.5);
378 let blur_bounds = arena.bounds(id);
379 if let Some(r) = blur_radius {
380 frame
381 .draw_order
382 .push(teksilo_canvas::DrawCommand::BeginBlurredSubtree {
383 bounds: blur_bounds,
384 radius: r,
385 });
386 }
387
388 if let Some(o) = opacity {
389 frame
390 .draw_order
391 .push(teksilo_canvas::DrawCommand::SetOpacity(o));
392 }
393
394 let node = arena.get(id).expect("node id is active (guarded above)");
402 let transform = node.transform_prop.as_ref().map(|p| p.get());
403 let push_transform = transform.filter(|t| *t != teksilo_canvas::Transform2D::IDENTITY);
404 let clips = node.clips_children;
405 let content_transform = node.content_transform;
406 let bounds = node.bounds;
407
408 let this_raster_scale = match push_transform {
416 Some(t) => quantize_raster_scale(accumulated_raster_scale * t.geometric_scale()),
417 None => accumulated_raster_scale,
418 };
419 let raster_scale_changed = this_raster_scale != accumulated_raster_scale;
423 if raster_scale_changed && let Some(tb) = text_backend {
424 tb.borrow_mut().set_raster_scale(this_raster_scale);
425 }
426 let clip_outside_transform = clips && content_transform;
435 if clip_outside_transform {
436 frame
437 .draw_order
438 .push(teksilo_canvas::DrawCommand::SetClip(bounds));
439 }
440 if let Some(t) = push_transform {
441 frame
442 .draw_order
443 .push(teksilo_canvas::DrawCommand::PushTransform(t));
444 }
445
446 let node = arena.get(id).expect("node id is active (guarded above)");
447 let needs_paint = node.dirty.needs_paint || node.paint_raster_scale != this_raster_scale;
452
453 if needs_paint || node.cached_paint.is_none() {
454 let resolved_theme = arena.resolve_theme(id, base_theme);
455 let ctx = PaintContext {
456 theme: &resolved_theme,
457 scale_factor: this_raster_scale,
458 text_scale: a11y_prefs.text_scale,
459 layout_direction,
460 effective_enabled: this_effective_enabled,
461 prefers_high_contrast: a11y_prefs.high_contrast,
462 prefers_reduced_motion: a11y_prefs.reduced_motion,
463 prefers_large_text: a11y_prefs.large_text,
464 window_active: a11y_prefs.window_active,
465 clip_bounds,
466 };
467
468 let bounds = arena.bounds(id);
469 let node = arena.get(id).expect("node id is active (guarded above)");
470
471 let mut canvas = match text_backend {
472 Some(tb) => Canvas::with_text_backend(tb.clone()),
473 None => Canvas::new(),
474 };
475 if let Some(wake) = &node.repaint_wake {
478 wake.consume_repaint();
479 }
480 node.widget.paint(bounds, &mut canvas, &ctx);
481 let widget_frame = canvas.into_render_frame();
482
483 frame.merge(&widget_frame);
484 if let Some(node) = arena.get_mut(id) {
485 node.cached_paint = Some(widget_frame);
486 node.paint_raster_scale = this_raster_scale;
487 node.paint_consumed_epoch = paint_epoch;
488 }
489 } else {
490 let node = arena.get(id).expect("node id is active (guarded above)");
491 if let Some(cached) = &node.cached_paint {
492 if !cached.layout_keys.is_empty()
500 && let Some(tb) = text_backend
501 {
502 let mut tb = tb.borrow_mut();
503 for key in &cached.layout_keys {
504 tb.touch_layout(*key);
505 }
506 #[cfg(debug_assertions)]
507 debug_validate_layout_keys(&*tb, &cached.layout_keys, Some(id));
508 }
509 frame.merge(cached);
510 }
511 }
512
513 let node = arena.get(id).expect("node id is active (guarded above)");
514 let children: Vec<WidgetId> = node.children.clone();
515 let next_clip = if clips {
516 Some(match clip_bounds {
517 Some(clip) => {
518 let x0 = bounds.x.max(clip.x);
519 let y0 = bounds.y.max(clip.y);
520 let x1 = bounds.right().min(clip.right());
521 let y1 = bounds.bottom().min(clip.bottom());
522 Rect::new(x0, y0, (x1 - x0).max(0.0), (y1 - y0).max(0.0))
523 }
524 None => bounds,
525 })
526 } else {
527 clip_bounds
528 };
529
530 let next_clip = match (
544 content_transform,
545 next_clip,
546 transform.and_then(|t| t.inverse()),
547 ) {
548 (true, Some(screen_clip), Some(inv)) => Some(inv.apply_rect(screen_clip)),
549 _ => next_clip,
550 };
551
552 if clips && !clip_outside_transform {
556 frame
557 .draw_order
558 .push(teksilo_canvas::DrawCommand::SetClip(bounds));
559 }
560
561 for child_id in children {
562 if overlay_skip.contains(&child_id) {
568 continue;
569 }
570 paint_widget_cached(
571 arena,
572 child_id,
573 frame,
574 base_theme,
575 text_backend,
576 next_clip,
577 a11y_prefs,
578 paint_epoch,
579 overlay_skip,
580 overlay_rects,
581 layout_direction,
582 this_effective_enabled,
583 this_raster_scale,
584 );
585 }
586
587 {
595 let arena_ref: &WidgetArena = &*arena;
596 if let Some(node) = arena_ref.get(id)
597 && node.widget.wants_after_paint()
598 {
599 let view = crate::widget::WidgetTreeView::new(arena_ref, overlay_rects);
600 let resolved_theme = arena_ref.resolve_theme(id, base_theme);
601 let ctx = PaintContext {
602 theme: &resolved_theme,
603 scale_factor: this_raster_scale,
604 text_scale: a11y_prefs.text_scale,
605 layout_direction,
606 effective_enabled: this_effective_enabled,
607 prefers_high_contrast: a11y_prefs.high_contrast,
608 prefers_reduced_motion: a11y_prefs.reduced_motion,
609 prefers_large_text: a11y_prefs.large_text,
610 window_active: a11y_prefs.window_active,
611 clip_bounds,
612 };
613 node.widget.after_paint(&view, &ctx);
614 }
615 }
616
617 let wants_post_paint = arena
626 .get(id)
627 .map(|n| n.widget.wants_post_paint())
628 .unwrap_or(false);
629 if wants_post_paint {
630 let has_post_cache = arena
631 .get(id)
632 .map(|n| n.cached_post_paint.is_some())
633 .unwrap_or(false);
634 if needs_paint || !has_post_cache {
635 if raster_scale_changed && let Some(tb) = text_backend {
640 tb.borrow_mut().set_raster_scale(this_raster_scale);
641 }
642 let resolved_theme = arena.resolve_theme(id, base_theme);
643 let ctx = PaintContext {
644 theme: &resolved_theme,
645 scale_factor: this_raster_scale,
646 text_scale: a11y_prefs.text_scale,
647 layout_direction,
648 effective_enabled: this_effective_enabled,
649 prefers_high_contrast: a11y_prefs.high_contrast,
650 prefers_reduced_motion: a11y_prefs.reduced_motion,
651 prefers_large_text: a11y_prefs.large_text,
652 window_active: a11y_prefs.window_active,
653 clip_bounds,
654 };
655 let bounds = arena.bounds(id);
656 let node = arena.get(id).expect("node id is active (guarded above)");
657 let mut canvas = match text_backend {
658 Some(tb) => Canvas::with_text_backend(tb.clone()),
659 None => Canvas::new(),
660 };
661 node.widget.post_paint(bounds, &mut canvas, &ctx);
662 let post_frame = canvas.into_render_frame();
663 frame.merge(&post_frame);
664 if let Some(node) = arena.get_mut(id) {
665 node.cached_post_paint = Some(post_frame);
666 }
667 } else if let Some(cached) = arena.get(id).and_then(|n| n.cached_post_paint.as_ref()) {
668 if !cached.layout_keys.is_empty()
669 && let Some(tb) = text_backend
670 {
671 let mut tb = tb.borrow_mut();
672 for key in &cached.layout_keys {
673 tb.touch_layout(*key);
674 }
675 #[cfg(debug_assertions)]
676 debug_validate_layout_keys(&*tb, &cached.layout_keys, Some(id));
677 }
678 frame.merge(cached);
679 }
680 }
681
682 if clips && !clip_outside_transform {
683 frame
684 .draw_order
685 .push(teksilo_canvas::DrawCommand::ClearClip);
686 }
687
688 if push_transform.is_some() {
689 frame
690 .draw_order
691 .push(teksilo_canvas::DrawCommand::PopTransform);
692 }
693
694 if clip_outside_transform {
697 frame
698 .draw_order
699 .push(teksilo_canvas::DrawCommand::ClearClip);
700 }
701
702 if opacity.is_some() {
703 frame
704 .draw_order
705 .push(teksilo_canvas::DrawCommand::RestoreOpacity);
706 }
707
708 if blur_radius.is_some() {
709 frame
710 .draw_order
711 .push(teksilo_canvas::DrawCommand::EndBlurredSubtree);
712 }
713
714 if raster_scale_changed && let Some(tb) = text_backend {
717 tb.borrow_mut().set_raster_scale(accumulated_raster_scale);
718 }
719}
720
721#[cfg(debug_assertions)]
734fn debug_validate_layout_keys(
735 tb: &dyn teksilo_canvas::TextBackend,
736 layout_keys: &[u64],
737 widget: Option<WidgetId>,
738) {
739 use teksilo_canvas::GlyphValidation;
740 std::thread_local! {
741 static REPORTED_STALE_KEYS: std::cell::RefCell<std::collections::HashSet<u64>> =
742 std::cell::RefCell::new(std::collections::HashSet::new());
743 }
744 for key in layout_keys {
745 match tb.debug_validate_layout(*key) {
746 GlyphValidation::Valid => {}
747 GlyphValidation::StaleKey => {
748 let first_report = REPORTED_STALE_KEYS.with(|set| set.borrow_mut().insert(*key));
749 if first_report {
750 eprintln!(
751 "[teksilo] WARNING: retained paint cache replays layout_key={key} \
752 (widget={widget:?}) that the text backend no longer knows — the \
753 frame survived a backend cache clear, so a paint-cache \
754 invalidation path is likely missing."
755 );
756 }
757 }
758 GlyphValidation::RectMismatch => {
759 panic!(
760 "stale glyph UVs in retained paint cache (layout_key={key}, \
761 widget={widget:?}): cached quads no longer match the live glyph \
762 atlas — a cache-invalidation path is missing"
763 );
764 }
765 }
766 }
767}
768
769#[cfg(test)]
770mod tests {
771 use super::*;
772 use crate::test_widgets::{FillWidget, StackWidget};
773 use teksilo_tokens::{Color, CornerRadius};
774
775 #[derive(Default)]
782 struct RecordingTextBackend {
783 touched: std::rc::Rc<std::cell::RefCell<Vec<u64>>>,
784 raster_scale: f32,
785 layout_scales: std::rc::Rc<std::cell::RefCell<Vec<f32>>>,
788 }
789
790 impl RecordingTextBackend {
791 fn new(touched: std::rc::Rc<std::cell::RefCell<Vec<u64>>>) -> Self {
792 Self {
793 touched,
794 raster_scale: 1.0,
795 layout_scales: Default::default(),
796 }
797 }
798 }
799
800 impl teksilo_canvas::TextBackend for RecordingTextBackend {
801 fn set_raster_scale(&mut self, raster_scale: f32) {
802 self.raster_scale = raster_scale;
803 }
804
805 fn raster_scale(&self) -> f32 {
806 self.raster_scale
807 }
808
809 fn layout_single_line(
810 &mut self,
811 text: &str,
812 _style: &teksilo_tokens::TextStyle,
813 _max_width: Option<f32>,
814 ) -> teksilo_canvas::TextLayout {
815 self.layout_scales.borrow_mut().push(self.raster_scale);
816 teksilo_canvas::TextLayout {
817 width: text.len() as f32 * 8.0,
818 height: 16.0,
819 ascent: 12.0,
820 descent: 4.0,
821 underline_offset: 1.0,
822 underline_thickness: 1.0,
823 layout_key: 42,
824 line_count: 1,
825 spans: Vec::new(),
826 raster_scale: self.raster_scale,
827 geometry: None,
828 }
829 }
830
831 fn ensure_glyphs(
832 &mut self,
833 _layout: &teksilo_canvas::TextLayout,
834 ) -> Vec<teksilo_canvas::GlyphQuad> {
835 Vec::new()
836 }
837
838 fn touch_layout(&mut self, layout_key: u64) {
839 self.touched.borrow_mut().push(layout_key);
840 }
841 }
842
843 #[derive(Debug)]
846 struct TextPaintWidget;
847
848 impl Widget for TextPaintWidget {
849 fn layout_response(
850 &self,
851 proposal: SizeProposal,
852 _ctx: &LayoutContext,
853 ) -> crate::widget::LayoutResponse {
854 proposal.resolve(100.0, 20.0).into()
855 }
856
857 fn paint(
858 &self,
859 bounds: teksilo_canvas::Rect,
860 canvas: &mut teksilo_canvas::Canvas,
861 _ctx: &PaintContext,
862 ) {
863 let style = teksilo_tokens::TextStyle::default();
864 let _ = canvas.draw_text("hello", bounds, &style, Color::BLACK);
865 }
866 }
867
868 #[test]
869 fn full_frame_cache_hit_touches_layout_keys() {
870 let touched = std::rc::Rc::new(std::cell::RefCell::new(Vec::new()));
871 let backend = RecordingTextBackend::new(touched.clone());
872 let mut tree = WidgetTree::new()
873 .with_theme(crate::presets::intui::light())
874 .with_text_backend(std::rc::Rc::new(std::cell::RefCell::new(backend)));
875 tree.add(TextPaintWidget);
876 tree.layout(SizeProposal::exact(200.0, 50.0));
877
878 let _ = tree.render();
880 touched.borrow_mut().clear();
881
882 let _ = tree.render();
886 assert!(
887 touched.borrow().contains(&42),
888 "full-frame cache hit must touch_layout every baked layout key; \
889 touched = {:?}",
890 touched.borrow()
891 );
892 }
893
894 fn scaled_subtree_tree(
905 transform: impl Into<crate::signal::Prop<teksilo_canvas::Transform2D>>,
906 ) -> (
907 WidgetTree,
908 std::rc::Rc<std::cell::RefCell<RecordingTextBackend>>,
909 std::rc::Rc<std::cell::RefCell<Vec<f32>>>,
910 ) {
911 let backend = RecordingTextBackend::new(Default::default());
912 let layout_scales = backend.layout_scales.clone();
913 let backend_rc = std::rc::Rc::new(std::cell::RefCell::new(backend));
914 let mut tree = WidgetTree::new()
915 .with_theme(crate::presets::intui::light())
916 .with_text_backend(backend_rc.clone()
917 as std::rc::Rc<std::cell::RefCell<dyn teksilo_canvas::TextBackend>>);
918
919 let root = tree.add(StackWidget::new());
920 tree.add_child(root, TextPaintWidget); let wrapper = tree.add_child(root, StackWidget::new());
922 tree.add_child(wrapper, TextPaintWidget); tree.add_child(root, TextPaintWidget); tree.set_transform(wrapper, transform);
925
926 tree.layout(SizeProposal::exact(400.0, 300.0));
927 (tree, backend_rc, layout_scales)
928 }
929
930 #[test]
931 fn walker_sets_raster_scale_for_scaled_subtree_and_restores() {
932 let (mut tree, backend_rc, layout_scales) =
933 scaled_subtree_tree(teksilo_canvas::Transform2D::scale(2.0, 2.0));
934 let _ = tree.render();
935
936 let expected_b = 1.25_f32.powi(3); assert_eq!(
941 layout_scales.borrow().as_slice(),
942 &[1.0, expected_b, 1.0],
943 "text inside the transform scope must lay out at the quantized \
944 scale; siblings before/after at screen scale"
945 );
946 assert_eq!(backend_rc.borrow().raster_scale, 1.0);
948 }
949
950 #[test]
951 fn raster_scale_change_repaints_clean_descendants() {
952 let transform = crate::signal::Signal::new(teksilo_canvas::Transform2D::scale(2.0, 2.0));
953 let (mut tree, _backend_rc, layout_scales) = scaled_subtree_tree(transform.clone());
954 let _ = tree.render();
955 layout_scales.borrow_mut().clear();
956
957 transform.set(teksilo_canvas::Transform2D::scale(3.0, 3.0));
961 let _ = tree.render();
962 assert_eq!(
963 layout_scales.borrow().as_slice(),
964 &[1.25_f32.powi(5)],
965 "the clean text leaf inside the scope must re-rasterize at the \
966 new quantized scale (A and C stay cached: no layout calls)"
967 );
968
969 layout_scales.borrow_mut().clear();
971 transform.set(teksilo_canvas::Transform2D::IDENTITY);
972 let _ = tree.render();
973 assert_eq!(layout_scales.borrow().as_slice(), &[1.0]);
974
975 layout_scales.borrow_mut().clear();
978 let _ = tree.render();
979 assert!(layout_scales.borrow().is_empty());
980 }
981
982 #[test]
983 fn nested_scale_transforms_multiply() {
984 let backend = RecordingTextBackend::new(Default::default());
985 let layout_scales = backend.layout_scales.clone();
986 let mut tree = WidgetTree::new()
987 .with_theme(crate::presets::intui::light())
988 .with_text_backend(std::rc::Rc::new(std::cell::RefCell::new(backend)));
989
990 let outer = tree.add(StackWidget::new());
993 let inner = tree.add_child(outer, StackWidget::new());
994 tree.add_child(inner, TextPaintWidget);
995 tree.set_transform(outer, teksilo_canvas::Transform2D::scale(2.0, 2.0));
996 tree.set_content_transform(inner, teksilo_canvas::Transform2D::scale(1.5, 1.5));
997
998 tree.layout(SizeProposal::exact(400.0, 300.0));
999 let _ = tree.render();
1000 assert_eq!(layout_scales.borrow().as_slice(), &[1.25_f32.powi(5)]);
1001 }
1002
1003 #[test]
1004 fn invalidate_all_paints_clears_post_paint_cache() {
1005 #[derive(Debug)]
1008 struct PostPaintWidget;
1009
1010 impl Widget for PostPaintWidget {
1011 fn layout_response(
1012 &self,
1013 proposal: SizeProposal,
1014 _ctx: &LayoutContext,
1015 ) -> crate::widget::LayoutResponse {
1016 proposal.resolve(50.0, 50.0).into()
1017 }
1018
1019 fn wants_post_paint(&self) -> bool {
1020 true
1021 }
1022
1023 fn post_paint(
1024 &self,
1025 bounds: teksilo_canvas::Rect,
1026 canvas: &mut teksilo_canvas::Canvas,
1027 _ctx: &PaintContext,
1028 ) {
1029 canvas.fill_rect(bounds, Color::RED);
1030 }
1031 }
1032
1033 let mut tree = WidgetTree::new().with_theme(crate::presets::intui::light());
1034 let id = tree.add(PostPaintWidget);
1035 tree.layout(SizeProposal::exact(100.0, 100.0));
1036 let _ = tree.render();
1037 assert!(
1038 tree.arena
1039 .get(id)
1040 .and_then(|n| n.cached_post_paint.as_ref())
1041 .is_some(),
1042 "render must populate cached_post_paint for a post-painting widget"
1043 );
1044
1045 tree.invalidate_all_paints();
1046 assert!(
1047 tree.arena
1048 .get(id)
1049 .and_then(|n| n.cached_post_paint.as_ref())
1050 .is_none(),
1051 "invalidate_all_paints must clear cached_post_paint too — a retained \
1052 post-paint frame can hold stale glyph UVs after atlas eviction"
1053 );
1054 }
1055
1056 #[derive(Debug)]
1057 struct ThemeAwareWidget;
1058
1059 impl Widget for ThemeAwareWidget {
1060 fn layout_response(
1061 &self,
1062 proposal: SizeProposal,
1063 _ctx: &LayoutContext,
1064 ) -> crate::widget::LayoutResponse {
1065 proposal.resolve(0.0, 0.0).into()
1066 }
1067
1068 fn paint(
1069 &self,
1070 bounds: teksilo_canvas::Rect,
1071 canvas: &mut teksilo_canvas::Canvas,
1072 ctx: &PaintContext,
1073 ) {
1074 canvas.fill_rounded_rect(
1075 bounds,
1076 teksilo_tokens::CornerRadius::uniform(4.0),
1077 ctx.theme.colors.accent,
1078 );
1079 }
1080 }
1081
1082 #[test]
1083 fn fill_widget_produces_shape_in_frame() {
1084 let mut tree = WidgetTree::new().with_theme(crate::presets::intui::light());
1085 tree.add(
1086 FillWidget::new()
1087 .background(Color::RED)
1088 .corner_radius(CornerRadius::uniform(6.0)),
1089 );
1090 tree.layout(SizeProposal::exact(100.0, 40.0));
1091 let frame = tree.render();
1092 assert_eq!(frame.shapes.len(), 1);
1093 assert_eq!(
1094 frame.shapes[0].shape,
1095 teksilo_canvas::ShapeKind::RoundedRect
1096 );
1097 }
1098
1099 #[test]
1100 fn empty_tree_renders_empty_frame() {
1101 let mut tree = WidgetTree::new();
1102 let frame = tree.render();
1103 assert!(frame.is_empty());
1104 }
1105
1106 #[test]
1107 fn render_clears_paint_dirty() {
1108 let mut tree = WidgetTree::new();
1109 tree.add(FillWidget::new().background(Color::RED));
1110 tree.layout(SizeProposal::exact(100.0, 50.0));
1111 assert!(tree.needs_paint());
1112 tree.render();
1113 assert!(!tree.needs_paint());
1114 }
1115
1116 #[test]
1117 fn dormant_widget_not_rendered() {
1118 let mut tree = WidgetTree::new();
1119 let widget = tree.add(
1120 FillWidget::new()
1121 .background(Color::RED)
1122 .corner_radius(CornerRadius::uniform(4.0)),
1123 );
1124 tree.layout(SizeProposal::exact(100.0, 50.0));
1125 let frame = tree.render();
1126 assert!(!frame.shapes.is_empty());
1127
1128 tree.set_dormant(widget);
1129 tree.layout(SizeProposal::exact(100.0, 50.0));
1130 let frame = tree.render();
1131 assert!(frame.shapes.is_empty());
1132 }
1133
1134 #[test]
1135 fn dormancy_is_recursive() {
1136 let mut tree = WidgetTree::new();
1137 let child = tree.add(
1138 FillWidget::new()
1139 .background(Color::RED)
1140 .corner_radius(CornerRadius::uniform(4.0)),
1141 );
1142 let parent = tree.add(StackWidget::new().child(child));
1143 tree.layout(SizeProposal::exact(100.0, 50.0));
1144
1145 let frame = tree.render();
1146 assert_eq!(frame.shapes.len(), 1);
1147
1148 tree.set_dormant(parent);
1149 tree.layout(SizeProposal::exact(100.0, 50.0));
1150 let frame = tree.render();
1151 assert!(frame.shapes.is_empty());
1152
1153 tree.activate(parent);
1154 tree.layout(SizeProposal::exact(100.0, 50.0));
1155 let frame = tree.render();
1156 assert_eq!(frame.shapes.len(), 1);
1157 }
1158
1159 #[test]
1160 fn set_theme_marks_all_dirty() {
1161 let mut tree = WidgetTree::new().with_theme(crate::presets::intui::light());
1162 tree.add(FillWidget::new().background(Color::RED));
1163 tree.layout(SizeProposal::exact(100.0, 50.0));
1164 tree.render();
1165
1166 assert!(!tree.needs_layout());
1167 assert!(!tree.needs_paint());
1168
1169 tree.set_theme(crate::presets::intui::dark());
1170 assert!(tree.needs_layout());
1171 assert!(tree.needs_paint());
1172 }
1173
1174 #[test]
1175 fn set_theme_changes_rendered_colors() {
1176 let mut tree = WidgetTree::new().with_theme(crate::presets::intui::light());
1177 tree.add(ThemeAwareWidget);
1178 tree.layout(SizeProposal::exact(100.0, 50.0));
1179 let light_frame = tree.render();
1180 let light_color = light_frame.shapes[0].color;
1181
1182 tree.set_theme(crate::presets::intui::dark());
1183 tree.layout(SizeProposal::exact(100.0, 50.0));
1184 let dark_frame = tree.render();
1185 let dark_color = dark_frame.shapes[0].color;
1186
1187 assert_ne!(light_color, dark_color);
1188 }
1189
1190 #[test]
1191 fn subtree_theme_override() {
1192 let mut tree = WidgetTree::new().with_theme(crate::presets::intui::light());
1193 let parent = tree.add(ThemeAwareWidget);
1194 let _child = tree.add_child(parent, ThemeAwareWidget);
1195
1196 tree.set_theme_override(parent, |theme| {
1197 theme.colors = teksilo_tokens::ColorTokens::dark_default();
1198 });
1199
1200 tree.layout(SizeProposal::exact(100.0, 50.0));
1201 let frame = tree.render();
1202
1203 let dark_accent = teksilo_tokens::ColorTokens::dark_default()
1204 .accent
1205 .to_array();
1206 assert_eq!(frame.shapes[0].color, dark_accent);
1207 assert_eq!(frame.shapes[1].color, dark_accent);
1208 }
1209
1210 #[test]
1211 fn theme_override_only_affects_subtree() {
1212 let mut tree = WidgetTree::new().with_theme(crate::presets::intui::light());
1213
1214 let _unaffected = tree.add(ThemeAwareWidget);
1215 let overridden = tree.add(ThemeAwareWidget);
1216
1217 tree.set_theme_override(overridden, |theme| {
1218 theme.colors = teksilo_tokens::ColorTokens::dark_default();
1219 });
1220
1221 tree.layout(SizeProposal::exact(100.0, 50.0));
1222 let frame = tree.render();
1223
1224 let light_accent = teksilo_tokens::ColorTokens::light_default()
1225 .accent
1226 .to_array();
1227 let dark_accent = teksilo_tokens::ColorTokens::dark_default()
1228 .accent
1229 .to_array();
1230
1231 assert_eq!(frame.shapes[0].color, light_accent);
1232 assert_eq!(frame.shapes[1].color, dark_accent);
1233 }
1234
1235 #[test]
1236 fn resolved_theme_reflects_overrides() {
1237 let mut tree = WidgetTree::new().with_theme(crate::presets::intui::light());
1238
1239 let parent = tree.add(FillWidget::new());
1240 let child = tree.add_child(parent, FillWidget::new());
1241
1242 tree.set_theme_override(parent, |theme| {
1243 theme.colors.accent = Color::RED;
1244 });
1245
1246 tree.layout(SizeProposal::exact(100.0, 50.0));
1247
1248 let parent_theme = tree.resolved_theme(parent);
1249 assert_eq!(parent_theme.colors.accent, Color::RED);
1250
1251 let child_theme = tree.resolved_theme(child);
1252 assert_eq!(child_theme.colors.accent, Color::RED);
1253 }
1254
1255 #[test]
1256 fn opacity_prop_emits_set_and_restore_around_subtree() {
1257 let mut tree = WidgetTree::new().with_theme(crate::presets::intui::light());
1262 let parent = tree.add(StackWidget::new());
1263 let _child = tree.add_child(parent, FillWidget::new().background(Color::RED));
1264 tree.set_opacity(parent, 0.5_f32);
1265 tree.layout(SizeProposal::exact(100.0, 50.0));
1266 let frame = tree.render();
1267
1268 let mut set_count = 0;
1269 let mut restore_count = 0;
1270 for cmd in &frame.draw_order {
1271 match cmd {
1272 teksilo_canvas::DrawCommand::SetOpacity(v) => {
1273 assert!((v - 0.5).abs() < 1e-6, "expected 0.5, got {}", v);
1274 set_count += 1;
1275 }
1276 teksilo_canvas::DrawCommand::RestoreOpacity => restore_count += 1,
1277 _ => {}
1278 }
1279 }
1280 assert_eq!(set_count, 1, "draw_order = {:?}", frame.draw_order);
1281 assert_eq!(restore_count, 1);
1282 }
1283
1284 #[test]
1285 fn opacity_prop_zero_skips_subtree_entirely() {
1286 let mut tree = WidgetTree::new().with_theme(crate::presets::intui::light());
1289 let parent = tree.add(StackWidget::new());
1290 tree.add_child(parent, FillWidget::new().background(Color::RED));
1291 tree.set_opacity(parent, 0.0_f32);
1292 tree.layout(SizeProposal::exact(100.0, 50.0));
1293 let frame = tree.render();
1294
1295 for cmd in &frame.draw_order {
1296 assert!(
1297 !matches!(cmd, teksilo_canvas::DrawCommand::SetOpacity(_)),
1298 "fully-transparent subtree should not emit SetOpacity"
1299 );
1300 }
1301 assert!(
1303 !frame
1304 .shapes
1305 .iter()
1306 .any(|s| s.color == Color::RED.to_array()),
1307 "fully-transparent subtree should not paint its descendants"
1308 );
1309 }
1310
1311 #[test]
1312 fn transform_prop_emits_push_and_pop_around_subtree() {
1313 let mut tree = WidgetTree::new().with_theme(crate::presets::intui::light());
1318 let parent = tree.add(StackWidget::new());
1319 let _child = tree.add_child(parent, FillWidget::new().background(Color::RED));
1320 let scale_2x = teksilo_canvas::Transform2D::scale(2.0, 2.0);
1321 tree.set_transform(parent, scale_2x);
1322 tree.layout(SizeProposal::exact(100.0, 50.0));
1323 let frame = tree.render();
1324
1325 let mut push_count = 0;
1326 let mut pop_count = 0;
1327 let mut push_value = None;
1328 for cmd in &frame.draw_order {
1329 match cmd {
1330 teksilo_canvas::DrawCommand::PushTransform(t) => {
1331 push_count += 1;
1332 push_value = Some(*t);
1333 }
1334 teksilo_canvas::DrawCommand::PopTransform => pop_count += 1,
1335 _ => {}
1336 }
1337 }
1338 assert_eq!(push_count, 1, "draw_order = {:?}", frame.draw_order);
1339 assert_eq!(pop_count, 1);
1340 assert_eq!(push_value, Some(scale_2x));
1341 }
1342
1343 #[test]
1344 fn content_transform_clip_wraps_outside_the_transform() {
1345 let mut tree = WidgetTree::new().with_theme(crate::presets::intui::light());
1353 let parent = tree.add(StackWidget::new());
1354 tree.add_child(parent, FillWidget::new().background(Color::RED));
1355 tree.layout(SizeProposal::exact(100.0, 50.0));
1356 tree.set_clips_children(parent, true);
1358 tree.set_content_transform(parent, teksilo_canvas::Transform2D::translate(50.0, 30.0));
1359 let frame = tree.render();
1360
1361 let mut set_clip = None;
1362 let mut push = None;
1363 let mut pop = None;
1364 let mut clear = None;
1365 for (i, cmd) in frame.draw_order.iter().enumerate() {
1366 match cmd {
1367 teksilo_canvas::DrawCommand::SetClip(_) => set_clip = set_clip.or(Some(i)),
1368 teksilo_canvas::DrawCommand::PushTransform(_) => push = push.or(Some(i)),
1369 teksilo_canvas::DrawCommand::PopTransform => pop = Some(i),
1370 teksilo_canvas::DrawCommand::ClearClip => clear = Some(i),
1371 _ => {}
1372 }
1373 }
1374 let (sc, pt, pop, cc) = (
1375 set_clip.expect("SetClip emitted"),
1376 push.expect("PushTransform emitted"),
1377 pop.expect("PopTransform emitted"),
1378 clear.expect("ClearClip emitted"),
1379 );
1380 assert!(
1381 sc < pt,
1382 "clip must open before the transform: SetClip@{sc}, PushTransform@{pt}; order={:?}",
1383 frame.draw_order
1384 );
1385 assert!(
1386 pop < cc,
1387 "clip must close after the transform: PopTransform@{pop}, ClearClip@{cc}"
1388 );
1389 }
1390
1391 #[test]
1392 fn identity_transform_prop_skipped() {
1393 let mut tree = WidgetTree::new().with_theme(crate::presets::intui::light());
1398 let parent = tree.add(StackWidget::new());
1399 tree.add_child(parent, FillWidget::new().background(Color::RED));
1400 tree.set_transform(parent, teksilo_canvas::Transform2D::IDENTITY);
1401 tree.layout(SizeProposal::exact(100.0, 50.0));
1402 let frame = tree.render();
1403
1404 for cmd in &frame.draw_order {
1405 assert!(
1406 !matches!(
1407 cmd,
1408 teksilo_canvas::DrawCommand::PushTransform(_)
1409 | teksilo_canvas::DrawCommand::PopTransform
1410 ),
1411 "identity transform must not emit a push/pop scope"
1412 );
1413 }
1414 }
1415
1416 #[test]
1417 fn transform_scope_paint_order_opacity_outer_transform_inner() {
1418 let mut tree = WidgetTree::new().with_theme(crate::presets::intui::light());
1423 let parent = tree.add(StackWidget::new());
1424 tree.add_child(parent, FillWidget::new().background(Color::RED));
1425 tree.set_opacity(parent, 0.7_f32);
1426 tree.set_transform(parent, teksilo_canvas::Transform2D::scale(2.0, 2.0));
1427 tree.layout(SizeProposal::exact(100.0, 50.0));
1428 let frame = tree.render();
1429
1430 let mut set_opacity_idx = None;
1432 let mut push_transform_idx = None;
1433 let mut pop_transform_idx = None;
1434 let mut restore_opacity_idx = None;
1435 for (i, cmd) in frame.draw_order.iter().enumerate() {
1436 match cmd {
1437 teksilo_canvas::DrawCommand::SetOpacity(_) => set_opacity_idx = Some(i),
1438 teksilo_canvas::DrawCommand::PushTransform(_) => push_transform_idx = Some(i),
1439 teksilo_canvas::DrawCommand::PopTransform => pop_transform_idx = Some(i),
1440 teksilo_canvas::DrawCommand::RestoreOpacity => restore_opacity_idx = Some(i),
1441 _ => {}
1442 }
1443 }
1444 let so = set_opacity_idx.expect("SetOpacity emitted");
1445 let pt = push_transform_idx.expect("PushTransform emitted");
1446 let popt = pop_transform_idx.expect("PopTransform emitted");
1447 let ro = restore_opacity_idx.expect("RestoreOpacity emitted");
1448 assert!(so < pt, "opacity must open before transform: {so} < {pt}");
1449 assert!(pt < popt, "transform push must precede its pop");
1450 assert!(
1451 popt < ro,
1452 "transform must close before opacity: {popt} < {ro}"
1453 );
1454 }
1455
1456 #[derive(Debug)]
1460 struct Sandwich {
1461 child: Option<crate::WidgetId>,
1462 }
1463
1464 impl Widget for Sandwich {
1465 fn build(&mut self, ctx: &mut crate::build_context::BuildContext) -> Vec<crate::WidgetId> {
1466 let c = ctx.add(FillWidget::new().background(Color::GREEN));
1467 self.child = Some(c);
1468 vec![c]
1469 }
1470
1471 fn layout_response(
1472 &self,
1473 proposal: SizeProposal,
1474 _ctx: &LayoutContext,
1475 ) -> crate::widget::LayoutResponse {
1476 proposal.resolve(0.0, 0.0).into()
1477 }
1478
1479 fn place_children(
1480 &self,
1481 bounds: teksilo_canvas::Rect,
1482 _proposal: SizeProposal,
1483 children: &mut [crate::widget::WidgetPlacement],
1484 _ctx: &LayoutContext,
1485 ) {
1486 for child in children.iter_mut() {
1487 child.origin = bounds.origin();
1488 child.size = bounds.size();
1489 }
1490 }
1491
1492 fn children(&self) -> Vec<crate::WidgetId> {
1493 self.child.iter().copied().collect()
1494 }
1495
1496 fn paint(
1497 &self,
1498 bounds: teksilo_canvas::Rect,
1499 canvas: &mut teksilo_canvas::Canvas,
1500 _ctx: &PaintContext,
1501 ) {
1502 canvas.fill_rounded_rect(bounds, CornerRadius::uniform(0.0), Color::RED);
1503 }
1504
1505 fn wants_post_paint(&self) -> bool {
1506 true
1507 }
1508
1509 fn post_paint(
1510 &self,
1511 bounds: teksilo_canvas::Rect,
1512 canvas: &mut teksilo_canvas::Canvas,
1513 _ctx: &PaintContext,
1514 ) {
1515 canvas.fill_rounded_rect(bounds, CornerRadius::uniform(0.0), Color::BLUE);
1516 }
1517 }
1518
1519 #[test]
1520 fn post_paint_emits_after_children() {
1521 let mut tree = WidgetTree::new().with_theme(crate::presets::intui::light());
1526 tree.add(Sandwich { child: None });
1527 tree.layout(SizeProposal::exact(100.0, 50.0));
1528 let frame = tree.render();
1529
1530 let color_of = |cmd: &teksilo_canvas::DrawCommand| -> Option<[f32; 4]> {
1531 match cmd {
1532 teksilo_canvas::DrawCommand::Shape(i) => frame.shapes.get(*i).map(|s| s.color),
1533 teksilo_canvas::DrawCommand::Decoration(i) => {
1534 frame.decorations.get(*i).map(|d| d.color)
1535 }
1536 _ => None,
1537 }
1538 };
1539 let find = |dominant: usize| -> Option<usize> {
1541 frame.draw_order.iter().position(|cmd| {
1542 color_of(cmd).is_some_and(|c| {
1543 c[dominant] > 0.5 && (0..3).all(|ch| ch == dominant || c[ch] < 0.5)
1544 })
1545 })
1546 };
1547 let red = find(0).expect("backdrop (RED) painted");
1548 let green = find(1).expect("child (GREEN) painted");
1549 let blue = find(2).expect("foreground (BLUE) painted");
1550 assert!(
1551 red < green && green < blue,
1552 "expected backdrop < child < foreground; RED@{red}, GREEN@{green}, BLUE@{blue}; order={:?}",
1553 frame.draw_order
1554 );
1555 }
1556
1557 #[test]
1558 fn blur_prop_emits_begin_end_pair_around_subtree() {
1559 let mut tree = WidgetTree::new().with_theme(crate::presets::intui::light());
1565 let parent = tree.add(StackWidget::new());
1566 let _child = tree.add_child(parent, FillWidget::new().background(Color::RED));
1567 tree.set_blur(parent, 8.0_f32);
1568 tree.layout(SizeProposal::exact(100.0, 50.0));
1569 let frame = tree.render();
1570
1571 let mut begin_count = 0;
1572 let mut end_count = 0;
1573 let mut begin_radius = None;
1574 for cmd in &frame.draw_order {
1575 match cmd {
1576 teksilo_canvas::DrawCommand::BeginBlurredSubtree { radius, .. } => {
1577 begin_count += 1;
1578 begin_radius = Some(*radius);
1579 }
1580 teksilo_canvas::DrawCommand::EndBlurredSubtree => end_count += 1,
1581 _ => {}
1582 }
1583 }
1584 assert_eq!(begin_count, 1, "draw_order = {:?}", frame.draw_order);
1585 assert_eq!(end_count, 1);
1586 assert_eq!(begin_radius, Some(8.0));
1587 }
1588
1589 #[test]
1590 fn blur_prop_subperceptual_radius_skipped() {
1591 let mut tree = WidgetTree::new().with_theme(crate::presets::intui::light());
1595 let parent = tree.add(StackWidget::new());
1596 tree.add_child(parent, FillWidget::new().background(Color::RED));
1597 tree.set_blur(parent, 0.2_f32);
1598 tree.layout(SizeProposal::exact(100.0, 50.0));
1599 let frame = tree.render();
1600
1601 for cmd in &frame.draw_order {
1602 assert!(
1603 !matches!(
1604 cmd,
1605 teksilo_canvas::DrawCommand::BeginBlurredSubtree { .. }
1606 | teksilo_canvas::DrawCommand::EndBlurredSubtree
1607 ),
1608 "sub-perceptual blur must not emit Begin/End"
1609 );
1610 }
1611 }
1612
1613 #[test]
1614 fn blur_scope_is_outermost_when_combined_with_opacity_and_transform() {
1615 let mut tree = WidgetTree::new().with_theme(crate::presets::intui::light());
1622 let parent = tree.add(StackWidget::new());
1623 tree.add_child(parent, FillWidget::new().background(Color::RED));
1624 tree.set_blur(parent, 8.0_f32);
1625 tree.set_opacity(parent, 0.7_f32);
1626 tree.set_transform(parent, teksilo_canvas::Transform2D::scale(2.0, 2.0));
1627 tree.layout(SizeProposal::exact(100.0, 50.0));
1628 let frame = tree.render();
1629
1630 let mut begin_idx = None;
1631 let mut set_opacity_idx = None;
1632 let mut push_transform_idx = None;
1633 let mut pop_transform_idx = None;
1634 let mut restore_opacity_idx = None;
1635 let mut end_idx = None;
1636 for (i, cmd) in frame.draw_order.iter().enumerate() {
1637 match cmd {
1638 teksilo_canvas::DrawCommand::BeginBlurredSubtree { .. } => begin_idx = Some(i),
1639 teksilo_canvas::DrawCommand::SetOpacity(_) => set_opacity_idx = Some(i),
1640 teksilo_canvas::DrawCommand::PushTransform(_) => push_transform_idx = Some(i),
1641 teksilo_canvas::DrawCommand::PopTransform => pop_transform_idx = Some(i),
1642 teksilo_canvas::DrawCommand::RestoreOpacity => restore_opacity_idx = Some(i),
1643 teksilo_canvas::DrawCommand::EndBlurredSubtree => end_idx = Some(i),
1644 _ => {}
1645 }
1646 }
1647 let bg = begin_idx.expect("Begin emitted");
1648 let so = set_opacity_idx.expect("SetOpacity emitted");
1649 let pt = push_transform_idx.expect("PushTransform emitted");
1650 let popt = pop_transform_idx.expect("PopTransform emitted");
1651 let ro = restore_opacity_idx.expect("RestoreOpacity emitted");
1652 let en = end_idx.expect("End emitted");
1653 assert!(bg < so, "blur opens before opacity");
1654 assert!(so < pt, "opacity opens before transform");
1655 assert!(pt < popt);
1656 assert!(popt < ro, "transform closes before opacity");
1657 assert!(ro < en, "opacity closes before blur");
1658 }
1659
1660 #[test]
1661 fn nested_theme_overrides_compose() {
1662 let mut tree = WidgetTree::new().with_theme(crate::presets::intui::light());
1663
1664 let grandparent = tree.add(FillWidget::new());
1665 let parent = tree.add_child(grandparent, FillWidget::new());
1666 let child = tree.add_child(parent, FillWidget::new());
1667
1668 tree.set_theme_override(grandparent, |theme| {
1669 theme.colors.accent = Color::RED;
1670 });
1671 tree.set_theme_override(parent, |theme| {
1672 theme.colors.text_secondary = Color::GREEN;
1673 });
1674
1675 tree.layout(SizeProposal::exact(100.0, 50.0));
1676
1677 let child_theme = tree.resolved_theme(child);
1678 assert_eq!(child_theme.colors.accent, Color::RED);
1679 assert_eq!(child_theme.colors.text_secondary, Color::GREEN);
1680 }
1681
1682 #[derive(Debug)]
1685 struct ColorProbe {
1686 color: Rc<std::cell::Cell<Color>>,
1687 paints: Rc<std::cell::Cell<u32>>,
1688 post_paints: Rc<std::cell::Cell<u32>>,
1689 foreground: bool,
1690 }
1691
1692 impl ColorProbe {
1693 fn new(color: Color) -> Self {
1694 Self {
1695 color: Rc::new(std::cell::Cell::new(color)),
1696 paints: Rc::new(std::cell::Cell::new(0)),
1697 post_paints: Rc::new(std::cell::Cell::new(0)),
1698 foreground: false,
1699 }
1700 }
1701 }
1702
1703 impl Widget for ColorProbe {
1704 fn layout_response(
1705 &self,
1706 proposal: SizeProposal,
1707 _ctx: &LayoutContext,
1708 ) -> crate::widget::LayoutResponse {
1709 proposal.resolve(40.0, 40.0).into()
1710 }
1711
1712 fn paint(&self, bounds: Rect, canvas: &mut Canvas, _ctx: &PaintContext) {
1713 self.paints.set(self.paints.get() + 1);
1714 canvas.fill_rect(bounds, self.color.get());
1715 }
1716
1717 fn wants_post_paint(&self) -> bool {
1718 self.foreground
1719 }
1720
1721 fn post_paint(&self, bounds: Rect, canvas: &mut Canvas, _ctx: &PaintContext) {
1722 self.post_paints.set(self.post_paints.get() + 1);
1723 canvas.fill_rect(bounds, self.color.get());
1724 }
1725 }
1726
1727 #[derive(Debug)]
1731 struct OffsetContainer {
1732 offset: f32,
1733 }
1734
1735 impl Widget for OffsetContainer {
1736 fn layout_response(
1737 &self,
1738 proposal: SizeProposal,
1739 _ctx: &LayoutContext,
1740 ) -> crate::widget::LayoutResponse {
1741 proposal.resolve(0.0, 0.0).into()
1742 }
1743
1744 fn place_children(
1745 &self,
1746 bounds: Rect,
1747 _proposal: SizeProposal,
1748 children: &mut [WidgetPlacement],
1749 _ctx: &LayoutContext,
1750 ) {
1751 for child in children.iter_mut() {
1752 child.origin = teksilo_canvas::Point::new(bounds.x + self.offset, bounds.y);
1753 child.size = teksilo_canvas::Size::new(40.0, 40.0);
1754 }
1755 }
1756 }
1757
1758 fn painted_colors(frame: &RenderFrame) -> Vec<[f32; 4]> {
1759 frame.decorations.iter().map(|d| d.color).collect()
1760 }
1761
1762 fn clipped_probe_tree(probe: ColorProbe) -> (WidgetTree, WidgetId) {
1765 let mut tree = WidgetTree::new().with_theme(crate::presets::intui::light());
1766 let root = tree.add(OffsetContainer { offset: 150.0 });
1767 tree.set_clips_children(root, true);
1768 let child = tree.add_child(root, probe);
1769 tree.layout(SizeProposal::exact(200.0, 50.0));
1770 (tree, child)
1771 }
1772
1773 #[test]
1774 fn clipped_out_dirty_node_repaints_when_it_comes_back_into_view() {
1775 let probe = ColorProbe::new(Color::RED);
1776 let (color, paints) = (probe.color.clone(), probe.paints.clone());
1777 let (mut tree, child) = clipped_probe_tree(probe);
1778 assert!(painted_colors(&tree.render()).contains(&Color::RED.to_array()));
1779
1780 tree.layout(SizeProposal::exact(100.0, 50.0));
1781 assert_eq!(tree.bounds(child).x, 150.0, "the probe must not move");
1782 color.set(Color::BLUE);
1783 tree.arena.mark_needs_paint(child);
1784 let frame = tree.render();
1785 assert!(
1786 painted_colors(&frame).is_empty(),
1787 "the probe is clipped out"
1788 );
1789 assert_eq!(paints.get(), 1, "a clipped-out node does not paint");
1790
1791 tree.layout(SizeProposal::exact(200.0, 50.0));
1792 let colors = painted_colors(&tree.render());
1793 assert_eq!(
1794 colors,
1795 vec![Color::BLUE.to_array()],
1796 "a node dirtied while clipped out must not replay its old paint"
1797 );
1798 assert_eq!(paints.get(), 2);
1799 }
1800
1801 #[test]
1802 fn clipped_out_clean_node_keeps_its_cache() {
1803 let probe = ColorProbe::new(Color::RED);
1804 let paints = probe.paints.clone();
1805 let (mut tree, child) = clipped_probe_tree(probe);
1806 let _ = tree.render();
1807
1808 tree.layout(SizeProposal::exact(100.0, 50.0));
1809 let _ = tree.render();
1810 assert!(
1811 tree.arena.get(child).unwrap().cached_paint.is_some(),
1812 "a clean node keeps its cache while clipped out"
1813 );
1814
1815 tree.layout(SizeProposal::exact(200.0, 50.0));
1816 let colors = painted_colors(&tree.render());
1817 assert_eq!(colors, vec![Color::RED.to_array()]);
1818 assert_eq!(paints.get(), 1, "the clean node replays its cache");
1819 }
1820
1821 #[test]
1822 fn node_dirtied_under_a_transparent_ancestor_repaints_when_it_fades_in() {
1823 let probe = ColorProbe::new(Color::RED);
1824 let (color, paints) = (probe.color.clone(), probe.paints.clone());
1825 let mut tree = WidgetTree::new().with_theme(crate::presets::intui::light());
1826 let parent = tree.add(StackWidget::new());
1827 let child = tree.add_child(parent, probe);
1828 let opacity = crate::signal::Signal::new(1.0_f32);
1829 tree.set_opacity(parent, opacity.clone());
1830 tree.layout(SizeProposal::exact(100.0, 50.0));
1831 let _ = tree.render();
1832
1833 opacity.set(0.0);
1834 tree.layout(SizeProposal::exact(100.0, 50.0));
1835 color.set(Color::BLUE);
1836 tree.arena.mark_needs_paint(child);
1837 let _ = tree.render();
1838 assert_eq!(paints.get(), 1, "a transparent subtree does not paint");
1839
1840 opacity.set(1.0);
1841 tree.layout(SizeProposal::exact(100.0, 50.0));
1842 let colors = painted_colors(&tree.render());
1843 assert_eq!(colors, vec![Color::BLUE.to_array()]);
1844 }
1845
1846 #[test]
1847 fn transparent_dirty_node_repaints_when_it_fades_in() {
1848 let probe = ColorProbe::new(Color::RED);
1849 let color = probe.color.clone();
1850 let mut tree = WidgetTree::new().with_theme(crate::presets::intui::light());
1851 let node = tree.add(probe);
1852 let opacity = crate::signal::Signal::new(1.0_f32);
1853 tree.set_opacity(node, opacity.clone());
1854 tree.layout(SizeProposal::exact(100.0, 50.0));
1855 let _ = tree.render();
1856
1857 opacity.set(0.0);
1864 tree.layout(SizeProposal::exact(100.0, 50.0));
1865 color.set(Color::BLUE);
1866 tree.arena.mark_needs_paint(node);
1867 let _ = tree.render();
1868 assert!(
1869 tree.arena.get(node).unwrap().cached_paint.is_none(),
1870 "a node dirtied while transparent drops its stale cache"
1871 );
1872
1873 opacity.set(1.0);
1874 tree.layout(SizeProposal::exact(100.0, 50.0));
1875 let colors = painted_colors(&tree.render());
1876 assert_eq!(colors, vec![Color::BLUE.to_array()]);
1877 }
1878
1879 #[test]
1884 fn a_node_changed_while_dormant_repaints_when_activated() {
1885 let probe = ColorProbe::new(Color::RED);
1886 let color = probe.color.clone();
1887 let mut tree = WidgetTree::new().with_theme(crate::presets::intui::light());
1888 let node = tree.add(probe);
1889 tree.layout(SizeProposal::exact(100.0, 50.0));
1890 let _ = tree.render();
1891
1892 tree.set_dormant(node);
1893 color.set(Color::BLUE);
1894 tree.arena.mark_needs_paint(node);
1895 tree.layout(SizeProposal::exact(100.0, 50.0));
1896 let _ = tree.render();
1897
1898 tree.activate(node);
1899 tree.layout(SizeProposal::exact(100.0, 50.0));
1900 let colors = painted_colors(&tree.render());
1901 assert_eq!(colors, vec![Color::BLUE.to_array()]);
1902 }
1903
1904 #[derive(Debug)]
1907 struct CountingStack {
1908 children: Vec<WidgetId>,
1909 layouts: Rc<std::cell::Cell<u32>>,
1910 }
1911
1912 impl Widget for CountingStack {
1913 fn layout_response(
1914 &self,
1915 proposal: SizeProposal,
1916 _ctx: &LayoutContext,
1917 ) -> crate::widget::LayoutResponse {
1918 self.layouts.set(self.layouts.get() + 1);
1919 proposal.resolve(0.0, 0.0).into()
1920 }
1921
1922 fn place_children(
1923 &self,
1924 bounds: Rect,
1925 _proposal: SizeProposal,
1926 children: &mut [WidgetPlacement],
1927 _ctx: &LayoutContext,
1928 ) {
1929 for child in children.iter_mut() {
1930 child.origin = bounds.origin();
1931 child.size = bounds.size();
1932 }
1933 }
1934
1935 fn children(&self) -> Vec<WidgetId> {
1936 self.children.clone()
1937 }
1938 }
1939
1940 #[test]
1944 fn a_destroyed_subtree_leaves_the_next_frame() {
1945 let mut tree = WidgetTree::new().with_theme(crate::presets::intui::light());
1946 let red = tree.add(ColorProbe::new(Color::RED));
1947 let blue = tree.add(ColorProbe::new(Color::BLUE));
1948 let layouts = Rc::new(std::cell::Cell::new(0));
1949 let root = tree.add(CountingStack {
1950 children: vec![red, blue],
1951 layouts: layouts.clone(),
1952 });
1953 tree.layout(SizeProposal::exact(100.0, 50.0));
1954 assert_eq!(painted_colors(&tree.render()).len(), 2);
1955 tree.layout(SizeProposal::exact(100.0, 50.0));
1956 let settled = layouts.get();
1957
1958 tree.destroy_subtree_for_testing(red);
1959 tree.layout(SizeProposal::exact(100.0, 50.0));
1960 assert!(layouts.get() > settled, "the parent laid out again");
1961 assert_eq!(painted_colors(&tree.render()), vec![Color::BLUE.to_array()]);
1962
1963 tree.destroy_subtree_for_testing(root);
1965 tree.layout(SizeProposal::exact(100.0, 50.0));
1966 assert!(painted_colors(&tree.render()).is_empty());
1967 }
1968
1969 #[test]
1972 fn a_subtree_a_handler_destroys_leaves_the_next_frame() {
1973 use crate::widget_builder::WidgetBuilder;
1974 let mut tree = WidgetTree::new().with_theme(crate::presets::intui::light());
1975 let red = tree.add(ColorProbe::new(Color::RED));
1976 let button =
1977 tree.add(ColorProbe::new(Color::BLUE).on_tap(move |_tap, ctx| ctx.destroy(red)));
1978 tree.add(StackWidget::new().child(red).child(button));
1979 tree.layout(SizeProposal::exact(100.0, 50.0));
1980 assert_eq!(painted_colors(&tree.render()).len(), 2);
1981
1982 tree.synthesise_tap(button);
1983 assert!(tree.arena.get(red).is_none(), "the tap destroyed it");
1984 tree.layout(SizeProposal::exact(100.0, 50.0));
1985 assert_eq!(painted_colors(&tree.render()), vec![Color::BLUE.to_array()]);
1986 }
1987
1988 #[test]
1989 fn clipped_out_dirty_node_reruns_its_foreground_pass() {
1990 let mut probe = ColorProbe::new(Color::RED);
1991 probe.foreground = true;
1992 let (color, post_paints) = (probe.color.clone(), probe.post_paints.clone());
1993 let (mut tree, child) = clipped_probe_tree(probe);
1994 let _ = tree.render();
1995 assert_eq!(post_paints.get(), 1);
1996
1997 tree.layout(SizeProposal::exact(100.0, 50.0));
1998 color.set(Color::BLUE);
1999 tree.arena.mark_needs_paint(child);
2000 let _ = tree.render();
2001 assert!(
2002 tree.arena.get(child).unwrap().cached_post_paint.is_none(),
2003 "the stale foreground cache is dropped with the main one"
2004 );
2005
2006 tree.layout(SizeProposal::exact(200.0, 50.0));
2007 let colors = painted_colors(&tree.render());
2008 assert_eq!(
2009 colors,
2010 vec![Color::BLUE.to_array(), Color::BLUE.to_array()],
2011 "both passes repaint in the new colour"
2012 );
2013 assert_eq!(post_paints.get(), 2);
2014 }
2015}