1use std::rc::Rc;
2
3use cranpose_foundation::PointerEvent;
4use cranpose_ui::{Brush, DrawCommand, DrawCommandFn, LayoutNodeData, ModifierNodeSlices};
5use cranpose_ui_graphics::{
6 BlendMode, Color, ColorFilter, CommandRecording, CompositingStrategy, CornerRadii,
7 DrawPrimitive, FinishedRecording, GraphicsLayer, Point, RoundedCornerShape, Size,
8};
9
10use crate::layer_transform::{layer_scale_x, layer_scale_y, layer_uniform_scale};
11
12pub struct NodeStyle {
13 pub padding: cranpose_ui_graphics::EdgeInsets,
14 pub background: Option<Color>,
15 pub click_actions: Vec<Rc<dyn Fn(Point)>>,
16 pub shape: Option<RoundedCornerShape>,
17 pub pointer_inputs: Vec<Rc<dyn Fn(PointerEvent)>>,
18 pub draw_commands: Vec<DrawCommand>,
19 pub graphics_layer: Option<GraphicsLayer>,
20 pub clip_to_bounds: bool,
21}
22
23impl NodeStyle {
24 pub fn from_layout_node(data: &LayoutNodeData) -> Self {
25 let resolved = data.resolved_modifiers;
26 let slices: &ModifierNodeSlices = data.modifier_slices();
27 let pointer_inputs = slices.pointer_inputs().to_vec();
28
29 Self {
30 padding: resolved.padding(),
31 background: None,
32 click_actions: slices.click_handlers().to_vec(),
33 shape: None,
34 pointer_inputs,
35 draw_commands: slices.draw_commands().to_vec(),
36 graphics_layer: slices.graphics_layer(),
37 clip_to_bounds: slices.clip_to_bounds(),
38 }
39 }
40}
41
42pub fn combine_layers(
43 current: GraphicsLayer,
44 modifier_layer: Option<GraphicsLayer>,
45) -> GraphicsLayer {
46 if let Some(layer) = modifier_layer {
47 GraphicsLayer {
48 alpha: (current.alpha * layer.alpha).clamp(0.0, 1.0),
49 scale: current.scale * layer.scale,
50 scale_x: current.scale_x * layer.scale_x,
51 scale_y: current.scale_y * layer.scale_y,
52 rotation_x: current.rotation_x + layer.rotation_x,
53 rotation_y: current.rotation_y + layer.rotation_y,
54 rotation_z: current.rotation_z + layer.rotation_z,
55 camera_distance: layer.camera_distance,
56 transform_origin: layer.transform_origin,
57 translation_x: current.translation_x + layer.translation_x,
58 translation_y: current.translation_y + layer.translation_y,
59 shadow_elevation: layer.shadow_elevation,
60 ambient_shadow_color: layer.ambient_shadow_color,
61 spot_shadow_color: layer.spot_shadow_color,
62 shape: layer.shape,
63 clip: current.clip || layer.clip,
64 color_filter: compose_color_filters(current.color_filter, layer.color_filter),
65 compositing_strategy: layer.compositing_strategy,
66 blend_mode: layer.blend_mode,
67 render_effect: layer.render_effect,
69 backdrop_effect: layer.backdrop_effect,
71 }
72 } else {
73 GraphicsLayer {
74 compositing_strategy: CompositingStrategy::Auto,
75 blend_mode: BlendMode::SrcOver,
76 render_effect: None,
77 backdrop_effect: None,
78 ..current
79 }
80 }
81}
82
83pub use crate::graph::quad_bounds;
84
85pub fn apply_layer_to_color(color: Color, layer: &GraphicsLayer) -> Color {
95 let color = color.srgb_8bit();
96 apply_color_filter_to_color(
97 Color(
98 color.0,
99 color.1,
100 color.2,
101 (color.3 * layer.alpha).clamp(0.0, 1.0),
102 ),
103 layer.color_filter,
104 )
105}
106
107fn apply_color_filter_to_color(color: Color, filter: Option<ColorFilter>) -> Color {
108 match filter {
109 Some(filter) => {
110 let [r, g, b, a] = filter.apply_rgba([color.0, color.1, color.2, color.3]);
111 Color(r, g, b, a)
112 }
113 None => color,
114 }
115}
116
117pub fn compose_color_filters(
118 base: Option<ColorFilter>,
119 overlay: Option<ColorFilter>,
120) -> Option<ColorFilter> {
121 match (base, overlay) {
122 (None, None) => None,
123 (Some(filter), None) | (None, Some(filter)) => Some(filter),
124 (Some(filter), Some(next)) => Some(filter.compose(next)),
125 }
126}
127
128pub fn apply_layer_to_brush(brush: Brush, layer: &GraphicsLayer) -> Brush {
129 if layer.alpha == 1.0
135 && layer.color_filter.is_none()
136 && layer_scale_x(layer) == 1.0
137 && layer_scale_y(layer) == 1.0
138 {
139 return map_brush_colors(brush, Color::srgb_8bit);
140 }
141 map_brush_colors(scale_brush_geometry(brush, layer), |color| {
142 apply_layer_to_color(color, layer)
143 })
144}
145
146fn map_brush_colors(brush: Brush, paint: impl Fn(Color) -> Color) -> Brush {
148 match brush {
149 Brush::Solid(color) => Brush::solid(paint(color)),
150 Brush::LinearGradient {
151 colors,
152 stops,
153 start,
154 end,
155 tile_mode,
156 } => Brush::LinearGradient {
157 colors: colors.into_iter().map(paint).collect(),
158 stops,
159 start,
160 end,
161 tile_mode,
162 },
163 Brush::RadialGradient {
164 colors,
165 stops,
166 center,
167 radius,
168 tile_mode,
169 } => Brush::RadialGradient {
170 colors: colors.into_iter().map(paint).collect(),
171 stops,
172 center,
173 radius,
174 tile_mode,
175 },
176 Brush::SweepGradient {
177 colors,
178 stops,
179 center,
180 } => Brush::SweepGradient {
181 colors: colors.into_iter().map(paint).collect(),
182 stops,
183 center,
184 },
185 }
186}
187
188fn scale_brush_geometry(brush: Brush, layer: &GraphicsLayer) -> Brush {
190 let scale_x = layer_scale_x(layer);
191 let scale_y = layer_scale_y(layer);
192 let uniform_scale = layer_uniform_scale(layer);
193
194 match brush {
195 Brush::Solid(color) => Brush::Solid(color),
196 Brush::LinearGradient {
197 colors,
198 stops,
199 mut start,
200 mut end,
201 tile_mode,
202 } => {
203 start.x *= scale_x;
204 start.y *= scale_y;
205 end.x *= scale_x;
206 end.y *= scale_y;
207 Brush::LinearGradient {
208 colors,
209 stops,
210 start,
211 end,
212 tile_mode,
213 }
214 }
215 Brush::RadialGradient {
216 colors,
217 stops,
218 mut center,
219 mut radius,
220 tile_mode,
221 } => {
222 center.x *= scale_x;
223 center.y *= scale_y;
224 radius *= uniform_scale;
225 Brush::RadialGradient {
226 colors,
227 stops,
228 center,
229 radius,
230 tile_mode,
231 }
232 }
233 Brush::SweepGradient {
234 colors,
235 stops,
236 mut center,
237 } => {
238 center.x *= scale_x;
239 center.y *= scale_y;
240 Brush::SweepGradient {
241 colors,
242 stops,
243 center,
244 }
245 }
246 }
247}
248
249#[derive(Clone, Debug, PartialEq)]
257pub enum ResolvedBrush {
258 Solid(Color),
259 Other(Brush),
261}
262
263impl ResolvedBrush {
264 pub fn from_brush(brush: Brush) -> Self {
265 match brush {
266 Brush::Solid(color) => Self::Solid(color),
267 other => Self::Other(other),
268 }
269 }
270
271 pub fn into_brush(self) -> Brush {
274 match self {
275 Self::Solid(color) => Brush::Solid(color),
276 Self::Other(brush) => brush,
277 }
278 }
279}
280
281pub fn resolve_layer_brush(brush: &Brush, layer: &GraphicsLayer) -> ResolvedBrush {
286 match brush {
287 Brush::Solid(color) => {
288 if layer.alpha == 1.0
289 && layer.color_filter.is_none()
290 && layer_scale_x(layer) == 1.0
291 && layer_scale_y(layer) == 1.0
292 {
293 ResolvedBrush::Solid(*color)
294 } else {
295 ResolvedBrush::Solid(apply_layer_to_color(*color, layer))
296 }
297 }
298 other => ResolvedBrush::Other(apply_layer_to_brush(other.clone(), layer)),
299 }
300}
301
302pub fn scale_corner_radii(radii: CornerRadii, scale: f32) -> CornerRadii {
303 CornerRadii {
304 top_left: radii.top_left * scale,
305 top_right: radii.top_right * scale,
306 bottom_right: radii.bottom_right * scale,
307 bottom_left: radii.bottom_left * scale,
308 }
309}
310
311#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
312pub enum DrawPlacement {
313 Behind,
314 Overlay,
315}
316
317pub fn primitives_for_placement(
318 command: &DrawCommand,
319 placement: DrawPlacement,
320 size: Size,
321) -> Vec<DrawPrimitive> {
322 primitives_for_placement_reusing(command, placement, size, Vec::new())
323}
324
325pub fn primitives_for_placement_reusing(
330 command: &DrawCommand,
331 placement: DrawPlacement,
332 size: Size,
333 storage: Vec<DrawPrimitive>,
334) -> Vec<DrawPrimitive> {
335 primitives_for_placement_retained(
336 command,
337 placement,
338 size,
339 CommandRecording::default(),
340 storage,
341 )
342 .0
343}
344
345pub fn primitives_for_placement_retained(
349 command: &DrawCommand,
350 placement: DrawPlacement,
351 size: Size,
352 recording: CommandRecording,
353 storage: Vec<DrawPrimitive>,
354) -> (Vec<DrawPrimitive>, CommandRecording) {
355 let mut no_replay = None;
356 let (primitives, recording, _) = primitives_for_placement_verified(
357 command,
358 placement,
359 size,
360 recording,
361 storage,
362 &mut no_replay,
363 None,
364 );
365 (primitives, recording)
366}
367
368pub struct CommandReplayContext<'a> {
375 pub state: &'a mut cranpose_ui_graphics::CommandReplayState,
376 pub stale_available: bool,
380 pub serve_stale: bool,
386}
387
388pub fn primitives_for_placement_verified(
398 command: &DrawCommand,
399 placement: DrawPlacement,
400 size: Size,
401 recording: CommandRecording,
402 storage: Vec<DrawPrimitive>,
403 replay: &mut Option<CommandReplayContext<'_>>,
404 command_id: Option<crate::graph::DrawCommandId>,
405) -> (
406 Vec<DrawPrimitive>,
407 CommandRecording,
408 Option<cranpose_ui_graphics::CommandReplayFrame>,
409) {
410 let filter_content = |primitives: Vec<DrawPrimitive>, markers: u32| {
417 if markers == 0 {
418 return primitives;
419 }
420 let mut out = Vec::with_capacity(primitives.len());
426 out.extend(
427 primitives
428 .into_iter()
429 .filter(|primitive| !matches!(primitive, DrawPrimitive::Content)),
430 );
431 out
432 };
433
434 let split_with_content = |primitives: Vec<DrawPrimitive>, placement, markers: u32| {
435 let last_content_idx = if markers == 0 {
436 None
437 } else {
438 primitives
439 .iter()
440 .rposition(|primitive| matches!(primitive, DrawPrimitive::Content))
441 };
442 let Some(last_content_idx) = last_content_idx else {
443 return if matches!(placement, DrawPlacement::Overlay) {
444 filter_content(primitives, markers)
445 } else {
446 Vec::new()
447 };
448 };
449
450 let mut out = Vec::with_capacity(primitives.len());
451 out.extend(
452 primitives
453 .into_iter()
454 .enumerate()
455 .filter_map(|(index, primitive)| {
456 if matches!(primitive, DrawPrimitive::Content) {
457 return None;
458 }
459 let is_before = index < last_content_idx;
460 match placement {
461 DrawPlacement::Behind if is_before => Some(primitive),
462 DrawPlacement::Overlay if !is_before => Some(primitive),
463 _ => None,
464 }
465 }),
466 );
467 out
468 };
469
470 fn record_into(
473 func: &DrawCommandFn,
474 size: Size,
475 recording: CommandRecording,
476 storage: Vec<DrawPrimitive>,
477 replay: &mut Option<CommandReplayContext<'_>>,
478 command: Option<crate::graph::DrawCommandId>,
479 ) -> (
480 FinishedRecording,
481 Option<cranpose_ui_graphics::CommandReplayFrame>,
482 ) {
483 let mut scope = cranpose_ui::command_draw_scope_retained(size, recording, storage);
484 func(&mut scope);
485 let Some(ctx) = replay.as_mut() else {
486 return (scope.finish(), None);
487 };
488 let state = &mut *ctx.state;
489 let outcome =
490 state.advance_pooled(scope.recorded(), crate::scene_builder::verify_executor());
491 let diag = if cranpose_core::env_flag!("CRANPOSE_COMMAND_REPLAY_DIAG") {
495 if let cranpose_ui_graphics::ReplayOutcome::Spans(spans) = &outcome {
496 let (mut retained, mut dynamic) = (0usize, 0usize);
497 for span in spans {
498 match span {
499 cranpose_ui_graphics::ReplaySpan::Retained { .. } => retained += 1,
500 cranpose_ui_graphics::ReplaySpan::Dynamic {
501 tape_start,
502 tape_end,
503 } => dynamic += tape_end - tape_start,
504 }
505 }
506 Some((
507 scope.recorded().len(),
508 retained,
509 dynamic,
510 state.segments().len(),
511 state.stats(),
512 state.optimistic_commits(),
513 state.prefix_commits(),
514 ))
515 } else {
516 None
517 }
518 } else {
519 None
520 };
521 if !state.segments().is_empty() {
527 thread_local! {
528 static VERIFIED_FRAMES: std::cell::Cell<u64> = const { std::cell::Cell::new(0) };
529 }
530 let frames = VERIFIED_FRAMES.with(|cell| {
531 let next = cell.get().wrapping_add(1);
532 cell.set(next);
533 next
534 });
535 if frames.is_multiple_of(256) {
536 let (deaths, splits) = state.stats();
537 log::warn!(
538 "[command-replay] health: {} segments, pooled commits {} + prefix {}, \
539 lifetime deaths {} splits {}",
540 state.segments().len(),
541 state.optimistic_commits(),
542 state.prefix_commits(),
543 deaths,
544 splits,
545 );
546 }
547 }
548 let center = state.center();
549 let markers = scope.content_marker_count();
554 if ctx.stale_available && markers == 0 && state.collapsed_from_captured() {
555 ctx.serve_stale = true;
567 if cranpose_core::env_flag!("CRANPOSE_COMMAND_REPLAY_DIAG") {
568 log::warn!(
569 "[command-replay] stale transition: collapse frame of {} records \
570 re-serves the previous emission",
571 scope.recorded().len(),
572 );
573 }
574 return (scope.finish_recording_only(), None);
575 }
576 let mut bypass = |slot: u32| {
577 markers == 0
578 && command.is_some_and(|id| crate::scene_builder::retained_slot_confirmed(id, slot))
579 };
580 let (finished, frame) = scope.finish_replay(center, outcome, &mut bypass);
581 if let Some((records, retained, dynamic, segments, (deaths, splits), pooled, prefix)) = diag
582 {
583 log::warn!(
584 "[command-replay] {} records: {} retained spans, {} dynamic records, \
585 {} materialized; {} segments alive, lifetime deaths {} splits {}, \
586 pooled commits {} + prefix {}",
587 records,
588 retained,
589 dynamic,
590 finished.primitives.len(),
591 segments,
592 deaths,
593 splits,
594 pooled,
595 prefix,
596 );
597 }
598 (finished, frame)
599 }
600 match (placement, command) {
601 (DrawPlacement::Behind, DrawCommand::Behind(func)) => {
602 let (finished, frame) = record_into(func, size, recording, storage, replay, command_id);
603 let frame = (finished.content_markers == 0).then_some(frame).flatten();
604 (
605 filter_content(finished.primitives, finished.content_markers),
606 finished.recording,
607 frame,
608 )
609 }
610 (DrawPlacement::Overlay, DrawCommand::Overlay(func)) => {
611 let (finished, frame) = record_into(func, size, recording, storage, replay, command_id);
612 let frame = (finished.content_markers == 0).then_some(frame).flatten();
613 (
614 filter_content(finished.primitives, finished.content_markers),
615 finished.recording,
616 frame,
617 )
618 }
619 (_, DrawCommand::WithContent(func)) => {
620 let (finished, _) = record_into(func, size, recording, storage, replay, None);
623 (
624 split_with_content(finished.primitives, placement, finished.content_markers),
625 finished.recording,
626 None,
627 )
628 }
629 _ => (Vec::new(), recording, None),
630 }
631}
632
633#[cfg(test)]
634mod tests {
635 use super::*;
636 use cranpose_ui_graphics::{DrawScope, DrawScopeDefault, Rect};
637
638 fn recorded_command(record: impl Fn(&mut dyn DrawScope) + 'static) -> DrawCommandFn {
639 Rc::new(move |scope: &mut DrawScopeDefault| record(scope))
640 }
641
642 fn rect_at(x: f32) -> Rect {
643 Rect {
644 x,
645 y: 0.0,
646 width: 1.0,
647 height: 1.0,
648 }
649 }
650
651 fn rect_xs(primitives: &[DrawPrimitive]) -> Vec<f32> {
652 primitives
653 .iter()
654 .map(|primitive| match primitive {
655 DrawPrimitive::Rect { rect, .. } => rect.x,
656 other => panic!("unexpected primitive {other:?}"),
657 })
658 .collect()
659 }
660
661 #[test]
662 fn marker_free_recording_passes_through() {
663 let command = DrawCommand::Behind(recorded_command(|scope| {
664 scope.draw_rect_at(rect_at(1.0), Brush::solid(Color::WHITE));
665 scope.draw_rect_at(rect_at(2.0), Brush::solid(Color::WHITE));
666 }));
667 let out = primitives_for_placement(&command, DrawPlacement::Behind, Size::new(10.0, 10.0));
668 assert_eq!(rect_xs(&out), [1.0, 2.0]);
669 }
670
671 #[test]
672 fn recorded_markers_still_split_content_placements() {
673 let with_content = recorded_command(|scope| {
674 scope.draw_rect_at(rect_at(1.0), Brush::solid(Color::WHITE));
675 scope.draw_content();
676 scope.draw_rect_at(rect_at(2.0), Brush::solid(Color::WHITE));
677 });
678 let command = DrawCommand::WithContent(with_content);
679 let size = Size::new(10.0, 10.0);
680 let behind = primitives_for_placement(&command, DrawPlacement::Behind, size);
681 assert_eq!(rect_xs(&behind), [1.0]);
682 let overlay = primitives_for_placement(&command, DrawPlacement::Overlay, size);
683 assert_eq!(rect_xs(&overlay), [2.0]);
684 }
685
686 #[test]
690 fn reused_storage_records_identically_to_fresh() {
691 let command = DrawCommand::WithContent(recorded_command(|scope| {
692 scope.draw_rect_at(rect_at(1.0), Brush::solid(Color::WHITE));
693 scope.draw_content();
694 scope.draw_rect_at(rect_at(2.0), Brush::solid(Color::WHITE));
695 }));
696 let size = Size::new(10.0, 10.0);
697 for placement in [DrawPlacement::Behind, DrawPlacement::Overlay] {
698 let fresh = primitives_for_placement(&command, placement, size);
699 let dirty = vec![DrawPrimitive::Content; 8];
701 let reused = primitives_for_placement_reusing(&command, placement, size, dirty);
702 assert_eq!(fresh, reused);
703 }
704 }
705
706 #[test]
707 fn pushed_batches_keep_marker_count_authoritative() {
708 let command = DrawCommand::Behind(Rc::new(|scope: &mut DrawScopeDefault| {
711 scope.push_recorded(vec![
712 DrawPrimitive::Rect {
713 rect: rect_at(1.0),
714 brush: Brush::solid(Color::WHITE),
715 stroke: None,
716 },
717 DrawPrimitive::Content,
718 DrawPrimitive::Rect {
719 rect: rect_at(2.0),
720 brush: Brush::solid(Color::WHITE),
721 stroke: None,
722 },
723 ]);
724 }));
725 let out = primitives_for_placement(&command, DrawPlacement::Behind, Size::new(10.0, 10.0));
726 assert_eq!(rect_xs(&out), [1.0, 2.0]);
727 }
728}