srdusr
aboutsummaryrefslogtreecommitdiffstats
path: root/crates/wayland
diff options
context:
space:
mode:
authorsrdusr <[email protected]>2026-07-14 22:34:00 +0200
committersrdusr <[email protected]>2026-07-14 22:34:00 +0200
commitaa7b7278b9e758985197170bcee4f0d2d4cb4590 (patch)
treea7426dd4851afb3bf5960788baf0bc78070ba4b0 /crates/wayland
parent5ef39bd041c64c66f629f9fac53ecc0cdd7aff11 (diff)
downloadsrdwm-aa7b7278b9e758985197170bcee4f0d2d4cb4590.tar.gz
srdwm-aa7b7278b9e758985197170bcee4f0d2d4cb4590.zip
Workspace capture writes a readable image, and left-edge resize holds its anchor
Two things, and the first is smaller than I told anyone. WORKSPACE CAPTURE. I said off-screen workspace capture did not exist and would need building. It already did: udev/capture.rs renders a workspace that is not on screen, at the target monitor's native size, downscaled to a requested size, wallpaper included. Verified on the live DRM session rather than from the source - capturing the active workspace and a non-visible one gave 320x180 images with mean luminance 0.067 and 0.137, so the second is genuinely a different render and not a copy of what is presented. The only thing missing was the container. It wrote PPM, which the shells that want thumbnails cannot decode, so the file was written successfully, returned successfully, and silently not drawn - the same failure class as a capture pass that omits a tier. encode_capture now picks the format from the destination's extension: .ppm still writes PPM so existing callers keep working, .jpg/.jpeg write JPEG, anything else writes PNG. Four tests check the actual magic bytes rather than trusting the call, plus the unfamiliar extension fallback and a size-mismatch error. LEFT-EDGE RESIZE. Reported as content resizing "from the right side even when i resize from left". The window's origin moves the instant the pointer does, but the client only commits a matching buffer some frames later, so its still-old content was being placed at the new origin - which slides the whole window rather than growing it, and leaves the edge that should be nailed down drifting. sync_geometry now derives the origin from the size the client has actually committed when the drag is from a left or top edge, so the opposite edge stays exactly where the drag started and the dragged edge catches up as commits arrive. A right or bottom drag is untouched: its origin never moves. 533 tests pass, clippy clean.
Diffstat (limited to 'crates/wayland')
-rw-r--r--crates/wayland/src/state/geometry.rs45
-rw-r--r--crates/wayland/src/udev/capture.rs72
2 files changed, 114 insertions, 3 deletions
diff --git a/crates/wayland/src/state/geometry.rs b/crates/wayland/src/state/geometry.rs
index 1910334..b8f610e 100644
--- a/crates/wayland/src/state/geometry.rs
+++ b/crates/wayland/src/state/geometry.rs
@@ -241,6 +241,37 @@ impl CompState {
/// reading `Window.geometry` unchanged - those are about this
/// compositor's own bookkeeping staying self-consistent, not about
/// matching a client's real pixels.
+ /// `geom`'s origin, corrected so an in-progress left/top resize keeps
+ /// its opposite edge fixed. Returns `geom`'s own origin unchanged when
+ /// this window is not being resized, or is being resized from an edge
+ /// whose origin does not move.
+ ///
+ /// `committed` is the client's own last-committed content size, in
+ /// logical points; `scale` converts it to the physical space every rect
+ /// here uses.
+ fn anchor_resizing_origin(
+ wm: &Rc<RefCell<WindowManager>>,
+ id: WindowId,
+ geom: srdwm_core::Rect,
+ committed: smithay::utils::Size<i32, smithay::utils::Logical>,
+ scale: f64,
+ ) -> (i32, i32) {
+ let Some((resizing, edge, orig)) = wm.borrow().resize_anchor() else { return (geom.x, geom.y) };
+ if resizing != id {
+ return (geom.x, geom.y);
+ }
+ let (mut x, mut y) = (geom.x, geom.y);
+ let physical_w = (committed.w as f64 * scale).round() as i32;
+ let physical_h = (committed.h as f64 * scale).round() as i32;
+ if physical_w > 0 && matches!(edge, srdwm_core::ResizeEdge::Left | srdwm_core::ResizeEdge::TopLeft | srdwm_core::ResizeEdge::BottomLeft) {
+ x = orig.right() - physical_w;
+ }
+ if physical_h > 0 && matches!(edge, srdwm_core::ResizeEdge::Top | srdwm_core::ResizeEdge::TopLeft | srdwm_core::ResizeEdge::TopRight) {
+ y = orig.bottom() - physical_h;
+ }
+ (x, y)
+ }
+
pub(crate) fn effective_frame(&self, id: WindowId, geom: srdwm_core::Rect) -> srdwm_core::Rect {
Self::effective_frame_of(&self.wm, &self.id_to_window, &self.pending_size_configure, id, geom)
}
@@ -362,7 +393,19 @@ impl CompState {
// calls already never did this (X11 windows have no equivalent
// shadow-margin geometry), which in hindsight was the correct
// pattern being followed there all along.
- self.space.map_element(w.clone(), (geom.x, geom.y + band), false);
+ // While resizing from a left or top edge, hold the opposite
+ // edge still. `geom` moves the instant the pointer does, but
+ // the client only commits a matching buffer some frames later,
+ // so placing its still-old content at the new origin slides the
+ // whole window sideways rather than growing it - reported as
+ // content resizing "from the right side even when i resize from
+ // left". Deriving the origin from the size the client has
+ // actually committed keeps the anchored edge exactly where the
+ // drag started, and the dragged edge catches up as commits
+ // arrive. A right/bottom drag needs none of this: its origin
+ // does not move at all.
+ let placed = Self::anchor_resizing_origin(&self.wm, id, geom, w.geometry().size, scale);
+ self.space.map_element(w.clone(), (placed.0, placed.1 + band), false);
moved = true;
if let Some(top) = w.toplevel() {
// xdg-shell position is a purely compositor-side concept --
diff --git a/crates/wayland/src/udev/capture.rs b/crates/wayland/src/udev/capture.rs
index fb4c22f..a64a73c 100644
--- a/crates/wayland/src/udev/capture.rs
+++ b/crates/wayland/src/udev/capture.rs
@@ -116,6 +116,36 @@ impl CompState {
}
}
+/// Encodes packed RGB to whatever the destination's extension asks for.
+///
+/// PPM was the only format this ever wrote, which made the capture
+/// unreadable to its actual consumers: a shell drawing thumbnails decodes
+/// PNG/JPEG/WebP and not PPM, so the file was written successfully,
+/// returned successfully, and then silently not drawn. The render itself
+/// was never the problem - only the container.
+///
+/// `.ppm` still produces PPM, so any existing caller keeps working;
+/// anything else is chosen by extension, defaulting to PNG when the
+/// extension is unfamiliar. PNG is the safe default: it is lossless and
+/// universally decodable, and at thumbnail sizes the size difference
+/// against JPEG is tens of kilobytes.
+fn encode_capture(rgb: &[u8], width: u32, height: u32, path: &str) -> Result<Vec<u8>, String> {
+ let extension = std::path::Path::new(path).extension().and_then(|e| e.to_str()).unwrap_or_default().to_ascii_lowercase();
+ if extension == "ppm" {
+ let mut out = format!("P6\n{width} {height}\n255\n").into_bytes();
+ out.extend_from_slice(rgb);
+ return Ok(out);
+ }
+ let format = match extension.as_str() {
+ "jpg" | "jpeg" => image::ImageFormat::Jpeg,
+ _ => image::ImageFormat::Png,
+ };
+ let buffer = image::RgbImage::from_raw(width, height, rgb.to_vec()).ok_or_else(|| format!("capture buffer is not {width}x{height} RGB"))?;
+ let mut out = std::io::Cursor::new(Vec::new());
+ image::DynamicImage::ImageRgb8(buffer).write_to(&mut out, format).map_err(|e| format!("encode {extension}: {e}"))?;
+ Ok(out.into_inner())
+}
+
/// `pixels` is `Xrgb8888` - 4 bytes per pixel, little-endian, so byte
/// order in memory is B, G, R, X. PPM (`P6`) wants tightly-packed R, G, B
/// with no pad byte, hence the reorder rather than a straight `memcpy`.
@@ -150,8 +180,7 @@ fn write_ppm(pixels: &[u8], native: (u32, u32), target: Option<(u32, u32)>, path
}
}
- let mut out = format!("P6\n{tw} {th}\n255\n").into_bytes();
- out.extend_from_slice(&rgb);
+ let out = encode_capture(&rgb, tw, th, path)?;
// Written to a `.tmp` sibling and renamed into place: a reader (AGS's
// wsPreview poller) racing a partial write is exactly the kind of
// flicker/corruption a debounced, event-driven cache is supposed to
@@ -161,3 +190,42 @@ fn write_ppm(pixels: &[u8], native: (u32, u32), target: Option<(u32, u32)>, path
std::fs::write(&tmp, &out).map_err(|e| format!("write {tmp}: {e}"))?;
std::fs::rename(&tmp, path).map_err(|e| format!("rename to {path}: {e}"))
}
+
+#[cfg(test)]
+mod tests {
+ use super::encode_capture;
+
+ fn rgb(w: u32, h: u32) -> Vec<u8> {
+ (0..w * h).flat_map(|i| [(i % 251) as u8, 0x40, 0x80]).collect()
+ }
+
+ #[test]
+ fn the_extension_picks_the_container() {
+ // Checked by magic bytes rather than by trusting the call: the whole
+ // point is that the file a consumer opens is the format it expects.
+ let px = rgb(8, 4);
+ assert!(encode_capture(&px, 8, 4, "/tmp/x.ppm").unwrap().starts_with(b"P6"), "ppm");
+ assert!(encode_capture(&px, 8, 4, "/tmp/x.png").unwrap().starts_with(&[0x89, b'P', b'N', b'G']), "png");
+ assert!(encode_capture(&px, 8, 4, "/tmp/x.jpg").unwrap().starts_with(&[0xff, 0xd8]), "jpg");
+ assert!(encode_capture(&px, 8, 4, "/tmp/x.jpeg").unwrap().starts_with(&[0xff, 0xd8]), "jpeg");
+ }
+
+ #[test]
+ fn an_unfamiliar_extension_falls_back_to_png_rather_than_failing() {
+ let px = rgb(4, 4);
+ let out = encode_capture(&px, 4, 4, "/tmp/thumb.thumbnail").unwrap();
+ assert!(out.starts_with(&[0x89, b'P', b'N', b'G']));
+ }
+
+ #[test]
+ fn a_buffer_that_does_not_match_the_size_is_an_error_not_a_panic() {
+ assert!(encode_capture(&rgb(4, 4), 8, 8, "/tmp/x.png").is_err());
+ }
+
+ #[test]
+ fn the_encoded_image_round_trips_at_the_requested_size() {
+ let out = encode_capture(&rgb(9, 5), 9, 5, "/tmp/x.png").unwrap();
+ let decoded = image::load_from_memory(&out).expect("our own png must decode");
+ assert_eq!((decoded.width(), decoded.height()), (9, 5));
+ }
+}