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 = self.effective_theme.for_paint_state(self.paint_state());
133 let text_backend = self.text_backend.clone();
134 let a11y_prefs = A11yPaintPrefs {
135 high_contrast: self.prefers_high_contrast(),
136 reduced_motion: self.prefers_reduced_motion,
137 large_text: self.text_scale_factor > 1.0,
138 text_scale: self.effective_text_scale,
139 window_active: self.is_window_active(),
140 };
141
142 let overlay_skip: std::collections::HashSet<WidgetId> = self
143 .overlay_manager
144 .active_content_ids()
145 .into_iter()
146 .collect();
147
148 let overlay_rects: Vec<Rect> = self.overlay_manager.interactive_rects();
154
155 for root_id in self.arena.roots() {
156 if overlay_skip.contains(&root_id) {
160 continue;
161 }
162 paint_widget_cached(
163 &mut self.arena,
164 root_id,
165 &mut frame,
166 &base_theme,
167 &text_backend,
168 None,
169 &a11y_prefs,
170 paint_epoch,
171 &overlay_skip,
172 &overlay_rects,
173 self.layout_direction,
174 true,
177 1.0,
180 );
181 }
182
183 for content_id in self.overlay_manager.active_content_ids() {
184 paint_widget_cached(
185 &mut self.arena,
186 content_id,
187 &mut frame,
188 &base_theme,
189 &text_backend,
190 None,
191 &a11y_prefs,
192 paint_epoch,
193 &overlay_skip,
194 &overlay_rects,
195 self.layout_direction,
196 true,
201 1.0,
204 );
205 }
206
207 self.arena.fill_active_ids(&mut self.active_ids_scratch);
218 let ids = std::mem::take(&mut self.active_ids_scratch);
219 for &id in &ids {
220 if let Some(node) = self.arena.get_mut(id) {
221 if node.dirty.needs_paint && node.paint_consumed_epoch != paint_epoch {
222 node.cached_paint = None;
223 node.cached_post_paint = None;
224 }
225 node.dirty.needs_paint = false;
226 }
227 }
228 self.active_ids_scratch = ids;
229
230 if has_animations {
231 frame
232 .anim_params
233 .extend_from_slice(self.animated_quads.scratch_slice());
234 }
235 frame.debug_validate_stacks();
236 let rc = std::rc::Rc::new(frame);
237 self.cached_frame = Some(std::rc::Rc::clone(&rc));
238 self.arm_frame_tick_for_visible_subscribers();
244 rc
245 }
246
247 pub(crate) fn arm_frame_tick_for_visible_subscribers(&mut self) {
256 if self
257 .frame_tick_scheduler
258 .should_arm_frame_tick(&self.arena, self.paint_epoch)
259 {
260 self.frame_tick_armed_by_subscribers = true;
261 }
262 }
263}
264
265#[allow(clippy::too_many_arguments)]
285fn paint_widget_cached(
286 arena: &mut WidgetArena,
287 id: WidgetId,
288 frame: &mut RenderFrame,
289 base_theme: &crate::styles::Theme,
290 text_backend: &Option<Rc<RefCell<dyn teksilo_canvas::TextBackend>>>,
291 clip_bounds: Option<Rect>,
292 a11y_prefs: &A11yPaintPrefs,
293 paint_epoch: u64,
294 overlay_skip: &std::collections::HashSet<WidgetId>,
295 overlay_rects: &[Rect],
296 layout_direction: crate::environment::LayoutDirection,
297 parent_effective_enabled: bool,
298 accumulated_raster_scale: f32,
299) {
300 if !arena.is_active(id) {
301 return;
302 }
303
304 let this_effective_enabled = parent_effective_enabled
309 && arena
310 .get(id)
311 .and_then(|n| n.enabled_state.as_ref())
312 .is_none_or(|p| p.get());
313
314 let node = arena.get(id).expect("node id is active (guarded above)");
315 let bounds = node.bounds;
316 if let Some(clip) = clip_bounds {
317 let x0 = bounds.x.max(clip.x);
318 let y0 = bounds.y.max(clip.y);
319 let x1 = bounds.right().min(clip.right());
320 let y1 = bounds.bottom().min(clip.bottom());
321 if x1 <= x0 || y1 <= y0 {
322 return;
327 }
328 }
329
330 if let Some(node_mut) = arena.get_mut(id) {
335 node_mut.last_painted_epoch = paint_epoch;
336 }
337
338 let node = arena.get(id).expect("node id is active (guarded above)");
343 let opacity = node.opacity_prop.as_ref().map(|p| p.get().clamp(0.0, 1.0));
344 if let Some(o) = opacity
345 && o < 1.0 / 512.0
346 {
347 return;
353 }
354
355 let node = arena.get(id).expect("node id is active (guarded above)");
363 let blur_radius = node
364 .blur_prop
365 .as_ref()
366 .map(|p| p.get())
367 .filter(|r| *r >= 0.5);
368 let blur_bounds = arena.bounds(id);
369 if let Some(r) = blur_radius {
370 frame
371 .draw_order
372 .push(teksilo_canvas::DrawCommand::BeginBlurredSubtree {
373 bounds: blur_bounds,
374 radius: r,
375 });
376 }
377
378 if let Some(o) = opacity {
379 frame
380 .draw_order
381 .push(teksilo_canvas::DrawCommand::SetOpacity(o));
382 }
383
384 let node = arena.get(id).expect("node id is active (guarded above)");
392 let transform = node.transform_prop.as_ref().map(|p| p.get());
393 let push_transform = transform.filter(|t| *t != teksilo_canvas::Transform2D::IDENTITY);
394 let clips = node.clips_children;
395 let content_transform = node.content_transform;
396 let bounds = node.bounds;
397
398 let this_raster_scale = match push_transform {
406 Some(t) => quantize_raster_scale(accumulated_raster_scale * t.geometric_scale()),
407 None => accumulated_raster_scale,
408 };
409 let raster_scale_changed = this_raster_scale != accumulated_raster_scale;
413 if raster_scale_changed && let Some(tb) = text_backend {
414 tb.borrow_mut().set_raster_scale(this_raster_scale);
415 }
416 let clip_outside_transform = clips && content_transform;
425 if clip_outside_transform {
426 frame
427 .draw_order
428 .push(teksilo_canvas::DrawCommand::SetClip(bounds));
429 }
430 if let Some(t) = push_transform {
431 frame
432 .draw_order
433 .push(teksilo_canvas::DrawCommand::PushTransform(t));
434 }
435
436 let node = arena.get(id).expect("node id is active (guarded above)");
437 let needs_paint = node.dirty.needs_paint || node.paint_raster_scale != this_raster_scale;
442
443 if needs_paint || node.cached_paint.is_none() {
444 let resolved_theme = arena.resolve_theme(id, base_theme);
445 let ctx = PaintContext {
446 theme: &resolved_theme,
447 scale_factor: this_raster_scale,
448 text_scale: a11y_prefs.text_scale,
449 layout_direction,
450 effective_enabled: this_effective_enabled,
451 prefers_high_contrast: a11y_prefs.high_contrast,
452 prefers_reduced_motion: a11y_prefs.reduced_motion,
453 prefers_large_text: a11y_prefs.large_text,
454 window_active: a11y_prefs.window_active,
455 clip_bounds,
456 };
457
458 let bounds = arena.bounds(id);
459 let node = arena.get(id).expect("node id is active (guarded above)");
460
461 let mut canvas = match text_backend {
462 Some(tb) => Canvas::with_text_backend(tb.clone()),
463 None => Canvas::new(),
464 };
465 if let Some(wake) = &node.repaint_wake {
468 wake.consume_repaint();
469 }
470 node.widget.paint(bounds, &mut canvas, &ctx);
471 let widget_frame = canvas.into_render_frame();
472
473 frame.merge(&widget_frame);
474 if let Some(node) = arena.get_mut(id) {
475 node.cached_paint = Some(widget_frame);
476 node.paint_raster_scale = this_raster_scale;
477 node.paint_consumed_epoch = paint_epoch;
478 }
479 } else {
480 let node = arena.get(id).expect("node id is active (guarded above)");
481 if let Some(cached) = &node.cached_paint {
482 if !cached.layout_keys.is_empty()
490 && let Some(tb) = text_backend
491 {
492 let mut tb = tb.borrow_mut();
493 for key in &cached.layout_keys {
494 tb.touch_layout(*key);
495 }
496 #[cfg(debug_assertions)]
497 debug_validate_layout_keys(&*tb, &cached.layout_keys, Some(id));
498 }
499 frame.merge(cached);
500 }
501 }
502
503 let node = arena.get(id).expect("node id is active (guarded above)");
504 let children: Vec<WidgetId> = node.children.clone();
505 let next_clip = if clips {
506 Some(match clip_bounds {
507 Some(clip) => {
508 let x0 = bounds.x.max(clip.x);
509 let y0 = bounds.y.max(clip.y);
510 let x1 = bounds.right().min(clip.right());
511 let y1 = bounds.bottom().min(clip.bottom());
512 Rect::new(x0, y0, (x1 - x0).max(0.0), (y1 - y0).max(0.0))
513 }
514 None => bounds,
515 })
516 } else {
517 clip_bounds
518 };
519
520 let next_clip = match (
534 content_transform,
535 next_clip,
536 transform.and_then(|t| t.inverse()),
537 ) {
538 (true, Some(screen_clip), Some(inv)) => Some(inv.apply_rect(screen_clip)),
539 _ => next_clip,
540 };
541
542 if clips && !clip_outside_transform {
546 frame
547 .draw_order
548 .push(teksilo_canvas::DrawCommand::SetClip(bounds));
549 }
550
551 for child_id in children {
552 if overlay_skip.contains(&child_id) {
558 continue;
559 }
560 paint_widget_cached(
561 arena,
562 child_id,
563 frame,
564 base_theme,
565 text_backend,
566 next_clip,
567 a11y_prefs,
568 paint_epoch,
569 overlay_skip,
570 overlay_rects,
571 layout_direction,
572 this_effective_enabled,
573 this_raster_scale,
574 );
575 }
576
577 {
585 let arena_ref: &WidgetArena = &*arena;
586 if let Some(node) = arena_ref.get(id)
587 && node.widget.wants_after_paint()
588 {
589 let view = crate::widget::WidgetTreeView::new(arena_ref, overlay_rects);
590 let resolved_theme = arena_ref.resolve_theme(id, base_theme);
591 let ctx = PaintContext {
592 theme: &resolved_theme,
593 scale_factor: this_raster_scale,
594 text_scale: a11y_prefs.text_scale,
595 layout_direction,
596 effective_enabled: this_effective_enabled,
597 prefers_high_contrast: a11y_prefs.high_contrast,
598 prefers_reduced_motion: a11y_prefs.reduced_motion,
599 prefers_large_text: a11y_prefs.large_text,
600 window_active: a11y_prefs.window_active,
601 clip_bounds,
602 };
603 node.widget.after_paint(&view, &ctx);
604 }
605 }
606
607 let wants_post_paint = arena
616 .get(id)
617 .map(|n| n.widget.wants_post_paint())
618 .unwrap_or(false);
619 if wants_post_paint {
620 let has_post_cache = arena
621 .get(id)
622 .map(|n| n.cached_post_paint.is_some())
623 .unwrap_or(false);
624 if needs_paint || !has_post_cache {
625 if raster_scale_changed && let Some(tb) = text_backend {
630 tb.borrow_mut().set_raster_scale(this_raster_scale);
631 }
632 let resolved_theme = arena.resolve_theme(id, base_theme);
633 let ctx = PaintContext {
634 theme: &resolved_theme,
635 scale_factor: this_raster_scale,
636 text_scale: a11y_prefs.text_scale,
637 layout_direction,
638 effective_enabled: this_effective_enabled,
639 prefers_high_contrast: a11y_prefs.high_contrast,
640 prefers_reduced_motion: a11y_prefs.reduced_motion,
641 prefers_large_text: a11y_prefs.large_text,
642 window_active: a11y_prefs.window_active,
643 clip_bounds,
644 };
645 let bounds = arena.bounds(id);
646 let node = arena.get(id).expect("node id is active (guarded above)");
647 let mut canvas = match text_backend {
648 Some(tb) => Canvas::with_text_backend(tb.clone()),
649 None => Canvas::new(),
650 };
651 node.widget.post_paint(bounds, &mut canvas, &ctx);
652 let post_frame = canvas.into_render_frame();
653 frame.merge(&post_frame);
654 if let Some(node) = arena.get_mut(id) {
655 node.cached_post_paint = Some(post_frame);
656 }
657 } else if let Some(cached) = arena.get(id).and_then(|n| n.cached_post_paint.as_ref()) {
658 if !cached.layout_keys.is_empty()
659 && let Some(tb) = text_backend
660 {
661 let mut tb = tb.borrow_mut();
662 for key in &cached.layout_keys {
663 tb.touch_layout(*key);
664 }
665 #[cfg(debug_assertions)]
666 debug_validate_layout_keys(&*tb, &cached.layout_keys, Some(id));
667 }
668 frame.merge(cached);
669 }
670 }
671
672 if clips && !clip_outside_transform {
673 frame
674 .draw_order
675 .push(teksilo_canvas::DrawCommand::ClearClip);
676 }
677
678 if push_transform.is_some() {
679 frame
680 .draw_order
681 .push(teksilo_canvas::DrawCommand::PopTransform);
682 }
683
684 if clip_outside_transform {
687 frame
688 .draw_order
689 .push(teksilo_canvas::DrawCommand::ClearClip);
690 }
691
692 if opacity.is_some() {
693 frame
694 .draw_order
695 .push(teksilo_canvas::DrawCommand::RestoreOpacity);
696 }
697
698 if blur_radius.is_some() {
699 frame
700 .draw_order
701 .push(teksilo_canvas::DrawCommand::EndBlurredSubtree);
702 }
703
704 if raster_scale_changed && let Some(tb) = text_backend {
707 tb.borrow_mut().set_raster_scale(accumulated_raster_scale);
708 }
709}
710
711#[cfg(debug_assertions)]
724fn debug_validate_layout_keys(
725 tb: &dyn teksilo_canvas::TextBackend,
726 layout_keys: &[u64],
727 widget: Option<WidgetId>,
728) {
729 use teksilo_canvas::GlyphValidation;
730 std::thread_local! {
731 static REPORTED_STALE_KEYS: std::cell::RefCell<std::collections::HashSet<u64>> =
732 std::cell::RefCell::new(std::collections::HashSet::new());
733 }
734 for key in layout_keys {
735 match tb.debug_validate_layout(*key) {
736 GlyphValidation::Valid => {}
737 GlyphValidation::StaleKey => {
738 let first_report = REPORTED_STALE_KEYS.with(|set| set.borrow_mut().insert(*key));
739 if first_report {
740 eprintln!(
741 "[teksilo] WARNING: retained paint cache replays layout_key={key} \
742 (widget={widget:?}) that the text backend no longer knows — the \
743 frame survived a backend cache clear, so a paint-cache \
744 invalidation path is likely missing."
745 );
746 }
747 }
748 GlyphValidation::RectMismatch => {
749 panic!(
750 "stale glyph UVs in retained paint cache (layout_key={key}, \
751 widget={widget:?}): cached quads no longer match the live glyph \
752 atlas — a cache-invalidation path is missing"
753 );
754 }
755 }
756 }
757}
758
759#[cfg(test)]
760mod tests {
761 use super::*;
762 use crate::test_widgets::{FillWidget, StackWidget};
763 use teksilo_tokens::{Color, CornerRadius};
764
765 #[derive(Default)]
772 struct RecordingTextBackend {
773 touched: std::rc::Rc<std::cell::RefCell<Vec<u64>>>,
774 raster_scale: f32,
775 layout_scales: std::rc::Rc<std::cell::RefCell<Vec<f32>>>,
778 }
779
780 impl RecordingTextBackend {
781 fn new(touched: std::rc::Rc<std::cell::RefCell<Vec<u64>>>) -> Self {
782 Self {
783 touched,
784 raster_scale: 1.0,
785 layout_scales: Default::default(),
786 }
787 }
788 }
789
790 impl teksilo_canvas::TextBackend for RecordingTextBackend {
791 fn set_raster_scale(&mut self, raster_scale: f32) {
792 self.raster_scale = raster_scale;
793 }
794
795 fn raster_scale(&self) -> f32 {
796 self.raster_scale
797 }
798
799 fn layout_single_line(
800 &mut self,
801 text: &str,
802 _style: &teksilo_tokens::TextStyle,
803 _max_width: Option<f32>,
804 ) -> teksilo_canvas::TextLayout {
805 self.layout_scales.borrow_mut().push(self.raster_scale);
806 teksilo_canvas::TextLayout {
807 width: text.len() as f32 * 8.0,
808 height: 16.0,
809 ascent: 12.0,
810 descent: 4.0,
811 underline_offset: 1.0,
812 underline_thickness: 1.0,
813 layout_key: 42,
814 line_count: 1,
815 spans: Vec::new(),
816 raster_scale: self.raster_scale,
817 geometry: None,
818 }
819 }
820
821 fn ensure_glyphs(
822 &mut self,
823 _layout: &teksilo_canvas::TextLayout,
824 ) -> Vec<teksilo_canvas::GlyphQuad> {
825 Vec::new()
826 }
827
828 fn touch_layout(&mut self, layout_key: u64) {
829 self.touched.borrow_mut().push(layout_key);
830 }
831 }
832
833 #[derive(Debug)]
836 struct TextPaintWidget;
837
838 impl Widget for TextPaintWidget {
839 fn layout_response(
840 &self,
841 proposal: SizeProposal,
842 _ctx: &LayoutContext,
843 ) -> crate::widget::LayoutResponse {
844 proposal.resolve(100.0, 20.0).into()
845 }
846
847 fn paint(
848 &self,
849 bounds: teksilo_canvas::Rect,
850 canvas: &mut teksilo_canvas::Canvas,
851 _ctx: &PaintContext,
852 ) {
853 let style = teksilo_tokens::TextStyle::default();
854 let _ = canvas.draw_text("hello", bounds, &style, Color::BLACK);
855 }
856 }
857
858 #[test]
859 fn full_frame_cache_hit_touches_layout_keys() {
860 let touched = std::rc::Rc::new(std::cell::RefCell::new(Vec::new()));
861 let backend = RecordingTextBackend::new(touched.clone());
862 let mut tree = WidgetTree::new()
863 .with_theme(crate::presets::intui::light())
864 .with_text_backend(std::rc::Rc::new(std::cell::RefCell::new(backend)));
865 tree.add(TextPaintWidget);
866 tree.layout(SizeProposal::exact(200.0, 50.0));
867
868 let _ = tree.render();
870 touched.borrow_mut().clear();
871
872 let _ = tree.render();
876 assert!(
877 touched.borrow().contains(&42),
878 "full-frame cache hit must touch_layout every baked layout key; \
879 touched = {:?}",
880 touched.borrow()
881 );
882 }
883
884 fn scaled_subtree_tree(
895 transform: impl Into<crate::signal::Prop<teksilo_canvas::Transform2D>>,
896 ) -> (
897 WidgetTree,
898 std::rc::Rc<std::cell::RefCell<RecordingTextBackend>>,
899 std::rc::Rc<std::cell::RefCell<Vec<f32>>>,
900 ) {
901 let backend = RecordingTextBackend::new(Default::default());
902 let layout_scales = backend.layout_scales.clone();
903 let backend_rc = std::rc::Rc::new(std::cell::RefCell::new(backend));
904 let mut tree = WidgetTree::new()
905 .with_theme(crate::presets::intui::light())
906 .with_text_backend(backend_rc.clone()
907 as std::rc::Rc<std::cell::RefCell<dyn teksilo_canvas::TextBackend>>);
908
909 let root = tree.add(StackWidget::new());
910 tree.add_child(root, TextPaintWidget); let wrapper = tree.add_child(root, StackWidget::new());
912 tree.add_child(wrapper, TextPaintWidget); tree.add_child(root, TextPaintWidget); tree.set_transform(wrapper, transform);
915
916 tree.layout(SizeProposal::exact(400.0, 300.0));
917 (tree, backend_rc, layout_scales)
918 }
919
920 #[test]
921 fn walker_sets_raster_scale_for_scaled_subtree_and_restores() {
922 let (mut tree, backend_rc, layout_scales) =
923 scaled_subtree_tree(teksilo_canvas::Transform2D::scale(2.0, 2.0));
924 let _ = tree.render();
925
926 let expected_b = 1.25_f32.powi(3); assert_eq!(
931 layout_scales.borrow().as_slice(),
932 &[1.0, expected_b, 1.0],
933 "text inside the transform scope must lay out at the quantized \
934 scale; siblings before/after at screen scale"
935 );
936 assert_eq!(backend_rc.borrow().raster_scale, 1.0);
938 }
939
940 #[test]
941 fn raster_scale_change_repaints_clean_descendants() {
942 let transform = crate::signal::Signal::new(teksilo_canvas::Transform2D::scale(2.0, 2.0));
943 let (mut tree, _backend_rc, layout_scales) = scaled_subtree_tree(transform.clone());
944 let _ = tree.render();
945 layout_scales.borrow_mut().clear();
946
947 transform.set(teksilo_canvas::Transform2D::scale(3.0, 3.0));
951 let _ = tree.render();
952 assert_eq!(
953 layout_scales.borrow().as_slice(),
954 &[1.25_f32.powi(5)],
955 "the clean text leaf inside the scope must re-rasterize at the \
956 new quantized scale (A and C stay cached: no layout calls)"
957 );
958
959 layout_scales.borrow_mut().clear();
961 transform.set(teksilo_canvas::Transform2D::IDENTITY);
962 let _ = tree.render();
963 assert_eq!(layout_scales.borrow().as_slice(), &[1.0]);
964
965 layout_scales.borrow_mut().clear();
968 let _ = tree.render();
969 assert!(layout_scales.borrow().is_empty());
970 }
971
972 #[test]
973 fn nested_scale_transforms_multiply() {
974 let backend = RecordingTextBackend::new(Default::default());
975 let layout_scales = backend.layout_scales.clone();
976 let mut tree = WidgetTree::new()
977 .with_theme(crate::presets::intui::light())
978 .with_text_backend(std::rc::Rc::new(std::cell::RefCell::new(backend)));
979
980 let outer = tree.add(StackWidget::new());
983 let inner = tree.add_child(outer, StackWidget::new());
984 tree.add_child(inner, TextPaintWidget);
985 tree.set_transform(outer, teksilo_canvas::Transform2D::scale(2.0, 2.0));
986 tree.set_content_transform(inner, teksilo_canvas::Transform2D::scale(1.5, 1.5));
987
988 tree.layout(SizeProposal::exact(400.0, 300.0));
989 let _ = tree.render();
990 assert_eq!(layout_scales.borrow().as_slice(), &[1.25_f32.powi(5)]);
991 }
992
993 #[test]
994 fn invalidate_all_paints_clears_post_paint_cache() {
995 #[derive(Debug)]
998 struct PostPaintWidget;
999
1000 impl Widget for PostPaintWidget {
1001 fn layout_response(
1002 &self,
1003 proposal: SizeProposal,
1004 _ctx: &LayoutContext,
1005 ) -> crate::widget::LayoutResponse {
1006 proposal.resolve(50.0, 50.0).into()
1007 }
1008
1009 fn wants_post_paint(&self) -> bool {
1010 true
1011 }
1012
1013 fn post_paint(
1014 &self,
1015 bounds: teksilo_canvas::Rect,
1016 canvas: &mut teksilo_canvas::Canvas,
1017 _ctx: &PaintContext,
1018 ) {
1019 canvas.fill_rect(bounds, Color::RED);
1020 }
1021 }
1022
1023 let mut tree = WidgetTree::new().with_theme(crate::presets::intui::light());
1024 let id = tree.add(PostPaintWidget);
1025 tree.layout(SizeProposal::exact(100.0, 100.0));
1026 let _ = tree.render();
1027 assert!(
1028 tree.arena
1029 .get(id)
1030 .and_then(|n| n.cached_post_paint.as_ref())
1031 .is_some(),
1032 "render must populate cached_post_paint for a post-painting widget"
1033 );
1034
1035 tree.invalidate_all_paints();
1036 assert!(
1037 tree.arena
1038 .get(id)
1039 .and_then(|n| n.cached_post_paint.as_ref())
1040 .is_none(),
1041 "invalidate_all_paints must clear cached_post_paint too — a retained \
1042 post-paint frame can hold stale glyph UVs after atlas eviction"
1043 );
1044 }
1045
1046 #[derive(Debug)]
1047 struct ThemeAwareWidget;
1048
1049 impl Widget for ThemeAwareWidget {
1050 fn layout_response(
1051 &self,
1052 proposal: SizeProposal,
1053 _ctx: &LayoutContext,
1054 ) -> crate::widget::LayoutResponse {
1055 proposal.resolve(0.0, 0.0).into()
1056 }
1057
1058 fn paint(
1059 &self,
1060 bounds: teksilo_canvas::Rect,
1061 canvas: &mut teksilo_canvas::Canvas,
1062 ctx: &PaintContext,
1063 ) {
1064 canvas.fill_rounded_rect(
1065 bounds,
1066 teksilo_tokens::CornerRadius::uniform(4.0),
1067 ctx.theme.colors.accent,
1068 );
1069 }
1070 }
1071
1072 #[test]
1073 fn fill_widget_produces_shape_in_frame() {
1074 let mut tree = WidgetTree::new().with_theme(crate::presets::intui::light());
1075 tree.add(
1076 FillWidget::new()
1077 .background(Color::RED)
1078 .corner_radius(CornerRadius::uniform(6.0)),
1079 );
1080 tree.layout(SizeProposal::exact(100.0, 40.0));
1081 let frame = tree.render();
1082 assert_eq!(frame.shapes.len(), 1);
1083 assert_eq!(
1084 frame.shapes[0].shape,
1085 teksilo_canvas::ShapeKind::RoundedRect
1086 );
1087 }
1088
1089 #[test]
1090 fn empty_tree_renders_empty_frame() {
1091 let mut tree = WidgetTree::new();
1092 let frame = tree.render();
1093 assert!(frame.is_empty());
1094 }
1095
1096 #[test]
1097 fn render_clears_paint_dirty() {
1098 let mut tree = WidgetTree::new();
1099 tree.add(FillWidget::new().background(Color::RED));
1100 tree.layout(SizeProposal::exact(100.0, 50.0));
1101 assert!(tree.needs_paint());
1102 tree.render();
1103 assert!(!tree.needs_paint());
1104 }
1105
1106 #[test]
1107 fn dormant_widget_not_rendered() {
1108 let mut tree = WidgetTree::new();
1109 let widget = tree.add(
1110 FillWidget::new()
1111 .background(Color::RED)
1112 .corner_radius(CornerRadius::uniform(4.0)),
1113 );
1114 tree.layout(SizeProposal::exact(100.0, 50.0));
1115 let frame = tree.render();
1116 assert!(!frame.shapes.is_empty());
1117
1118 tree.set_dormant(widget);
1119 tree.layout(SizeProposal::exact(100.0, 50.0));
1120 let frame = tree.render();
1121 assert!(frame.shapes.is_empty());
1122 }
1123
1124 #[test]
1125 fn dormancy_is_recursive() {
1126 let mut tree = WidgetTree::new();
1127 let child = tree.add(
1128 FillWidget::new()
1129 .background(Color::RED)
1130 .corner_radius(CornerRadius::uniform(4.0)),
1131 );
1132 let parent = tree.add(StackWidget::new().child(child));
1133 tree.layout(SizeProposal::exact(100.0, 50.0));
1134
1135 let frame = tree.render();
1136 assert_eq!(frame.shapes.len(), 1);
1137
1138 tree.set_dormant(parent);
1139 tree.layout(SizeProposal::exact(100.0, 50.0));
1140 let frame = tree.render();
1141 assert!(frame.shapes.is_empty());
1142
1143 tree.activate(parent);
1144 tree.layout(SizeProposal::exact(100.0, 50.0));
1145 let frame = tree.render();
1146 assert_eq!(frame.shapes.len(), 1);
1147 }
1148
1149 #[test]
1150 fn set_theme_marks_all_dirty() {
1151 let mut tree = WidgetTree::new().with_theme(crate::presets::intui::light());
1152 tree.add(FillWidget::new().background(Color::RED));
1153 tree.layout(SizeProposal::exact(100.0, 50.0));
1154 tree.render();
1155
1156 assert!(!tree.needs_layout());
1157 assert!(!tree.needs_paint());
1158
1159 tree.set_theme(crate::presets::intui::dark());
1160 assert!(tree.needs_layout());
1161 assert!(tree.needs_paint());
1162 }
1163
1164 #[test]
1165 fn set_theme_changes_rendered_colors() {
1166 let mut tree = WidgetTree::new().with_theme(crate::presets::intui::light());
1167 tree.add(ThemeAwareWidget);
1168 tree.layout(SizeProposal::exact(100.0, 50.0));
1169 let light_frame = tree.render();
1170 let light_color = light_frame.shapes[0].color;
1171
1172 tree.set_theme(crate::presets::intui::dark());
1173 tree.layout(SizeProposal::exact(100.0, 50.0));
1174 let dark_frame = tree.render();
1175 let dark_color = dark_frame.shapes[0].color;
1176
1177 assert_ne!(light_color, dark_color);
1178 }
1179
1180 #[test]
1181 fn subtree_theme_override() {
1182 let mut tree = WidgetTree::new().with_theme(crate::presets::intui::light());
1183 let parent = tree.add(ThemeAwareWidget);
1184 let _child = tree.add_child(parent, ThemeAwareWidget);
1185
1186 tree.set_theme_override(parent, |theme| {
1187 theme.colors = teksilo_tokens::ColorTokens::dark_default();
1188 });
1189
1190 tree.layout(SizeProposal::exact(100.0, 50.0));
1191 let frame = tree.render();
1192
1193 let dark_accent = teksilo_tokens::ColorTokens::dark_default()
1194 .accent
1195 .to_array();
1196 assert_eq!(frame.shapes[0].color, dark_accent);
1197 assert_eq!(frame.shapes[1].color, dark_accent);
1198 }
1199
1200 #[test]
1201 fn theme_override_only_affects_subtree() {
1202 let mut tree = WidgetTree::new().with_theme(crate::presets::intui::light());
1203
1204 let _unaffected = tree.add(ThemeAwareWidget);
1205 let overridden = tree.add(ThemeAwareWidget);
1206
1207 tree.set_theme_override(overridden, |theme| {
1208 theme.colors = teksilo_tokens::ColorTokens::dark_default();
1209 });
1210
1211 tree.layout(SizeProposal::exact(100.0, 50.0));
1212 let frame = tree.render();
1213
1214 let light_accent = teksilo_tokens::ColorTokens::light_default()
1215 .accent
1216 .to_array();
1217 let dark_accent = teksilo_tokens::ColorTokens::dark_default()
1218 .accent
1219 .to_array();
1220
1221 assert_eq!(frame.shapes[0].color, light_accent);
1222 assert_eq!(frame.shapes[1].color, dark_accent);
1223 }
1224
1225 #[test]
1226 fn resolved_theme_reflects_overrides() {
1227 let mut tree = WidgetTree::new().with_theme(crate::presets::intui::light());
1228
1229 let parent = tree.add(FillWidget::new());
1230 let child = tree.add_child(parent, FillWidget::new());
1231
1232 tree.set_theme_override(parent, |theme| {
1233 theme.colors.accent = Color::RED;
1234 });
1235
1236 tree.layout(SizeProposal::exact(100.0, 50.0));
1237
1238 let parent_theme = tree.resolved_theme(parent);
1239 assert_eq!(parent_theme.colors.accent, Color::RED);
1240
1241 let child_theme = tree.resolved_theme(child);
1242 assert_eq!(child_theme.colors.accent, Color::RED);
1243 }
1244
1245 #[test]
1246 fn opacity_prop_emits_set_and_restore_around_subtree() {
1247 let mut tree = WidgetTree::new().with_theme(crate::presets::intui::light());
1252 let parent = tree.add(StackWidget::new());
1253 let _child = tree.add_child(parent, FillWidget::new().background(Color::RED));
1254 tree.set_opacity(parent, 0.5_f32);
1255 tree.layout(SizeProposal::exact(100.0, 50.0));
1256 let frame = tree.render();
1257
1258 let mut set_count = 0;
1259 let mut restore_count = 0;
1260 for cmd in &frame.draw_order {
1261 match cmd {
1262 teksilo_canvas::DrawCommand::SetOpacity(v) => {
1263 assert!((v - 0.5).abs() < 1e-6, "expected 0.5, got {}", v);
1264 set_count += 1;
1265 }
1266 teksilo_canvas::DrawCommand::RestoreOpacity => restore_count += 1,
1267 _ => {}
1268 }
1269 }
1270 assert_eq!(set_count, 1, "draw_order = {:?}", frame.draw_order);
1271 assert_eq!(restore_count, 1);
1272 }
1273
1274 #[test]
1275 fn opacity_prop_zero_skips_subtree_entirely() {
1276 let mut tree = WidgetTree::new().with_theme(crate::presets::intui::light());
1279 let parent = tree.add(StackWidget::new());
1280 tree.add_child(parent, FillWidget::new().background(Color::RED));
1281 tree.set_opacity(parent, 0.0_f32);
1282 tree.layout(SizeProposal::exact(100.0, 50.0));
1283 let frame = tree.render();
1284
1285 for cmd in &frame.draw_order {
1286 assert!(
1287 !matches!(cmd, teksilo_canvas::DrawCommand::SetOpacity(_)),
1288 "fully-transparent subtree should not emit SetOpacity"
1289 );
1290 }
1291 assert!(
1293 !frame
1294 .shapes
1295 .iter()
1296 .any(|s| s.color == Color::RED.to_array()),
1297 "fully-transparent subtree should not paint its descendants"
1298 );
1299 }
1300
1301 #[test]
1302 fn transform_prop_emits_push_and_pop_around_subtree() {
1303 let mut tree = WidgetTree::new().with_theme(crate::presets::intui::light());
1308 let parent = tree.add(StackWidget::new());
1309 let _child = tree.add_child(parent, FillWidget::new().background(Color::RED));
1310 let scale_2x = teksilo_canvas::Transform2D::scale(2.0, 2.0);
1311 tree.set_transform(parent, scale_2x);
1312 tree.layout(SizeProposal::exact(100.0, 50.0));
1313 let frame = tree.render();
1314
1315 let mut push_count = 0;
1316 let mut pop_count = 0;
1317 let mut push_value = None;
1318 for cmd in &frame.draw_order {
1319 match cmd {
1320 teksilo_canvas::DrawCommand::PushTransform(t) => {
1321 push_count += 1;
1322 push_value = Some(*t);
1323 }
1324 teksilo_canvas::DrawCommand::PopTransform => pop_count += 1,
1325 _ => {}
1326 }
1327 }
1328 assert_eq!(push_count, 1, "draw_order = {:?}", frame.draw_order);
1329 assert_eq!(pop_count, 1);
1330 assert_eq!(push_value, Some(scale_2x));
1331 }
1332
1333 #[test]
1334 fn content_transform_clip_wraps_outside_the_transform() {
1335 let mut tree = WidgetTree::new().with_theme(crate::presets::intui::light());
1343 let parent = tree.add(StackWidget::new());
1344 tree.add_child(parent, FillWidget::new().background(Color::RED));
1345 tree.layout(SizeProposal::exact(100.0, 50.0));
1346 tree.set_clips_children(parent, true);
1348 tree.set_content_transform(parent, teksilo_canvas::Transform2D::translate(50.0, 30.0));
1349 let frame = tree.render();
1350
1351 let mut set_clip = None;
1352 let mut push = None;
1353 let mut pop = None;
1354 let mut clear = None;
1355 for (i, cmd) in frame.draw_order.iter().enumerate() {
1356 match cmd {
1357 teksilo_canvas::DrawCommand::SetClip(_) => set_clip = set_clip.or(Some(i)),
1358 teksilo_canvas::DrawCommand::PushTransform(_) => push = push.or(Some(i)),
1359 teksilo_canvas::DrawCommand::PopTransform => pop = Some(i),
1360 teksilo_canvas::DrawCommand::ClearClip => clear = Some(i),
1361 _ => {}
1362 }
1363 }
1364 let (sc, pt, pop, cc) = (
1365 set_clip.expect("SetClip emitted"),
1366 push.expect("PushTransform emitted"),
1367 pop.expect("PopTransform emitted"),
1368 clear.expect("ClearClip emitted"),
1369 );
1370 assert!(
1371 sc < pt,
1372 "clip must open before the transform: SetClip@{sc}, PushTransform@{pt}; order={:?}",
1373 frame.draw_order
1374 );
1375 assert!(
1376 pop < cc,
1377 "clip must close after the transform: PopTransform@{pop}, ClearClip@{cc}"
1378 );
1379 }
1380
1381 #[test]
1382 fn identity_transform_prop_skipped() {
1383 let mut tree = WidgetTree::new().with_theme(crate::presets::intui::light());
1388 let parent = tree.add(StackWidget::new());
1389 tree.add_child(parent, FillWidget::new().background(Color::RED));
1390 tree.set_transform(parent, teksilo_canvas::Transform2D::IDENTITY);
1391 tree.layout(SizeProposal::exact(100.0, 50.0));
1392 let frame = tree.render();
1393
1394 for cmd in &frame.draw_order {
1395 assert!(
1396 !matches!(
1397 cmd,
1398 teksilo_canvas::DrawCommand::PushTransform(_)
1399 | teksilo_canvas::DrawCommand::PopTransform
1400 ),
1401 "identity transform must not emit a push/pop scope"
1402 );
1403 }
1404 }
1405
1406 #[test]
1407 fn transform_scope_paint_order_opacity_outer_transform_inner() {
1408 let mut tree = WidgetTree::new().with_theme(crate::presets::intui::light());
1413 let parent = tree.add(StackWidget::new());
1414 tree.add_child(parent, FillWidget::new().background(Color::RED));
1415 tree.set_opacity(parent, 0.7_f32);
1416 tree.set_transform(parent, teksilo_canvas::Transform2D::scale(2.0, 2.0));
1417 tree.layout(SizeProposal::exact(100.0, 50.0));
1418 let frame = tree.render();
1419
1420 let mut set_opacity_idx = None;
1422 let mut push_transform_idx = None;
1423 let mut pop_transform_idx = None;
1424 let mut restore_opacity_idx = None;
1425 for (i, cmd) in frame.draw_order.iter().enumerate() {
1426 match cmd {
1427 teksilo_canvas::DrawCommand::SetOpacity(_) => set_opacity_idx = Some(i),
1428 teksilo_canvas::DrawCommand::PushTransform(_) => push_transform_idx = Some(i),
1429 teksilo_canvas::DrawCommand::PopTransform => pop_transform_idx = Some(i),
1430 teksilo_canvas::DrawCommand::RestoreOpacity => restore_opacity_idx = Some(i),
1431 _ => {}
1432 }
1433 }
1434 let so = set_opacity_idx.expect("SetOpacity emitted");
1435 let pt = push_transform_idx.expect("PushTransform emitted");
1436 let popt = pop_transform_idx.expect("PopTransform emitted");
1437 let ro = restore_opacity_idx.expect("RestoreOpacity emitted");
1438 assert!(so < pt, "opacity must open before transform: {so} < {pt}");
1439 assert!(pt < popt, "transform push must precede its pop");
1440 assert!(
1441 popt < ro,
1442 "transform must close before opacity: {popt} < {ro}"
1443 );
1444 }
1445
1446 #[derive(Debug)]
1450 struct Sandwich {
1451 child: Option<crate::WidgetId>,
1452 }
1453
1454 impl Widget for Sandwich {
1455 fn build(&mut self, ctx: &mut crate::build_context::BuildContext) -> Vec<crate::WidgetId> {
1456 let c = ctx.add(FillWidget::new().background(Color::GREEN));
1457 self.child = Some(c);
1458 vec![c]
1459 }
1460
1461 fn layout_response(
1462 &self,
1463 proposal: SizeProposal,
1464 _ctx: &LayoutContext,
1465 ) -> crate::widget::LayoutResponse {
1466 proposal.resolve(0.0, 0.0).into()
1467 }
1468
1469 fn place_children(
1470 &self,
1471 bounds: teksilo_canvas::Rect,
1472 _proposal: SizeProposal,
1473 children: &mut [crate::widget::WidgetPlacement],
1474 _ctx: &LayoutContext,
1475 ) {
1476 for child in children.iter_mut() {
1477 child.origin = bounds.origin();
1478 child.size = bounds.size();
1479 }
1480 }
1481
1482 fn children(&self) -> Vec<crate::WidgetId> {
1483 self.child.iter().copied().collect()
1484 }
1485
1486 fn paint(
1487 &self,
1488 bounds: teksilo_canvas::Rect,
1489 canvas: &mut teksilo_canvas::Canvas,
1490 _ctx: &PaintContext,
1491 ) {
1492 canvas.fill_rounded_rect(bounds, CornerRadius::uniform(0.0), Color::RED);
1493 }
1494
1495 fn wants_post_paint(&self) -> bool {
1496 true
1497 }
1498
1499 fn post_paint(
1500 &self,
1501 bounds: teksilo_canvas::Rect,
1502 canvas: &mut teksilo_canvas::Canvas,
1503 _ctx: &PaintContext,
1504 ) {
1505 canvas.fill_rounded_rect(bounds, CornerRadius::uniform(0.0), Color::BLUE);
1506 }
1507 }
1508
1509 #[test]
1510 fn post_paint_emits_after_children() {
1511 let mut tree = WidgetTree::new().with_theme(crate::presets::intui::light());
1516 tree.add(Sandwich { child: None });
1517 tree.layout(SizeProposal::exact(100.0, 50.0));
1518 let frame = tree.render();
1519
1520 let color_of = |cmd: &teksilo_canvas::DrawCommand| -> Option<[f32; 4]> {
1521 match cmd {
1522 teksilo_canvas::DrawCommand::Shape(i) => frame.shapes.get(*i).map(|s| s.color),
1523 teksilo_canvas::DrawCommand::Decoration(i) => {
1524 frame.decorations.get(*i).map(|d| d.color)
1525 }
1526 _ => None,
1527 }
1528 };
1529 let find = |dominant: usize| -> Option<usize> {
1531 frame.draw_order.iter().position(|cmd| {
1532 color_of(cmd).is_some_and(|c| {
1533 c[dominant] > 0.5 && (0..3).all(|ch| ch == dominant || c[ch] < 0.5)
1534 })
1535 })
1536 };
1537 let red = find(0).expect("backdrop (RED) painted");
1538 let green = find(1).expect("child (GREEN) painted");
1539 let blue = find(2).expect("foreground (BLUE) painted");
1540 assert!(
1541 red < green && green < blue,
1542 "expected backdrop < child < foreground; RED@{red}, GREEN@{green}, BLUE@{blue}; order={:?}",
1543 frame.draw_order
1544 );
1545 }
1546
1547 #[test]
1548 fn blur_prop_emits_begin_end_pair_around_subtree() {
1549 let mut tree = WidgetTree::new().with_theme(crate::presets::intui::light());
1555 let parent = tree.add(StackWidget::new());
1556 let _child = tree.add_child(parent, FillWidget::new().background(Color::RED));
1557 tree.set_blur(parent, 8.0_f32);
1558 tree.layout(SizeProposal::exact(100.0, 50.0));
1559 let frame = tree.render();
1560
1561 let mut begin_count = 0;
1562 let mut end_count = 0;
1563 let mut begin_radius = None;
1564 for cmd in &frame.draw_order {
1565 match cmd {
1566 teksilo_canvas::DrawCommand::BeginBlurredSubtree { radius, .. } => {
1567 begin_count += 1;
1568 begin_radius = Some(*radius);
1569 }
1570 teksilo_canvas::DrawCommand::EndBlurredSubtree => end_count += 1,
1571 _ => {}
1572 }
1573 }
1574 assert_eq!(begin_count, 1, "draw_order = {:?}", frame.draw_order);
1575 assert_eq!(end_count, 1);
1576 assert_eq!(begin_radius, Some(8.0));
1577 }
1578
1579 #[test]
1580 fn blur_prop_subperceptual_radius_skipped() {
1581 let mut tree = WidgetTree::new().with_theme(crate::presets::intui::light());
1585 let parent = tree.add(StackWidget::new());
1586 tree.add_child(parent, FillWidget::new().background(Color::RED));
1587 tree.set_blur(parent, 0.2_f32);
1588 tree.layout(SizeProposal::exact(100.0, 50.0));
1589 let frame = tree.render();
1590
1591 for cmd in &frame.draw_order {
1592 assert!(
1593 !matches!(
1594 cmd,
1595 teksilo_canvas::DrawCommand::BeginBlurredSubtree { .. }
1596 | teksilo_canvas::DrawCommand::EndBlurredSubtree
1597 ),
1598 "sub-perceptual blur must not emit Begin/End"
1599 );
1600 }
1601 }
1602
1603 #[test]
1604 fn blur_scope_is_outermost_when_combined_with_opacity_and_transform() {
1605 let mut tree = WidgetTree::new().with_theme(crate::presets::intui::light());
1612 let parent = tree.add(StackWidget::new());
1613 tree.add_child(parent, FillWidget::new().background(Color::RED));
1614 tree.set_blur(parent, 8.0_f32);
1615 tree.set_opacity(parent, 0.7_f32);
1616 tree.set_transform(parent, teksilo_canvas::Transform2D::scale(2.0, 2.0));
1617 tree.layout(SizeProposal::exact(100.0, 50.0));
1618 let frame = tree.render();
1619
1620 let mut begin_idx = None;
1621 let mut set_opacity_idx = None;
1622 let mut push_transform_idx = None;
1623 let mut pop_transform_idx = None;
1624 let mut restore_opacity_idx = None;
1625 let mut end_idx = None;
1626 for (i, cmd) in frame.draw_order.iter().enumerate() {
1627 match cmd {
1628 teksilo_canvas::DrawCommand::BeginBlurredSubtree { .. } => begin_idx = Some(i),
1629 teksilo_canvas::DrawCommand::SetOpacity(_) => set_opacity_idx = Some(i),
1630 teksilo_canvas::DrawCommand::PushTransform(_) => push_transform_idx = Some(i),
1631 teksilo_canvas::DrawCommand::PopTransform => pop_transform_idx = Some(i),
1632 teksilo_canvas::DrawCommand::RestoreOpacity => restore_opacity_idx = Some(i),
1633 teksilo_canvas::DrawCommand::EndBlurredSubtree => end_idx = Some(i),
1634 _ => {}
1635 }
1636 }
1637 let bg = begin_idx.expect("Begin emitted");
1638 let so = set_opacity_idx.expect("SetOpacity emitted");
1639 let pt = push_transform_idx.expect("PushTransform emitted");
1640 let popt = pop_transform_idx.expect("PopTransform emitted");
1641 let ro = restore_opacity_idx.expect("RestoreOpacity emitted");
1642 let en = end_idx.expect("End emitted");
1643 assert!(bg < so, "blur opens before opacity");
1644 assert!(so < pt, "opacity opens before transform");
1645 assert!(pt < popt);
1646 assert!(popt < ro, "transform closes before opacity");
1647 assert!(ro < en, "opacity closes before blur");
1648 }
1649
1650 #[test]
1651 fn nested_theme_overrides_compose() {
1652 let mut tree = WidgetTree::new().with_theme(crate::presets::intui::light());
1653
1654 let grandparent = tree.add(FillWidget::new());
1655 let parent = tree.add_child(grandparent, FillWidget::new());
1656 let child = tree.add_child(parent, FillWidget::new());
1657
1658 tree.set_theme_override(grandparent, |theme| {
1659 theme.colors.accent = Color::RED;
1660 });
1661 tree.set_theme_override(parent, |theme| {
1662 theme.colors.text_secondary = Color::GREEN;
1663 });
1664
1665 tree.layout(SizeProposal::exact(100.0, 50.0));
1666
1667 let child_theme = tree.resolved_theme(child);
1668 assert_eq!(child_theme.colors.accent, Color::RED);
1669 assert_eq!(child_theme.colors.text_secondary, Color::GREEN);
1670 }
1671
1672 #[derive(Debug)]
1675 struct ColorProbe {
1676 color: Rc<std::cell::Cell<Color>>,
1677 paints: Rc<std::cell::Cell<u32>>,
1678 post_paints: Rc<std::cell::Cell<u32>>,
1679 foreground: bool,
1680 }
1681
1682 impl ColorProbe {
1683 fn new(color: Color) -> Self {
1684 Self {
1685 color: Rc::new(std::cell::Cell::new(color)),
1686 paints: Rc::new(std::cell::Cell::new(0)),
1687 post_paints: Rc::new(std::cell::Cell::new(0)),
1688 foreground: false,
1689 }
1690 }
1691 }
1692
1693 impl Widget for ColorProbe {
1694 fn layout_response(
1695 &self,
1696 proposal: SizeProposal,
1697 _ctx: &LayoutContext,
1698 ) -> crate::widget::LayoutResponse {
1699 proposal.resolve(40.0, 40.0).into()
1700 }
1701
1702 fn paint(&self, bounds: Rect, canvas: &mut Canvas, _ctx: &PaintContext) {
1703 self.paints.set(self.paints.get() + 1);
1704 canvas.fill_rect(bounds, self.color.get());
1705 }
1706
1707 fn wants_post_paint(&self) -> bool {
1708 self.foreground
1709 }
1710
1711 fn post_paint(&self, bounds: Rect, canvas: &mut Canvas, _ctx: &PaintContext) {
1712 self.post_paints.set(self.post_paints.get() + 1);
1713 canvas.fill_rect(bounds, self.color.get());
1714 }
1715 }
1716
1717 #[derive(Debug)]
1721 struct OffsetContainer {
1722 offset: f32,
1723 }
1724
1725 impl Widget for OffsetContainer {
1726 fn layout_response(
1727 &self,
1728 proposal: SizeProposal,
1729 _ctx: &LayoutContext,
1730 ) -> crate::widget::LayoutResponse {
1731 proposal.resolve(0.0, 0.0).into()
1732 }
1733
1734 fn place_children(
1735 &self,
1736 bounds: Rect,
1737 _proposal: SizeProposal,
1738 children: &mut [WidgetPlacement],
1739 _ctx: &LayoutContext,
1740 ) {
1741 for child in children.iter_mut() {
1742 child.origin = teksilo_canvas::Point::new(bounds.x + self.offset, bounds.y);
1743 child.size = teksilo_canvas::Size::new(40.0, 40.0);
1744 }
1745 }
1746 }
1747
1748 fn painted_colors(frame: &RenderFrame) -> Vec<[f32; 4]> {
1749 frame.decorations.iter().map(|d| d.color).collect()
1750 }
1751
1752 fn clipped_probe_tree(probe: ColorProbe) -> (WidgetTree, WidgetId) {
1755 let mut tree = WidgetTree::new().with_theme(crate::presets::intui::light());
1756 let root = tree.add(OffsetContainer { offset: 150.0 });
1757 tree.set_clips_children(root, true);
1758 let child = tree.add_child(root, probe);
1759 tree.layout(SizeProposal::exact(200.0, 50.0));
1760 (tree, child)
1761 }
1762
1763 #[test]
1764 fn clipped_out_dirty_node_repaints_when_it_comes_back_into_view() {
1765 let probe = ColorProbe::new(Color::RED);
1766 let (color, paints) = (probe.color.clone(), probe.paints.clone());
1767 let (mut tree, child) = clipped_probe_tree(probe);
1768 assert!(painted_colors(&tree.render()).contains(&Color::RED.to_array()));
1769
1770 tree.layout(SizeProposal::exact(100.0, 50.0));
1771 assert_eq!(tree.bounds(child).x, 150.0, "the probe must not move");
1772 color.set(Color::BLUE);
1773 tree.arena.mark_needs_paint(child);
1774 let frame = tree.render();
1775 assert!(
1776 painted_colors(&frame).is_empty(),
1777 "the probe is clipped out"
1778 );
1779 assert_eq!(paints.get(), 1, "a clipped-out node does not paint");
1780
1781 tree.layout(SizeProposal::exact(200.0, 50.0));
1782 let colors = painted_colors(&tree.render());
1783 assert_eq!(
1784 colors,
1785 vec![Color::BLUE.to_array()],
1786 "a node dirtied while clipped out must not replay its old paint"
1787 );
1788 assert_eq!(paints.get(), 2);
1789 }
1790
1791 #[test]
1792 fn clipped_out_clean_node_keeps_its_cache() {
1793 let probe = ColorProbe::new(Color::RED);
1794 let paints = probe.paints.clone();
1795 let (mut tree, child) = clipped_probe_tree(probe);
1796 let _ = tree.render();
1797
1798 tree.layout(SizeProposal::exact(100.0, 50.0));
1799 let _ = tree.render();
1800 assert!(
1801 tree.arena.get(child).unwrap().cached_paint.is_some(),
1802 "a clean node keeps its cache while clipped out"
1803 );
1804
1805 tree.layout(SizeProposal::exact(200.0, 50.0));
1806 let colors = painted_colors(&tree.render());
1807 assert_eq!(colors, vec![Color::RED.to_array()]);
1808 assert_eq!(paints.get(), 1, "the clean node replays its cache");
1809 }
1810
1811 #[test]
1812 fn node_dirtied_under_a_transparent_ancestor_repaints_when_it_fades_in() {
1813 let probe = ColorProbe::new(Color::RED);
1814 let (color, paints) = (probe.color.clone(), probe.paints.clone());
1815 let mut tree = WidgetTree::new().with_theme(crate::presets::intui::light());
1816 let parent = tree.add(StackWidget::new());
1817 let child = tree.add_child(parent, probe);
1818 let opacity = crate::signal::Signal::new(1.0_f32);
1819 tree.set_opacity(parent, opacity.clone());
1820 tree.layout(SizeProposal::exact(100.0, 50.0));
1821 let _ = tree.render();
1822
1823 opacity.set(0.0);
1824 tree.layout(SizeProposal::exact(100.0, 50.0));
1825 color.set(Color::BLUE);
1826 tree.arena.mark_needs_paint(child);
1827 let _ = tree.render();
1828 assert_eq!(paints.get(), 1, "a transparent subtree does not paint");
1829
1830 opacity.set(1.0);
1831 tree.layout(SizeProposal::exact(100.0, 50.0));
1832 let colors = painted_colors(&tree.render());
1833 assert_eq!(colors, vec![Color::BLUE.to_array()]);
1834 }
1835
1836 #[test]
1837 fn transparent_dirty_node_repaints_when_it_fades_in() {
1838 let probe = ColorProbe::new(Color::RED);
1839 let color = probe.color.clone();
1840 let mut tree = WidgetTree::new().with_theme(crate::presets::intui::light());
1841 let node = tree.add(probe);
1842 let opacity = crate::signal::Signal::new(1.0_f32);
1843 tree.set_opacity(node, opacity.clone());
1844 tree.layout(SizeProposal::exact(100.0, 50.0));
1845 let _ = tree.render();
1846
1847 opacity.set(0.0);
1854 tree.layout(SizeProposal::exact(100.0, 50.0));
1855 color.set(Color::BLUE);
1856 tree.arena.mark_needs_paint(node);
1857 let _ = tree.render();
1858 assert!(
1859 tree.arena.get(node).unwrap().cached_paint.is_none(),
1860 "a node dirtied while transparent drops its stale cache"
1861 );
1862
1863 opacity.set(1.0);
1864 tree.layout(SizeProposal::exact(100.0, 50.0));
1865 let colors = painted_colors(&tree.render());
1866 assert_eq!(colors, vec![Color::BLUE.to_array()]);
1867 }
1868
1869 #[test]
1874 fn a_node_changed_while_dormant_repaints_when_activated() {
1875 let probe = ColorProbe::new(Color::RED);
1876 let color = probe.color.clone();
1877 let mut tree = WidgetTree::new().with_theme(crate::presets::intui::light());
1878 let node = tree.add(probe);
1879 tree.layout(SizeProposal::exact(100.0, 50.0));
1880 let _ = tree.render();
1881
1882 tree.set_dormant(node);
1883 color.set(Color::BLUE);
1884 tree.arena.mark_needs_paint(node);
1885 tree.layout(SizeProposal::exact(100.0, 50.0));
1886 let _ = tree.render();
1887
1888 tree.activate(node);
1889 tree.layout(SizeProposal::exact(100.0, 50.0));
1890 let colors = painted_colors(&tree.render());
1891 assert_eq!(colors, vec![Color::BLUE.to_array()]);
1892 }
1893
1894 #[derive(Debug)]
1897 struct CountingStack {
1898 children: Vec<WidgetId>,
1899 layouts: Rc<std::cell::Cell<u32>>,
1900 }
1901
1902 impl Widget for CountingStack {
1903 fn layout_response(
1904 &self,
1905 proposal: SizeProposal,
1906 _ctx: &LayoutContext,
1907 ) -> crate::widget::LayoutResponse {
1908 self.layouts.set(self.layouts.get() + 1);
1909 proposal.resolve(0.0, 0.0).into()
1910 }
1911
1912 fn place_children(
1913 &self,
1914 bounds: Rect,
1915 _proposal: SizeProposal,
1916 children: &mut [WidgetPlacement],
1917 _ctx: &LayoutContext,
1918 ) {
1919 for child in children.iter_mut() {
1920 child.origin = bounds.origin();
1921 child.size = bounds.size();
1922 }
1923 }
1924
1925 fn children(&self) -> Vec<WidgetId> {
1926 self.children.clone()
1927 }
1928 }
1929
1930 #[test]
1934 fn a_destroyed_subtree_leaves_the_next_frame() {
1935 let mut tree = WidgetTree::new().with_theme(crate::presets::intui::light());
1936 let red = tree.add(ColorProbe::new(Color::RED));
1937 let blue = tree.add(ColorProbe::new(Color::BLUE));
1938 let layouts = Rc::new(std::cell::Cell::new(0));
1939 let root = tree.add(CountingStack {
1940 children: vec![red, blue],
1941 layouts: layouts.clone(),
1942 });
1943 tree.layout(SizeProposal::exact(100.0, 50.0));
1944 assert_eq!(painted_colors(&tree.render()).len(), 2);
1945 tree.layout(SizeProposal::exact(100.0, 50.0));
1946 let settled = layouts.get();
1947
1948 tree.destroy_subtree_for_testing(red);
1949 tree.layout(SizeProposal::exact(100.0, 50.0));
1950 assert!(layouts.get() > settled, "the parent laid out again");
1951 assert_eq!(painted_colors(&tree.render()), vec![Color::BLUE.to_array()]);
1952
1953 tree.destroy_subtree_for_testing(root);
1955 tree.layout(SizeProposal::exact(100.0, 50.0));
1956 assert!(painted_colors(&tree.render()).is_empty());
1957 }
1958
1959 #[test]
1962 fn a_subtree_a_handler_destroys_leaves_the_next_frame() {
1963 use crate::widget_builder::WidgetBuilder;
1964 let mut tree = WidgetTree::new().with_theme(crate::presets::intui::light());
1965 let red = tree.add(ColorProbe::new(Color::RED));
1966 let button =
1967 tree.add(ColorProbe::new(Color::BLUE).on_tap(move |_tap, ctx| ctx.destroy(red)));
1968 tree.add(StackWidget::new().child(red).child(button));
1969 tree.layout(SizeProposal::exact(100.0, 50.0));
1970 assert_eq!(painted_colors(&tree.render()).len(), 2);
1971
1972 tree.synthesise_tap(button);
1973 assert!(tree.arena.get(red).is_none(), "the tap destroyed it");
1974 tree.layout(SizeProposal::exact(100.0, 50.0));
1975 assert_eq!(painted_colors(&tree.render()), vec![Color::BLUE.to_array()]);
1976 }
1977
1978 #[test]
1979 fn clipped_out_dirty_node_reruns_its_foreground_pass() {
1980 let mut probe = ColorProbe::new(Color::RED);
1981 probe.foreground = true;
1982 let (color, post_paints) = (probe.color.clone(), probe.post_paints.clone());
1983 let (mut tree, child) = clipped_probe_tree(probe);
1984 let _ = tree.render();
1985 assert_eq!(post_paints.get(), 1);
1986
1987 tree.layout(SizeProposal::exact(100.0, 50.0));
1988 color.set(Color::BLUE);
1989 tree.arena.mark_needs_paint(child);
1990 let _ = tree.render();
1991 assert!(
1992 tree.arena.get(child).unwrap().cached_post_paint.is_none(),
1993 "the stale foreground cache is dropped with the main one"
1994 );
1995
1996 tree.layout(SizeProposal::exact(200.0, 50.0));
1997 let colors = painted_colors(&tree.render());
1998 assert_eq!(
1999 colors,
2000 vec![Color::BLUE.to_array(), Color::BLUE.to_array()],
2001 "both passes repaint in the new colour"
2002 );
2003 assert_eq!(post_paints.get(), 2);
2004 }
2005}