Skip to main content

lumen_core/
native.rs

1//! Native paint seam - the way a plugin draws its own pixels inside the retained scene.
2//!
3//! A plugin that owns a drawing surface (a chart, a map, a canvas) has content the core
4//! primitives cannot describe. The seam gives it an opaque leaf: the plugin contributes an
5//! [`ExtractedNative`] from a normal extract fn, registers a [`NativePainter`] for the same
6//! `extension_id`, and the render backend calls that painter with its own draw target when the
7//! leaf's turn comes in paint order.
8//!
9//! This is the same shape as `QSGRenderNode` in Qt SceneGraph and `GskGLShaderNode` in GTK 4:
10//! an opaque leaf that the scene graph positions, orders, and clips, and whose interior only the
11//! backend and the extension understand.
12//!
13//! ## Contracts
14//!
15//! - **Bounds enclose the paint.** [`ExtractedNative::bounds`] must cover every pixel the painter
16//!   touches. The damage diff repaints exactly that rect, so paint outside it survives as stale
17//!   pixels until something else damages the region.
18//! - **`revision` is pixel identity.** Two leaves with the same `extension_id`, `bounds`,
19//!   `clip_to_bounds`, and `revision` are treated as identical pixels and contribute no damage.
20//!   Call [`next_revision`] whenever the content changes, or the frame never repaints. Payload
21//!   identity is deliberately not part of the comparison: producers rebuild the `Arc` on every
22//!   dirty frame, which would mark the leaf changed every time.
23//! - **Painters take `&self`.** One painter serves every leaf carrying its `extension_id`, so
24//!   per-frame state goes behind interior mutability.
25//! - **`extension_id` names a contract**, covering both the concrete payload type and the API the
26//!   painter expects from the backend. A backend with no painter registered for an id skips the
27//!   leaf, which is what keeps a scene portable across backends.
28//! - **Bounds with no area never repaint on their own.** Damage is a rect, and an empty rect is no
29//!   damage, so a leaf that declares zero width or height falls back to whole-viewport damage when
30//!   its revision moves. Declare the area you paint.
31//!
32//! ## Producing leaves
33//!
34//! [`NativeExtract`] resolves the placement of one entity - paint order, scroll offset, inherited
35//! opacity, hidden subtrees - the same way the built-in extractors do, and
36//! [`upsert_native_leaves`] carries the leaves across frames under one extension id so two plugins
37//! extracting in the same frame cannot evict each other's entities.
38
39use crate::components::Transform;
40use crate::node_ir::Affine2;
41use crate::render_world::{
42    PaintOrder, Rect, RenderEntityMap, aabb_outside, build_parent_map, draw_opacity,
43    hidden_entities, opacity_model, paint_order_of, parent_scroll_clip_rects,
44    parent_scroll_offsets,
45};
46use bevy_ecs::entity::Entity;
47use bevy_ecs::prelude::{Component, Resource};
48use bevy_ecs::world::World;
49use glam::Vec2;
50use std::any::Any;
51use std::collections::HashMap;
52use std::fmt;
53use std::sync::Arc;
54use std::sync::atomic::{AtomicU64, Ordering};
55
56/// Render-world component describing one backend-painted leaf.
57///
58/// Spawned by a plugin's extract fn like any other `Extracted*` component, so it inherits document
59/// paint order via [`crate::render_world::paint_order_of`], enclosing clip brackets, the top-layer
60/// band, the [`crate::render_world::clear_extracted`] lifecycle, and the hidden-subtree sweep.
61#[derive(Component, Clone)]
62pub struct ExtractedNative {
63    /// Identifies the extension that owns this leaf. The backend dispatches on it and skips the
64    /// leaf when no painter is registered.
65    pub extension_id: Arc<str>,
66    /// Type-erased draw data. The painter downcasts it to the type its `extension_id` promises.
67    pub payload: Arc<dyn Any + Send + Sync>,
68    /// Bounding rect in logical window coordinates. Encloses every pixel the painter touches.
69    pub bounds: Rect,
70    /// Painter-algorithm sort key, from [`crate::render_world::paint_order_of`].
71    pub order: PaintOrder,
72    /// Content stamp. Equal revisions mean identical pixels; see [`next_revision`].
73    pub revision: u64,
74    /// When `true`, the backend clips the painter to [`Self::bounds`].
75    pub clip_to_bounds: bool,
76}
77
78impl fmt::Debug for ExtractedNative {
79    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
80        f.debug_struct("ExtractedNative")
81            .field("extension_id", &self.extension_id)
82            .field("bounds", &self.bounds)
83            .field("order", &self.order)
84            .field("revision", &self.revision)
85            .field("clip_to_bounds", &self.clip_to_bounds)
86            .finish()
87    }
88}
89
90/// Monotonic content stamps for [`ExtractedNative::revision`].
91///
92/// Every call returns a value no other call returns, so a producer that stamps its leaf on each
93/// content change can never collide with a stale value.
94pub fn next_revision() -> u64 {
95    static REVISION: AtomicU64 = AtomicU64::new(1);
96    REVISION.fetch_add(1, Ordering::Relaxed)
97}
98
99/// Where one entity's leaf belongs this frame, as [`NativeExtract`] resolved it.
100#[derive(Clone, Copy, Debug, PartialEq)]
101pub struct NativePlacement {
102    /// Bounds in window coordinates, with ancestor scroll offsets already applied.
103    pub bounds: Rect,
104    /// Paint order for the entity's position in the document.
105    pub order: PaintOrder,
106    /// The alpha the leaf's own paints carry: the entity's own opacity, unless the entity fades as a
107    /// group with its children, times what ancestors outside any group holding it contribute. A
108    /// group's fade is a layer the backend composites, so it is not part of this figure.
109    pub opacity: f32,
110}
111
112/// The hierarchy-derived facts a plugin's extract fn needs, computed once per frame.
113///
114/// A leaf placed by hand from `Transform::absolute` alone ignores everything an ancestor says: it
115/// stays pinned while its scroll container scrolls, ignores `opacity`, and keeps painting inside a
116/// hidden subtree. Build one of these at the top of the extract fn and ask it to
117/// [`place`](Self::place) each entity instead. It reads the same memo the built-in extractors
118/// share, so the maps are built once per frame no matter how many extractors ask.
119pub struct NativeExtract {
120    parents: HashMap<Entity, Entity>,
121    depth_cache: HashMap<Entity, u32>,
122    hidden: std::collections::HashSet<Entity>,
123    scroll: HashMap<Entity, Vec2>,
124    opacities: HashMap<Entity, f32>,
125    clip: HashMap<Entity, (Vec2, Vec2)>,
126}
127
128impl NativeExtract {
129    /// Reads the frame's hierarchy, scroll, opacity, visibility, and clip maps from the main world.
130    pub fn new(main: &mut World) -> Self {
131        let (parents, depth_cache) = build_parent_map(main);
132        let hidden = hidden_entities(main, &parents);
133        let scroll = parent_scroll_offsets(main, &parents);
134        let opacities = opacity_model(main, &parents).draw;
135        let clip = parent_scroll_clip_rects(main, &parents);
136        Self {
137            parents,
138            depth_cache,
139            hidden,
140            scroll,
141            opacities,
142            clip,
143        }
144    }
145
146    /// Resolves where `entity`'s leaf belongs, or `None` when it should not be extracted at all:
147    /// hidden by a `Visible(false)` on itself or an ancestor, or scrolled fully out of the nearest
148    /// scroll or `overflow: hidden` container.
149    pub fn place(&mut self, entity: Entity, transform: &Transform) -> Option<NativePlacement> {
150        if self.hidden.contains(&entity) {
151            return None;
152        }
153        let offset = self.scroll.get(&entity).copied().unwrap_or(Vec2::ZERO);
154        let origin = transform.absolute - offset;
155        if let Some(clip_rect) = self.clip.get(&entity)
156            && aabb_outside(origin, transform.size, *clip_rect)
157        {
158            return None;
159        }
160        Some(NativePlacement {
161            bounds: Rect::new(origin, transform.size),
162            order: paint_order_of(entity, &self.parents, &mut self.depth_cache),
163            opacity: draw_opacity(&self.opacities, entity).0,
164        })
165    }
166}
167
168/// Carries one extension's leaves into the render world, reusing each entity's render-world entity
169/// across frames and despawning the ones that went away.
170///
171/// The lifecycle is scoped to `extension_id`, so several plugins extracting in the same frame keep
172/// their own sets: a plugin only ever retires leaves it produced. Pass the id the leaves carry.
173pub fn upsert_native_leaves<I>(render: &mut World, extension_id: &str, leaves: I)
174where
175    I: IntoIterator<Item = (Entity, ExtractedNative)>,
176{
177    let mut map = std::mem::take(&mut render.resource_mut::<RenderEntityMap>().native);
178    let mut prior: HashMap<Entity, Entity> = HashMap::new();
179    map.retain(|(id, main_e), render_e| {
180        if &**id == extension_id {
181            prior.insert(*main_e, *render_e);
182            false
183        } else {
184            true
185        }
186    });
187
188    let id: Arc<str> = Arc::from(extension_id);
189    for (main_e, leaf) in leaves {
190        let reuse = prior
191            .remove(&main_e)
192            .filter(|&re| render.get_entity(re).is_ok());
193        let render_e = match reuse {
194            Some(re) => {
195                render.entity_mut(re).insert(leaf);
196                re
197            }
198            None => render.spawn(leaf).id(),
199        };
200        map.insert((id.clone(), main_e), render_e);
201    }
202    // Whatever is left in `prior` had a leaf last frame and none now.
203    for (_, render_e) in prior {
204        if let Ok(em) = render.get_entity_mut(render_e) {
205            em.despawn();
206        }
207    }
208    render.resource_mut::<RenderEntityMap>().native = map;
209}
210
211/// What a backend hands a [`NativePainter`] for one leaf.
212///
213/// Both the payload and the draw target are type-erased, which is what keeps this crate free of
214/// backend dependencies. A painter downcasts the payload to the type its `extension_id` promises
215/// and the target to the type the backend named in [`Self::backend_id`]; a painter that does not
216/// recognise the backend draws nothing.
217pub struct NativePaintCtx<'a> {
218    payload: &'a (dyn Any + Send + Sync),
219    target: &'a mut dyn Any,
220    /// Identifies the backend that owns [`Self::target`], for example `"lumen.render-wgpu"`.
221    pub backend_id: &'static str,
222    /// The leaf's bounds in logical window coordinates.
223    pub bounds: Rect,
224    /// Transform accumulated from ancestor transform nodes, in logical coordinates. The tree
225    /// builder emits no transform nodes today, so this is the identity for now; compose through
226    /// [`Self::device_transform`] rather than assuming it.
227    pub transform: Affine2,
228    /// Device pixel ratio between logical coordinates and the target's pixels.
229    pub dpr: f32,
230    /// Alpha multiplier accumulated from ancestor opacity nodes.
231    ///
232    /// The painter is the only thing that applies it. A bounds clip composites nothing, so asking
233    /// to be clipped never changes a leaf's alpha. An ancestor that fades as a group is a layer the
234    /// backend composites over whatever the painter draws, so it never reaches this figure, which is
235    /// `1.0` for every leaf the tree builder places; the way to honour CSS `opacity` is to fold
236    /// [`NativePlacement::opacity`] into the payload at extract, which is what the built-in
237    /// extractors do with their own colours.
238    pub opacity: f32,
239}
240
241impl<'a> NativePaintCtx<'a> {
242    /// Builds a context around one leaf's payload and the backend's draw target.
243    pub fn new(
244        payload: &'a (dyn Any + Send + Sync),
245        target: &'a mut dyn Any,
246        backend_id: &'static str,
247        bounds: Rect,
248        transform: Affine2,
249        dpr: f32,
250        opacity: f32,
251    ) -> Self {
252        Self {
253            payload,
254            target,
255            backend_id,
256            bounds,
257            transform,
258            dpr,
259            opacity,
260        }
261    }
262
263    /// Borrows the payload as `T`, or `None` when the leaf carries something else.
264    pub fn payload_as<T: Any>(&self) -> Option<&'a T> {
265        self.payload.downcast_ref::<T>()
266    }
267
268    /// Borrows the draw target as `T`, or `None` when this backend's target is something else.
269    pub fn target_as<T: Any>(&mut self) -> Option<&mut T> {
270        self.target.downcast_mut::<T>()
271    }
272
273    /// The transform from logical leaf coordinates to target pixels: the ancestor transform
274    /// followed by the device-pixel scale.
275    pub fn device_transform(&self) -> Affine2 {
276        Affine2::scale(self.dpr as f64) * self.transform
277    }
278}
279
280/// Paints one [`ExtractedNative`] leaf onto a backend draw target.
281///
282/// A single painter serves every leaf carrying its `extension_id`, so it takes `&self` and keeps
283/// any per-frame state behind interior mutability.
284pub trait NativePainter: Send + Sync + 'static {
285    /// Draws the leaf described by `ctx`.
286    fn paint(&self, ctx: &mut NativePaintCtx<'_>);
287}
288
289/// Render-world registry mapping an `extension_id` to the painter that draws it.
290///
291/// Cloning shares the table; registering on a clone copies it first, so a backend can take a cheap
292/// snapshot of the registry before it starts a frame.
293#[derive(Resource, Clone, Default)]
294pub struct NativePainters {
295    painters: Arc<HashMap<Arc<str>, Arc<dyn NativePainter>>>,
296}
297
298impl NativePainters {
299    /// Registers `painter` for `extension_id`, replacing any painter already registered for it.
300    pub fn register<P: NativePainter>(&mut self, extension_id: impl Into<Arc<str>>, painter: P) {
301        Arc::make_mut(&mut self.painters).insert(extension_id.into(), Arc::new(painter));
302    }
303
304    /// Returns the painter registered for `extension_id`, or `None`.
305    pub fn get(&self, extension_id: &str) -> Option<&Arc<dyn NativePainter>> {
306        self.painters.get(extension_id)
307    }
308
309    /// Number of registered painters.
310    pub fn len(&self) -> usize {
311        self.painters.len()
312    }
313
314    /// Returns `true` when no painter is registered.
315    pub fn is_empty(&self) -> bool {
316        self.painters.is_empty()
317    }
318}
319
320impl fmt::Debug for NativePainters {
321    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
322        f.debug_struct("NativePainters")
323            .field("registered", &self.painters.len())
324            .finish()
325    }
326}
327
328#[cfg(test)]
329mod tests {
330    use super::*;
331    use std::sync::Mutex;
332
333    #[derive(Clone, Default)]
334    struct Recorder {
335        seen: Arc<Mutex<Vec<String>>>,
336    }
337
338    impl NativePainter for Recorder {
339        fn paint(&self, ctx: &mut NativePaintCtx<'_>) {
340            let label = ctx
341                .payload_as::<String>()
342                .cloned()
343                .unwrap_or_else(|| "?".to_string());
344            self.seen.lock().expect("recorder lock").push(label);
345        }
346    }
347
348    struct Noop;
349
350    impl NativePainter for Noop {
351        fn paint(&self, _ctx: &mut NativePaintCtx<'_>) {}
352    }
353
354    fn ctx_over<'a>(
355        payload: &'a (dyn Any + Send + Sync),
356        target: &'a mut dyn Any,
357    ) -> NativePaintCtx<'a> {
358        NativePaintCtx::new(
359            payload,
360            target,
361            "test.backend",
362            Rect::new(glam::Vec2::ZERO, glam::Vec2::new(10.0, 10.0)),
363            Affine2::IDENTITY,
364            1.0,
365            1.0,
366        )
367    }
368
369    /// A painter is found by the id it was registered under, and registering the
370    /// same id again replaces it - so a plugin reinstalled at runtime does not
371    /// leave the old painter drawing.
372    #[test]
373    fn the_last_painter_registered_for_an_id_is_the_one_that_paints() {
374        let mut registry = NativePainters::default();
375        assert!(registry.is_empty());
376
377        registry.register("demo.native", Noop);
378        registry.register("demo.other", Noop);
379        assert_eq!(registry.len(), 2);
380        assert!(registry.get("demo.native").is_some());
381        assert!(registry.get("demo.missing").is_none());
382
383        let recorder = Recorder::default();
384        registry.register("demo.native", recorder.clone());
385        assert_eq!(registry.len(), 2, "re-registering replaces, not appends");
386
387        let payload: Arc<dyn Any + Send + Sync> = Arc::new("hello".to_string());
388        let mut target = ();
389        let painter = registry.get("demo.native").expect("painter").clone();
390        painter.paint(&mut ctx_over(payload.as_ref(), &mut target));
391
392        assert_eq!(recorder.seen.lock().expect("lock").as_slice(), ["hello"]);
393    }
394
395    /// A backend snapshots the registry for the frame it is about to paint.
396    /// Registering after the snapshot must not reach into it.
397    #[test]
398    fn a_snapshot_of_the_registry_does_not_see_later_registrations() {
399        let mut registry = NativePainters::default();
400        registry.register("demo.native", Noop);
401
402        let snapshot = registry.clone();
403        registry.register("demo.late", Noop);
404
405        assert_eq!(snapshot.len(), 1);
406        assert!(snapshot.get("demo.late").is_none());
407        assert!(registry.get("demo.late").is_some());
408    }
409
410    /// The painter maps its own logical coordinates to target pixels through
411    /// `device_transform`: ancestor placement first, device scale second.
412    #[test]
413    fn device_transform_scales_the_ancestor_transform() {
414        let payload: Arc<dyn Any + Send + Sync> = Arc::new(7u32);
415        let mut target = ();
416        let mut ctx = ctx_over(payload.as_ref(), &mut target);
417        ctx.transform = Affine2::translate(10.0, 20.0);
418        ctx.dpr = 2.0;
419
420        assert_eq!(
421            ctx.device_transform().coeffs,
422            [2.0, 0.0, 0.0, 2.0, 20.0, 40.0]
423        );
424
425        ctx.dpr = 1.0;
426        assert_eq!(ctx.device_transform().coeffs, ctx.transform.coeffs);
427    }
428
429    /// Downcasting is how a painter checks that a leaf really carries the payload
430    /// its `extension_id` promises; a mismatch reads as absent rather than
431    /// misinterpreting the bytes.
432    #[test]
433    fn a_payload_of_the_wrong_type_reads_as_absent() {
434        let payload: Arc<dyn Any + Send + Sync> = Arc::new(7u32);
435        let mut target = 5i64;
436        let mut ctx = ctx_over(payload.as_ref(), &mut target);
437
438        assert_eq!(ctx.payload_as::<u32>(), Some(&7));
439        assert!(ctx.payload_as::<String>().is_none());
440        assert_eq!(ctx.target_as::<i64>(), Some(&mut 5));
441        assert!(ctx.target_as::<String>().is_none());
442    }
443
444    /// Revisions are what tell the damage diff that pixels changed, so two of
445    /// them are never equal.
446    #[test]
447    fn revisions_never_repeat() {
448        let a = next_revision();
449        let b = next_revision();
450        let c = next_revision();
451        assert!(a < b && b < c);
452    }
453}