From 0b94ae1eb8c686bbfeb8542967fbb9a1e8366c70 Mon Sep 17 00:00:00 2001 From: Shawn Wallace Date: Sat, 6 Sep 2025 12:56:58 -0400 Subject: [PATCH] Support client initiated window move (_NET_WM_MOVERESIZE) Part of #185 --- Cargo.lock | 82 +++++++++++++++++++++++++++++++++++++++++- Cargo.toml | 1 + src/server/dispatch.rs | 34 +++++++++--------- src/server/event.rs | 42 +++++++++++++++++----- src/server/mod.rs | 25 +++++++++++++ src/xstate/mod.rs | 81 ++++++++++++++++++++++------------------- tests/integration.rs | 33 +++++++++++++++-- testwl/src/lib.rs | 6 ++++ 8 files changed, 239 insertions(+), 65 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e7ffed3..8c426f0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -205,6 +205,12 @@ dependencies = [ "termcolor", ] +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + [[package]] name = "errno" version = "0.3.12" @@ -236,13 +242,19 @@ dependencies = [ "ahash", ] +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" + [[package]] name = "hecs" version = "0.10.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e1cbc675ee8d97b4d206a985137f8ad59666538f56f906474f554467a63c776d" dependencies = [ - "hashbrown", + "hashbrown 0.14.5", "hecs-macros", "spin", ] @@ -285,6 +297,16 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" +[[package]] +name = "indexmap" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2481980430f9f78649238835720ddccc57e52df14ffce1c6f37391d61b563e9" +dependencies = [ + "equivalent", + "hashbrown 0.15.5", +] + [[package]] name = "is-terminal" version = "0.4.16" @@ -397,6 +419,28 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" +[[package]] +name = "num_enum" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a973b4e44ce6cad84ce69d797acf9a044532e4184c4f267913d1b546a0727b7a" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77e878c846a8abae00dd069496dbe8751b16ac1c3d6bd2a7283a938e8228f90d" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "num_threads" version = "0.1.7" @@ -444,6 +488,15 @@ dependencies = [ "syn", ] +[[package]] +name = "proc-macro-crate" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edce586971a4dfaa28950c6f18ed55e0406c1ab88bbce2c6f6293a7aaba73d35" +dependencies = [ + "toml_edit", +] + [[package]] name = "proc-macro2" version = "1.0.95" @@ -694,6 +747,23 @@ dependencies = [ "time-core", ] +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "toml_datetime", + "winnow", +] + [[package]] name = "unicode-ident" version = "1.0.18" @@ -1007,6 +1077,15 @@ version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "271414315aff87387382ec3d271b52d7ae78726f5d44ac98b4f4030c91880486" +[[package]] +name = "winnow" +version = "0.7.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21a0236b59786fed61e2a80582dd500fe61f18b5dca67a4a067d0bc9039339cf" +dependencies = [ + "memchr", +] + [[package]] name = "wl_drm" version = "0.1.0" @@ -1069,6 +1148,7 @@ dependencies = [ "hecs", "log", "macros", + "num_enum", "pretty_env_logger", "rustix", "sd-notify", diff --git a/Cargo.toml b/Cargo.toml index a19f556..69b4bbd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -42,6 +42,7 @@ smithay-client-toolkit = { version = "0.19.1", default-features = false } sd-notify = { version = "0.4.2", optional = true } macros = { version = "0.1.0", path = "macros" } hecs = { version = "0.10.5", features = ["macros"] } +num_enum = "0.7.4" [features] default = [] diff --git a/src/server/dispatch.rs b/src/server/dispatch.rs index f540259..51f640d 100644 --- a/src/server/dispatch.rs +++ b/src/server/dispatch.rs @@ -350,11 +350,6 @@ impl Dispatch for InnerServerState { _: &DisplayHandle, _: &mut wayland_server::DataInit<'_, Self>, ) { - let c_pointer = state - .world - .get::<&client::wl_pointer::WlPointer>(*entity) - .unwrap(); - match request { Request::::SetCursor { serial, @@ -362,6 +357,11 @@ impl Dispatch for InnerServerState { hotspot_y, surface, } => { + let c_pointer = state + .world + .get::<&client::wl_pointer::WlPointer>(*entity) + .unwrap(); + let c_surface = surface.and_then(|s| { let e = s.data().copied()?; Some( @@ -374,9 +374,11 @@ impl Dispatch for InnerServerState { c_pointer.set_cursor(serial, c_surface.as_deref(), hotspot_x, hotspot_y); } Request::::Release => { - c_pointer.release(); - drop(c_pointer); - let _ = state.world.despawn(*entity); + let (client, _) = state + .world + .remove::<(client::wl_pointer::WlPointer, WlPointer)>(*entity) + .unwrap(); + client.release(); } _ => warn!("unhandled cursor request: {request:?}"), } @@ -395,12 +397,11 @@ impl Dispatch for InnerServerState { ) { match request { Request::::Release => { - state + let (client, _) = state .world - .get::<&client::wl_keyboard::WlKeyboard>(*entity) - .unwrap() - .release(); - state.world.despawn(*entity).unwrap(); + .remove::<(client::wl_keyboard::WlKeyboard, WlKeyboard)>(*entity) + .unwrap(); + client.release(); } _ => unreachable!(), } @@ -443,16 +444,15 @@ impl Dispatch for InnerServerState { ) { match request { Request::::GetPointer { id } => { - let new_entity = state.world.reserve_entity(); let client = { state .world .get::<&client::wl_seat::WlSeat>(*entity) .unwrap() - .get_pointer(&state.qh, new_entity) + .get_pointer(&state.qh, *entity) }; - let server = data_init.init(id, new_entity); - state.world.spawn_at(new_entity, (client, server)); + let server = data_init.init(id, *entity); + state.world.insert(*entity, (client, server)).unwrap(); } Request::::GetKeyboard { id } => { let client = { diff --git a/src/server/event.rs b/src/server/event.rs index 8ebd4be..d97d441 100644 --- a/src/server/event.rs +++ b/src/server/event.rs @@ -435,6 +435,8 @@ impl Event for client::wl_seat::Event { } struct PendingEnter(client::wl_pointer::Event); +struct CurrentSurface(Entity); +pub struct LastClickSerial(pub client::wl_seat::WlSeat, pub u32); impl Event for client::wl_pointer::Event { fn handle(self, target: Entity, state: &mut ServerState) { @@ -459,7 +461,8 @@ impl Event for client::wl_pointer::Event { let mut cmd = CommandBuffer::new(); let pending_enter = state.world.remove_one::(target).ok(); let server = state.world.get::<&WlPointer>(target).unwrap(); - let mut query = surface.data().copied().and_then(|e| { + let surface_entity = surface.data().copied(); + let mut query = surface_entity.and_then(|e| { state .world .query_one::<(&WlSurface, &SurfaceRole, &SurfaceScaleFactor, &x::Window)>(e) @@ -481,6 +484,7 @@ impl Event for client::wl_pointer::Event { if !surface_is_popup { state.last_hovered = Some(*window); } + cmd.insert(target, (CurrentSurface(surface_entity.unwrap()),)); }; if !surface_is_popup { @@ -512,11 +516,11 @@ impl Event for client::wl_pointer::Event { } client::wl_pointer::Event::Leave { serial, surface } => { let _ = state.world.remove_one::(target); + let _ = state.world.remove_one::(target); if !surface.is_alive() { return; } debug!("leaving surface ({serial})"); - let _ = state.world.remove_one::(target); if let Some(surface) = surface .data() .copied() @@ -584,17 +588,39 @@ impl Event for client::wl_pointer::Event { } } } + client::wl_pointer::Event::Button { + serial, + time, + button, + state: button_state, + } => { + let mut cmd = CommandBuffer::new(); + let (server, seat, CurrentSurface(surface)) = state + .world + .query_one_mut::<(&WlPointer, &client::wl_seat::WlSeat, &CurrentSurface)>( + target, + ) + .unwrap(); + + // from linux/input-event-codes.h + mod button_codes { + pub const LEFT: u32 = 0x110; + } + + if button_state == WEnum::Value(client::wl_pointer::ButtonState::Pressed) + && button == button_codes::LEFT + { + cmd.insert(*surface, (LastClickSerial(seat.clone(), serial),)); + } + + server.button(serial, time, button, convert_wenum(button_state)); + cmd.run_on(&mut state.world); + } _ => { let server = state.world.get::<&WlPointer>(target).unwrap(); simple_event_shunt! { server, self => [ Frame, - Button { - serial, - time, - button, - |state| convert_wenum(state) - }, Axis { time, |axis| convert_wenum(axis), diff --git a/src/server/mod.rs b/src/server/mod.rs index d818562..28e4a34 100644 --- a/src/server/mod.rs +++ b/src/server/mod.rs @@ -1091,6 +1091,31 @@ impl InnerServerState { ); } + pub fn move_window(&mut self, window: x::Window) { + let Some(data) = self + .windows + .get(&window) + .copied() + .and_then(|e| self.world.entity(e).ok()) + else { + warn!("Requested move of unknown window {window:?}"); + return; + }; + + let Some(last_click_data) = data.get::<&LastClickSerial>() else { + warn!("Requested move of window {window:?} but we don't have a click serial for it"); + return; + }; + + let role = data.get::<&SurfaceRole>(); + let Some(SurfaceRole::Toplevel(Some(data))) = role.as_deref() else { + warn!("Requested move of non toplevel {window:?} ({role:?})"); + return; + }; + + data.toplevel._move(&last_click_data.0, last_click_data.1); + } + pub fn destroy_window(&mut self, window: x::Window) { if let Some(id) = self.windows.remove(&window) { self.world.remove::<(x::Window, WindowData)>(id).unwrap(); diff --git a/src/xstate/mod.rs b/src/xstate/mod.rs index 7b61948..c6a2b50 100644 --- a/src/xstate/mod.rs +++ b/src/xstate/mod.rs @@ -509,6 +509,28 @@ impl XState { x if x == self.atoms.active_win => { server_state.activate_window(e.window()); } + x if x == self.atoms.moveresize => { + let x::ClientMessageData::Data32(data) = e.data() else { + unreachable!(); + }; + + let (_x_root, _y_root) = (data[0], data[1]); + let Ok(direction) = MoveResizeDirection::try_from(data[2]) else { + warn!("unknown direction for _NET_WM_MOVERESIZE: {}", data[2]); + continue; + }; + let button = data[3]; + // XXX: This can technically be driven by keyboard events and other mouse buttons as well, + // but I haven't found an application that does this yet. We'll cross that bridge when we get to it. + if button != 1 { + warn!("Attempted move/resize of {:?} with non left click button ({button})", e.window()); + continue; + } + + if matches!(direction, MoveResizeDirection::Move) { + server_state.move_window(e.window()); + } + } t => warn!("unrecognized message: {t:?}"), }, xcb::Event::X(x::Event::MappingNotify(_)) => {} @@ -933,6 +955,7 @@ xcb::atoms_struct! { xsettings => b"_XSETTINGS_S0" only_if_exists = false, xsettings_settings => b"_XSETTINGS_SETTINGS" only_if_exists = false, primary => b"PRIMARY" only_if_exists = false, + moveresize => b"_NET_WM_MOVERESIZE" only_if_exists = false, } } @@ -1076,24 +1099,13 @@ mod motif { } } - #[derive(Debug, PartialEq, Eq, Clone, Copy)] + #[derive(Debug, PartialEq, Eq, Clone, Copy, num_enum::TryFromPrimitive)] + #[repr(u32)] pub enum Decorations { Client = 0, Server = 1, } - impl TryFrom for Decorations { - type Error = (); - - fn try_from(value: u32) -> Result { - match value { - 0 => Ok(Self::Client), - 1 => Ok(Self::Server), - _ => Err(()), - } - } - } - impl From for zxdg_toplevel_decoration_v1::Mode { fn from(value: Decorations) -> Self { match value { @@ -1104,42 +1116,37 @@ mod motif { } } -#[derive(Debug, Clone, Copy)] +#[derive(Debug, Clone, Copy, num_enum::TryFromPrimitive)] +#[repr(u32)] pub enum SetState { Remove, Add, Toggle, } -impl TryFrom for SetState { - type Error = (); - fn try_from(value: u32) -> Result { - match value { - 0 => Ok(Self::Remove), - 1 => Ok(Self::Add), - 2 => Ok(Self::Toggle), - _ => Err(()), - } - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, num_enum::TryFromPrimitive)] +#[repr(u32)] pub enum WmState { Withdrawn = 0, Normal = 1, Iconic = 3, } -impl TryFrom for WmState { - type Error = (); - fn try_from(value: u32) -> Result { - match value { - 0 => Ok(Self::Withdrawn), - 1 => Ok(Self::Normal), - 3 => Ok(Self::Iconic), - _ => Err(()), - } - } +#[derive(Debug, Copy, Clone, num_enum::TryFromPrimitive, num_enum::IntoPrimitive)] +#[repr(u32)] +pub enum MoveResizeDirection { + SizeTopLeft, + SizeTop, + SizeTopRight, + SizeRight, + SizeBottomRight, + SizeBottom, + SizeBottomLeft, + SizeLeft, + Move, + SizeKeyboard, + MoveKeyboard, + Cancel, } pub struct RealConnection { diff --git a/tests/integration.rs b/tests/integration.rs index e58617b..71b0291 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -14,11 +14,11 @@ use std::time::{Duration, Instant}; use wayland_protocols::xdg::{ decoration::zv1::server::zxdg_toplevel_decoration_v1, shell::server::xdg_toplevel, }; -use wayland_server::protocol::wl_output; +use wayland_server::protocol::{wl_output, wl_pointer}; use wayland_server::Resource; use xcb::{x, Xid}; use xwayland_satellite as xwls; -use xwayland_satellite::xstate::{WmSizeHintsFlags, WmState}; +use xwayland_satellite::xstate::{MoveResizeDirection, WmSizeHintsFlags, WmState}; #[derive(Default)] struct TestDataInner { @@ -329,6 +329,7 @@ xcb::atoms_struct! { incr => b"INCR", xsettings => b"_XSETTINGS_S0", xsettings_setting => b"_XSETTINGS_SETTINGS", + moveresize => b"_NET_WM_MOVERESIZE", } } @@ -2024,3 +2025,31 @@ fn rotated_output() { other => panic!("unexpected event {other:?}"), } } + +const BTN_LEFT: u32 = 0x110; + +#[test] +fn client_init_move() { + let mut f = Fixture::new(); + let mut connection = Connection::new(&f.display); + + let win_toplevel = connection.new_window(connection.root, 0, 0, 20, 20, false); + let surface = f.map_as_toplevel(&mut connection, win_toplevel); + f.testwl.move_pointer_to(surface, 10., 10.); + let ptr = f.testwl.pointer(); + ptr.motion(10, 10.0, 10.0); + ptr.frame(); + ptr.button(10, 20, BTN_LEFT, wl_pointer::ButtonState::Pressed); + ptr.frame(); + f.testwl.dispatch(); + + connection.send_client_message(&x::ClientMessageEvent::new( + win_toplevel, + connection.atoms.moveresize, + x::ClientMessageData::Data32([0, 0, MoveResizeDirection::Move.into(), 1, 0]), + )); + + f.wait_and_dispatch(); + let data = f.testwl.get_surface_data(surface).unwrap(); + assert!(data.moving); +} diff --git a/testwl/src/lib.rs b/testwl/src/lib.rs index 6dd52de..481dcc6 100644 --- a/testwl/src/lib.rs +++ b/testwl/src/lib.rs @@ -110,6 +110,7 @@ pub struct SurfaceData { pub last_enter_serial: Option, pub fractional: Option, pub viewport: Option, + pub moving: bool, } impl SurfaceData { @@ -1541,6 +1542,10 @@ impl Dispatch for State { }; toplevel.parent = parent; } + xdg_toplevel::Request::Move { seat: _, serial: _ } => { + let data = state.surfaces.get_mut(surface_id).unwrap(); + data.moving = true; + } other => todo!("unhandled request {other:?}"), } } @@ -1862,6 +1867,7 @@ impl Dispatch for State { last_enter_serial: None, fractional: None, viewport: None, + moving: false, }, ); state.last_surface_id = Some(SurfaceId(id));