Skip to main content

azul_layout/widgets/
screencap.rs

1//! Screen-capture widget — a "dumb widget" identical in architecture to the
2//! [`CameraWidget`](super::camera), only the source differs (a display /
3//! window). SUPER_PLAN_2 §4 P6, widget pivot.
4//!
5//! `ScreenCaptureWidget::create(config).dom()` → an `<img>` a background
6//! capture thread keeps fed; each frame goes through
7//! [`super::capture_common::present_frame`] (GL-texture install-once /
8//! re-upload + recomposite). The shared core lives in `capture_common`; this
9//! widget is its config + worker. Test-pattern worker (a moving band) stands
10//! in for the real ScreenCaptureKit / MediaProjection / PipeWire worker.
11
12use alloc::vec::Vec;
13
14use azul_core::callbacks::Update;
15use azul_core::dom::{ComponentEventFilter, DatasetMergeCallbackType, Dom, EventFilter};
16use azul_core::refany::{OptionRefAny, RefAny};
17use azul_core::resources::{ImageRef, RawImageFormat};
18use azul_core::screencap::ScreenCaptureConfig;
19use azul_core::task::{ThreadId, ThreadReceiver};
20
21use azul_core::video::VideoFrame;
22
23use super::capture_common::{
24    invoke_on_frame, present_frame, screen_backend, OnVideoFrame, OnVideoFrameCallback,
25    OptionOnVideoFrame,
26};
27use crate::callbacks::{Callback, CallbackInfo, CallbackType};
28use crate::thread::{
29    Thread, ThreadCallback, ThreadReceiveMsg, ThreadSender, ThreadWriteBackMsg, WriteBackCallback,
30};
31
32/// Default capture size for the test pattern (the real backend reports the
33/// source's actual size).
34const DEFAULT_W: u32 = 1280;
35const DEFAULT_H: u32 = 720;
36
37/// Live state for one screencap widget, carried across relayout by
38/// [`merge_screencap_state`].
39#[derive(Debug)]
40pub struct ScreenCaptureWidgetState {
41    /// The requested capture configuration (the control POD).
42    pub config: ScreenCaptureConfig,
43    /// `true` once the capture thread has been started.
44    pub started: bool,
45    /// The stable external GL texture id once installed.
46    pub gl_texture_id: Option<u32>,
47    /// Optional user hook invoked with each captured frame (effects / save /
48    /// send). Re-set on every fresh build (see [`merge_screencap_state`]).
49    pub on_frame: OptionOnVideoFrame,
50}
51
52/// A screen-capture widget. `create(config).dom()` yields an `<img>` the
53/// capture thread keeps fed.
54#[repr(C)]
55#[derive(Debug)]
56pub struct ScreenCaptureWidget {
57    /// What to capture + fps + format.
58    pub config: ScreenCaptureConfig,
59    /// Optional per-frame user hook (effects / save / send - azul-meet).
60    pub on_frame: OptionOnVideoFrame,
61}
62
63impl ScreenCaptureWidget {
64    /// Create a screencap widget for the given config.
65    #[must_use] pub const fn create(config: ScreenCaptureConfig) -> Self {
66        Self {
67            config,
68            on_frame: OptionOnVideoFrame::None,
69        }
70    }
71
72    /// Set a hook invoked with every captured frame - for live effects, saving
73    /// frames into your data model, or sending them over the network
74    /// (azul-meet). The backreference DI pattern (see `architecture.md`).
75    pub fn set_on_frame<C: Into<OnVideoFrameCallback>>(&mut self, data: RefAny, on_frame: C) {
76        self.on_frame = Some(OnVideoFrame {
77            refany: data,
78            callback: on_frame.into(),
79        })
80        .into();
81    }
82
83    /// Builder form of [`set_on_frame`](Self::set_on_frame).
84    #[must_use]
85    pub fn with_on_frame<C: Into<OnVideoFrameCallback>>(
86        mut self,
87        data: RefAny,
88        on_frame: C,
89    ) -> Self {
90        self.set_on_frame(data, on_frame);
91        self
92    }
93
94    /// Build the widget's DOM: a single `<img>` node, fed by a background
95    /// capture thread started on mount.
96    #[must_use] pub fn dom(self) -> Dom {
97        let state = ScreenCaptureWidgetState {
98            config: self.config,
99            started: false,
100            gl_texture_id: None,
101            on_frame: self.on_frame,
102        };
103        let dataset = RefAny::new(state);
104
105        let placeholder = ImageRef::null_image(
106            DEFAULT_W as usize,
107            DEFAULT_H as usize,
108            RawImageFormat::BGRA8,
109            b"azul-screencap-placeholder".to_vec(),
110        );
111
112        Dom::create_image(placeholder)
113            .with_dataset(OptionRefAny::Some(dataset.clone()))
114            .with_merge_callback(azul_core::dom::DatasetMergeCallback::from_ptr(merge_screencap_state))
115            .with_callback(
116                EventFilter::Component(ComponentEventFilter::AfterMount),
117                dataset,
118                Callback::from_ptr(screencap_on_after_mount),
119            )
120    }
121}
122
123/// `AfterMount`: start the background capture thread exactly once.
124extern "C" fn screencap_on_after_mount(mut data: RefAny, mut info: CallbackInfo) -> Update {
125    {
126        let Some(mut s) = data.downcast_mut::<ScreenCaptureWidgetState>() else {
127            return Update::DoNothing;
128        };
129        if s.started {
130            return Update::DoNothing;
131        }
132        s.started = true;
133    }
134    info.add_thread(
135        ThreadId::unique(),
136        Thread::create(
137            RefAny::new(()),
138            data.clone(),
139            ThreadCallback::new(screencap_worker),
140        ),
141    );
142    Update::DoNothing
143}
144
145/// Background worker (test pattern): a downward-moving white band on dark grey,
146/// ~30x/s. Replaced by the real `ScreenCaptureKit` / `MediaProjection` worker.
147extern "C" fn screencap_worker(_init: RefAny, mut sender: ThreadSender, _recv: ThreadReceiver) {
148    // Real platform capture if the dll registered a screen backend
149    // (ScreenCaptureKit / X11 / DXGI; Wayland stays a dummy); else the test pattern.
150    if let Some(backend) = screen_backend() {
151        let handle = (backend.open)(0, DEFAULT_W, DEFAULT_H);
152        if handle != 0 {
153            let mut buf: alloc::vec::Vec<u8> = alloc::vec::Vec::new();
154            loop {
155                let (fw, fh) = (backend.read)(handle, &mut buf);
156                if fw == 0 || fh == 0 {
157                    break;
158                }
159                let frame = VideoFrame {
160                    width: fw,
161                    height: fh,
162                    bytes: buf.clone().into(),
163                };
164                if !sender.send(ThreadReceiveMsg::WriteBack(ThreadWriteBackMsg::new(
165                    WriteBackCallback::new(screencap_writeback),
166                    RefAny::new(frame),
167                ))) {
168                    break;
169                }
170            }
171            (backend.close)(handle);
172            return;
173        }
174    }
175
176    let (w, h) = (DEFAULT_W as usize, DEFAULT_H as usize);
177    let mut tick: u32 = 0;
178    loop {
179        let band = (tick as usize) % h;
180        let mut bytes = Vec::with_capacity(w * h * 4);
181        for y in 0..h {
182            let v = if y.abs_diff(band) < 8 { 235u8 } else { 28u8 };
183            for _ in 0..w {
184                bytes.extend_from_slice(&[v, v, v, 255]);
185            }
186        }
187        let frame = VideoFrame {
188            width: u32::try_from(w).unwrap_or(0),
189            height: u32::try_from(h).unwrap_or(0),
190            bytes: bytes.into(),
191        };
192        let sent = sender.send(ThreadReceiveMsg::WriteBack(ThreadWriteBackMsg::new(
193            WriteBackCallback::new(screencap_writeback),
194            RefAny::new(frame),
195        )));
196        if !sent {
197            break;
198        }
199        std::thread::sleep(std::time::Duration::from_millis(33));
200        tick = tick.wrapping_add(12);
201    }
202}
203
204/// Writeback (main thread): hand the frame to the shared GL presenter and
205/// store the (stable) texture id.
206extern "C" fn screencap_writeback(
207    mut writeback_data: RefAny,
208    mut frame_data: RefAny,
209    mut info: CallbackInfo,
210) -> Update {
211    let (current, hook) = writeback_data.downcast_ref::<ScreenCaptureWidgetState>().map_or_else(|| (None, OptionOnVideoFrame::None), |s| (s.gl_texture_id, s.on_frame.clone()));
212    let mut user_update = Update::DoNothing;
213    let new_id = match frame_data.downcast_ref::<VideoFrame>() {
214        Some(frame) => {
215            let id = present_frame(&mut info, writeback_data.clone(), current, &frame);
216            user_update = invoke_on_frame(&hook, &mut info, &frame);
217            id
218        }
219        None => return Update::DoNothing,
220    };
221    if let Some(mut s) = writeback_data.downcast_mut::<ScreenCaptureWidgetState>() {
222        s.gl_texture_id = new_id;
223    }
224    user_update
225}
226
227/// Carry live state forward across relayout.
228extern "C" fn merge_screencap_state(mut new_data: RefAny, mut old_data: RefAny) -> RefAny {
229    {
230        let new_guard = new_data.downcast_mut::<ScreenCaptureWidgetState>();
231        let old_guard = old_data.downcast_ref::<ScreenCaptureWidgetState>();
232        if let (Some(mut new_g), Some(old_g)) = (new_guard, old_guard) {
233            new_g.started = old_g.started;
234            new_g.gl_texture_id = old_g.gl_texture_id;
235        }
236    }
237    new_data
238}
239
240// ============================================================================
241// Generated adversarial tests
242// ============================================================================
243
244#[cfg(test)]
245#[allow(clippy::too_many_lines, clippy::cast_possible_truncation)]
246mod autotest_generated {
247    use std::{
248        collections::BTreeMap,
249        panic::{catch_unwind, AssertUnwindSafe},
250        sync::{
251            mpsc::{channel, Receiver, Sender},
252            Arc, Mutex, PoisonError,
253        },
254    };
255
256    use azul_core::{
257        dom::{DomId, DomNodeId, NodeType},
258        geom::OptionLogicalPosition,
259        gl::OptionGlContextPtr,
260        hit_test::ScrollPosition,
261        resources::{DecodedImage, RendererResources},
262        screencap::ScreenCaptureSource,
263        styled_dom::NodeHierarchyItemId,
264        task::{
265            OptionThreadSendMsg, ThreadReceiverDestructorCallback, ThreadReceiverInner,
266            ThreadRecvCallback, ThreadSendMsg,
267        },
268        window::{MonitorVec, RawWindowHandle},
269    };
270    use azul_css::system::SystemStyle;
271    use rust_fontconfig::FcFontCache;
272
273    use super::*;
274    #[cfg(feature = "icu")]
275    use crate::icu::IcuLocalizerHandle;
276    use crate::{
277        callbacks::{CallbackChange, CallbackInfoRefData, ExternalSystemCallbacks},
278        thread::{ThreadSendCallback, ThreadSenderDestructorCallback, ThreadSenderInner},
279        widgets::capture_common::OnVideoFrameCallbackType,
280        window::LayoutWindow,
281        window_state::FullWindowState,
282    };
283
284    // ------------------------------------------------------------------
285    // Config fixtures
286    // ------------------------------------------------------------------
287
288    const fn cfg(
289        source: ScreenCaptureSource,
290        fps: u32,
291        output_format: RawImageFormat,
292    ) -> ScreenCaptureConfig {
293        ScreenCaptureConfig {
294            source,
295            fps,
296            output_format,
297        }
298    }
299
300    /// Representative + extreme configs: both payload boundaries of each
301    /// carrying `ScreenCaptureSource` variant, `fps` at 0 / 1 / `u32::MAX`, and
302    /// a format that is deliberately *not* the widget's placeholder format.
303    const ALL_CONFIGS: [ScreenCaptureConfig; 8] = [
304        cfg(ScreenCaptureSource::PrimaryDisplay, 0, RawImageFormat::BGRA8),
305        cfg(
306            ScreenCaptureSource::PrimaryDisplay,
307            u32::MAX,
308            RawImageFormat::RGBA8,
309        ),
310        cfg(ScreenCaptureSource::Display(0), 1, RawImageFormat::BGRA8),
311        cfg(
312            ScreenCaptureSource::Display(u32::MAX),
313            60,
314            RawImageFormat::R8,
315        ),
316        cfg(ScreenCaptureSource::Window(0), 0, RawImageFormat::BGRA8),
317        cfg(
318            ScreenCaptureSource::Window(u64::MAX),
319            u32::MAX,
320            RawImageFormat::R8,
321        ),
322        cfg(
323            ScreenCaptureSource::Window(u32::MAX as u64),
324            30,
325            RawImageFormat::RGBA8,
326        ),
327        cfg(ScreenCaptureSource::Display(1), 240, RawImageFormat::BGRA8),
328    ];
329
330    const DEFAULT_CFG: ScreenCaptureConfig = ALL_CONFIGS[0];
331
332    /// Compile-time proof that `create` really is a `const fn` (its `const`
333    /// qualifier is part of the public API - a non-const `create` would make
334    /// this fn fail to compile).
335    const fn const_create(config: ScreenCaptureConfig) -> ScreenCaptureWidget {
336        ScreenCaptureWidget::create(config)
337    }
338
339    // ------------------------------------------------------------------
340    // State fixtures
341    // ------------------------------------------------------------------
342
343    /// A `ScreenCaptureWidgetState` payload with no `on_frame` hook.
344    fn state(
345        config: ScreenCaptureConfig,
346        started: bool,
347        gl_texture_id: Option<u32>,
348    ) -> RefAny {
349        RefAny::new(ScreenCaptureWidgetState {
350            config,
351            started,
352            gl_texture_id,
353            on_frame: OptionOnVideoFrame::None,
354        })
355    }
356
357    /// `(config, started, gl_texture_id, has_hook)` of a `ScreenCaptureWidgetState`.
358    fn read_state(data: &mut RefAny) -> (ScreenCaptureConfig, bool, Option<u32>, bool) {
359        let s = data
360            .downcast_ref::<ScreenCaptureWidgetState>()
361            .expect("payload must still be a ScreenCaptureWidgetState");
362        (
363            s.config,
364            s.started,
365            s.gl_texture_id,
366            matches!(s.on_frame, OptionOnVideoFrame::Some(_)),
367        )
368    }
369
370    /// The placeholder image behind an `<img>` `Dom` root: `(w, h, format, tag)`.
371    fn placeholder_of(dom: &Dom) -> (usize, usize, RawImageFormat, Vec<u8>) {
372        let NodeType::Image(image) = dom.root.get_node_type() else {
373            panic!("ScreenCaptureWidget::dom must build an image node");
374        };
375        match image.get_data() {
376            DecodedImage::NullImage {
377                width,
378                height,
379                format,
380                tag,
381            } => (*width, *height, *format, tag.clone()),
382            _ => panic!("the placeholder must be a NullImage (no decode, no allocation)"),
383        }
384    }
385
386    // ---- frame hook -------------------------------------------------------
387
388    /// Records every frame a widget's `on_frame` hook is handed, and replies
389    /// with a caller-chosen `Update`.
390    struct FrameLog {
391        seen: Vec<(u32, u32, usize)>,
392        reply: Update,
393    }
394
395    extern "C" fn record_frame(mut data: RefAny, _: CallbackInfo, frame: VideoFrame) -> Update {
396        let mut reply = Update::DoNothing;
397        if let Some(mut log) = data.downcast_mut::<FrameLog>() {
398            log.seen
399                .push((frame.width, frame.height, frame.bytes.as_ref().len()));
400            reply = log.reply;
401        }
402        reply
403    }
404
405    extern "C" fn frame_do_nothing(_: RefAny, _: CallbackInfo, _: VideoFrame) -> Update {
406        // A distinct body so the linker cannot fold this onto `record_frame` and
407        // make the fn-pointer identity assertions vacuous.
408        core::hint::black_box(Update::DoNothing)
409    }
410
411    fn frame_log(reply: Update) -> RefAny {
412        RefAny::new(FrameLog {
413            seen: Vec::new(),
414            reply,
415        })
416    }
417
418    /// The frames recorded by a `FrameLog` payload.
419    fn logged_frames(data: &mut RefAny) -> Vec<(u32, u32, usize)> {
420        data.downcast_ref::<FrameLog>()
421            .expect("payload must still be a FrameLog")
422            .seen
423            .clone()
424    }
425
426    /// A `ScreenCaptureWidgetState` whose `on_frame` hook writes into `log`.
427    fn state_with_hook(config: ScreenCaptureConfig, log: &RefAny) -> RefAny {
428        RefAny::new(ScreenCaptureWidgetState {
429            config,
430            started: true,
431            gl_texture_id: None,
432            on_frame: Some(OnVideoFrame {
433                refany: log.clone(),
434                callback: (record_frame as OnVideoFrameCallbackType).into(),
435            })
436            .into(),
437        })
438    }
439
440    /// A tightly-packed RGBA frame (`width * height * 4` bytes).
441    fn frame(width: u32, height: u32) -> VideoFrame {
442        let px = (width as usize) * (height as usize);
443        VideoFrame {
444            width,
445            height,
446            bytes: vec![7u8; px * 4].into(),
447        }
448    }
449
450    /// A frame whose declared dimensions need not match its byte count.
451    fn frame_raw(width: u32, height: u32, bytes: Vec<u8>) -> VideoFrame {
452        VideoFrame {
453            width,
454            height,
455            bytes: bytes.into(),
456        }
457    }
458
459    // ---- CallbackInfo harness --------------------------------------------
460
461    /// Runs `f` against a real `CallbackInfo` over an empty `LayoutWindow` (no GL
462    /// context -> the widget's CPU present path). Returns `f`'s value plus every
463    /// `CallbackChange` the callback recorded.
464    fn with_callback_info<R>(f: impl FnOnce(CallbackInfo) -> R) -> (R, Vec<CallbackChange>) {
465        let layout_window =
466            LayoutWindow::new(FcFontCache::default()).expect("LayoutWindow::new failed");
467        let renderer_resources = RendererResources::default();
468        let previous_window_state: Option<FullWindowState> = None;
469        let current_window_state = FullWindowState::default();
470        let gl_context = OptionGlContextPtr::None;
471        let scroll_states: BTreeMap<DomId, BTreeMap<NodeHierarchyItemId, ScrollPosition>> =
472            BTreeMap::new();
473        let window_handle = RawWindowHandle::Unsupported;
474        let system_callbacks = ExternalSystemCallbacks::rust_internal();
475
476        let ref_data = CallbackInfoRefData {
477            layout_window: &layout_window,
478            renderer_resources: &renderer_resources,
479            previous_window_state: &previous_window_state,
480            current_window_state: &current_window_state,
481            gl_context: &gl_context,
482            current_scroll_manager: &scroll_states,
483            current_window_handle: &window_handle,
484            system_callbacks: &system_callbacks,
485            system_style: Arc::new(SystemStyle::default()),
486            monitors: Arc::new(Mutex::new(MonitorVec::from_const_slice(&[]))),
487            #[cfg(feature = "icu")]
488            icu_localizer: IcuLocalizerHandle::default(),
489            ctx: OptionRefAny::None,
490        };
491
492        let changes: Arc<Mutex<Vec<CallbackChange>>> = Arc::new(Mutex::new(Vec::new()));
493
494        let info = CallbackInfo::new(
495            &ref_data,
496            &changes,
497            DomNodeId {
498                dom: DomId::ROOT_ID,
499                node: NodeHierarchyItemId::NONE,
500            },
501            OptionLogicalPosition::None,
502            OptionLogicalPosition::None,
503        );
504
505        let out = f(info);
506        let recorded = core::mem::take(&mut *changes.lock().expect("change log poisoned"));
507        (out, recorded)
508    }
509
510    // ---- screencap_worker harness ----------------------------------------
511
512    /// One frame `screencap_worker` pushed, summarised so the (multi-megabyte)
513    /// pixel buffer never has to be cloned into the log.
514    #[derive(Debug, Clone, PartialEq, Eq)]
515    struct SentFrame {
516        width: u32,
517        height: u32,
518        len: usize,
519        /// The first byte of every scanline (that row's test-pattern value).
520        row_values: Vec<u8>,
521        /// Every pixel of every scanline is `[v, v, v, 255]` for that row's `v`.
522        rows_uniform_opaque: bool,
523    }
524
525    /// Everything `screencap_worker` managed to send. Guarded by `WORKER_GATE` -
526    /// the worker's send callback is a plain C fn pointer, so it has nowhere else
527    /// to put its result.
528    static WORKER_LOG: Mutex<Vec<SentFrame>> = Mutex::new(Vec::new());
529    static WORKER_GATE: Mutex<()> = Mutex::new(());
530
531    /// Records the frame, then reports the send as *failed* - i.e. "the main
532    /// thread is gone", the only signal `screencap_worker` has to stop. A worker
533    /// that ignored it would hang this test binary forever (and grow ~3.7 MB per
534    /// 33 ms while doing so).
535    extern "C" fn record_and_stop(_sender: *const core::ffi::c_void, msg: ThreadReceiveMsg) -> bool {
536        if let ThreadReceiveMsg::WriteBack(mut wb) = msg {
537            if let Some(f) = wb.refany.downcast_ref::<VideoFrame>() {
538                let bytes = f.bytes.as_ref();
539                let stride = (f.width as usize) * 4;
540                let mut row_values = Vec::new();
541                let mut rows_uniform_opaque = true;
542                if stride > 0 {
543                    for row in bytes.chunks_exact(stride) {
544                        let v = row[0];
545                        row_values.push(v);
546                        if !row.chunks_exact(4).all(|px| px == &[v, v, v, 255][..]) {
547                            rows_uniform_opaque = false;
548                        }
549                    }
550                }
551                WORKER_LOG
552                    .lock()
553                    .unwrap_or_else(PoisonError::into_inner)
554                    .push(SentFrame {
555                        width: f.width,
556                        height: f.height,
557                        len: bytes.len(),
558                        row_values,
559                        rows_uniform_opaque,
560                    });
561            }
562        }
563        false
564    }
565
566    extern "C" fn sender_drop_noop(_: *mut ThreadSenderInner) {}
567    extern "C" fn receiver_drop_noop(_: *mut ThreadReceiverInner) {}
568    extern "C" fn recv_nothing(_: *const core::ffi::c_void) -> OptionThreadSendMsg {
569        OptionThreadSendMsg::None
570    }
571
572    /// A `ThreadSender` whose every `send` is recorded and then rejected.
573    fn stopped_sender() -> (Receiver<ThreadReceiveMsg>, ThreadSender) {
574        let (tx, rx) = channel::<ThreadReceiveMsg>();
575        let sender = ThreadSender::new(ThreadSenderInner {
576            ptr: Box::new(tx),
577            send_fn: ThreadSendCallback { cb: record_and_stop },
578            destructor: ThreadSenderDestructorCallback {
579                cb: sender_drop_noop,
580            },
581        });
582        (rx, sender)
583    }
584
585    /// A `ThreadReceiver` that never delivers anything (the worker ignores it).
586    fn silent_receiver() -> (Sender<ThreadSendMsg>, ThreadReceiver) {
587        let (tx, rx) = channel::<ThreadSendMsg>();
588        let receiver = ThreadReceiver::new(ThreadReceiverInner {
589            ptr: Box::new(rx),
590            recv_fn: ThreadRecvCallback { cb: recv_nothing },
591            destructor: ThreadReceiverDestructorCallback {
592                cb: receiver_drop_noop,
593            },
594        });
595        (tx, receiver)
596    }
597
598    /// Runs `screencap_worker` with `init` against a sender that rejects the
599    /// first frame, and returns everything the worker managed to send.
600    ///
601    /// `None` when a real platform screen backend is registered in this process
602    /// (`capture_common`'s own tests register one into the same process-global
603    /// `OnceLock`) - the worker is then not the test pattern these assertions
604    /// describe. The check *after* the run is the load-bearing one: a `OnceLock`
605    /// is monotone, so "still unset afterwards" proves it was unset throughout.
606    fn run_worker(init: RefAny) -> Option<Vec<SentFrame>> {
607        let _gate = WORKER_GATE.lock().unwrap_or_else(PoisonError::into_inner);
608        if screen_backend().is_some() {
609            return None;
610        }
611        WORKER_LOG
612            .lock()
613            .unwrap_or_else(PoisonError::into_inner)
614            .clear();
615
616        let (_rx, sender) = stopped_sender();
617        let (_tx, receiver) = silent_receiver();
618        screencap_worker(init, sender, receiver);
619
620        if screen_backend().is_some() {
621            return None; // registered by a parallel test mid-run
622        }
623        Some(
624            WORKER_LOG
625                .lock()
626                .unwrap_or_else(PoisonError::into_inner)
627                .clone(),
628        )
629    }
630
631    // ------------------------------------------------------------------
632    // ScreenCaptureWidget::create
633    // ------------------------------------------------------------------
634
635    #[test]
636    fn create_stores_the_config_verbatim_and_leaves_the_hook_unset() {
637        for config in ALL_CONFIGS {
638            let widget = ScreenCaptureWidget::create(config);
639            assert_eq!(
640                widget.config, config,
641                "create must not normalise or clamp the config"
642            );
643            assert!(
644                matches!(widget.on_frame, OptionOnVideoFrame::None),
645                "a fresh widget has no frame hook"
646            );
647        }
648    }
649
650    #[test]
651    fn create_preserves_the_full_source_payload_width() {
652        // A `as u32` anywhere in the widget would collapse a u64 window handle.
653        let widget = ScreenCaptureWidget::create(cfg(
654            ScreenCaptureSource::Window(u64::MAX),
655            0,
656            RawImageFormat::BGRA8,
657        ));
658        match widget.config.source {
659            ScreenCaptureSource::Window(h) => assert_eq!(h, u64::MAX),
660            other => panic!("expected Window(u64::MAX), got {other:?}"),
661        }
662
663        let widget = ScreenCaptureWidget::create(cfg(
664            ScreenCaptureSource::Display(u32::MAX),
665            u32::MAX,
666            RawImageFormat::BGRA8,
667        ));
668        match widget.config.source {
669            ScreenCaptureSource::Display(i) => assert_eq!(i, u32::MAX),
670            other => panic!("expected Display(u32::MAX), got {other:?}"),
671        }
672        assert_eq!(widget.config.fps, u32::MAX, "fps must not be clamped");
673    }
674
675    #[test]
676    fn create_is_usable_from_a_const_fn() {
677        for config in ALL_CONFIGS {
678            let widget = const_create(config);
679            assert_eq!(widget.config, config);
680            assert!(matches!(widget.on_frame, OptionOnVideoFrame::None));
681        }
682    }
683
684    // ------------------------------------------------------------------
685    // ScreenCaptureWidget::set_on_frame / with_on_frame
686    // ------------------------------------------------------------------
687
688    #[test]
689    fn set_on_frame_installs_the_hook_without_touching_the_config() {
690        for config in ALL_CONFIGS {
691            let mut widget = ScreenCaptureWidget::create(config);
692            widget.set_on_frame(
693                frame_log(Update::DoNothing),
694                record_frame as OnVideoFrameCallbackType,
695            );
696
697            assert_eq!(widget.config, config, "the hook must not alter the config");
698            let OptionOnVideoFrame::Some(hook) = &widget.on_frame else {
699                panic!("set_on_frame must install a hook");
700            };
701            assert_eq!(
702                hook.callback.cb as usize,
703                record_frame as OnVideoFrameCallbackType as usize,
704                "the stored fn pointer must be exactly the one that was passed in"
705            );
706        }
707    }
708
709    #[test]
710    fn set_on_frame_twice_keeps_only_the_last_hook() {
711        let mut widget = ScreenCaptureWidget::create(DEFAULT_CFG);
712        widget.set_on_frame(
713            RefAny::new(0_usize),
714            record_frame as OnVideoFrameCallbackType,
715        );
716        widget.set_on_frame(
717            RefAny::new(1_usize),
718            frame_do_nothing as OnVideoFrameCallbackType,
719        );
720
721        let OptionOnVideoFrame::Some(hook) = &widget.on_frame else {
722            panic!("hook must still be set");
723        };
724        assert_eq!(
725            hook.callback.cb as usize,
726            frame_do_nothing as OnVideoFrameCallbackType as usize,
727            "the second set_on_frame must replace the first, not stack"
728        );
729        assert_eq!(
730            hook.refany.clone().downcast_ref::<usize>().map(|v| *v),
731            Some(1),
732            "the replacement's payload must come with it"
733        );
734    }
735
736    #[test]
737    fn set_on_frame_shares_the_users_payload_rather_than_copying_it() {
738        // The backreference DI pattern only works if the widget holds a handle to
739        // the *same* allocation the caller kept.
740        let mut log = frame_log(Update::DoNothing);
741        let mut widget = ScreenCaptureWidget::create(DEFAULT_CFG);
742        widget.set_on_frame(log.clone(), record_frame as OnVideoFrameCallbackType);
743
744        let OptionOnVideoFrame::Some(hook) = &widget.on_frame else {
745            panic!("hook must be set");
746        };
747        let mut stored = hook.refany.clone();
748        {
749            let mut inner = stored
750                .downcast_mut::<FrameLog>()
751                .expect("the widget must hold a FrameLog");
752            inner.seen.push((1, 2, 3));
753        }
754        assert_eq!(
755            logged_frames(&mut log),
756            vec![(1, 2, 3)],
757            "the widget must share the caller's payload, not clone it"
758        );
759    }
760
761    #[test]
762    fn with_on_frame_is_exactly_create_plus_set_on_frame() {
763        for config in ALL_CONFIGS {
764            let built = ScreenCaptureWidget::create(config).with_on_frame(
765                frame_log(Update::RefreshDom),
766                record_frame as OnVideoFrameCallbackType,
767            );
768            let mut manual = ScreenCaptureWidget::create(config);
769            manual.set_on_frame(
770                frame_log(Update::RefreshDom),
771                record_frame as OnVideoFrameCallbackType,
772            );
773
774            assert_eq!(built.config, config, "the builder must not touch the config");
775            assert_eq!(built.config, manual.config);
776
777            let (OptionOnVideoFrame::Some(a), OptionOnVideoFrame::Some(b)) =
778                (&built.on_frame, &manual.on_frame)
779            else {
780                panic!("both forms must install a hook");
781            };
782            assert_eq!(a.callback.cb as usize, b.callback.cb as usize);
783        }
784    }
785
786    // ------------------------------------------------------------------
787    // ScreenCaptureWidget::dom
788    // ------------------------------------------------------------------
789
790    #[test]
791    fn dom_placeholder_is_always_1280x720_bgra8_whatever_the_config_asks_for() {
792        // The placeholder is a fixed-size stand-in: the *real* size is whatever
793        // the backend reports at runtime. So neither the requested source nor the
794        // requested output format may leak into it.
795        for config in ALL_CONFIGS {
796            let (w, h, format, tag) = placeholder_of(&ScreenCaptureWidget::create(config).dom());
797            assert_eq!(
798                (w, h),
799                (1280, 720),
800                "the placeholder size is fixed, not derived from {config:?}"
801            );
802            assert_eq!(
803                format,
804                RawImageFormat::BGRA8,
805                "output_format is a *capture* request; the placeholder stays BGRA8"
806            );
807            assert_eq!(tag, b"azul-screencap-placeholder".to_vec());
808        }
809    }
810
811    #[test]
812    fn dom_placeholder_is_a_null_image_that_allocates_no_pixels() {
813        // 1280 * 720 * 4 bytes would be ~3.7 MB per widget if the placeholder were
814        // a real raw image; a NullImage is only a descriptor.
815        let dom = ScreenCaptureWidget::create(DEFAULT_CFG).dom();
816        let NodeType::Image(image) = dom.root.get_node_type() else {
817            panic!("the widget must build an image node");
818        };
819        assert!(
820            matches!(image.get_data(), DecodedImage::NullImage { .. }),
821            "the placeholder must not decode or allocate"
822        );
823    }
824
825    #[test]
826    fn dom_wires_exactly_one_after_mount_callback_a_dataset_and_a_merge_callback() {
827        let dom = ScreenCaptureWidget::create(DEFAULT_CFG).dom();
828
829        assert_eq!(dom.children.as_ref().len(), 0, "the widget is a single node");
830
831        let callbacks = dom.root.get_callbacks();
832        assert_eq!(
833            callbacks.as_ref().len(),
834            1,
835            "exactly one callback: the AfterMount capture-thread starter"
836        );
837        assert_eq!(
838            callbacks.as_ref()[0].event,
839            EventFilter::Component(ComponentEventFilter::AfterMount),
840            "the thread must start on AfterMount, not on any input event"
841        );
842        assert_eq!(
843            callbacks.as_ref()[0].callback.cb,
844            screencap_on_after_mount as CallbackType as usize,
845            "the wired callback must be screencap_on_after_mount"
846        );
847
848        let merge = dom
849            .root
850            .get_merge_callback()
851            .expect("state must survive relayout");
852        assert_eq!(
853            merge.cb as usize,
854            merge_screencap_state as DatasetMergeCallbackType as usize,
855            "the merge callback must be merge_screencap_state"
856        );
857    }
858
859    #[test]
860    fn dom_seeds_the_dataset_with_the_config_and_a_not_yet_started_thread() {
861        for config in ALL_CONFIGS {
862            let dom = ScreenCaptureWidget::create(config).dom();
863            let mut dataset = dom
864                .root
865                .get_dataset()
866                .cloned()
867                .expect("the node must carry its ScreenCaptureWidgetState");
868            let (stored, started, texture, has_hook) = read_state(&mut dataset);
869
870            assert_eq!(stored, config, "dom() must not rewrite the config");
871            assert!(!started, "the capture thread only starts on AfterMount");
872            assert_eq!(texture, None, "no texture exists before the first frame");
873            assert!(!has_hook, "no hook was set on this widget");
874        }
875    }
876
877    #[test]
878    fn dom_moves_the_on_frame_hook_into_the_dataset() {
879        let dom = ScreenCaptureWidget::create(DEFAULT_CFG)
880            .with_on_frame(
881                frame_log(Update::DoNothing),
882                record_frame as OnVideoFrameCallbackType,
883            )
884            .dom();
885
886        let mut dataset = dom.root.get_dataset().cloned().expect("dataset");
887        let (_, _, _, has_hook) = read_state(&mut dataset);
888        assert!(has_hook, "dom() must carry the user hook into the state");
889    }
890
891    #[test]
892    fn dom_gives_the_after_mount_callback_the_very_same_state_the_node_carries() {
893        // `dom()` hands the callback a *clone* of the dataset. If that clone did
894        // not share the payload, AfterMount would flip `started` on a copy and the
895        // capture thread would be started again on every mount.
896        let dom = ScreenCaptureWidget::create(DEFAULT_CFG).dom();
897        let mut node_ds = dom.root.get_dataset().cloned().expect("dataset");
898        let mut cb_ds = dom.root.get_callbacks().as_ref()[0].refany.clone();
899
900        {
901            let mut s = cb_ds
902                .downcast_mut::<ScreenCaptureWidgetState>()
903                .expect("the callback's payload must be the widget state");
904            s.started = true;
905            s.gl_texture_id = Some(1234);
906        }
907
908        let (_, started, texture, _) = read_state(&mut node_ds);
909        assert!(
910            started,
911            "the callback and the node must share one state, not two copies"
912        );
913        assert_eq!(texture, Some(1234));
914    }
915
916    #[test]
917    fn two_widgets_built_from_one_config_get_independent_state() {
918        let a = ScreenCaptureWidget::create(cfg(
919            ScreenCaptureSource::Display(0),
920            30,
921            RawImageFormat::BGRA8,
922        ))
923        .dom();
924        let b = ScreenCaptureWidget::create(cfg(
925            ScreenCaptureSource::Window(7),
926            60,
927            RawImageFormat::RGBA8,
928        ))
929        .dom();
930
931        let mut da = a.root.get_dataset().cloned().expect("dataset a");
932        let mut db = b.root.get_dataset().cloned().expect("dataset b");
933        {
934            let mut s = da
935                .downcast_mut::<ScreenCaptureWidgetState>()
936                .expect("state a");
937            s.started = true;
938        }
939
940        let (config_a, started_a, _, _) = read_state(&mut da);
941        let (config_b, started_b, _, _) = read_state(&mut db);
942        assert!(started_a);
943        assert!(
944            !started_b,
945            "two widgets must not share one global capture state"
946        );
947        assert_eq!(config_a.source, ScreenCaptureSource::Display(0));
948        assert_eq!(config_b.source, ScreenCaptureSource::Window(7));
949    }
950
951    // ------------------------------------------------------------------
952    // screencap_on_after_mount
953    //
954    // NOTE: the *first* mount (started == false) is deliberately not exercised.
955    // It calls `Thread::create`, which spawns a real OS thread running
956    // `screencap_worker`; nothing in a unit test drains that thread's channel, so
957    // the worker would loop forever pushing ~3.7 MB frames while the `Thread`
958    // destructor waits to join it. Only the guard paths below can be driven
959    // safely (this mirrors the camera widget's test module).
960    // ------------------------------------------------------------------
961
962    #[test]
963    fn after_mount_ignores_a_dataset_that_is_not_a_screencap_state() {
964        for foreign in [RefAny::new(0_u32), RefAny::new(DEFAULT_CFG)] {
965            // The second case is the plausible mistake: handing the *config* POD
966            // instead of the widget state.
967            let (update, changes) =
968                with_callback_info(|info| screencap_on_after_mount(foreign.clone(), info));
969
970            assert_eq!(update, Update::DoNothing);
971            assert!(
972                changes.is_empty(),
973                "a foreign dataset must not start a capture thread: {changes:?}"
974            );
975        }
976    }
977
978    #[test]
979    fn after_mount_is_a_no_op_once_the_thread_has_started() {
980        let log = frame_log(Update::RefreshDom);
981        let mut data = state_with_hook(DEFAULT_CFG, &log);
982        {
983            let mut s = data
984                .downcast_mut::<ScreenCaptureWidgetState>()
985                .expect("state");
986            s.gl_texture_id = Some(3);
987        }
988
989        // Repeated mounts (relayout re-runs AfterMount) must stay inert.
990        for _ in 0..3 {
991            let (update, changes) =
992                with_callback_info(|info| screencap_on_after_mount(data.clone(), info));
993            assert_eq!(update, Update::DoNothing);
994            assert!(
995                changes.is_empty(),
996                "AfterMount must start the capture thread at most once: {changes:?}"
997            );
998        }
999
1000        let (config, started, texture, has_hook) = read_state(&mut data);
1001        assert_eq!(config, DEFAULT_CFG, "a re-mount must not rewrite the config");
1002        assert!(started);
1003        assert_eq!(texture, Some(3), "a re-mount must not drop the texture");
1004        assert!(has_hook, "a re-mount must not drop the user hook");
1005    }
1006
1007    // ------------------------------------------------------------------
1008    // screencap_worker
1009    // ------------------------------------------------------------------
1010
1011    #[test]
1012    fn worker_stops_as_soon_as_the_main_thread_stops_receiving() {
1013        let Some(sent) = run_worker(RefAny::new(())) else {
1014            return; // a platform screen backend is registered: not the test pattern
1015        };
1016
1017        assert_eq!(
1018            sent.len(),
1019            1,
1020            "the worker must stop after the first rejected send, not spin"
1021        );
1022        assert_eq!(
1023            (sent[0].width, sent[0].height),
1024            (DEFAULT_W, DEFAULT_H),
1025            "the test pattern is emitted at the widget's default capture size"
1026        );
1027        assert_eq!(
1028            sent[0].len,
1029            (DEFAULT_W as usize) * (DEFAULT_H as usize) * 4,
1030            "the frame must be tightly-packed RGBA8: w * h * 4 bytes"
1031        );
1032    }
1033
1034    #[test]
1035    fn worker_emits_the_documented_band_pattern_on_its_first_frame() {
1036        let Some(sent) = run_worker(RefAny::new(())) else {
1037            return;
1038        };
1039        let f = &sent[0];
1040
1041        assert!(
1042            f.rows_uniform_opaque,
1043            "every pixel must be an opaque grey [v, v, v, 255]"
1044        );
1045        assert_eq!(
1046            f.row_values.len(),
1047            DEFAULT_H as usize,
1048            "one value per scanline"
1049        );
1050        // tick 0 => band == 0, so rows 0..8 are the bright band (|y - 0| < 8).
1051        assert!(
1052            f.row_values[..8].iter().all(|&v| v == 235),
1053            "rows 0..8 are the bright band, got {:?}",
1054            &f.row_values[..8]
1055        );
1056        assert!(
1057            f.row_values[8..].iter().all(|&v| v == 28),
1058            "every row below the band is dark grey"
1059        );
1060    }
1061
1062    #[test]
1063    fn worker_ignores_its_init_payload_entirely() {
1064        // ADVERSARIAL: the test-pattern worker takes NO input - not the widget's
1065        // config, not its fps, not its source. A caller cannot influence the
1066        // frames by handing it a different init, and a garbage init must not
1067        // panic.
1068        let Some(unit) = run_worker(RefAny::new(())) else {
1069            return;
1070        };
1071        let Some(text) = run_worker(RefAny::new("not an init struct")) else {
1072            return;
1073        };
1074        let Some(widget_state) = run_worker(state(
1075            cfg(
1076                ScreenCaptureSource::Window(u64::MAX),
1077                u32::MAX,
1078                RawImageFormat::R8,
1079            ),
1080            true,
1081            Some(u32::MAX),
1082        )) else {
1083            return;
1084        };
1085
1086        assert_eq!(unit, text, "a foreign init must not change the frames");
1087        assert_eq!(
1088            unit, widget_state,
1089            "even a full widget state (fps = u32::MAX, R8) must not change the \
1090             test pattern - it is hard-coded"
1091        );
1092    }
1093
1094    // ------------------------------------------------------------------
1095    // screencap_writeback
1096    // ------------------------------------------------------------------
1097
1098    #[test]
1099    fn writeback_invokes_the_hook_with_the_frame_and_returns_its_update() {
1100        for reply in [
1101            Update::DoNothing,
1102            Update::RefreshDom,
1103            Update::RefreshDomAllWindows,
1104        ] {
1105            let mut log = frame_log(reply);
1106            let mut data = state_with_hook(DEFAULT_CFG, &log);
1107            let frame_data = RefAny::new(frame(2, 2));
1108
1109            let (update, _) = with_callback_info(|info| {
1110                screencap_writeback(data.clone(), frame_data.clone(), info)
1111            });
1112
1113            assert_eq!(update, reply, "the user hook's Update must be returned as-is");
1114            assert_eq!(logged_frames(&mut log), vec![(2, 2, 16)]);
1115            let (_, _, texture, _) = read_state(&mut data);
1116            assert_eq!(
1117                texture, None,
1118                "without a GL context no texture id is ever installed"
1119            );
1120        }
1121    }
1122
1123    #[test]
1124    fn writeback_ignores_frame_data_of_the_wrong_type() {
1125        let mut log = frame_log(Update::RefreshDom);
1126        let mut data = state_with_hook(DEFAULT_CFG, &log);
1127
1128        let (update, changes) =
1129            with_callback_info(|info| screencap_writeback(data.clone(), RefAny::new(0_u32), info));
1130
1131        assert_eq!(update, Update::DoNothing);
1132        assert!(changes.is_empty(), "no frame -> no image change");
1133        assert!(
1134            logged_frames(&mut log).is_empty(),
1135            "the user hook must not fire without a frame"
1136        );
1137    }
1138
1139    #[test]
1140    fn writeback_survives_a_writeback_dataset_that_is_not_a_screencap_state() {
1141        let (update, changes) = with_callback_info(|info| {
1142            screencap_writeback(RefAny::new(0_u32), RefAny::new(frame(1, 1)), info)
1143        });
1144
1145        assert_eq!(
1146            update,
1147            Update::DoNothing,
1148            "a foreign dataset means no hook and no texture - but no panic either"
1149        );
1150        assert!(
1151            changes.is_empty(),
1152            "no node owns that dataset, so nothing may be installed: {changes:?}"
1153        );
1154    }
1155
1156    #[test]
1157    fn writeback_keeps_a_preexisting_texture_id_on_the_cpu_path() {
1158        for current in [Some(0_u32), Some(42), Some(u32::MAX)] {
1159            let mut data = state(DEFAULT_CFG, true, current);
1160            let frame_data = RefAny::new(frame(2, 2));
1161
1162            let (update, _) = with_callback_info(|info| {
1163                screencap_writeback(data.clone(), frame_data.clone(), info)
1164            });
1165
1166            assert_eq!(update, Update::DoNothing, "no hook -> no user update");
1167            let (_, _, texture, _) = read_state(&mut data);
1168            assert_eq!(
1169                texture, current,
1170                "the stable texture id must survive the writeback unchanged"
1171            );
1172        }
1173    }
1174
1175    #[test]
1176    fn writeback_rejects_a_frame_whose_bytes_do_not_match_its_dimensions() {
1177        // A malformed/hostile backend frame: the image upload must fail cleanly
1178        // instead of indexing out of bounds or allocating ~17 GB.
1179        for (w, h, bytes) in [
1180            (u32::MAX, 1_u32, Vec::new()),
1181            (4, 4, vec![0_u8; 63]),
1182            (4, 4, vec![0_u8; 65]),
1183            (2, 2, Vec::new()),
1184        ] {
1185            let mut data = state(DEFAULT_CFG, true, None);
1186            let bogus = RefAny::new(frame_raw(w, h, bytes.clone()));
1187
1188            let (update, changes) =
1189                with_callback_info(|info| screencap_writeback(data.clone(), bogus.clone(), info));
1190
1191            assert_eq!(update, Update::DoNothing);
1192            assert!(
1193                changes.is_empty(),
1194                "a {w}x{h} frame with {} bytes must not touch the DOM: {changes:?}",
1195                bytes.len()
1196            );
1197            let (_, _, texture, _) = read_state(&mut data);
1198            assert_eq!(texture, None, "a rejected frame must not invent a texture id");
1199        }
1200    }
1201
1202    #[test]
1203    fn writeback_hands_even_a_rejected_frame_to_the_user_hook() {
1204        // FOOTGUN worth pinning: `present_frame` and `invoke_on_frame` are
1205        // independent. A frame the image pipeline rejects still reaches user code,
1206        // so `on_frame` is NOT a "this frame was valid" signal.
1207        let mut log = frame_log(Update::RefreshDom);
1208        let mut data = state_with_hook(DEFAULT_CFG, &log);
1209        let bogus = RefAny::new(frame_raw(u32::MAX, 1, Vec::new()));
1210
1211        let (update, changes) =
1212            with_callback_info(|info| screencap_writeback(data.clone(), bogus.clone(), info));
1213
1214        assert_eq!(update, Update::RefreshDom);
1215        assert!(changes.is_empty(), "the frame itself was rejected");
1216        assert_eq!(
1217            logged_frames(&mut log),
1218            vec![(u32::MAX, 1, 0)],
1219            "the hook sees the raw frame, dimensions and all, unvalidated"
1220        );
1221    }
1222
1223    #[test]
1224    fn writeback_accepts_a_zero_sized_frame_without_panicking() {
1225        // 0 * 0 * 4 == 0 == len(bytes), so a 0x0 frame passes the length check and
1226        // is installed as a degenerate image. Pin that it stays panic-free and
1227        // leaves the texture id alone.
1228        let mut data = state(DEFAULT_CFG, true, Some(2));
1229        let empty = RefAny::new(frame_raw(0, 0, Vec::new()));
1230
1231        let (update, _) =
1232            with_callback_info(|info| screencap_writeback(data.clone(), empty.clone(), info));
1233
1234        assert_eq!(update, Update::DoNothing);
1235        let (_, _, texture, _) = read_state(&mut data);
1236        assert_eq!(texture, Some(2));
1237    }
1238
1239    #[test]
1240    fn writeback_survives_dimensions_whose_byte_count_overflows_usize() {
1241        // ADVERSARIAL: a backend reporting 2^31 x 2^31 makes the CPU present path
1242        // compute `width * height * 4` in usize -> 2^64, which overflows. In a
1243        // debug build that is an arithmetic-overflow panic; in release it wraps to
1244        // 0 and the empty buffer is *accepted* as a valid 2^31 x 2^31 image.
1245        // Neither is a graceful rejection (see the autotest report) - what must
1246        // hold in both modes is that the widget's stored texture id is never
1247        // corrupted and the process is still usable afterwards.
1248        let mut data = state(DEFAULT_CFG, true, Some(11));
1249        let huge = RefAny::new(frame_raw(1_u32 << 31, 1_u32 << 31, Vec::new()));
1250
1251        let (result, _) = with_callback_info(|info| {
1252            catch_unwind(AssertUnwindSafe(|| {
1253                screencap_writeback(data.clone(), huge.clone(), info)
1254            }))
1255        });
1256
1257        match result {
1258            Ok(update) => {
1259                assert_eq!(update, Update::DoNothing);
1260                let (_, _, texture, _) = read_state(&mut data);
1261                assert_eq!(texture, Some(11), "the texture id must not be corrupted");
1262            }
1263            Err(_) => eprintln!(
1264                "NOTE: screencap_writeback panicked (usize overflow of width*height*4) for a \
1265                 2^31 x 2^31 frame - a malformed capture backend can take the process down"
1266            ),
1267        }
1268    }
1269
1270    // ------------------------------------------------------------------
1271    // merge_screencap_state
1272    // ------------------------------------------------------------------
1273
1274    #[test]
1275    fn merge_takes_the_thread_state_from_old_and_everything_else_from_new() {
1276        let fresh = cfg(
1277            ScreenCaptureSource::Window(u64::MAX),
1278            60,
1279            RawImageFormat::RGBA8,
1280        );
1281        let log = frame_log(Update::DoNothing);
1282        let new_data = state_with_hook(fresh, &log);
1283        let old_data = state(
1284            cfg(ScreenCaptureSource::Display(3), 1, RawImageFormat::R8),
1285            true,
1286            Some(9),
1287        );
1288
1289        let mut merged = merge_screencap_state(new_data, old_data);
1290        let (config, started, texture, has_hook) = read_state(&mut merged);
1291
1292        assert_eq!(config, fresh, "the fresh build's config wins");
1293        assert!(has_hook, "the fresh build's hook wins");
1294        assert!(started, "'thread already running' must carry forward");
1295        assert_eq!(texture, Some(9), "the stable texture id must carry forward");
1296    }
1297
1298    #[test]
1299    fn merge_lets_the_old_thread_state_overwrite_a_fresh_builds_claim() {
1300        // The old state is authoritative for `started` / `gl_texture_id` in BOTH
1301        // directions: a fresh build that (wrongly) claims to be running is reset,
1302        // so the thread is started exactly once per real mount.
1303        let new_data = RefAny::new(ScreenCaptureWidgetState {
1304            config: DEFAULT_CFG,
1305            started: true,
1306            gl_texture_id: Some(77),
1307            on_frame: OptionOnVideoFrame::None,
1308        });
1309        let old_data = state(DEFAULT_CFG, false, None);
1310
1311        let mut merged = merge_screencap_state(new_data, old_data);
1312        let (_, started, texture, _) = read_state(&mut merged);
1313
1314        assert!(!started, "the old state wins for `started`, in both directions");
1315        assert_eq!(texture, None, "and for the texture id too");
1316    }
1317
1318    #[test]
1319    fn merge_returns_the_new_payload_itself_not_a_copy() {
1320        let new_data = state(DEFAULT_CFG, false, None);
1321        let mut kept = new_data.clone();
1322
1323        let mut merged = merge_screencap_state(new_data, state(DEFAULT_CFG, true, Some(5)));
1324        {
1325            let mut s = merged
1326                .downcast_mut::<ScreenCaptureWidgetState>()
1327                .expect("merged state");
1328            s.gl_texture_id = Some(1);
1329        }
1330
1331        let (_, started, texture, _) = read_state(&mut kept);
1332        assert!(started, "the merge must have written into the new payload");
1333        assert_eq!(
1334            texture,
1335            Some(1),
1336            "merge must hand back the same allocation it was given"
1337        );
1338    }
1339
1340    #[test]
1341    fn merge_leaves_the_new_state_alone_when_the_old_one_is_foreign() {
1342        let new_data = state(DEFAULT_CFG, false, None);
1343        let mut merged = merge_screencap_state(new_data, RefAny::new(0_u32));
1344
1345        let (config, started, texture, _) = read_state(&mut merged);
1346        assert_eq!(config, DEFAULT_CFG);
1347        assert!(!started, "nothing to carry forward from a foreign payload");
1348        assert_eq!(texture, None);
1349    }
1350
1351    #[test]
1352    fn merge_returns_a_foreign_new_dataset_untouched() {
1353        let old_data = state(DEFAULT_CFG, true, Some(1));
1354        let mut merged = merge_screencap_state(RefAny::new(77_u32), old_data);
1355
1356        assert_eq!(
1357            merged.downcast_ref::<u32>().map(|v| *v),
1358            Some(77),
1359            "merge must hand back exactly the payload it was given"
1360        );
1361    }
1362
1363    #[test]
1364    fn merge_of_a_dataset_with_itself_does_not_panic() {
1365        // The same RefAny on both sides: the mutable + shared borrow overlap, so
1366        // the merge is skipped rather than aliasing. Either way the state must
1367        // survive intact.
1368        let mut data = state(DEFAULT_CFG, true, Some(5));
1369        let mut merged = merge_screencap_state(data.clone(), data.clone());
1370
1371        let (config, started, texture, _) = read_state(&mut merged);
1372        assert_eq!(config, DEFAULT_CFG);
1373        assert!(started);
1374        assert_eq!(texture, Some(5));
1375        assert_eq!(read_state(&mut data), (DEFAULT_CFG, true, Some(5), false));
1376    }
1377}