diff --git a/.gitignore b/.gitignore index bf9fb189..e09114e2 100644 --- a/.gitignore +++ b/.gitignore @@ -152,7 +152,9 @@ WPrefs.app/WPrefs.desktop # Rust stuff. /**/target/** -WINGs/wings-rs/src/WINGsP.rs +WINGs/wings-rs-tests/Cargo.lock WINGs/wings-rs/Cargo.lock +WINGs/wings-rs/src/WINGsP.rs wmaker-rs/Cargo.lock +wrlib-rs/src/ffi.rs wutil-rs/Cargo.lock diff --git a/WINGs/Makefile.am b/WINGs/Makefile.am index 22c4b25c..942c619c 100644 --- a/WINGs/Makefile.am +++ b/WINGs/Makefile.am @@ -2,7 +2,7 @@ AUTOMAKE_OPTIONS = -SUBDIRS = WINGs wings-rs . po Documentation Resources +SUBDIRS = WINGs wings-rs wings-rs-tests . po Documentation Resources DIST_SUBDIRS = $(SUBDIRS) Tests Examples Extras libWINGs_la_LDFLAGS = -version-info @WINGS_VERSION@ diff --git a/WINGs/wings-rs-tests/Cargo.lock b/WINGs/wings-rs-tests/Cargo.lock index 0826c6d7..516cfed1 100644 --- a/WINGs/wings-rs-tests/Cargo.lock +++ b/WINGs/wings-rs-tests/Cargo.lock @@ -903,6 +903,7 @@ version = "0.1.0" dependencies = [ "insta", "insta-image", + "libc", "png", "tempdir", "wings-rs", diff --git a/WINGs/wings-rs-tests/Cargo.toml b/WINGs/wings-rs-tests/Cargo.toml new file mode 100644 index 00000000..64cd64e2 --- /dev/null +++ b/WINGs/wings-rs-tests/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "wings-rs-tests" +version = "0.1.0" +edition = "2024" + +[dependencies] +libc = "0.2" +insta = { git = "https://github.com/mitsuhiko/insta.git", branch = "master" } +insta-image = { git = "https://git.sdf.org/trurl/insta-image.git", branch = "main", features = ["png"] } +png = "0.18" +tempdir = "0.3.7" +wings-rs = { path = "../wings-rs" } +wutil-rs = { path = "../../wutil-rs" } +x11 = "2.21.0" + +[profile.dev.package] +insta.opt-level = 3 +similar.opt-level = 3 diff --git a/WINGs/wings-rs-tests/Makefile.am b/WINGs/wings-rs-tests/Makefile.am new file mode 100644 index 00000000..ec5fb970 --- /dev/null +++ b/WINGs/wings-rs-tests/Makefile.am @@ -0,0 +1,40 @@ +AUTOMAKE_OPTIONS = + +RUST_SOURCES = \ + src/lib.rs \ + src/headless/mod.rs \ + src/headless/xvfb.rs \ + src/headless/xwd.rs \ + src/headless/snapshots/wings_rs_tests__headless__xvfb__tests__blank_screen.snap \ + src/headless/snapshots/wings_rs_tests__headless__xvfb__tests__blank_screen.snap.png \ + src/headless/snapshots/wings_rs_tests__headless__xvfb__tests__snowlamp_in_window.snap \ + src/headless/snapshots/wings_rs_tests__headless__xvfb__tests__snowlamp_in_window.snap.png \ + src/headless/snapshots/wings_rs_tests__headless__xvfb__tests__xeyes.snap \ + src/headless/snapshots/wings_rs_tests__headless__xvfb__tests__xeyes.snap.png \ + src/headless/snapshots/wings_rs_tests__headless__xwd__tests__snowlamp_encoded.snap \ + src/headless/snapshots/wings_rs_tests__headless__xwd__tests__snowlamp_encoded.snap.png + +RUST_EXTRA = \ + Cargo.lock \ + Cargo.toml + +wutil_c_lib = $(top_builddir)/WINGs/libWUtil.la +wings_c_lib = $(top_builddir)/WINGs/libWINGs.la +wings_rs_lib = $(top_builddir)/WINGs/wings-rs/target/debug/libwings_rs.a +wings_rs_test_lib = target/debug/libwings_rs_tests.rlib + +rustlib: $(RUST_SOURCES) $(RUST_EXTRA) + cargo build + +check-local: rustlib + +clean-local: + $(CARGO) clean + +all: rustlib + +# Tests run with cargo-nextest because it puts each application test in its own +# process, and WINGs currently assumes that it is running in a single-threaded +# environment. +test: rustlib + LD_LIBRARY_PATH=../.libs $(CARGO) nextest run diff --git a/WINGs/wings-rs-tests/build.rs b/WINGs/wings-rs-tests/build.rs new file mode 100644 index 00000000..79eb0cc6 --- /dev/null +++ b/WINGs/wings-rs-tests/build.rs @@ -0,0 +1,6 @@ +fn main() { + println!("cargo:rustc-link-lib=static=X11"); + println!("cargo:rustc-link-lib=static=xcb"); + println!("cargo:rustc-link-lib=static=Xau"); + println!("cargo:rustc-link-lib=static=Xdmcp"); +} diff --git a/WINGs/wings-rs-tests/src/headless/mod.rs b/WINGs/wings-rs-tests/src/headless/mod.rs new file mode 100644 index 00000000..680d15bd --- /dev/null +++ b/WINGs/wings-rs-tests/src/headless/mod.rs @@ -0,0 +1,133 @@ +//! Provides utilites for integration tests that render to a headless X11 server. +//! +//! ## X11 `DISPLAY` value allocation +//! +//! When starting headless X servers, some care needs to be taken in selecting +//! display numbers. An X server needs to have a unique display number and will +//! not start if it is assigned a display number that is already in use. +//! +//! Local X servers usually leave a lockfile in `/tmp`, like `/tmp/.X0-lock` for +//! display 0. Avoiding the use of display numbers that have a corresponding +//! lockfile will get you part of the way towards avoiding collisions, but +//! further coordination is provided by the [`Lock`] and +//! [`DisplayNumberRegistry`] structs. These interfaces provide a mechanism for +//! claiming a display number in a way that coordinates across processes and +//! should be more efficient than simply scanning `/tmp` each time a new display +//! number is needed. + +use std::{ + fs, + io, + path::PathBuf, + sync::atomic::{self, AtomicU16}, +}; + +pub mod xvfb; +pub mod xwd; + +/// Represents a lock on a display number, which is released on drop. +pub struct Lock { + display: u16, + path: PathBuf, +} + +/// Errors that may occur when trying to lock a display number. +pub enum LockError { + Io(io::Error), + Locked, +} + +impl Lock { + pub(crate) fn new(display: u16, path: PathBuf) -> Self { + Lock { + display, + path + } + } + + /// Returns the locked `DISPLAY` value. + pub fn display(&self) -> u16 { + self.display + } +} + +impl Drop for Lock { + fn drop(&mut self) { + // `file` should be unlinked already, but we explicitly try to delete it + // and unwrap the result so that errors aren't dropped silently. + match fs::remove_file(&self.path) { + Ok(_) => (), + Err(e) if e.kind() == io::ErrorKind::NotFound => (), + Err(e) => panic!("could not unlink lock file: {:?}", e), + } + } +} + +// Shared across threads in this process to help keep us from repeatedly asking +// for the same DISPLAY value. +static NEXT_DISPLAY: AtomicU16 = AtomicU16::new(32); + +/// Coordinates on the value of `DISPLAY` to use when creating new X11 servers. +/// +/// Methods on `DisplayNumberRegistry` may be called across threads or in +/// different processes to ensure that X servers are created with unique +/// display numbers. +pub struct DisplayNumberRegistry; + +impl DisplayNumberRegistry { + /// Returns a lock on the first local display number (the `N` in `:N` for + /// the X client `DISPLAY` environment variable) that is not currently in + /// use. + /// + /// If no display numbers are available, returns `None`. + /// + /// To avoid collisions with other processes, attempts are made to sniff out + /// which display numbers are already in use by looking for lock files + /// matching the pattern `/tmp/.X*-lock`. If such a file exists, its + /// display number will not be used. + /// + /// When an available display number is found, an empty lockfile is + /// atomically created (and marking the display number as claimed to other + /// well-behaved processes). When `Xvfb` is run using that display number, + /// it silently overwrites the empty lockfile. + /// + /// Any extant lockfile should be deleted by the `Drop` impl for [`Lock`], + /// although tests that panic may leave stale lockfiles behind. + pub fn next_unused_display() -> io::Result { + loop { + let prev = NEXT_DISPLAY.fetch_add(1, atomic::Ordering::SeqCst); + if prev == u16::MAX { + return Err(io::Error::other("display numbers exhausted; check /tmp/.X{n}-lock")); + } + let next = prev + 1; + let path = PathBuf::from(format!("/tmp/.X{}-lock", next)); + match fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&path) { + Ok(_) => return Ok(Lock::new(next, path)), + Err(e) if e.kind() == io::ErrorKind::AlreadyExists => continue, + Err(e) => return Err(e), + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn find_unused_display() { + assert!(DisplayNumberRegistry::next_unused_display().is_ok()); + + assert_ne!( + DisplayNumberRegistry::next_unused_display() + .unwrap() + .display, + DisplayNumberRegistry::next_unused_display() + .unwrap() + .display + ); + } +} diff --git a/WINGs/wings-rs-tests/src/headless/snapshots/wings_rs_tests__headless__xvfb__tests__blank_screen.snap b/WINGs/wings-rs-tests/src/headless/snapshots/wings_rs_tests__headless__xvfb__tests__blank_screen.snap new file mode 100644 index 00000000..f8ae3214 --- /dev/null +++ b/WINGs/wings-rs-tests/src/headless/snapshots/wings_rs_tests__headless__xvfb__tests__blank_screen.snap @@ -0,0 +1,7 @@ +--- +source: src/headless/xvfb.rs +assertion_line: 387 +expression: xwd.into_png().unwrap() +extension: png +snapshot_kind: binary +--- diff --git a/WINGs/wings-rs-tests/src/headless/snapshots/wings_rs_tests__headless__xvfb__tests__blank_screen.snap.png b/WINGs/wings-rs-tests/src/headless/snapshots/wings_rs_tests__headless__xvfb__tests__blank_screen.snap.png new file mode 100644 index 00000000..0eb22405 Binary files /dev/null and b/WINGs/wings-rs-tests/src/headless/snapshots/wings_rs_tests__headless__xvfb__tests__blank_screen.snap.png differ diff --git a/WINGs/wings-rs-tests/src/headless/snapshots/wings_rs_tests__headless__xvfb__tests__snowlamp_in_window.snap b/WINGs/wings-rs-tests/src/headless/snapshots/wings_rs_tests__headless__xvfb__tests__snowlamp_in_window.snap new file mode 100644 index 00000000..040e2c18 --- /dev/null +++ b/WINGs/wings-rs-tests/src/headless/snapshots/wings_rs_tests__headless__xvfb__tests__snowlamp_in_window.snap @@ -0,0 +1,7 @@ +--- +source: src/headless/xvfb.rs +assertion_line: 416 +expression: compressed +extension: png +snapshot_kind: binary +--- diff --git a/WINGs/wings-rs-tests/src/headless/snapshots/wings_rs_tests__headless__xvfb__tests__snowlamp_in_window.snap.png b/WINGs/wings-rs-tests/src/headless/snapshots/wings_rs_tests__headless__xvfb__tests__snowlamp_in_window.snap.png new file mode 100644 index 00000000..6d3be375 Binary files /dev/null and b/WINGs/wings-rs-tests/src/headless/snapshots/wings_rs_tests__headless__xvfb__tests__snowlamp_in_window.snap.png differ diff --git a/WINGs/wings-rs-tests/src/headless/snapshots/wings_rs_tests__headless__xvfb__tests__xeyes.snap b/WINGs/wings-rs-tests/src/headless/snapshots/wings_rs_tests__headless__xvfb__tests__xeyes.snap new file mode 100644 index 00000000..38d8d74a --- /dev/null +++ b/WINGs/wings-rs-tests/src/headless/snapshots/wings_rs_tests__headless__xvfb__tests__xeyes.snap @@ -0,0 +1,7 @@ +--- +source: src/headless/xvfb.rs +assertion_line: 402 +expression: compressed +extension: png +snapshot_kind: binary +--- diff --git a/WINGs/wings-rs-tests/src/headless/snapshots/wings_rs_tests__headless__xvfb__tests__xeyes.snap.png b/WINGs/wings-rs-tests/src/headless/snapshots/wings_rs_tests__headless__xvfb__tests__xeyes.snap.png new file mode 100644 index 00000000..a871d5b7 Binary files /dev/null and b/WINGs/wings-rs-tests/src/headless/snapshots/wings_rs_tests__headless__xvfb__tests__xeyes.snap.png differ diff --git a/WINGs/wings-rs-tests/src/headless/snapshots/wings_rs_tests__headless__xwd__tests__snowlamp_encoded.snap b/WINGs/wings-rs-tests/src/headless/snapshots/wings_rs_tests__headless__xwd__tests__snowlamp_encoded.snap new file mode 100644 index 00000000..5bf63630 --- /dev/null +++ b/WINGs/wings-rs-tests/src/headless/snapshots/wings_rs_tests__headless__xwd__tests__snowlamp_encoded.snap @@ -0,0 +1,7 @@ +--- +source: src/headless/xwd.rs +assertion_line: 321 +expression: snowlamp_xwd.into_png().unwrap() +extension: png +snapshot_kind: binary +--- diff --git a/WINGs/wings-rs-tests/src/headless/snapshots/wings_rs_tests__headless__xwd__tests__snowlamp_encoded.snap.png b/WINGs/wings-rs-tests/src/headless/snapshots/wings_rs_tests__headless__xwd__tests__snowlamp_encoded.snap.png new file mode 100644 index 00000000..6d3be375 Binary files /dev/null and b/WINGs/wings-rs-tests/src/headless/snapshots/wings_rs_tests__headless__xwd__tests__snowlamp_encoded.snap.png differ diff --git a/WINGs/wings-rs-tests/src/headless/snowlamp.xwd b/WINGs/wings-rs-tests/src/headless/snowlamp.xwd new file mode 100644 index 00000000..fe1e1c06 Binary files /dev/null and b/WINGs/wings-rs-tests/src/headless/snowlamp.xwd differ diff --git a/WINGs/wings-rs-tests/src/headless/xvfb.rs b/WINGs/wings-rs-tests/src/headless/xvfb.rs new file mode 100644 index 00000000..ab10bf02 --- /dev/null +++ b/WINGs/wings-rs-tests/src/headless/xvfb.rs @@ -0,0 +1,471 @@ +//! Provides utilities for headlessly running and taking screenshots of X11 +//! programs in unit and integration tests. +//! +//! This uses [Xvfb](https://x.org/releases/X11R7.7/doc/man/man1/Xvfb.1.xhtml). +//! +//! Tests of this module invoke `xeyes` and `xwud` subprocesses. For all tests +//! to pass, you may need to install these X11 utilities and make them available +//! on your `PATH`. + +use std::{ + ffi::CString, + fs, io, + process::{Child, Command, ExitStatus, Stdio}, + thread, + time::{Duration, Instant}, +}; +use tempdir::TempDir; + +use super::xwd; +use super::Lock; + +/// Arguments for `Xvfb`. Use `XvfbArgs::default()` for default values. +#[derive(Clone, Copy, Debug)] +pub struct XvfbArgs { + /// Width of the X11 screen. + pub width: u32, + /// Height of the X11 screen. + pub height: u32, + /// Bit depth of the X11 screen. + /// + /// This should probably be one of 8, 16, or 24. (And if you don't choose 24 + /// bits, you should have a good reason why.) + pub depth: u8, +} + +impl Default for XvfbArgs { + fn default() -> Self { + XvfbArgs { + width: 640, + height: 480, + depth: 24, + } + } +} + +/// A captive `Xvfb` process, with affordances for running subprocesses that +/// connect to it and taking screenshots. +/// +/// When dropped, child processes will be killed automatically. +pub struct XvfbProcess { + lock: Lock, + framebuffer_directory: TempDir, + process: Child, + subprocesses: Vec<(Child, SubprocessMonitor)>, + is_shutdown: bool, +} + +impl XvfbProcess { + /// Attempts to start `Xvfb` with default options. + /// + /// Returns `None` if an error occurs while starting the `Xvfb` process. + pub fn start_default(lock: Lock) -> SubprocessResult { + XvfbProcess::start(lock, XvfbArgs::default()) + } + + /// Starts an `Xvfb` process with options specified by `args`. + /// + /// Returns `None` if an error occurs while starting the `Xvfb` process. + /// + /// This function connects to the `Xvfb` server briefly to try to ensure + /// that Xvfb is ready for clients to connect to it. + pub fn start(lock: Lock, args: XvfbArgs) -> SubprocessResult { + let framebuffer_directory = TempDir::new("wings_rs_xvfb").map_err(|e| { + SubprocessError::new("Xvfb temp dir".into(), SubprocessErrorType::Spawn(e)) + })?; + + #[cfg(target_os = "linux")] + unsafe { + // Kill children if this parent process dies catastrophically (e.g., + // a test raises SIGABRT). + libc::prctl(libc::PR_SET_PDEATHSIG, libc::SIGKILL); + } + let process = Command::new("Xvfb") + .arg(format!(":{}", lock.display)) + .arg("-screen") + .arg("0") + .arg(format!("{}x{}x{}", args.width, args.height, args.depth)) + .arg("-fbdir") + .arg(format!("{}", framebuffer_directory.path().display())) + .stderr(Stdio::piped()) + .spawn() + .map_err(|e| SubprocessError::new("Xvfb".into(), SubprocessErrorType::Spawn(e)))?; + + let process = XvfbProcess { + lock, + framebuffer_directory, + process, + subprocesses: Vec::new(), + is_shutdown: false, + }; + + let now = Instant::now(); + let display_name = CString::new( + format!(":{}", process.lock.display) + ).expect("could not construct display name"); + while now.elapsed() < Duration::from_secs(1) { + unsafe { + let display = x11::xlib::XOpenDisplay(display_name.as_ptr()); + if !display.is_null() { + x11::xlib::XCloseDisplay(display); + break; + } + } + thread::sleep(Duration::from_millis(10)); + } + return Ok(process); + } + + /// Returns the number in the `DISPLAY` environment variable to connect to + /// this X11 server. + pub fn display(&self) -> u16 { + self.lock.display + } + + /// Provides access to the `Xvfb` child process wrapped by `self`. + pub fn process(&mut self) -> &mut Child { + &mut self.process + } + + /// Attempts to clean up all child processes. Returns any errors encountered, or `Ok` if none. + /// + /// Child processes are cleaned up with the following escalating steps: + /// + /// * Call [`SubprocessMonitor::on_xvfb_shutdown`] on each child's monitor. + /// * Kill each of them directly (with [`Child::kill`]). + /// * Kill the `Xvfb` process (which may cause any children still connected to that display server to exit). + pub fn shutdown(&mut self) -> Result<(), Vec> { + if self.is_shutdown { + return Ok(()); + } + self.is_shutdown = true; + let mut errors = Vec::new(); + for (child, monitor) in self.subprocesses.iter_mut() { + if let Err(e) = monitor.on_xvfb_shutdown(child) { + errors.push(e); + } + match child.try_wait() { + Ok(Some(status)) => { + if let Err(e) = monitor.check_subprocess_exit(status) { + errors.push(e); + } + } + Ok(None) => { + if let Err(e) = child.kill() { + errors.push(SubprocessError::new( + monitor.name.clone(), + SubprocessErrorType::Kill(e), + )); + } + } + Err(e) => errors.push(SubprocessError::new( + monitor.name.clone(), + SubprocessErrorType::Status(e), + )), + } + } + if let Err(e) = self.process.kill() { + errors.push(SubprocessError::new( + "Xvfb".into(), + SubprocessErrorType::Kill(e), + )); + } + if errors.is_empty() { + Ok(()) + } else { + Err(errors) + } + } + + /// Runs a simple command in this display server. No command line parsing is + /// done, so `run("ls -la")` will probably not do what you expect (because + /// there is no binary on your `PATH` called `ls -la`). Use [`XvfbProcess::run_args`] + /// to provide arguments to `command`. + pub fn run(&mut self, command: impl Into) -> Result<(), SubprocessError> { + let name = command.into(); + self.run_args(name.clone(), &name, |_c| {}) + } + + /// Runs a command that you can provide arguments to in this display + /// server. + /// + /// The `f` parameter can be used to modify the `Command` before it is + /// run (e.g., by adding arguments). For example: + /// + /// ``` + /// # use wings_rs_tests::headless::DisplayNumberRegistry; + /// # use wings_rs_tests::headless::xvfb::XvfbProcess; + /// # fn main() { + /// # let mut xvfb = XvfbProcess::start_default(DisplayNumberRegistry::next_unused_display().unwrap()).unwrap(); + /// xvfb.run_args( + /// "demo of xeyes, angel-style", + /// "/usr/bin/xeyes", + /// |cmd| { cmd.arg("-biblicallyAccurate"); }, + /// ).unwrap(); + /// # } + /// ``` + pub fn run_args( + &mut self, + name: impl Into, + command: impl AsRef, + f: impl FnOnce(&mut Command), + ) -> Result<(), SubprocessError> { + let mut command = Command::new(command); + command.env("DISPLAY", format!(":{}", self.lock.display)); + f(&mut command); + let mut monitor = SubprocessMonitor::new(name.into(), command); + monitor.with_subprocess_exit(SubprocessMonitor::require_clean_exit()); + let child = monitor.start()?; + self.subprocesses.push((child, monitor)); + Ok(()) + } + + /// Checks all subprocesses started with the [`XvfbProcess::run`] family of + /// methods and returns errors for any that have an error status, or `Ok` if + /// none do. + pub fn check(&mut self) -> Result<(), Vec> { + let errors: Vec<_> = self + .subprocesses + .iter_mut() + .filter_map(|(child, monitor)| match child.try_wait() { + Ok(Some(status)) => monitor.check_subprocess_exit(status).err(), + _ => None, + }) + .collect(); + if errors.is_empty() { + Ok(()) + } else { + Err(errors) + } + } + + /// Returns a screenshot of the current framebuffer contents in `xwd(1)` + /// format. + /// + /// For the same in a more generally useful format, try [`XvfbProcess::png_screenshot`]. + pub fn xwd_screenshot(&self) -> io::Result { + let path = self.framebuffer_directory.path().join("Xvfb_screen0"); + Ok(xwd::XwdImage::read(&mut fs::read(path)?.into_iter())) + } + + /// Returns a screenshot of the current framebuffer contents in PNG format. + /// + /// Panics on errors. + pub fn png_screenshot(&self) -> Vec { + self.xwd_screenshot().unwrap().into_png().unwrap() + } +} + +impl Drop for XvfbProcess { + fn drop(&mut self) { + self.shutdown().unwrap(); + } +} + +pub type SubprocessResult = Result; + +/// Errors that may occur when a subprocess fails to run or exit cleanly. +#[derive(Debug)] +pub struct SubprocessError { + /// Meaningful name that can be used to determine what subprocess failed. + pub name: String, + pub err: SubprocessErrorType, +} + +impl SubprocessError { + pub fn new(name: String, err: SubprocessErrorType) -> Self { + SubprocessError { name, err } + } +} + +/// Error details for `SubprocessError`. +#[derive(Debug)] +pub enum SubprocessErrorType { + /// Subprocess exited with bad exit code. + BadExit(ExitStatus), + /// Could not spawn child process (with `io::Error` as the reason). + Spawn(io::Error), + /// Could not kill child process (with `io::Error` as the reason). + Kill(io::Error), + /// Could not query child process for exit status (with `io::Error` as the reason). + Status(io::Error), +} + +/// Manages spawning a subprocess, monitoring it during execution, and killing it when `Xvfb` shuts down. +pub struct SubprocessMonitor { + name: String, + command: Command, + check_subprocess_exit: Option SubprocessResult<()>>>, + on_xvfb_shutdown: Option SubprocessResult<()>>>, +} + +impl SubprocessMonitor { + /// Creates a new monitor but does not start any subprocess yet. + /// + /// This monitor may be configured further by calling mutators like + /// [`SubprocessMonitor::with_subprocess_exit`]. Once configuration is + /// completely, start the subprocess with [`SubprocessMonitor::start`]. + pub fn new(name: impl Into, command: Command) -> Self { + SubprocessMonitor { + name: name.into(), + command, + check_subprocess_exit: None, + on_xvfb_shutdown: None, + } + } + + /// Sets the callback to execute when [`SubprocessMonitor::check_subprocess_exit`] is called. + pub fn with_subprocess_exit( + &mut self, + f: Box SubprocessResult<()>>, + ) -> &mut Self { + self.check_subprocess_exit = Some(f); + self + } + + /// Sets the callback to execute when + /// [`SubprocessMonitor::on_xvfb_shutdown`] is called. It may check process + /// status, clean up, etc. + pub fn with_xvfb_shutdown( + &mut self, + f: Box SubprocessResult<()>>, + ) -> &mut Self { + self.on_xvfb_shutdown = Some(f); + self + } + + /// Attempts to spawn a child process from the `Command` provided to + /// [`SubprocessMonitor::new`]. + pub fn start(&mut self) -> SubprocessResult { + self.command + .spawn() + .map_err(|e| SubprocessError::new(self.name.clone(), SubprocessErrorType::Spawn(e))) + } + + /// Called when subprocess exit status is polled. This may be used to signal + /// an error because the exit status is bad. + pub fn check_subprocess_exit(&mut self, status: ExitStatus) -> SubprocessResult<()> { + if let Some(f) = &mut self.check_subprocess_exit { + (f)(&self.name, status) + } else { + Ok(()) + } + } + + /// Called immediately before the parent display server is being shut + /// down. This may be used to send `SIGINT` or otherwise clean up the child + /// before it is killed more forcibly. + pub fn on_xvfb_shutdown(&mut self, child: &mut Child) -> SubprocessResult<()> { + if let Some(f) = &mut self.on_xvfb_shutdown { + (f)(child) + } else { + Ok(()) + } + } + + /// Returns a callback suitable for passing to [`SubprocessMonitor::check_subprocess_exit`] + /// which requires that the process has exited cleanly. + pub fn require_clean_exit() -> Box SubprocessResult<()>> { + Box::new(|name: &str, status: ExitStatus| { + if status.success() { + Ok(()) + } else { + Err(SubprocessError::new( + String::from(name), + SubprocessErrorType::BadExit(status), + )) + } + }) + } +} + +#[cfg(test)] +mod tests { + use crate::headless::DisplayNumberRegistry; + use super::XvfbProcess; + use insta_image::assert_png_snapshot; + use std::{ + thread, + time::Duration, + }; + + /// Starts Xvfb and returns a managing object, or panics. + fn start_xvfb() -> XvfbProcess { + XvfbProcess::start_default( + DisplayNumberRegistry::next_unused_display().expect("cannot find a value for DISPLAY"), + ) + .expect("cannot start Xvfb") + } + + /// Reads a single frame of PNG data from the PNG image in `data`. Panics on + /// errors. + fn read_png_metadata(data: &[u8]) -> png::OutputInfo { + let mut reader = png::Decoder::new(std::io::Cursor::new(data)) + .read_info() + .unwrap(); + let mut buf = vec![0; reader.output_buffer_size().unwrap()]; + reader.next_frame(&mut buf).unwrap() + } + + /// Launches `xeyes(1)` with colors to make sure we're doing the right thing + /// with our color channels when encoding a screenshot tas PNG. + fn xeyes_color(xvfb: &mut XvfbProcess) { + xvfb.run_args("xeyes", "xeyes", |c| { + c.arg("-outline") + .arg("blue") + .arg("-center") + .arg("red") + .arg("-fg") + .arg("green"); + }) + .unwrap(); + thread::sleep(Duration::from_millis(100)); + } + + #[test] + fn png_blank_screenshot() { + let xvfb = start_xvfb(); + let xwd = xvfb.xwd_screenshot().unwrap(); + + assert_eq!( + xwd.visual_class(), + crate::headless::xwd::VisualClass::TrueColor + ); + + let args = crate::headless::xvfb::XvfbArgs::default(); + assert_eq!(xwd.header.depth, args.depth as u32); + assert_eq!(xwd.header.width, args.width); + assert_eq!(xwd.header.height, args.height); + + assert_png_snapshot!("blank_screen", xwd.into_png().unwrap()); + } + + #[test] + fn png_xeyes_screenshot() { + let mut xvfb = start_xvfb(); + xeyes_color(&mut xvfb); + + let compressed = xvfb.png_screenshot(); + let image_data = read_png_metadata(&compressed); + + let args = crate::headless::xvfb::XvfbArgs::default(); + assert_eq!(image_data.width, args.width); + assert_eq!(image_data.height, args.height); + + assert_png_snapshot!("xeyes", compressed); + } + + #[test] + fn png_lamp_image() { + let mut xvfb = start_xvfb(); + xvfb.run_args("xwud", "xwud", |c| { + c.arg("-in").arg("src/headless/snowlamp.xwd"); + }) + .unwrap(); + thread::sleep(Duration::from_millis(100)); + + let compressed = xvfb.png_screenshot(); + + assert_png_snapshot!("snowlamp_in_window", compressed); + } +} diff --git a/WINGs/wings-rs-tests/src/headless/xwd.rs b/WINGs/wings-rs-tests/src/headless/xwd.rs new file mode 100644 index 00000000..2f113d40 --- /dev/null +++ b/WINGs/wings-rs-tests/src/headless/xwd.rs @@ -0,0 +1,325 @@ +//! Provides basic support for rendering dumps of X11 windows made with [the `xwud` utility](https://gitlab.freedesktop.org/xorg/app/xwud/-/blob/master/xwud.c?ref_type=heads). + +use std::{ + ffi::CString, + io::Cursor, + mem, +}; + +const SUPPORTED_XWD_VERSION: u32 = 7; + +/// Header for `xwd`-style dumps of X11 windows. +/// +/// This is taken from `/usr/include/X11/XWDFile.h` on a Debian system. +#[derive(Clone)] +#[repr(C)] +pub struct XwdHeader { + /// Total header size (including null-terminated window name). + pub size: u32, + /// Version of `xwd` that this dump came from. + pub version: u32, + pub _format: u32, + /// Bit depth of the X11 server. + pub depth: u32, + /// Image width. + pub width: u32, + /// Image height. + pub height: u32, + pub _x_offset: u32, + pub _byte_order: u32, + pub _bitmap_unit: u32, + pub _bitmap_bit_order: u32, + pub _bitmap_pad: u32, + pub bits_per_pixel: u32, + pub _bytes_per_line: u32, + /// X11 visual class for this window. See [`XwdImage::visual_class`] for a + /// more human-interpretable value. + pub visual_class: u32, + /// Bitmask for the red channel in pixel data. + pub red_mask: u32, + /// Bitmask for the green channel in pixel data. + pub green_mask: u32, + /// Bitmask for the blue channel in pixel data. + pub blue_mask: u32, + /// Number of bits per RGB tuple in color data. (E.g., 24-bit color may + /// actually be padded to 4 bytes.) + pub bits_per_rgb: u32, + pub _colormap_entries: u32, + /// Number of colors in the colormap for the window that this dump came from. + /// + /// Note that a colormap may exist but be unused (if the visual class is not + /// one that uses a colormap). + pub ncolors: u32, + pub _window_width: u32, + pub _window_height: u32, + pub _window_x: u32, + pub _window_y: u32, + pub _window_border_width: u32, +} + +const HEADER_SIZE: u32 = mem::size_of::() as u32; + +/// Color data for an entry in a colormap. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[repr(C)] +pub struct XwdColor { + pixel: u32, + red: u16, + green: u16, + blue: u16, + flags: u8, + pad: u8, +} + +fn next_u32(bytes: &mut impl Iterator) -> u32 { + let bytes = [ + bytes.next().unwrap(), + bytes.next().unwrap(), + bytes.next().unwrap(), + bytes.next().unwrap(), + ]; + u32::from_be_bytes(bytes) +} + +fn next_u16(bytes: &mut impl Iterator) -> u16 { + let bytes = [bytes.next().unwrap(), bytes.next().unwrap()]; + u16::from_be_bytes(bytes) +} + +/// Describes how pixel data is represented in an `xwd` dump. +/// +/// See descriptions of visual classes at +/// and [the official +/// documentation](https://www.x.org/releases/X11R7.7/doc/xproto/x11protocol.html#visual_information) +/// for more information. +#[derive(Clone, Copy, Eq, Debug, PartialEq)] +pub enum VisualClass { + StaticGray, + GrayScale, + StaticColor, + PseudoColor, + TrueColor, + DirectColor, +} + +impl XwdHeader { + /// Decodes an `XwdHeader` from `bytes`, panicking on failure. + /// + /// Leaves `bytes` pointing to the start of the window name. + pub fn read(bytes: &mut impl Iterator) -> XwdHeader { + // This layout might change in the future, but let's just go with it for now. + XwdHeader { + size: next_u32(bytes), + version: next_u32(bytes), + _format: next_u32(bytes), + depth: next_u32(bytes), + width: next_u32(bytes), + height: next_u32(bytes), + _x_offset: next_u32(bytes), + _byte_order: next_u32(bytes), + _bitmap_unit: next_u32(bytes), + _bitmap_bit_order: next_u32(bytes), + _bitmap_pad: next_u32(bytes), + bits_per_pixel: next_u32(bytes), + _bytes_per_line: next_u32(bytes), + visual_class: next_u32(bytes), + red_mask: next_u32(bytes), + green_mask: next_u32(bytes), + blue_mask: next_u32(bytes), + bits_per_rgb: next_u32(bytes), + _colormap_entries: next_u32(bytes), + ncolors: next_u32(bytes), + _window_width: next_u32(bytes), + _window_height: next_u32(bytes), + _window_x: next_u32(bytes), + _window_y: next_u32(bytes), + _window_border_width: next_u32(bytes), + } + } +} + +impl XwdColor { + /// Reads an `XwdColor` from `bytes`, panicking on failure. + /// + /// Leaves `bytes` pointing to the next byte after the end of the color + /// definition. + pub fn read(bytes: &mut impl Iterator) -> XwdColor { + XwdColor { + pixel: next_u32(bytes), + red: next_u16(bytes), + green: next_u16(bytes), + blue: next_u16(bytes), + flags: bytes.next().unwrap(), + pad: bytes.next().unwrap(), + } + } +} + +/// A decoded `xwd` dump. +pub struct XwdImage { + /// Header with image metadata. + pub header: XwdHeader, + /// Name of the X11 window that the image came from. + pub name: CString, + /// Colormap data for the X11 window that the image came from. + /// + /// Note that this may be populated even if it is unused (because the + /// window's visual class is one that does not use a colormap). + pub colors: Vec, + /// Pixel data for the image. + /// + /// This may be indices pointing into `colors`, or it may be actual pixel + /// values. + pub pixels: Vec, +} + +impl XwdImage { + /// Reads an `XwdImage` from `bytes`, panicking on failure. + pub fn read(bytes: &mut impl Iterator) -> XwdImage { + let header = XwdHeader::read(bytes); + if header.version != SUPPORTED_XWD_VERSION { + panic!( + "header version {} is not supported. only version {} files are supported.", + header.version, SUPPORTED_XWD_VERSION + ); + } + let name = Self::read_name(&header, bytes); + let colors = Self::read_colors(&header, bytes); + let pixels = bytes.collect(); + + XwdImage { + header, + name, + colors, + pixels, + } + } + + /// Reads a colormap for the image described by `header` from `bytes`. + /// + /// `bytes` must point at the start of the colormap data. Leaves `bytes` + /// pointing at the first byte of the next item in the data stream. + pub fn read_colors(header: &XwdHeader, bytes: &mut impl Iterator) -> Vec { + let mut colors = Vec::with_capacity(header.ncolors as usize); + for _ in 0..header.ncolors { + colors.push(XwdColor::read(bytes)); + } + colors + } + + /// Reads the X11 window name from `bytes`. + /// + /// `bytes` must point at the start of the window name. Leaves `bytes` + /// pointing at the first byte of the next item in the data stream. + /// + /// Note that the window name is a null-terminated string, but `header.size` + /// (which is supposed to be equal to the size of the header structure plus + /// the window name) may actually be longer than `strlen(name) + + /// sizeof(HeaderType)`. When that is the case, the extra padding after the + /// first null byte in the window name will be passed over silently. + pub fn read_name(header: &XwdHeader, bytes: &mut impl Iterator) -> CString { + if header.size <= HEADER_SIZE { + panic!( + "Invalid header size {} (smaller than header struct size {})", + header.size, HEADER_SIZE + ); + } + let mut buf = Vec::new(); + let mut found_nul = false; + for _ in 0..header.size - HEADER_SIZE { + let b = bytes.next().unwrap(); + if b == 0 { + if found_nul { + continue; + } else { + buf.push(b); + found_nul = true; + } + } else { + buf.push(b); + } + } + CString::from_vec_with_nul(buf).unwrap() + } + + /// Returns the visual class for this window dump. + pub fn visual_class(&self) -> VisualClass { + match self.header.visual_class { + 0 => VisualClass::StaticGray, + 1 => VisualClass::GrayScale, + 2 => VisualClass::StaticColor, + 3 => VisualClass::PseudoColor, + 4 => VisualClass::TrueColor, + 5 => VisualClass::DirectColor, + _ => panic!( + "unrecognized X11 visual class: {}", + self.header.visual_class + ), + } + } + + /// Encodes this image as a PNG, consumes `self` in the process (because the + /// image data is reused during the encoding process). + /// + /// Note that only [`VisualClass::TrueColor`] images with a specific pixel + /// format (24-bit color packed into 32-bit segments with BGR ordering) are + /// supported. + pub fn into_png(self) -> Result, png::EncodingError> { + match self.visual_class() { + VisualClass::TrueColor => into_png_true_color(self.header, self.pixels), + _ => todo!("X11 visual classes other than TrueColor are not yet supported"), + } + } +} + +fn into_png_true_color( + header: XwdHeader, + mut pixels: Vec, +) -> Result, png::EncodingError> { + if header.depth != 24 { + todo!("pixmap color depths other than 24-bit are not yet supported"); + } + if !(header.red_mask == 0xff0000 + && header.green_mask == 0x00ff00 + && header.blue_mask == 0x0000ff) + { + todo!("color orderings other than RGB are not yet supported"); + } + if header.bits_per_rgb == 32 { + todo!("bits per rgb triplet other than 32 are not yet supported"); + } + + let mut out = Cursor::new(Vec::::new()); + let mut encoder = png::Encoder::new(&mut out, header.width, header.height); + encoder.set_color(png::ColorType::Rgba); + encoder.set_depth(png::BitDepth::Eight); + encoder.set_compression(png::Compression::High); + + for pixel in pixels.chunks_mut(4) { + let blue = pixel[0]; + let green = pixel[1]; + let red = pixel[2]; + pixel[0] = red; + pixel[1] = green; + pixel[2] = blue; + pixel[3] = 0xFF; + } + + let mut writer = encoder.write_header()?; + writer.write_image_data(&pixels)?; + writer.finish()?; + + Ok(out.into_inner()) +} + +#[cfg(test)] +mod tests { + use super::XwdImage; + use insta_image::assert_png_snapshot; + + #[test] + fn png_encode() { + let snowlamp_xwd = XwdImage::read(&mut include_bytes!("snowlamp.xwd").into_iter().copied()); + assert_png_snapshot!("snowlamp_encoded", snowlamp_xwd.into_png().unwrap()); + } +} diff --git a/WINGs/wings-rs-tests/src/lib.rs b/WINGs/wings-rs-tests/src/lib.rs new file mode 100644 index 00000000..0baad46e --- /dev/null +++ b/WINGs/wings-rs-tests/src/lib.rs @@ -0,0 +1,185 @@ +pub mod headless; + +use headless::{xvfb::XvfbProcess, DisplayNumberRegistry}; +use std::{ + env, + ffi::{c_char, c_int, CStr, CString}, + ptr::{self, NonNull}, + sync::Mutex, +}; +use wings_rs::WINGsP::{WMHandleEvent, WMInitializeApplication, WMReleaseApplication, WMScreen}; + +static APPLICATION_COUNT: Mutex = Mutex::new(0); + +/// Stubbed wrapper for WMApplication. +pub struct HeadlessApplication { + /// The headless X server process. + pub xvfb: XvfbProcess, + /// An open client handle to the headless X server. + pub display: NonNull, + /// A fully initialized `WMScreen` for an application running on the + /// headless X server. + pub screen: NonNull, +} + +impl HeadlessApplication { + /// Starts a headless X server and calls `WMInitializeApplication` if no + /// WINGs application is active. + pub fn new() -> Self { + static PROGNAME: &'static CStr = c"Test@eqweq_ewq$eqw"; + + let xvfb = XvfbProcess::start_default( + DisplayNumberRegistry::next_unused_display() + .expect("cannot allocate a value for DISPLAY"), + ) + .expect("cannot start Xvfb"); + + { + let mut application_count = APPLICATION_COUNT.lock().unwrap(); + if *application_count == 0 { + unsafe { + let mut argv: Vec<*mut c_char> = + vec![PROGNAME.as_ptr().cast::() as *mut c_char]; + let mut argc: c_int = 1; + WMInitializeApplication( + PROGNAME.as_ptr().cast::(), + &mut argc as *mut _, + argv.as_mut_ptr(), + ); + } + } + *application_count += 1; + } + + let display_str = CString::new(format!(":{}", xvfb.display())).unwrap(); + let display = + NonNull::new(unsafe { x11::xlib::XOpenDisplay(display_str.as_ptr()) }).unwrap(); + let screen = WMScreen::new(&display).unwrap(); + + HeadlessApplication { + xvfb, + display, + screen, + } + } + + /// Pumps the WUtil and X11 event queues, hackily. + /// + /// Runs WUtil event handlers that should be run by `now`, pumps the X11 + /// event queue, dispatches the next pending event (if any), and runs + /// WUtil idle handlers (if no X11 events are available). + /// + /// Returns `true` if more events are pending. (This does not account for + /// WUtils timer events that might need to fire, since the future time when + /// `pump_event_queue` will next be called cannot be known.) + /// + /// This is somewhat hacky (because it does not match the WINGs main loop + /// logic exactly), so it should only be used by tests. + pub fn pump_event_queue(&mut self, now: std::time::Instant) -> bool { + let display = self.display.as_ptr(); + wutil_rs::handlers::with_global_handlers(|handlers| handlers.check_timer_handlers(now)); + + unsafe { + x11::xlib::XSync(display, 0); + if x11::xlib::XPending(display) > 0 { + let mut event = x11::xlib::XEvent { type_: 0 }; + x11::xlib::XNextEvent(display, &mut event as *mut _); + WMHandleEvent(&mut event as *mut _); + } else { + wutil_rs::handlers::run_global_idle_handlers(); + } + + x11::xlib::XSync(display, 0); + if x11::xlib::XPending(display) > 0 { + return true; + } + wutil_rs::handlers::with_global_handlers(|handlers| handlers.has_idle_handlers()) + } + } +} + +impl Drop for HeadlessApplication { + fn drop(&mut self) { + unsafe { + x11::xlib::XCloseDisplay(self.display.as_ptr()); + // Leak self.screen, since WINGs doesn't provide a cleanup function. + } + let mut application_count = APPLICATION_COUNT.lock().unwrap(); + if *application_count <= 1 { + *application_count = 0; + unsafe { + WMReleaseApplication(); + } + } else { + *application_count -= 1; + } + self.xvfb.shutdown().unwrap(); + } +} + +/// Simple wrapper for demo WINGs applications. +/// +/// This handles calling `WMInitializeApplication`, which operates on a +/// singleton behind the scenes and wants to be passed command line arguments. +/// +/// When the last instantiated `LiveApplication` is dropped, +/// `WMReleaseApplication` is called automatically. +pub struct LiveApplication { + /// Live X11 `Display` that the application is running on. + pub display: NonNull, + /// Fully intialized `WMScreen` for the application. + /// + /// This is leaked when the `LiveApplication` is dropped because WINGs does + /// not provide a deletion function. + pub screen: NonNull, +} + +impl LiveApplication { + /// Creates a new application wrapper and initializes the global + /// `WMApplication` if necessary. + pub fn new(name: &str) -> Self { + let mut application_count = APPLICATION_COUNT.lock().unwrap(); + if *application_count > 0 { + panic!("application already started!"); + } + let name = CString::new(name).expect("invalid program name"); + unsafe { + let mut argv: Vec = vec![name]; + let name_ptr = argv[0].as_ptr().cast::(); + for arg in env::args() { + argv.push(CString::new(arg).expect("invalid argument string")); + } + let mut argv_ptrs: Vec<*mut c_char> = argv + .iter_mut() + .map(|a| a.as_ptr().cast::() as *mut c_char) + .collect(); + let mut argc: c_int = argv_ptrs.len().try_into().expect("invalid argument count"); + WMInitializeApplication(name_ptr, &mut argc as *mut _, argv_ptrs.as_mut_ptr()); + } + *application_count = 1; + + let display = NonNull::new(unsafe { x11::xlib::XOpenDisplay(ptr::null_mut()) }) + .expect("could not connect to X11 display"); + let screen = WMScreen::new(&display).unwrap(); + + LiveApplication { display, screen } + } +} + +impl Drop for LiveApplication { + fn drop(&mut self) { + unsafe { + x11::xlib::XCloseDisplay(self.display.as_ptr()); + // Leak self.screen, since WINGs doesn't provide a cleanup function. + } + let mut application_count = APPLICATION_COUNT.lock().unwrap(); + if *application_count <= 1 { + *application_count = 0; + unsafe { + WMReleaseApplication(); + } + } else { + *application_count -= 1; + } + } +} diff --git a/WINGs/wings-rs/Cargo.toml b/WINGs/wings-rs/Cargo.toml index 689a9201..3d12b5ed 100644 --- a/WINGs/wings-rs/Cargo.toml +++ b/WINGs/wings-rs/Cargo.toml @@ -4,7 +4,7 @@ version = "0.1.0" edition = "2024" [lib] -crate-type = ["staticlib"] +crate-type = ["staticlib", "rlib"] [dependencies] libc = "0.2.177" diff --git a/WINGs/wings-rs/Makefile.am b/WINGs/wings-rs/Makefile.am index 73afa18a..aa9e1b23 100644 --- a/WINGs/wings-rs/Makefile.am +++ b/WINGs/wings-rs/Makefile.am @@ -13,7 +13,15 @@ RUST_EXTRA = \ Cargo.toml src/WINGsP.rs: ../WINGs/WINGsP.h ../../wrlib/wraster.h ../WINGs/WINGs.h ../WINGs/WUtil.h Makefile patch_WINGsP.sh - $(BINDGEN) ../WINGs/WINGsP.h --ignore-functions --allowlist-type "^W_.+|^WM(View|Array|DragOperationType|Point|Data|OpenPanel|SavePanel|HashTable|DraggingInfo|SelectionProcs|Rect|EventProc|Widget|Size|Color|Pixmap|FilePanel)|R(Context|ContextAttributes|Image|RenderingMode|ScalingFilter|StdColormapMode|ImageFormat|Color)|_WINGsConfiguration" --no-recursive-allowlist -o src/WINGsP.rs -- @PANGO_CFLAGS@ -I../../wrlib -I.. && ./patch_WINGsP.sh src/WINGsP.rs + $(BINDGEN) ../WINGs/WINGsP.h \ + --no-recursive-allowlist \ + --allowlist-type "^W_.+|^WM(View|Array|DragOperationType|Point|Data|OpenPanel|SavePanel|HashTable|DraggingInfo|SelectionProcs|Rect|EventProc|Widget|Size|Color|Pixmap|FilePanel)|R(Context|ContextAttributes|Image|RenderingMode|ScalingFilter|StdColormapMode|ImageFormat|Color)|_WINGsConfiguration" \ + --allowlist-type "^WM(FontPanel|Screen|Button)" \ + --allowlist-function "^WMCreateScreen|^WM(Get|Show)FontPanel|^WMCreateCommandButton|^WM(Initialize|Release)Application|^WMScreenMainLoop|^WMHandleEvent" \ + -o src/WINGsP.rs -- \ + @PANGO_CFLAGS@ \ + -I../../wrlib \ + -I.. && ./patch_WINGsP.sh src/WINGsP.rs target/debug/libwings_rs.a: $(RUST_SOURCES) $(RUST_EXTRA) $(CARGO) build diff --git a/WINGs/wings-rs/src/screen.rs b/WINGs/wings-rs/src/screen.rs index 8d5170e9..c887875a 100644 --- a/WINGs/wings-rs/src/screen.rs +++ b/WINGs/wings-rs/src/screen.rs @@ -1,15 +1,20 @@ use crate::{ - WINGsP::W_Screen, + WINGsP::{WMScreen, WMCreateScreen}, font::{Font, FontName}, }; use std::{ collections::{HashMap, hash_map::Entry}, ffi::c_void, + ptr::NonNull, rc::Rc, }; -impl W_Screen { +impl WMScreen { + pub fn new(display: &NonNull) -> Option> { + NonNull::new(unsafe { WMCreateScreen(display.as_ptr(), x11::xlib::XDefaultScreen(display.as_ptr())) }) + } + fn font_cache_mut(&mut self) -> &mut HashMap> { if self.fontCache.is_null() { self.fontCache = (Box::leak(Box::new(HashMap::>::new())) @@ -50,13 +55,13 @@ impl W_Screen { #[cfg(test)] mod test { - use crate::WINGsP::W_Screen; + use crate::WINGsP::WMScreen; use std::mem::MaybeUninit; #[test] fn font_cache_init() { - let mut screen: W_Screen = unsafe { MaybeUninit::zeroed().assume_init() }; + let mut screen: WMScreen = unsafe { MaybeUninit::zeroed().assume_init() }; let cache = screen.font_cache_mut(); assert!(cache.is_empty()); } diff --git a/configure.ac b/configure.ac index 61761963..93c42684 100644 --- a/configure.ac +++ b/configure.ac @@ -933,9 +933,9 @@ AC_CONFIG_FILES( wutil-rs/Makefile dnl WINGs toolkit - WINGs/Makefile WINGs/wings-rs/Makefile WINGs/WINGs/Makefile WINGs/po/Makefile - WINGs/Documentation/Makefile WINGs/Resources/Makefile WINGs/Extras/Makefile - WINGs/Examples/Makefile WINGs/Tests/Makefile + WINGs/Makefile WINGs/wings-rs/Makefile WINGs/WINGs/Makefile WINGs/wings-rs-tests/Makefile + WINGs/po/Makefile WINGs/Documentation/Makefile WINGs/Resources/Makefile + WINGs/Extras/Makefile WINGs/Examples/Makefile WINGs/Tests/Makefile dnl Rust implementation of Window Maker core wmaker-rs/Makefile diff --git a/wutil-rs/Cargo.toml b/wutil-rs/Cargo.toml index 0d3e85dd..d9f7277b 100644 --- a/wutil-rs/Cargo.toml +++ b/wutil-rs/Cargo.toml @@ -4,7 +4,7 @@ version = "0.1.0" edition = "2024" [lib] -crate-type = ["staticlib"] +crate-type = ["staticlib","rlib"] [build-dependencies] cc = "1.0" diff --git a/wutil-rs/src/handlers.rs b/wutil-rs/src/handlers.rs index d9927d7e..41ea1956 100644 --- a/wutil-rs/src/handlers.rs +++ b/wutil-rs/src/handlers.rs @@ -158,6 +158,11 @@ impl HandlerQueues { QueueStatus::Pending } } + + /// Returns `true` if any idle handlers need to be run. + pub fn has_idle_handlers(&self) -> bool { + !self.idle_handlers.is_empty() + } } /// Runs each of `handlers` that should run before `now` and returns those that @@ -209,11 +214,11 @@ struct IdleHandler { static DEFAULT_HANDLERS: Mutex = Mutex::new(HandlerQueues::new()); -fn with_global_handlers(f: impl FnOnce(&mut HandlerQueues) -> R) -> R { +pub fn with_global_handlers(f: impl FnOnce(&mut HandlerQueues) -> R) -> R { f(&mut DEFAULT_HANDLERS.try_lock().unwrap()) } -fn run_global_idle_handlers() -> QueueStatus { +pub fn run_global_idle_handlers() -> QueueStatus { // Move handlers out of the global queue because it is locked while // with_global_handlers is running, and a callback may try to schedule // another callback or perform some other operation on the global queue. @@ -226,10 +231,10 @@ fn run_global_idle_handlers() -> QueueStatus { (h.callback)(); } with_global_handlers(|handlers| { - if handlers.idle_handlers.is_empty() { - QueueStatus::Empty - } else { + if handlers.has_idle_handlers() { QueueStatus::Pending + } else { + QueueStatus::Empty } }) }