1use 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
32const DEFAULT_W: u32 = 1280;
35const DEFAULT_H: u32 = 720;
36
37#[derive(Debug)]
40pub struct ScreenCaptureWidgetState {
41 pub config: ScreenCaptureConfig,
43 pub started: bool,
45 pub gl_texture_id: Option<u32>,
47 pub on_frame: OptionOnVideoFrame,
50}
51
52#[repr(C)]
55#[derive(Debug)]
56pub struct ScreenCaptureWidget {
57 pub config: ScreenCaptureConfig,
59 pub on_frame: OptionOnVideoFrame,
61}
62
63impl ScreenCaptureWidget {
64 #[must_use] pub const fn create(config: ScreenCaptureConfig) -> Self {
66 Self {
67 config,
68 on_frame: OptionOnVideoFrame::None,
69 }
70 }
71
72 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 #[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 #[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
123extern "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
145extern "C" fn screencap_worker(_init: RefAny, mut sender: ThreadSender, _recv: ThreadReceiver) {
148 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
204extern "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
227extern "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#[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 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 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 const fn const_create(config: ScreenCaptureConfig) -> ScreenCaptureWidget {
336 ScreenCaptureWidget::create(config)
337 }
338
339 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 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 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 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 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 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 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 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 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 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: ¤t_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 #[derive(Debug, Clone, PartialEq, Eq)]
515 struct SentFrame {
516 width: u32,
517 height: u32,
518 len: usize,
519 row_values: Vec<u8>,
521 rows_uniform_opaque: bool,
523 }
524
525 static WORKER_LOG: Mutex<Vec<SentFrame>> = Mutex::new(Vec::new());
529 static WORKER_GATE: Mutex<()> = Mutex::new(());
530
531 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 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 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 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; }
623 Some(
624 WORKER_LOG
625 .lock()
626 .unwrap_or_else(PoisonError::into_inner)
627 .clone(),
628 )
629 }
630
631 #[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 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 #[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 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 #[test]
791 fn dom_placeholder_is_always_1280x720_bgra8_whatever_the_config_asks_for() {
792 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 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 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 #[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 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 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 #[test]
1012 fn worker_stops_as_soon_as_the_main_thread_stops_receiving() {
1013 let Some(sent) = run_worker(RefAny::new(())) else {
1014 return; };
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 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 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 #[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 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 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 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 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 #[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 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 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}