Wayland compositor (wlroots)
git clone https://git.lucas.co/cce-compositor.git
feat: native screenshots — ccectl screenshot [region|window]
Full-output and region capture read the next composited frame back off
the output state's buffer (wlr_texture_from_buffer +
wlr_texture_read_pixels) right after wlr_scene_output_build_state; a
parked PendingScreenshot forces the render even without damage, and
regions crop the buffer CPU-side (args in logical on-screen px).
Window capture composites the window's committed surface textures
(root + subsurfaces at their offsets), so it works for windows panned
outside the visible viewport. PNG encode (png 0.17) and the
announcement run on a worker thread — the IPC reply returns the
destination path (~/Pictures/screenshots) immediately, inside the
socket's 1 s timeout.
Saved captures are announced through notify-send with the image-path
hint (cce-notifier renders it as a thumbnail); notifications {
screenshots false } in config.kdl disables the announcement, re-read
per shot. New C shim: river_wlr_surface_get_buffer_size.
Co-Authored-By: Claude Fable 5 <[email protected]>
Cargo.toml | 1 +
src/cce_ctl.rs | 3 +
src/lib.rs | 2 +
src/server/output.rs | 34 +++-
src/server/screenshot.rs | 329 +++++++++++++++++++++++++++++++++++++++
src/server/window_manager.rs | 82 ++++++++++
src/server/wlroots_log_wrapper.c | 5 +
wrapper.h | 1 +
8 files changed, 456 insertions(+), 1 deletion(-)
diff --git a/Cargo.toml b/Cargo.toml
index ad43ddc..68421cc 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -17,6 +17,7 @@ bitflags = "2"
tokio = { version = "1.35", features = ["full"] }
serde_json = "1.0"
kdl = "4.6"
+png = "0.17"
fontdue = "0.9.3"
clap = { version = "4.4", features = ["derive"] }
log = "0.4"
diff --git a/src/cce_ctl.rs b/src/cce_ctl.rs
index 1871c23..e658ca5 100644
--- a/src/cce_ctl.rs
+++ b/src/cce_ctl.rs
@@ -64,6 +64,9 @@ fn usage(name: &str, to_stderr: bool) {
print(" input <device_name|*> scroll-factor <value>");
print(" config-done");
print(" spawn <command>");
+ print(" screenshot # capture the screen to ~/Pictures/screenshots");
+ print(" screenshot region <x> <y> <w> <h> # capture an on-screen region (logical px)");
+ print(" screenshot window [app_id|id] # capture a window (focused if omitted; works off-screen)");
print(" notify <title> [body]");
print(" bind <mods> <keysym> <action> [args...]");
print(" pbind <mods> <button> <action>");
diff --git a/src/lib.rs b/src/lib.rs
index 36e6765..b622d38 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -29,6 +29,8 @@ pub use cce_window_manager::tiling;
pub mod config;
#[path = "server/ipc_server.rs"]
pub mod ipc_server;
+#[path = "server/screenshot.rs"]
+pub mod screenshot;
#[path = "server/status_server.rs"]
pub mod status_server;
#[path = "server/scene_node_data.rs"]
diff --git a/src/server/output.rs b/src/server/output.rs
index 5b75b2f..f6e28c9 100644
--- a/src/server/output.rs
+++ b/src/server/output.rs
@@ -486,7 +486,23 @@ impl Output {
self.draw_grid();
self.draw_adjust_overlay();
- if !ffi::wlr_scene_output_needs_frame(self.scene_output) {
+ // A parked `ccectl screenshot` targeting this output forces a render
+ // even without damage so there is a fresh buffer to read back.
+ let pending_shot = {
+ let wm = &mut (*self.server).wm;
+ if wm
+ .pending_screenshot
+ .as_ref()
+ .map(|s| s.output == self as *mut Output)
+ .unwrap_or(false)
+ {
+ wm.pending_screenshot.take()
+ } else {
+ None
+ }
+ };
+
+ if pending_shot.is_none() && !ffi::wlr_scene_output_needs_frame(self.scene_output) {
return Ok(());
}
@@ -520,6 +536,22 @@ impl Output {
return Err("Failed to commit state");
}
+ // Read the just-committed frame back while the state's buffer is
+ // still alive; encode/notify happen on a worker thread.
+ if let Some(shot) = pending_shot {
+ if state.buffer.is_null() {
+ log::warn!("screenshot: output state has no buffer");
+ } else {
+ crate::screenshot::capture_state_buffer(
+ (*self.server).renderer,
+ state.buffer,
+ ffi::river_wlr_output_get_width(self.wlr_output),
+ ffi::river_wlr_output_get_height(self.wlr_output),
+ shot,
+ );
+ }
+ }
+
ffi::wlr_output_state_finish(&mut state);
match (*self.server).lock_manager.state {
diff --git a/src/server/screenshot.rs b/src/server/screenshot.rs
new file mode 100644
index 0000000..57a05f9
--- /dev/null
+++ b/src/server/screenshot.rs
@@ -0,0 +1,329 @@
+//! Native screenshots.
+//!
+//! Two capture paths, both replying over IPC with the destination path and
+//! finishing (PNG encode + notification) on a worker thread:
+//!
+//! - Full-output / region: `process_ipc_command` parks a [`PendingScreenshot`]
+//! on the window manager and schedules a frame; `Output::render_and_commit`
+//! picks it up right after `wlr_scene_output_build_state` renders the frame
+//! into the output state's buffer, and reads that buffer back
+//! (`wlr_texture_from_buffer` + `wlr_texture_read_pixels`). Regions are
+//! cropped CPU-side in buffer pixels.
+//! - Window: the window's committed surface textures are read back directly
+//! (root surface + subsurfaces composited by their offsets), so it works
+//! even when the window is panned outside the visible viewport — the
+//! client's last committed buffers still exist regardless of culling.
+//!
+//! Completion is announced through the freedesktop notification daemon
+//! (`notify-send` with the standard `image-path` hint, which cce-notifier
+//! renders as a thumbnail). The `notifications { screenshots }` key in
+//! config.kdl disables the announcement (default enabled); it is re-read per
+//! screenshot on the worker thread, so edits take effect immediately.
+
+use std::path::PathBuf;
+
+use crate::ffi;
+
+// DRM fourcc codes wlr_texture_preferred_read_format may hand us; all are
+// 8-bit-per-channel, little-endian packed (so ARGB8888 is B,G,R,A in memory).
+const DRM_FORMAT_XRGB8888: u32 = 0x34325258;
+const DRM_FORMAT_ARGB8888: u32 = 0x34325241;
+const DRM_FORMAT_XBGR8888: u32 = 0x34324258;
+const DRM_FORMAT_ABGR8888: u32 = 0x34324241;
+
+/// A full-output / region capture waiting for the next composited frame.
+pub struct PendingScreenshot {
+ /// The output whose next frame is captured.
+ pub output: *mut crate::output::Output,
+ /// Crop in output-buffer pixels; `None` captures the whole output.
+ pub region: Option<ffi::wlr_box>,
+ pub path: PathBuf,
+}
+
+/// `~/Pictures/screenshots/screenshot-YYYYMMDD-HHMMSS.png` (the directory is
+/// created by the encode thread).
+pub fn default_path() -> PathBuf {
+ let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string());
+ let mut tm: libc::tm = unsafe { std::mem::zeroed() };
+ let now = unsafe { libc::time(std::ptr::null_mut()) };
+ unsafe { libc::localtime_r(&now, &mut tm) };
+ let name = format!(
+ "screenshot-{:04}{:02}{:02}-{:02}{:02}{:02}.png",
+ tm.tm_year + 1900,
+ tm.tm_mon + 1,
+ tm.tm_mday,
+ tm.tm_hour,
+ tm.tm_min,
+ tm.tm_sec
+ );
+ PathBuf::from(home).join("Pictures").join("screenshots").join(name)
+}
+
+/// Read a texture's full contents into a tightly packed `w*h*4` byte buffer.
+/// Returns the bytes plus the DRM format they are in.
+unsafe fn read_texture(texture: *mut ffi::wlr_texture, w: i32, h: i32) -> Option<(Vec<u8>, u32)> {
+ if texture.is_null() || w <= 0 || h <= 0 {
+ return None;
+ }
+ let format = ffi::wlr_texture_preferred_read_format(texture);
+ let mut data = vec![0u8; (w as usize) * (h as usize) * 4];
+ let options = ffi::wlr_texture_read_pixels_options {
+ data: data.as_mut_ptr() as *mut std::ffi::c_void,
+ format,
+ stride: (w as u32) * 4,
+ dst_x: 0,
+ dst_y: 0,
+ src_box: std::mem::zeroed(), // empty = full texture
+ };
+ if !ffi::wlr_texture_read_pixels(texture, &options) {
+ return None;
+ }
+ Some((data, format))
+}
+
+/// Convert read-back pixels to RGBA in place. Alpha is forced opaque — the
+/// X-variants carry garbage alpha, and screenshots should not be translucent.
+fn to_rgba(mut pixels: Vec<u8>, format: u32) -> Option<Vec<u8>> {
+ match format {
+ DRM_FORMAT_XRGB8888 | DRM_FORMAT_ARGB8888 => {
+ for px in pixels.chunks_exact_mut(4) {
+ px.swap(0, 2);
+ px[3] = 255;
+ }
+ Some(pixels)
+ }
+ DRM_FORMAT_XBGR8888 | DRM_FORMAT_ABGR8888 => {
+ for px in pixels.chunks_exact_mut(4) {
+ px[3] = 255;
+ }
+ Some(pixels)
+ }
+ _ => {
+ log::warn!("screenshot: unsupported read format {format:#x}");
+ None
+ }
+ }
+}
+
+fn crop_rgba(pixels: &[u8], w: i32, h: i32, region: ffi::wlr_box) -> Option<(Vec<u8>, i32, i32)> {
+ let x0 = region.x.clamp(0, w);
+ let y0 = region.y.clamp(0, h);
+ let x1 = (region.x + region.width).clamp(0, w);
+ let y1 = (region.y + region.height).clamp(0, h);
+ let (cw, ch) = (x1 - x0, y1 - y0);
+ if cw <= 0 || ch <= 0 {
+ return None;
+ }
+ let mut out = Vec::with_capacity((cw as usize) * (ch as usize) * 4);
+ for row in y0..y1 {
+ let start = ((row * w + x0) * 4) as usize;
+ out.extend_from_slice(&pixels[start..start + (cw as usize) * 4]);
+ }
+ Some((out, cw, ch))
+}
+
+/// Full-output / region capture: called from `Output::render_and_commit`
+/// after a successful commit, while the output state's buffer is still alive.
+pub unsafe fn capture_state_buffer(
+ renderer: *mut ffi::wlr_renderer,
+ buffer: *mut ffi::wlr_buffer,
+ buf_w: i32,
+ buf_h: i32,
+ shot: PendingScreenshot,
+) {
+ let texture = ffi::wlr_texture_from_buffer(renderer, buffer);
+ if texture.is_null() {
+ log::warn!("screenshot: wlr_texture_from_buffer failed");
+ return;
+ }
+ let read = read_texture(texture, buf_w, buf_h);
+ ffi::wlr_texture_destroy(texture);
+ let Some((pixels, format)) = read else {
+ log::warn!("screenshot: pixel readback failed");
+ return;
+ };
+ let Some(rgba) = to_rgba(pixels, format) else { return };
+ let (rgba, out_w, out_h) = match shot.region {
+ Some(region) => match crop_rgba(&rgba, buf_w, buf_h, region) {
+ Some(cropped) => cropped,
+ None => {
+ log::warn!("screenshot: region outside the output");
+ return;
+ }
+ },
+ None => (rgba, buf_w, buf_h),
+ };
+ spawn_encode(rgba, out_w as u32, out_h as u32, shot.path);
+}
+
+/// Window capture straight from the committed surface textures: the root
+/// surface's buffer is the canvas, subsurfaces composite at their offsets.
+/// Works for windows outside the visible viewport (their last committed
+/// buffers persist), but needs the client to have committed at least once.
+pub unsafe fn capture_window(window: *mut crate::window::Window, path: PathBuf) -> Result<String, String> {
+ let root = (*window).root_surface();
+ if root.is_null() {
+ return Err("window has no surface".to_string());
+ }
+ let (mut bw, mut bh) = (0i32, 0i32);
+ ffi::river_wlr_surface_get_buffer_size(root, &mut bw, &mut bh);
+ if bw <= 0 || bh <= 0 {
+ return Err("window has no committed buffer".to_string());
+ }
+ // Subsurface offsets are surface-logical; buffers are physical pixels.
+ let logical_w = ffi::river_wlr_surface_get_width(root).max(1);
+ let scale = bw as f64 / logical_w as f64;
+
+ struct Collect {
+ list: Vec<(*mut ffi::wlr_surface, i32, i32)>,
+ }
+ unsafe extern "C" fn collect_cb(
+ surface: *mut ffi::wlr_surface,
+ sx: std::os::raw::c_int,
+ sy: std::os::raw::c_int,
+ data: *mut std::ffi::c_void,
+ ) {
+ let collect = &mut *(data as *mut Collect);
+ collect.list.push((surface, sx, sy));
+ }
+ let mut collect = Collect { list: Vec::new() };
+ ffi::wlr_surface_for_each_surface(
+ root,
+ Some(collect_cb),
+ &mut collect as *mut Collect as *mut std::ffi::c_void,
+ );
+
+ let mut canvas = vec![0u8; (bw as usize) * (bh as usize) * 4];
+ let mut composited = 0usize;
+ for (surface, sx, sy) in collect.list {
+ let texture = ffi::wlr_surface_get_texture(surface);
+ if texture.is_null() {
+ continue;
+ }
+ let (mut sw, mut sh) = (0i32, 0i32);
+ ffi::river_wlr_surface_get_buffer_size(surface, &mut sw, &mut sh);
+ let Some((pixels, format)) = read_texture(texture, sw, sh) else { continue };
+ let Some(rgba) = to_rgba(pixels, format) else { continue };
+ let dst_x = (sx as f64 * scale).round() as i32;
+ let dst_y = (sy as f64 * scale).round() as i32;
+ blit(&mut canvas, bw, bh, &rgba, sw, sh, dst_x, dst_y);
+ composited += 1;
+ }
+ if composited == 0 {
+ return Err("no readable surface content".to_string());
+ }
+
+ let reply = path.display().to_string();
+ spawn_encode(canvas, bw as u32, bh as u32, path);
+ Ok(reply)
+}
+
+/// Copy `src` (sw×sh RGBA) into `dst` (dw×dh RGBA) at (dx, dy), clipped.
+fn blit(dst: &mut [u8], dw: i32, dh: i32, src: &[u8], sw: i32, sh: i32, dx: i32, dy: i32) {
+ for sy in 0..sh {
+ let ty = dy + sy;
+ if ty < 0 || ty >= dh {
+ continue;
+ }
+ let sx0 = (-dx).clamp(0, sw);
+ let sx1 = (dw - dx).clamp(0, sw);
+ if sx0 >= sx1 {
+ continue;
+ }
+ let src_start = ((sy * sw + sx0) * 4) as usize;
+ let dst_start = ((ty * dw + dx + sx0) * 4) as usize;
+ let len = ((sx1 - sx0) * 4) as usize;
+ dst[dst_start..dst_start + len].copy_from_slice(&src[src_start..src_start + len]);
+ }
+}
+
+/// PNG-encode and save off the main thread, then announce via the
+/// notification daemon (unless disabled in config).
+fn spawn_encode(rgba: Vec<u8>, w: u32, h: u32, path: PathBuf) {
+ std::thread::spawn(move || {
+ if let Some(parent) = path.parent() {
+ let _ = std::fs::create_dir_all(parent);
+ }
+ let file = match std::fs::File::create(&path) {
+ Ok(f) => f,
+ Err(e) => {
+ log::warn!("screenshot: failed to create {}: {e}", path.display());
+ return;
+ }
+ };
+ let mut encoder = png::Encoder::new(std::io::BufWriter::new(file), w, h);
+ encoder.set_color(png::ColorType::Rgba);
+ encoder.set_depth(png::BitDepth::Eight);
+ let write = encoder
+ .write_header()
+ .and_then(|mut writer| writer.write_image_data(&rgba));
+ if let Err(e) = write {
+ log::warn!("screenshot: failed to encode {}: {e}", path.display());
+ return;
+ }
+ log::info!("screenshot saved to {}", path.display());
+
+ if notifications_enabled() {
+ let path_str = path.display().to_string();
+ let _ = std::process::Command::new("notify-send")
+ .arg("-a")
+ .arg("cce")
+ .arg("-h")
+ .arg(format!("string:image-path:{path_str}"))
+ .arg("Screenshot saved")
+ .arg(&path_str)
+ .spawn();
+ }
+ });
+}
+
+/// `notifications { screenshots <bool> }` in the shared config.kdl; absent
+/// means enabled.
+fn notifications_enabled() -> bool {
+ let Ok(content) = std::fs::read_to_string(cce_ui::config::get_config_path()) else {
+ return true;
+ };
+ cce_ui::config::parse_kdl_to_json(&content)
+ .pointer("/notifications/screenshots")
+ .and_then(|v| v.as_bool())
+ .unwrap_or(true)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn to_rgba_swizzles_bgra_and_forces_opaque() {
+ // One BGRA pixel: B=1 G=2 R=3 A=4 → RGBA 3,2,1,255.
+ let out = to_rgba(vec![1, 2, 3, 4], DRM_FORMAT_ARGB8888).unwrap();
+ assert_eq!(out, vec![3, 2, 1, 255]);
+ // RGBA passthrough, alpha forced opaque.
+ let out = to_rgba(vec![1, 2, 3, 4], DRM_FORMAT_ABGR8888).unwrap();
+ assert_eq!(out, vec![1, 2, 3, 255]);
+ assert!(to_rgba(vec![0; 4], 0x1234).is_none());
+ }
+
+ #[test]
+ fn crop_clamps_to_bounds() {
+ // 2x2 image, pixels numbered 0..4 in the red channel.
+ let px: Vec<u8> = (0..4u8).flat_map(|i| [i, 0, 0, 255]).collect();
+ let region = ffi::wlr_box { x: 1, y: 0, width: 5, height: 5 };
+ let (out, w, h) = crop_rgba(&px, 2, 2, region).unwrap();
+ assert_eq!((w, h), (1, 2));
+ assert_eq!(out[0], 1);
+ assert_eq!(out[4], 3);
+ let empty = ffi::wlr_box { x: 5, y: 5, width: 1, height: 1 };
+ assert!(crop_rgba(&px, 2, 2, empty).is_none());
+ }
+
+ #[test]
+ fn blit_clips_at_edges() {
+ let mut dst = vec![0u8; 2 * 2 * 4];
+ let src: Vec<u8> = vec![9; 2 * 2 * 4];
+ blit(&mut dst, 2, 2, &src, 2, 2, 1, 1); // only dst (1,1) covered
+ assert_eq!(dst[(1 * 2 + 1) * 4], 9);
+ assert_eq!(dst[0], 0);
+ blit(&mut dst, 2, 2, &src, 2, 2, -5, -5); // fully clipped: no panic
+ }
+}
diff --git a/src/server/window_manager.rs b/src/server/window_manager.rs
index 77f9f40..adc9fd8 100644
--- a/src/server/window_manager.rs
+++ b/src/server/window_manager.rs
@@ -75,6 +75,9 @@ pub struct WindowManager {
pub gesture_binds: Vec<crate::config::GestureBind>,
pub ipc_rx: Option<std::sync::mpsc::Receiver<crate::ipc_server::IpcRequest>>,
pub ipc_timer: *mut ffi::wl_event_source,
+ /// A full-output/region screenshot parked for the next composited frame
+ /// (`ccectl screenshot`); consumed by `Output::render_and_commit`.
+ pub pending_screenshot: Option<crate::screenshot::PendingScreenshot>,
pub startup: Vec<crate::config::StartupConfig>,
pub startup_pids: Vec<(crate::config::StartupConfig, nix::unistd::Pid)>,
pub status_sender: Option<crate::status_server::StatusSender>,
@@ -152,6 +155,7 @@ impl WindowManager {
self.target_desk_pan_y = None;
self.animation_timer = std::ptr::null_mut();
self.desk_zoom = 1.0;
+ self.pending_screenshot = None;
self.mode = WindowManagerMode::Normal;
self.global_layout = crate::tiling::TilingMode::Cascade;
self.restore_queue = Vec::new();
@@ -2607,6 +2611,84 @@ impl WindowManager {
(h * self.desk_zoom).round() as i32,
)
}
+ "screenshot" => {
+ // screenshot → the enabled output's next frame
+ // screenshot region <x> <y> <w> <h> → on-screen region (logical px)
+ // screenshot window [app_id|id] → window content, even off-viewport
+ let path = crate::screenshot::default_path();
+ match parts.get(1).copied() {
+ Some("window") => {
+ let target: *mut Window = if parts.len() >= 3 {
+ self.find_window_by_query(&parts[2..].join(" "))
+ } else if let Some(seat) = self.first_seat() {
+ match (*seat).focused {
+ crate::seat::Focus::Window(w) => w,
+ _ => std::ptr::null_mut(),
+ }
+ } else {
+ std::ptr::null_mut()
+ };
+ if target.is_null() {
+ return "error: window not found\n".to_string();
+ }
+ match crate::screenshot::capture_window(target, path) {
+ Ok(p) => format!("ok {}\n", p),
+ Err(e) => format!("error: {}\n", e),
+ }
+ }
+ None | Some("region") => {
+ // Region args are logical on-screen coordinates relative to
+ // the output; the capture crops the physical buffer.
+ let region_logical = if parts.get(1) == Some(&"region") {
+ let vals: Vec<f64> = parts[2..].iter().filter_map(|p| p.parse().ok()).collect();
+ if vals.len() != 4 {
+ return "error: usage: screenshot region <x> <y> <w> <h>\n".to_string();
+ }
+ Some((vals[0], vals[1], vals[2], vals[3]))
+ } else {
+ None
+ };
+
+ // First enabled output (same walk as center-window).
+ let mut target_out: *mut crate::output::Output = std::ptr::null_mut();
+ let outputs_list = &mut (*self.server).om.outputs as *mut ffi::wl_list as *mut WlList;
+ let mut curr_out = (*outputs_list).next;
+ while curr_out != outputs_list {
+ let output = crate::container_of!(curr_out, crate::output::Output, link);
+ if (*output).sent.state == crate::output::OutputStateValue::Enabled {
+ target_out = output;
+ break;
+ }
+ curr_out = (*curr_out).next;
+ }
+ if target_out.is_null() {
+ return "error: no enabled output\n".to_string();
+ }
+
+ let region = region_logical.map(|(x, y, w, h)| {
+ // logical → buffer px via the output's effective scale.
+ let buf_w = ffi::river_wlr_output_get_width((*target_out).wlr_output) as f64;
+ let layout_w = (*target_out).sent.box_layout().width.max(1) as f64;
+ let scale = buf_w / layout_w;
+ ffi::wlr_box {
+ x: (x * scale).round() as i32,
+ y: (y * scale).round() as i32,
+ width: (w * scale).round() as i32,
+ height: (h * scale).round() as i32,
+ }
+ });
+
+ self.pending_screenshot = Some(crate::screenshot::PendingScreenshot {
+ output: target_out,
+ region,
+ path: path.clone(),
+ });
+ ffi::wlr_output_schedule_frame((*target_out).wlr_output);
+ format!("ok {}\n", path.display())
+ }
+ Some(other) => format!("error: unknown screenshot target: {}\n", other),
+ }
+ }
"exit" => {
self.execute_action(&crate::config::Action::Exit, None);
"ok\n".to_string()
diff --git a/src/server/wlroots_log_wrapper.c b/src/server/wlroots_log_wrapper.c
index bb80fc6..ef47fd7 100644
--- a/src/server/wlroots_log_wrapper.c
+++ b/src/server/wlroots_log_wrapper.c
@@ -603,6 +603,11 @@ int river_wlr_surface_get_height(struct wlr_surface *surface) {
return surface->current.height;
}
+void river_wlr_surface_get_buffer_size(struct wlr_surface *surface, int *width, int *height) {
+ *width = surface->current.buffer_width;
+ *height = surface->current.buffer_height;
+}
+
struct wlr_keyboard *river_wlr_input_method_keyboard_grab_v2_get_keyboard(struct wlr_input_method_keyboard_grab_v2 *grab) {
return grab->keyboard;
}
diff --git a/wrapper.h b/wrapper.h
index 7288e93..10300f8 100644
--- a/wrapper.h
+++ b/wrapper.h
@@ -244,6 +244,7 @@ struct wlr_surface *river_wlr_seat_get_keyboard_focused_surface(struct wlr_seat
int river_wlr_surface_get_width(struct wlr_surface *surface);
int river_wlr_surface_get_height(struct wlr_surface *surface);
+void river_wlr_surface_get_buffer_size(struct wlr_surface *surface, int *width, int *height);
struct wlr_keyboard *river_wlr_input_method_keyboard_grab_v2_get_keyboard(struct wlr_input_method_keyboard_grab_v2 *grab);
struct wl_signal *river_wlr_input_method_keyboard_grab_v2_get_destroy_signal(struct wlr_input_method_keyboard_grab_v2 *grab);