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(&self) {
250 if self
251 .frame_tick_scheduler
252 .should_arm_frame_tick(&self.arena, self.paint_epoch)
253 {
254 self.frame_tick_requested.set(true);
255 }
256 }
257}
258
259#[allow(clippy::too_many_arguments)]
279fn paint_widget_cached(
280 arena: &mut WidgetArena,
281 id: WidgetId,
282 frame: &mut RenderFrame,
283 base_theme: &crate::styles::Theme,
284 text_backend: &Option<Rc<RefCell<dyn teksilo_canvas::TextBackend>>>,
285 clip_bounds: Option<Rect>,
286 a11y_prefs: &A11yPaintPrefs,
287 paint_epoch: u64,
288 overlay_skip: &std::collections::HashSet<WidgetId>,
289 overlay_rects: &[Rect],
290 layout_direction: crate::environment::LayoutDirection,
291 parent_effective_enabled: bool,
292 accumulated_raster_scale: f32,
293) {
294 if !arena.is_active(id) {
295 return;
296 }
297
298 let this_effective_enabled = parent_effective_enabled
303 && arena
304 .get(id)
305 .and_then(|n| n.enabled_state.as_ref())
306 .is_none_or(|p| p.get());
307
308 let node = arena.get(id).expect("node id is active (guarded above)");
309 let bounds = node.bounds;
310 if let Some(clip) = clip_bounds {
311 let x0 = bounds.x.max(clip.x);
312 let y0 = bounds.y.max(clip.y);
313 let x1 = bounds.right().min(clip.right());
314 let y1 = bounds.bottom().min(clip.bottom());
315 if x1 <= x0 || y1 <= y0 {
316 return;
321 }
322 }
323
324 if let Some(node_mut) = arena.get_mut(id) {
329 node_mut.last_painted_epoch = paint_epoch;
330 }
331
332 let node = arena.get(id).expect("node id is active (guarded above)");
337 let opacity = node.opacity_prop.as_ref().map(|p| p.get().clamp(0.0, 1.0));
338 if let Some(o) = opacity
339 && o < 1.0 / 512.0
340 {
341 return;
347 }
348
349 let node = arena.get(id).expect("node id is active (guarded above)");
357 let blur_radius = node
358 .blur_prop
359 .as_ref()
360 .map(|p| p.get())
361 .filter(|r| *r >= 0.5);
362 let blur_bounds = arena.bounds(id);
363 if let Some(r) = blur_radius {
364 frame
365 .draw_order
366 .push(teksilo_canvas::DrawCommand::BeginBlurredSubtree {
367 bounds: blur_bounds,
368 radius: r,
369 });
370 }
371
372 if let Some(o) = opacity {
373 frame
374 .draw_order
375 .push(teksilo_canvas::DrawCommand::SetOpacity(o));
376 }
377
378 let node = arena.get(id).expect("node id is active (guarded above)");
386 let transform = node.transform_prop.as_ref().map(|p| p.get());
387 let push_transform = transform.filter(|t| *t != teksilo_canvas::Transform2D::IDENTITY);
388 let clips = node.clips_children;
389 let content_transform = node.content_transform;
390 let bounds = node.bounds;
391
392 let this_raster_scale = match push_transform {
400 Some(t) => quantize_raster_scale(accumulated_raster_scale * t.geometric_scale()),
401 None => accumulated_raster_scale,
402 };
403 let raster_scale_changed = this_raster_scale != accumulated_raster_scale;
407 if raster_scale_changed && let Some(tb) = text_backend {
408 tb.borrow_mut().set_raster_scale(this_raster_scale);
409 }
410 let clip_outside_transform = clips && content_transform;
419 if clip_outside_transform {
420 frame
421 .draw_order
422 .push(teksilo_canvas::DrawCommand::SetClip(bounds));
423 }
424 if let Some(t) = push_transform {
425 frame
426 .draw_order
427 .push(teksilo_canvas::DrawCommand::PushTransform(t));
428 }
429
430 let node = arena.get(id).expect("node id is active (guarded above)");
431 let needs_paint = node.dirty.needs_paint || node.paint_raster_scale != this_raster_scale;
436
437 if needs_paint || node.cached_paint.is_none() {
438 let resolved_theme = arena.resolve_theme(id, base_theme);
439 let ctx = PaintContext {
440 theme: &resolved_theme,
441 scale_factor: this_raster_scale,
442 text_scale: a11y_prefs.text_scale,
443 layout_direction,
444 effective_enabled: this_effective_enabled,
445 prefers_high_contrast: a11y_prefs.high_contrast,
446 prefers_reduced_motion: a11y_prefs.reduced_motion,
447 prefers_large_text: a11y_prefs.large_text,
448 window_active: a11y_prefs.window_active,
449 clip_bounds,
450 };
451
452 let bounds = arena.bounds(id);
453 let node = arena.get(id).expect("node id is active (guarded above)");
454
455 let mut canvas = match text_backend {
456 Some(tb) => Canvas::with_text_backend(tb.clone()),
457 None => Canvas::new(),
458 };
459 node.widget.paint(bounds, &mut canvas, &ctx);
460 let widget_frame = canvas.into_render_frame();
461
462 frame.merge(&widget_frame);
463 if let Some(node) = arena.get_mut(id) {
464 node.cached_paint = Some(widget_frame);
465 node.paint_raster_scale = this_raster_scale;
466 }
467 } else {
468 let node = arena.get(id).expect("node id is active (guarded above)");
469 if let Some(cached) = &node.cached_paint {
470 if !cached.layout_keys.is_empty()
478 && let Some(tb) = text_backend
479 {
480 let mut tb = tb.borrow_mut();
481 for key in &cached.layout_keys {
482 tb.touch_layout(*key);
483 }
484 #[cfg(debug_assertions)]
485 debug_validate_layout_keys(&*tb, &cached.layout_keys, Some(id));
486 }
487 frame.merge(cached);
488 }
489 }
490
491 let node = arena.get(id).expect("node id is active (guarded above)");
492 let children: Vec<WidgetId> = node.children.clone();
493 let next_clip = if clips {
494 Some(match clip_bounds {
495 Some(clip) => {
496 let x0 = bounds.x.max(clip.x);
497 let y0 = bounds.y.max(clip.y);
498 let x1 = bounds.right().min(clip.right());
499 let y1 = bounds.bottom().min(clip.bottom());
500 Rect::new(x0, y0, (x1 - x0).max(0.0), (y1 - y0).max(0.0))
501 }
502 None => bounds,
503 })
504 } else {
505 clip_bounds
506 };
507
508 let next_clip = match (
522 content_transform,
523 next_clip,
524 transform.and_then(|t| t.inverse()),
525 ) {
526 (true, Some(screen_clip), Some(inv)) => Some(inv.apply_rect(screen_clip)),
527 _ => next_clip,
528 };
529
530 if clips && !clip_outside_transform {
534 frame
535 .draw_order
536 .push(teksilo_canvas::DrawCommand::SetClip(bounds));
537 }
538
539 for child_id in children {
540 if overlay_skip.contains(&child_id) {
546 continue;
547 }
548 paint_widget_cached(
549 arena,
550 child_id,
551 frame,
552 base_theme,
553 text_backend,
554 next_clip,
555 a11y_prefs,
556 paint_epoch,
557 overlay_skip,
558 overlay_rects,
559 layout_direction,
560 this_effective_enabled,
561 this_raster_scale,
562 );
563 }
564
565 {
573 let arena_ref: &WidgetArena = &*arena;
574 if let Some(node) = arena_ref.get(id)
575 && node.widget.wants_after_paint()
576 {
577 let view = crate::widget::WidgetTreeView::new(arena_ref, overlay_rects);
578 let resolved_theme = arena_ref.resolve_theme(id, base_theme);
579 let ctx = PaintContext {
580 theme: &resolved_theme,
581 scale_factor: this_raster_scale,
582 text_scale: a11y_prefs.text_scale,
583 layout_direction,
584 effective_enabled: this_effective_enabled,
585 prefers_high_contrast: a11y_prefs.high_contrast,
586 prefers_reduced_motion: a11y_prefs.reduced_motion,
587 prefers_large_text: a11y_prefs.large_text,
588 window_active: a11y_prefs.window_active,
589 clip_bounds,
590 };
591 node.widget.after_paint(&view, &ctx);
592 }
593 }
594
595 let wants_post_paint = arena
604 .get(id)
605 .map(|n| n.widget.wants_post_paint())
606 .unwrap_or(false);
607 if wants_post_paint {
608 let has_post_cache = arena
609 .get(id)
610 .map(|n| n.cached_post_paint.is_some())
611 .unwrap_or(false);
612 if needs_paint || !has_post_cache {
613 if raster_scale_changed && let Some(tb) = text_backend {
618 tb.borrow_mut().set_raster_scale(this_raster_scale);
619 }
620 let resolved_theme = arena.resolve_theme(id, base_theme);
621 let ctx = PaintContext {
622 theme: &resolved_theme,
623 scale_factor: this_raster_scale,
624 text_scale: a11y_prefs.text_scale,
625 layout_direction,
626 effective_enabled: this_effective_enabled,
627 prefers_high_contrast: a11y_prefs.high_contrast,
628 prefers_reduced_motion: a11y_prefs.reduced_motion,
629 prefers_large_text: a11y_prefs.large_text,
630 window_active: a11y_prefs.window_active,
631 clip_bounds,
632 };
633 let bounds = arena.bounds(id);
634 let node = arena.get(id).expect("node id is active (guarded above)");
635 let mut canvas = match text_backend {
636 Some(tb) => Canvas::with_text_backend(tb.clone()),
637 None => Canvas::new(),
638 };
639 node.widget.post_paint(bounds, &mut canvas, &ctx);
640 let post_frame = canvas.into_render_frame();
641 frame.merge(&post_frame);
642 if let Some(node) = arena.get_mut(id) {
643 node.cached_post_paint = Some(post_frame);
644 }
645 } else if let Some(cached) = arena.get(id).and_then(|n| n.cached_post_paint.as_ref()) {
646 if !cached.layout_keys.is_empty()
647 && let Some(tb) = text_backend
648 {
649 let mut tb = tb.borrow_mut();
650 for key in &cached.layout_keys {
651 tb.touch_layout(*key);
652 }
653 #[cfg(debug_assertions)]
654 debug_validate_layout_keys(&*tb, &cached.layout_keys, Some(id));
655 }
656 frame.merge(cached);
657 }
658 }
659
660 if clips && !clip_outside_transform {
661 frame
662 .draw_order
663 .push(teksilo_canvas::DrawCommand::ClearClip);
664 }
665
666 if push_transform.is_some() {
667 frame
668 .draw_order
669 .push(teksilo_canvas::DrawCommand::PopTransform);
670 }
671
672 if clip_outside_transform {
675 frame
676 .draw_order
677 .push(teksilo_canvas::DrawCommand::ClearClip);
678 }
679
680 if opacity.is_some() {
681 frame
682 .draw_order
683 .push(teksilo_canvas::DrawCommand::RestoreOpacity);
684 }
685
686 if blur_radius.is_some() {
687 frame
688 .draw_order
689 .push(teksilo_canvas::DrawCommand::EndBlurredSubtree);
690 }
691
692 if raster_scale_changed && let Some(tb) = text_backend {
695 tb.borrow_mut().set_raster_scale(accumulated_raster_scale);
696 }
697}
698
699#[cfg(debug_assertions)]
712fn debug_validate_layout_keys(
713 tb: &dyn teksilo_canvas::TextBackend,
714 layout_keys: &[u64],
715 widget: Option<WidgetId>,
716) {
717 use teksilo_canvas::GlyphValidation;
718 std::thread_local! {
719 static REPORTED_STALE_KEYS: std::cell::RefCell<std::collections::HashSet<u64>> =
720 std::cell::RefCell::new(std::collections::HashSet::new());
721 }
722 for key in layout_keys {
723 match tb.debug_validate_layout(*key) {
724 GlyphValidation::Valid => {}
725 GlyphValidation::StaleKey => {
726 let first_report = REPORTED_STALE_KEYS.with(|set| set.borrow_mut().insert(*key));
727 if first_report {
728 eprintln!(
729 "[teksilo] WARNING: retained paint cache replays layout_key={key} \
730 (widget={widget:?}) that the text backend no longer knows — the \
731 frame survived a backend cache clear, so a paint-cache \
732 invalidation path is likely missing."
733 );
734 }
735 }
736 GlyphValidation::RectMismatch => {
737 panic!(
738 "stale glyph UVs in retained paint cache (layout_key={key}, \
739 widget={widget:?}): cached quads no longer match the live glyph \
740 atlas — a cache-invalidation path is missing"
741 );
742 }
743 }
744 }
745}
746
747#[cfg(test)]
748mod tests {
749 use super::*;
750 use crate::test_widgets::{FillWidget, StackWidget};
751 use teksilo_tokens::{Color, CornerRadius};
752
753 #[derive(Default)]
760 struct RecordingTextBackend {
761 touched: std::rc::Rc<std::cell::RefCell<Vec<u64>>>,
762 raster_scale: f32,
763 layout_scales: std::rc::Rc<std::cell::RefCell<Vec<f32>>>,
766 }
767
768 impl RecordingTextBackend {
769 fn new(touched: std::rc::Rc<std::cell::RefCell<Vec<u64>>>) -> Self {
770 Self {
771 touched,
772 raster_scale: 1.0,
773 layout_scales: Default::default(),
774 }
775 }
776 }
777
778 impl teksilo_canvas::TextBackend for RecordingTextBackend {
779 fn set_raster_scale(&mut self, raster_scale: f32) {
780 self.raster_scale = raster_scale;
781 }
782
783 fn raster_scale(&self) -> f32 {
784 self.raster_scale
785 }
786
787 fn layout_single_line(
788 &mut self,
789 text: &str,
790 _style: &teksilo_tokens::TextStyle,
791 _max_width: Option<f32>,
792 ) -> teksilo_canvas::TextLayout {
793 self.layout_scales.borrow_mut().push(self.raster_scale);
794 teksilo_canvas::TextLayout {
795 width: text.len() as f32 * 8.0,
796 height: 16.0,
797 ascent: 12.0,
798 descent: 4.0,
799 underline_offset: 1.0,
800 underline_thickness: 1.0,
801 layout_key: 42,
802 line_count: 1,
803 spans: Vec::new(),
804 raster_scale: self.raster_scale,
805 geometry: None,
806 }
807 }
808
809 fn ensure_glyphs(
810 &mut self,
811 _layout: &teksilo_canvas::TextLayout,
812 ) -> Vec<teksilo_canvas::GlyphQuad> {
813 Vec::new()
814 }
815
816 fn touch_layout(&mut self, layout_key: u64) {
817 self.touched.borrow_mut().push(layout_key);
818 }
819 }
820
821 #[derive(Debug)]
824 struct TextPaintWidget;
825
826 impl Widget for TextPaintWidget {
827 fn layout_response(
828 &self,
829 proposal: SizeProposal,
830 _ctx: &LayoutContext,
831 ) -> crate::widget::LayoutResponse {
832 proposal.resolve(100.0, 20.0).into()
833 }
834
835 fn paint(
836 &self,
837 bounds: teksilo_canvas::Rect,
838 canvas: &mut teksilo_canvas::Canvas,
839 _ctx: &PaintContext,
840 ) {
841 let style = teksilo_tokens::TextStyle::default();
842 let _ = canvas.draw_text("hello", bounds, &style, Color::BLACK);
843 }
844 }
845
846 #[test]
847 fn full_frame_cache_hit_touches_layout_keys() {
848 let touched = std::rc::Rc::new(std::cell::RefCell::new(Vec::new()));
849 let backend = RecordingTextBackend::new(touched.clone());
850 let mut tree = WidgetTree::new()
851 .with_theme(crate::presets::intui::light())
852 .with_text_backend(std::rc::Rc::new(std::cell::RefCell::new(backend)));
853 tree.add(TextPaintWidget);
854 tree.layout(SizeProposal::exact(200.0, 50.0));
855
856 let _ = tree.render();
858 touched.borrow_mut().clear();
859
860 let _ = tree.render();
864 assert!(
865 touched.borrow().contains(&42),
866 "full-frame cache hit must touch_layout every baked layout key; \
867 touched = {:?}",
868 touched.borrow()
869 );
870 }
871
872 fn scaled_subtree_tree(
883 transform: impl Into<crate::signal::Prop<teksilo_canvas::Transform2D>>,
884 ) -> (
885 WidgetTree,
886 std::rc::Rc<std::cell::RefCell<RecordingTextBackend>>,
887 std::rc::Rc<std::cell::RefCell<Vec<f32>>>,
888 ) {
889 let backend = RecordingTextBackend::new(Default::default());
890 let layout_scales = backend.layout_scales.clone();
891 let backend_rc = std::rc::Rc::new(std::cell::RefCell::new(backend));
892 let mut tree = WidgetTree::new()
893 .with_theme(crate::presets::intui::light())
894 .with_text_backend(backend_rc.clone()
895 as std::rc::Rc<std::cell::RefCell<dyn teksilo_canvas::TextBackend>>);
896
897 let root = tree.add(StackWidget::new());
898 tree.add_child(root, TextPaintWidget); let wrapper = tree.add_child(root, StackWidget::new());
900 tree.add_child(wrapper, TextPaintWidget); tree.add_child(root, TextPaintWidget); tree.set_transform(wrapper, transform);
903
904 tree.layout(SizeProposal::exact(400.0, 300.0));
905 (tree, backend_rc, layout_scales)
906 }
907
908 #[test]
909 fn walker_sets_raster_scale_for_scaled_subtree_and_restores() {
910 let (mut tree, backend_rc, layout_scales) =
911 scaled_subtree_tree(teksilo_canvas::Transform2D::scale(2.0, 2.0));
912 let _ = tree.render();
913
914 let expected_b = 1.25_f32.powi(3); assert_eq!(
919 layout_scales.borrow().as_slice(),
920 &[1.0, expected_b, 1.0],
921 "text inside the transform scope must lay out at the quantized \
922 scale; siblings before/after at screen scale"
923 );
924 assert_eq!(backend_rc.borrow().raster_scale, 1.0);
926 }
927
928 #[test]
929 fn raster_scale_change_repaints_clean_descendants() {
930 let transform = crate::signal::Signal::new(teksilo_canvas::Transform2D::scale(2.0, 2.0));
931 let (mut tree, _backend_rc, layout_scales) = scaled_subtree_tree(transform.clone());
932 let _ = tree.render();
933 layout_scales.borrow_mut().clear();
934
935 transform.set(teksilo_canvas::Transform2D::scale(3.0, 3.0));
939 let _ = tree.render();
940 assert_eq!(
941 layout_scales.borrow().as_slice(),
942 &[1.25_f32.powi(5)],
943 "the clean text leaf inside the scope must re-rasterize at the \
944 new quantized scale (A and C stay cached: no layout calls)"
945 );
946
947 layout_scales.borrow_mut().clear();
949 transform.set(teksilo_canvas::Transform2D::IDENTITY);
950 let _ = tree.render();
951 assert_eq!(layout_scales.borrow().as_slice(), &[1.0]);
952
953 layout_scales.borrow_mut().clear();
956 let _ = tree.render();
957 assert!(layout_scales.borrow().is_empty());
958 }
959
960 #[test]
961 fn nested_scale_transforms_multiply() {
962 let backend = RecordingTextBackend::new(Default::default());
963 let layout_scales = backend.layout_scales.clone();
964 let mut tree = WidgetTree::new()
965 .with_theme(crate::presets::intui::light())
966 .with_text_backend(std::rc::Rc::new(std::cell::RefCell::new(backend)));
967
968 let outer = tree.add(StackWidget::new());
971 let inner = tree.add_child(outer, StackWidget::new());
972 tree.add_child(inner, TextPaintWidget);
973 tree.set_transform(outer, teksilo_canvas::Transform2D::scale(2.0, 2.0));
974 tree.set_content_transform(inner, teksilo_canvas::Transform2D::scale(1.5, 1.5));
975
976 tree.layout(SizeProposal::exact(400.0, 300.0));
977 let _ = tree.render();
978 assert_eq!(layout_scales.borrow().as_slice(), &[1.25_f32.powi(5)]);
979 }
980
981 #[test]
982 fn invalidate_all_paints_clears_post_paint_cache() {
983 #[derive(Debug)]
986 struct PostPaintWidget;
987
988 impl Widget for PostPaintWidget {
989 fn layout_response(
990 &self,
991 proposal: SizeProposal,
992 _ctx: &LayoutContext,
993 ) -> crate::widget::LayoutResponse {
994 proposal.resolve(50.0, 50.0).into()
995 }
996
997 fn wants_post_paint(&self) -> bool {
998 true
999 }
1000
1001 fn post_paint(
1002 &self,
1003 bounds: teksilo_canvas::Rect,
1004 canvas: &mut teksilo_canvas::Canvas,
1005 _ctx: &PaintContext,
1006 ) {
1007 canvas.fill_rect(bounds, Color::RED);
1008 }
1009 }
1010
1011 let mut tree = WidgetTree::new().with_theme(crate::presets::intui::light());
1012 let id = tree.add(PostPaintWidget);
1013 tree.layout(SizeProposal::exact(100.0, 100.0));
1014 let _ = tree.render();
1015 assert!(
1016 tree.arena
1017 .get(id)
1018 .and_then(|n| n.cached_post_paint.as_ref())
1019 .is_some(),
1020 "render must populate cached_post_paint for a post-painting widget"
1021 );
1022
1023 tree.invalidate_all_paints();
1024 assert!(
1025 tree.arena
1026 .get(id)
1027 .and_then(|n| n.cached_post_paint.as_ref())
1028 .is_none(),
1029 "invalidate_all_paints must clear cached_post_paint too — a retained \
1030 post-paint frame can hold stale glyph UVs after atlas eviction"
1031 );
1032 }
1033
1034 #[derive(Debug)]
1035 struct ThemeAwareWidget;
1036
1037 impl Widget for ThemeAwareWidget {
1038 fn layout_response(
1039 &self,
1040 proposal: SizeProposal,
1041 _ctx: &LayoutContext,
1042 ) -> crate::widget::LayoutResponse {
1043 proposal.resolve(0.0, 0.0).into()
1044 }
1045
1046 fn paint(
1047 &self,
1048 bounds: teksilo_canvas::Rect,
1049 canvas: &mut teksilo_canvas::Canvas,
1050 ctx: &PaintContext,
1051 ) {
1052 canvas.fill_rounded_rect(
1053 bounds,
1054 teksilo_tokens::CornerRadius::uniform(4.0),
1055 ctx.theme.colors.accent,
1056 );
1057 }
1058 }
1059
1060 #[test]
1061 fn fill_widget_produces_shape_in_frame() {
1062 let mut tree = WidgetTree::new().with_theme(crate::presets::intui::light());
1063 tree.add(
1064 FillWidget::new()
1065 .background(Color::RED)
1066 .corner_radius(CornerRadius::uniform(6.0)),
1067 );
1068 tree.layout(SizeProposal::exact(100.0, 40.0));
1069 let frame = tree.render();
1070 assert_eq!(frame.shapes.len(), 1);
1071 assert_eq!(
1072 frame.shapes[0].shape,
1073 teksilo_canvas::ShapeKind::RoundedRect
1074 );
1075 }
1076
1077 #[test]
1078 fn empty_tree_renders_empty_frame() {
1079 let mut tree = WidgetTree::new();
1080 let frame = tree.render();
1081 assert!(frame.is_empty());
1082 }
1083
1084 #[test]
1085 fn render_clears_paint_dirty() {
1086 let mut tree = WidgetTree::new();
1087 tree.add(FillWidget::new().background(Color::RED));
1088 tree.layout(SizeProposal::exact(100.0, 50.0));
1089 assert!(tree.needs_paint());
1090 tree.render();
1091 assert!(!tree.needs_paint());
1092 }
1093
1094 #[test]
1095 fn dormant_widget_not_rendered() {
1096 let mut tree = WidgetTree::new();
1097 let widget = tree.add(
1098 FillWidget::new()
1099 .background(Color::RED)
1100 .corner_radius(CornerRadius::uniform(4.0)),
1101 );
1102 tree.layout(SizeProposal::exact(100.0, 50.0));
1103 let frame = tree.render();
1104 assert!(!frame.shapes.is_empty());
1105
1106 tree.set_dormant(widget);
1107 tree.layout(SizeProposal::exact(100.0, 50.0));
1108 let frame = tree.render();
1109 assert!(frame.shapes.is_empty());
1110 }
1111
1112 #[test]
1113 fn dormancy_is_recursive() {
1114 let mut tree = WidgetTree::new();
1115 let child = tree.add(
1116 FillWidget::new()
1117 .background(Color::RED)
1118 .corner_radius(CornerRadius::uniform(4.0)),
1119 );
1120 let parent = tree.add(StackWidget::new().add_child(child));
1121 tree.layout(SizeProposal::exact(100.0, 50.0));
1122
1123 let frame = tree.render();
1124 assert_eq!(frame.shapes.len(), 1);
1125
1126 tree.set_dormant(parent);
1127 tree.layout(SizeProposal::exact(100.0, 50.0));
1128 let frame = tree.render();
1129 assert!(frame.shapes.is_empty());
1130
1131 tree.activate(parent);
1132 tree.layout(SizeProposal::exact(100.0, 50.0));
1133 let frame = tree.render();
1134 assert_eq!(frame.shapes.len(), 1);
1135 }
1136
1137 #[test]
1138 fn set_theme_marks_all_dirty() {
1139 let mut tree = WidgetTree::new().with_theme(crate::presets::intui::light());
1140 tree.add(FillWidget::new().background(Color::RED));
1141 tree.layout(SizeProposal::exact(100.0, 50.0));
1142 tree.render();
1143
1144 assert!(!tree.needs_layout());
1145 assert!(!tree.needs_paint());
1146
1147 tree.set_theme(crate::presets::intui::dark());
1148 assert!(tree.needs_layout());
1149 assert!(tree.needs_paint());
1150 }
1151
1152 #[test]
1153 fn set_theme_changes_rendered_colors() {
1154 let mut tree = WidgetTree::new().with_theme(crate::presets::intui::light());
1155 tree.add(ThemeAwareWidget);
1156 tree.layout(SizeProposal::exact(100.0, 50.0));
1157 let light_frame = tree.render();
1158 let light_color = light_frame.shapes[0].color;
1159
1160 tree.set_theme(crate::presets::intui::dark());
1161 tree.layout(SizeProposal::exact(100.0, 50.0));
1162 let dark_frame = tree.render();
1163 let dark_color = dark_frame.shapes[0].color;
1164
1165 assert_ne!(light_color, dark_color);
1166 }
1167
1168 #[test]
1169 fn subtree_theme_override() {
1170 let mut tree = WidgetTree::new().with_theme(crate::presets::intui::light());
1171 let parent = tree.add(ThemeAwareWidget);
1172 let _child = tree.add_child(parent, ThemeAwareWidget);
1173
1174 tree.set_theme_override(parent, |theme| {
1175 theme.colors = teksilo_tokens::ColorTokens::dark_default();
1176 });
1177
1178 tree.layout(SizeProposal::exact(100.0, 50.0));
1179 let frame = tree.render();
1180
1181 let dark_accent = teksilo_tokens::ColorTokens::dark_default()
1182 .accent
1183 .to_array();
1184 assert_eq!(frame.shapes[0].color, dark_accent);
1185 assert_eq!(frame.shapes[1].color, dark_accent);
1186 }
1187
1188 #[test]
1189 fn theme_override_only_affects_subtree() {
1190 let mut tree = WidgetTree::new().with_theme(crate::presets::intui::light());
1191
1192 let _unaffected = tree.add(ThemeAwareWidget);
1193 let overridden = tree.add(ThemeAwareWidget);
1194
1195 tree.set_theme_override(overridden, |theme| {
1196 theme.colors = teksilo_tokens::ColorTokens::dark_default();
1197 });
1198
1199 tree.layout(SizeProposal::exact(100.0, 50.0));
1200 let frame = tree.render();
1201
1202 let light_accent = teksilo_tokens::ColorTokens::light_default()
1203 .accent
1204 .to_array();
1205 let dark_accent = teksilo_tokens::ColorTokens::dark_default()
1206 .accent
1207 .to_array();
1208
1209 assert_eq!(frame.shapes[0].color, light_accent);
1210 assert_eq!(frame.shapes[1].color, dark_accent);
1211 }
1212
1213 #[test]
1214 fn resolved_theme_reflects_overrides() {
1215 let mut tree = WidgetTree::new().with_theme(crate::presets::intui::light());
1216
1217 let parent = tree.add(FillWidget::new());
1218 let child = tree.add_child(parent, FillWidget::new());
1219
1220 tree.set_theme_override(parent, |theme| {
1221 theme.colors.accent = Color::RED;
1222 });
1223
1224 tree.layout(SizeProposal::exact(100.0, 50.0));
1225
1226 let parent_theme = tree.resolved_theme(parent);
1227 assert_eq!(parent_theme.colors.accent, Color::RED);
1228
1229 let child_theme = tree.resolved_theme(child);
1230 assert_eq!(child_theme.colors.accent, Color::RED);
1231 }
1232
1233 #[test]
1234 fn opacity_prop_emits_set_and_restore_around_subtree() {
1235 let mut tree = WidgetTree::new().with_theme(crate::presets::intui::light());
1240 let parent = tree.add(StackWidget::new());
1241 let _child = tree.add_child(parent, FillWidget::new().background(Color::RED));
1242 tree.set_opacity(parent, 0.5_f32);
1243 tree.layout(SizeProposal::exact(100.0, 50.0));
1244 let frame = tree.render();
1245
1246 let mut set_count = 0;
1247 let mut restore_count = 0;
1248 for cmd in &frame.draw_order {
1249 match cmd {
1250 teksilo_canvas::DrawCommand::SetOpacity(v) => {
1251 assert!((v - 0.5).abs() < 1e-6, "expected 0.5, got {}", v);
1252 set_count += 1;
1253 }
1254 teksilo_canvas::DrawCommand::RestoreOpacity => restore_count += 1,
1255 _ => {}
1256 }
1257 }
1258 assert_eq!(set_count, 1, "draw_order = {:?}", frame.draw_order);
1259 assert_eq!(restore_count, 1);
1260 }
1261
1262 #[test]
1263 fn opacity_prop_zero_skips_subtree_entirely() {
1264 let mut tree = WidgetTree::new().with_theme(crate::presets::intui::light());
1267 let parent = tree.add(StackWidget::new());
1268 tree.add_child(parent, FillWidget::new().background(Color::RED));
1269 tree.set_opacity(parent, 0.0_f32);
1270 tree.layout(SizeProposal::exact(100.0, 50.0));
1271 let frame = tree.render();
1272
1273 for cmd in &frame.draw_order {
1274 assert!(
1275 !matches!(cmd, teksilo_canvas::DrawCommand::SetOpacity(_)),
1276 "fully-transparent subtree should not emit SetOpacity"
1277 );
1278 }
1279 assert!(
1281 !frame
1282 .shapes
1283 .iter()
1284 .any(|s| s.color == Color::RED.to_array()),
1285 "fully-transparent subtree should not paint its descendants"
1286 );
1287 }
1288
1289 #[test]
1290 fn transform_prop_emits_push_and_pop_around_subtree() {
1291 let mut tree = WidgetTree::new().with_theme(crate::presets::intui::light());
1296 let parent = tree.add(StackWidget::new());
1297 let _child = tree.add_child(parent, FillWidget::new().background(Color::RED));
1298 let scale_2x = teksilo_canvas::Transform2D::scale(2.0, 2.0);
1299 tree.set_transform(parent, scale_2x);
1300 tree.layout(SizeProposal::exact(100.0, 50.0));
1301 let frame = tree.render();
1302
1303 let mut push_count = 0;
1304 let mut pop_count = 0;
1305 let mut push_value = None;
1306 for cmd in &frame.draw_order {
1307 match cmd {
1308 teksilo_canvas::DrawCommand::PushTransform(t) => {
1309 push_count += 1;
1310 push_value = Some(*t);
1311 }
1312 teksilo_canvas::DrawCommand::PopTransform => pop_count += 1,
1313 _ => {}
1314 }
1315 }
1316 assert_eq!(push_count, 1, "draw_order = {:?}", frame.draw_order);
1317 assert_eq!(pop_count, 1);
1318 assert_eq!(push_value, Some(scale_2x));
1319 }
1320
1321 #[test]
1322 fn content_transform_clip_wraps_outside_the_transform() {
1323 let mut tree = WidgetTree::new().with_theme(crate::presets::intui::light());
1331 let parent = tree.add(StackWidget::new());
1332 tree.add_child(parent, FillWidget::new().background(Color::RED));
1333 tree.layout(SizeProposal::exact(100.0, 50.0));
1334 tree.set_clips_children(parent, true);
1336 tree.set_content_transform(parent, teksilo_canvas::Transform2D::translate(50.0, 30.0));
1337 let frame = tree.render();
1338
1339 let mut set_clip = None;
1340 let mut push = None;
1341 let mut pop = None;
1342 let mut clear = None;
1343 for (i, cmd) in frame.draw_order.iter().enumerate() {
1344 match cmd {
1345 teksilo_canvas::DrawCommand::SetClip(_) => set_clip = set_clip.or(Some(i)),
1346 teksilo_canvas::DrawCommand::PushTransform(_) => push = push.or(Some(i)),
1347 teksilo_canvas::DrawCommand::PopTransform => pop = Some(i),
1348 teksilo_canvas::DrawCommand::ClearClip => clear = Some(i),
1349 _ => {}
1350 }
1351 }
1352 let (sc, pt, pop, cc) = (
1353 set_clip.expect("SetClip emitted"),
1354 push.expect("PushTransform emitted"),
1355 pop.expect("PopTransform emitted"),
1356 clear.expect("ClearClip emitted"),
1357 );
1358 assert!(
1359 sc < pt,
1360 "clip must open before the transform: SetClip@{sc}, PushTransform@{pt}; order={:?}",
1361 frame.draw_order
1362 );
1363 assert!(
1364 pop < cc,
1365 "clip must close after the transform: PopTransform@{pop}, ClearClip@{cc}"
1366 );
1367 }
1368
1369 #[test]
1370 fn identity_transform_prop_skipped() {
1371 let mut tree = WidgetTree::new().with_theme(crate::presets::intui::light());
1376 let parent = tree.add(StackWidget::new());
1377 tree.add_child(parent, FillWidget::new().background(Color::RED));
1378 tree.set_transform(parent, teksilo_canvas::Transform2D::IDENTITY);
1379 tree.layout(SizeProposal::exact(100.0, 50.0));
1380 let frame = tree.render();
1381
1382 for cmd in &frame.draw_order {
1383 assert!(
1384 !matches!(
1385 cmd,
1386 teksilo_canvas::DrawCommand::PushTransform(_)
1387 | teksilo_canvas::DrawCommand::PopTransform
1388 ),
1389 "identity transform must not emit a push/pop scope"
1390 );
1391 }
1392 }
1393
1394 #[test]
1395 fn transform_scope_paint_order_opacity_outer_transform_inner() {
1396 let mut tree = WidgetTree::new().with_theme(crate::presets::intui::light());
1401 let parent = tree.add(StackWidget::new());
1402 tree.add_child(parent, FillWidget::new().background(Color::RED));
1403 tree.set_opacity(parent, 0.7_f32);
1404 tree.set_transform(parent, teksilo_canvas::Transform2D::scale(2.0, 2.0));
1405 tree.layout(SizeProposal::exact(100.0, 50.0));
1406 let frame = tree.render();
1407
1408 let mut set_opacity_idx = None;
1410 let mut push_transform_idx = None;
1411 let mut pop_transform_idx = None;
1412 let mut restore_opacity_idx = None;
1413 for (i, cmd) in frame.draw_order.iter().enumerate() {
1414 match cmd {
1415 teksilo_canvas::DrawCommand::SetOpacity(_) => set_opacity_idx = Some(i),
1416 teksilo_canvas::DrawCommand::PushTransform(_) => push_transform_idx = Some(i),
1417 teksilo_canvas::DrawCommand::PopTransform => pop_transform_idx = Some(i),
1418 teksilo_canvas::DrawCommand::RestoreOpacity => restore_opacity_idx = Some(i),
1419 _ => {}
1420 }
1421 }
1422 let so = set_opacity_idx.expect("SetOpacity emitted");
1423 let pt = push_transform_idx.expect("PushTransform emitted");
1424 let popt = pop_transform_idx.expect("PopTransform emitted");
1425 let ro = restore_opacity_idx.expect("RestoreOpacity emitted");
1426 assert!(so < pt, "opacity must open before transform: {so} < {pt}");
1427 assert!(pt < popt, "transform push must precede its pop");
1428 assert!(
1429 popt < ro,
1430 "transform must close before opacity: {popt} < {ro}"
1431 );
1432 }
1433
1434 #[derive(Debug)]
1438 struct Sandwich {
1439 child: Option<crate::WidgetId>,
1440 }
1441
1442 impl Widget for Sandwich {
1443 fn build(&mut self, ctx: &mut crate::build_context::BuildContext) -> Vec<crate::WidgetId> {
1444 let c = ctx.add(FillWidget::new().background(Color::GREEN));
1445 self.child = Some(c);
1446 vec![c]
1447 }
1448
1449 fn layout_response(
1450 &self,
1451 proposal: SizeProposal,
1452 _ctx: &LayoutContext,
1453 ) -> crate::widget::LayoutResponse {
1454 proposal.resolve(0.0, 0.0).into()
1455 }
1456
1457 fn place_children(
1458 &self,
1459 bounds: teksilo_canvas::Rect,
1460 _proposal: SizeProposal,
1461 children: &mut [crate::widget::WidgetPlacement],
1462 _ctx: &LayoutContext,
1463 ) {
1464 for child in children.iter_mut() {
1465 child.origin = bounds.origin();
1466 child.size = bounds.size();
1467 }
1468 }
1469
1470 fn children(&self) -> Vec<crate::WidgetId> {
1471 self.child.iter().copied().collect()
1472 }
1473
1474 fn paint(
1475 &self,
1476 bounds: teksilo_canvas::Rect,
1477 canvas: &mut teksilo_canvas::Canvas,
1478 _ctx: &PaintContext,
1479 ) {
1480 canvas.fill_rounded_rect(bounds, CornerRadius::uniform(0.0), Color::RED);
1481 }
1482
1483 fn wants_post_paint(&self) -> bool {
1484 true
1485 }
1486
1487 fn post_paint(
1488 &self,
1489 bounds: teksilo_canvas::Rect,
1490 canvas: &mut teksilo_canvas::Canvas,
1491 _ctx: &PaintContext,
1492 ) {
1493 canvas.fill_rounded_rect(bounds, CornerRadius::uniform(0.0), Color::BLUE);
1494 }
1495 }
1496
1497 #[test]
1498 fn post_paint_emits_after_children() {
1499 let mut tree = WidgetTree::new().with_theme(crate::presets::intui::light());
1504 tree.add(Sandwich { child: None });
1505 tree.layout(SizeProposal::exact(100.0, 50.0));
1506 let frame = tree.render();
1507
1508 let color_of = |cmd: &teksilo_canvas::DrawCommand| -> Option<[f32; 4]> {
1509 match cmd {
1510 teksilo_canvas::DrawCommand::Shape(i) => frame.shapes.get(*i).map(|s| s.color),
1511 teksilo_canvas::DrawCommand::Decoration(i) => {
1512 frame.decorations.get(*i).map(|d| d.color)
1513 }
1514 _ => None,
1515 }
1516 };
1517 let find = |dominant: usize| -> Option<usize> {
1519 frame.draw_order.iter().position(|cmd| {
1520 color_of(cmd).is_some_and(|c| {
1521 c[dominant] > 0.5 && (0..3).all(|ch| ch == dominant || c[ch] < 0.5)
1522 })
1523 })
1524 };
1525 let red = find(0).expect("backdrop (RED) painted");
1526 let green = find(1).expect("child (GREEN) painted");
1527 let blue = find(2).expect("foreground (BLUE) painted");
1528 assert!(
1529 red < green && green < blue,
1530 "expected backdrop < child < foreground; RED@{red}, GREEN@{green}, BLUE@{blue}; order={:?}",
1531 frame.draw_order
1532 );
1533 }
1534
1535 #[test]
1536 fn blur_prop_emits_begin_end_pair_around_subtree() {
1537 let mut tree = WidgetTree::new().with_theme(crate::presets::intui::light());
1543 let parent = tree.add(StackWidget::new());
1544 let _child = tree.add_child(parent, FillWidget::new().background(Color::RED));
1545 tree.set_blur(parent, 8.0_f32);
1546 tree.layout(SizeProposal::exact(100.0, 50.0));
1547 let frame = tree.render();
1548
1549 let mut begin_count = 0;
1550 let mut end_count = 0;
1551 let mut begin_radius = None;
1552 for cmd in &frame.draw_order {
1553 match cmd {
1554 teksilo_canvas::DrawCommand::BeginBlurredSubtree { radius, .. } => {
1555 begin_count += 1;
1556 begin_radius = Some(*radius);
1557 }
1558 teksilo_canvas::DrawCommand::EndBlurredSubtree => end_count += 1,
1559 _ => {}
1560 }
1561 }
1562 assert_eq!(begin_count, 1, "draw_order = {:?}", frame.draw_order);
1563 assert_eq!(end_count, 1);
1564 assert_eq!(begin_radius, Some(8.0));
1565 }
1566
1567 #[test]
1568 fn blur_prop_subperceptual_radius_skipped() {
1569 let mut tree = WidgetTree::new().with_theme(crate::presets::intui::light());
1573 let parent = tree.add(StackWidget::new());
1574 tree.add_child(parent, FillWidget::new().background(Color::RED));
1575 tree.set_blur(parent, 0.2_f32);
1576 tree.layout(SizeProposal::exact(100.0, 50.0));
1577 let frame = tree.render();
1578
1579 for cmd in &frame.draw_order {
1580 assert!(
1581 !matches!(
1582 cmd,
1583 teksilo_canvas::DrawCommand::BeginBlurredSubtree { .. }
1584 | teksilo_canvas::DrawCommand::EndBlurredSubtree
1585 ),
1586 "sub-perceptual blur must not emit Begin/End"
1587 );
1588 }
1589 }
1590
1591 #[test]
1592 fn blur_scope_is_outermost_when_combined_with_opacity_and_transform() {
1593 let mut tree = WidgetTree::new().with_theme(crate::presets::intui::light());
1600 let parent = tree.add(StackWidget::new());
1601 tree.add_child(parent, FillWidget::new().background(Color::RED));
1602 tree.set_blur(parent, 8.0_f32);
1603 tree.set_opacity(parent, 0.7_f32);
1604 tree.set_transform(parent, teksilo_canvas::Transform2D::scale(2.0, 2.0));
1605 tree.layout(SizeProposal::exact(100.0, 50.0));
1606 let frame = tree.render();
1607
1608 let mut begin_idx = None;
1609 let mut set_opacity_idx = None;
1610 let mut push_transform_idx = None;
1611 let mut pop_transform_idx = None;
1612 let mut restore_opacity_idx = None;
1613 let mut end_idx = None;
1614 for (i, cmd) in frame.draw_order.iter().enumerate() {
1615 match cmd {
1616 teksilo_canvas::DrawCommand::BeginBlurredSubtree { .. } => begin_idx = Some(i),
1617 teksilo_canvas::DrawCommand::SetOpacity(_) => set_opacity_idx = Some(i),
1618 teksilo_canvas::DrawCommand::PushTransform(_) => push_transform_idx = Some(i),
1619 teksilo_canvas::DrawCommand::PopTransform => pop_transform_idx = Some(i),
1620 teksilo_canvas::DrawCommand::RestoreOpacity => restore_opacity_idx = Some(i),
1621 teksilo_canvas::DrawCommand::EndBlurredSubtree => end_idx = Some(i),
1622 _ => {}
1623 }
1624 }
1625 let bg = begin_idx.expect("Begin emitted");
1626 let so = set_opacity_idx.expect("SetOpacity emitted");
1627 let pt = push_transform_idx.expect("PushTransform emitted");
1628 let popt = pop_transform_idx.expect("PopTransform emitted");
1629 let ro = restore_opacity_idx.expect("RestoreOpacity emitted");
1630 let en = end_idx.expect("End emitted");
1631 assert!(bg < so, "blur opens before opacity");
1632 assert!(so < pt, "opacity opens before transform");
1633 assert!(pt < popt);
1634 assert!(popt < ro, "transform closes before opacity");
1635 assert!(ro < en, "opacity closes before blur");
1636 }
1637
1638 #[test]
1639 fn nested_theme_overrides_compose() {
1640 let mut tree = WidgetTree::new().with_theme(crate::presets::intui::light());
1641
1642 let grandparent = tree.add(FillWidget::new());
1643 let parent = tree.add_child(grandparent, FillWidget::new());
1644 let child = tree.add_child(parent, FillWidget::new());
1645
1646 tree.set_theme_override(grandparent, |theme| {
1647 theme.colors.accent = Color::RED;
1648 });
1649 tree.set_theme_override(parent, |theme| {
1650 theme.colors.text_secondary = Color::GREEN;
1651 });
1652
1653 tree.layout(SizeProposal::exact(100.0, 50.0));
1654
1655 let child_theme = tree.resolved_theme(child);
1656 assert_eq!(child_theme.colors.accent, Color::RED);
1657 assert_eq!(child_theme.colors.text_secondary, Color::GREEN);
1658 }
1659}