use alloc::vec::Vec;
use azul_core::callbacks::Update;
use azul_core::dom::{ComponentEventFilter, DatasetMergeCallbackType, Dom, EventFilter};
use azul_core::refany::{OptionRefAny, RefAny};
use azul_core::resources::{ImageRef, RawImageFormat};
use azul_core::screencap::ScreenCaptureConfig;
use azul_core::task::{ThreadId, ThreadReceiver};
use azul_core::video::VideoFrame;
use super::capture_common::{
invoke_on_frame, present_frame, screen_backend, OnVideoFrame, OnVideoFrameCallback,
OptionOnVideoFrame,
};
use crate::callbacks::{Callback, CallbackInfo, CallbackType};
use crate::thread::{
Thread, ThreadCallback, ThreadReceiveMsg, ThreadSender, ThreadWriteBackMsg, WriteBackCallback,
};
const DEFAULT_W: u32 = 1280;
const DEFAULT_H: u32 = 720;
#[derive(Debug)]
pub struct ScreenCaptureWidgetState {
pub config: ScreenCaptureConfig,
pub started: bool,
pub gl_texture_id: Option<u32>,
pub on_frame: OptionOnVideoFrame,
}
#[repr(C)]
#[derive(Debug)]
pub struct ScreenCaptureWidget {
pub config: ScreenCaptureConfig,
pub on_frame: OptionOnVideoFrame,
}
impl ScreenCaptureWidget {
#[must_use] pub const fn create(config: ScreenCaptureConfig) -> Self {
Self {
config,
on_frame: OptionOnVideoFrame::None,
}
}
pub fn set_on_frame<C: Into<OnVideoFrameCallback>>(&mut self, data: RefAny, on_frame: C) {
self.on_frame = Some(OnVideoFrame {
refany: data,
callback: on_frame.into(),
})
.into();
}
#[must_use]
pub fn with_on_frame<C: Into<OnVideoFrameCallback>>(
mut self,
data: RefAny,
on_frame: C,
) -> Self {
self.set_on_frame(data, on_frame);
self
}
#[must_use] pub fn dom(self) -> Dom {
let state = ScreenCaptureWidgetState {
config: self.config,
started: false,
gl_texture_id: None,
on_frame: self.on_frame,
};
let dataset = RefAny::new(state);
let placeholder = ImageRef::null_image(
DEFAULT_W as usize,
DEFAULT_H as usize,
RawImageFormat::BGRA8,
b"azul-screencap-placeholder".to_vec(),
);
Dom::create_image(placeholder)
.with_dataset(OptionRefAny::Some(dataset.clone()))
.with_merge_callback(azul_core::dom::DatasetMergeCallback::from_ptr(merge_screencap_state))
.with_callback(
EventFilter::Component(ComponentEventFilter::AfterMount),
dataset,
Callback::from_ptr(screencap_on_after_mount),
)
}
}
extern "C" fn screencap_on_after_mount(mut data: RefAny, mut info: CallbackInfo) -> Update {
{
let Some(mut s) = data.downcast_mut::<ScreenCaptureWidgetState>() else {
return Update::DoNothing;
};
if s.started {
return Update::DoNothing;
}
s.started = true;
}
info.add_thread(
ThreadId::unique(),
Thread::create(
RefAny::new(()),
data.clone(),
ThreadCallback::new(screencap_worker),
),
);
Update::DoNothing
}
extern "C" fn screencap_worker(_init: RefAny, mut sender: ThreadSender, _recv: ThreadReceiver) {
if let Some(backend) = screen_backend() {
let handle = (backend.open)(0, DEFAULT_W, DEFAULT_H);
if handle != 0 {
let mut buf: alloc::vec::Vec<u8> = alloc::vec::Vec::new();
loop {
let (fw, fh) = (backend.read)(handle, &mut buf);
if fw == 0 || fh == 0 {
break;
}
let frame = VideoFrame {
width: fw,
height: fh,
bytes: buf.clone().into(),
};
if !sender.send(ThreadReceiveMsg::WriteBack(ThreadWriteBackMsg::new(
WriteBackCallback::new(screencap_writeback),
RefAny::new(frame),
))) {
break;
}
}
(backend.close)(handle);
return;
}
}
let (w, h) = (DEFAULT_W as usize, DEFAULT_H as usize);
let mut tick: u32 = 0;
loop {
let band = (tick as usize) % h;
let mut bytes = Vec::with_capacity(w * h * 4);
for y in 0..h {
let v = if y.abs_diff(band) < 8 { 235u8 } else { 28u8 };
for _ in 0..w {
bytes.extend_from_slice(&[v, v, v, 255]);
}
}
let frame = VideoFrame {
width: u32::try_from(w).unwrap_or(0),
height: u32::try_from(h).unwrap_or(0),
bytes: bytes.into(),
};
let sent = sender.send(ThreadReceiveMsg::WriteBack(ThreadWriteBackMsg::new(
WriteBackCallback::new(screencap_writeback),
RefAny::new(frame),
)));
if !sent {
break;
}
std::thread::sleep(std::time::Duration::from_millis(33));
tick = tick.wrapping_add(12);
}
}
extern "C" fn screencap_writeback(
mut writeback_data: RefAny,
mut frame_data: RefAny,
mut info: CallbackInfo,
) -> Update {
let (current, hook) = writeback_data.downcast_ref::<ScreenCaptureWidgetState>().map_or_else(|| (None, OptionOnVideoFrame::None), |s| (s.gl_texture_id, s.on_frame.clone()));
let mut user_update = Update::DoNothing;
let new_id = match frame_data.downcast_ref::<VideoFrame>() {
Some(frame) => {
let id = present_frame(&mut info, writeback_data.clone(), current, &frame);
user_update = invoke_on_frame(&hook, &mut info, &frame);
id
}
None => return Update::DoNothing,
};
if let Some(mut s) = writeback_data.downcast_mut::<ScreenCaptureWidgetState>() {
s.gl_texture_id = new_id;
}
user_update
}
extern "C" fn merge_screencap_state(mut new_data: RefAny, mut old_data: RefAny) -> RefAny {
{
let new_guard = new_data.downcast_mut::<ScreenCaptureWidgetState>();
let old_guard = old_data.downcast_ref::<ScreenCaptureWidgetState>();
if let (Some(mut new_g), Some(old_g)) = (new_guard, old_guard) {
new_g.started = old_g.started;
new_g.gl_texture_id = old_g.gl_texture_id;
}
}
new_data
}
#[cfg(test)]
#[allow(clippy::too_many_lines, clippy::cast_possible_truncation)]
mod autotest_generated {
use std::{
collections::BTreeMap,
panic::{catch_unwind, AssertUnwindSafe},
sync::{
mpsc::{channel, Receiver, Sender},
Arc, Mutex, PoisonError,
},
};
use azul_core::{
dom::{DomId, DomNodeId, NodeType},
geom::OptionLogicalPosition,
gl::OptionGlContextPtr,
hit_test::ScrollPosition,
resources::{DecodedImage, RendererResources},
screencap::ScreenCaptureSource,
styled_dom::NodeHierarchyItemId,
task::{
OptionThreadSendMsg, ThreadReceiverDestructorCallback, ThreadReceiverInner,
ThreadRecvCallback, ThreadSendMsg,
},
window::{MonitorVec, RawWindowHandle},
};
use azul_css::system::SystemStyle;
use rust_fontconfig::FcFontCache;
use super::*;
#[cfg(feature = "icu")]
use crate::icu::IcuLocalizerHandle;
use crate::{
callbacks::{CallbackChange, CallbackInfoRefData, ExternalSystemCallbacks},
thread::{ThreadSendCallback, ThreadSenderDestructorCallback, ThreadSenderInner},
widgets::capture_common::OnVideoFrameCallbackType,
window::LayoutWindow,
window_state::FullWindowState,
};
const fn cfg(
source: ScreenCaptureSource,
fps: u32,
output_format: RawImageFormat,
) -> ScreenCaptureConfig {
ScreenCaptureConfig {
source,
fps,
output_format,
}
}
const ALL_CONFIGS: [ScreenCaptureConfig; 8] = [
cfg(ScreenCaptureSource::PrimaryDisplay, 0, RawImageFormat::BGRA8),
cfg(
ScreenCaptureSource::PrimaryDisplay,
u32::MAX,
RawImageFormat::RGBA8,
),
cfg(ScreenCaptureSource::Display(0), 1, RawImageFormat::BGRA8),
cfg(
ScreenCaptureSource::Display(u32::MAX),
60,
RawImageFormat::R8,
),
cfg(ScreenCaptureSource::Window(0), 0, RawImageFormat::BGRA8),
cfg(
ScreenCaptureSource::Window(u64::MAX),
u32::MAX,
RawImageFormat::R8,
),
cfg(
ScreenCaptureSource::Window(u32::MAX as u64),
30,
RawImageFormat::RGBA8,
),
cfg(ScreenCaptureSource::Display(1), 240, RawImageFormat::BGRA8),
];
const DEFAULT_CFG: ScreenCaptureConfig = ALL_CONFIGS[0];
const fn const_create(config: ScreenCaptureConfig) -> ScreenCaptureWidget {
ScreenCaptureWidget::create(config)
}
fn state(
config: ScreenCaptureConfig,
started: bool,
gl_texture_id: Option<u32>,
) -> RefAny {
RefAny::new(ScreenCaptureWidgetState {
config,
started,
gl_texture_id,
on_frame: OptionOnVideoFrame::None,
})
}
fn read_state(data: &mut RefAny) -> (ScreenCaptureConfig, bool, Option<u32>, bool) {
let s = data
.downcast_ref::<ScreenCaptureWidgetState>()
.expect("payload must still be a ScreenCaptureWidgetState");
(
s.config,
s.started,
s.gl_texture_id,
matches!(s.on_frame, OptionOnVideoFrame::Some(_)),
)
}
fn placeholder_of(dom: &Dom) -> (usize, usize, RawImageFormat, Vec<u8>) {
let NodeType::Image(image) = dom.root.get_node_type() else {
panic!("ScreenCaptureWidget::dom must build an image node");
};
match image.get_data() {
DecodedImage::NullImage {
width,
height,
format,
tag,
} => (*width, *height, *format, tag.clone()),
_ => panic!("the placeholder must be a NullImage (no decode, no allocation)"),
}
}
struct FrameLog {
seen: Vec<(u32, u32, usize)>,
reply: Update,
}
extern "C" fn record_frame(mut data: RefAny, _: CallbackInfo, frame: VideoFrame) -> Update {
let mut reply = Update::DoNothing;
if let Some(mut log) = data.downcast_mut::<FrameLog>() {
log.seen
.push((frame.width, frame.height, frame.bytes.as_ref().len()));
reply = log.reply;
}
reply
}
extern "C" fn frame_do_nothing(_: RefAny, _: CallbackInfo, _: VideoFrame) -> Update {
core::hint::black_box(Update::DoNothing)
}
fn frame_log(reply: Update) -> RefAny {
RefAny::new(FrameLog {
seen: Vec::new(),
reply,
})
}
fn logged_frames(data: &mut RefAny) -> Vec<(u32, u32, usize)> {
data.downcast_ref::<FrameLog>()
.expect("payload must still be a FrameLog")
.seen
.clone()
}
fn state_with_hook(config: ScreenCaptureConfig, log: &RefAny) -> RefAny {
RefAny::new(ScreenCaptureWidgetState {
config,
started: true,
gl_texture_id: None,
on_frame: Some(OnVideoFrame {
refany: log.clone(),
callback: (record_frame as OnVideoFrameCallbackType).into(),
})
.into(),
})
}
fn frame(width: u32, height: u32) -> VideoFrame {
let px = (width as usize) * (height as usize);
VideoFrame {
width,
height,
bytes: vec![7u8; px * 4].into(),
}
}
fn frame_raw(width: u32, height: u32, bytes: Vec<u8>) -> VideoFrame {
VideoFrame {
width,
height,
bytes: bytes.into(),
}
}
fn with_callback_info<R>(f: impl FnOnce(CallbackInfo) -> R) -> (R, Vec<CallbackChange>) {
let layout_window =
LayoutWindow::new(FcFontCache::default()).expect("LayoutWindow::new failed");
let renderer_resources = RendererResources::default();
let previous_window_state: Option<FullWindowState> = None;
let current_window_state = FullWindowState::default();
let gl_context = OptionGlContextPtr::None;
let scroll_states: BTreeMap<DomId, BTreeMap<NodeHierarchyItemId, ScrollPosition>> =
BTreeMap::new();
let window_handle = RawWindowHandle::Unsupported;
let system_callbacks = ExternalSystemCallbacks::rust_internal();
let ref_data = CallbackInfoRefData {
layout_window: &layout_window,
renderer_resources: &renderer_resources,
previous_window_state: &previous_window_state,
current_window_state: ¤t_window_state,
gl_context: &gl_context,
current_scroll_manager: &scroll_states,
current_window_handle: &window_handle,
system_callbacks: &system_callbacks,
system_style: Arc::new(SystemStyle::default()),
monitors: Arc::new(Mutex::new(MonitorVec::from_const_slice(&[]))),
#[cfg(feature = "icu")]
icu_localizer: IcuLocalizerHandle::default(),
ctx: OptionRefAny::None,
};
let changes: Arc<Mutex<Vec<CallbackChange>>> = Arc::new(Mutex::new(Vec::new()));
let info = CallbackInfo::new(
&ref_data,
&changes,
DomNodeId {
dom: DomId::ROOT_ID,
node: NodeHierarchyItemId::NONE,
},
OptionLogicalPosition::None,
OptionLogicalPosition::None,
);
let out = f(info);
let recorded = core::mem::take(&mut *changes.lock().expect("change log poisoned"));
(out, recorded)
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct SentFrame {
width: u32,
height: u32,
len: usize,
row_values: Vec<u8>,
rows_uniform_opaque: bool,
}
static WORKER_LOG: Mutex<Vec<SentFrame>> = Mutex::new(Vec::new());
static WORKER_GATE: Mutex<()> = Mutex::new(());
extern "C" fn record_and_stop(_sender: *const core::ffi::c_void, msg: ThreadReceiveMsg) -> bool {
if let ThreadReceiveMsg::WriteBack(mut wb) = msg {
if let Some(f) = wb.refany.downcast_ref::<VideoFrame>() {
let bytes = f.bytes.as_ref();
let stride = (f.width as usize) * 4;
let mut row_values = Vec::new();
let mut rows_uniform_opaque = true;
if stride > 0 {
for row in bytes.chunks_exact(stride) {
let v = row[0];
row_values.push(v);
if !row.chunks_exact(4).all(|px| px == &[v, v, v, 255][..]) {
rows_uniform_opaque = false;
}
}
}
WORKER_LOG
.lock()
.unwrap_or_else(PoisonError::into_inner)
.push(SentFrame {
width: f.width,
height: f.height,
len: bytes.len(),
row_values,
rows_uniform_opaque,
});
}
}
false
}
extern "C" fn sender_drop_noop(_: *mut ThreadSenderInner) {}
extern "C" fn receiver_drop_noop(_: *mut ThreadReceiverInner) {}
extern "C" fn recv_nothing(_: *const core::ffi::c_void) -> OptionThreadSendMsg {
OptionThreadSendMsg::None
}
fn stopped_sender() -> (Receiver<ThreadReceiveMsg>, ThreadSender) {
let (tx, rx) = channel::<ThreadReceiveMsg>();
let sender = ThreadSender::new(ThreadSenderInner {
ptr: Box::new(tx),
send_fn: ThreadSendCallback { cb: record_and_stop },
destructor: ThreadSenderDestructorCallback {
cb: sender_drop_noop,
},
});
(rx, sender)
}
fn silent_receiver() -> (Sender<ThreadSendMsg>, ThreadReceiver) {
let (tx, rx) = channel::<ThreadSendMsg>();
let receiver = ThreadReceiver::new(ThreadReceiverInner {
ptr: Box::new(rx),
recv_fn: ThreadRecvCallback { cb: recv_nothing },
destructor: ThreadReceiverDestructorCallback {
cb: receiver_drop_noop,
},
});
(tx, receiver)
}
fn run_worker(init: RefAny) -> Option<Vec<SentFrame>> {
let _gate = WORKER_GATE.lock().unwrap_or_else(PoisonError::into_inner);
if screen_backend().is_some() {
return None;
}
WORKER_LOG
.lock()
.unwrap_or_else(PoisonError::into_inner)
.clear();
let (_rx, sender) = stopped_sender();
let (_tx, receiver) = silent_receiver();
screencap_worker(init, sender, receiver);
if screen_backend().is_some() {
return None; }
Some(
WORKER_LOG
.lock()
.unwrap_or_else(PoisonError::into_inner)
.clone(),
)
}
#[test]
fn create_stores_the_config_verbatim_and_leaves_the_hook_unset() {
for config in ALL_CONFIGS {
let widget = ScreenCaptureWidget::create(config);
assert_eq!(
widget.config, config,
"create must not normalise or clamp the config"
);
assert!(
matches!(widget.on_frame, OptionOnVideoFrame::None),
"a fresh widget has no frame hook"
);
}
}
#[test]
fn create_preserves_the_full_source_payload_width() {
let widget = ScreenCaptureWidget::create(cfg(
ScreenCaptureSource::Window(u64::MAX),
0,
RawImageFormat::BGRA8,
));
match widget.config.source {
ScreenCaptureSource::Window(h) => assert_eq!(h, u64::MAX),
other => panic!("expected Window(u64::MAX), got {other:?}"),
}
let widget = ScreenCaptureWidget::create(cfg(
ScreenCaptureSource::Display(u32::MAX),
u32::MAX,
RawImageFormat::BGRA8,
));
match widget.config.source {
ScreenCaptureSource::Display(i) => assert_eq!(i, u32::MAX),
other => panic!("expected Display(u32::MAX), got {other:?}"),
}
assert_eq!(widget.config.fps, u32::MAX, "fps must not be clamped");
}
#[test]
fn create_is_usable_from_a_const_fn() {
for config in ALL_CONFIGS {
let widget = const_create(config);
assert_eq!(widget.config, config);
assert!(matches!(widget.on_frame, OptionOnVideoFrame::None));
}
}
#[test]
fn set_on_frame_installs_the_hook_without_touching_the_config() {
for config in ALL_CONFIGS {
let mut widget = ScreenCaptureWidget::create(config);
widget.set_on_frame(
frame_log(Update::DoNothing),
record_frame as OnVideoFrameCallbackType,
);
assert_eq!(widget.config, config, "the hook must not alter the config");
let OptionOnVideoFrame::Some(hook) = &widget.on_frame else {
panic!("set_on_frame must install a hook");
};
assert_eq!(
hook.callback.cb as usize,
record_frame as OnVideoFrameCallbackType as usize,
"the stored fn pointer must be exactly the one that was passed in"
);
}
}
#[test]
fn set_on_frame_twice_keeps_only_the_last_hook() {
let mut widget = ScreenCaptureWidget::create(DEFAULT_CFG);
widget.set_on_frame(
RefAny::new(0_usize),
record_frame as OnVideoFrameCallbackType,
);
widget.set_on_frame(
RefAny::new(1_usize),
frame_do_nothing as OnVideoFrameCallbackType,
);
let OptionOnVideoFrame::Some(hook) = &widget.on_frame else {
panic!("hook must still be set");
};
assert_eq!(
hook.callback.cb as usize,
frame_do_nothing as OnVideoFrameCallbackType as usize,
"the second set_on_frame must replace the first, not stack"
);
assert_eq!(
hook.refany.clone().downcast_ref::<usize>().map(|v| *v),
Some(1),
"the replacement's payload must come with it"
);
}
#[test]
fn set_on_frame_shares_the_users_payload_rather_than_copying_it() {
let mut log = frame_log(Update::DoNothing);
let mut widget = ScreenCaptureWidget::create(DEFAULT_CFG);
widget.set_on_frame(log.clone(), record_frame as OnVideoFrameCallbackType);
let OptionOnVideoFrame::Some(hook) = &widget.on_frame else {
panic!("hook must be set");
};
let mut stored = hook.refany.clone();
{
let mut inner = stored
.downcast_mut::<FrameLog>()
.expect("the widget must hold a FrameLog");
inner.seen.push((1, 2, 3));
}
assert_eq!(
logged_frames(&mut log),
vec![(1, 2, 3)],
"the widget must share the caller's payload, not clone it"
);
}
#[test]
fn with_on_frame_is_exactly_create_plus_set_on_frame() {
for config in ALL_CONFIGS {
let built = ScreenCaptureWidget::create(config).with_on_frame(
frame_log(Update::RefreshDom),
record_frame as OnVideoFrameCallbackType,
);
let mut manual = ScreenCaptureWidget::create(config);
manual.set_on_frame(
frame_log(Update::RefreshDom),
record_frame as OnVideoFrameCallbackType,
);
assert_eq!(built.config, config, "the builder must not touch the config");
assert_eq!(built.config, manual.config);
let (OptionOnVideoFrame::Some(a), OptionOnVideoFrame::Some(b)) =
(&built.on_frame, &manual.on_frame)
else {
panic!("both forms must install a hook");
};
assert_eq!(a.callback.cb as usize, b.callback.cb as usize);
}
}
#[test]
fn dom_placeholder_is_always_1280x720_bgra8_whatever_the_config_asks_for() {
for config in ALL_CONFIGS {
let (w, h, format, tag) = placeholder_of(&ScreenCaptureWidget::create(config).dom());
assert_eq!(
(w, h),
(1280, 720),
"the placeholder size is fixed, not derived from {config:?}"
);
assert_eq!(
format,
RawImageFormat::BGRA8,
"output_format is a *capture* request; the placeholder stays BGRA8"
);
assert_eq!(tag, b"azul-screencap-placeholder".to_vec());
}
}
#[test]
fn dom_placeholder_is_a_null_image_that_allocates_no_pixels() {
let dom = ScreenCaptureWidget::create(DEFAULT_CFG).dom();
let NodeType::Image(image) = dom.root.get_node_type() else {
panic!("the widget must build an image node");
};
assert!(
matches!(image.get_data(), DecodedImage::NullImage { .. }),
"the placeholder must not decode or allocate"
);
}
#[test]
fn dom_wires_exactly_one_after_mount_callback_a_dataset_and_a_merge_callback() {
let dom = ScreenCaptureWidget::create(DEFAULT_CFG).dom();
assert_eq!(dom.children.as_ref().len(), 0, "the widget is a single node");
let callbacks = dom.root.get_callbacks();
assert_eq!(
callbacks.as_ref().len(),
1,
"exactly one callback: the AfterMount capture-thread starter"
);
assert_eq!(
callbacks.as_ref()[0].event,
EventFilter::Component(ComponentEventFilter::AfterMount),
"the thread must start on AfterMount, not on any input event"
);
assert_eq!(
callbacks.as_ref()[0].callback.cb,
screencap_on_after_mount as CallbackType as usize,
"the wired callback must be screencap_on_after_mount"
);
let merge = dom
.root
.get_merge_callback()
.expect("state must survive relayout");
assert_eq!(
merge.cb as usize,
merge_screencap_state as DatasetMergeCallbackType as usize,
"the merge callback must be merge_screencap_state"
);
}
#[test]
fn dom_seeds_the_dataset_with_the_config_and_a_not_yet_started_thread() {
for config in ALL_CONFIGS {
let dom = ScreenCaptureWidget::create(config).dom();
let mut dataset = dom
.root
.get_dataset()
.cloned()
.expect("the node must carry its ScreenCaptureWidgetState");
let (stored, started, texture, has_hook) = read_state(&mut dataset);
assert_eq!(stored, config, "dom() must not rewrite the config");
assert!(!started, "the capture thread only starts on AfterMount");
assert_eq!(texture, None, "no texture exists before the first frame");
assert!(!has_hook, "no hook was set on this widget");
}
}
#[test]
fn dom_moves_the_on_frame_hook_into_the_dataset() {
let dom = ScreenCaptureWidget::create(DEFAULT_CFG)
.with_on_frame(
frame_log(Update::DoNothing),
record_frame as OnVideoFrameCallbackType,
)
.dom();
let mut dataset = dom.root.get_dataset().cloned().expect("dataset");
let (_, _, _, has_hook) = read_state(&mut dataset);
assert!(has_hook, "dom() must carry the user hook into the state");
}
#[test]
fn dom_gives_the_after_mount_callback_the_very_same_state_the_node_carries() {
let dom = ScreenCaptureWidget::create(DEFAULT_CFG).dom();
let mut node_ds = dom.root.get_dataset().cloned().expect("dataset");
let mut cb_ds = dom.root.get_callbacks().as_ref()[0].refany.clone();
{
let mut s = cb_ds
.downcast_mut::<ScreenCaptureWidgetState>()
.expect("the callback's payload must be the widget state");
s.started = true;
s.gl_texture_id = Some(1234);
}
let (_, started, texture, _) = read_state(&mut node_ds);
assert!(
started,
"the callback and the node must share one state, not two copies"
);
assert_eq!(texture, Some(1234));
}
#[test]
fn two_widgets_built_from_one_config_get_independent_state() {
let a = ScreenCaptureWidget::create(cfg(
ScreenCaptureSource::Display(0),
30,
RawImageFormat::BGRA8,
))
.dom();
let b = ScreenCaptureWidget::create(cfg(
ScreenCaptureSource::Window(7),
60,
RawImageFormat::RGBA8,
))
.dom();
let mut da = a.root.get_dataset().cloned().expect("dataset a");
let mut db = b.root.get_dataset().cloned().expect("dataset b");
{
let mut s = da
.downcast_mut::<ScreenCaptureWidgetState>()
.expect("state a");
s.started = true;
}
let (config_a, started_a, _, _) = read_state(&mut da);
let (config_b, started_b, _, _) = read_state(&mut db);
assert!(started_a);
assert!(
!started_b,
"two widgets must not share one global capture state"
);
assert_eq!(config_a.source, ScreenCaptureSource::Display(0));
assert_eq!(config_b.source, ScreenCaptureSource::Window(7));
}
#[test]
fn after_mount_ignores_a_dataset_that_is_not_a_screencap_state() {
for foreign in [RefAny::new(0_u32), RefAny::new(DEFAULT_CFG)] {
let (update, changes) =
with_callback_info(|info| screencap_on_after_mount(foreign.clone(), info));
assert_eq!(update, Update::DoNothing);
assert!(
changes.is_empty(),
"a foreign dataset must not start a capture thread: {changes:?}"
);
}
}
#[test]
fn after_mount_is_a_no_op_once_the_thread_has_started() {
let log = frame_log(Update::RefreshDom);
let mut data = state_with_hook(DEFAULT_CFG, &log);
{
let mut s = data
.downcast_mut::<ScreenCaptureWidgetState>()
.expect("state");
s.gl_texture_id = Some(3);
}
for _ in 0..3 {
let (update, changes) =
with_callback_info(|info| screencap_on_after_mount(data.clone(), info));
assert_eq!(update, Update::DoNothing);
assert!(
changes.is_empty(),
"AfterMount must start the capture thread at most once: {changes:?}"
);
}
let (config, started, texture, has_hook) = read_state(&mut data);
assert_eq!(config, DEFAULT_CFG, "a re-mount must not rewrite the config");
assert!(started);
assert_eq!(texture, Some(3), "a re-mount must not drop the texture");
assert!(has_hook, "a re-mount must not drop the user hook");
}
#[test]
fn worker_stops_as_soon_as_the_main_thread_stops_receiving() {
let Some(sent) = run_worker(RefAny::new(())) else {
return; };
assert_eq!(
sent.len(),
1,
"the worker must stop after the first rejected send, not spin"
);
assert_eq!(
(sent[0].width, sent[0].height),
(DEFAULT_W, DEFAULT_H),
"the test pattern is emitted at the widget's default capture size"
);
assert_eq!(
sent[0].len,
(DEFAULT_W as usize) * (DEFAULT_H as usize) * 4,
"the frame must be tightly-packed RGBA8: w * h * 4 bytes"
);
}
#[test]
fn worker_emits_the_documented_band_pattern_on_its_first_frame() {
let Some(sent) = run_worker(RefAny::new(())) else {
return;
};
let f = &sent[0];
assert!(
f.rows_uniform_opaque,
"every pixel must be an opaque grey [v, v, v, 255]"
);
assert_eq!(
f.row_values.len(),
DEFAULT_H as usize,
"one value per scanline"
);
assert!(
f.row_values[..8].iter().all(|&v| v == 235),
"rows 0..8 are the bright band, got {:?}",
&f.row_values[..8]
);
assert!(
f.row_values[8..].iter().all(|&v| v == 28),
"every row below the band is dark grey"
);
}
#[test]
fn worker_ignores_its_init_payload_entirely() {
let Some(unit) = run_worker(RefAny::new(())) else {
return;
};
let Some(text) = run_worker(RefAny::new("not an init struct")) else {
return;
};
let Some(widget_state) = run_worker(state(
cfg(
ScreenCaptureSource::Window(u64::MAX),
u32::MAX,
RawImageFormat::R8,
),
true,
Some(u32::MAX),
)) else {
return;
};
assert_eq!(unit, text, "a foreign init must not change the frames");
assert_eq!(
unit, widget_state,
"even a full widget state (fps = u32::MAX, R8) must not change the \
test pattern - it is hard-coded"
);
}
#[test]
fn writeback_invokes_the_hook_with_the_frame_and_returns_its_update() {
for reply in [
Update::DoNothing,
Update::RefreshDom,
Update::RefreshDomAllWindows,
] {
let mut log = frame_log(reply);
let mut data = state_with_hook(DEFAULT_CFG, &log);
let frame_data = RefAny::new(frame(2, 2));
let (update, _) = with_callback_info(|info| {
screencap_writeback(data.clone(), frame_data.clone(), info)
});
assert_eq!(update, reply, "the user hook's Update must be returned as-is");
assert_eq!(logged_frames(&mut log), vec![(2, 2, 16)]);
let (_, _, texture, _) = read_state(&mut data);
assert_eq!(
texture, None,
"without a GL context no texture id is ever installed"
);
}
}
#[test]
fn writeback_ignores_frame_data_of_the_wrong_type() {
let mut log = frame_log(Update::RefreshDom);
let mut data = state_with_hook(DEFAULT_CFG, &log);
let (update, changes) =
with_callback_info(|info| screencap_writeback(data.clone(), RefAny::new(0_u32), info));
assert_eq!(update, Update::DoNothing);
assert!(changes.is_empty(), "no frame -> no image change");
assert!(
logged_frames(&mut log).is_empty(),
"the user hook must not fire without a frame"
);
}
#[test]
fn writeback_survives_a_writeback_dataset_that_is_not_a_screencap_state() {
let (update, changes) = with_callback_info(|info| {
screencap_writeback(RefAny::new(0_u32), RefAny::new(frame(1, 1)), info)
});
assert_eq!(
update,
Update::DoNothing,
"a foreign dataset means no hook and no texture - but no panic either"
);
assert!(
changes.is_empty(),
"no node owns that dataset, so nothing may be installed: {changes:?}"
);
}
#[test]
fn writeback_keeps_a_preexisting_texture_id_on_the_cpu_path() {
for current in [Some(0_u32), Some(42), Some(u32::MAX)] {
let mut data = state(DEFAULT_CFG, true, current);
let frame_data = RefAny::new(frame(2, 2));
let (update, _) = with_callback_info(|info| {
screencap_writeback(data.clone(), frame_data.clone(), info)
});
assert_eq!(update, Update::DoNothing, "no hook -> no user update");
let (_, _, texture, _) = read_state(&mut data);
assert_eq!(
texture, current,
"the stable texture id must survive the writeback unchanged"
);
}
}
#[test]
fn writeback_rejects_a_frame_whose_bytes_do_not_match_its_dimensions() {
for (w, h, bytes) in [
(u32::MAX, 1_u32, Vec::new()),
(4, 4, vec![0_u8; 63]),
(4, 4, vec![0_u8; 65]),
(2, 2, Vec::new()),
] {
let mut data = state(DEFAULT_CFG, true, None);
let bogus = RefAny::new(frame_raw(w, h, bytes.clone()));
let (update, changes) =
with_callback_info(|info| screencap_writeback(data.clone(), bogus.clone(), info));
assert_eq!(update, Update::DoNothing);
assert!(
changes.is_empty(),
"a {w}x{h} frame with {} bytes must not touch the DOM: {changes:?}",
bytes.len()
);
let (_, _, texture, _) = read_state(&mut data);
assert_eq!(texture, None, "a rejected frame must not invent a texture id");
}
}
#[test]
fn writeback_hands_even_a_rejected_frame_to_the_user_hook() {
let mut log = frame_log(Update::RefreshDom);
let mut data = state_with_hook(DEFAULT_CFG, &log);
let bogus = RefAny::new(frame_raw(u32::MAX, 1, Vec::new()));
let (update, changes) =
with_callback_info(|info| screencap_writeback(data.clone(), bogus.clone(), info));
assert_eq!(update, Update::RefreshDom);
assert!(changes.is_empty(), "the frame itself was rejected");
assert_eq!(
logged_frames(&mut log),
vec![(u32::MAX, 1, 0)],
"the hook sees the raw frame, dimensions and all, unvalidated"
);
}
#[test]
fn writeback_accepts_a_zero_sized_frame_without_panicking() {
let mut data = state(DEFAULT_CFG, true, Some(2));
let empty = RefAny::new(frame_raw(0, 0, Vec::new()));
let (update, _) =
with_callback_info(|info| screencap_writeback(data.clone(), empty.clone(), info));
assert_eq!(update, Update::DoNothing);
let (_, _, texture, _) = read_state(&mut data);
assert_eq!(texture, Some(2));
}
#[test]
fn writeback_survives_dimensions_whose_byte_count_overflows_usize() {
let mut data = state(DEFAULT_CFG, true, Some(11));
let huge = RefAny::new(frame_raw(1_u32 << 31, 1_u32 << 31, Vec::new()));
let (result, _) = with_callback_info(|info| {
catch_unwind(AssertUnwindSafe(|| {
screencap_writeback(data.clone(), huge.clone(), info)
}))
});
match result {
Ok(update) => {
assert_eq!(update, Update::DoNothing);
let (_, _, texture, _) = read_state(&mut data);
assert_eq!(texture, Some(11), "the texture id must not be corrupted");
}
Err(_) => eprintln!(
"NOTE: screencap_writeback panicked (usize overflow of width*height*4) for a \
2^31 x 2^31 frame - a malformed capture backend can take the process down"
),
}
}
#[test]
fn merge_takes_the_thread_state_from_old_and_everything_else_from_new() {
let fresh = cfg(
ScreenCaptureSource::Window(u64::MAX),
60,
RawImageFormat::RGBA8,
);
let log = frame_log(Update::DoNothing);
let new_data = state_with_hook(fresh, &log);
let old_data = state(
cfg(ScreenCaptureSource::Display(3), 1, RawImageFormat::R8),
true,
Some(9),
);
let mut merged = merge_screencap_state(new_data, old_data);
let (config, started, texture, has_hook) = read_state(&mut merged);
assert_eq!(config, fresh, "the fresh build's config wins");
assert!(has_hook, "the fresh build's hook wins");
assert!(started, "'thread already running' must carry forward");
assert_eq!(texture, Some(9), "the stable texture id must carry forward");
}
#[test]
fn merge_lets_the_old_thread_state_overwrite_a_fresh_builds_claim() {
let new_data = RefAny::new(ScreenCaptureWidgetState {
config: DEFAULT_CFG,
started: true,
gl_texture_id: Some(77),
on_frame: OptionOnVideoFrame::None,
});
let old_data = state(DEFAULT_CFG, false, None);
let mut merged = merge_screencap_state(new_data, old_data);
let (_, started, texture, _) = read_state(&mut merged);
assert!(!started, "the old state wins for `started`, in both directions");
assert_eq!(texture, None, "and for the texture id too");
}
#[test]
fn merge_returns_the_new_payload_itself_not_a_copy() {
let new_data = state(DEFAULT_CFG, false, None);
let mut kept = new_data.clone();
let mut merged = merge_screencap_state(new_data, state(DEFAULT_CFG, true, Some(5)));
{
let mut s = merged
.downcast_mut::<ScreenCaptureWidgetState>()
.expect("merged state");
s.gl_texture_id = Some(1);
}
let (_, started, texture, _) = read_state(&mut kept);
assert!(started, "the merge must have written into the new payload");
assert_eq!(
texture,
Some(1),
"merge must hand back the same allocation it was given"
);
}
#[test]
fn merge_leaves_the_new_state_alone_when_the_old_one_is_foreign() {
let new_data = state(DEFAULT_CFG, false, None);
let mut merged = merge_screencap_state(new_data, RefAny::new(0_u32));
let (config, started, texture, _) = read_state(&mut merged);
assert_eq!(config, DEFAULT_CFG);
assert!(!started, "nothing to carry forward from a foreign payload");
assert_eq!(texture, None);
}
#[test]
fn merge_returns_a_foreign_new_dataset_untouched() {
let old_data = state(DEFAULT_CFG, true, Some(1));
let mut merged = merge_screencap_state(RefAny::new(77_u32), old_data);
assert_eq!(
merged.downcast_ref::<u32>().map(|v| *v),
Some(77),
"merge must hand back exactly the payload it was given"
);
}
#[test]
fn merge_of_a_dataset_with_itself_does_not_panic() {
let mut data = state(DEFAULT_CFG, true, Some(5));
let mut merged = merge_screencap_state(data.clone(), data.clone());
let (config, started, texture, _) = read_state(&mut merged);
assert_eq!(config, DEFAULT_CFG);
assert!(started);
assert_eq!(texture, Some(5));
assert_eq!(read_state(&mut data), (DEFAULT_CFG, true, Some(5), false));
}
}