Skip to main content

cranpose_render_common/
style_shared.rs

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
83/// The colour a primitive is actually painted in, once this layer has had its
84/// say.
85///
86/// The colour is snapped to eight bits *first*, because that is where the
87/// platform's own colour type already is by the time anything paints with it
88/// (see [`Color::srgb_8bit`]). Only then does the layer's alpha multiply it.
89/// The order is the whole point: an isolated layer's contents land in an 8-bit
90/// buffer and the alpha multiplies whole channel values, so
91/// `round(round(c * 255) * a)` and not `round(c * 255 * a)`.
92pub 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/// A layer-resolved brush, split at the solid/gradient boundary.
241///
242/// The per-frame shape emit produces one of these for every draw: the solid
243/// case — effectively all of a heavy animated scene — carries its color
244/// inline, so emitting a shape neither clones a `Brush` nor leaves an enum
245/// with heap-carrying variants for frame teardown to walk. Only the rare
246/// gradient still travels as a cloned [`Brush`].
247#[derive(Clone, Debug, PartialEq)]
248pub enum ResolvedBrush {
249    Solid(Color),
250    /// A non-solid brush (gradients), already layer-resolved.
251    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    /// The plain `Brush` this resolved form stands for — same values,
263    /// reassembled for consumers that keep speaking `Brush`.
264    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
272/// [`apply_layer_to_brush`] without the solid-brush clone: the borrowed
273/// brush's color is copied (or layer-adjusted) inline, and only gradients
274/// are cloned. Produces exactly the values `apply_layer_to_brush` would —
275/// both branches below mirror its fast path and its `Solid` arm verbatim.
276pub 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
316/// [`primitives_for_placement`] recording into `storage` the caller retains
317/// between frames. A command that re-records every frame keeps one buffer
318/// whose capacity was earned on earlier frames; only the rare marker-bearing
319/// recording pays for a second output vector.
320pub 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
336/// The full retained form: the compact recording buffers come from and
337/// return to the caller alongside the materialized primitives, so a command
338/// re-recording every frame allocates nothing in the steady state.
339pub 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
359/// The replay half of [`primitives_for_placement_verified`]'s contract: the
360/// command's verification state plus the stale-transition seam. The caller
361/// (the scene builder, which owns the per-command registry) says whether a
362/// valid previous-frame emission is on hand; verification answers whether
363/// this frame collapsed out of its capture and was therefore NOT emitted —
364/// nothing materialized, the caller must re-emit its saved emission.
365pub struct CommandReplayContext<'a> {
366    pub state: &'a mut cranpose_ui_graphics::CommandReplayState,
367    /// In: the previous build emitted a replay frame the caller saved,
368    /// still valid under the current recording generation and retained
369    /// feed epoch, and the stale-transition flag is on.
370    pub stale_available: bool,
371    /// Out: verification collapsed out of an established capture while
372    /// `stale_available` held, so the recording was finished via
373    /// [`cranpose_ui_graphics::DrawScopeDefault::finish_recording_only`]
374    /// — no primitives, no frame — and the caller re-emits its saved
375    /// emission in place of this frame's.
376    pub serve_stale: bool,
377}
378
379/// [`primitives_for_placement_retained`] with per-command similarity
380/// verification: when `replay` carries the command's state, the freshly
381/// recorded compact form advances it BEFORE materialization, yielding the
382/// frame's retained/dynamic span structure in primitive space. Rendering
383/// still materializes everything — consuming the spans (and skipping
384/// materialization for retained ones) is the renderer-side half of the
385/// retention work. The frame is only returned for marker-free recordings:
386/// content splitting reindexes the primitive vector, and no game-scale
387/// command records content markers.
388pub 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}