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",
]
[[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",

View file

@ -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 = []

View file

@ -350,11 +350,6 @@ impl<S: X11Selection> Dispatch<WlPointer, Entity> for InnerServerState<S> {
_: &DisplayHandle,
_: &mut wayland_server::DataInit<'_, Self>,
) {
let c_pointer = state
.world
.get::<&client::wl_pointer::WlPointer>(*entity)
.unwrap();
match request {
Request::<WlPointer>::SetCursor {
serial,
@ -362,6 +357,11 @@ impl<S: X11Selection> Dispatch<WlPointer, Entity> for InnerServerState<S> {
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<S: X11Selection> Dispatch<WlPointer, Entity> for InnerServerState<S> {
c_pointer.set_cursor(serial, c_surface.as_deref(), hotspot_x, hotspot_y);
}
Request::<WlPointer>::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<S: X11Selection> Dispatch<WlKeyboard, Entity> for InnerServerState<S> {
) {
match request {
Request::<WlKeyboard>::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<S: X11Selection> Dispatch<WlSeat, Entity> for InnerServerState<S> {
) {
match request {
Request::<WlSeat>::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::<WlSeat>::GetKeyboard { id } => {
let client = {

View file

@ -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<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 pending_enter = state.world.remove_one::<PendingEnter>(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::<PendingEnter>(target);
let _ = state.world.remove_one::<CurrentSurface>(target);
if !surface.is_alive() {
return;
}
debug!("leaving surface ({serial})");
let _ = state.world.remove_one::<PendingEnter>(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),

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) {
if let Some(id) = self.windows.remove(&window) {
self.world.remove::<(x::Window, WindowData)>(id).unwrap();

View file

@ -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<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 {
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<u32> for SetState {
type Error = ();
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)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, num_enum::TryFromPrimitive)]
#[repr(u32)]
pub enum WmState {
Withdrawn = 0,
Normal = 1,
Iconic = 3,
}
impl TryFrom<u32> for WmState {
type Error = ();
fn try_from(value: u32) -> Result<Self, Self::Error> {
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 {

View file

@ -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);
}

View file

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