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 = self.overlay_rects_for_view();
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 pub(crate) fn overlay_rects_for_view(&self) -> crate::widget::OverlayRects {
279 let mut rects = crate::widget::OverlayRects::default();
280 for overlay in self.overlay_manager.interactive() {
281 let content = overlay.content_id;
282 let mut parts = Vec::new();
283 if self
284 .arena
285 .get(content)
286 .is_some_and(|node| node.event_pass_through)
287 {
288 self.collect_press_takers(content, &mut parts);
289 } else {
290 parts.push(overlay.bounds);
291 }
292 parts.retain(|r| r.width > 0.0 && r.height > 0.0);
293 if !self.overlay_manager.is_modal_scrim(overlay) {
294 rects.over_chrome.extend(parts.iter().copied());
295 }
296 rects.all.extend(parts);
297 }
298 rects
299 }
300
301 fn collect_press_takers(&self, id: WidgetId, out: &mut Vec<Rect>) {
304 for &child in self.arena.children(id) {
305 if !self.arena.is_active(child) {
306 continue;
307 }
308 if self
309 .arena
310 .get(child)
311 .is_some_and(|node| node.event_pass_through)
312 {
313 self.collect_press_takers(child, out);
314 } else {
315 out.push(self.arena.bounds(child));
316 }
317 }
318 }
319}
320
321#[allow(clippy::too_many_arguments)]
341fn paint_widget_cached(
342 arena: &mut WidgetArena,
343 id: WidgetId,
344 frame: &mut RenderFrame,
345 base_theme: &crate::styles::Theme,
346 text_backend: &Option<Rc<RefCell<dyn teksilo_canvas::TextBackend>>>,
347 clip_bounds: Option<Rect>,
348 a11y_prefs: &A11yPaintPrefs,
349 paint_epoch: u64,
350 overlay_skip: &std::collections::HashSet<WidgetId>,
351 overlay_rects: &crate::widget::OverlayRects,
352 layout_direction: crate::environment::LayoutDirection,
353 parent_effective_enabled: bool,
354 accumulated_raster_scale: f32,
355) {
356 if !arena.is_active(id) {
357 return;
358 }
359
360 let this_effective_enabled = parent_effective_enabled
365 && arena
366 .get(id)
367 .and_then(|n| n.enabled_state.as_ref())
368 .is_none_or(|p| p.get());
369
370 let node = arena.get(id).expect("node id is active (guarded above)");
371 let bounds = node.bounds;
372 if let Some(clip) = clip_bounds {
373 let x0 = bounds.x.max(clip.x);
374 let y0 = bounds.y.max(clip.y);
375 let x1 = bounds.right().min(clip.right());
376 let y1 = bounds.bottom().min(clip.bottom());
377 if x1 <= x0 || y1 <= y0 {
378 return;
383 }
384 }
385
386 if let Some(node_mut) = arena.get_mut(id) {
391 node_mut.last_painted_epoch = paint_epoch;
392 }
393
394 let node = arena.get(id).expect("node id is active (guarded above)");
399 let opacity = node.opacity_prop.as_ref().map(|p| p.get().clamp(0.0, 1.0));
400 if let Some(o) = opacity
401 && o < 1.0 / 512.0
402 {
403 return;
409 }
410
411 let node = arena.get(id).expect("node id is active (guarded above)");
419 let blur_radius = node
420 .blur_prop
421 .as_ref()
422 .map(|p| p.get())
423 .filter(|r| *r >= 0.5);
424 let blur_bounds = arena.bounds(id);
425 if let Some(r) = blur_radius {
426 frame
427 .draw_order
428 .push(teksilo_canvas::DrawCommand::BeginBlurredSubtree {
429 bounds: blur_bounds,
430 radius: r,
431 });
432 }
433
434 if let Some(o) = opacity {
435 frame
436 .draw_order
437 .push(teksilo_canvas::DrawCommand::SetOpacity(o));
438 }
439
440 let node = arena.get(id).expect("node id is active (guarded above)");
448 let transform = node.transform_prop.as_ref().map(|p| p.get());
449 let push_transform = transform.filter(|t| *t != teksilo_canvas::Transform2D::IDENTITY);
450 let clips = node.clips_children;
451 let content_transform = node.content_transform;
452 let bounds = node.bounds;
453
454 let this_raster_scale = match push_transform {
462 Some(t) => quantize_raster_scale(accumulated_raster_scale * t.geometric_scale()),
463 None => accumulated_raster_scale,
464 };
465 let raster_scale_changed = this_raster_scale != accumulated_raster_scale;
469 if raster_scale_changed && let Some(tb) = text_backend {
470 tb.borrow_mut().set_raster_scale(this_raster_scale);
471 }
472 let clip_outside_transform = clips && content_transform;
481 if clip_outside_transform {
482 frame
483 .draw_order
484 .push(teksilo_canvas::DrawCommand::SetClip(bounds));
485 }
486 if let Some(t) = push_transform {
487 frame
488 .draw_order
489 .push(teksilo_canvas::DrawCommand::PushTransform(t));
490 }
491
492 let node = arena.get(id).expect("node id is active (guarded above)");
493 let needs_paint = node.dirty.needs_paint || node.paint_raster_scale != this_raster_scale;
498
499 if needs_paint || node.cached_paint.is_none() {
500 let resolved_theme = arena.resolve_theme(id, base_theme);
501 let ctx = PaintContext {
502 theme: &resolved_theme,
503 scale_factor: this_raster_scale,
504 text_scale: a11y_prefs.text_scale,
505 layout_direction,
506 effective_enabled: this_effective_enabled,
507 prefers_high_contrast: a11y_prefs.high_contrast,
508 prefers_reduced_motion: a11y_prefs.reduced_motion,
509 prefers_large_text: a11y_prefs.large_text,
510 window_active: a11y_prefs.window_active,
511 clip_bounds,
512 };
513
514 let bounds = arena.bounds(id);
515 let node = arena.get(id).expect("node id is active (guarded above)");
516
517 let mut canvas = match text_backend {
518 Some(tb) => Canvas::with_text_backend(tb.clone()),
519 None => Canvas::new(),
520 };
521 if let Some(wake) = &node.repaint_wake {
524 wake.consume_repaint();
525 }
526 node.widget.paint(bounds, &mut canvas, &ctx);
527 let widget_frame = canvas.into_render_frame();
528
529 frame.merge(&widget_frame);
530 if let Some(node) = arena.get_mut(id) {
531 node.cached_paint = Some(widget_frame);
532 node.paint_raster_scale = this_raster_scale;
533 node.paint_consumed_epoch = paint_epoch;
534 }
535 } else {
536 let node = arena.get(id).expect("node id is active (guarded above)");
537 if let Some(cached) = &node.cached_paint {
538 if !cached.layout_keys.is_empty()
546 && let Some(tb) = text_backend
547 {
548 let mut tb = tb.borrow_mut();
549 for key in &cached.layout_keys {
550 tb.touch_layout(*key);
551 }
552 #[cfg(debug_assertions)]
553 debug_validate_layout_keys(&*tb, &cached.layout_keys, Some(id));
554 }
555 frame.merge(cached);
556 }
557 }
558
559 let node = arena.get(id).expect("node id is active (guarded above)");
560 let children: Vec<WidgetId> = node.children.clone();
561 let next_clip = if clips {
562 Some(match clip_bounds {
563 Some(clip) => {
564 let x0 = bounds.x.max(clip.x);
565 let y0 = bounds.y.max(clip.y);
566 let x1 = bounds.right().min(clip.right());
567 let y1 = bounds.bottom().min(clip.bottom());
568 Rect::new(x0, y0, (x1 - x0).max(0.0), (y1 - y0).max(0.0))
569 }
570 None => bounds,
571 })
572 } else {
573 clip_bounds
574 };
575
576 let next_clip = match (
590 content_transform,
591 next_clip,
592 transform.and_then(|t| t.inverse()),
593 ) {
594 (true, Some(screen_clip), Some(inv)) => Some(inv.apply_rect(screen_clip)),
595 _ => next_clip,
596 };
597
598 if clips && !clip_outside_transform {
602 frame
603 .draw_order
604 .push(teksilo_canvas::DrawCommand::SetClip(bounds));
605 }
606
607 for child_id in children {
608 if overlay_skip.contains(&child_id) {
614 continue;
615 }
616 paint_widget_cached(
617 arena,
618 child_id,
619 frame,
620 base_theme,
621 text_backend,
622 next_clip,
623 a11y_prefs,
624 paint_epoch,
625 overlay_skip,
626 overlay_rects,
627 layout_direction,
628 this_effective_enabled,
629 this_raster_scale,
630 );
631 }
632
633 {
641 let arena_ref: &WidgetArena = &*arena;
642 if let Some(node) = arena_ref.get(id)
643 && node.widget.wants_after_paint()
644 {
645 let view = crate::widget::WidgetTreeView::new(arena_ref, overlay_rects);
646 let resolved_theme = arena_ref.resolve_theme(id, base_theme);
647 let ctx = PaintContext {
648 theme: &resolved_theme,
649 scale_factor: this_raster_scale,
650 text_scale: a11y_prefs.text_scale,
651 layout_direction,
652 effective_enabled: this_effective_enabled,
653 prefers_high_contrast: a11y_prefs.high_contrast,
654 prefers_reduced_motion: a11y_prefs.reduced_motion,
655 prefers_large_text: a11y_prefs.large_text,
656 window_active: a11y_prefs.window_active,
657 clip_bounds,
658 };
659 node.widget.after_paint(&view, &ctx);
660 }
661 }
662
663 let wants_post_paint = arena
672 .get(id)
673 .map(|n| n.widget.wants_post_paint())
674 .unwrap_or(false);
675 if wants_post_paint {
676 let has_post_cache = arena
677 .get(id)
678 .map(|n| n.cached_post_paint.is_some())
679 .unwrap_or(false);
680 if needs_paint || !has_post_cache {
681 if raster_scale_changed && let Some(tb) = text_backend {
686 tb.borrow_mut().set_raster_scale(this_raster_scale);
687 }
688 let resolved_theme = arena.resolve_theme(id, base_theme);
689 let ctx = PaintContext {
690 theme: &resolved_theme,
691 scale_factor: this_raster_scale,
692 text_scale: a11y_prefs.text_scale,
693 layout_direction,
694 effective_enabled: this_effective_enabled,
695 prefers_high_contrast: a11y_prefs.high_contrast,
696 prefers_reduced_motion: a11y_prefs.reduced_motion,
697 prefers_large_text: a11y_prefs.large_text,
698 window_active: a11y_prefs.window_active,
699 clip_bounds,
700 };
701 let bounds = arena.bounds(id);
702 let node = arena.get(id).expect("node id is active (guarded above)");
703 let mut canvas = match text_backend {
704 Some(tb) => Canvas::with_text_backend(tb.clone()),
705 None => Canvas::new(),
706 };
707 node.widget.post_paint(bounds, &mut canvas, &ctx);
708 let post_frame = canvas.into_render_frame();
709 frame.merge(&post_frame);
710 if let Some(node) = arena.get_mut(id) {
711 node.cached_post_paint = Some(post_frame);
712 }
713 } else if let Some(cached) = arena.get(id).and_then(|n| n.cached_post_paint.as_ref()) {
714 if !cached.layout_keys.is_empty()
715 && let Some(tb) = text_backend
716 {
717 let mut tb = tb.borrow_mut();
718 for key in &cached.layout_keys {
719 tb.touch_layout(*key);
720 }
721 #[cfg(debug_assertions)]
722 debug_validate_layout_keys(&*tb, &cached.layout_keys, Some(id));
723 }
724 frame.merge(cached);
725 }
726 }
727
728 if clips && !clip_outside_transform {
729 frame
730 .draw_order
731 .push(teksilo_canvas::DrawCommand::ClearClip);
732 }
733
734 if push_transform.is_some() {
735 frame
736 .draw_order
737 .push(teksilo_canvas::DrawCommand::PopTransform);
738 }
739
740 if clip_outside_transform {
743 frame
744 .draw_order
745 .push(teksilo_canvas::DrawCommand::ClearClip);
746 }
747
748 if opacity.is_some() {
749 frame
750 .draw_order
751 .push(teksilo_canvas::DrawCommand::RestoreOpacity);
752 }
753
754 if blur_radius.is_some() {
755 frame
756 .draw_order
757 .push(teksilo_canvas::DrawCommand::EndBlurredSubtree);
758 }
759
760 if raster_scale_changed && let Some(tb) = text_backend {
763 tb.borrow_mut().set_raster_scale(accumulated_raster_scale);
764 }
765}
766
767#[cfg(debug_assertions)]
780fn debug_validate_layout_keys(
781 tb: &dyn teksilo_canvas::TextBackend,
782 layout_keys: &[u64],
783 widget: Option<WidgetId>,
784) {
785 use teksilo_canvas::GlyphValidation;
786 std::thread_local! {
787 static REPORTED_STALE_KEYS: std::cell::RefCell<std::collections::HashSet<u64>> =
788 std::cell::RefCell::new(std::collections::HashSet::new());
789 }
790 for key in layout_keys {
791 match tb.debug_validate_layout(*key) {
792 GlyphValidation::Valid => {}
793 GlyphValidation::StaleKey => {
794 let first_report = REPORTED_STALE_KEYS.with(|set| set.borrow_mut().insert(*key));
795 if first_report {
796 eprintln!(
797 "[teksilo] WARNING: retained paint cache replays layout_key={key} \
798 (widget={widget:?}) that the text backend no longer knows — the \
799 frame survived a backend cache clear, so a paint-cache \
800 invalidation path is likely missing."
801 );
802 }
803 }
804 GlyphValidation::RectMismatch => {
805 panic!(
806 "stale glyph UVs in retained paint cache (layout_key={key}, \
807 widget={widget:?}): cached quads no longer match the live glyph \
808 atlas — a cache-invalidation path is missing"
809 );
810 }
811 }
812 }
813}
814
815#[cfg(test)]
816mod tests {
817 use super::*;
818 use crate::test_widgets::{FillWidget, StackWidget};
819 use teksilo_tokens::{Color, CornerRadius};
820
821 #[derive(Default)]
828 struct RecordingTextBackend {
829 touched: std::rc::Rc<std::cell::RefCell<Vec<u64>>>,
830 raster_scale: f32,
831 layout_scales: std::rc::Rc<std::cell::RefCell<Vec<f32>>>,
834 }
835
836 impl RecordingTextBackend {
837 fn new(touched: std::rc::Rc<std::cell::RefCell<Vec<u64>>>) -> Self {
838 Self {
839 touched,
840 raster_scale: 1.0,
841 layout_scales: Default::default(),
842 }
843 }
844 }
845
846 impl teksilo_canvas::TextBackend for RecordingTextBackend {
847 fn set_raster_scale(&mut self, raster_scale: f32) {
848 self.raster_scale = raster_scale;
849 }
850
851 fn raster_scale(&self) -> f32 {
852 self.raster_scale
853 }
854
855 fn layout_single_line(
856 &mut self,
857 text: &str,
858 _style: &teksilo_tokens::TextStyle,
859 _max_width: Option<f32>,
860 ) -> teksilo_canvas::TextLayout {
861 self.layout_scales.borrow_mut().push(self.raster_scale);
862 teksilo_canvas::TextLayout {
863 width: text.len() as f32 * 8.0,
864 height: 16.0,
865 ascent: 12.0,
866 descent: 4.0,
867 underline_offset: 1.0,
868 underline_thickness: 1.0,
869 layout_key: 42,
870 line_count: 1,
871 spans: Vec::new(),
872 raster_scale: self.raster_scale,
873 geometry: None,
874 }
875 }
876
877 fn ensure_glyphs(
878 &mut self,
879 _layout: &teksilo_canvas::TextLayout,
880 ) -> Vec<teksilo_canvas::GlyphQuad> {
881 Vec::new()
882 }
883
884 fn touch_layout(&mut self, layout_key: u64) {
885 self.touched.borrow_mut().push(layout_key);
886 }
887 }
888
889 #[derive(Debug)]
892 struct TextPaintWidget;
893
894 impl Widget for TextPaintWidget {
895 fn layout_response(
896 &self,
897 proposal: SizeProposal,
898 _ctx: &LayoutContext,
899 ) -> crate::widget::LayoutResponse {
900 proposal.resolve(100.0, 20.0).into()
901 }
902
903 fn paint(
904 &self,
905 bounds: teksilo_canvas::Rect,
906 canvas: &mut teksilo_canvas::Canvas,
907 _ctx: &PaintContext,
908 ) {
909 let style = teksilo_tokens::TextStyle::default();
910 let _ = canvas.draw_text("hello", bounds, &style, Color::BLACK);
911 }
912 }
913
914 #[test]
915 fn full_frame_cache_hit_touches_layout_keys() {
916 let touched = std::rc::Rc::new(std::cell::RefCell::new(Vec::new()));
917 let backend = RecordingTextBackend::new(touched.clone());
918 let mut tree = WidgetTree::new()
919 .with_theme(crate::presets::intui::light())
920 .with_text_backend(std::rc::Rc::new(std::cell::RefCell::new(backend)));
921 tree.add(TextPaintWidget);
922 tree.layout(SizeProposal::exact(200.0, 50.0));
923
924 let _ = tree.render();
926 touched.borrow_mut().clear();
927
928 let _ = tree.render();
932 assert!(
933 touched.borrow().contains(&42),
934 "full-frame cache hit must touch_layout every baked layout key; \
935 touched = {:?}",
936 touched.borrow()
937 );
938 }
939
940 fn scaled_subtree_tree(
951 transform: impl Into<crate::signal::Prop<teksilo_canvas::Transform2D>>,
952 ) -> (
953 WidgetTree,
954 std::rc::Rc<std::cell::RefCell<RecordingTextBackend>>,
955 std::rc::Rc<std::cell::RefCell<Vec<f32>>>,
956 ) {
957 let backend = RecordingTextBackend::new(Default::default());
958 let layout_scales = backend.layout_scales.clone();
959 let backend_rc = std::rc::Rc::new(std::cell::RefCell::new(backend));
960 let mut tree = WidgetTree::new()
961 .with_theme(crate::presets::intui::light())
962 .with_text_backend(backend_rc.clone()
963 as std::rc::Rc<std::cell::RefCell<dyn teksilo_canvas::TextBackend>>);
964
965 let root = tree.add(StackWidget::new());
966 tree.add_child(root, TextPaintWidget); let wrapper = tree.add_child(root, StackWidget::new());
968 tree.add_child(wrapper, TextPaintWidget); tree.add_child(root, TextPaintWidget); tree.set_transform(wrapper, transform);
971
972 tree.layout(SizeProposal::exact(400.0, 300.0));
973 (tree, backend_rc, layout_scales)
974 }
975
976 #[test]
977 fn walker_sets_raster_scale_for_scaled_subtree_and_restores() {
978 let (mut tree, backend_rc, layout_scales) =
979 scaled_subtree_tree(teksilo_canvas::Transform2D::scale(2.0, 2.0));
980 let _ = tree.render();
981
982 let expected_b = 1.25_f32.powi(3); assert_eq!(
987 layout_scales.borrow().as_slice(),
988 &[1.0, expected_b, 1.0],
989 "text inside the transform scope must lay out at the quantized \
990 scale; siblings before/after at screen scale"
991 );
992 assert_eq!(backend_rc.borrow().raster_scale, 1.0);
994 }
995
996 #[test]
997 fn raster_scale_change_repaints_clean_descendants() {
998 let transform = crate::signal::Signal::new(teksilo_canvas::Transform2D::scale(2.0, 2.0));
999 let (mut tree, _backend_rc, layout_scales) = scaled_subtree_tree(transform.clone());
1000 let _ = tree.render();
1001 layout_scales.borrow_mut().clear();
1002
1003 transform.set(teksilo_canvas::Transform2D::scale(3.0, 3.0));
1007 let _ = tree.render();
1008 assert_eq!(
1009 layout_scales.borrow().as_slice(),
1010 &[1.25_f32.powi(5)],
1011 "the clean text leaf inside the scope must re-rasterize at the \
1012 new quantized scale (A and C stay cached: no layout calls)"
1013 );
1014
1015 layout_scales.borrow_mut().clear();
1017 transform.set(teksilo_canvas::Transform2D::IDENTITY);
1018 let _ = tree.render();
1019 assert_eq!(layout_scales.borrow().as_slice(), &[1.0]);
1020
1021 layout_scales.borrow_mut().clear();
1024 let _ = tree.render();
1025 assert!(layout_scales.borrow().is_empty());
1026 }
1027
1028 #[test]
1029 fn nested_scale_transforms_multiply() {
1030 let backend = RecordingTextBackend::new(Default::default());
1031 let layout_scales = backend.layout_scales.clone();
1032 let mut tree = WidgetTree::new()
1033 .with_theme(crate::presets::intui::light())
1034 .with_text_backend(std::rc::Rc::new(std::cell::RefCell::new(backend)));
1035
1036 let outer = tree.add(StackWidget::new());
1039 let inner = tree.add_child(outer, StackWidget::new());
1040 tree.add_child(inner, TextPaintWidget);
1041 tree.set_transform(outer, teksilo_canvas::Transform2D::scale(2.0, 2.0));
1042 tree.set_content_transform(inner, teksilo_canvas::Transform2D::scale(1.5, 1.5));
1043
1044 tree.layout(SizeProposal::exact(400.0, 300.0));
1045 let _ = tree.render();
1046 assert_eq!(layout_scales.borrow().as_slice(), &[1.25_f32.powi(5)]);
1047 }
1048
1049 #[test]
1050 fn invalidate_all_paints_clears_post_paint_cache() {
1051 #[derive(Debug)]
1054 struct PostPaintWidget;
1055
1056 impl Widget for PostPaintWidget {
1057 fn layout_response(
1058 &self,
1059 proposal: SizeProposal,
1060 _ctx: &LayoutContext,
1061 ) -> crate::widget::LayoutResponse {
1062 proposal.resolve(50.0, 50.0).into()
1063 }
1064
1065 fn wants_post_paint(&self) -> bool {
1066 true
1067 }
1068
1069 fn post_paint(
1070 &self,
1071 bounds: teksilo_canvas::Rect,
1072 canvas: &mut teksilo_canvas::Canvas,
1073 _ctx: &PaintContext,
1074 ) {
1075 canvas.fill_rect(bounds, Color::RED);
1076 }
1077 }
1078
1079 let mut tree = WidgetTree::new().with_theme(crate::presets::intui::light());
1080 let id = tree.add(PostPaintWidget);
1081 tree.layout(SizeProposal::exact(100.0, 100.0));
1082 let _ = tree.render();
1083 assert!(
1084 tree.arena
1085 .get(id)
1086 .and_then(|n| n.cached_post_paint.as_ref())
1087 .is_some(),
1088 "render must populate cached_post_paint for a post-painting widget"
1089 );
1090
1091 tree.invalidate_all_paints();
1092 assert!(
1093 tree.arena
1094 .get(id)
1095 .and_then(|n| n.cached_post_paint.as_ref())
1096 .is_none(),
1097 "invalidate_all_paints must clear cached_post_paint too — a retained \
1098 post-paint frame can hold stale glyph UVs after atlas eviction"
1099 );
1100 }
1101
1102 #[derive(Debug)]
1103 struct ThemeAwareWidget;
1104
1105 impl Widget for ThemeAwareWidget {
1106 fn layout_response(
1107 &self,
1108 proposal: SizeProposal,
1109 _ctx: &LayoutContext,
1110 ) -> crate::widget::LayoutResponse {
1111 proposal.resolve(0.0, 0.0).into()
1112 }
1113
1114 fn paint(
1115 &self,
1116 bounds: teksilo_canvas::Rect,
1117 canvas: &mut teksilo_canvas::Canvas,
1118 ctx: &PaintContext,
1119 ) {
1120 canvas.fill_rounded_rect(
1121 bounds,
1122 teksilo_tokens::CornerRadius::uniform(4.0),
1123 ctx.theme.colors.accent,
1124 );
1125 }
1126 }
1127
1128 #[test]
1129 fn fill_widget_produces_shape_in_frame() {
1130 let mut tree = WidgetTree::new().with_theme(crate::presets::intui::light());
1131 tree.add(
1132 FillWidget::new()
1133 .background(Color::RED)
1134 .corner_radius(CornerRadius::uniform(6.0)),
1135 );
1136 tree.layout(SizeProposal::exact(100.0, 40.0));
1137 let frame = tree.render();
1138 assert_eq!(frame.shapes.len(), 1);
1139 assert_eq!(
1140 frame.shapes[0].shape,
1141 teksilo_canvas::ShapeKind::RoundedRect
1142 );
1143 }
1144
1145 #[test]
1146 fn empty_tree_renders_empty_frame() {
1147 let mut tree = WidgetTree::new();
1148 let frame = tree.render();
1149 assert!(frame.is_empty());
1150 }
1151
1152 #[test]
1153 fn render_clears_paint_dirty() {
1154 let mut tree = WidgetTree::new();
1155 tree.add(FillWidget::new().background(Color::RED));
1156 tree.layout(SizeProposal::exact(100.0, 50.0));
1157 assert!(tree.needs_paint());
1158 tree.render();
1159 assert!(!tree.needs_paint());
1160 }
1161
1162 #[test]
1163 fn dormant_widget_not_rendered() {
1164 let mut tree = WidgetTree::new();
1165 let widget = tree.add(
1166 FillWidget::new()
1167 .background(Color::RED)
1168 .corner_radius(CornerRadius::uniform(4.0)),
1169 );
1170 tree.layout(SizeProposal::exact(100.0, 50.0));
1171 let frame = tree.render();
1172 assert!(!frame.shapes.is_empty());
1173
1174 tree.set_dormant(widget);
1175 tree.layout(SizeProposal::exact(100.0, 50.0));
1176 let frame = tree.render();
1177 assert!(frame.shapes.is_empty());
1178 }
1179
1180 #[test]
1181 fn dormancy_is_recursive() {
1182 let mut tree = WidgetTree::new();
1183 let child = tree.add(
1184 FillWidget::new()
1185 .background(Color::RED)
1186 .corner_radius(CornerRadius::uniform(4.0)),
1187 );
1188 let parent = tree.add(StackWidget::new().child(child));
1189 tree.layout(SizeProposal::exact(100.0, 50.0));
1190
1191 let frame = tree.render();
1192 assert_eq!(frame.shapes.len(), 1);
1193
1194 tree.set_dormant(parent);
1195 tree.layout(SizeProposal::exact(100.0, 50.0));
1196 let frame = tree.render();
1197 assert!(frame.shapes.is_empty());
1198
1199 tree.activate(parent);
1200 tree.layout(SizeProposal::exact(100.0, 50.0));
1201 let frame = tree.render();
1202 assert_eq!(frame.shapes.len(), 1);
1203 }
1204
1205 #[test]
1206 fn set_theme_marks_all_dirty() {
1207 let mut tree = WidgetTree::new().with_theme(crate::presets::intui::light());
1208 tree.add(FillWidget::new().background(Color::RED));
1209 tree.layout(SizeProposal::exact(100.0, 50.0));
1210 tree.render();
1211
1212 assert!(!tree.needs_layout());
1213 assert!(!tree.needs_paint());
1214
1215 tree.set_theme(crate::presets::intui::dark());
1216 assert!(tree.needs_layout());
1217 assert!(tree.needs_paint());
1218 }
1219
1220 #[test]
1221 fn set_theme_changes_rendered_colors() {
1222 let mut tree = WidgetTree::new().with_theme(crate::presets::intui::light());
1223 tree.add(ThemeAwareWidget);
1224 tree.layout(SizeProposal::exact(100.0, 50.0));
1225 let light_frame = tree.render();
1226 let light_color = light_frame.shapes[0].color;
1227
1228 tree.set_theme(crate::presets::intui::dark());
1229 tree.layout(SizeProposal::exact(100.0, 50.0));
1230 let dark_frame = tree.render();
1231 let dark_color = dark_frame.shapes[0].color;
1232
1233 assert_ne!(light_color, dark_color);
1234 }
1235
1236 #[test]
1237 fn subtree_theme_override() {
1238 let mut tree = WidgetTree::new().with_theme(crate::presets::intui::light());
1239 let parent = tree.add(ThemeAwareWidget);
1240 let _child = tree.add_child(parent, ThemeAwareWidget);
1241
1242 tree.set_theme_override(parent, |theme| {
1243 theme.colors = teksilo_tokens::ColorTokens::dark_default();
1244 });
1245
1246 tree.layout(SizeProposal::exact(100.0, 50.0));
1247 let frame = tree.render();
1248
1249 let dark_accent = teksilo_tokens::ColorTokens::dark_default()
1250 .accent
1251 .to_array();
1252 assert_eq!(frame.shapes[0].color, dark_accent);
1253 assert_eq!(frame.shapes[1].color, dark_accent);
1254 }
1255
1256 #[test]
1257 fn theme_override_only_affects_subtree() {
1258 let mut tree = WidgetTree::new().with_theme(crate::presets::intui::light());
1259
1260 let _unaffected = tree.add(ThemeAwareWidget);
1261 let overridden = tree.add(ThemeAwareWidget);
1262
1263 tree.set_theme_override(overridden, |theme| {
1264 theme.colors = teksilo_tokens::ColorTokens::dark_default();
1265 });
1266
1267 tree.layout(SizeProposal::exact(100.0, 50.0));
1268 let frame = tree.render();
1269
1270 let light_accent = teksilo_tokens::ColorTokens::light_default()
1271 .accent
1272 .to_array();
1273 let dark_accent = teksilo_tokens::ColorTokens::dark_default()
1274 .accent
1275 .to_array();
1276
1277 assert_eq!(frame.shapes[0].color, light_accent);
1278 assert_eq!(frame.shapes[1].color, dark_accent);
1279 }
1280
1281 #[test]
1282 fn resolved_theme_reflects_overrides() {
1283 let mut tree = WidgetTree::new().with_theme(crate::presets::intui::light());
1284
1285 let parent = tree.add(FillWidget::new());
1286 let child = tree.add_child(parent, FillWidget::new());
1287
1288 tree.set_theme_override(parent, |theme| {
1289 theme.colors.accent = Color::RED;
1290 });
1291
1292 tree.layout(SizeProposal::exact(100.0, 50.0));
1293
1294 let parent_theme = tree.resolved_theme(parent);
1295 assert_eq!(parent_theme.colors.accent, Color::RED);
1296
1297 let child_theme = tree.resolved_theme(child);
1298 assert_eq!(child_theme.colors.accent, Color::RED);
1299 }
1300
1301 #[test]
1302 fn opacity_prop_emits_set_and_restore_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 tree.set_opacity(parent, 0.5_f32);
1311 tree.layout(SizeProposal::exact(100.0, 50.0));
1312 let frame = tree.render();
1313
1314 let mut set_count = 0;
1315 let mut restore_count = 0;
1316 for cmd in &frame.draw_order {
1317 match cmd {
1318 teksilo_canvas::DrawCommand::SetOpacity(v) => {
1319 assert!((v - 0.5).abs() < 1e-6, "expected 0.5, got {}", v);
1320 set_count += 1;
1321 }
1322 teksilo_canvas::DrawCommand::RestoreOpacity => restore_count += 1,
1323 _ => {}
1324 }
1325 }
1326 assert_eq!(set_count, 1, "draw_order = {:?}", frame.draw_order);
1327 assert_eq!(restore_count, 1);
1328 }
1329
1330 #[test]
1331 fn opacity_prop_zero_skips_subtree_entirely() {
1332 let mut tree = WidgetTree::new().with_theme(crate::presets::intui::light());
1335 let parent = tree.add(StackWidget::new());
1336 tree.add_child(parent, FillWidget::new().background(Color::RED));
1337 tree.set_opacity(parent, 0.0_f32);
1338 tree.layout(SizeProposal::exact(100.0, 50.0));
1339 let frame = tree.render();
1340
1341 for cmd in &frame.draw_order {
1342 assert!(
1343 !matches!(cmd, teksilo_canvas::DrawCommand::SetOpacity(_)),
1344 "fully-transparent subtree should not emit SetOpacity"
1345 );
1346 }
1347 assert!(
1349 !frame
1350 .shapes
1351 .iter()
1352 .any(|s| s.color == Color::RED.to_array()),
1353 "fully-transparent subtree should not paint its descendants"
1354 );
1355 }
1356
1357 #[test]
1358 fn transform_prop_emits_push_and_pop_around_subtree() {
1359 let mut tree = WidgetTree::new().with_theme(crate::presets::intui::light());
1364 let parent = tree.add(StackWidget::new());
1365 let _child = tree.add_child(parent, FillWidget::new().background(Color::RED));
1366 let scale_2x = teksilo_canvas::Transform2D::scale(2.0, 2.0);
1367 tree.set_transform(parent, scale_2x);
1368 tree.layout(SizeProposal::exact(100.0, 50.0));
1369 let frame = tree.render();
1370
1371 let mut push_count = 0;
1372 let mut pop_count = 0;
1373 let mut push_value = None;
1374 for cmd in &frame.draw_order {
1375 match cmd {
1376 teksilo_canvas::DrawCommand::PushTransform(t) => {
1377 push_count += 1;
1378 push_value = Some(*t);
1379 }
1380 teksilo_canvas::DrawCommand::PopTransform => pop_count += 1,
1381 _ => {}
1382 }
1383 }
1384 assert_eq!(push_count, 1, "draw_order = {:?}", frame.draw_order);
1385 assert_eq!(pop_count, 1);
1386 assert_eq!(push_value, Some(scale_2x));
1387 }
1388
1389 #[test]
1390 fn content_transform_clip_wraps_outside_the_transform() {
1391 let mut tree = WidgetTree::new().with_theme(crate::presets::intui::light());
1399 let parent = tree.add(StackWidget::new());
1400 tree.add_child(parent, FillWidget::new().background(Color::RED));
1401 tree.layout(SizeProposal::exact(100.0, 50.0));
1402 tree.set_clips_children(parent, true);
1404 tree.set_content_transform(parent, teksilo_canvas::Transform2D::translate(50.0, 30.0));
1405 let frame = tree.render();
1406
1407 let mut set_clip = None;
1408 let mut push = None;
1409 let mut pop = None;
1410 let mut clear = None;
1411 for (i, cmd) in frame.draw_order.iter().enumerate() {
1412 match cmd {
1413 teksilo_canvas::DrawCommand::SetClip(_) => set_clip = set_clip.or(Some(i)),
1414 teksilo_canvas::DrawCommand::PushTransform(_) => push = push.or(Some(i)),
1415 teksilo_canvas::DrawCommand::PopTransform => pop = Some(i),
1416 teksilo_canvas::DrawCommand::ClearClip => clear = Some(i),
1417 _ => {}
1418 }
1419 }
1420 let (sc, pt, pop, cc) = (
1421 set_clip.expect("SetClip emitted"),
1422 push.expect("PushTransform emitted"),
1423 pop.expect("PopTransform emitted"),
1424 clear.expect("ClearClip emitted"),
1425 );
1426 assert!(
1427 sc < pt,
1428 "clip must open before the transform: SetClip@{sc}, PushTransform@{pt}; order={:?}",
1429 frame.draw_order
1430 );
1431 assert!(
1432 pop < cc,
1433 "clip must close after the transform: PopTransform@{pop}, ClearClip@{cc}"
1434 );
1435 }
1436
1437 #[test]
1438 fn identity_transform_prop_skipped() {
1439 let mut tree = WidgetTree::new().with_theme(crate::presets::intui::light());
1444 let parent = tree.add(StackWidget::new());
1445 tree.add_child(parent, FillWidget::new().background(Color::RED));
1446 tree.set_transform(parent, teksilo_canvas::Transform2D::IDENTITY);
1447 tree.layout(SizeProposal::exact(100.0, 50.0));
1448 let frame = tree.render();
1449
1450 for cmd in &frame.draw_order {
1451 assert!(
1452 !matches!(
1453 cmd,
1454 teksilo_canvas::DrawCommand::PushTransform(_)
1455 | teksilo_canvas::DrawCommand::PopTransform
1456 ),
1457 "identity transform must not emit a push/pop scope"
1458 );
1459 }
1460 }
1461
1462 #[test]
1463 fn transform_scope_paint_order_opacity_outer_transform_inner() {
1464 let mut tree = WidgetTree::new().with_theme(crate::presets::intui::light());
1469 let parent = tree.add(StackWidget::new());
1470 tree.add_child(parent, FillWidget::new().background(Color::RED));
1471 tree.set_opacity(parent, 0.7_f32);
1472 tree.set_transform(parent, teksilo_canvas::Transform2D::scale(2.0, 2.0));
1473 tree.layout(SizeProposal::exact(100.0, 50.0));
1474 let frame = tree.render();
1475
1476 let mut set_opacity_idx = None;
1478 let mut push_transform_idx = None;
1479 let mut pop_transform_idx = None;
1480 let mut restore_opacity_idx = None;
1481 for (i, cmd) in frame.draw_order.iter().enumerate() {
1482 match cmd {
1483 teksilo_canvas::DrawCommand::SetOpacity(_) => set_opacity_idx = Some(i),
1484 teksilo_canvas::DrawCommand::PushTransform(_) => push_transform_idx = Some(i),
1485 teksilo_canvas::DrawCommand::PopTransform => pop_transform_idx = Some(i),
1486 teksilo_canvas::DrawCommand::RestoreOpacity => restore_opacity_idx = Some(i),
1487 _ => {}
1488 }
1489 }
1490 let so = set_opacity_idx.expect("SetOpacity emitted");
1491 let pt = push_transform_idx.expect("PushTransform emitted");
1492 let popt = pop_transform_idx.expect("PopTransform emitted");
1493 let ro = restore_opacity_idx.expect("RestoreOpacity emitted");
1494 assert!(so < pt, "opacity must open before transform: {so} < {pt}");
1495 assert!(pt < popt, "transform push must precede its pop");
1496 assert!(
1497 popt < ro,
1498 "transform must close before opacity: {popt} < {ro}"
1499 );
1500 }
1501
1502 #[derive(Debug)]
1506 struct Sandwich {
1507 child: Option<crate::WidgetId>,
1508 }
1509
1510 impl Widget for Sandwich {
1511 fn build(&mut self, ctx: &mut crate::build_context::BuildContext) -> Vec<crate::WidgetId> {
1512 let c = ctx.add(FillWidget::new().background(Color::GREEN));
1513 self.child = Some(c);
1514 vec![c]
1515 }
1516
1517 fn layout_response(
1518 &self,
1519 proposal: SizeProposal,
1520 _ctx: &LayoutContext,
1521 ) -> crate::widget::LayoutResponse {
1522 proposal.resolve(0.0, 0.0).into()
1523 }
1524
1525 fn place_children(
1526 &self,
1527 bounds: teksilo_canvas::Rect,
1528 _proposal: SizeProposal,
1529 children: &mut [crate::widget::WidgetPlacement],
1530 _ctx: &LayoutContext,
1531 ) {
1532 for child in children.iter_mut() {
1533 child.origin = bounds.origin();
1534 child.size = bounds.size();
1535 }
1536 }
1537
1538 fn children(&self) -> Vec<crate::WidgetId> {
1539 self.child.iter().copied().collect()
1540 }
1541
1542 fn paint(
1543 &self,
1544 bounds: teksilo_canvas::Rect,
1545 canvas: &mut teksilo_canvas::Canvas,
1546 _ctx: &PaintContext,
1547 ) {
1548 canvas.fill_rounded_rect(bounds, CornerRadius::uniform(0.0), Color::RED);
1549 }
1550
1551 fn wants_post_paint(&self) -> bool {
1552 true
1553 }
1554
1555 fn post_paint(
1556 &self,
1557 bounds: teksilo_canvas::Rect,
1558 canvas: &mut teksilo_canvas::Canvas,
1559 _ctx: &PaintContext,
1560 ) {
1561 canvas.fill_rounded_rect(bounds, CornerRadius::uniform(0.0), Color::BLUE);
1562 }
1563 }
1564
1565 #[test]
1566 fn post_paint_emits_after_children() {
1567 let mut tree = WidgetTree::new().with_theme(crate::presets::intui::light());
1572 tree.add(Sandwich { child: None });
1573 tree.layout(SizeProposal::exact(100.0, 50.0));
1574 let frame = tree.render();
1575
1576 let color_of = |cmd: &teksilo_canvas::DrawCommand| -> Option<[f32; 4]> {
1577 match cmd {
1578 teksilo_canvas::DrawCommand::Shape(i) => frame.shapes.get(*i).map(|s| s.color),
1579 teksilo_canvas::DrawCommand::Decoration(i) => {
1580 frame.decorations.get(*i).map(|d| d.color)
1581 }
1582 _ => None,
1583 }
1584 };
1585 let find = |dominant: usize| -> Option<usize> {
1587 frame.draw_order.iter().position(|cmd| {
1588 color_of(cmd).is_some_and(|c| {
1589 c[dominant] > 0.5 && (0..3).all(|ch| ch == dominant || c[ch] < 0.5)
1590 })
1591 })
1592 };
1593 let red = find(0).expect("backdrop (RED) painted");
1594 let green = find(1).expect("child (GREEN) painted");
1595 let blue = find(2).expect("foreground (BLUE) painted");
1596 assert!(
1597 red < green && green < blue,
1598 "expected backdrop < child < foreground; RED@{red}, GREEN@{green}, BLUE@{blue}; order={:?}",
1599 frame.draw_order
1600 );
1601 }
1602
1603 #[test]
1604 fn blur_prop_emits_begin_end_pair_around_subtree() {
1605 let mut tree = WidgetTree::new().with_theme(crate::presets::intui::light());
1611 let parent = tree.add(StackWidget::new());
1612 let _child = tree.add_child(parent, FillWidget::new().background(Color::RED));
1613 tree.set_blur(parent, 8.0_f32);
1614 tree.layout(SizeProposal::exact(100.0, 50.0));
1615 let frame = tree.render();
1616
1617 let mut begin_count = 0;
1618 let mut end_count = 0;
1619 let mut begin_radius = None;
1620 for cmd in &frame.draw_order {
1621 match cmd {
1622 teksilo_canvas::DrawCommand::BeginBlurredSubtree { radius, .. } => {
1623 begin_count += 1;
1624 begin_radius = Some(*radius);
1625 }
1626 teksilo_canvas::DrawCommand::EndBlurredSubtree => end_count += 1,
1627 _ => {}
1628 }
1629 }
1630 assert_eq!(begin_count, 1, "draw_order = {:?}", frame.draw_order);
1631 assert_eq!(end_count, 1);
1632 assert_eq!(begin_radius, Some(8.0));
1633 }
1634
1635 #[test]
1636 fn blur_prop_subperceptual_radius_skipped() {
1637 let mut tree = WidgetTree::new().with_theme(crate::presets::intui::light());
1641 let parent = tree.add(StackWidget::new());
1642 tree.add_child(parent, FillWidget::new().background(Color::RED));
1643 tree.set_blur(parent, 0.2_f32);
1644 tree.layout(SizeProposal::exact(100.0, 50.0));
1645 let frame = tree.render();
1646
1647 for cmd in &frame.draw_order {
1648 assert!(
1649 !matches!(
1650 cmd,
1651 teksilo_canvas::DrawCommand::BeginBlurredSubtree { .. }
1652 | teksilo_canvas::DrawCommand::EndBlurredSubtree
1653 ),
1654 "sub-perceptual blur must not emit Begin/End"
1655 );
1656 }
1657 }
1658
1659 #[test]
1660 fn blur_scope_is_outermost_when_combined_with_opacity_and_transform() {
1661 let mut tree = WidgetTree::new().with_theme(crate::presets::intui::light());
1668 let parent = tree.add(StackWidget::new());
1669 tree.add_child(parent, FillWidget::new().background(Color::RED));
1670 tree.set_blur(parent, 8.0_f32);
1671 tree.set_opacity(parent, 0.7_f32);
1672 tree.set_transform(parent, teksilo_canvas::Transform2D::scale(2.0, 2.0));
1673 tree.layout(SizeProposal::exact(100.0, 50.0));
1674 let frame = tree.render();
1675
1676 let mut begin_idx = None;
1677 let mut set_opacity_idx = None;
1678 let mut push_transform_idx = None;
1679 let mut pop_transform_idx = None;
1680 let mut restore_opacity_idx = None;
1681 let mut end_idx = None;
1682 for (i, cmd) in frame.draw_order.iter().enumerate() {
1683 match cmd {
1684 teksilo_canvas::DrawCommand::BeginBlurredSubtree { .. } => begin_idx = Some(i),
1685 teksilo_canvas::DrawCommand::SetOpacity(_) => set_opacity_idx = Some(i),
1686 teksilo_canvas::DrawCommand::PushTransform(_) => push_transform_idx = Some(i),
1687 teksilo_canvas::DrawCommand::PopTransform => pop_transform_idx = Some(i),
1688 teksilo_canvas::DrawCommand::RestoreOpacity => restore_opacity_idx = Some(i),
1689 teksilo_canvas::DrawCommand::EndBlurredSubtree => end_idx = Some(i),
1690 _ => {}
1691 }
1692 }
1693 let bg = begin_idx.expect("Begin emitted");
1694 let so = set_opacity_idx.expect("SetOpacity emitted");
1695 let pt = push_transform_idx.expect("PushTransform emitted");
1696 let popt = pop_transform_idx.expect("PopTransform emitted");
1697 let ro = restore_opacity_idx.expect("RestoreOpacity emitted");
1698 let en = end_idx.expect("End emitted");
1699 assert!(bg < so, "blur opens before opacity");
1700 assert!(so < pt, "opacity opens before transform");
1701 assert!(pt < popt);
1702 assert!(popt < ro, "transform closes before opacity");
1703 assert!(ro < en, "opacity closes before blur");
1704 }
1705
1706 #[test]
1707 fn nested_theme_overrides_compose() {
1708 let mut tree = WidgetTree::new().with_theme(crate::presets::intui::light());
1709
1710 let grandparent = tree.add(FillWidget::new());
1711 let parent = tree.add_child(grandparent, FillWidget::new());
1712 let child = tree.add_child(parent, FillWidget::new());
1713
1714 tree.set_theme_override(grandparent, |theme| {
1715 theme.colors.accent = Color::RED;
1716 });
1717 tree.set_theme_override(parent, |theme| {
1718 theme.colors.text_secondary = Color::GREEN;
1719 });
1720
1721 tree.layout(SizeProposal::exact(100.0, 50.0));
1722
1723 let child_theme = tree.resolved_theme(child);
1724 assert_eq!(child_theme.colors.accent, Color::RED);
1725 assert_eq!(child_theme.colors.text_secondary, Color::GREEN);
1726 }
1727
1728 #[derive(Debug)]
1731 struct ColorProbe {
1732 color: Rc<std::cell::Cell<Color>>,
1733 paints: Rc<std::cell::Cell<u32>>,
1734 post_paints: Rc<std::cell::Cell<u32>>,
1735 foreground: bool,
1736 }
1737
1738 impl ColorProbe {
1739 fn new(color: Color) -> Self {
1740 Self {
1741 color: Rc::new(std::cell::Cell::new(color)),
1742 paints: Rc::new(std::cell::Cell::new(0)),
1743 post_paints: Rc::new(std::cell::Cell::new(0)),
1744 foreground: false,
1745 }
1746 }
1747 }
1748
1749 impl Widget for ColorProbe {
1750 fn layout_response(
1751 &self,
1752 proposal: SizeProposal,
1753 _ctx: &LayoutContext,
1754 ) -> crate::widget::LayoutResponse {
1755 proposal.resolve(40.0, 40.0).into()
1756 }
1757
1758 fn paint(&self, bounds: Rect, canvas: &mut Canvas, _ctx: &PaintContext) {
1759 self.paints.set(self.paints.get() + 1);
1760 canvas.fill_rect(bounds, self.color.get());
1761 }
1762
1763 fn wants_post_paint(&self) -> bool {
1764 self.foreground
1765 }
1766
1767 fn post_paint(&self, bounds: Rect, canvas: &mut Canvas, _ctx: &PaintContext) {
1768 self.post_paints.set(self.post_paints.get() + 1);
1769 canvas.fill_rect(bounds, self.color.get());
1770 }
1771 }
1772
1773 #[derive(Debug)]
1777 struct OffsetContainer {
1778 offset: f32,
1779 }
1780
1781 impl Widget for OffsetContainer {
1782 fn layout_response(
1783 &self,
1784 proposal: SizeProposal,
1785 _ctx: &LayoutContext,
1786 ) -> crate::widget::LayoutResponse {
1787 proposal.resolve(0.0, 0.0).into()
1788 }
1789
1790 fn place_children(
1791 &self,
1792 bounds: Rect,
1793 _proposal: SizeProposal,
1794 children: &mut [WidgetPlacement],
1795 _ctx: &LayoutContext,
1796 ) {
1797 for child in children.iter_mut() {
1798 child.origin = teksilo_canvas::Point::new(bounds.x + self.offset, bounds.y);
1799 child.size = teksilo_canvas::Size::new(40.0, 40.0);
1800 }
1801 }
1802 }
1803
1804 fn painted_colors(frame: &RenderFrame) -> Vec<[f32; 4]> {
1805 frame.decorations.iter().map(|d| d.color).collect()
1806 }
1807
1808 fn clipped_probe_tree(probe: ColorProbe) -> (WidgetTree, WidgetId) {
1811 let mut tree = WidgetTree::new().with_theme(crate::presets::intui::light());
1812 let root = tree.add(OffsetContainer { offset: 150.0 });
1813 tree.set_clips_children(root, true);
1814 let child = tree.add_child(root, probe);
1815 tree.layout(SizeProposal::exact(200.0, 50.0));
1816 (tree, child)
1817 }
1818
1819 #[test]
1820 fn clipped_out_dirty_node_repaints_when_it_comes_back_into_view() {
1821 let probe = ColorProbe::new(Color::RED);
1822 let (color, paints) = (probe.color.clone(), probe.paints.clone());
1823 let (mut tree, child) = clipped_probe_tree(probe);
1824 assert!(painted_colors(&tree.render()).contains(&Color::RED.to_array()));
1825
1826 tree.layout(SizeProposal::exact(100.0, 50.0));
1827 assert_eq!(tree.bounds(child).x, 150.0, "the probe must not move");
1828 color.set(Color::BLUE);
1829 tree.arena.mark_needs_paint(child);
1830 let frame = tree.render();
1831 assert!(
1832 painted_colors(&frame).is_empty(),
1833 "the probe is clipped out"
1834 );
1835 assert_eq!(paints.get(), 1, "a clipped-out node does not paint");
1836
1837 tree.layout(SizeProposal::exact(200.0, 50.0));
1838 let colors = painted_colors(&tree.render());
1839 assert_eq!(
1840 colors,
1841 vec![Color::BLUE.to_array()],
1842 "a node dirtied while clipped out must not replay its old paint"
1843 );
1844 assert_eq!(paints.get(), 2);
1845 }
1846
1847 #[test]
1848 fn clipped_out_clean_node_keeps_its_cache() {
1849 let probe = ColorProbe::new(Color::RED);
1850 let paints = probe.paints.clone();
1851 let (mut tree, child) = clipped_probe_tree(probe);
1852 let _ = tree.render();
1853
1854 tree.layout(SizeProposal::exact(100.0, 50.0));
1855 let _ = tree.render();
1856 assert!(
1857 tree.arena.get(child).unwrap().cached_paint.is_some(),
1858 "a clean node keeps its cache while clipped out"
1859 );
1860
1861 tree.layout(SizeProposal::exact(200.0, 50.0));
1862 let colors = painted_colors(&tree.render());
1863 assert_eq!(colors, vec![Color::RED.to_array()]);
1864 assert_eq!(paints.get(), 1, "the clean node replays its cache");
1865 }
1866
1867 #[test]
1868 fn node_dirtied_under_a_transparent_ancestor_repaints_when_it_fades_in() {
1869 let probe = ColorProbe::new(Color::RED);
1870 let (color, paints) = (probe.color.clone(), probe.paints.clone());
1871 let mut tree = WidgetTree::new().with_theme(crate::presets::intui::light());
1872 let parent = tree.add(StackWidget::new());
1873 let child = tree.add_child(parent, probe);
1874 let opacity = crate::signal::Signal::new(1.0_f32);
1875 tree.set_opacity(parent, opacity.clone());
1876 tree.layout(SizeProposal::exact(100.0, 50.0));
1877 let _ = tree.render();
1878
1879 opacity.set(0.0);
1880 tree.layout(SizeProposal::exact(100.0, 50.0));
1881 color.set(Color::BLUE);
1882 tree.arena.mark_needs_paint(child);
1883 let _ = tree.render();
1884 assert_eq!(paints.get(), 1, "a transparent subtree does not paint");
1885
1886 opacity.set(1.0);
1887 tree.layout(SizeProposal::exact(100.0, 50.0));
1888 let colors = painted_colors(&tree.render());
1889 assert_eq!(colors, vec![Color::BLUE.to_array()]);
1890 }
1891
1892 #[test]
1893 fn transparent_dirty_node_repaints_when_it_fades_in() {
1894 let probe = ColorProbe::new(Color::RED);
1895 let color = probe.color.clone();
1896 let mut tree = WidgetTree::new().with_theme(crate::presets::intui::light());
1897 let node = tree.add(probe);
1898 let opacity = crate::signal::Signal::new(1.0_f32);
1899 tree.set_opacity(node, opacity.clone());
1900 tree.layout(SizeProposal::exact(100.0, 50.0));
1901 let _ = tree.render();
1902
1903 opacity.set(0.0);
1910 tree.layout(SizeProposal::exact(100.0, 50.0));
1911 color.set(Color::BLUE);
1912 tree.arena.mark_needs_paint(node);
1913 let _ = tree.render();
1914 assert!(
1915 tree.arena.get(node).unwrap().cached_paint.is_none(),
1916 "a node dirtied while transparent drops its stale cache"
1917 );
1918
1919 opacity.set(1.0);
1920 tree.layout(SizeProposal::exact(100.0, 50.0));
1921 let colors = painted_colors(&tree.render());
1922 assert_eq!(colors, vec![Color::BLUE.to_array()]);
1923 }
1924
1925 #[test]
1930 fn a_node_changed_while_dormant_repaints_when_activated() {
1931 let probe = ColorProbe::new(Color::RED);
1932 let color = probe.color.clone();
1933 let mut tree = WidgetTree::new().with_theme(crate::presets::intui::light());
1934 let node = tree.add(probe);
1935 tree.layout(SizeProposal::exact(100.0, 50.0));
1936 let _ = tree.render();
1937
1938 tree.set_dormant(node);
1939 color.set(Color::BLUE);
1940 tree.arena.mark_needs_paint(node);
1941 tree.layout(SizeProposal::exact(100.0, 50.0));
1942 let _ = tree.render();
1943
1944 tree.activate(node);
1945 tree.layout(SizeProposal::exact(100.0, 50.0));
1946 let colors = painted_colors(&tree.render());
1947 assert_eq!(colors, vec![Color::BLUE.to_array()]);
1948 }
1949
1950 #[derive(Debug)]
1953 struct CountingStack {
1954 children: Vec<WidgetId>,
1955 layouts: Rc<std::cell::Cell<u32>>,
1956 }
1957
1958 impl Widget for CountingStack {
1959 fn layout_response(
1960 &self,
1961 proposal: SizeProposal,
1962 _ctx: &LayoutContext,
1963 ) -> crate::widget::LayoutResponse {
1964 self.layouts.set(self.layouts.get() + 1);
1965 proposal.resolve(0.0, 0.0).into()
1966 }
1967
1968 fn place_children(
1969 &self,
1970 bounds: Rect,
1971 _proposal: SizeProposal,
1972 children: &mut [WidgetPlacement],
1973 _ctx: &LayoutContext,
1974 ) {
1975 for child in children.iter_mut() {
1976 child.origin = bounds.origin();
1977 child.size = bounds.size();
1978 }
1979 }
1980
1981 fn children(&self) -> Vec<WidgetId> {
1982 self.children.clone()
1983 }
1984 }
1985
1986 #[test]
1990 fn a_destroyed_subtree_leaves_the_next_frame() {
1991 let mut tree = WidgetTree::new().with_theme(crate::presets::intui::light());
1992 let red = tree.add(ColorProbe::new(Color::RED));
1993 let blue = tree.add(ColorProbe::new(Color::BLUE));
1994 let layouts = Rc::new(std::cell::Cell::new(0));
1995 let root = tree.add(CountingStack {
1996 children: vec![red, blue],
1997 layouts: layouts.clone(),
1998 });
1999 tree.layout(SizeProposal::exact(100.0, 50.0));
2000 assert_eq!(painted_colors(&tree.render()).len(), 2);
2001 tree.layout(SizeProposal::exact(100.0, 50.0));
2002 let settled = layouts.get();
2003
2004 tree.destroy_subtree_for_testing(red);
2005 tree.layout(SizeProposal::exact(100.0, 50.0));
2006 assert!(layouts.get() > settled, "the parent laid out again");
2007 assert_eq!(painted_colors(&tree.render()), vec![Color::BLUE.to_array()]);
2008
2009 tree.destroy_subtree_for_testing(root);
2011 tree.layout(SizeProposal::exact(100.0, 50.0));
2012 assert!(painted_colors(&tree.render()).is_empty());
2013 }
2014
2015 #[test]
2018 fn a_subtree_a_handler_destroys_leaves_the_next_frame() {
2019 use crate::widget_builder::WidgetBuilder;
2020 let mut tree = WidgetTree::new().with_theme(crate::presets::intui::light());
2021 let red = tree.add(ColorProbe::new(Color::RED));
2022 let button =
2023 tree.add(ColorProbe::new(Color::BLUE).on_tap(move |_tap, ctx| ctx.destroy(red)));
2024 tree.add(StackWidget::new().child(red).child(button));
2025 tree.layout(SizeProposal::exact(100.0, 50.0));
2026 assert_eq!(painted_colors(&tree.render()).len(), 2);
2027
2028 tree.synthesise_tap(button);
2029 assert!(tree.arena.get(red).is_none(), "the tap destroyed it");
2030 tree.layout(SizeProposal::exact(100.0, 50.0));
2031 assert_eq!(painted_colors(&tree.render()), vec![Color::BLUE.to_array()]);
2032 }
2033
2034 #[test]
2035 fn clipped_out_dirty_node_reruns_its_foreground_pass() {
2036 let mut probe = ColorProbe::new(Color::RED);
2037 probe.foreground = true;
2038 let (color, post_paints) = (probe.color.clone(), probe.post_paints.clone());
2039 let (mut tree, child) = clipped_probe_tree(probe);
2040 let _ = tree.render();
2041 assert_eq!(post_paints.get(), 1);
2042
2043 tree.layout(SizeProposal::exact(100.0, 50.0));
2044 color.set(Color::BLUE);
2045 tree.arena.mark_needs_paint(child);
2046 let _ = tree.render();
2047 assert!(
2048 tree.arena.get(child).unwrap().cached_post_paint.is_none(),
2049 "the stale foreground cache is dropped with the main one"
2050 );
2051
2052 tree.layout(SizeProposal::exact(200.0, 50.0));
2053 let colors = painted_colors(&tree.render());
2054 assert_eq!(
2055 colors,
2056 vec![Color::BLUE.to_array(), Color::BLUE.to_array()],
2057 "both passes repaint in the new colour"
2058 );
2059 assert_eq!(post_paints.get(), 2);
2060 }
2061}