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,
68 backdrop_effect: layer.backdrop_effect,
69 }
70 } else {
71 GraphicsLayer {
72 compositing_strategy: CompositingStrategy::Auto,
73 blend_mode: BlendMode::SrcOver,
74 render_effect: None,
75 backdrop_effect: None,
76 ..current
77 }
78 }
79}
80
81pub use crate::graph::quad_bounds;
82
83pub fn apply_layer_to_color(color: Color, layer: &GraphicsLayer) -> Color {
93 let color = color.srgb_8bit();
94 apply_color_filter_to_color(
95 Color(
96 color.0,
97 color.1,
98 color.2,
99 (color.3 * layer.alpha).clamp(0.0, 1.0),
100 ),
101 layer.color_filter,
102 )
103}
104
105fn apply_color_filter_to_color(color: Color, filter: Option<ColorFilter>) -> Color {
106 match filter {
107 Some(filter) => {
108 let [r, g, b, a] = filter.apply_rgba([color.0, color.1, color.2, color.3]);
109 Color(r, g, b, a)
110 }
111 None => color,
112 }
113}
114
115pub fn compose_color_filters(
116 base: Option<ColorFilter>,
117 overlay: Option<ColorFilter>,
118) -> Option<ColorFilter> {
119 match (base, overlay) {
120 (None, None) => None,
121 (Some(filter), None) | (None, Some(filter)) => Some(filter),
122 (Some(filter), Some(next)) => Some(filter.compose(next)),
123 }
124}
125
126pub fn apply_layer_to_brush(brush: Brush, layer: &GraphicsLayer) -> Brush {
127 if layer.alpha == 1.0
128 && layer.color_filter.is_none()
129 && layer_scale_x(layer) == 1.0
130 && layer_scale_y(layer) == 1.0
131 {
132 return map_brush_colors(brush, Color::srgb_8bit);
133 }
134 map_brush_colors(scale_brush_geometry(brush, layer), |color| {
135 apply_layer_to_color(color, layer)
136 })
137}
138
139fn map_brush_colors(brush: Brush, paint: impl Fn(Color) -> Color) -> Brush {
140 match brush {
141 Brush::Solid(color) => Brush::solid(paint(color)),
142 Brush::LinearGradient {
143 colors,
144 stops,
145 start,
146 end,
147 tile_mode,
148 } => Brush::LinearGradient {
149 colors: colors.into_iter().map(paint).collect(),
150 stops,
151 start,
152 end,
153 tile_mode,
154 },
155 Brush::RadialGradient {
156 colors,
157 stops,
158 center,
159 radius,
160 tile_mode,
161 } => Brush::RadialGradient {
162 colors: colors.into_iter().map(paint).collect(),
163 stops,
164 center,
165 radius,
166 tile_mode,
167 },
168 Brush::SweepGradient {
169 colors,
170 stops,
171 center,
172 } => Brush::SweepGradient {
173 colors: colors.into_iter().map(paint).collect(),
174 stops,
175 center,
176 },
177 }
178}
179
180fn scale_brush_geometry(brush: Brush, layer: &GraphicsLayer) -> Brush {
181 let scale_x = layer_scale_x(layer);
182 let scale_y = layer_scale_y(layer);
183 let uniform_scale = layer_uniform_scale(layer);
184
185 match brush {
186 Brush::Solid(color) => Brush::Solid(color),
187 Brush::LinearGradient {
188 colors,
189 stops,
190 mut start,
191 mut end,
192 tile_mode,
193 } => {
194 start.x *= scale_x;
195 start.y *= scale_y;
196 end.x *= scale_x;
197 end.y *= scale_y;
198 Brush::LinearGradient {
199 colors,
200 stops,
201 start,
202 end,
203 tile_mode,
204 }
205 }
206 Brush::RadialGradient {
207 colors,
208 stops,
209 mut center,
210 mut radius,
211 tile_mode,
212 } => {
213 center.x *= scale_x;
214 center.y *= scale_y;
215 radius *= uniform_scale;
216 Brush::RadialGradient {
217 colors,
218 stops,
219 center,
220 radius,
221 tile_mode,
222 }
223 }
224 Brush::SweepGradient {
225 colors,
226 stops,
227 mut center,
228 } => {
229 center.x *= scale_x;
230 center.y *= scale_y;
231 Brush::SweepGradient {
232 colors,
233 stops,
234 center,
235 }
236 }
237 }
238}
239
240#[derive(Clone, Debug, PartialEq)]
248pub enum ResolvedBrush {
249 Solid(Color),
250 Other(Brush),
252}
253
254impl ResolvedBrush {
255 pub fn from_brush(brush: Brush) -> Self {
256 match brush {
257 Brush::Solid(color) => Self::Solid(color),
258 other => Self::Other(other),
259 }
260 }
261
262 pub fn into_brush(self) -> Brush {
265 match self {
266 Self::Solid(color) => Brush::Solid(color),
267 Self::Other(brush) => brush,
268 }
269 }
270}
271
272pub fn resolve_layer_brush(brush: &Brush, layer: &GraphicsLayer) -> ResolvedBrush {
277 match brush {
278 Brush::Solid(color) => {
279 if layer.alpha == 1.0
280 && layer.color_filter.is_none()
281 && layer_scale_x(layer) == 1.0
282 && layer_scale_y(layer) == 1.0
283 {
284 ResolvedBrush::Solid(*color)
285 } else {
286 ResolvedBrush::Solid(apply_layer_to_color(*color, layer))
287 }
288 }
289 other => ResolvedBrush::Other(apply_layer_to_brush(other.clone(), layer)),
290 }
291}
292
293pub fn scale_corner_radii(radii: CornerRadii, scale: f32) -> CornerRadii {
294 CornerRadii {
295 top_left: radii.top_left * scale,
296 top_right: radii.top_right * scale,
297 bottom_right: radii.bottom_right * scale,
298 bottom_left: radii.bottom_left * scale,
299 }
300}
301
302#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
303pub enum DrawPlacement {
304 Behind,
305 Overlay,
306}
307
308pub fn primitives_for_placement(
309 command: &DrawCommand,
310 placement: DrawPlacement,
311 size: Size,
312) -> Vec<DrawPrimitive> {
313 primitives_for_placement_reusing(command, placement, size, Vec::new())
314}
315
316pub fn primitives_for_placement_reusing(
321 command: &DrawCommand,
322 placement: DrawPlacement,
323 size: Size,
324 storage: Vec<DrawPrimitive>,
325) -> Vec<DrawPrimitive> {
326 primitives_for_placement_retained(
327 command,
328 placement,
329 size,
330 CommandRecording::default(),
331 storage,
332 )
333 .0
334}
335
336pub fn primitives_for_placement_retained(
340 command: &DrawCommand,
341 placement: DrawPlacement,
342 size: Size,
343 recording: CommandRecording,
344 storage: Vec<DrawPrimitive>,
345) -> (Vec<DrawPrimitive>, CommandRecording) {
346 let mut no_replay = None;
347 let (primitives, recording, _) = primitives_for_placement_verified(
348 command,
349 placement,
350 size,
351 recording,
352 storage,
353 &mut no_replay,
354 None,
355 );
356 (primitives, recording)
357}
358
359pub struct CommandReplayContext<'a> {
366 pub state: &'a mut cranpose_ui_graphics::CommandReplayState,
367 pub stale_available: bool,
371 pub serve_stale: bool,
377}
378
379pub fn primitives_for_placement_verified(
389 command: &DrawCommand,
390 placement: DrawPlacement,
391 size: Size,
392 recording: CommandRecording,
393 storage: Vec<DrawPrimitive>,
394 replay: &mut Option<CommandReplayContext<'_>>,
395 command_id: Option<crate::graph::DrawCommandId>,
396) -> (
397 Vec<DrawPrimitive>,
398 CommandRecording,
399 Option<cranpose_ui_graphics::CommandReplayFrame>,
400) {
401 let filter_content = |primitives: Vec<DrawPrimitive>, markers: u32| {
402 if markers == 0 {
403 return primitives;
404 }
405 let mut out = Vec::with_capacity(primitives.len());
406 out.extend(
407 primitives
408 .into_iter()
409 .filter(|primitive| !matches!(primitive, DrawPrimitive::Content)),
410 );
411 out
412 };
413
414 let split_with_content = |primitives: Vec<DrawPrimitive>, placement, markers: u32| {
415 let last_content_idx = if markers == 0 {
416 None
417 } else {
418 primitives
419 .iter()
420 .rposition(|primitive| matches!(primitive, DrawPrimitive::Content))
421 };
422 let Some(last_content_idx) = last_content_idx else {
423 return if matches!(placement, DrawPlacement::Overlay) {
424 filter_content(primitives, markers)
425 } else {
426 Vec::new()
427 };
428 };
429
430 let mut out = Vec::with_capacity(primitives.len());
431 out.extend(
432 primitives
433 .into_iter()
434 .enumerate()
435 .filter_map(|(index, primitive)| {
436 if matches!(primitive, DrawPrimitive::Content) {
437 return None;
438 }
439 let is_before = index < last_content_idx;
440 match placement {
441 DrawPlacement::Behind if is_before => Some(primitive),
442 DrawPlacement::Overlay if !is_before => Some(primitive),
443 _ => None,
444 }
445 }),
446 );
447 out
448 };
449
450 fn record_into(
451 func: &DrawCommandFn,
452 size: Size,
453 recording: CommandRecording,
454 storage: Vec<DrawPrimitive>,
455 replay: &mut Option<CommandReplayContext<'_>>,
456 command: Option<crate::graph::DrawCommandId>,
457 ) -> (
458 FinishedRecording,
459 Option<cranpose_ui_graphics::CommandReplayFrame>,
460 ) {
461 let mut scope = cranpose_ui::command_draw_scope_retained(size, recording, storage);
462 func(&mut scope);
463 let Some(ctx) = replay.as_mut() else {
464 return (scope.finish(), None);
465 };
466 let state = &mut *ctx.state;
467 let outcome =
468 state.advance_pooled(scope.recorded(), crate::scene_builder::verify_executor());
469 let diag = if cranpose_core::env_flag!("CRANPOSE_COMMAND_REPLAY_DIAG") {
470 if let cranpose_ui_graphics::ReplayOutcome::Spans(spans) = &outcome {
471 let (mut retained, mut dynamic) = (0usize, 0usize);
472 for span in spans {
473 match span {
474 cranpose_ui_graphics::ReplaySpan::Retained { .. } => retained += 1,
475 cranpose_ui_graphics::ReplaySpan::Dynamic {
476 tape_start,
477 tape_end,
478 } => dynamic += tape_end - tape_start,
479 }
480 }
481 Some((
482 scope.recorded().len(),
483 retained,
484 dynamic,
485 state.segments().len(),
486 state.stats(),
487 state.optimistic_commits(),
488 state.prefix_commits(),
489 ))
490 } else {
491 None
492 }
493 } else {
494 None
495 };
496 if !state.segments().is_empty() {
497 thread_local! {
498 static VERIFIED_FRAMES: std::cell::Cell<u64> = const { std::cell::Cell::new(0) };
499 }
500 let frames = VERIFIED_FRAMES.with(|cell| {
501 let next = cell.get().wrapping_add(1);
502 cell.set(next);
503 next
504 });
505 if frames.is_multiple_of(256) {
506 let (deaths, splits) = state.stats();
507 log::warn!(
508 "[command-replay] health: {} segments, pooled commits {} + prefix {}, \
509 lifetime deaths {} splits {}",
510 state.segments().len(),
511 state.optimistic_commits(),
512 state.prefix_commits(),
513 deaths,
514 splits,
515 );
516 }
517 }
518 let center = state.center();
519 let markers = scope.content_marker_count();
520 if ctx.stale_available && markers == 0 && state.collapsed_from_captured() {
521 ctx.serve_stale = true;
522 if cranpose_core::env_flag!("CRANPOSE_COMMAND_REPLAY_DIAG") {
523 log::warn!(
524 "[command-replay] stale transition: collapse frame of {} records \
525 re-serves the previous emission",
526 scope.recorded().len(),
527 );
528 }
529 return (scope.finish_recording_only(), None);
530 }
531 let mut bypass = |slot: u32| {
532 markers == 0
533 && command.is_some_and(|id| crate::scene_builder::retained_slot_confirmed(id, slot))
534 };
535 let (finished, frame) = scope.finish_replay(center, outcome, &mut bypass);
536 if let Some((records, retained, dynamic, segments, (deaths, splits), pooled, prefix)) = diag
537 {
538 log::warn!(
539 "[command-replay] {} records: {} retained spans, {} dynamic records, \
540 {} materialized; {} segments alive, lifetime deaths {} splits {}, \
541 pooled commits {} + prefix {}",
542 records,
543 retained,
544 dynamic,
545 finished.primitives.len(),
546 segments,
547 deaths,
548 splits,
549 pooled,
550 prefix,
551 );
552 }
553 (finished, frame)
554 }
555 match (placement, command) {
556 (DrawPlacement::Behind, DrawCommand::Behind(func)) => {
557 let (finished, frame) = record_into(func, size, recording, storage, replay, command_id);
558 let frame = (finished.content_markers == 0).then_some(frame).flatten();
559 (
560 filter_content(finished.primitives, finished.content_markers),
561 finished.recording,
562 frame,
563 )
564 }
565 (DrawPlacement::Overlay, DrawCommand::Overlay(func)) => {
566 let (finished, frame) = record_into(func, size, recording, storage, replay, command_id);
567 let frame = (finished.content_markers == 0).then_some(frame).flatten();
568 (
569 filter_content(finished.primitives, finished.content_markers),
570 finished.recording,
571 frame,
572 )
573 }
574 (_, DrawCommand::WithContent(func)) => {
575 let (finished, _) = record_into(func, size, recording, storage, replay, None);
576 (
577 split_with_content(finished.primitives, placement, finished.content_markers),
578 finished.recording,
579 None,
580 )
581 }
582 _ => (Vec::new(), recording, None),
583 }
584}
585
586#[cfg(test)]
587mod tests {
588 use cranpose_ui_graphics::{DrawScope, DrawScopeDefault, Rect};
589
590 use super::*;
591
592 fn recorded_command(record: impl Fn(&mut dyn DrawScope) + 'static) -> DrawCommandFn {
593 Rc::new(move |scope: &mut DrawScopeDefault| record(scope))
594 }
595
596 fn rect_at(x: f32) -> Rect {
597 Rect {
598 x,
599 y: 0.0,
600 width: 1.0,
601 height: 1.0,
602 }
603 }
604
605 fn rect_xs(primitives: &[DrawPrimitive]) -> Vec<f32> {
606 primitives
607 .iter()
608 .map(|primitive| match primitive {
609 DrawPrimitive::Rect { rect, .. } => rect.x,
610 other => panic!("unexpected primitive {other:?}"),
611 })
612 .collect()
613 }
614
615 #[test]
616 fn marker_free_recording_passes_through() {
617 let command = DrawCommand::Behind(recorded_command(|scope| {
618 scope.draw_rect_at(rect_at(1.0), Brush::solid(Color::WHITE));
619 scope.draw_rect_at(rect_at(2.0), Brush::solid(Color::WHITE));
620 }));
621 let out = primitives_for_placement(&command, DrawPlacement::Behind, Size::new(10.0, 10.0));
622 assert_eq!(rect_xs(&out), [1.0, 2.0]);
623 }
624
625 #[test]
626 fn recorded_markers_still_split_content_placements() {
627 let with_content = recorded_command(|scope| {
628 scope.draw_rect_at(rect_at(1.0), Brush::solid(Color::WHITE));
629 scope.draw_content();
630 scope.draw_rect_at(rect_at(2.0), Brush::solid(Color::WHITE));
631 });
632 let command = DrawCommand::WithContent(with_content);
633 let size = Size::new(10.0, 10.0);
634 let behind = primitives_for_placement(&command, DrawPlacement::Behind, size);
635 assert_eq!(rect_xs(&behind), [1.0]);
636 let overlay = primitives_for_placement(&command, DrawPlacement::Overlay, size);
637 assert_eq!(rect_xs(&overlay), [2.0]);
638 }
639
640 #[test]
641 fn reused_storage_records_identically_to_fresh() {
642 let command = DrawCommand::WithContent(recorded_command(|scope| {
643 scope.draw_rect_at(rect_at(1.0), Brush::solid(Color::WHITE));
644 scope.draw_content();
645 scope.draw_rect_at(rect_at(2.0), Brush::solid(Color::WHITE));
646 }));
647 let size = Size::new(10.0, 10.0);
648 for placement in [DrawPlacement::Behind, DrawPlacement::Overlay] {
649 let fresh = primitives_for_placement(&command, placement, size);
650 let dirty = vec![DrawPrimitive::Content; 8];
651 let reused = primitives_for_placement_reusing(&command, placement, size, dirty);
652 assert_eq!(fresh, reused);
653 }
654 }
655
656 #[test]
657 fn pushed_batches_keep_marker_count_authoritative() {
658 let command = DrawCommand::Behind(Rc::new(|scope: &mut DrawScopeDefault| {
659 scope.push_recorded(vec![
660 DrawPrimitive::Rect {
661 rect: rect_at(1.0),
662 brush: Brush::solid(Color::WHITE),
663 stroke: None,
664 },
665 DrawPrimitive::Content,
666 DrawPrimitive::Rect {
667 rect: rect_at(2.0),
668 brush: Brush::solid(Color::WHITE),
669 stroke: None,
670 },
671 ]);
672 }));
673 let out = primitives_for_placement(&command, DrawPlacement::Behind, Size::new(10.0, 10.0));
674 assert_eq!(rect_xs(&out), [1.0, 2.0]);
675 }
676}