git.lucas.co / cce-compositor
Wayland compositor (wlroots)
git clone https://git.lucas.co/cce-compositor.git

commitb8ea70058ac593d0c20edbbcdb39b8455b134f66
parent52db9d9a06
authorLucas Galante <[email protected]>
date2026-07-16 15:14
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);