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 is NOT inherited — it applies only to this layer's subtree
68            render_effect: layer.render_effect,
69            // backdrop_effect is NOT inherited — it applies only to this node's backdrop.
70            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 {
86    apply_color_filter_to_color(
87        Color(
88            color.0,
89            color.1,
90            color.2,
91            (color.3 * layer.alpha).clamp(0.0, 1.0),
92        ),
93        layer.color_filter,
94    )
95}
96
97fn apply_color_filter_to_color(color: Color, filter: Option<ColorFilter>) -> Color {
98    match filter {
99        Some(filter) => {
100            let [r, g, b, a] = filter.apply_rgba([color.0, color.1, color.2, color.3]);
101            Color(r, g, b, a)
102        }
103        None => color,
104    }
105}
106
107pub fn compose_color_filters(
108    base: Option<ColorFilter>,
109    overlay: Option<ColorFilter>,
110) -> Option<ColorFilter> {
111    match (base, overlay) {
112        (None, None) => None,
113        (Some(filter), None) | (None, Some(filter)) => Some(filter),
114        (Some(filter), Some(next)) => Some(filter.compose(next)),
115    }
116}
117
118pub fn apply_layer_to_brush(brush: Brush, layer: &GraphicsLayer) -> Brush {
119    // The overwhelmingly common case — full-alpha layer, no filter, unit
120    // scale — leaves every brush untouched; a scene of thousands of shape
121    // draws per frame should not rebuild its colors to discover that.
122    if layer.alpha == 1.0
123        && layer.color_filter.is_none()
124        && layer_scale_x(layer) == 1.0
125        && layer_scale_y(layer) == 1.0
126    {
127        return brush;
128    }
129    let scale_x = layer_scale_x(layer);
130    let scale_y = layer_scale_y(layer);
131    let uniform_scale = layer_uniform_scale(layer);
132
133    match brush {
134        Brush::Solid(color) => Brush::solid(apply_layer_to_color(color, layer)),
135        Brush::LinearGradient {
136            colors,
137            stops,
138            mut start,
139            mut end,
140            tile_mode,
141        } => {
142            start.x *= scale_x;
143            start.y *= scale_y;
144            end.x *= scale_x;
145            end.y *= scale_y;
146            Brush::LinearGradient {
147                colors: colors
148                    .into_iter()
149                    .map(|c| apply_layer_to_color(c, layer))
150                    .collect(),
151                stops,
152                start,
153                end,
154                tile_mode,
155            }
156        }
157        Brush::RadialGradient {
158            colors,
159            stops,
160            mut center,
161            mut radius,
162            tile_mode,
163        } => {
164            center.x *= scale_x;
165            center.y *= scale_y;
166            radius *= uniform_scale;
167            Brush::RadialGradient {
168                colors: colors
169                    .into_iter()
170                    .map(|c| apply_layer_to_color(c, layer))
171                    .collect(),
172                stops,
173                center,
174                radius,
175                tile_mode,
176            }
177        }
178        Brush::SweepGradient {
179            colors,
180            stops,
181            mut center,
182        } => {
183            center.x *= scale_x;
184            center.y *= scale_y;
185            Brush::SweepGradient {
186                colors: colors
187                    .into_iter()
188                    .map(|c| apply_layer_to_color(c, layer))
189                    .collect(),
190                stops,
191                center,
192            }
193        }
194    }
195}
196
197pub fn scale_corner_radii(radii: CornerRadii, scale: f32) -> CornerRadii {
198    CornerRadii {
199        top_left: radii.top_left * scale,
200        top_right: radii.top_right * scale,
201        bottom_right: radii.bottom_right * scale,
202        bottom_left: radii.bottom_left * scale,
203    }
204}
205
206#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
207pub enum DrawPlacement {
208    Behind,
209    Overlay,
210}
211
212pub fn primitives_for_placement(
213    command: &DrawCommand,
214    placement: DrawPlacement,
215    size: Size,
216) -> Vec<DrawPrimitive> {
217    primitives_for_placement_reusing(command, placement, size, Vec::new())
218}
219
220/// [`primitives_for_placement`] recording into `storage` the caller retains
221/// between frames. A command that re-records every frame keeps one buffer
222/// whose capacity was earned on earlier frames; only the rare marker-bearing
223/// recording pays for a second output vector.
224pub fn primitives_for_placement_reusing(
225    command: &DrawCommand,
226    placement: DrawPlacement,
227    size: Size,
228    storage: Vec<DrawPrimitive>,
229) -> Vec<DrawPrimitive> {
230    primitives_for_placement_retained(
231        command,
232        placement,
233        size,
234        CommandRecording::default(),
235        storage,
236    )
237    .0
238}
239
240/// The full retained form: the compact recording buffers come from and
241/// return to the caller alongside the materialized primitives, so a command
242/// re-recording every frame allocates nothing in the steady state.
243pub fn primitives_for_placement_retained(
244    command: &DrawCommand,
245    placement: DrawPlacement,
246    size: Size,
247    recording: CommandRecording,
248    storage: Vec<DrawPrimitive>,
249) -> (Vec<DrawPrimitive>, CommandRecording) {
250    let mut no_replay = None;
251    let (primitives, recording, _) = primitives_for_placement_verified(
252        command,
253        placement,
254        size,
255        recording,
256        storage,
257        &mut no_replay,
258        None,
259    );
260    (primitives, recording)
261}
262
263/// [`primitives_for_placement_retained`] with per-command similarity
264/// verification: when `replay` carries the command's state, the freshly
265/// recorded compact form advances it BEFORE materialization, yielding the
266/// frame's retained/dynamic span structure in primitive space. Rendering
267/// still materializes everything — consuming the spans (and skipping
268/// materialization for retained ones) is the renderer-side half of the
269/// retention work. The frame is only returned for marker-free recordings:
270/// content splitting reindexes the primitive vector, and no game-scale
271/// command records content markers.
272pub fn primitives_for_placement_verified(
273    command: &DrawCommand,
274    placement: DrawPlacement,
275    size: Size,
276    recording: CommandRecording,
277    storage: Vec<DrawPrimitive>,
278    replay: &mut Option<&mut cranpose_ui_graphics::CommandReplayState>,
279    command_id: Option<crate::graph::DrawCommandId>,
280) -> (
281    Vec<DrawPrimitive>,
282    CommandRecording,
283    Option<cranpose_ui_graphics::CommandReplayFrame>,
284) {
285    // `markers` is the recording scope's own count of `Content` markers,
286    // maintained while the command records (`draw_content()` calls and pushed
287    // batches both keep it current), so it is authoritative: zero means the
288    // vector holds no marker and passes through untouched. On a watch-class
289    // core, re-streaming a fresh multi-thousand-primitive recording just to
290    // learn "no markers" is measurable frame time.
291    let filter_content = |primitives: Vec<DrawPrimitive>, markers: u32| {
292        if markers == 0 {
293            return primitives;
294        }
295        // `filter(...).collect()` reports a zero lower-bound size hint, so it
296        // grows the output through the whole doubling schedule; for an
297        // animated scene these vectors hold thousands of primitives and are
298        // rebuilt every frame. `Content` markers are rare, so the input
299        // length is the right capacity.
300        let mut out = Vec::with_capacity(primitives.len());
301        out.extend(
302            primitives
303                .into_iter()
304                .filter(|primitive| !matches!(primitive, DrawPrimitive::Content)),
305        );
306        out
307    };
308
309    let split_with_content = |primitives: Vec<DrawPrimitive>, placement, markers: u32| {
310        let last_content_idx = if markers == 0 {
311            None
312        } else {
313            primitives
314                .iter()
315                .rposition(|primitive| matches!(primitive, DrawPrimitive::Content))
316        };
317        let Some(last_content_idx) = last_content_idx else {
318            return if matches!(placement, DrawPlacement::Overlay) {
319                filter_content(primitives, markers)
320            } else {
321                Vec::new()
322            };
323        };
324
325        let mut out = Vec::with_capacity(primitives.len());
326        out.extend(
327            primitives
328                .into_iter()
329                .enumerate()
330                .filter_map(|(index, primitive)| {
331                    if matches!(primitive, DrawPrimitive::Content) {
332                        return None;
333                    }
334                    let is_before = index < last_content_idx;
335                    match placement {
336                        DrawPlacement::Behind if is_before => Some(primitive),
337                        DrawPlacement::Overlay if !is_before => Some(primitive),
338                        _ => None,
339                    }
340                }),
341        );
342        out
343    };
344
345    // The command records into a scope this consumer owns, so the marker
346    // count travels with the recording instead of through a side channel.
347    fn record_into(
348        func: &DrawCommandFn,
349        size: Size,
350        recording: CommandRecording,
351        storage: Vec<DrawPrimitive>,
352        replay: &mut Option<&mut cranpose_ui_graphics::CommandReplayState>,
353        command: Option<crate::graph::DrawCommandId>,
354    ) -> (
355        FinishedRecording,
356        Option<cranpose_ui_graphics::CommandReplayFrame>,
357    ) {
358        let mut scope = cranpose_ui::command_draw_scope_retained(size, recording, storage);
359        func(&mut scope);
360        let Some(state) = replay.as_mut() else {
361            return (scope.finish(), None);
362        };
363        let outcome =
364            state.advance_pooled(scope.recorded(), crate::scene_builder::verify_executor());
365        // Span counts are taken before `finish_replay` consumes the outcome
366        // (recolor patch lists move into the frame instead of being cloned
367        // every frame).
368        let diag = if cranpose_core::env_flag!("CRANPOSE_COMMAND_REPLAY_DIAG") {
369            if let cranpose_ui_graphics::ReplayOutcome::Spans(spans) = &outcome {
370                let (mut retained, mut dynamic) = (0usize, 0usize);
371                for span in spans {
372                    match span {
373                        cranpose_ui_graphics::ReplaySpan::Retained { .. } => retained += 1,
374                        cranpose_ui_graphics::ReplaySpan::Dynamic {
375                            tape_start,
376                            tape_end,
377                        } => dynamic += tape_end - tape_start,
378                    }
379                }
380                Some((
381                    scope.recorded().len(),
382                    retained,
383                    dynamic,
384                    state.segments().len(),
385                    state.stats(),
386                ))
387            } else {
388                None
389            }
390        } else {
391            None
392        };
393        let center = state.center();
394        // Only spans whose retained buffer the renderer has confirmed may
395        // skip materialization: everything else must exist as primitives
396        // for this frame's ordinary path. A marker-bearing recording drops
397        // its frame after the split, so it must materialize whole.
398        let markers = scope.content_marker_count();
399        let mut bypass = |slot: u32| {
400            markers == 0
401                && command.is_some_and(|id| crate::scene_builder::retained_slot_confirmed(id, slot))
402        };
403        let (finished, frame) = scope.finish_replay(center, outcome, &mut bypass);
404        if let Some((records, retained, dynamic, segments, (deaths, splits))) = diag {
405            log::warn!(
406                "[command-replay] {} records: {} retained spans, {} dynamic records, \
407                 {} materialized; {} segments alive, lifetime deaths {} splits {}",
408                records,
409                retained,
410                dynamic,
411                finished.primitives.len(),
412                segments,
413                deaths,
414                splits,
415            );
416        }
417        (finished, frame)
418    }
419    match (placement, command) {
420        (DrawPlacement::Behind, DrawCommand::Behind(func)) => {
421            let (finished, frame) = record_into(func, size, recording, storage, replay, command_id);
422            let frame = (finished.content_markers == 0).then_some(frame).flatten();
423            (
424                filter_content(finished.primitives, finished.content_markers),
425                finished.recording,
426                frame,
427            )
428        }
429        (DrawPlacement::Overlay, DrawCommand::Overlay(func)) => {
430            let (finished, frame) = record_into(func, size, recording, storage, replay, command_id);
431            let frame = (finished.content_markers == 0).then_some(frame).flatten();
432            (
433                filter_content(finished.primitives, finished.content_markers),
434                finished.recording,
435                frame,
436            )
437        }
438        (_, DrawCommand::WithContent(func)) => {
439            // Content splitting reindexes the vector; the frame's ranges
440            // would not survive it. No id means no bypass either.
441            let (finished, _) = record_into(func, size, recording, storage, replay, None);
442            (
443                split_with_content(finished.primitives, placement, finished.content_markers),
444                finished.recording,
445                None,
446            )
447        }
448        _ => (Vec::new(), recording, None),
449    }
450}
451
452#[cfg(test)]
453mod tests {
454    use super::*;
455    use cranpose_ui_graphics::{DrawScope, DrawScopeDefault, Rect};
456
457    fn recorded_command(record: impl Fn(&mut dyn DrawScope) + 'static) -> DrawCommandFn {
458        Rc::new(move |scope: &mut DrawScopeDefault| record(scope))
459    }
460
461    fn rect_at(x: f32) -> Rect {
462        Rect {
463            x,
464            y: 0.0,
465            width: 1.0,
466            height: 1.0,
467        }
468    }
469
470    fn rect_xs(primitives: &[DrawPrimitive]) -> Vec<f32> {
471        primitives
472            .iter()
473            .map(|primitive| match primitive {
474                DrawPrimitive::Rect { rect, .. } => rect.x,
475                other => panic!("unexpected primitive {other:?}"),
476            })
477            .collect()
478    }
479
480    #[test]
481    fn marker_free_recording_passes_through() {
482        let command = DrawCommand::Behind(recorded_command(|scope| {
483            scope.draw_rect_at(rect_at(1.0), Brush::solid(Color::WHITE));
484            scope.draw_rect_at(rect_at(2.0), Brush::solid(Color::WHITE));
485        }));
486        let out = primitives_for_placement(&command, DrawPlacement::Behind, Size::new(10.0, 10.0));
487        assert_eq!(rect_xs(&out), [1.0, 2.0]);
488    }
489
490    #[test]
491    fn recorded_markers_still_split_content_placements() {
492        let with_content = recorded_command(|scope| {
493            scope.draw_rect_at(rect_at(1.0), Brush::solid(Color::WHITE));
494            scope.draw_content();
495            scope.draw_rect_at(rect_at(2.0), Brush::solid(Color::WHITE));
496        });
497        let command = DrawCommand::WithContent(with_content);
498        let size = Size::new(10.0, 10.0);
499        let behind = primitives_for_placement(&command, DrawPlacement::Behind, size);
500        assert_eq!(rect_xs(&behind), [1.0]);
501        let overlay = primitives_for_placement(&command, DrawPlacement::Overlay, size);
502        assert_eq!(rect_xs(&overlay), [2.0]);
503    }
504
505    /// Recording into a previously used buffer must be indistinguishable
506    /// from recording into a fresh one — for every placement, including the
507    /// marker-splitting paths.
508    #[test]
509    fn reused_storage_records_identically_to_fresh() {
510        let command = DrawCommand::WithContent(recorded_command(|scope| {
511            scope.draw_rect_at(rect_at(1.0), Brush::solid(Color::WHITE));
512            scope.draw_content();
513            scope.draw_rect_at(rect_at(2.0), Brush::solid(Color::WHITE));
514        }));
515        let size = Size::new(10.0, 10.0);
516        for placement in [DrawPlacement::Behind, DrawPlacement::Overlay] {
517            let fresh = primitives_for_placement(&command, placement, size);
518            // Junk in the reused buffer must not leak into the recording.
519            let dirty = vec![DrawPrimitive::Content; 8];
520            let reused = primitives_for_placement_reusing(&command, placement, size, dirty);
521            assert_eq!(fresh, reused);
522        }
523    }
524
525    #[test]
526    fn pushed_batches_keep_marker_count_authoritative() {
527        // A pre-built vector enters through `push_recorded`, which counts the
528        // `Content` marker it carries; the filter path must then strip it.
529        let command = DrawCommand::Behind(Rc::new(|scope: &mut DrawScopeDefault| {
530            scope.push_recorded(vec![
531                DrawPrimitive::Rect {
532                    rect: rect_at(1.0),
533                    brush: Brush::solid(Color::WHITE),
534                    stroke: None,
535                },
536                DrawPrimitive::Content,
537                DrawPrimitive::Rect {
538                    rect: rect_at(2.0),
539                    brush: Brush::solid(Color::WHITE),
540                    stroke: None,
541                },
542            ]);
543        }));
544        let out = primitives_for_placement(&command, DrawPlacement::Behind, Size::new(10.0, 10.0));
545        assert_eq!(rect_xs(&out), [1.0, 2.0]);
546    }
547}