1use super::*;
5
6use teksilo_canvas::quantize_raster_scale;
7
8struct A11yPaintPrefs {
10 high_contrast: bool,
11 reduced_motion: bool,
12 large_text: bool,
13 text_scale: f32,
16 window_active: bool,
20}
21
22impl WidgetTree {
23 pub fn render(&mut self) -> std::rc::Rc<RenderFrame> {
29 let mut noop = crate::window::NoopWindowOps;
30 self.render_with_ops(&mut noop)
31 }
32
33 pub fn render_with_ops(
38 &mut self,
39 ops: &mut dyn crate::window::WindowOps,
40 ) -> std::rc::Rc<RenderFrame> {
41 self.process_state_changes(&mut *ops);
42
43 let now = std::time::Instant::now();
56 let has_animations = self.animated_quads.has_running();
57 if has_animations {
58 self.animated_quads
59 .tick(now, &self.arena, self.paint_epoch, &self.theme);
60 }
61
62 if !self.arena.any_needs_paint() && self.cached_frame.is_some() {
81 self.arm_frame_tick_for_visible_subscribers();
86
87 if let Some(cached) = self.cached_frame.as_mut() {
88 let frame = std::rc::Rc::make_mut(cached);
89 if has_animations {
90 let src = self.animated_quads.scratch_slice();
91 frame.anim_params.clear();
92 frame.anim_params.extend_from_slice(src);
93 }
94 if !frame.layout_keys.is_empty()
103 && let Some(tb) = &self.text_backend
104 {
105 let mut tb = tb.borrow_mut();
106 for key in &frame.layout_keys {
107 tb.touch_layout(*key);
108 }
109 #[cfg(debug_assertions)]
110 debug_validate_layout_keys(&*tb, &frame.layout_keys, None);
111 }
112 return std::rc::Rc::clone(cached);
113 }
114 }
115
116 self.paint_epoch = self.paint_epoch.saturating_add(1);
117 let paint_epoch = self.paint_epoch;
118
119 let mut frame = RenderFrame::new();
120 let base_theme = if self.is_window_active() {
129 self.effective_theme.clone()
130 } else {
131 self.effective_theme.for_inactive_window()
132 };
133 let base_theme = if self.prefers_high_contrast {
139 base_theme.for_high_contrast()
140 } else {
141 base_theme
142 };
143 let text_backend = self.text_backend.clone();
144 let a11y_prefs = A11yPaintPrefs {
145 high_contrast: self.prefers_high_contrast,
146 reduced_motion: self.prefers_reduced_motion,
147 large_text: self.text_scale_factor > 1.0,
148 text_scale: self.effective_text_scale,
149 window_active: self.is_window_active(),
150 };
151
152 let overlay_skip: std::collections::HashSet<WidgetId> = self
153 .overlay_manager
154 .active_content_ids()
155 .into_iter()
156 .collect();
157
158 let overlay_rects: Vec<Rect> = self.overlay_manager.interactive_rects();
164
165 for root_id in self.arena.roots() {
166 if overlay_skip.contains(&root_id) {
170 continue;
171 }
172 paint_widget_cached(
173 &mut self.arena,
174 root_id,
175 &mut frame,
176 &base_theme,
177 &text_backend,
178 None,
179 &a11y_prefs,
180 paint_epoch,
181 &overlay_skip,
182 &overlay_rects,
183 self.layout_direction,
184 true,
187 1.0,
190 );
191 }
192
193 for content_id in self.overlay_manager.active_content_ids() {
194 paint_widget_cached(
195 &mut self.arena,
196 content_id,
197 &mut frame,
198 &base_theme,
199 &text_backend,
200 None,
201 &a11y_prefs,
202 paint_epoch,
203 &overlay_skip,
204 &overlay_rects,
205 self.layout_direction,
206 true,
211 1.0,
214 );
215 }
216
217 self.arena.fill_active_ids(&mut self.active_ids_scratch);
221 let ids = std::mem::take(&mut self.active_ids_scratch);
222 for &id in &ids {
223 if let Some(node) = self.arena.get_mut(id) {
224 node.dirty.needs_paint = false;
225 }
226 }
227 self.active_ids_scratch = ids;
228
229 if has_animations {
230 frame
231 .anim_params
232 .extend_from_slice(self.animated_quads.scratch_slice());
233 }
234 frame.debug_validate_stacks();
235 let rc = std::rc::Rc::new(frame);
236 self.cached_frame = Some(std::rc::Rc::clone(&rc));
237 self.arm_frame_tick_for_visible_subscribers();
243 rc
244 }
245
246 pub(crate) fn arm_frame_tick_for_visible_subscribers(&mut self) {
255 if self
256 .frame_tick_scheduler
257 .should_arm_frame_tick(&self.arena, self.paint_epoch)
258 {
259 self.frame_tick_armed_by_subscribers = true;
260 }
261 }
262}
263
264#[allow(clippy::too_many_arguments)]
284fn paint_widget_cached(
285 arena: &mut WidgetArena,
286 id: WidgetId,
287 frame: &mut RenderFrame,
288 base_theme: &crate::styles::Theme,
289 text_backend: &Option<Rc<RefCell<dyn teksilo_canvas::TextBackend>>>,
290 clip_bounds: Option<Rect>,
291 a11y_prefs: &A11yPaintPrefs,
292 paint_epoch: u64,
293 overlay_skip: &std::collections::HashSet<WidgetId>,
294 overlay_rects: &[Rect],
295 layout_direction: crate::environment::LayoutDirection,
296 parent_effective_enabled: bool,
297 accumulated_raster_scale: f32,
298) {
299 if !arena.is_active(id) {
300 return;
301 }
302
303 let this_effective_enabled = parent_effective_enabled
308 && arena
309 .get(id)
310 .and_then(|n| n.enabled_state.as_ref())
311 .is_none_or(|p| p.get());
312
313 let node = arena.get(id).expect("node id is active (guarded above)");
314 let bounds = node.bounds;
315 if let Some(clip) = clip_bounds {
316 let x0 = bounds.x.max(clip.x);
317 let y0 = bounds.y.max(clip.y);
318 let x1 = bounds.right().min(clip.right());
319 let y1 = bounds.bottom().min(clip.bottom());
320 if x1 <= x0 || y1 <= y0 {
321 return;
326 }
327 }
328
329 if let Some(node_mut) = arena.get_mut(id) {
334 node_mut.last_painted_epoch = paint_epoch;
335 }
336
337 let node = arena.get(id).expect("node id is active (guarded above)");
342 let opacity = node.opacity_prop.as_ref().map(|p| p.get().clamp(0.0, 1.0));
343 if let Some(o) = opacity
344 && o < 1.0 / 512.0
345 {
346 return;
352 }
353
354 let node = arena.get(id).expect("node id is active (guarded above)");
362 let blur_radius = node
363 .blur_prop
364 .as_ref()
365 .map(|p| p.get())
366 .filter(|r| *r >= 0.5);
367 let blur_bounds = arena.bounds(id);
368 if let Some(r) = blur_radius {
369 frame
370 .draw_order
371 .push(teksilo_canvas::DrawCommand::BeginBlurredSubtree {
372 bounds: blur_bounds,
373 radius: r,
374 });
375 }
376
377 if let Some(o) = opacity {
378 frame
379 .draw_order
380 .push(teksilo_canvas::DrawCommand::SetOpacity(o));
381 }
382
383 let node = arena.get(id).expect("node id is active (guarded above)");
391 let transform = node.transform_prop.as_ref().map(|p| p.get());
392 let push_transform = transform.filter(|t| *t != teksilo_canvas::Transform2D::IDENTITY);
393 let clips = node.clips_children;
394 let content_transform = node.content_transform;
395 let bounds = node.bounds;
396
397 let this_raster_scale = match push_transform {
405 Some(t) => quantize_raster_scale(accumulated_raster_scale * t.geometric_scale()),
406 None => accumulated_raster_scale,
407 };
408 let raster_scale_changed = this_raster_scale != accumulated_raster_scale;
412 if raster_scale_changed && let Some(tb) = text_backend {
413 tb.borrow_mut().set_raster_scale(this_raster_scale);
414 }
415 let clip_outside_transform = clips && content_transform;
424 if clip_outside_transform {
425 frame
426 .draw_order
427 .push(teksilo_canvas::DrawCommand::SetClip(bounds));
428 }
429 if let Some(t) = push_transform {
430 frame
431 .draw_order
432 .push(teksilo_canvas::DrawCommand::PushTransform(t));
433 }
434
435 let node = arena.get(id).expect("node id is active (guarded above)");
436 let needs_paint = node.dirty.needs_paint || node.paint_raster_scale != this_raster_scale;
441
442 if needs_paint || node.cached_paint.is_none() {
443 let resolved_theme = arena.resolve_theme(id, base_theme);
444 let ctx = PaintContext {
445 theme: &resolved_theme,
446 scale_factor: this_raster_scale,
447 text_scale: a11y_prefs.text_scale,
448 layout_direction,
449 effective_enabled: this_effective_enabled,
450 prefers_high_contrast: a11y_prefs.high_contrast,
451 prefers_reduced_motion: a11y_prefs.reduced_motion,
452 prefers_large_text: a11y_prefs.large_text,
453 window_active: a11y_prefs.window_active,
454 clip_bounds,
455 };
456
457 let bounds = arena.bounds(id);
458 let node = arena.get(id).expect("node id is active (guarded above)");
459
460 let mut canvas = match text_backend {
461 Some(tb) => Canvas::with_text_backend(tb.clone()),
462 None => Canvas::new(),
463 };
464 node.widget.paint(bounds, &mut canvas, &ctx);
465 let widget_frame = canvas.into_render_frame();
466
467 frame.merge(&widget_frame);
468 if let Some(node) = arena.get_mut(id) {
469 node.cached_paint = Some(widget_frame);
470 node.paint_raster_scale = this_raster_scale;
471 }
472 } else {
473 let node = arena.get(id).expect("node id is active (guarded above)");
474 if let Some(cached) = &node.cached_paint {
475 if !cached.layout_keys.is_empty()
483 && let Some(tb) = text_backend
484 {
485 let mut tb = tb.borrow_mut();
486 for key in &cached.layout_keys {
487 tb.touch_layout(*key);
488 }
489 #[cfg(debug_assertions)]
490 debug_validate_layout_keys(&*tb, &cached.layout_keys, Some(id));
491 }
492 frame.merge(cached);
493 }
494 }
495
496 let node = arena.get(id).expect("node id is active (guarded above)");
497 let children: Vec<WidgetId> = node.children.clone();
498 let next_clip = if clips {
499 Some(match clip_bounds {
500 Some(clip) => {
501 let x0 = bounds.x.max(clip.x);
502 let y0 = bounds.y.max(clip.y);
503 let x1 = bounds.right().min(clip.right());
504 let y1 = bounds.bottom().min(clip.bottom());
505 Rect::new(x0, y0, (x1 - x0).max(0.0), (y1 - y0).max(0.0))
506 }
507 None => bounds,
508 })
509 } else {
510 clip_bounds
511 };
512
513 let next_clip = match (
527 content_transform,
528 next_clip,
529 transform.and_then(|t| t.inverse()),
530 ) {
531 (true, Some(screen_clip), Some(inv)) => Some(inv.apply_rect(screen_clip)),
532 _ => next_clip,
533 };
534
535 if clips && !clip_outside_transform {
539 frame
540 .draw_order
541 .push(teksilo_canvas::DrawCommand::SetClip(bounds));
542 }
543
544 for child_id in children {
545 if overlay_skip.contains(&child_id) {
551 continue;
552 }
553 paint_widget_cached(
554 arena,
555 child_id,
556 frame,
557 base_theme,
558 text_backend,
559 next_clip,
560 a11y_prefs,
561 paint_epoch,
562 overlay_skip,
563 overlay_rects,
564 layout_direction,
565 this_effective_enabled,
566 this_raster_scale,
567 );
568 }
569
570 {
578 let arena_ref: &WidgetArena = &*arena;
579 if let Some(node) = arena_ref.get(id)
580 && node.widget.wants_after_paint()
581 {
582 let view = crate::widget::WidgetTreeView::new(arena_ref, overlay_rects);
583 let resolved_theme = arena_ref.resolve_theme(id, base_theme);
584 let ctx = PaintContext {
585 theme: &resolved_theme,
586 scale_factor: this_raster_scale,
587 text_scale: a11y_prefs.text_scale,
588 layout_direction,
589 effective_enabled: this_effective_enabled,
590 prefers_high_contrast: a11y_prefs.high_contrast,
591 prefers_reduced_motion: a11y_prefs.reduced_motion,
592 prefers_large_text: a11y_prefs.large_text,
593 window_active: a11y_prefs.window_active,
594 clip_bounds,
595 };
596 node.widget.after_paint(&view, &ctx);
597 }
598 }
599
600 let wants_post_paint = arena
609 .get(id)
610 .map(|n| n.widget.wants_post_paint())
611 .unwrap_or(false);
612 if wants_post_paint {
613 let has_post_cache = arena
614 .get(id)
615 .map(|n| n.cached_post_paint.is_some())
616 .unwrap_or(false);
617 if needs_paint || !has_post_cache {
618 if raster_scale_changed && let Some(tb) = text_backend {
623 tb.borrow_mut().set_raster_scale(this_raster_scale);
624 }
625 let resolved_theme = arena.resolve_theme(id, base_theme);
626 let ctx = PaintContext {
627 theme: &resolved_theme,
628 scale_factor: this_raster_scale,
629 text_scale: a11y_prefs.text_scale,
630 layout_direction,
631 effective_enabled: this_effective_enabled,
632 prefers_high_contrast: a11y_prefs.high_contrast,
633 prefers_reduced_motion: a11y_prefs.reduced_motion,
634 prefers_large_text: a11y_prefs.large_text,
635 window_active: a11y_prefs.window_active,
636 clip_bounds,
637 };
638 let bounds = arena.bounds(id);
639 let node = arena.get(id).expect("node id is active (guarded above)");
640 let mut canvas = match text_backend {
641 Some(tb) => Canvas::with_text_backend(tb.clone()),
642 None => Canvas::new(),
643 };
644 node.widget.post_paint(bounds, &mut canvas, &ctx);
645 let post_frame = canvas.into_render_frame();
646 frame.merge(&post_frame);
647 if let Some(node) = arena.get_mut(id) {
648 node.cached_post_paint = Some(post_frame);
649 }
650 } else if let Some(cached) = arena.get(id).and_then(|n| n.cached_post_paint.as_ref()) {
651 if !cached.layout_keys.is_empty()
652 && let Some(tb) = text_backend
653 {
654 let mut tb = tb.borrow_mut();
655 for key in &cached.layout_keys {
656 tb.touch_layout(*key);
657 }
658 #[cfg(debug_assertions)]
659 debug_validate_layout_keys(&*tb, &cached.layout_keys, Some(id));
660 }
661 frame.merge(cached);
662 }
663 }
664
665 if clips && !clip_outside_transform {
666 frame
667 .draw_order
668 .push(teksilo_canvas::DrawCommand::ClearClip);
669 }
670
671 if push_transform.is_some() {
672 frame
673 .draw_order
674 .push(teksilo_canvas::DrawCommand::PopTransform);
675 }
676
677 if clip_outside_transform {
680 frame
681 .draw_order
682 .push(teksilo_canvas::DrawCommand::ClearClip);
683 }
684
685 if opacity.is_some() {
686 frame
687 .draw_order
688 .push(teksilo_canvas::DrawCommand::RestoreOpacity);
689 }
690
691 if blur_radius.is_some() {
692 frame
693 .draw_order
694 .push(teksilo_canvas::DrawCommand::EndBlurredSubtree);
695 }
696
697 if raster_scale_changed && let Some(tb) = text_backend {
700 tb.borrow_mut().set_raster_scale(accumulated_raster_scale);
701 }
702}
703
704#[cfg(debug_assertions)]
717fn debug_validate_layout_keys(
718 tb: &dyn teksilo_canvas::TextBackend,
719 layout_keys: &[u64],
720 widget: Option<WidgetId>,
721) {
722 use teksilo_canvas::GlyphValidation;
723 std::thread_local! {
724 static REPORTED_STALE_KEYS: std::cell::RefCell<std::collections::HashSet<u64>> =
725 std::cell::RefCell::new(std::collections::HashSet::new());
726 }
727 for key in layout_keys {
728 match tb.debug_validate_layout(*key) {
729 GlyphValidation::Valid => {}
730 GlyphValidation::StaleKey => {
731 let first_report = REPORTED_STALE_KEYS.with(|set| set.borrow_mut().insert(*key));
732 if first_report {
733 eprintln!(
734 "[teksilo] WARNING: retained paint cache replays layout_key={key} \
735 (widget={widget:?}) that the text backend no longer knows — the \
736 frame survived a backend cache clear, so a paint-cache \
737 invalidation path is likely missing."
738 );
739 }
740 }
741 GlyphValidation::RectMismatch => {
742 panic!(
743 "stale glyph UVs in retained paint cache (layout_key={key}, \
744 widget={widget:?}): cached quads no longer match the live glyph \
745 atlas — a cache-invalidation path is missing"
746 );
747 }
748 }
749 }
750}
751
752#[cfg(test)]
753mod tests {
754 use super::*;
755 use crate::test_widgets::{FillWidget, StackWidget};
756 use teksilo_tokens::{Color, CornerRadius};
757
758 #[derive(Default)]
765 struct RecordingTextBackend {
766 touched: std::rc::Rc<std::cell::RefCell<Vec<u64>>>,
767 raster_scale: f32,
768 layout_scales: std::rc::Rc<std::cell::RefCell<Vec<f32>>>,
771 }
772
773 impl RecordingTextBackend {
774 fn new(touched: std::rc::Rc<std::cell::RefCell<Vec<u64>>>) -> Self {
775 Self {
776 touched,
777 raster_scale: 1.0,
778 layout_scales: Default::default(),
779 }
780 }
781 }
782
783 impl teksilo_canvas::TextBackend for RecordingTextBackend {
784 fn set_raster_scale(&mut self, raster_scale: f32) {
785 self.raster_scale = raster_scale;
786 }
787
788 fn raster_scale(&self) -> f32 {
789 self.raster_scale
790 }
791
792 fn layout_single_line(
793 &mut self,
794 text: &str,
795 _style: &teksilo_tokens::TextStyle,
796 _max_width: Option<f32>,
797 ) -> teksilo_canvas::TextLayout {
798 self.layout_scales.borrow_mut().push(self.raster_scale);
799 teksilo_canvas::TextLayout {
800 width: text.len() as f32 * 8.0,
801 height: 16.0,
802 ascent: 12.0,
803 descent: 4.0,
804 underline_offset: 1.0,
805 underline_thickness: 1.0,
806 layout_key: 42,
807 line_count: 1,
808 spans: Vec::new(),
809 raster_scale: self.raster_scale,
810 geometry: None,
811 }
812 }
813
814 fn ensure_glyphs(
815 &mut self,
816 _layout: &teksilo_canvas::TextLayout,
817 ) -> Vec<teksilo_canvas::GlyphQuad> {
818 Vec::new()
819 }
820
821 fn touch_layout(&mut self, layout_key: u64) {
822 self.touched.borrow_mut().push(layout_key);
823 }
824 }
825
826 #[derive(Debug)]
829 struct TextPaintWidget;
830
831 impl Widget for TextPaintWidget {
832 fn layout_response(
833 &self,
834 proposal: SizeProposal,
835 _ctx: &LayoutContext,
836 ) -> crate::widget::LayoutResponse {
837 proposal.resolve(100.0, 20.0).into()
838 }
839
840 fn paint(
841 &self,
842 bounds: teksilo_canvas::Rect,
843 canvas: &mut teksilo_canvas::Canvas,
844 _ctx: &PaintContext,
845 ) {
846 let style = teksilo_tokens::TextStyle::default();
847 let _ = canvas.draw_text("hello", bounds, &style, Color::BLACK);
848 }
849 }
850
851 #[test]
852 fn full_frame_cache_hit_touches_layout_keys() {
853 let touched = std::rc::Rc::new(std::cell::RefCell::new(Vec::new()));
854 let backend = RecordingTextBackend::new(touched.clone());
855 let mut tree = WidgetTree::new()
856 .with_theme(crate::presets::intui::light())
857 .with_text_backend(std::rc::Rc::new(std::cell::RefCell::new(backend)));
858 tree.add(TextPaintWidget);
859 tree.layout(SizeProposal::exact(200.0, 50.0));
860
861 let _ = tree.render();
863 touched.borrow_mut().clear();
864
865 let _ = tree.render();
869 assert!(
870 touched.borrow().contains(&42),
871 "full-frame cache hit must touch_layout every baked layout key; \
872 touched = {:?}",
873 touched.borrow()
874 );
875 }
876
877 fn scaled_subtree_tree(
888 transform: impl Into<crate::signal::Prop<teksilo_canvas::Transform2D>>,
889 ) -> (
890 WidgetTree,
891 std::rc::Rc<std::cell::RefCell<RecordingTextBackend>>,
892 std::rc::Rc<std::cell::RefCell<Vec<f32>>>,
893 ) {
894 let backend = RecordingTextBackend::new(Default::default());
895 let layout_scales = backend.layout_scales.clone();
896 let backend_rc = std::rc::Rc::new(std::cell::RefCell::new(backend));
897 let mut tree = WidgetTree::new()
898 .with_theme(crate::presets::intui::light())
899 .with_text_backend(backend_rc.clone()
900 as std::rc::Rc<std::cell::RefCell<dyn teksilo_canvas::TextBackend>>);
901
902 let root = tree.add(StackWidget::new());
903 tree.add_child(root, TextPaintWidget); let wrapper = tree.add_child(root, StackWidget::new());
905 tree.add_child(wrapper, TextPaintWidget); tree.add_child(root, TextPaintWidget); tree.set_transform(wrapper, transform);
908
909 tree.layout(SizeProposal::exact(400.0, 300.0));
910 (tree, backend_rc, layout_scales)
911 }
912
913 #[test]
914 fn walker_sets_raster_scale_for_scaled_subtree_and_restores() {
915 let (mut tree, backend_rc, layout_scales) =
916 scaled_subtree_tree(teksilo_canvas::Transform2D::scale(2.0, 2.0));
917 let _ = tree.render();
918
919 let expected_b = 1.25_f32.powi(3); assert_eq!(
924 layout_scales.borrow().as_slice(),
925 &[1.0, expected_b, 1.0],
926 "text inside the transform scope must lay out at the quantized \
927 scale; siblings before/after at screen scale"
928 );
929 assert_eq!(backend_rc.borrow().raster_scale, 1.0);
931 }
932
933 #[test]
934 fn raster_scale_change_repaints_clean_descendants() {
935 let transform = crate::signal::Signal::new(teksilo_canvas::Transform2D::scale(2.0, 2.0));
936 let (mut tree, _backend_rc, layout_scales) = scaled_subtree_tree(transform.clone());
937 let _ = tree.render();
938 layout_scales.borrow_mut().clear();
939
940 transform.set(teksilo_canvas::Transform2D::scale(3.0, 3.0));
944 let _ = tree.render();
945 assert_eq!(
946 layout_scales.borrow().as_slice(),
947 &[1.25_f32.powi(5)],
948 "the clean text leaf inside the scope must re-rasterize at the \
949 new quantized scale (A and C stay cached: no layout calls)"
950 );
951
952 layout_scales.borrow_mut().clear();
954 transform.set(teksilo_canvas::Transform2D::IDENTITY);
955 let _ = tree.render();
956 assert_eq!(layout_scales.borrow().as_slice(), &[1.0]);
957
958 layout_scales.borrow_mut().clear();
961 let _ = tree.render();
962 assert!(layout_scales.borrow().is_empty());
963 }
964
965 #[test]
966 fn nested_scale_transforms_multiply() {
967 let backend = RecordingTextBackend::new(Default::default());
968 let layout_scales = backend.layout_scales.clone();
969 let mut tree = WidgetTree::new()
970 .with_theme(crate::presets::intui::light())
971 .with_text_backend(std::rc::Rc::new(std::cell::RefCell::new(backend)));
972
973 let outer = tree.add(StackWidget::new());
976 let inner = tree.add_child(outer, StackWidget::new());
977 tree.add_child(inner, TextPaintWidget);
978 tree.set_transform(outer, teksilo_canvas::Transform2D::scale(2.0, 2.0));
979 tree.set_content_transform(inner, teksilo_canvas::Transform2D::scale(1.5, 1.5));
980
981 tree.layout(SizeProposal::exact(400.0, 300.0));
982 let _ = tree.render();
983 assert_eq!(layout_scales.borrow().as_slice(), &[1.25_f32.powi(5)]);
984 }
985
986 #[test]
987 fn invalidate_all_paints_clears_post_paint_cache() {
988 #[derive(Debug)]
991 struct PostPaintWidget;
992
993 impl Widget for PostPaintWidget {
994 fn layout_response(
995 &self,
996 proposal: SizeProposal,
997 _ctx: &LayoutContext,
998 ) -> crate::widget::LayoutResponse {
999 proposal.resolve(50.0, 50.0).into()
1000 }
1001
1002 fn wants_post_paint(&self) -> bool {
1003 true
1004 }
1005
1006 fn post_paint(
1007 &self,
1008 bounds: teksilo_canvas::Rect,
1009 canvas: &mut teksilo_canvas::Canvas,
1010 _ctx: &PaintContext,
1011 ) {
1012 canvas.fill_rect(bounds, Color::RED);
1013 }
1014 }
1015
1016 let mut tree = WidgetTree::new().with_theme(crate::presets::intui::light());
1017 let id = tree.add(PostPaintWidget);
1018 tree.layout(SizeProposal::exact(100.0, 100.0));
1019 let _ = tree.render();
1020 assert!(
1021 tree.arena
1022 .get(id)
1023 .and_then(|n| n.cached_post_paint.as_ref())
1024 .is_some(),
1025 "render must populate cached_post_paint for a post-painting widget"
1026 );
1027
1028 tree.invalidate_all_paints();
1029 assert!(
1030 tree.arena
1031 .get(id)
1032 .and_then(|n| n.cached_post_paint.as_ref())
1033 .is_none(),
1034 "invalidate_all_paints must clear cached_post_paint too — a retained \
1035 post-paint frame can hold stale glyph UVs after atlas eviction"
1036 );
1037 }
1038
1039 #[derive(Debug)]
1040 struct ThemeAwareWidget;
1041
1042 impl Widget for ThemeAwareWidget {
1043 fn layout_response(
1044 &self,
1045 proposal: SizeProposal,
1046 _ctx: &LayoutContext,
1047 ) -> crate::widget::LayoutResponse {
1048 proposal.resolve(0.0, 0.0).into()
1049 }
1050
1051 fn paint(
1052 &self,
1053 bounds: teksilo_canvas::Rect,
1054 canvas: &mut teksilo_canvas::Canvas,
1055 ctx: &PaintContext,
1056 ) {
1057 canvas.fill_rounded_rect(
1058 bounds,
1059 teksilo_tokens::CornerRadius::uniform(4.0),
1060 ctx.theme.colors.accent,
1061 );
1062 }
1063 }
1064
1065 #[test]
1066 fn fill_widget_produces_shape_in_frame() {
1067 let mut tree = WidgetTree::new().with_theme(crate::presets::intui::light());
1068 tree.add(
1069 FillWidget::new()
1070 .background(Color::RED)
1071 .corner_radius(CornerRadius::uniform(6.0)),
1072 );
1073 tree.layout(SizeProposal::exact(100.0, 40.0));
1074 let frame = tree.render();
1075 assert_eq!(frame.shapes.len(), 1);
1076 assert_eq!(
1077 frame.shapes[0].shape,
1078 teksilo_canvas::ShapeKind::RoundedRect
1079 );
1080 }
1081
1082 #[test]
1083 fn empty_tree_renders_empty_frame() {
1084 let mut tree = WidgetTree::new();
1085 let frame = tree.render();
1086 assert!(frame.is_empty());
1087 }
1088
1089 #[test]
1090 fn render_clears_paint_dirty() {
1091 let mut tree = WidgetTree::new();
1092 tree.add(FillWidget::new().background(Color::RED));
1093 tree.layout(SizeProposal::exact(100.0, 50.0));
1094 assert!(tree.needs_paint());
1095 tree.render();
1096 assert!(!tree.needs_paint());
1097 }
1098
1099 #[test]
1100 fn dormant_widget_not_rendered() {
1101 let mut tree = WidgetTree::new();
1102 let widget = tree.add(
1103 FillWidget::new()
1104 .background(Color::RED)
1105 .corner_radius(CornerRadius::uniform(4.0)),
1106 );
1107 tree.layout(SizeProposal::exact(100.0, 50.0));
1108 let frame = tree.render();
1109 assert!(!frame.shapes.is_empty());
1110
1111 tree.set_dormant(widget);
1112 tree.layout(SizeProposal::exact(100.0, 50.0));
1113 let frame = tree.render();
1114 assert!(frame.shapes.is_empty());
1115 }
1116
1117 #[test]
1118 fn dormancy_is_recursive() {
1119 let mut tree = WidgetTree::new();
1120 let child = tree.add(
1121 FillWidget::new()
1122 .background(Color::RED)
1123 .corner_radius(CornerRadius::uniform(4.0)),
1124 );
1125 let parent = tree.add(StackWidget::new().child(child));
1126 tree.layout(SizeProposal::exact(100.0, 50.0));
1127
1128 let frame = tree.render();
1129 assert_eq!(frame.shapes.len(), 1);
1130
1131 tree.set_dormant(parent);
1132 tree.layout(SizeProposal::exact(100.0, 50.0));
1133 let frame = tree.render();
1134 assert!(frame.shapes.is_empty());
1135
1136 tree.activate(parent);
1137 tree.layout(SizeProposal::exact(100.0, 50.0));
1138 let frame = tree.render();
1139 assert_eq!(frame.shapes.len(), 1);
1140 }
1141
1142 #[test]
1143 fn set_theme_marks_all_dirty() {
1144 let mut tree = WidgetTree::new().with_theme(crate::presets::intui::light());
1145 tree.add(FillWidget::new().background(Color::RED));
1146 tree.layout(SizeProposal::exact(100.0, 50.0));
1147 tree.render();
1148
1149 assert!(!tree.needs_layout());
1150 assert!(!tree.needs_paint());
1151
1152 tree.set_theme(crate::presets::intui::dark());
1153 assert!(tree.needs_layout());
1154 assert!(tree.needs_paint());
1155 }
1156
1157 #[test]
1158 fn set_theme_changes_rendered_colors() {
1159 let mut tree = WidgetTree::new().with_theme(crate::presets::intui::light());
1160 tree.add(ThemeAwareWidget);
1161 tree.layout(SizeProposal::exact(100.0, 50.0));
1162 let light_frame = tree.render();
1163 let light_color = light_frame.shapes[0].color;
1164
1165 tree.set_theme(crate::presets::intui::dark());
1166 tree.layout(SizeProposal::exact(100.0, 50.0));
1167 let dark_frame = tree.render();
1168 let dark_color = dark_frame.shapes[0].color;
1169
1170 assert_ne!(light_color, dark_color);
1171 }
1172
1173 #[test]
1174 fn subtree_theme_override() {
1175 let mut tree = WidgetTree::new().with_theme(crate::presets::intui::light());
1176 let parent = tree.add(ThemeAwareWidget);
1177 let _child = tree.add_child(parent, ThemeAwareWidget);
1178
1179 tree.set_theme_override(parent, |theme| {
1180 theme.colors = teksilo_tokens::ColorTokens::dark_default();
1181 });
1182
1183 tree.layout(SizeProposal::exact(100.0, 50.0));
1184 let frame = tree.render();
1185
1186 let dark_accent = teksilo_tokens::ColorTokens::dark_default()
1187 .accent
1188 .to_array();
1189 assert_eq!(frame.shapes[0].color, dark_accent);
1190 assert_eq!(frame.shapes[1].color, dark_accent);
1191 }
1192
1193 #[test]
1194 fn theme_override_only_affects_subtree() {
1195 let mut tree = WidgetTree::new().with_theme(crate::presets::intui::light());
1196
1197 let _unaffected = tree.add(ThemeAwareWidget);
1198 let overridden = tree.add(ThemeAwareWidget);
1199
1200 tree.set_theme_override(overridden, |theme| {
1201 theme.colors = teksilo_tokens::ColorTokens::dark_default();
1202 });
1203
1204 tree.layout(SizeProposal::exact(100.0, 50.0));
1205 let frame = tree.render();
1206
1207 let light_accent = teksilo_tokens::ColorTokens::light_default()
1208 .accent
1209 .to_array();
1210 let dark_accent = teksilo_tokens::ColorTokens::dark_default()
1211 .accent
1212 .to_array();
1213
1214 assert_eq!(frame.shapes[0].color, light_accent);
1215 assert_eq!(frame.shapes[1].color, dark_accent);
1216 }
1217
1218 #[test]
1219 fn resolved_theme_reflects_overrides() {
1220 let mut tree = WidgetTree::new().with_theme(crate::presets::intui::light());
1221
1222 let parent = tree.add(FillWidget::new());
1223 let child = tree.add_child(parent, FillWidget::new());
1224
1225 tree.set_theme_override(parent, |theme| {
1226 theme.colors.accent = Color::RED;
1227 });
1228
1229 tree.layout(SizeProposal::exact(100.0, 50.0));
1230
1231 let parent_theme = tree.resolved_theme(parent);
1232 assert_eq!(parent_theme.colors.accent, Color::RED);
1233
1234 let child_theme = tree.resolved_theme(child);
1235 assert_eq!(child_theme.colors.accent, Color::RED);
1236 }
1237
1238 #[test]
1239 fn opacity_prop_emits_set_and_restore_around_subtree() {
1240 let mut tree = WidgetTree::new().with_theme(crate::presets::intui::light());
1245 let parent = tree.add(StackWidget::new());
1246 let _child = tree.add_child(parent, FillWidget::new().background(Color::RED));
1247 tree.set_opacity(parent, 0.5_f32);
1248 tree.layout(SizeProposal::exact(100.0, 50.0));
1249 let frame = tree.render();
1250
1251 let mut set_count = 0;
1252 let mut restore_count = 0;
1253 for cmd in &frame.draw_order {
1254 match cmd {
1255 teksilo_canvas::DrawCommand::SetOpacity(v) => {
1256 assert!((v - 0.5).abs() < 1e-6, "expected 0.5, got {}", v);
1257 set_count += 1;
1258 }
1259 teksilo_canvas::DrawCommand::RestoreOpacity => restore_count += 1,
1260 _ => {}
1261 }
1262 }
1263 assert_eq!(set_count, 1, "draw_order = {:?}", frame.draw_order);
1264 assert_eq!(restore_count, 1);
1265 }
1266
1267 #[test]
1268 fn opacity_prop_zero_skips_subtree_entirely() {
1269 let mut tree = WidgetTree::new().with_theme(crate::presets::intui::light());
1272 let parent = tree.add(StackWidget::new());
1273 tree.add_child(parent, FillWidget::new().background(Color::RED));
1274 tree.set_opacity(parent, 0.0_f32);
1275 tree.layout(SizeProposal::exact(100.0, 50.0));
1276 let frame = tree.render();
1277
1278 for cmd in &frame.draw_order {
1279 assert!(
1280 !matches!(cmd, teksilo_canvas::DrawCommand::SetOpacity(_)),
1281 "fully-transparent subtree should not emit SetOpacity"
1282 );
1283 }
1284 assert!(
1286 !frame
1287 .shapes
1288 .iter()
1289 .any(|s| s.color == Color::RED.to_array()),
1290 "fully-transparent subtree should not paint its descendants"
1291 );
1292 }
1293
1294 #[test]
1295 fn transform_prop_emits_push_and_pop_around_subtree() {
1296 let mut tree = WidgetTree::new().with_theme(crate::presets::intui::light());
1301 let parent = tree.add(StackWidget::new());
1302 let _child = tree.add_child(parent, FillWidget::new().background(Color::RED));
1303 let scale_2x = teksilo_canvas::Transform2D::scale(2.0, 2.0);
1304 tree.set_transform(parent, scale_2x);
1305 tree.layout(SizeProposal::exact(100.0, 50.0));
1306 let frame = tree.render();
1307
1308 let mut push_count = 0;
1309 let mut pop_count = 0;
1310 let mut push_value = None;
1311 for cmd in &frame.draw_order {
1312 match cmd {
1313 teksilo_canvas::DrawCommand::PushTransform(t) => {
1314 push_count += 1;
1315 push_value = Some(*t);
1316 }
1317 teksilo_canvas::DrawCommand::PopTransform => pop_count += 1,
1318 _ => {}
1319 }
1320 }
1321 assert_eq!(push_count, 1, "draw_order = {:?}", frame.draw_order);
1322 assert_eq!(pop_count, 1);
1323 assert_eq!(push_value, Some(scale_2x));
1324 }
1325
1326 #[test]
1327 fn content_transform_clip_wraps_outside_the_transform() {
1328 let mut tree = WidgetTree::new().with_theme(crate::presets::intui::light());
1336 let parent = tree.add(StackWidget::new());
1337 tree.add_child(parent, FillWidget::new().background(Color::RED));
1338 tree.layout(SizeProposal::exact(100.0, 50.0));
1339 tree.set_clips_children(parent, true);
1341 tree.set_content_transform(parent, teksilo_canvas::Transform2D::translate(50.0, 30.0));
1342 let frame = tree.render();
1343
1344 let mut set_clip = None;
1345 let mut push = None;
1346 let mut pop = None;
1347 let mut clear = None;
1348 for (i, cmd) in frame.draw_order.iter().enumerate() {
1349 match cmd {
1350 teksilo_canvas::DrawCommand::SetClip(_) => set_clip = set_clip.or(Some(i)),
1351 teksilo_canvas::DrawCommand::PushTransform(_) => push = push.or(Some(i)),
1352 teksilo_canvas::DrawCommand::PopTransform => pop = Some(i),
1353 teksilo_canvas::DrawCommand::ClearClip => clear = Some(i),
1354 _ => {}
1355 }
1356 }
1357 let (sc, pt, pop, cc) = (
1358 set_clip.expect("SetClip emitted"),
1359 push.expect("PushTransform emitted"),
1360 pop.expect("PopTransform emitted"),
1361 clear.expect("ClearClip emitted"),
1362 );
1363 assert!(
1364 sc < pt,
1365 "clip must open before the transform: SetClip@{sc}, PushTransform@{pt}; order={:?}",
1366 frame.draw_order
1367 );
1368 assert!(
1369 pop < cc,
1370 "clip must close after the transform: PopTransform@{pop}, ClearClip@{cc}"
1371 );
1372 }
1373
1374 #[test]
1375 fn identity_transform_prop_skipped() {
1376 let mut tree = WidgetTree::new().with_theme(crate::presets::intui::light());
1381 let parent = tree.add(StackWidget::new());
1382 tree.add_child(parent, FillWidget::new().background(Color::RED));
1383 tree.set_transform(parent, teksilo_canvas::Transform2D::IDENTITY);
1384 tree.layout(SizeProposal::exact(100.0, 50.0));
1385 let frame = tree.render();
1386
1387 for cmd in &frame.draw_order {
1388 assert!(
1389 !matches!(
1390 cmd,
1391 teksilo_canvas::DrawCommand::PushTransform(_)
1392 | teksilo_canvas::DrawCommand::PopTransform
1393 ),
1394 "identity transform must not emit a push/pop scope"
1395 );
1396 }
1397 }
1398
1399 #[test]
1400 fn transform_scope_paint_order_opacity_outer_transform_inner() {
1401 let mut tree = WidgetTree::new().with_theme(crate::presets::intui::light());
1406 let parent = tree.add(StackWidget::new());
1407 tree.add_child(parent, FillWidget::new().background(Color::RED));
1408 tree.set_opacity(parent, 0.7_f32);
1409 tree.set_transform(parent, teksilo_canvas::Transform2D::scale(2.0, 2.0));
1410 tree.layout(SizeProposal::exact(100.0, 50.0));
1411 let frame = tree.render();
1412
1413 let mut set_opacity_idx = None;
1415 let mut push_transform_idx = None;
1416 let mut pop_transform_idx = None;
1417 let mut restore_opacity_idx = None;
1418 for (i, cmd) in frame.draw_order.iter().enumerate() {
1419 match cmd {
1420 teksilo_canvas::DrawCommand::SetOpacity(_) => set_opacity_idx = Some(i),
1421 teksilo_canvas::DrawCommand::PushTransform(_) => push_transform_idx = Some(i),
1422 teksilo_canvas::DrawCommand::PopTransform => pop_transform_idx = Some(i),
1423 teksilo_canvas::DrawCommand::RestoreOpacity => restore_opacity_idx = Some(i),
1424 _ => {}
1425 }
1426 }
1427 let so = set_opacity_idx.expect("SetOpacity emitted");
1428 let pt = push_transform_idx.expect("PushTransform emitted");
1429 let popt = pop_transform_idx.expect("PopTransform emitted");
1430 let ro = restore_opacity_idx.expect("RestoreOpacity emitted");
1431 assert!(so < pt, "opacity must open before transform: {so} < {pt}");
1432 assert!(pt < popt, "transform push must precede its pop");
1433 assert!(
1434 popt < ro,
1435 "transform must close before opacity: {popt} < {ro}"
1436 );
1437 }
1438
1439 #[derive(Debug)]
1443 struct Sandwich {
1444 child: Option<crate::WidgetId>,
1445 }
1446
1447 impl Widget for Sandwich {
1448 fn build(&mut self, ctx: &mut crate::build_context::BuildContext) -> Vec<crate::WidgetId> {
1449 let c = ctx.add(FillWidget::new().background(Color::GREEN));
1450 self.child = Some(c);
1451 vec![c]
1452 }
1453
1454 fn layout_response(
1455 &self,
1456 proposal: SizeProposal,
1457 _ctx: &LayoutContext,
1458 ) -> crate::widget::LayoutResponse {
1459 proposal.resolve(0.0, 0.0).into()
1460 }
1461
1462 fn place_children(
1463 &self,
1464 bounds: teksilo_canvas::Rect,
1465 _proposal: SizeProposal,
1466 children: &mut [crate::widget::WidgetPlacement],
1467 _ctx: &LayoutContext,
1468 ) {
1469 for child in children.iter_mut() {
1470 child.origin = bounds.origin();
1471 child.size = bounds.size();
1472 }
1473 }
1474
1475 fn children(&self) -> Vec<crate::WidgetId> {
1476 self.child.iter().copied().collect()
1477 }
1478
1479 fn paint(
1480 &self,
1481 bounds: teksilo_canvas::Rect,
1482 canvas: &mut teksilo_canvas::Canvas,
1483 _ctx: &PaintContext,
1484 ) {
1485 canvas.fill_rounded_rect(bounds, CornerRadius::uniform(0.0), Color::RED);
1486 }
1487
1488 fn wants_post_paint(&self) -> bool {
1489 true
1490 }
1491
1492 fn post_paint(
1493 &self,
1494 bounds: teksilo_canvas::Rect,
1495 canvas: &mut teksilo_canvas::Canvas,
1496 _ctx: &PaintContext,
1497 ) {
1498 canvas.fill_rounded_rect(bounds, CornerRadius::uniform(0.0), Color::BLUE);
1499 }
1500 }
1501
1502 #[test]
1503 fn post_paint_emits_after_children() {
1504 let mut tree = WidgetTree::new().with_theme(crate::presets::intui::light());
1509 tree.add(Sandwich { child: None });
1510 tree.layout(SizeProposal::exact(100.0, 50.0));
1511 let frame = tree.render();
1512
1513 let color_of = |cmd: &teksilo_canvas::DrawCommand| -> Option<[f32; 4]> {
1514 match cmd {
1515 teksilo_canvas::DrawCommand::Shape(i) => frame.shapes.get(*i).map(|s| s.color),
1516 teksilo_canvas::DrawCommand::Decoration(i) => {
1517 frame.decorations.get(*i).map(|d| d.color)
1518 }
1519 _ => None,
1520 }
1521 };
1522 let find = |dominant: usize| -> Option<usize> {
1524 frame.draw_order.iter().position(|cmd| {
1525 color_of(cmd).is_some_and(|c| {
1526 c[dominant] > 0.5 && (0..3).all(|ch| ch == dominant || c[ch] < 0.5)
1527 })
1528 })
1529 };
1530 let red = find(0).expect("backdrop (RED) painted");
1531 let green = find(1).expect("child (GREEN) painted");
1532 let blue = find(2).expect("foreground (BLUE) painted");
1533 assert!(
1534 red < green && green < blue,
1535 "expected backdrop < child < foreground; RED@{red}, GREEN@{green}, BLUE@{blue}; order={:?}",
1536 frame.draw_order
1537 );
1538 }
1539
1540 #[test]
1541 fn blur_prop_emits_begin_end_pair_around_subtree() {
1542 let mut tree = WidgetTree::new().with_theme(crate::presets::intui::light());
1548 let parent = tree.add(StackWidget::new());
1549 let _child = tree.add_child(parent, FillWidget::new().background(Color::RED));
1550 tree.set_blur(parent, 8.0_f32);
1551 tree.layout(SizeProposal::exact(100.0, 50.0));
1552 let frame = tree.render();
1553
1554 let mut begin_count = 0;
1555 let mut end_count = 0;
1556 let mut begin_radius = None;
1557 for cmd in &frame.draw_order {
1558 match cmd {
1559 teksilo_canvas::DrawCommand::BeginBlurredSubtree { radius, .. } => {
1560 begin_count += 1;
1561 begin_radius = Some(*radius);
1562 }
1563 teksilo_canvas::DrawCommand::EndBlurredSubtree => end_count += 1,
1564 _ => {}
1565 }
1566 }
1567 assert_eq!(begin_count, 1, "draw_order = {:?}", frame.draw_order);
1568 assert_eq!(end_count, 1);
1569 assert_eq!(begin_radius, Some(8.0));
1570 }
1571
1572 #[test]
1573 fn blur_prop_subperceptual_radius_skipped() {
1574 let mut tree = WidgetTree::new().with_theme(crate::presets::intui::light());
1578 let parent = tree.add(StackWidget::new());
1579 tree.add_child(parent, FillWidget::new().background(Color::RED));
1580 tree.set_blur(parent, 0.2_f32);
1581 tree.layout(SizeProposal::exact(100.0, 50.0));
1582 let frame = tree.render();
1583
1584 for cmd in &frame.draw_order {
1585 assert!(
1586 !matches!(
1587 cmd,
1588 teksilo_canvas::DrawCommand::BeginBlurredSubtree { .. }
1589 | teksilo_canvas::DrawCommand::EndBlurredSubtree
1590 ),
1591 "sub-perceptual blur must not emit Begin/End"
1592 );
1593 }
1594 }
1595
1596 #[test]
1597 fn blur_scope_is_outermost_when_combined_with_opacity_and_transform() {
1598 let mut tree = WidgetTree::new().with_theme(crate::presets::intui::light());
1605 let parent = tree.add(StackWidget::new());
1606 tree.add_child(parent, FillWidget::new().background(Color::RED));
1607 tree.set_blur(parent, 8.0_f32);
1608 tree.set_opacity(parent, 0.7_f32);
1609 tree.set_transform(parent, teksilo_canvas::Transform2D::scale(2.0, 2.0));
1610 tree.layout(SizeProposal::exact(100.0, 50.0));
1611 let frame = tree.render();
1612
1613 let mut begin_idx = None;
1614 let mut set_opacity_idx = None;
1615 let mut push_transform_idx = None;
1616 let mut pop_transform_idx = None;
1617 let mut restore_opacity_idx = None;
1618 let mut end_idx = None;
1619 for (i, cmd) in frame.draw_order.iter().enumerate() {
1620 match cmd {
1621 teksilo_canvas::DrawCommand::BeginBlurredSubtree { .. } => begin_idx = Some(i),
1622 teksilo_canvas::DrawCommand::SetOpacity(_) => set_opacity_idx = Some(i),
1623 teksilo_canvas::DrawCommand::PushTransform(_) => push_transform_idx = Some(i),
1624 teksilo_canvas::DrawCommand::PopTransform => pop_transform_idx = Some(i),
1625 teksilo_canvas::DrawCommand::RestoreOpacity => restore_opacity_idx = Some(i),
1626 teksilo_canvas::DrawCommand::EndBlurredSubtree => end_idx = Some(i),
1627 _ => {}
1628 }
1629 }
1630 let bg = begin_idx.expect("Begin emitted");
1631 let so = set_opacity_idx.expect("SetOpacity emitted");
1632 let pt = push_transform_idx.expect("PushTransform emitted");
1633 let popt = pop_transform_idx.expect("PopTransform emitted");
1634 let ro = restore_opacity_idx.expect("RestoreOpacity emitted");
1635 let en = end_idx.expect("End emitted");
1636 assert!(bg < so, "blur opens before opacity");
1637 assert!(so < pt, "opacity opens before transform");
1638 assert!(pt < popt);
1639 assert!(popt < ro, "transform closes before opacity");
1640 assert!(ro < en, "opacity closes before blur");
1641 }
1642
1643 #[test]
1644 fn nested_theme_overrides_compose() {
1645 let mut tree = WidgetTree::new().with_theme(crate::presets::intui::light());
1646
1647 let grandparent = tree.add(FillWidget::new());
1648 let parent = tree.add_child(grandparent, FillWidget::new());
1649 let child = tree.add_child(parent, FillWidget::new());
1650
1651 tree.set_theme_override(grandparent, |theme| {
1652 theme.colors.accent = Color::RED;
1653 });
1654 tree.set_theme_override(parent, |theme| {
1655 theme.colors.text_secondary = Color::GREEN;
1656 });
1657
1658 tree.layout(SizeProposal::exact(100.0, 50.0));
1659
1660 let child_theme = tree.resolved_theme(child);
1661 assert_eq!(child_theme.colors.accent, Color::RED);
1662 assert_eq!(child_theme.colors.text_secondary, Color::GREEN);
1663 }
1664}