Skip to main content

cranpose_render_common/
primitive_emit.rs

1use std::rc::Rc;
2
3use cranpose_ui::text::{
4    AnnotatedString, TextLayoutOptions, TextOverflow, TextStyle, text_style_for_draw_style,
5};
6use cranpose_ui_graphics::{
7    ArcGeometry, BlendMode, Brush, Color, ColorFilter, CornerRadii, DrawPrimitive, GraphicsLayer,
8    ImageBitmap, ImageSampling, Point, Rect, RoundedCornerShape, ShadowPrimitive, Stroke,
9    TextPrimitive, arc_band, inflate_rect,
10};
11
12use crate::{
13    graph::quad_bounds,
14    layer_transform::{
15        apply_layer_affine_to_point, apply_layer_affine_to_rect, apply_layer_to_quad,
16        apply_layer_to_rect, layer_uniform_scale,
17    },
18    style_shared::{
19        ResolvedBrush, apply_layer_to_color, compose_color_filters, resolve_layer_brush,
20        scale_corner_radii,
21    },
22};
23
24#[derive(Clone, Copy, Debug, PartialEq, Eq)]
25pub enum PrimitiveClipSpace {
26    Local,
27    LayerTransformed,
28}
29
30pub struct ShapeDrawParams {
31    pub rect: Rect,
32    pub local_rect: Rect,
33    pub quad: [[f32; 2]; 4],
34    /// Layer-resolved at emit time. Solid — effectively every shape of a
35    /// heavy animated frame — is an inline color; only gradients carry a
36    /// cloned [`Brush`].
37    pub brush: ResolvedBrush,
38    pub shape: Option<RoundedCornerShape>,
39    /// `Some` means "stroke the outline of `local_rect`/`shape`" instead of
40    /// filling it. The width is already in `local_rect` units (the layer scale
41    /// has been applied), and `local_rect`/`quad` have already been inflated by
42    /// half that width so the outer half of the stroke has geometry to land on.
43    pub stroke: Option<Stroke>,
44    /// `Some` replaces the rect geometry entirely with a circular band. The
45    /// center and radii are in `local_rect` units.
46    pub arc: Option<ArcGeometry>,
47    pub clip: Option<Rect>,
48    pub blend_mode: BlendMode,
49    pub motion_context_animated: bool,
50}
51
52fn stroked_draw_rect(
53    local_rect: Rect,
54    stroke: Option<Stroke>,
55    layer_bounds: Rect,
56    layer: &GraphicsLayer,
57) -> Option<(Rect, Option<Stroke>)> {
58    let draw_rect = local_rect.translate(layer_bounds.x, layer_bounds.y);
59    let Some(stroke) = stroke else {
60        return Some((draw_rect, None));
61    };
62    if !stroke.is_visible() {
63        return None;
64    }
65    let outset = stroke.half_width();
66    Some((
67        inflate_rect(draw_rect, outset),
68        Some(stroke.scaled(layer_uniform_scale(layer))),
69    ))
70}
71
72pub struct ImageDrawParams {
73    pub rect: Rect,
74    pub local_rect: Rect,
75    pub quad: [[f32; 2]; 4],
76    pub image: ImageBitmap,
77    pub alpha: f32,
78    pub color_filter: Option<ColorFilter>,
79    pub sampling: ImageSampling,
80    pub clip: Option<Rect>,
81    pub src_rect: Option<Rect>,
82    pub blend_mode: BlendMode,
83    pub motion_context_animated: bool,
84}
85
86/// A `DrawScope` text run, lowered into the vocabulary the text pipeline
87/// already speaks.
88///
89/// The fields line up one-for-one with `CompositorScene::push_text` in both
90/// backends, so a text primitive joins the same glyph atlas, run cache and
91/// shader the `Text` composable uses instead of getting a pipeline of its own.
92pub struct TextDrawParams {
93    /// Layer-transformed block box. Glyphs are laid out from its top-left; the
94    /// draw scope already resolved alignment into it.
95    pub rect: Rect,
96    pub text: Rc<AnnotatedString>,
97    /// `text` as a renderer's draws carry it across threads.
98    pub render_text: std::sync::Arc<cranpose_ui::text::RenderString>,
99    pub color: Color,
100    pub text_style: TextStyle,
101    pub font_size: f32,
102    /// Uniform layer scale — the factor glyphs are rasterized at.
103    pub scale: f32,
104    pub layout_options: TextLayoutOptions,
105    pub clip: Option<Rect>,
106}
107
108pub trait DrawPrimitiveSink {
109    fn push_shape(&mut self, params: ShapeDrawParams);
110
111    fn push_image(&mut self, params: ImageDrawParams);
112
113    /// Emits a shadow without transferring ownership of its caster and cutout.
114    fn push_shadow(
115        &mut self,
116        shadow_primitive: &ShadowPrimitive,
117        layer_bounds: Rect,
118        layer: &GraphicsLayer,
119        clip: Option<Rect>,
120    );
121
122    /// Draws a text run. The default drops it, for sinks that only collect
123    /// geometry (shadow casters, hit testing) and backends with no text
124    /// pipeline.
125    fn push_text(&mut self, params: TextDrawParams) {
126        let _ = params;
127    }
128}
129
130/// Resolves a borrowed shape with the layer transform, paint, and clipping applied.
131pub fn draw_shape_params_for_primitive(
132    primitive: &DrawPrimitive,
133    layer_bounds: Rect,
134    layer: &GraphicsLayer,
135    clip: Option<Rect>,
136    blend_mode: BlendMode,
137) -> Option<ShapeDrawParams> {
138    struct SingleShapeSink {
139        shape: Option<ShapeDrawParams>,
140    }
141
142    impl DrawPrimitiveSink for SingleShapeSink {
143        fn push_shape(&mut self, params: ShapeDrawParams) {
144            if self.shape.is_none() {
145                self.shape = Some(params);
146            }
147        }
148
149        fn push_image(&mut self, _params: ImageDrawParams) {}
150
151        fn push_shadow(
152            &mut self,
153            _shadow_primitive: &ShadowPrimitive,
154            _layer_bounds: Rect,
155            _layer: &GraphicsLayer,
156            _clip: Option<Rect>,
157        ) {
158        }
159    }
160
161    let mut sink = SingleShapeSink { shape: None };
162    emit_draw_primitive(
163        primitive,
164        layer_bounds,
165        layer,
166        clip,
167        &mut sink,
168        Some(blend_mode),
169        false,
170    );
171    sink.shape
172}
173
174/// The clip a node paints within once its own clip meets the clips above
175/// it: `None` only when nothing clips at all. Two clips that do not overlap
176/// resolve to [`Rect::EMPTY`], never to `None` -- a list that has scrolled a
177/// control past its edge has clipped the control away, not set it free.
178pub fn resolve_clip(parent_clip: Option<Rect>, requested_clip: Option<Rect>) -> Option<Rect> {
179    match (parent_clip, requested_clip) {
180        (Some(parent), Some(current)) => Some(parent.intersect(current).unwrap_or(Rect::EMPTY)),
181        (Some(parent), None) => Some(parent),
182        (None, Some(current)) => Some(current),
183        (None, None) => None,
184    }
185}
186
187pub fn resolve_primitive_clip(
188    local_clip: Option<Rect>,
189    layer_bounds: Rect,
190    layer: &GraphicsLayer,
191    parent_clip: Option<Rect>,
192    clip_space: PrimitiveClipSpace,
193) -> Option<Rect> {
194    let Some(local_clip) = local_clip else {
195        return parent_clip;
196    };
197    let clip_rect = Rect {
198        x: layer_bounds.x + local_clip.x,
199        y: layer_bounds.y + local_clip.y,
200        width: local_clip.width,
201        height: local_clip.height,
202    };
203    let requested_clip = match clip_space {
204        PrimitiveClipSpace::Local => clip_rect,
205        PrimitiveClipSpace::LayerTransformed => apply_layer_to_rect(clip_rect, layer_bounds, layer),
206    };
207    resolve_clip(parent_clip, Some(requested_clip))
208}
209
210/// The [`DrawPrimitive::Rect`] arm of [`emit_draw_primitive`] as a pure
211/// builder over borrowed fields. The parallel shape-run collect calls these
212/// directly from worker threads — a `&DrawPrimitive` cannot cross (the text
213/// variant carries `Rc`), but the shape variants' fields can.
214#[expect(clippy::too_many_arguments)]
215pub fn rect_shape_params(
216    local_rect: Rect,
217    brush: &Brush,
218    stroke: Option<Stroke>,
219    layer_bounds: Rect,
220    layer: &GraphicsLayer,
221    clip: Option<Rect>,
222    blend_mode: BlendMode,
223    motion_context_animated: bool,
224) -> Option<ShapeDrawParams> {
225    let (draw_rect, stroke) = stroked_draw_rect(local_rect, stroke, layer_bounds, layer)?;
226    let local_rect = apply_layer_affine_to_rect(draw_rect, layer_bounds, layer);
227    let quad = apply_layer_to_quad(draw_rect, layer_bounds, layer);
228    Some(ShapeDrawParams {
229        rect: quad_bounds(quad),
230        local_rect,
231        quad,
232        brush: resolve_layer_brush(brush, layer),
233        shape: None,
234        stroke,
235        arc: None,
236        clip,
237        blend_mode,
238        motion_context_animated,
239    })
240}
241
242/// The [`DrawPrimitive::RoundRect`] arm of [`emit_draw_primitive`]; see
243/// [`rect_shape_params`].
244#[expect(clippy::too_many_arguments)]
245pub fn round_rect_shape_params(
246    local_rect: Rect,
247    brush: &Brush,
248    radii: CornerRadii,
249    stroke: Option<Stroke>,
250    layer_bounds: Rect,
251    layer: &GraphicsLayer,
252    clip: Option<Rect>,
253    blend_mode: BlendMode,
254    motion_context_animated: bool,
255) -> Option<ShapeDrawParams> {
256    let (draw_rect, stroke) = stroked_draw_rect(local_rect, stroke, layer_bounds, layer)?;
257    let local_rect = apply_layer_affine_to_rect(draw_rect, layer_bounds, layer);
258    let quad = apply_layer_to_quad(draw_rect, layer_bounds, layer);
259    let shape =
260        RoundedCornerShape::with_radii(scale_corner_radii(radii, layer_uniform_scale(layer)));
261    Some(ShapeDrawParams {
262        rect: quad_bounds(quad),
263        local_rect,
264        quad,
265        brush: resolve_layer_brush(brush, layer),
266        shape: Some(shape),
267        stroke,
268        arc: None,
269        clip,
270        blend_mode,
271        motion_context_animated,
272    })
273}
274
275/// The [`DrawPrimitive::Arc`] arm of [`emit_draw_primitive`]; see
276/// [`rect_shape_params`].
277#[expect(clippy::too_many_arguments)]
278pub fn arc_shape_params(
279    local_rect: Rect,
280    brush: &Brush,
281    center: Point,
282    radius: f32,
283    start_angle: f32,
284    sweep_angle: f32,
285    stroke: Option<Stroke>,
286    inner_radius: f32,
287    layer_bounds: Rect,
288    layer: &GraphicsLayer,
289    clip: Option<Rect>,
290    blend_mode: BlendMode,
291    motion_context_animated: bool,
292) -> Option<ShapeDrawParams> {
293    let (band_inner, band_outer, cap) = arc_band(radius, inner_radius, stroke);
294    let arc = ArcGeometry::new(
295        center,
296        band_inner,
297        band_outer,
298        start_angle,
299        sweep_angle,
300        cap,
301    );
302    if arc.is_degenerate() {
303        return None;
304    }
305    let draw_rect = local_rect.translate(layer_bounds.x, layer_bounds.y);
306    let out_rect = apply_layer_affine_to_rect(draw_rect, layer_bounds, layer);
307    let quad = apply_layer_to_quad(draw_rect, layer_bounds, layer);
308    let scale = layer_uniform_scale(layer);
309    let arc_center = apply_layer_affine_to_point(
310        Point::new(center.x + layer_bounds.x, center.y + layer_bounds.y),
311        layer_bounds,
312        layer,
313    );
314    Some(ShapeDrawParams {
315        rect: quad_bounds(quad),
316        local_rect: out_rect,
317        quad,
318        brush: resolve_layer_brush(brush, layer),
319        shape: None,
320        stroke: None,
321        arc: Some(arc.scaled_about(arc_center, scale)),
322        clip,
323        blend_mode,
324        motion_context_animated,
325    })
326}
327
328pub fn emit_draw_primitive<S: DrawPrimitiveSink>(
329    primitive: &DrawPrimitive,
330    layer_bounds: Rect,
331    layer: &GraphicsLayer,
332    clip: Option<Rect>,
333    sink: &mut S,
334    blend_mode: Option<BlendMode>,
335    motion_context_animated: bool,
336) {
337    match primitive {
338        DrawPrimitive::Content => {}
339        DrawPrimitive::Blend {
340            primitive,
341            blend_mode: nested,
342        } => emit_draw_primitive(
343            primitive,
344            layer_bounds,
345            layer,
346            clip,
347            sink,
348            blend_mode.or(Some(*nested)),
349            motion_context_animated,
350        ),
351        DrawPrimitive::Rect {
352            rect: local_rect,
353            brush,
354            stroke,
355        } => {
356            if let Some(params) = rect_shape_params(
357                *local_rect,
358                brush,
359                *stroke,
360                layer_bounds,
361                layer,
362                clip,
363                blend_mode.unwrap_or(BlendMode::SrcOver),
364                motion_context_animated,
365            ) {
366                sink.push_shape(params);
367            }
368        }
369        DrawPrimitive::RoundRect {
370            rect: local_rect,
371            brush,
372            radii,
373            stroke,
374        } => {
375            if let Some(params) = round_rect_shape_params(
376                *local_rect,
377                brush,
378                *radii,
379                *stroke,
380                layer_bounds,
381                layer,
382                clip,
383                blend_mode.unwrap_or(BlendMode::SrcOver),
384                motion_context_animated,
385            ) {
386                sink.push_shape(params);
387            }
388        }
389        DrawPrimitive::Arc {
390            rect: local_rect,
391            brush,
392            center,
393            radius,
394            start_angle,
395            sweep_angle,
396            stroke,
397            inner_radius,
398        } => {
399            if let Some(params) = arc_shape_params(
400                *local_rect,
401                brush,
402                *center,
403                *radius,
404                *start_angle,
405                *sweep_angle,
406                *stroke,
407                *inner_radius,
408                layer_bounds,
409                layer,
410                clip,
411                blend_mode.unwrap_or(BlendMode::SrcOver),
412                motion_context_animated,
413            ) {
414                sink.push_shape(params);
415            }
416        }
417        DrawPrimitive::Image {
418            rect: local_rect,
419            image,
420            alpha,
421            color_filter,
422            sampling,
423            src_rect,
424        } => {
425            let draw_rect = local_rect.translate(layer_bounds.x, layer_bounds.y);
426            let local_rect = apply_layer_affine_to_rect(draw_rect, layer_bounds, layer);
427            let quad = apply_layer_to_quad(draw_rect, layer_bounds, layer);
428            sink.push_image(ImageDrawParams {
429                rect: quad_bounds(quad),
430                local_rect,
431                quad,
432                image: image.clone(),
433                alpha: (alpha * layer.alpha).clamp(0.0, 1.0),
434                color_filter: compose_color_filters(*color_filter, layer.color_filter),
435                sampling: *sampling,
436                clip,
437                src_rect: *src_rect,
438                blend_mode: blend_mode.unwrap_or(BlendMode::SrcOver),
439                motion_context_animated,
440            });
441        }
442        DrawPrimitive::Text(text) => {
443            if let Some(params) = text_draw_params((**text).clone(), layer_bounds, layer, clip) {
444                sink.push_text(params);
445            }
446        }
447        DrawPrimitive::Shadow(shadow_primitive) => {
448            sink.push_shadow(shadow_primitive, layer_bounds, layer, clip);
449        }
450    }
451}
452
453fn text_draw_params(
454    text: TextPrimitive,
455    layer_bounds: Rect,
456    layer: &GraphicsLayer,
457    clip: Option<Rect>,
458) -> Option<TextDrawParams> {
459    if text.text.is_empty() {
460        return None;
461    }
462    let draw_rect = text.rect.translate(layer_bounds.x, layer_bounds.y);
463    let rect = apply_layer_to_rect(draw_rect, layer_bounds, layer);
464    if !(rect.width > 0.0 && rect.height > 0.0) {
465        return None;
466    }
467    let scale = layer_uniform_scale(layer);
468    if !scale.is_finite() || scale <= 0.0 {
469        return None;
470    }
471    let color = apply_layer_to_color(text.color, layer);
472    if color.3 <= 0.0 {
473        return None;
474    }
475
476    Some(TextDrawParams {
477        rect,
478        text: cranpose_ui::text::shared_plain_annotated_string(text.text.as_ref()),
479        render_text: cranpose_ui::text::shared_plain_render_string(text.text.as_ref()),
480        color,
481        text_style: text_style_for_draw_style(&text.style),
482        font_size: text.style.resolved_font_size(),
483        scale,
484        layout_options: TextLayoutOptions {
485            soft_wrap: false,
486            overflow: TextOverflow::Visible,
487            ..TextLayoutOptions::default()
488        },
489        clip,
490    })
491}
492
493#[cfg(test)]
494#[path = "tests/primitive_emit_tests.rs"]
495mod tests;