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 node.widget.paint(bounds, &mut canvas, &ctx);
476 let widget_frame = canvas.into_render_frame();
477
478 frame.merge(&widget_frame);
479 if let Some(node) = arena.get_mut(id) {
480 node.cached_paint = Some(widget_frame);
481 node.paint_raster_scale = this_raster_scale;
482 node.paint_consumed_epoch = paint_epoch;
483 }
484 } else {
485 let node = arena.get(id).expect("node id is active (guarded above)");
486 if let Some(cached) = &node.cached_paint {
487 if !cached.layout_keys.is_empty()
495 && let Some(tb) = text_backend
496 {
497 let mut tb = tb.borrow_mut();
498 for key in &cached.layout_keys {
499 tb.touch_layout(*key);
500 }
501 #[cfg(debug_assertions)]
502 debug_validate_layout_keys(&*tb, &cached.layout_keys, Some(id));
503 }
504 frame.merge(cached);
505 }
506 }
507
508 let node = arena.get(id).expect("node id is active (guarded above)");
509 let children: Vec<WidgetId> = node.children.clone();
510 let next_clip = if clips {
511 Some(match clip_bounds {
512 Some(clip) => {
513 let x0 = bounds.x.max(clip.x);
514 let y0 = bounds.y.max(clip.y);
515 let x1 = bounds.right().min(clip.right());
516 let y1 = bounds.bottom().min(clip.bottom());
517 Rect::new(x0, y0, (x1 - x0).max(0.0), (y1 - y0).max(0.0))
518 }
519 None => bounds,
520 })
521 } else {
522 clip_bounds
523 };
524
525 let next_clip = match (
539 content_transform,
540 next_clip,
541 transform.and_then(|t| t.inverse()),
542 ) {
543 (true, Some(screen_clip), Some(inv)) => Some(inv.apply_rect(screen_clip)),
544 _ => next_clip,
545 };
546
547 if clips && !clip_outside_transform {
551 frame
552 .draw_order
553 .push(teksilo_canvas::DrawCommand::SetClip(bounds));
554 }
555
556 for child_id in children {
557 if overlay_skip.contains(&child_id) {
563 continue;
564 }
565 paint_widget_cached(
566 arena,
567 child_id,
568 frame,
569 base_theme,
570 text_backend,
571 next_clip,
572 a11y_prefs,
573 paint_epoch,
574 overlay_skip,
575 overlay_rects,
576 layout_direction,
577 this_effective_enabled,
578 this_raster_scale,
579 );
580 }
581
582 {
590 let arena_ref: &WidgetArena = &*arena;
591 if let Some(node) = arena_ref.get(id)
592 && node.widget.wants_after_paint()
593 {
594 let view = crate::widget::WidgetTreeView::new(arena_ref, overlay_rects);
595 let resolved_theme = arena_ref.resolve_theme(id, base_theme);
596 let ctx = PaintContext {
597 theme: &resolved_theme,
598 scale_factor: this_raster_scale,
599 text_scale: a11y_prefs.text_scale,
600 layout_direction,
601 effective_enabled: this_effective_enabled,
602 prefers_high_contrast: a11y_prefs.high_contrast,
603 prefers_reduced_motion: a11y_prefs.reduced_motion,
604 prefers_large_text: a11y_prefs.large_text,
605 window_active: a11y_prefs.window_active,
606 clip_bounds,
607 };
608 node.widget.after_paint(&view, &ctx);
609 }
610 }
611
612 let wants_post_paint = arena
621 .get(id)
622 .map(|n| n.widget.wants_post_paint())
623 .unwrap_or(false);
624 if wants_post_paint {
625 let has_post_cache = arena
626 .get(id)
627 .map(|n| n.cached_post_paint.is_some())
628 .unwrap_or(false);
629 if needs_paint || !has_post_cache {
630 if raster_scale_changed && let Some(tb) = text_backend {
635 tb.borrow_mut().set_raster_scale(this_raster_scale);
636 }
637 let resolved_theme = arena.resolve_theme(id, base_theme);
638 let ctx = PaintContext {
639 theme: &resolved_theme,
640 scale_factor: this_raster_scale,
641 text_scale: a11y_prefs.text_scale,
642 layout_direction,
643 effective_enabled: this_effective_enabled,
644 prefers_high_contrast: a11y_prefs.high_contrast,
645 prefers_reduced_motion: a11y_prefs.reduced_motion,
646 prefers_large_text: a11y_prefs.large_text,
647 window_active: a11y_prefs.window_active,
648 clip_bounds,
649 };
650 let bounds = arena.bounds(id);
651 let node = arena.get(id).expect("node id is active (guarded above)");
652 let mut canvas = match text_backend {
653 Some(tb) => Canvas::with_text_backend(tb.clone()),
654 None => Canvas::new(),
655 };
656 node.widget.post_paint(bounds, &mut canvas, &ctx);
657 let post_frame = canvas.into_render_frame();
658 frame.merge(&post_frame);
659 if let Some(node) = arena.get_mut(id) {
660 node.cached_post_paint = Some(post_frame);
661 }
662 } else if let Some(cached) = arena.get(id).and_then(|n| n.cached_post_paint.as_ref()) {
663 if !cached.layout_keys.is_empty()
664 && let Some(tb) = text_backend
665 {
666 let mut tb = tb.borrow_mut();
667 for key in &cached.layout_keys {
668 tb.touch_layout(*key);
669 }
670 #[cfg(debug_assertions)]
671 debug_validate_layout_keys(&*tb, &cached.layout_keys, Some(id));
672 }
673 frame.merge(cached);
674 }
675 }
676
677 if clips && !clip_outside_transform {
678 frame
679 .draw_order
680 .push(teksilo_canvas::DrawCommand::ClearClip);
681 }
682
683 if push_transform.is_some() {
684 frame
685 .draw_order
686 .push(teksilo_canvas::DrawCommand::PopTransform);
687 }
688
689 if clip_outside_transform {
692 frame
693 .draw_order
694 .push(teksilo_canvas::DrawCommand::ClearClip);
695 }
696
697 if opacity.is_some() {
698 frame
699 .draw_order
700 .push(teksilo_canvas::DrawCommand::RestoreOpacity);
701 }
702
703 if blur_radius.is_some() {
704 frame
705 .draw_order
706 .push(teksilo_canvas::DrawCommand::EndBlurredSubtree);
707 }
708
709 if raster_scale_changed && let Some(tb) = text_backend {
712 tb.borrow_mut().set_raster_scale(accumulated_raster_scale);
713 }
714}
715
716#[cfg(debug_assertions)]
729fn debug_validate_layout_keys(
730 tb: &dyn teksilo_canvas::TextBackend,
731 layout_keys: &[u64],
732 widget: Option<WidgetId>,
733) {
734 use teksilo_canvas::GlyphValidation;
735 std::thread_local! {
736 static REPORTED_STALE_KEYS: std::cell::RefCell<std::collections::HashSet<u64>> =
737 std::cell::RefCell::new(std::collections::HashSet::new());
738 }
739 for key in layout_keys {
740 match tb.debug_validate_layout(*key) {
741 GlyphValidation::Valid => {}
742 GlyphValidation::StaleKey => {
743 let first_report = REPORTED_STALE_KEYS.with(|set| set.borrow_mut().insert(*key));
744 if first_report {
745 eprintln!(
746 "[teksilo] WARNING: retained paint cache replays layout_key={key} \
747 (widget={widget:?}) that the text backend no longer knows — the \
748 frame survived a backend cache clear, so a paint-cache \
749 invalidation path is likely missing."
750 );
751 }
752 }
753 GlyphValidation::RectMismatch => {
754 panic!(
755 "stale glyph UVs in retained paint cache (layout_key={key}, \
756 widget={widget:?}): cached quads no longer match the live glyph \
757 atlas — a cache-invalidation path is missing"
758 );
759 }
760 }
761 }
762}
763
764#[cfg(test)]
765mod tests {
766 use super::*;
767 use crate::test_widgets::{FillWidget, StackWidget};
768 use teksilo_tokens::{Color, CornerRadius};
769
770 #[derive(Default)]
777 struct RecordingTextBackend {
778 touched: std::rc::Rc<std::cell::RefCell<Vec<u64>>>,
779 raster_scale: f32,
780 layout_scales: std::rc::Rc<std::cell::RefCell<Vec<f32>>>,
783 }
784
785 impl RecordingTextBackend {
786 fn new(touched: std::rc::Rc<std::cell::RefCell<Vec<u64>>>) -> Self {
787 Self {
788 touched,
789 raster_scale: 1.0,
790 layout_scales: Default::default(),
791 }
792 }
793 }
794
795 impl teksilo_canvas::TextBackend for RecordingTextBackend {
796 fn set_raster_scale(&mut self, raster_scale: f32) {
797 self.raster_scale = raster_scale;
798 }
799
800 fn raster_scale(&self) -> f32 {
801 self.raster_scale
802 }
803
804 fn layout_single_line(
805 &mut self,
806 text: &str,
807 _style: &teksilo_tokens::TextStyle,
808 _max_width: Option<f32>,
809 ) -> teksilo_canvas::TextLayout {
810 self.layout_scales.borrow_mut().push(self.raster_scale);
811 teksilo_canvas::TextLayout {
812 width: text.len() as f32 * 8.0,
813 height: 16.0,
814 ascent: 12.0,
815 descent: 4.0,
816 underline_offset: 1.0,
817 underline_thickness: 1.0,
818 layout_key: 42,
819 line_count: 1,
820 spans: Vec::new(),
821 raster_scale: self.raster_scale,
822 geometry: None,
823 }
824 }
825
826 fn ensure_glyphs(
827 &mut self,
828 _layout: &teksilo_canvas::TextLayout,
829 ) -> Vec<teksilo_canvas::GlyphQuad> {
830 Vec::new()
831 }
832
833 fn touch_layout(&mut self, layout_key: u64) {
834 self.touched.borrow_mut().push(layout_key);
835 }
836 }
837
838 #[derive(Debug)]
841 struct TextPaintWidget;
842
843 impl Widget for TextPaintWidget {
844 fn layout_response(
845 &self,
846 proposal: SizeProposal,
847 _ctx: &LayoutContext,
848 ) -> crate::widget::LayoutResponse {
849 proposal.resolve(100.0, 20.0).into()
850 }
851
852 fn paint(
853 &self,
854 bounds: teksilo_canvas::Rect,
855 canvas: &mut teksilo_canvas::Canvas,
856 _ctx: &PaintContext,
857 ) {
858 let style = teksilo_tokens::TextStyle::default();
859 let _ = canvas.draw_text("hello", bounds, &style, Color::BLACK);
860 }
861 }
862
863 #[test]
864 fn full_frame_cache_hit_touches_layout_keys() {
865 let touched = std::rc::Rc::new(std::cell::RefCell::new(Vec::new()));
866 let backend = RecordingTextBackend::new(touched.clone());
867 let mut tree = WidgetTree::new()
868 .with_theme(crate::presets::intui::light())
869 .with_text_backend(std::rc::Rc::new(std::cell::RefCell::new(backend)));
870 tree.add(TextPaintWidget);
871 tree.layout(SizeProposal::exact(200.0, 50.0));
872
873 let _ = tree.render();
875 touched.borrow_mut().clear();
876
877 let _ = tree.render();
881 assert!(
882 touched.borrow().contains(&42),
883 "full-frame cache hit must touch_layout every baked layout key; \
884 touched = {:?}",
885 touched.borrow()
886 );
887 }
888
889 fn scaled_subtree_tree(
900 transform: impl Into<crate::signal::Prop<teksilo_canvas::Transform2D>>,
901 ) -> (
902 WidgetTree,
903 std::rc::Rc<std::cell::RefCell<RecordingTextBackend>>,
904 std::rc::Rc<std::cell::RefCell<Vec<f32>>>,
905 ) {
906 let backend = RecordingTextBackend::new(Default::default());
907 let layout_scales = backend.layout_scales.clone();
908 let backend_rc = std::rc::Rc::new(std::cell::RefCell::new(backend));
909 let mut tree = WidgetTree::new()
910 .with_theme(crate::presets::intui::light())
911 .with_text_backend(backend_rc.clone()
912 as std::rc::Rc<std::cell::RefCell<dyn teksilo_canvas::TextBackend>>);
913
914 let root = tree.add(StackWidget::new());
915 tree.add_child(root, TextPaintWidget); let wrapper = tree.add_child(root, StackWidget::new());
917 tree.add_child(wrapper, TextPaintWidget); tree.add_child(root, TextPaintWidget); tree.set_transform(wrapper, transform);
920
921 tree.layout(SizeProposal::exact(400.0, 300.0));
922 (tree, backend_rc, layout_scales)
923 }
924
925 #[test]
926 fn walker_sets_raster_scale_for_scaled_subtree_and_restores() {
927 let (mut tree, backend_rc, layout_scales) =
928 scaled_subtree_tree(teksilo_canvas::Transform2D::scale(2.0, 2.0));
929 let _ = tree.render();
930
931 let expected_b = 1.25_f32.powi(3); assert_eq!(
936 layout_scales.borrow().as_slice(),
937 &[1.0, expected_b, 1.0],
938 "text inside the transform scope must lay out at the quantized \
939 scale; siblings before/after at screen scale"
940 );
941 assert_eq!(backend_rc.borrow().raster_scale, 1.0);
943 }
944
945 #[test]
946 fn raster_scale_change_repaints_clean_descendants() {
947 let transform = crate::signal::Signal::new(teksilo_canvas::Transform2D::scale(2.0, 2.0));
948 let (mut tree, _backend_rc, layout_scales) = scaled_subtree_tree(transform.clone());
949 let _ = tree.render();
950 layout_scales.borrow_mut().clear();
951
952 transform.set(teksilo_canvas::Transform2D::scale(3.0, 3.0));
956 let _ = tree.render();
957 assert_eq!(
958 layout_scales.borrow().as_slice(),
959 &[1.25_f32.powi(5)],
960 "the clean text leaf inside the scope must re-rasterize at the \
961 new quantized scale (A and C stay cached: no layout calls)"
962 );
963
964 layout_scales.borrow_mut().clear();
966 transform.set(teksilo_canvas::Transform2D::IDENTITY);
967 let _ = tree.render();
968 assert_eq!(layout_scales.borrow().as_slice(), &[1.0]);
969
970 layout_scales.borrow_mut().clear();
973 let _ = tree.render();
974 assert!(layout_scales.borrow().is_empty());
975 }
976
977 #[test]
978 fn nested_scale_transforms_multiply() {
979 let backend = RecordingTextBackend::new(Default::default());
980 let layout_scales = backend.layout_scales.clone();
981 let mut tree = WidgetTree::new()
982 .with_theme(crate::presets::intui::light())
983 .with_text_backend(std::rc::Rc::new(std::cell::RefCell::new(backend)));
984
985 let outer = tree.add(StackWidget::new());
988 let inner = tree.add_child(outer, StackWidget::new());
989 tree.add_child(inner, TextPaintWidget);
990 tree.set_transform(outer, teksilo_canvas::Transform2D::scale(2.0, 2.0));
991 tree.set_content_transform(inner, teksilo_canvas::Transform2D::scale(1.5, 1.5));
992
993 tree.layout(SizeProposal::exact(400.0, 300.0));
994 let _ = tree.render();
995 assert_eq!(layout_scales.borrow().as_slice(), &[1.25_f32.powi(5)]);
996 }
997
998 #[test]
999 fn invalidate_all_paints_clears_post_paint_cache() {
1000 #[derive(Debug)]
1003 struct PostPaintWidget;
1004
1005 impl Widget for PostPaintWidget {
1006 fn layout_response(
1007 &self,
1008 proposal: SizeProposal,
1009 _ctx: &LayoutContext,
1010 ) -> crate::widget::LayoutResponse {
1011 proposal.resolve(50.0, 50.0).into()
1012 }
1013
1014 fn wants_post_paint(&self) -> bool {
1015 true
1016 }
1017
1018 fn post_paint(
1019 &self,
1020 bounds: teksilo_canvas::Rect,
1021 canvas: &mut teksilo_canvas::Canvas,
1022 _ctx: &PaintContext,
1023 ) {
1024 canvas.fill_rect(bounds, Color::RED);
1025 }
1026 }
1027
1028 let mut tree = WidgetTree::new().with_theme(crate::presets::intui::light());
1029 let id = tree.add(PostPaintWidget);
1030 tree.layout(SizeProposal::exact(100.0, 100.0));
1031 let _ = tree.render();
1032 assert!(
1033 tree.arena
1034 .get(id)
1035 .and_then(|n| n.cached_post_paint.as_ref())
1036 .is_some(),
1037 "render must populate cached_post_paint for a post-painting widget"
1038 );
1039
1040 tree.invalidate_all_paints();
1041 assert!(
1042 tree.arena
1043 .get(id)
1044 .and_then(|n| n.cached_post_paint.as_ref())
1045 .is_none(),
1046 "invalidate_all_paints must clear cached_post_paint too — a retained \
1047 post-paint frame can hold stale glyph UVs after atlas eviction"
1048 );
1049 }
1050
1051 #[derive(Debug)]
1052 struct ThemeAwareWidget;
1053
1054 impl Widget for ThemeAwareWidget {
1055 fn layout_response(
1056 &self,
1057 proposal: SizeProposal,
1058 _ctx: &LayoutContext,
1059 ) -> crate::widget::LayoutResponse {
1060 proposal.resolve(0.0, 0.0).into()
1061 }
1062
1063 fn paint(
1064 &self,
1065 bounds: teksilo_canvas::Rect,
1066 canvas: &mut teksilo_canvas::Canvas,
1067 ctx: &PaintContext,
1068 ) {
1069 canvas.fill_rounded_rect(
1070 bounds,
1071 teksilo_tokens::CornerRadius::uniform(4.0),
1072 ctx.theme.colors.accent,
1073 );
1074 }
1075 }
1076
1077 #[test]
1078 fn fill_widget_produces_shape_in_frame() {
1079 let mut tree = WidgetTree::new().with_theme(crate::presets::intui::light());
1080 tree.add(
1081 FillWidget::new()
1082 .background(Color::RED)
1083 .corner_radius(CornerRadius::uniform(6.0)),
1084 );
1085 tree.layout(SizeProposal::exact(100.0, 40.0));
1086 let frame = tree.render();
1087 assert_eq!(frame.shapes.len(), 1);
1088 assert_eq!(
1089 frame.shapes[0].shape,
1090 teksilo_canvas::ShapeKind::RoundedRect
1091 );
1092 }
1093
1094 #[test]
1095 fn empty_tree_renders_empty_frame() {
1096 let mut tree = WidgetTree::new();
1097 let frame = tree.render();
1098 assert!(frame.is_empty());
1099 }
1100
1101 #[test]
1102 fn render_clears_paint_dirty() {
1103 let mut tree = WidgetTree::new();
1104 tree.add(FillWidget::new().background(Color::RED));
1105 tree.layout(SizeProposal::exact(100.0, 50.0));
1106 assert!(tree.needs_paint());
1107 tree.render();
1108 assert!(!tree.needs_paint());
1109 }
1110
1111 #[test]
1112 fn dormant_widget_not_rendered() {
1113 let mut tree = WidgetTree::new();
1114 let widget = tree.add(
1115 FillWidget::new()
1116 .background(Color::RED)
1117 .corner_radius(CornerRadius::uniform(4.0)),
1118 );
1119 tree.layout(SizeProposal::exact(100.0, 50.0));
1120 let frame = tree.render();
1121 assert!(!frame.shapes.is_empty());
1122
1123 tree.set_dormant(widget);
1124 tree.layout(SizeProposal::exact(100.0, 50.0));
1125 let frame = tree.render();
1126 assert!(frame.shapes.is_empty());
1127 }
1128
1129 #[test]
1130 fn dormancy_is_recursive() {
1131 let mut tree = WidgetTree::new();
1132 let child = tree.add(
1133 FillWidget::new()
1134 .background(Color::RED)
1135 .corner_radius(CornerRadius::uniform(4.0)),
1136 );
1137 let parent = tree.add(StackWidget::new().child(child));
1138 tree.layout(SizeProposal::exact(100.0, 50.0));
1139
1140 let frame = tree.render();
1141 assert_eq!(frame.shapes.len(), 1);
1142
1143 tree.set_dormant(parent);
1144 tree.layout(SizeProposal::exact(100.0, 50.0));
1145 let frame = tree.render();
1146 assert!(frame.shapes.is_empty());
1147
1148 tree.activate(parent);
1149 tree.layout(SizeProposal::exact(100.0, 50.0));
1150 let frame = tree.render();
1151 assert_eq!(frame.shapes.len(), 1);
1152 }
1153
1154 #[test]
1155 fn set_theme_marks_all_dirty() {
1156 let mut tree = WidgetTree::new().with_theme(crate::presets::intui::light());
1157 tree.add(FillWidget::new().background(Color::RED));
1158 tree.layout(SizeProposal::exact(100.0, 50.0));
1159 tree.render();
1160
1161 assert!(!tree.needs_layout());
1162 assert!(!tree.needs_paint());
1163
1164 tree.set_theme(crate::presets::intui::dark());
1165 assert!(tree.needs_layout());
1166 assert!(tree.needs_paint());
1167 }
1168
1169 #[test]
1170 fn set_theme_changes_rendered_colors() {
1171 let mut tree = WidgetTree::new().with_theme(crate::presets::intui::light());
1172 tree.add(ThemeAwareWidget);
1173 tree.layout(SizeProposal::exact(100.0, 50.0));
1174 let light_frame = tree.render();
1175 let light_color = light_frame.shapes[0].color;
1176
1177 tree.set_theme(crate::presets::intui::dark());
1178 tree.layout(SizeProposal::exact(100.0, 50.0));
1179 let dark_frame = tree.render();
1180 let dark_color = dark_frame.shapes[0].color;
1181
1182 assert_ne!(light_color, dark_color);
1183 }
1184
1185 #[test]
1186 fn subtree_theme_override() {
1187 let mut tree = WidgetTree::new().with_theme(crate::presets::intui::light());
1188 let parent = tree.add(ThemeAwareWidget);
1189 let _child = tree.add_child(parent, ThemeAwareWidget);
1190
1191 tree.set_theme_override(parent, |theme| {
1192 theme.colors = teksilo_tokens::ColorTokens::dark_default();
1193 });
1194
1195 tree.layout(SizeProposal::exact(100.0, 50.0));
1196 let frame = tree.render();
1197
1198 let dark_accent = teksilo_tokens::ColorTokens::dark_default()
1199 .accent
1200 .to_array();
1201 assert_eq!(frame.shapes[0].color, dark_accent);
1202 assert_eq!(frame.shapes[1].color, dark_accent);
1203 }
1204
1205 #[test]
1206 fn theme_override_only_affects_subtree() {
1207 let mut tree = WidgetTree::new().with_theme(crate::presets::intui::light());
1208
1209 let _unaffected = tree.add(ThemeAwareWidget);
1210 let overridden = tree.add(ThemeAwareWidget);
1211
1212 tree.set_theme_override(overridden, |theme| {
1213 theme.colors = teksilo_tokens::ColorTokens::dark_default();
1214 });
1215
1216 tree.layout(SizeProposal::exact(100.0, 50.0));
1217 let frame = tree.render();
1218
1219 let light_accent = teksilo_tokens::ColorTokens::light_default()
1220 .accent
1221 .to_array();
1222 let dark_accent = teksilo_tokens::ColorTokens::dark_default()
1223 .accent
1224 .to_array();
1225
1226 assert_eq!(frame.shapes[0].color, light_accent);
1227 assert_eq!(frame.shapes[1].color, dark_accent);
1228 }
1229
1230 #[test]
1231 fn resolved_theme_reflects_overrides() {
1232 let mut tree = WidgetTree::new().with_theme(crate::presets::intui::light());
1233
1234 let parent = tree.add(FillWidget::new());
1235 let child = tree.add_child(parent, FillWidget::new());
1236
1237 tree.set_theme_override(parent, |theme| {
1238 theme.colors.accent = Color::RED;
1239 });
1240
1241 tree.layout(SizeProposal::exact(100.0, 50.0));
1242
1243 let parent_theme = tree.resolved_theme(parent);
1244 assert_eq!(parent_theme.colors.accent, Color::RED);
1245
1246 let child_theme = tree.resolved_theme(child);
1247 assert_eq!(child_theme.colors.accent, Color::RED);
1248 }
1249
1250 #[test]
1251 fn opacity_prop_emits_set_and_restore_around_subtree() {
1252 let mut tree = WidgetTree::new().with_theme(crate::presets::intui::light());
1257 let parent = tree.add(StackWidget::new());
1258 let _child = tree.add_child(parent, FillWidget::new().background(Color::RED));
1259 tree.set_opacity(parent, 0.5_f32);
1260 tree.layout(SizeProposal::exact(100.0, 50.0));
1261 let frame = tree.render();
1262
1263 let mut set_count = 0;
1264 let mut restore_count = 0;
1265 for cmd in &frame.draw_order {
1266 match cmd {
1267 teksilo_canvas::DrawCommand::SetOpacity(v) => {
1268 assert!((v - 0.5).abs() < 1e-6, "expected 0.5, got {}", v);
1269 set_count += 1;
1270 }
1271 teksilo_canvas::DrawCommand::RestoreOpacity => restore_count += 1,
1272 _ => {}
1273 }
1274 }
1275 assert_eq!(set_count, 1, "draw_order = {:?}", frame.draw_order);
1276 assert_eq!(restore_count, 1);
1277 }
1278
1279 #[test]
1280 fn opacity_prop_zero_skips_subtree_entirely() {
1281 let mut tree = WidgetTree::new().with_theme(crate::presets::intui::light());
1284 let parent = tree.add(StackWidget::new());
1285 tree.add_child(parent, FillWidget::new().background(Color::RED));
1286 tree.set_opacity(parent, 0.0_f32);
1287 tree.layout(SizeProposal::exact(100.0, 50.0));
1288 let frame = tree.render();
1289
1290 for cmd in &frame.draw_order {
1291 assert!(
1292 !matches!(cmd, teksilo_canvas::DrawCommand::SetOpacity(_)),
1293 "fully-transparent subtree should not emit SetOpacity"
1294 );
1295 }
1296 assert!(
1298 !frame
1299 .shapes
1300 .iter()
1301 .any(|s| s.color == Color::RED.to_array()),
1302 "fully-transparent subtree should not paint its descendants"
1303 );
1304 }
1305
1306 #[test]
1307 fn transform_prop_emits_push_and_pop_around_subtree() {
1308 let mut tree = WidgetTree::new().with_theme(crate::presets::intui::light());
1313 let parent = tree.add(StackWidget::new());
1314 let _child = tree.add_child(parent, FillWidget::new().background(Color::RED));
1315 let scale_2x = teksilo_canvas::Transform2D::scale(2.0, 2.0);
1316 tree.set_transform(parent, scale_2x);
1317 tree.layout(SizeProposal::exact(100.0, 50.0));
1318 let frame = tree.render();
1319
1320 let mut push_count = 0;
1321 let mut pop_count = 0;
1322 let mut push_value = None;
1323 for cmd in &frame.draw_order {
1324 match cmd {
1325 teksilo_canvas::DrawCommand::PushTransform(t) => {
1326 push_count += 1;
1327 push_value = Some(*t);
1328 }
1329 teksilo_canvas::DrawCommand::PopTransform => pop_count += 1,
1330 _ => {}
1331 }
1332 }
1333 assert_eq!(push_count, 1, "draw_order = {:?}", frame.draw_order);
1334 assert_eq!(pop_count, 1);
1335 assert_eq!(push_value, Some(scale_2x));
1336 }
1337
1338 #[test]
1339 fn content_transform_clip_wraps_outside_the_transform() {
1340 let mut tree = WidgetTree::new().with_theme(crate::presets::intui::light());
1348 let parent = tree.add(StackWidget::new());
1349 tree.add_child(parent, FillWidget::new().background(Color::RED));
1350 tree.layout(SizeProposal::exact(100.0, 50.0));
1351 tree.set_clips_children(parent, true);
1353 tree.set_content_transform(parent, teksilo_canvas::Transform2D::translate(50.0, 30.0));
1354 let frame = tree.render();
1355
1356 let mut set_clip = None;
1357 let mut push = None;
1358 let mut pop = None;
1359 let mut clear = None;
1360 for (i, cmd) in frame.draw_order.iter().enumerate() {
1361 match cmd {
1362 teksilo_canvas::DrawCommand::SetClip(_) => set_clip = set_clip.or(Some(i)),
1363 teksilo_canvas::DrawCommand::PushTransform(_) => push = push.or(Some(i)),
1364 teksilo_canvas::DrawCommand::PopTransform => pop = Some(i),
1365 teksilo_canvas::DrawCommand::ClearClip => clear = Some(i),
1366 _ => {}
1367 }
1368 }
1369 let (sc, pt, pop, cc) = (
1370 set_clip.expect("SetClip emitted"),
1371 push.expect("PushTransform emitted"),
1372 pop.expect("PopTransform emitted"),
1373 clear.expect("ClearClip emitted"),
1374 );
1375 assert!(
1376 sc < pt,
1377 "clip must open before the transform: SetClip@{sc}, PushTransform@{pt}; order={:?}",
1378 frame.draw_order
1379 );
1380 assert!(
1381 pop < cc,
1382 "clip must close after the transform: PopTransform@{pop}, ClearClip@{cc}"
1383 );
1384 }
1385
1386 #[test]
1387 fn identity_transform_prop_skipped() {
1388 let mut tree = WidgetTree::new().with_theme(crate::presets::intui::light());
1393 let parent = tree.add(StackWidget::new());
1394 tree.add_child(parent, FillWidget::new().background(Color::RED));
1395 tree.set_transform(parent, teksilo_canvas::Transform2D::IDENTITY);
1396 tree.layout(SizeProposal::exact(100.0, 50.0));
1397 let frame = tree.render();
1398
1399 for cmd in &frame.draw_order {
1400 assert!(
1401 !matches!(
1402 cmd,
1403 teksilo_canvas::DrawCommand::PushTransform(_)
1404 | teksilo_canvas::DrawCommand::PopTransform
1405 ),
1406 "identity transform must not emit a push/pop scope"
1407 );
1408 }
1409 }
1410
1411 #[test]
1412 fn transform_scope_paint_order_opacity_outer_transform_inner() {
1413 let mut tree = WidgetTree::new().with_theme(crate::presets::intui::light());
1418 let parent = tree.add(StackWidget::new());
1419 tree.add_child(parent, FillWidget::new().background(Color::RED));
1420 tree.set_opacity(parent, 0.7_f32);
1421 tree.set_transform(parent, teksilo_canvas::Transform2D::scale(2.0, 2.0));
1422 tree.layout(SizeProposal::exact(100.0, 50.0));
1423 let frame = tree.render();
1424
1425 let mut set_opacity_idx = None;
1427 let mut push_transform_idx = None;
1428 let mut pop_transform_idx = None;
1429 let mut restore_opacity_idx = None;
1430 for (i, cmd) in frame.draw_order.iter().enumerate() {
1431 match cmd {
1432 teksilo_canvas::DrawCommand::SetOpacity(_) => set_opacity_idx = Some(i),
1433 teksilo_canvas::DrawCommand::PushTransform(_) => push_transform_idx = Some(i),
1434 teksilo_canvas::DrawCommand::PopTransform => pop_transform_idx = Some(i),
1435 teksilo_canvas::DrawCommand::RestoreOpacity => restore_opacity_idx = Some(i),
1436 _ => {}
1437 }
1438 }
1439 let so = set_opacity_idx.expect("SetOpacity emitted");
1440 let pt = push_transform_idx.expect("PushTransform emitted");
1441 let popt = pop_transform_idx.expect("PopTransform emitted");
1442 let ro = restore_opacity_idx.expect("RestoreOpacity emitted");
1443 assert!(so < pt, "opacity must open before transform: {so} < {pt}");
1444 assert!(pt < popt, "transform push must precede its pop");
1445 assert!(
1446 popt < ro,
1447 "transform must close before opacity: {popt} < {ro}"
1448 );
1449 }
1450
1451 #[derive(Debug)]
1455 struct Sandwich {
1456 child: Option<crate::WidgetId>,
1457 }
1458
1459 impl Widget for Sandwich {
1460 fn build(&mut self, ctx: &mut crate::build_context::BuildContext) -> Vec<crate::WidgetId> {
1461 let c = ctx.add(FillWidget::new().background(Color::GREEN));
1462 self.child = Some(c);
1463 vec![c]
1464 }
1465
1466 fn layout_response(
1467 &self,
1468 proposal: SizeProposal,
1469 _ctx: &LayoutContext,
1470 ) -> crate::widget::LayoutResponse {
1471 proposal.resolve(0.0, 0.0).into()
1472 }
1473
1474 fn place_children(
1475 &self,
1476 bounds: teksilo_canvas::Rect,
1477 _proposal: SizeProposal,
1478 children: &mut [crate::widget::WidgetPlacement],
1479 _ctx: &LayoutContext,
1480 ) {
1481 for child in children.iter_mut() {
1482 child.origin = bounds.origin();
1483 child.size = bounds.size();
1484 }
1485 }
1486
1487 fn children(&self) -> Vec<crate::WidgetId> {
1488 self.child.iter().copied().collect()
1489 }
1490
1491 fn paint(
1492 &self,
1493 bounds: teksilo_canvas::Rect,
1494 canvas: &mut teksilo_canvas::Canvas,
1495 _ctx: &PaintContext,
1496 ) {
1497 canvas.fill_rounded_rect(bounds, CornerRadius::uniform(0.0), Color::RED);
1498 }
1499
1500 fn wants_post_paint(&self) -> bool {
1501 true
1502 }
1503
1504 fn post_paint(
1505 &self,
1506 bounds: teksilo_canvas::Rect,
1507 canvas: &mut teksilo_canvas::Canvas,
1508 _ctx: &PaintContext,
1509 ) {
1510 canvas.fill_rounded_rect(bounds, CornerRadius::uniform(0.0), Color::BLUE);
1511 }
1512 }
1513
1514 #[test]
1515 fn post_paint_emits_after_children() {
1516 let mut tree = WidgetTree::new().with_theme(crate::presets::intui::light());
1521 tree.add(Sandwich { child: None });
1522 tree.layout(SizeProposal::exact(100.0, 50.0));
1523 let frame = tree.render();
1524
1525 let color_of = |cmd: &teksilo_canvas::DrawCommand| -> Option<[f32; 4]> {
1526 match cmd {
1527 teksilo_canvas::DrawCommand::Shape(i) => frame.shapes.get(*i).map(|s| s.color),
1528 teksilo_canvas::DrawCommand::Decoration(i) => {
1529 frame.decorations.get(*i).map(|d| d.color)
1530 }
1531 _ => None,
1532 }
1533 };
1534 let find = |dominant: usize| -> Option<usize> {
1536 frame.draw_order.iter().position(|cmd| {
1537 color_of(cmd).is_some_and(|c| {
1538 c[dominant] > 0.5 && (0..3).all(|ch| ch == dominant || c[ch] < 0.5)
1539 })
1540 })
1541 };
1542 let red = find(0).expect("backdrop (RED) painted");
1543 let green = find(1).expect("child (GREEN) painted");
1544 let blue = find(2).expect("foreground (BLUE) painted");
1545 assert!(
1546 red < green && green < blue,
1547 "expected backdrop < child < foreground; RED@{red}, GREEN@{green}, BLUE@{blue}; order={:?}",
1548 frame.draw_order
1549 );
1550 }
1551
1552 #[test]
1553 fn blur_prop_emits_begin_end_pair_around_subtree() {
1554 let mut tree = WidgetTree::new().with_theme(crate::presets::intui::light());
1560 let parent = tree.add(StackWidget::new());
1561 let _child = tree.add_child(parent, FillWidget::new().background(Color::RED));
1562 tree.set_blur(parent, 8.0_f32);
1563 tree.layout(SizeProposal::exact(100.0, 50.0));
1564 let frame = tree.render();
1565
1566 let mut begin_count = 0;
1567 let mut end_count = 0;
1568 let mut begin_radius = None;
1569 for cmd in &frame.draw_order {
1570 match cmd {
1571 teksilo_canvas::DrawCommand::BeginBlurredSubtree { radius, .. } => {
1572 begin_count += 1;
1573 begin_radius = Some(*radius);
1574 }
1575 teksilo_canvas::DrawCommand::EndBlurredSubtree => end_count += 1,
1576 _ => {}
1577 }
1578 }
1579 assert_eq!(begin_count, 1, "draw_order = {:?}", frame.draw_order);
1580 assert_eq!(end_count, 1);
1581 assert_eq!(begin_radius, Some(8.0));
1582 }
1583
1584 #[test]
1585 fn blur_prop_subperceptual_radius_skipped() {
1586 let mut tree = WidgetTree::new().with_theme(crate::presets::intui::light());
1590 let parent = tree.add(StackWidget::new());
1591 tree.add_child(parent, FillWidget::new().background(Color::RED));
1592 tree.set_blur(parent, 0.2_f32);
1593 tree.layout(SizeProposal::exact(100.0, 50.0));
1594 let frame = tree.render();
1595
1596 for cmd in &frame.draw_order {
1597 assert!(
1598 !matches!(
1599 cmd,
1600 teksilo_canvas::DrawCommand::BeginBlurredSubtree { .. }
1601 | teksilo_canvas::DrawCommand::EndBlurredSubtree
1602 ),
1603 "sub-perceptual blur must not emit Begin/End"
1604 );
1605 }
1606 }
1607
1608 #[test]
1609 fn blur_scope_is_outermost_when_combined_with_opacity_and_transform() {
1610 let mut tree = WidgetTree::new().with_theme(crate::presets::intui::light());
1617 let parent = tree.add(StackWidget::new());
1618 tree.add_child(parent, FillWidget::new().background(Color::RED));
1619 tree.set_blur(parent, 8.0_f32);
1620 tree.set_opacity(parent, 0.7_f32);
1621 tree.set_transform(parent, teksilo_canvas::Transform2D::scale(2.0, 2.0));
1622 tree.layout(SizeProposal::exact(100.0, 50.0));
1623 let frame = tree.render();
1624
1625 let mut begin_idx = None;
1626 let mut set_opacity_idx = None;
1627 let mut push_transform_idx = None;
1628 let mut pop_transform_idx = None;
1629 let mut restore_opacity_idx = None;
1630 let mut end_idx = None;
1631 for (i, cmd) in frame.draw_order.iter().enumerate() {
1632 match cmd {
1633 teksilo_canvas::DrawCommand::BeginBlurredSubtree { .. } => begin_idx = Some(i),
1634 teksilo_canvas::DrawCommand::SetOpacity(_) => set_opacity_idx = Some(i),
1635 teksilo_canvas::DrawCommand::PushTransform(_) => push_transform_idx = Some(i),
1636 teksilo_canvas::DrawCommand::PopTransform => pop_transform_idx = Some(i),
1637 teksilo_canvas::DrawCommand::RestoreOpacity => restore_opacity_idx = Some(i),
1638 teksilo_canvas::DrawCommand::EndBlurredSubtree => end_idx = Some(i),
1639 _ => {}
1640 }
1641 }
1642 let bg = begin_idx.expect("Begin emitted");
1643 let so = set_opacity_idx.expect("SetOpacity emitted");
1644 let pt = push_transform_idx.expect("PushTransform emitted");
1645 let popt = pop_transform_idx.expect("PopTransform emitted");
1646 let ro = restore_opacity_idx.expect("RestoreOpacity emitted");
1647 let en = end_idx.expect("End emitted");
1648 assert!(bg < so, "blur opens before opacity");
1649 assert!(so < pt, "opacity opens before transform");
1650 assert!(pt < popt);
1651 assert!(popt < ro, "transform closes before opacity");
1652 assert!(ro < en, "opacity closes before blur");
1653 }
1654
1655 #[test]
1656 fn nested_theme_overrides_compose() {
1657 let mut tree = WidgetTree::new().with_theme(crate::presets::intui::light());
1658
1659 let grandparent = tree.add(FillWidget::new());
1660 let parent = tree.add_child(grandparent, FillWidget::new());
1661 let child = tree.add_child(parent, FillWidget::new());
1662
1663 tree.set_theme_override(grandparent, |theme| {
1664 theme.colors.accent = Color::RED;
1665 });
1666 tree.set_theme_override(parent, |theme| {
1667 theme.colors.text_secondary = Color::GREEN;
1668 });
1669
1670 tree.layout(SizeProposal::exact(100.0, 50.0));
1671
1672 let child_theme = tree.resolved_theme(child);
1673 assert_eq!(child_theme.colors.accent, Color::RED);
1674 assert_eq!(child_theme.colors.text_secondary, Color::GREEN);
1675 }
1676
1677 #[derive(Debug)]
1680 struct ColorProbe {
1681 color: Rc<std::cell::Cell<Color>>,
1682 paints: Rc<std::cell::Cell<u32>>,
1683 post_paints: Rc<std::cell::Cell<u32>>,
1684 foreground: bool,
1685 }
1686
1687 impl ColorProbe {
1688 fn new(color: Color) -> Self {
1689 Self {
1690 color: Rc::new(std::cell::Cell::new(color)),
1691 paints: Rc::new(std::cell::Cell::new(0)),
1692 post_paints: Rc::new(std::cell::Cell::new(0)),
1693 foreground: false,
1694 }
1695 }
1696 }
1697
1698 impl Widget for ColorProbe {
1699 fn layout_response(
1700 &self,
1701 proposal: SizeProposal,
1702 _ctx: &LayoutContext,
1703 ) -> crate::widget::LayoutResponse {
1704 proposal.resolve(40.0, 40.0).into()
1705 }
1706
1707 fn paint(&self, bounds: Rect, canvas: &mut Canvas, _ctx: &PaintContext) {
1708 self.paints.set(self.paints.get() + 1);
1709 canvas.fill_rect(bounds, self.color.get());
1710 }
1711
1712 fn wants_post_paint(&self) -> bool {
1713 self.foreground
1714 }
1715
1716 fn post_paint(&self, bounds: Rect, canvas: &mut Canvas, _ctx: &PaintContext) {
1717 self.post_paints.set(self.post_paints.get() + 1);
1718 canvas.fill_rect(bounds, self.color.get());
1719 }
1720 }
1721
1722 #[derive(Debug)]
1726 struct OffsetContainer {
1727 offset: f32,
1728 }
1729
1730 impl Widget for OffsetContainer {
1731 fn layout_response(
1732 &self,
1733 proposal: SizeProposal,
1734 _ctx: &LayoutContext,
1735 ) -> crate::widget::LayoutResponse {
1736 proposal.resolve(0.0, 0.0).into()
1737 }
1738
1739 fn place_children(
1740 &self,
1741 bounds: Rect,
1742 _proposal: SizeProposal,
1743 children: &mut [WidgetPlacement],
1744 _ctx: &LayoutContext,
1745 ) {
1746 for child in children.iter_mut() {
1747 child.origin = teksilo_canvas::Point::new(bounds.x + self.offset, bounds.y);
1748 child.size = teksilo_canvas::Size::new(40.0, 40.0);
1749 }
1750 }
1751 }
1752
1753 fn painted_colors(frame: &RenderFrame) -> Vec<[f32; 4]> {
1754 frame.decorations.iter().map(|d| d.color).collect()
1755 }
1756
1757 fn clipped_probe_tree(probe: ColorProbe) -> (WidgetTree, WidgetId) {
1760 let mut tree = WidgetTree::new().with_theme(crate::presets::intui::light());
1761 let root = tree.add(OffsetContainer { offset: 150.0 });
1762 tree.set_clips_children(root, true);
1763 let child = tree.add_child(root, probe);
1764 tree.layout(SizeProposal::exact(200.0, 50.0));
1765 (tree, child)
1766 }
1767
1768 #[test]
1769 fn clipped_out_dirty_node_repaints_when_it_comes_back_into_view() {
1770 let probe = ColorProbe::new(Color::RED);
1771 let (color, paints) = (probe.color.clone(), probe.paints.clone());
1772 let (mut tree, child) = clipped_probe_tree(probe);
1773 assert!(painted_colors(&tree.render()).contains(&Color::RED.to_array()));
1774
1775 tree.layout(SizeProposal::exact(100.0, 50.0));
1776 assert_eq!(tree.bounds(child).x, 150.0, "the probe must not move");
1777 color.set(Color::BLUE);
1778 tree.arena.mark_needs_paint(child);
1779 let frame = tree.render();
1780 assert!(
1781 painted_colors(&frame).is_empty(),
1782 "the probe is clipped out"
1783 );
1784 assert_eq!(paints.get(), 1, "a clipped-out node does not paint");
1785
1786 tree.layout(SizeProposal::exact(200.0, 50.0));
1787 let colors = painted_colors(&tree.render());
1788 assert_eq!(
1789 colors,
1790 vec![Color::BLUE.to_array()],
1791 "a node dirtied while clipped out must not replay its old paint"
1792 );
1793 assert_eq!(paints.get(), 2);
1794 }
1795
1796 #[test]
1797 fn clipped_out_clean_node_keeps_its_cache() {
1798 let probe = ColorProbe::new(Color::RED);
1799 let paints = probe.paints.clone();
1800 let (mut tree, child) = clipped_probe_tree(probe);
1801 let _ = tree.render();
1802
1803 tree.layout(SizeProposal::exact(100.0, 50.0));
1804 let _ = tree.render();
1805 assert!(
1806 tree.arena.get(child).unwrap().cached_paint.is_some(),
1807 "a clean node keeps its cache while clipped out"
1808 );
1809
1810 tree.layout(SizeProposal::exact(200.0, 50.0));
1811 let colors = painted_colors(&tree.render());
1812 assert_eq!(colors, vec![Color::RED.to_array()]);
1813 assert_eq!(paints.get(), 1, "the clean node replays its cache");
1814 }
1815
1816 #[test]
1817 fn node_dirtied_under_a_transparent_ancestor_repaints_when_it_fades_in() {
1818 let probe = ColorProbe::new(Color::RED);
1819 let (color, paints) = (probe.color.clone(), probe.paints.clone());
1820 let mut tree = WidgetTree::new().with_theme(crate::presets::intui::light());
1821 let parent = tree.add(StackWidget::new());
1822 let child = tree.add_child(parent, probe);
1823 let opacity = crate::signal::Signal::new(1.0_f32);
1824 tree.set_opacity(parent, opacity.clone());
1825 tree.layout(SizeProposal::exact(100.0, 50.0));
1826 let _ = tree.render();
1827
1828 opacity.set(0.0);
1829 tree.layout(SizeProposal::exact(100.0, 50.0));
1830 color.set(Color::BLUE);
1831 tree.arena.mark_needs_paint(child);
1832 let _ = tree.render();
1833 assert_eq!(paints.get(), 1, "a transparent subtree does not paint");
1834
1835 opacity.set(1.0);
1836 tree.layout(SizeProposal::exact(100.0, 50.0));
1837 let colors = painted_colors(&tree.render());
1838 assert_eq!(colors, vec![Color::BLUE.to_array()]);
1839 }
1840
1841 #[test]
1842 fn transparent_dirty_node_repaints_when_it_fades_in() {
1843 let probe = ColorProbe::new(Color::RED);
1844 let color = probe.color.clone();
1845 let mut tree = WidgetTree::new().with_theme(crate::presets::intui::light());
1846 let node = tree.add(probe);
1847 let opacity = crate::signal::Signal::new(1.0_f32);
1848 tree.set_opacity(node, opacity.clone());
1849 tree.layout(SizeProposal::exact(100.0, 50.0));
1850 let _ = tree.render();
1851
1852 opacity.set(0.0);
1859 tree.layout(SizeProposal::exact(100.0, 50.0));
1860 color.set(Color::BLUE);
1861 tree.arena.mark_needs_paint(node);
1862 let _ = tree.render();
1863 assert!(
1864 tree.arena.get(node).unwrap().cached_paint.is_none(),
1865 "a node dirtied while transparent drops its stale cache"
1866 );
1867
1868 opacity.set(1.0);
1869 tree.layout(SizeProposal::exact(100.0, 50.0));
1870 let colors = painted_colors(&tree.render());
1871 assert_eq!(colors, vec![Color::BLUE.to_array()]);
1872 }
1873
1874 #[test]
1875 fn node_dirtied_while_dormant_repaints_on_activation() {
1876 let probe = ColorProbe::new(Color::RED);
1877 let color = probe.color.clone();
1878 let mut tree = WidgetTree::new().with_theme(crate::presets::intui::light());
1879 let node = tree.add(probe);
1880 tree.layout(SizeProposal::exact(100.0, 50.0));
1881 let _ = tree.render();
1882
1883 tree.set_dormant(node);
1884 color.set(Color::BLUE);
1885 tree.arena.mark_needs_paint(node);
1886 tree.layout(SizeProposal::exact(100.0, 50.0));
1887 let _ = tree.render();
1888
1889 tree.activate(node);
1890 tree.layout(SizeProposal::exact(100.0, 50.0));
1891 let colors = painted_colors(&tree.render());
1892 assert_eq!(colors, vec![Color::BLUE.to_array()]);
1893 }
1894
1895 #[test]
1896 fn clipped_out_dirty_node_reruns_its_foreground_pass() {
1897 let mut probe = ColorProbe::new(Color::RED);
1898 probe.foreground = true;
1899 let (color, post_paints) = (probe.color.clone(), probe.post_paints.clone());
1900 let (mut tree, child) = clipped_probe_tree(probe);
1901 let _ = tree.render();
1902 assert_eq!(post_paints.get(), 1);
1903
1904 tree.layout(SizeProposal::exact(100.0, 50.0));
1905 color.set(Color::BLUE);
1906 tree.arena.mark_needs_paint(child);
1907 let _ = tree.render();
1908 assert!(
1909 tree.arena.get(child).unwrap().cached_post_paint.is_none(),
1910 "the stale foreground cache is dropped with the main one"
1911 );
1912
1913 tree.layout(SizeProposal::exact(200.0, 50.0));
1914 let colors = painted_colors(&tree.render());
1915 assert_eq!(
1916 colors,
1917 vec![Color::BLUE.to_array(), Color::BLUE.to_array()],
1918 "both passes repaint in the new colour"
1919 );
1920 assert_eq!(post_paints.get(), 2);
1921 }
1922}