1use anyhow::Result;
2use fission_core::internal::build_layout_tree;
3use fission_core::internal::BuildCtx;
4use fission_core::internal::InternalLoweringCx;
5use fission_core::{
6 Action, ActionEnvelope, ActionId, AdvanceTo, Clock, CurrentTime, Env, GlobalState, InputEvent,
7 LayoutPoint, Runtime, ScrollStateMap, View, Widget, WidgetIdExt,
8};
9use fission_ir::{CoreIR, WidgetId};
10use fission_layout::{LayoutEngine, LayoutInputNode, LayoutSize, LayoutSnapshot, TextMeasurer};
11use fission_render::{
12 embed_surface_id, BoxShadow, Color, DisplayList, DisplayOp, LayoutRect, RenderScene, Renderer,
13};
14use fission_render_vello::parley::FontContext;
15use fission_render_vello::VelloTextMeasurer;
16use fission_theme::fonts;
17use fontique::{Blob, Collection, CollectionOptions, FontInfoOverride, SourceCache};
18use std::sync::{Arc, Mutex};
19
20pub fn layout_input_nodes(ir: &CoreIR, env: &Env) -> Vec<LayoutInputNode> {
21 build_layout_tree(ir, env)
22}
23
24#[derive(Default, Clone)]
26pub struct MockRenderer {
27 pub last_display_list: Arc<Mutex<Option<DisplayList>>>,
28}
29
30impl Renderer for MockRenderer {
31 fn render_scene(&mut self, scene: &RenderScene) -> Result<()> {
32 let mut lock = self.last_display_list.lock().unwrap();
33 *lock = Some(scene.flatten());
34 Ok(())
35 }
36}
37
38struct MockTextMeasurer;
39impl TextMeasurer for MockTextMeasurer {
40 fn measure(&self, text: &str, _font_size: f32, avail: Option<f32>) -> (f32, f32) {
41 let char_width = 10.0;
42 let line_height = 20.0;
43 let full_width = text.len() as f32 * char_width;
44
45 if let Some(w) = avail {
46 if full_width > w {
47 let safe_w = w.max(char_width);
50 let lines = (full_width / safe_w).ceil();
51 return (safe_w, lines * line_height);
52 }
53 }
54 (full_width, line_height)
55 }
56 fn hit_test(
57 &self,
58 _text: &str,
59 _font_size: f32,
60 _available_width: Option<f32>,
61 _x: f32,
62 _y: f32,
63 ) -> usize {
64 0
65 }
66 fn measure_rich_text(
67 &self,
68 runs: &[fission_ir::op::TextRun],
69 available_width: Option<f32>,
70 ) -> (f32, f32) {
71 let full_w: f32 = runs.iter().map(|r| r.text.len() as f32 * 10.0).sum();
72 let char_width = 10.0;
73 let line_height = 20.0;
74
75 if let Some(w) = available_width {
76 if full_w > w {
77 let safe_w = w.max(char_width);
78 let lines = (full_w / safe_w).ceil();
79 return (safe_w, lines * line_height);
80 }
81 }
82 (full_w.max(10.0), line_height)
83 }
84}
85
86#[cfg(test)]
87mod tests {
88 use super::MockTextMeasurer;
89 use fission_ir::op::{Color, FontStyle, TextRun, TextStyle};
90 use fission_layout::TextMeasurer;
91
92 fn text_run(text: &str) -> TextRun {
93 TextRun {
94 text: text.to_string(),
95 style: TextStyle {
96 font_size: 14.0,
97 color: Color::BLACK,
98 underline: false,
99 font_family: None,
100 locale: None,
101 font_weight: 400,
102 font_style: FontStyle::Normal,
103 line_height: None,
104 letter_spacing: 0.0,
105 background_color: None,
106 },
107 }
108 }
109
110 #[test]
111 fn mock_text_measurer_uses_minimum_width_for_zero_or_tiny_constraints() {
112 let measurer = MockTextMeasurer;
113
114 assert_eq!(measurer.measure("abc", 14.0, Some(0.0)), (10.0, 60.0));
115 assert_eq!(measurer.measure("abc", 14.0, Some(4.0)), (10.0, 60.0));
116
117 let runs = vec![text_run("abc")];
118 assert_eq!(measurer.measure_rich_text(&runs, Some(0.0)), (10.0, 60.0));
119 assert_eq!(measurer.measure_rich_text(&runs, Some(4.0)), (10.0, 60.0));
120 }
121}
122
123const DEFAULT_TEST_FONT_FAMILY: &str = "Fission Default";
124
125fn should_use_mock_measurer() -> bool {
126 let env_mock = std::env::var("FISSION_TEST_USE_MOCK_MEASURER")
127 .map(|v| {
128 let v = v.to_ascii_lowercase();
129 v == "1" || v == "true" || v == "yes"
130 })
131 .unwrap_or(false);
132 let env_kind = std::env::var("FISSION_TEST_MEASURER")
133 .map(|v| v.to_ascii_lowercase())
134 .ok();
135 env_mock || matches!(env_kind.as_deref(), Some("mock"))
136}
137
138fn build_vello_measurer() -> Arc<dyn TextMeasurer> {
139 let font_cx = Arc::new(Mutex::new(build_font_context()));
140 {
141 let mut font_cx = font_cx.lock().unwrap();
142 let font_data = fonts::default_font_bytes().to_vec();
143 let info_override = FontInfoOverride {
144 family_name: Some(DEFAULT_TEST_FONT_FAMILY),
145 ..Default::default()
146 };
147 font_cx
148 .collection
149 .register_fonts(Blob::from(font_data), Some(info_override));
150 }
151 Arc::new(VelloTextMeasurer::new_with_default_family(
152 font_cx,
153 DEFAULT_TEST_FONT_FAMILY,
154 ))
155}
156
157fn build_font_context() -> FontContext {
158 let use_system_fonts = std::env::var("FISSION_USE_SYSTEM_FONTS")
159 .map(|v| matches!(v.to_ascii_lowercase().as_str(), "1" | "true" | "yes"))
160 .unwrap_or(false);
161 let options = CollectionOptions {
162 shared: false,
163 system_fonts: use_system_fonts,
164 };
165 FontContext {
166 collection: Collection::new(options),
167 source_cache: SourceCache::default(),
168 }
169}
170
171pub mod linter;
172pub use linter::*;
173
174pub mod driver;
175pub use driver::{SemanticMatch, TestDriver, TextMatch};
176
177pub mod prelude {
178 pub use crate::linter::{LayoutLinter, LayoutViolation};
179 pub use crate::{
180 detect_ir_cycle, MockRenderer, SemanticMatch, TestDriver, TestHarness, TextMatch,
181 };
182 pub use fission_ir::semantics::{ActionTrigger, Role};
183 pub use fission_ir::{EmbedKind, LayoutOp, Op, PaintOp};
184 pub use fission_render::{DisplayList, DisplayOp};
185}
186
187pub struct TestHarness<S: GlobalState> {
188 pub runtime: Runtime,
189 pub renderer: MockRenderer,
190 pub layout_engine: LayoutEngine,
191 pub last_snapshot: Option<LayoutSnapshot>,
192 pub last_ir: Option<CoreIR>,
193 pub root_widget: Option<Box<dyn Fn() -> Widget>>,
194 pub env: Env,
195 pub measurer: Arc<dyn TextMeasurer>,
196 _phantom: std::marker::PhantomData<S>,
197}
198
199impl<S: GlobalState> TestHarness<S> {
200 pub fn lint(&self) -> Vec<LayoutViolation> {
202 if let (Some(ir), Some(snapshot)) = (&self.last_ir, &self.last_snapshot) {
203 LayoutLinter::new(ir, snapshot).check()
204 } else {
205 vec![]
206 }
207 }
208
209 pub fn new(initial_state: S) -> Self {
210 if should_use_mock_measurer() {
211 return Self::new_with_measurer(initial_state, Arc::new(MockTextMeasurer));
212 }
213 Self::new_with_measurer(initial_state, build_vello_measurer())
214 }
215
216 pub fn new_with_mock_measurer(initial_state: S) -> Self {
217 Self::new_with_measurer(initial_state, Arc::new(MockTextMeasurer))
218 }
219
220 pub fn new_with_measurer(initial_state: S, measurer: Arc<dyn TextMeasurer>) -> Self {
221 let mut runtime = Runtime::default();
222 if std::any::TypeId::of::<S>() != std::any::TypeId::of::<Clock>() {
223 runtime
224 .add_app_state(Box::new(initial_state))
225 .expect("Failed to add initial state");
226 }
227
228 Self {
229 runtime: runtime.with_measurer(measurer.clone()),
230 renderer: MockRenderer::default(),
231 layout_engine: LayoutEngine::new().with_measurer(measurer.clone()),
232 last_snapshot: None,
233 last_ir: None,
234 root_widget: None,
235 env: Env::default(),
236 measurer,
237 _phantom: std::marker::PhantomData,
238 }
239 }
240
241 pub fn with_root_widget<W>(mut self, widget: W) -> Self
242 where
243 W: Clone + Into<Widget> + 'static,
244 {
245 self.root_widget = Some(Box::new(move || widget.clone().into()));
246 self
247 }
248
249 pub fn register_reducer(
250 mut self,
251 action_id: ActionId,
252 reducer: fission_core::action::Reducer<S>,
253 ) -> Self {
254 self.runtime
255 .register_reducer::<S>(action_id, reducer)
256 .unwrap();
257 self
258 }
259
260 pub fn dispatch(&mut self, action: impl Action + 'static) -> Result<()> {
261 let target = fission_core::WidgetId::from_u128(0);
262 let envelope: ActionEnvelope = action.into();
263 self.runtime.dispatch(envelope, target)
264 }
265
266 pub fn send_event(&mut self, event: InputEvent) -> Result<()> {
267 if let (Some(ir), Some(layout)) = (&self.last_ir, &self.last_snapshot) {
268 self.runtime.handle_input(event, ir, layout)
269 } else {
270 anyhow::bail!(
271 "Cannot handle input: no frame pumped (missing IR/Layout). Call pump() first."
272 );
273 }
274 }
275
276 pub fn tick(&mut self, dt: CurrentTime) -> Result<()> {
277 self.runtime.tick(dt).map(|_| ())
278 }
279
280 pub fn advance_to(&mut self, time: CurrentTime) -> Result<()> {
281 self.dispatch(AdvanceTo { time })
282 }
283
284 pub fn current_time(&self) -> CurrentTime {
285 self.runtime.clock().current_time()
286 }
287
288 pub fn pump(&mut self) -> Result<()> {
289 let trace = std::env::var("FISSION_TEST_TRACE").ok().as_deref() == Some("1");
290 let mut viewport = LayoutSize {
291 width: 800.0,
292 height: 600.0,
293 };
294 if self.env.viewport_size.width > 0.0
295 && self.env.viewport_size.height > 0.0
296 && self.env.viewport_size.width.is_finite()
297 && self.env.viewport_size.height.is_finite()
298 {
299 viewport = self.env.viewport_size;
300 } else {
301 self.env.viewport_size = viewport;
302 }
303 if let Some(root) = self.root_widget.as_ref() {
305 if trace {
307 eprintln!("[test-trace] build start");
308 }
309 let node_tree = {
310 let state = self
311 .runtime
312 .get_app_state::<S>()
313 .expect("App state missing");
314 let view = View::new(
315 state,
316 &self.runtime.runtime_state,
317 &self.env,
318 self.last_snapshot.as_ref(),
319 );
320 let mut ctx = BuildCtx::new();
321 let tree = fission_core::build::enter(&mut ctx, &view, root);
322
323 self.runtime.clear_reducers();
324 let motion_declarations = ctx.take_motion_declarations();
325 let video_nodes = ctx.take_video_registrations();
326 let web_nodes = ctx.take_web_registrations();
327 let portals_with_ids = ctx.take_portals();
328
329 let portals = portals_with_ids
330 .into_iter()
331 .map(|(id, node)| {
332 if let Some(id) = id {
333 let wrapper_id = WidgetId::derived(id.as_u128(), &[0x0000_F001]);
335 fission_core::ui::Container::new(node)
336 .width(viewport.width)
337 .height(viewport.height)
338 .id(wrapper_id)
339 } else {
340 node
341 }
342 })
343 .collect::<Vec<_>>();
344
345 self.runtime.absorb_registry(ctx.registry);
346 self.runtime
347 .sync_motion_declarations(&motion_declarations, self.last_snapshot.as_ref());
348 self.runtime.sync_video_nodes(&video_nodes);
349 self.runtime.sync_web_nodes(&web_nodes);
350
351 if portals.is_empty() {
352 tree
353 } else {
354 fission_core::ui::Overlay {
358 id: None,
359 content: fission_core::ui::Container::new(tree)
362 .width(viewport.width)
363 .height(viewport.height)
364 .into(),
365 overlay: fission_core::ui::ZStack {
366 id: None,
367 children: portals,
368 }
369 .into(),
370 }
371 .into()
372 }
373 };
374 if trace {
375 eprintln!("[test-trace] build done");
376 }
377
378 if trace {
380 eprintln!("[test-trace] lower start");
381 }
382 let mut cx = InternalLoweringCx::new(
383 &self.env,
384 &self.runtime.runtime_state,
385 Some(&self.measurer),
386 self.last_snapshot.as_ref(),
387 );
388 let root_id = fission_core::internal::lower_widget(&node_tree, &mut cx);
389 cx.ir.root = Some(root_id);
390
391 let layout_input_nodes = build_layout_tree(&cx.ir, &self.env);
392 self.last_ir = Some(cx.ir);
393 if trace {
394 eprintln!("[test-trace] lower done nodes={}", layout_input_nodes.len());
395 }
396
397 if trace {
399 eprintln!("[test-trace] layout start");
400 }
401 self.layout_engine.update(&layout_input_nodes);
402 self.layout_engine
403 .verify_post_update(&layout_input_nodes, root_id)?;
404 let snapshot = self.layout_engine.compute_layout(
405 &layout_input_nodes,
406 root_id,
407 viewport,
408 &|id| self.runtime.runtime_state.scroll.get_offset(id),
409 )?;
410 self.last_snapshot = Some(snapshot);
411 if trace {
412 eprintln!("[test-trace] layout done");
413 }
414 } else {
415 return Ok(());
416 }
417
418 if trace {
420 eprintln!("[test-trace] render start");
421 }
422 let mut display_list =
423 DisplayList::new(LayoutRect::new(0.0, 0.0, viewport.width, viewport.height));
424
425 if let (Some(ir), Some(snapshot)) = (&self.last_ir, &self.last_snapshot) {
426 if let Some(root_id) = ir.root {
427 let scroll_map = &self.runtime.runtime_state.scroll;
428 let animation_map = &self.runtime.runtime_state.motion;
429 generate_display_list(
430 root_id,
431 ir,
432 snapshot,
433 scroll_map,
434 animation_map,
435 &mut display_list,
436 );
437 }
438 }
439
440 self.renderer.render(&display_list)?;
441 if trace {
442 eprintln!("[test-trace] render done");
443 }
444
445 Ok(())
446 }
447
448 pub fn get_last_display_list(&self) -> Option<DisplayList> {
449 self.renderer.last_display_list.lock().unwrap().clone()
450 }
451}
452
453pub fn detect_ir_cycle(ir: &CoreIR) -> Option<Vec<WidgetId>> {
454 use std::collections::HashSet;
455
456 fn dfs(
457 ir: &CoreIR,
458 node: WidgetId,
459 visited: &mut HashSet<WidgetId>,
460 stack: &mut HashSet<WidgetId>,
461 path: &mut Vec<WidgetId>,
462 ) -> Option<Vec<WidgetId>> {
463 if !visited.insert(node) {
464 return None;
465 }
466 stack.insert(node);
467 path.push(node);
468 if let Some(n) = ir.nodes.get(&node) {
469 for &child in &n.children {
470 if stack.contains(&child) {
471 if let Some(pos) = path.iter().position(|&id| id == child) {
472 return Some(path[pos..].to_vec());
473 } else {
474 return Some(vec![child]);
475 }
476 }
477 if let Some(cy) = dfs(ir, child, visited, stack, path) {
478 return Some(cy);
479 }
480 }
481 }
482 stack.remove(&node);
483 path.pop();
484 None
485 }
486
487 if let Some(root) = ir.root {
488 let mut visited = HashSet::new();
489 let mut stack = HashSet::new();
490 let mut path = Vec::new();
491 return dfs(ir, root, &mut visited, &mut stack, &mut path);
492 }
493 None
494}
495
496fn map_fill(f: &fission_ir::op::Fill) -> fission_render::Fill {
497 match f {
498 fission_ir::op::Fill::Solid(c) => fission_render::Fill::Solid(fission_render::Color {
499 r: c.r,
500 g: c.g,
501 b: c.b,
502 a: c.a,
503 }),
504 fission_ir::op::Fill::LinearGradient { start, end, stops } => {
505 fission_render::Fill::LinearGradient {
506 start: *start,
507 end: *end,
508 stops: stops
509 .iter()
510 .map(|(o, c)| {
511 (
512 *o,
513 fission_render::Color {
514 r: c.r,
515 g: c.g,
516 b: c.b,
517 a: c.a,
518 },
519 )
520 })
521 .collect(),
522 }
523 }
524 fission_ir::op::Fill::RadialGradient {
525 center,
526 radius,
527 stops,
528 } => fission_render::Fill::RadialGradient {
529 center: *center,
530 radius: *radius,
531 stops: stops
532 .iter()
533 .map(|(o, c)| {
534 (
535 *o,
536 fission_render::Color {
537 r: c.r,
538 g: c.g,
539 b: c.b,
540 a: c.a,
541 },
542 )
543 })
544 .collect(),
545 },
546 }
547}
548
549fn map_stroke(s: &fission_ir::op::Stroke) -> fission_render::Stroke {
550 fission_render::Stroke {
551 fill: map_fill(&s.fill),
552 width: s.width,
553 dash_array: s.dash_array.clone(),
554 line_cap: match s.line_cap {
555 fission_ir::op::LineCap::Butt => fission_render::LineCap::Butt,
556 fission_ir::op::LineCap::Round => fission_render::LineCap::Round,
557 fission_ir::op::LineCap::Square => fission_render::LineCap::Square,
558 },
559 line_join: match s.line_join {
560 fission_ir::op::LineJoin::Miter => fission_render::LineJoin::Miter,
561 fission_ir::op::LineJoin::Round => fission_render::LineJoin::Round,
562 fission_ir::op::LineJoin::Bevel => fission_render::LineJoin::Bevel,
563 },
564 }
565}
566
567fn generate_display_list(
568 node_id: WidgetId,
569 ir: &CoreIR,
570 snapshot: &LayoutSnapshot,
571 scroll_map: &ScrollStateMap,
572 animation_map: &fission_core::MotionStateMap,
573 list: &mut DisplayList,
574) {
575 use std::collections::HashSet;
576 let mut visited = HashSet::new();
577 generate_display_list_with_visited(
578 node_id,
579 ir,
580 snapshot,
581 scroll_map,
582 animation_map,
583 list,
584 &mut visited,
585 );
586}
587
588fn generate_display_list_with_visited(
589 node_id: WidgetId,
590 ir: &CoreIR,
591 snapshot: &LayoutSnapshot,
592 scroll_map: &ScrollStateMap,
593 animation_map: &fission_core::MotionStateMap,
594 list: &mut DisplayList,
595 visited: &mut std::collections::HashSet<WidgetId>,
596) {
597 if !visited.insert(node_id) {
598 return;
599 }
600 if let Some(geom) = snapshot.nodes.get(&node_id) {
601 if let Some(node) = ir.nodes.get(&node_id) {
602 let mut pushed_state = false;
603 let mut clip_applied = false;
604 let rect = geom.rect;
605
606 let opacity = resolve_composite_scalar(
607 node.composite.opacity.as_ref(),
608 animation_map,
609 fission_core::MotionPropertyId::Opacity,
610 );
611 let tx = resolve_composite_scalar(
612 node.composite.translate_x.as_ref(),
613 animation_map,
614 fission_core::MotionPropertyId::TranslateX,
615 )
616 .unwrap_or(0.0);
617 let ty = resolve_composite_scalar(
618 node.composite.translate_y.as_ref(),
619 animation_map,
620 fission_core::MotionPropertyId::TranslateY,
621 )
622 .unwrap_or(0.0);
623 let scale = resolve_composite_scalar(
624 node.composite.scale.as_ref(),
625 animation_map,
626 fission_core::MotionPropertyId::Scale,
627 )
628 .unwrap_or(1.0);
629 let rotation = resolve_composite_scalar(
630 node.composite.rotation.as_ref(),
631 animation_map,
632 fission_core::MotionPropertyId::Rotation,
633 )
634 .unwrap_or(0.0);
635
636 match &node.op {
637 fission_ir::Op::Layout(fission_ir::LayoutOp::Scroll { direction, .. }) => {
638 let offset = scroll_map.get_offset(node_id);
639 list.push(DisplayOp::Save);
640 list.push(DisplayOp::ClipRect(rect));
641 clip_applied = true;
642 match direction {
643 fission_ir::FlexDirection::Row => {
644 list.push(DisplayOp::Translate(LayoutPoint::new(-offset, 0.0)));
645 }
646 fission_ir::FlexDirection::Column => {
647 list.push(DisplayOp::Translate(LayoutPoint::new(0.0, -offset)));
648 }
649 }
650 pushed_state = true;
651 }
652 fission_ir::Op::Layout(fission_ir::LayoutOp::Clip { .. }) => {
653 list.push(DisplayOp::Save);
654 list.push(DisplayOp::ClipRect(rect));
655 clip_applied = true;
656 pushed_state = true;
657 }
658 fission_ir::Op::Layout(fission_ir::LayoutOp::Transform { transform }) => {
659 list.push(DisplayOp::Save);
660 list.push(DisplayOp::Transform(*transform));
661 pushed_state = true;
662 }
663 _ => {}
664 }
665
666 if node.composite.clip_to_bounds && !clip_applied {
667 if !pushed_state {
668 list.push(DisplayOp::Save);
669 pushed_state = true;
670 }
671 list.push(DisplayOp::ClipRect(rect));
672 }
673
674 if let Some(opacity) = opacity {
675 if (opacity - 1.0).abs() > 0.001 {
676 if !pushed_state {
677 list.push(DisplayOp::Save);
678 pushed_state = true;
679 }
680 list.push(DisplayOp::OpacityLayer {
681 alpha: opacity,
682 bounds: rect,
683 });
684 }
685 }
686
687 if tx.abs() > 0.001
688 || ty.abs() > 0.001
689 || (scale - 1.0).abs() > 0.001
690 || rotation.abs() > 0.001
691 {
692 if !pushed_state {
693 list.push(DisplayOp::Save);
694 pushed_state = true;
695 }
696 list.push(DisplayOp::Transform(composite_transform_matrix(
697 rect, tx, ty, scale, rotation,
698 )));
699 }
700
701 match &node.op {
702 fission_ir::Op::Paint(fission_ir::PaintOp::DrawRect {
703 fill,
704 stroke,
705 corner_radius,
706 shadow,
707 }) => {
708 list.push(DisplayOp::DrawRect {
709 rect: geom.rect,
710 fill: fill.as_ref().map(map_fill),
711 stroke: stroke.as_ref().map(map_stroke),
712 corner_radius: *corner_radius,
713 shadow: shadow.map(|s| BoxShadow {
714 color: Color {
715 r: s.color.r,
716 g: s.color.g,
717 b: s.color.b,
718 a: s.color.a,
719 },
720 blur_radius: s.blur_radius,
721 offset: s.offset,
722 }),
723 bounds: geom.rect,
724 node_id: Some(node_id),
725 });
726 }
727 fission_ir::Op::Paint(fission_ir::PaintOp::DrawText {
728 text,
729 size,
730 color,
731 underline,
732 wrap,
733 caret_index,
734 caret_color,
735 caret_width,
736 caret_height,
737 caret_radius,
738 paragraph_style,
739 }) => {
740 list.push(DisplayOp::DrawText {
741 text: text.clone(),
742 position: LayoutPoint::new(geom.rect.x(), geom.rect.y()),
743 size: *size,
744 color: fission_render::Color {
745 r: color.r,
746 g: color.g,
747 b: color.b,
748 a: color.a,
749 },
750 bounds: geom.rect,
751 node_id: Some(node_id),
752 underline: *underline,
753 wrap: *wrap,
754 caret_index: *caret_index,
755 caret_color: caret_color.map(|color| fission_render::Color {
756 r: color.r,
757 g: color.g,
758 b: color.b,
759 a: color.a,
760 }),
761 caret_width: *caret_width,
762 caret_height: *caret_height,
763 caret_radius: *caret_radius,
764 paragraph_style: *paragraph_style,
765 });
766 }
767 fission_ir::Op::Paint(fission_ir::PaintOp::DrawRichText {
768 runs,
769 wrap,
770 caret_index,
771 caret_color,
772 caret_width,
773 caret_height,
774 caret_radius,
775 paragraph_style,
776 }) => {
777 let annotations = ir
778 .custom_render_objects
779 .get(&node_id)
780 .and_then(|sidecar| {
781 sidecar.downcast_ref::<Vec<fission_ir::op::RichTextAnnotation>>()
782 })
783 .cloned()
784 .unwrap_or_default();
785 list.push(DisplayOp::DrawRichText {
786 runs: runs
787 .iter()
788 .map(|r| fission_render::TextRun {
789 text: r.text.clone(),
790 style: fission_render::TextStyle {
791 font_size: r.style.font_size,
792 color: fission_render::Color {
793 r: r.style.color.r,
794 g: r.style.color.g,
795 b: r.style.color.b,
796 a: r.style.color.a,
797 },
798 underline: r.style.underline,
799 font_family: r.style.font_family.clone(),
800 locale: r.style.locale.clone(),
801 font_weight: r.style.font_weight,
802 font_style: r.style.font_style,
803 line_height: r.style.line_height,
804 letter_spacing: r.style.letter_spacing,
805 background_color: r.style.background_color.map(|c| {
806 fission_render::Color {
807 r: c.r,
808 g: c.g,
809 b: c.b,
810 a: c.a,
811 }
812 }),
813 },
814 })
815 .collect(),
816 position: LayoutPoint::new(geom.rect.x(), geom.rect.y()),
817 bounds: geom.rect,
818 node_id: Some(node_id),
819 wrap: *wrap,
820 caret_index: *caret_index,
821 caret_color: caret_color.map(|color| fission_render::Color {
822 r: color.r,
823 g: color.g,
824 b: color.b,
825 a: color.a,
826 }),
827 caret_width: *caret_width,
828 caret_height: *caret_height,
829 caret_radius: *caret_radius,
830 paragraph_style: *paragraph_style,
831 annotations,
832 });
833 }
834 fission_ir::Op::Paint(fission_ir::PaintOp::DrawSvg {
835 content,
836 fill,
837 stroke,
838 }) => {
839 list.push(DisplayOp::DrawSvg {
840 content: content.clone(),
841 fill: fill.as_ref().map(map_fill),
842 stroke: stroke.as_ref().map(map_stroke),
843 bounds: geom.rect,
844 node_id: Some(node_id),
845 });
846 }
847 fission_ir::Op::Paint(fission_ir::PaintOp::DrawImage {
848 request,
849 fit,
850 alignment,
851 }) => {
852 list.push(DisplayOp::DrawImage {
853 rect: geom.rect,
854 request: request.clone(),
855 fit: match fit {
856 fission_ir::op::ImageFit::Contain => fission_render::ImageFit::Contain,
857 fission_ir::op::ImageFit::Cover => fission_render::ImageFit::Cover,
858 fission_ir::op::ImageFit::Fill => fission_render::ImageFit::Fill,
859 fission_ir::op::ImageFit::None => fission_render::ImageFit::None,
860 },
861 alignment: *alignment,
862 bounds: geom.rect,
863 node_id: Some(node_id),
864 });
865 }
866 fission_ir::Op::Paint(fission_ir::PaintOp::DrawPath { path, fill, stroke }) => {
867 list.push(DisplayOp::DrawPath {
868 path: path.clone(),
869 fill: fill.as_ref().map(map_fill),
870 stroke: stroke.as_ref().map(map_stroke),
871 bounds: geom.rect,
872 node_id: Some(node_id),
873 });
874 }
875 fission_ir::Op::Layout(fission_ir::LayoutOp::Embed {
876 kind, widget_id, ..
877 }) => {
878 list.push(DisplayOp::DrawSurface {
879 rect: geom.rect,
880 surface_id: embed_surface_id(kind, *widget_id),
881 position: 0,
882 bounds: geom.rect,
883 node_id: Some(node_id),
884 });
885 }
886 _ => {}
887 }
888
889 for child in &node.children {
890 generate_display_list_with_visited(
891 *child,
892 ir,
893 snapshot,
894 scroll_map,
895 animation_map,
896 list,
897 visited,
898 );
899 }
900
901 if pushed_state {
902 list.push(DisplayOp::Restore);
903 }
904 }
905 }
906}
907
908fn resolve_composite_scalar(
909 scalar: Option<&fission_ir::CompositeScalar>,
910 animation_map: &fission_core::MotionStateMap,
911 property: fission_core::MotionPropertyId,
912) -> Option<f32> {
913 let scalar = scalar?;
914 Some(
915 scalar
916 .motion_target
917 .map(|target| animation_map.scalar_value(target, property))
918 .unwrap_or(scalar.base),
919 )
920}
921
922fn composite_transform_matrix(
923 rect: LayoutRect,
924 translate_x: f32,
925 translate_y: f32,
926 scale: f32,
927 rotation: f32,
928) -> [f32; 16] {
929 let center_x = rect.origin.x + rect.size.width * 0.5;
930 let center_y = rect.origin.y + rect.size.height * 0.5;
931
932 let to_center = translation_matrix(center_x, center_y);
933 let from_center = translation_matrix(-center_x, -center_y);
934 let scale_matrix = scale_matrix(scale);
935 let rotation_matrix = rotation_z_matrix(rotation);
936 let motion_translate = translation_matrix(translate_x, translate_y);
937
938 multiply_matrix(
939 motion_translate,
940 multiply_matrix(
941 to_center,
942 multiply_matrix(rotation_matrix, multiply_matrix(scale_matrix, from_center)),
943 ),
944 )
945}
946
947fn translation_matrix(tx: f32, ty: f32) -> [f32; 16] {
948 [
949 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, tx, ty, 0.0, 1.0,
950 ]
951}
952
953fn scale_matrix(scale: f32) -> [f32; 16] {
954 [
955 scale, 0.0, 0.0, 0.0, 0.0, scale, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0,
956 ]
957}
958
959fn rotation_z_matrix(radians: f32) -> [f32; 16] {
960 let sin = radians.sin();
961 let cos = radians.cos();
962 [
963 cos, sin, 0.0, 0.0, -sin, cos, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0,
964 ]
965}
966
967fn multiply_matrix(a: [f32; 16], b: [f32; 16]) -> [f32; 16] {
968 let mut out = [0.0; 16];
969 for row in 0..4 {
970 for col in 0..4 {
971 let mut sum = 0.0;
972 for k in 0..4 {
973 sum += a[row * 4 + k] * b[k * 4 + col];
974 }
975 out[row * 4 + col] = sum;
976 }
977 }
978 out
979}