Support client initiated window move (_NET_WM_MOVERESIZE)

Part of #185
This commit is contained in:
Shawn Wallace 2025-09-06 12:56:58 -04:00
parent 41e865c8d3
commit 0b94ae1eb8
8 changed files with 239 additions and 65 deletions

82
Cargo.lock generated
View file

@ -205,6 +205,12 @@ dependencies = [
"termcolor", "termcolor",
] ]
[[package]]
name = "equivalent"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
[[package]] [[package]]
name = "errno" name = "errno"
version = "0.3.12" version = "0.3.12"
@ -236,13 +242,19 @@ dependencies = [
"ahash", "ahash",
] ]
[[package]]
name = "hashbrown"
version = "0.15.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
[[package]] [[package]]
name = "hecs" name = "hecs"
version = "0.10.5" version = "0.10.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e1cbc675ee8d97b4d206a985137f8ad59666538f56f906474f554467a63c776d" checksum = "e1cbc675ee8d97b4d206a985137f8ad59666538f56f906474f554467a63c776d"
dependencies = [ dependencies = [
"hashbrown", "hashbrown 0.14.5",
"hecs-macros", "hecs-macros",
"spin", "spin",
] ]
@ -285,6 +297,16 @@ version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" 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]] [[package]]
name = "is-terminal" name = "is-terminal"
version = "0.4.16" version = "0.4.16"
@ -397,6 +419,28 @@ version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" 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]] [[package]]
name = "num_threads" name = "num_threads"
version = "0.1.7" version = "0.1.7"
@ -444,6 +488,15 @@ dependencies = [
"syn", "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]] [[package]]
name = "proc-macro2" name = "proc-macro2"
version = "1.0.95" version = "1.0.95"
@ -694,6 +747,23 @@ dependencies = [
"time-core", "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]] [[package]]
name = "unicode-ident" name = "unicode-ident"
version = "1.0.18" version = "1.0.18"
@ -1007,6 +1077,15 @@ version = "0.53.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "271414315aff87387382ec3d271b52d7ae78726f5d44ac98b4f4030c91880486" checksum = "271414315aff87387382ec3d271b52d7ae78726f5d44ac98b4f4030c91880486"
[[package]]
name = "winnow"
version = "0.7.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "21a0236b59786fed61e2a80582dd500fe61f18b5dca67a4a067d0bc9039339cf"
dependencies = [
"memchr",
]
[[package]] [[package]]
name = "wl_drm" name = "wl_drm"
version = "0.1.0" version = "0.1.0"
@ -1069,6 +1148,7 @@ dependencies = [
"hecs", "hecs",
"log", "log",
"macros", "macros",
"num_enum",
"pretty_env_logger", "pretty_env_logger",
"rustix", "rustix",
"sd-notify", "sd-notify",

View file

@ -42,6 +42,7 @@ smithay-client-toolkit = { version = "0.19.1", default-features = false }
sd-notify = { version = "0.4.2", optional = true } sd-notify = { version = "0.4.2", optional = true }
macros = { version = "0.1.0", path = "macros" } macros = { version = "0.1.0", path = "macros" }
hecs = { version = "0.10.5", features = ["macros"] } hecs = { version = "0.10.5", features = ["macros"] }
num_enum = "0.7.4"
[features] [features]
default = [] default = []

View file

@ -350,11 +350,6 @@ impl<S: X11Selection> Dispatch<WlPointer, Entity> for InnerServerState<S> {
_: &DisplayHandle, _: &DisplayHandle,
_: &mut wayland_server::DataInit<'_, Self>, _: &mut wayland_server::DataInit<'_, Self>,
) { ) {
let c_pointer = state
.world
.get::<&client::wl_pointer::WlPointer>(*entity)
.unwrap();
match request { match request {
Request::<WlPointer>::SetCursor { Request::<WlPointer>::SetCursor {
serial, serial,
@ -362,6 +357,11 @@ impl<S: X11Selection> Dispatch<WlPointer, Entity> for InnerServerState<S> {
hotspot_y, hotspot_y,
surface, surface,
} => { } => {
let c_pointer = state
.world
.get::<&client::wl_pointer::WlPointer>(*entity)
.unwrap();
let c_surface = surface.and_then(|s| { let c_surface = surface.and_then(|s| {
let e = s.data().copied()?; let e = s.data().copied()?;
Some( Some(
@ -374,9 +374,11 @@ impl<S: X11Selection> Dispatch<WlPointer, Entity> for InnerServerState<S> {
c_pointer.set_cursor(serial, c_surface.as_deref(), hotspot_x, hotspot_y); c_pointer.set_cursor(serial, c_surface.as_deref(), hotspot_x, hotspot_y);
} }
Request::<WlPointer>::Release => { Request::<WlPointer>::Release => {
c_pointer.release(); let (client, _) = state
drop(c_pointer); .world
let _ = state.world.despawn(*entity); .remove::<(client::wl_pointer::WlPointer, WlPointer)>(*entity)
.unwrap();
client.release();
} }
_ => warn!("unhandled cursor request: {request:?}"), _ => warn!("unhandled cursor request: {request:?}"),
} }
@ -395,12 +397,11 @@ impl<S: X11Selection> Dispatch<WlKeyboard, Entity> for InnerServerState<S> {
) { ) {
match request { match request {
Request::<WlKeyboard>::Release => { Request::<WlKeyboard>::Release => {
state let (client, _) = state
.world .world
.get::<&client::wl_keyboard::WlKeyboard>(*entity) .remove::<(client::wl_keyboard::WlKeyboard, WlKeyboard)>(*entity)
.unwrap() .unwrap();
.release(); client.release();
state.world.despawn(*entity).unwrap();
} }
_ => unreachable!(), _ => unreachable!(),
} }
@ -443,16 +444,15 @@ impl<S: X11Selection> Dispatch<WlSeat, Entity> for InnerServerState<S> {
) { ) {
match request { match request {
Request::<WlSeat>::GetPointer { id } => { Request::<WlSeat>::GetPointer { id } => {
let new_entity = state.world.reserve_entity();
let client = { let client = {
state state
.world .world
.get::<&client::wl_seat::WlSeat>(*entity) .get::<&client::wl_seat::WlSeat>(*entity)
.unwrap() .unwrap()
.get_pointer(&state.qh, new_entity) .get_pointer(&state.qh, *entity)
}; };
let server = data_init.init(id, new_entity); let server = data_init.init(id, *entity);
state.world.spawn_at(new_entity, (client, server)); state.world.insert(*entity, (client, server)).unwrap();
} }
Request::<WlSeat>::GetKeyboard { id } => { Request::<WlSeat>::GetKeyboard { id } => {
let client = { let client = {

View file

@ -435,6 +435,8 @@ impl Event for client::wl_seat::Event {
} }
struct PendingEnter(client::wl_pointer::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 { impl Event for client::wl_pointer::Event {
fn handle<C: XConnection>(self, target: Entity, state: &mut ServerState<C>) { fn handle<C: XConnection>(self, target: Entity, state: &mut ServerState<C>) {
@ -459,7 +461,8 @@ impl Event for client::wl_pointer::Event {
let mut cmd = CommandBuffer::new(); let mut cmd = CommandBuffer::new();
let pending_enter = state.world.remove_one::<PendingEnter>(target).ok(); let pending_enter = state.world.remove_one::<PendingEnter>(target).ok();
let server = state.world.get::<&WlPointer>(target).unwrap(); 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 state
.world .world
.query_one::<(&WlSurface, &SurfaceRole, &SurfaceScaleFactor, &x::Window)>(e) .query_one::<(&WlSurface, &SurfaceRole, &SurfaceScaleFactor, &x::Window)>(e)
@ -481,6 +484,7 @@ impl Event for client::wl_pointer::Event {
if !surface_is_popup { if !surface_is_popup {
state.last_hovered = Some(*window); state.last_hovered = Some(*window);
} }
cmd.insert(target, (CurrentSurface(surface_entity.unwrap()),));
}; };
if !surface_is_popup { if !surface_is_popup {
@ -512,11 +516,11 @@ impl Event for client::wl_pointer::Event {
} }
client::wl_pointer::Event::Leave { serial, surface } => { client::wl_pointer::Event::Leave { serial, surface } => {
let _ = state.world.remove_one::<PendingEnter>(target); let _ = state.world.remove_one::<PendingEnter>(target);
let _ = state.world.remove_one::<CurrentSurface>(target);
if !surface.is_alive() { if !surface.is_alive() {
return; return;
} }
debug!("leaving surface ({serial})"); debug!("leaving surface ({serial})");
let _ = state.world.remove_one::<PendingEnter>(target);
if let Some(surface) = surface if let Some(surface) = surface
.data() .data()
.copied() .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(); let server = state.world.get::<&WlPointer>(target).unwrap();
simple_event_shunt! { simple_event_shunt! {
server, self => [ server, self => [
Frame, Frame,
Button {
serial,
time,
button,
|state| convert_wenum(state)
},
Axis { Axis {
time, time,
|axis| convert_wenum(axis), |axis| convert_wenum(axis),

View file

@ -1091,6 +1091,31 @@ impl<S: X11Selection + 'static> InnerServerState<S> {
); );
} }
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) { pub fn destroy_window(&mut self, window: x::Window) {
if let Some(id) = self.windows.remove(&window) { if let Some(id) = self.windows.remove(&window) {
self.world.remove::<(x::Window, WindowData)>(id).unwrap(); self.world.remove::<(x::Window, WindowData)>(id).unwrap();

View file

@ -509,6 +509,28 @@ impl XState {
x if x == self.atoms.active_win => { x if x == self.atoms.active_win => {
server_state.activate_window(e.window()); 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:?}"), t => warn!("unrecognized message: {t:?}"),
}, },
xcb::Event::X(x::Event::MappingNotify(_)) => {} xcb::Event::X(x::Event::MappingNotify(_)) => {}
@ -933,6 +955,7 @@ xcb::atoms_struct! {
xsettings => b"_XSETTINGS_S0" only_if_exists = false, xsettings => b"_XSETTINGS_S0" only_if_exists = false,
xsettings_settings => b"_XSETTINGS_SETTINGS" only_if_exists = false, xsettings_settings => b"_XSETTINGS_SETTINGS" only_if_exists = false,
primary => b"PRIMARY" 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 { pub enum Decorations {
Client = 0, Client = 0,
Server = 1, Server = 1,
} }
impl TryFrom<u32> for Decorations {
type Error = ();
fn try_from(value: u32) -> Result<Self, ()> {
match value {
0 => Ok(Self::Client),
1 => Ok(Self::Server),
_ => Err(()),
}
}
}
impl From<Decorations> for zxdg_toplevel_decoration_v1::Mode { impl From<Decorations> for zxdg_toplevel_decoration_v1::Mode {
fn from(value: Decorations) -> Self { fn from(value: Decorations) -> Self {
match value { match value {
@ -1104,42 +1116,37 @@ mod motif {
} }
} }
#[derive(Debug, Clone, Copy)] #[derive(Debug, Clone, Copy, num_enum::TryFromPrimitive)]
#[repr(u32)]
pub enum SetState { pub enum SetState {
Remove, Remove,
Add, Add,
Toggle, Toggle,
} }
impl TryFrom<u32> for SetState { #[derive(Debug, Clone, Copy, PartialEq, Eq, num_enum::TryFromPrimitive)]
type Error = (); #[repr(u32)]
fn try_from(value: u32) -> Result<Self, Self::Error> {
match value {
0 => Ok(Self::Remove),
1 => Ok(Self::Add),
2 => Ok(Self::Toggle),
_ => Err(()),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum WmState { pub enum WmState {
Withdrawn = 0, Withdrawn = 0,
Normal = 1, Normal = 1,
Iconic = 3, Iconic = 3,
} }
impl TryFrom<u32> for WmState { #[derive(Debug, Copy, Clone, num_enum::TryFromPrimitive, num_enum::IntoPrimitive)]
type Error = (); #[repr(u32)]
fn try_from(value: u32) -> Result<Self, Self::Error> { pub enum MoveResizeDirection {
match value { SizeTopLeft,
0 => Ok(Self::Withdrawn), SizeTop,
1 => Ok(Self::Normal), SizeTopRight,
3 => Ok(Self::Iconic), SizeRight,
_ => Err(()), SizeBottomRight,
} SizeBottom,
} SizeBottomLeft,
SizeLeft,
Move,
SizeKeyboard,
MoveKeyboard,
Cancel,
} }
pub struct RealConnection { pub struct RealConnection {

View file

@ -14,11 +14,11 @@ use std::time::{Duration, Instant};
use wayland_protocols::xdg::{ use wayland_protocols::xdg::{
decoration::zv1::server::zxdg_toplevel_decoration_v1, shell::server::xdg_toplevel, 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 wayland_server::Resource;
use xcb::{x, Xid}; use xcb::{x, Xid};
use xwayland_satellite as xwls; use xwayland_satellite as xwls;
use xwayland_satellite::xstate::{WmSizeHintsFlags, WmState}; use xwayland_satellite::xstate::{MoveResizeDirection, WmSizeHintsFlags, WmState};
#[derive(Default)] #[derive(Default)]
struct TestDataInner { struct TestDataInner {
@ -329,6 +329,7 @@ xcb::atoms_struct! {
incr => b"INCR", incr => b"INCR",
xsettings => b"_XSETTINGS_S0", xsettings => b"_XSETTINGS_S0",
xsettings_setting => b"_XSETTINGS_SETTINGS", xsettings_setting => b"_XSETTINGS_SETTINGS",
moveresize => b"_NET_WM_MOVERESIZE",
} }
} }
@ -2024,3 +2025,31 @@ fn rotated_output() {
other => panic!("unexpected event {other:?}"), 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);
}

View file

@ -110,6 +110,7 @@ pub struct SurfaceData {
pub last_enter_serial: Option<u32>, pub last_enter_serial: Option<u32>,
pub fractional: Option<WpFractionalScaleV1>, pub fractional: Option<WpFractionalScaleV1>,
pub viewport: Option<Viewport>, pub viewport: Option<Viewport>,
pub moving: bool,
} }
impl SurfaceData { impl SurfaceData {
@ -1541,6 +1542,10 @@ impl Dispatch<XdgToplevel, SurfaceId> for State {
}; };
toplevel.parent = parent; 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:?}"), other => todo!("unhandled request {other:?}"),
} }
} }
@ -1862,6 +1867,7 @@ impl Dispatch<WlCompositor, ()> for State {
last_enter_serial: None, last_enter_serial: None,
fractional: None, fractional: None,
viewport: None, viewport: None,
moving: false,
}, },
); );
state.last_surface_id = Some(SurfaceId(id)); state.last_surface_id = Some(SurfaceId(id));