mirui 0.20.2

A lightweight, no_std ECS-driven UI framework for embedded, desktop, and WebAssembly
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
//! Offscreen render — mark an entity with [`OffscreenRender`] to send
//! its subtree through a private buffer instead of writing straight
//! into the parent renderer's target. The buffer is sized at
//! `ComputedRect × scale` (so `scale = 1.0` is a 1:1 cache and
//! `scale < 1.0` reduces resolution), rendered into, then blit'd back
//! onto the parent at the entity's full rect.
//!
//! Buffers live in [`OffscreenBufferPool`], a `World` resource keyed
//! by `(entity, w, h, format, generation)`. The dirty walker bumps
//! `generation` whenever the entity itself or any descendant carries
//! `Dirty`, which forces the next render to miss the cache and
//! rebuild the buffer; otherwise the prior buffer is blit'd as-is and
//! the subtree's raster work is skipped entirely.
//!
//! # Usage
//!
//! Two pieces opt the app in:
//!
//! ```ignore
//! // 1. Size the pool. Default is `Bytes(0)` (disabled), so the cache
//! //    never grows and OffscreenRender silently falls through to
//! //    inline. Pick a budget that fits a couple of buffers:
//! //    `width × height × bytes_per_pixel`. RGB565 is 2, RGBA8888 is 4.
//! app.with_offscreen_pool_budget(64 * 1024);
//!
//! // 2. Tag the entity whose subtree should be cached.
//! world.insert(panel, OffscreenRender::default());
//! ```
//!
//! # When it pays off
//!
//! Best fit: a subtree that is **static or near-static between frames**
//! while something else on screen redraws (forces the dirty rect to
//! cover the cached entity). Inline path re-rasters the subtree;
//! offscreen path blit's the cached buffer once.
//!
//! Worst fit: the subtree mutates every frame, or a `WidgetTransform`
//! on the entity animates while the subtree is static. Both cases
//! bump `generation` (or fail to skip raster) so the offscreen path
//! runs the full raster + blit each frame and ends up slower than
//! inline.
//!
//! # Constraints (debug_assert panics)
//!
//! - Renderer must implement the SW pipeline. GPU backends log once
//!   and fall through to inline rendering.
//! - Entity cannot also carry `WidgetTransform3D`.
//! - `OffscreenRender` cannot nest.

use crate::cache::{Handle, LruCache, MaxSize, WithFactory};
use crate::draw::texture::{ColorFormat, Texture};
use crate::ecs::{Entity, World};
use crate::types::Fixed;
use core::cell::{Ref, RefCell};

/// Mark an entity for offscreen rendering. Insert / remove to toggle.
///
/// Default ([`Self::new`]) renders at full resolution and only buys
/// caching. `with_scale(Fixed::HALF)` renders at half resolution and
/// upscales on present.
#[derive(Clone, Copy, Debug)]
pub struct OffscreenRender {
    /// Render scale relative to the entity's `ComputedRect`. 1.0 keeps
    /// the buffer at the entity's drawn size; 0.5 halves both axes (a
    /// quarter of the pixel count). Values below `Fixed::ONE / 8` are
    /// clamped at render time so `buf_w` / `buf_h` never round to 0.
    pub scale: Fixed,
}

impl Default for OffscreenRender {
    fn default() -> Self {
        Self::new()
    }
}

impl OffscreenRender {
    pub const fn new() -> Self {
        Self { scale: Fixed::ONE }
    }

    pub const fn with_scale(scale: Fixed) -> Self {
        Self { scale }
    }
}

/// Cache-invalidation counter. Bumped by the dirty walker when any
/// descendant of an `OffscreenRender` entity carries `Dirty`. Lives on
/// the same entity as `OffscreenRender`; default 0 on first render.
#[derive(Clone, Copy, Debug, Default)]
pub struct OffscreenGeneration(pub u32);

/// Mark on a consumer entity to keep `source`'s OffscreenRender
/// alive. mirui maintains a refcount per source — first ref adds
/// OffscreenRender to source; the last ref removed restores source
/// to its prior state (no OffscreenRender if the user hadn't opted
/// in).
///
/// Effect widgets typically attach this via the view registry's
/// `auto_attach` mechanism, so user code only inserts the effect's
/// main component.
#[derive(Clone, Copy, Debug)]
pub struct WidgetTextureRef(pub Entity);

/// Internal marker on a source entity that received `OffscreenRender`
/// from `maintain_widget_texture_refs` (not from user code). Only
/// auto-added entries get removed when the last `WidgetTextureRef`
/// goes away; user-explicit `OffscreenRender` is left alone.
#[derive(Clone, Copy, Debug)]
pub struct OffscreenAutoAdded;

/// Mark on an `OffscreenRender` source so its buffer initialises to
/// fully-transparent black instead of a copy of the framebuffer
/// underneath. Effect widgets that need the buffer's alpha channel
/// to encode the source's actual silhouette (zero outside the
/// widget's drawn pixels) attach this — without it, alpha
/// extraction sees the framebuffer's alpha bleeding through and
/// produces a rectangular silhouette that ignores `border_radius`.
#[derive(Clone, Copy, Debug)]
pub struct OffscreenAlphaMode {
    /// `true` ⇒ buffer is cleared to RGBA `(0, 0, 0, 0)` before the
    /// subtree renders. `false` ⇒ buffer is pre-seeded from the
    /// framebuffer (default — keeps anti-aliased edges blending against
    /// the existing background).
    pub clear_transparent: bool,
}

impl OffscreenAlphaMode {
    pub const fn clear_transparent() -> Self {
        Self {
            clear_transparent: true,
        }
    }
}

/// Internal marker on a consumer entity tracking the source's last-
/// seen `OffscreenGeneration`, so the consumer can be marked Dirty
/// when the source's buffer changes.
#[derive(Clone, Copy, Debug, Default)]
pub struct WidgetTextureRefPrevGen(pub u32);

/// Cache key for [`OffscreenBufferPool`]. `entity` is part of the key so
/// each offscreen entity owns its own slot — sharing buffers across
/// entities would race when both render in the same frame.
#[derive(Hash, PartialEq, Eq, Clone, Copy, Debug)]
pub(crate) struct BufferKey {
    pub entity: Entity,
    pub w: u16,
    pub h: u16,
    pub format: ColorFormat,
    pub generation: u32,
}

/// LRU pool of offscreen buffers, sized by total byte budget rather
/// than entry count: the cap reflects the heap held by all live
/// Texture allocations, not just the slot count. Inserted into the
/// `World` as a resource by `App::with_factory` with budget `0`,
/// which disables the cache and routes every `OffscreenRender` entity
/// through inline rendering; set a real value via
/// [`crate::app::App::with_offscreen_pool_budget`].
pub struct OffscreenBufferPool {
    // RefCell so render_system can borrow the pool mutably while
    // holding `&World`. Each cached value is itself a RefCell<Texture>
    // because the inner SwRenderer borrows the buffer's bytes mutably
    // for the duration of the subtree render.
    pub(crate) cache:
        RefCell<WithFactory<LruCache<BufferKey, RefCell<Texture<'static>>>, BufferCtor>>,
    // Format used by the most recent buffer write. `None` until the
    // first render. Effect widgets / `World::texture_of` read this to
    // reconstruct the BufferKey without holding a Renderer reference.
    pub(crate) last_format: core::cell::Cell<Option<ColorFormat>>,
}

pub(crate) type BufferCtor = fn(&BufferKey) -> Result<RefCell<Texture<'static>>, BufferAllocError>;

#[derive(Debug)]
pub struct BufferAllocError;

fn make_buffer(k: &BufferKey) -> Result<RefCell<Texture<'static>>, BufferAllocError> {
    Ok(RefCell::new(Texture::owned(k.w, k.h, k.format)))
}

impl OffscreenBufferPool {
    /// Build a pool with an explicit byte budget. The budget caps the
    /// total Texture heap held by the cache; LRU eviction kicks in
    /// once an insert would push the running total past the budget.
    pub fn with_budget(budget_bytes: usize) -> Self {
        let cache = LruCache::builder()
            .max_size(MaxSize::Bytes(budget_bytes))
            .name("widget/offscreen")
            .build();
        Self {
            cache: RefCell::new(WithFactory::new(cache, make_buffer as BufferCtor)),
            last_format: core::cell::Cell::new(None),
        }
    }
}

impl Default for OffscreenBufferPool {
    /// Disabled cache. Buffer working sets depend on widget sizes and
    /// available RAM, neither of which the library can guess; the
    /// caller opts in via
    /// [`crate::app::App::with_offscreen_pool_budget`].
    fn default() -> Self {
        Self::with_budget(0)
    }
}

/// Borrow guard for an entity's rendered texture cached in the
/// [`OffscreenBufferPool`]. Holding it keeps the buffer alive in the
/// pool (it counts as a live reference for LRU eviction). Drop
/// before the next render so the cache can advance generations
/// normally.
#[derive(Clone)]
pub struct TextureSnapshot {
    handle: Handle<RefCell<Texture<'static>>>,
}

impl TextureSnapshot {
    /// Borrow the underlying texture immutably.
    ///
    /// Panics if the texture is concurrently borrowed mutably (this
    /// only happens if user code retains a snapshot across the next
    /// render — drop snapshots before the frame ends).
    pub fn borrow(&self) -> Ref<'_, Texture<'static>> {
        self.handle.get().borrow()
    }
}

fn texture_for(world: &World, entity: Entity, generation_offset: i64) -> Option<TextureSnapshot> {
    let pool = world.resource::<OffscreenBufferPool>()?;
    let format = pool.last_format.get()?;
    let off = world.get::<OffscreenRender>(entity)?;
    let rect = world.get::<super::ComputedRect>(entity)?.0;
    let scale = off.scale.max(Fixed::ONE / 8);
    let buf_w_f = Fixed::from_int(rect.w.to_int().max(1)) * scale;
    let buf_h_f = Fixed::from_int(rect.h.to_int().max(1)) * scale;
    let w = buf_w_f.to_int().max(1).min(u16::MAX as i32) as u16;
    let h = buf_h_f.to_int().max(1).min(u16::MAX as i32) as u16;

    let g_now = world
        .get::<OffscreenGeneration>(entity)
        .map(|g| g.0)
        .unwrap_or(0);
    let g = if generation_offset >= 0 {
        g_now.checked_add(generation_offset as u32)?
    } else {
        g_now.checked_sub((-generation_offset) as u32)?
    };

    let key = BufferKey {
        entity,
        w,
        h,
        format,
        generation: g,
    };
    let handle = pool.cache.borrow_mut().acquire(&key)?;
    Some(TextureSnapshot { handle })
}

/// Extension trait that lives on `World` for ergonomic access to
/// rendered widget textures from inside an effect widget's view fn.
pub trait WidgetTextureAccess {
    /// The entity's rendered texture from the current frame.
    /// Returns `None` until at least one render happens after the
    /// source got `OffscreenRender` (user-explicit or auto-added via
    /// [`WidgetTextureRef`]).
    fn texture_of(&self, entity: Entity) -> Option<TextureSnapshot>;

    /// The entity's rendered texture from the previous frame. For
    /// effects that mix two frames (TemporalMix) or run before the
    /// source in z-order.
    ///
    /// Returns `None` on the first frame after opt-in (no prev
    /// buffer yet) or when the prev buffer has been evicted.
    fn prev_texture_of(&self, entity: Entity) -> Option<TextureSnapshot>;
}

impl WidgetTextureAccess for World {
    fn texture_of(&self, entity: Entity) -> Option<TextureSnapshot> {
        texture_for(self, entity, 0)
    }

    fn prev_texture_of(&self, entity: Entity) -> Option<TextureSnapshot> {
        texture_for(self, entity, -1)
    }
}

/// Walk every `WidgetTextureRef` and reconcile each referenced
/// source's `OffscreenRender` state. Auto-add when a source gains
/// its first ref; auto-remove when the last ref drops.
#[crate::system(order = PRE_RENDER)]
pub fn maintain_widget_texture_refs(world: &mut World) {
    use alloc::vec::Vec;
    use hashbrown::HashMap;

    // Cheap path: if neither component is in use, the system has
    // nothing to do — common until any effect widget gets attached.
    let any_ref = world.query::<WidgetTextureRef>().iter().next().is_some();
    let any_auto = world.query::<OffscreenAutoAdded>().iter().next().is_some();
    if !any_ref && !any_auto {
        return;
    }

    let mut counts: HashMap<Entity, u32> = HashMap::new();
    for (_e, r) in world.query::<WidgetTextureRef>().iter() {
        *counts.entry(r.0).or_insert(0) += 1;
    }

    let mut to_add: Vec<Entity> = Vec::new();
    for (&source, &n) in &counts {
        if n > 0 && world.get::<OffscreenRender>(source).is_none() {
            to_add.push(source);
        }
    }
    for source in to_add {
        world.insert(source, OffscreenRender::default());
        world.insert(source, OffscreenAutoAdded);
    }

    let auto_entries: Vec<Entity> = world
        .query::<OffscreenAutoAdded>()
        .iter()
        .map(|(e, _)| e)
        .collect();
    for source in auto_entries {
        // Keep self-attached entries: an effect widget that holds a
        // `WidgetTextureRef` may also need its own `OffscreenRender`
        // (e.g. TemporalMix uses its own buffer for IIR feedback).
        // Such an entity isn't anyone's source, so the refcount check
        // alone would tear its buffer down.
        if counts.get(&source).copied().unwrap_or(0) == 0
            && world.get::<WidgetTextureRef>(source).is_none()
        {
            world.remove::<OffscreenRender>(source);
            world.remove::<OffscreenAutoAdded>(source);
        }
    }

    // Generation-change detection: mark consumer Dirty when source's
    // OffscreenGeneration moved since last frame, so the consumer's
    // view fn re-runs against the fresh source texture.
    let pairs: Vec<(Entity, Entity)> = world
        .query::<WidgetTextureRef>()
        .iter()
        .map(|(e, r)| (e, r.0))
        .collect();
    for (consumer, source) in pairs {
        let g_now = world
            .get::<OffscreenGeneration>(source)
            .map(|g| g.0)
            .unwrap_or(0);
        let g_prev = world
            .get::<WidgetTextureRefPrevGen>(consumer)
            .map(|g| g.0)
            .unwrap_or(0);
        let source_dirty = world.get::<super::dirty::Dirty>(source).is_some();
        let source_transform = world
            .get::<crate::components::WidgetTransform>(source)
            .copied();
        if g_now != g_prev || source_dirty {
            // `g_now != g_prev` catches buffer-content changes; the
            // `source_dirty` clause catches translation / rotation
            // animations on the source — those re-render at a new
            // screen position without bumping the buffer's
            // generation, so consumers that compose the source's
            // transform onto their blit need to repaint even when
            // the source's pixels haven't changed.
            world.insert(consumer, super::dirty::Dirty);
            world.insert(consumer, WidgetTextureRefPrevGen(g_now));
            // Mirror the source's WidgetTransform onto the consumer
            // so the dirty walker computes the consumer's screen
            // bbox at the source's actual painted position, not at
            // the consumer's static layout slot. Without this the
            // dirty rect doesn't track the source's animation and
            // old shadow / mirror pixels stick around as the source
            // moves.
            if let Some(tf) = source_transform {
                world.insert(consumer, tf);
            } else {
                world.remove::<crate::components::WidgetTransform>(consumer);
            }
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    fn dummy_entity(id: u32) -> Entity {
        Entity { id, generation: 0 }
    }

    #[test]
    fn pool_creates_with_byte_budget() {
        let pool = OffscreenBufferPool::with_budget(64 * 1024);
        assert_eq!(pool.cache.borrow().cache().len(), 0);
    }

    #[test]
    fn pool_default_disables_cache() {
        // No platform sniffing in the default — caller must opt in via
        // `App::with_offscreen_pool_budget`. Until they do, every
        // insert lands as a detached invalid handle and the cache
        // stays empty.
        let pool = OffscreenBufferPool::default();
        let key = BufferKey {
            entity: dummy_entity(1),
            w: 32,
            h: 32,
            format: ColorFormat::RGBA8888,
            generation: 0,
        };
        let handle = pool
            .cache
            .borrow_mut()
            .entry(key)
            .or_insert()
            .expect("ctor still runs even when the budget is zero");
        assert!(
            handle.is_invalid(),
            "Bytes(0) must hand back a detached handle"
        );
        assert_eq!(pool.cache.borrow().cache().len(), 0);
    }

    #[test]
    fn buffer_key_distinguishes_entities() {
        let k1 = BufferKey {
            entity: dummy_entity(1),
            w: 40,
            h: 24,
            format: ColorFormat::RGBA8888,
            generation: 0,
        };
        let k2 = BufferKey {
            entity: dummy_entity(2),
            w: 40,
            h: 24,
            format: ColorFormat::RGBA8888,
            generation: 0,
        };
        assert_ne!(k1, k2);
    }

    #[test]
    fn buffer_key_distinguishes_generations() {
        let k1 = BufferKey {
            entity: dummy_entity(1),
            w: 40,
            h: 24,
            format: ColorFormat::RGBA8888,
            generation: 0,
        };
        let k2 = BufferKey {
            generation: 1,
            ..k1
        };
        assert_ne!(k1, k2);
    }

    #[test]
    fn pool_or_insert_creates_buffer_at_requested_size() {
        let pool = OffscreenBufferPool::with_budget(64 * 1024);
        let key = BufferKey {
            entity: dummy_entity(1),
            w: 40,
            h: 24,
            format: ColorFormat::RGBA8888,
            generation: 0,
        };
        let handle = pool
            .cache
            .borrow_mut()
            .entry(key)
            .or_insert()
            .expect("alloc");
        assert!(!handle.is_invalid());
        let tex = handle.borrow();
        assert_eq!(tex.width, 40);
        assert_eq!(tex.height, 24);
        assert_eq!(tex.format, ColorFormat::RGBA8888);
    }

    #[test]
    fn pool_or_insert_hits_same_key() {
        let pool = OffscreenBufferPool::with_budget(64 * 1024);
        let key = BufferKey {
            entity: dummy_entity(1),
            w: 40,
            h: 24,
            format: ColorFormat::RGBA8888,
            generation: 0,
        };
        let _h1 = pool
            .cache
            .borrow_mut()
            .entry(key)
            .or_insert()
            .expect("first");
        let stats_after_first = *pool.cache.borrow().cache().stats();
        let _h2 = pool
            .cache
            .borrow_mut()
            .entry(key)
            .or_insert()
            .expect("second");
        let stats_after_second = *pool.cache.borrow().cache().stats();
        // Second call hits, not misses.
        assert_eq!(stats_after_second.miss_count, stats_after_first.miss_count);
        assert_eq!(
            stats_after_second.hit_count,
            stats_after_first.hit_count + 1
        );
    }

    #[test]
    fn pool_or_insert_misses_after_generation_bump() {
        let pool = OffscreenBufferPool::with_budget(64 * 1024);
        let key0 = BufferKey {
            entity: dummy_entity(1),
            w: 40,
            h: 24,
            format: ColorFormat::RGBA8888,
            generation: 0,
        };
        let _h0 = pool
            .cache
            .borrow_mut()
            .entry(key0)
            .or_insert()
            .expect("gen 0");
        let key1 = BufferKey {
            generation: 1,
            ..key0
        };
        let stats_before = *pool.cache.borrow().cache().stats();
        let _h1 = pool
            .cache
            .borrow_mut()
            .entry(key1)
            .or_insert()
            .expect("gen 1");
        let stats_after = *pool.cache.borrow().cache().stats();
        // generation bump → key not in cache → miss.
        assert_eq!(stats_after.miss_count, stats_before.miss_count + 1);
    }

    #[test]
    fn pool_byte_budget_evicts_lru_when_total_exceeds_limit() {
        // Budget = 8 KB; one 40×24 RGBA buffer is 3840 bytes. Three of
        // them (11.5 KB) won't fit, so the LRU one must leave.
        let pool = OffscreenBufferPool::with_budget(8 * 1024);
        let key = |id, g| BufferKey {
            entity: dummy_entity(id),
            w: 40,
            h: 24,
            format: ColorFormat::RGBA8888,
            generation: g,
        };
        let _h1 = pool.cache.borrow_mut().entry(key(1, 0)).or_insert();
        let _h2 = pool.cache.borrow_mut().entry(key(2, 0)).or_insert();
        // Touch h1 so h2 is the LRU candidate.
        let _ = pool.cache.borrow_mut().acquire(&key(1, 0));
        let _h3 = pool.cache.borrow_mut().entry(key(3, 0)).or_insert();

        let cache = pool.cache.borrow();
        assert_eq!(cache.cache().len(), 2);
        assert!(cache.cache().current_size() <= 8 * 1024);
        assert_eq!(cache.cache().stats().evict_count, 1);
    }

    #[test]
    fn pool_oversized_entry_returns_invalid_handle_without_growing_cache() {
        // 200×200 RGBA = 160 KB, way past a 4 KB budget.
        let pool = OffscreenBufferPool::with_budget(4 * 1024);
        let key = BufferKey {
            entity: dummy_entity(1),
            w: 200,
            h: 200,
            format: ColorFormat::RGBA8888,
            generation: 0,
        };
        let handle = pool
            .cache
            .borrow_mut()
            .entry(key)
            .or_insert()
            .expect("ctor still runs even when entry won't fit");
        assert!(
            handle.is_invalid(),
            "oversized entry must come back detached"
        );
        assert_eq!(pool.cache.borrow().cache().len(), 0);
    }

    #[test]
    fn offscreen_render_default_is_full_scale() {
        let off = OffscreenRender::default();
        assert_eq!(off.scale, Fixed::ONE);
    }

    #[test]
    fn offscreen_render_with_scale() {
        let off = OffscreenRender::with_scale(Fixed::ONE / 2);
        assert_eq!(off.scale, Fixed::ONE / 2);
    }

    fn run_refs(world: &mut World) {
        super::maintain_widget_texture_refs(world);
    }

    #[test]
    fn first_ref_auto_adds_offscreen_render() {
        let mut world = World::default();
        let source = world.spawn();
        let consumer = world.spawn();
        world.insert(consumer, WidgetTextureRef(source));

        run_refs(&mut world);
        assert!(world.get::<OffscreenRender>(source).is_some());
        assert!(world.get::<OffscreenAutoAdded>(source).is_some());
    }

    #[test]
    fn last_ref_dropped_auto_removes_offscreen_render() {
        let mut world = World::default();
        let source = world.spawn();
        let c1 = world.spawn();
        let c2 = world.spawn();
        world.insert(c1, WidgetTextureRef(source));
        world.insert(c2, WidgetTextureRef(source));
        run_refs(&mut world);
        assert!(world.get::<OffscreenRender>(source).is_some());

        world.remove::<WidgetTextureRef>(c1);
        run_refs(&mut world);
        assert!(world.get::<OffscreenRender>(source).is_some());

        world.remove::<WidgetTextureRef>(c2);
        run_refs(&mut world);
        assert!(world.get::<OffscreenRender>(source).is_none());
        assert!(world.get::<OffscreenAutoAdded>(source).is_none());
    }

    #[test]
    fn user_explicit_offscreen_render_is_never_removed() {
        let mut world = World::default();
        let source = world.spawn();
        // User-explicit: no `OffscreenAutoAdded` marker.
        world.insert(source, OffscreenRender::default());

        let consumer = world.spawn();
        world.insert(consumer, WidgetTextureRef(source));
        run_refs(&mut world);
        assert!(world.get::<OffscreenAutoAdded>(source).is_none());

        world.remove::<WidgetTextureRef>(consumer);
        run_refs(&mut world);
        assert!(world.get::<OffscreenRender>(source).is_some());
    }
}