diff --git a/Cargo.lock b/Cargo.lock
index c0901b2..5487f8c 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -3282,6 +3282,16 @@ dependencies = [
"zstd",
]
+[[package]]
+name = "wayray-wm-protocol"
+version = "0.1.0"
+dependencies = [
+ "wayland-backend",
+ "wayland-client",
+ "wayland-scanner",
+ "wayland-server",
+]
+
[[package]]
name = "web-sys"
version = "0.3.94"
@@ -3805,6 +3815,16 @@ dependencies = [
"wasmparser",
]
+[[package]]
+name = "wr-wm-tiling"
+version = "0.1.0"
+dependencies = [
+ "tracing",
+ "tracing-subscriber",
+ "wayland-client",
+ "wayray-wm-protocol",
+]
+
[[package]]
name = "wradm"
version = "0.1.0"
@@ -3849,6 +3869,7 @@ dependencies = [
"tracing",
"tracing-subscriber",
"wayray-protocol",
+ "wayray-wm-protocol",
]
[[package]]
diff --git a/Cargo.toml b/Cargo.toml
index aead0b5..cec2950 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -2,9 +2,11 @@
resolver = "3"
members = [
"crates/wayray-protocol",
+ "crates/wayray-wm-protocol",
"crates/wrsrvd",
"crates/wrclient",
"crates/wradm",
+ "crates/wr-wm-tiling",
]
[workspace.package]
@@ -14,6 +16,7 @@ license = "MPL-2.0"
[workspace.dependencies]
wayray-protocol = { path = "crates/wayray-protocol" }
+wayray-wm-protocol = { path = "crates/wayray-wm-protocol" }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
miette = { version = "7", features = ["fancy"] }
diff --git a/crates/wayray-wm-protocol/Cargo.toml b/crates/wayray-wm-protocol/Cargo.toml
new file mode 100644
index 0000000..ad08473
--- /dev/null
+++ b/crates/wayray-wm-protocol/Cargo.toml
@@ -0,0 +1,17 @@
+[package]
+name = "wayray-wm-protocol"
+edition.workspace = true
+version.workspace = true
+license.workspace = true
+description = "WayRay pluggable window management Wayland protocol"
+
+[dependencies]
+wayland-backend = "0.3"
+wayland-server = { version = "0.31", optional = true }
+wayland-client = { version = "0.31", optional = true }
+wayland-scanner = "0.31"
+
+[features]
+default = []
+server = ["dep:wayland-server"]
+client = ["dep:wayland-client"]
diff --git a/crates/wayray-wm-protocol/protocols/wayray-wm-v1.xml b/crates/wayray-wm-protocol/protocols/wayray-wm-v1.xml
new file mode 100644
index 0000000..8c47345
--- /dev/null
+++ b/crates/wayray-wm-protocol/protocols/wayray-wm-v1.xml
@@ -0,0 +1,414 @@
+
+
+
+ Copyright 2026 WayRay Contributors
+ SPDX-License-Identifier: MPL-2.0
+
+
+
+ This protocol allows an external window manager process to control
+ window layout, focus, and keybindings in the WayRay compositor.
+
+ Only one WM client may be bound at a time. If a second WM binds,
+ the first receives a "replaced" event and should disconnect.
+
+ Window management follows a two-phase transaction model:
+ 1. Manage phase: WM makes policy decisions (dimensions, focus, decorations)
+ 2. Render phase: WM specifies visual placement (position, z-order, visibility)
+
+
+
+
+
+
+
+
+ The main entry point for external window managers. A WM client binds
+ this global to receive window lifecycle events and participate in the
+ two-phase manage/render transaction model.
+
+
+
+
+
+
+ Sent when a new toplevel is mapped. The WM receives a
+ wayray_wm_window_v1 object to interact with this window.
+ Property events (title, app_id, size_hints) follow immediately,
+ terminated by a done event.
+
+
+
+
+
+
+ Sent when a toplevel is unmapped and destroyed. The wayray_wm_window_v1
+ object becomes inert after this event.
+
+
+
+
+
+
+ Sent when state changes require WM policy decisions. The WM should
+ evaluate pending changes and respond with policy requests on the
+ affected window objects, then call manage_done.
+
+
+
+
+
+ Sent after clients have acknowledged configures and committed. The WM
+ should set visual placement (position, z-order, visibility) on window
+ objects, then call render_done. All changes are applied atomically.
+
+
+
+
+
+ Sent when a new output is available for window placement.
+
+
+
+
+
+
+
+
+ Sent when an output is no longer available.
+
+
+
+
+
+
+ Sent when another WM client has connected and taken over. The old WM
+ should disconnect gracefully after receiving this event.
+
+
+
+
+
+
+
+ Signals that the WM has finished making policy decisions for this
+ manage phase. The compositor will send configures to affected windows.
+
+
+
+
+
+ Signals that the WM has finished specifying visual placement. The
+ compositor will apply all changes atomically in one frame.
+
+
+
+
+
+ Creates a wayray_wm_seat_v1 object for registering keybindings
+ and initiating interactive move/resize operations.
+
+
+
+
+
+
+ Creates a wayray_wm_workspace_v1 object for managing virtual
+ desktops and window-to-workspace assignments.
+
+
+
+
+
+
+ Destroy this manager object. The compositor returns to its built-in
+ window management.
+
+
+
+
+
+
+
+
+
+
+ Represents a single toplevel window. The compositor sends property
+ events and the WM sends policy/placement requests.
+
+ Policy requests (propose_dimensions, set_focus, etc.) should be sent
+ during the manage phase. Visual placement requests (set_position,
+ set_z_*, etc.) should be sent during the render phase.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Null if this window has no parent.
+
+
+
+
+
+
+ A value of 0 means unconstrained.
+
+
+
+
+
+
+
+
+
+ Respond with grant_fullscreen or deny_fullscreen during manage phase.
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Actual committed size after client draws.
+
+
+
+
+
+
+
+ Process all preceding property events atomically.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Register keybindings, create binding modes, and initiate
+ interactive move/resize operations.
+
+
+
+
+
+
+ Empty mode string means default mode.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Empty string returns to default mode only.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Create and manage virtual desktops. Supports both exclusive workspaces
+ and tag-based systems (windows on multiple tags simultaneously).
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Window is visible when any tag matches the output's active tags.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/crates/wayray-wm-protocol/src/lib.rs b/crates/wayray-wm-protocol/src/lib.rs
new file mode 100644
index 0000000..40e47d1
--- /dev/null
+++ b/crates/wayray-wm-protocol/src/lib.rs
@@ -0,0 +1,48 @@
+//! WayRay pluggable window management Wayland protocol.
+//!
+//! Provides generated Rust bindings for the `wayray_wm_v1` protocol family:
+//! - `wayray_wm_manager_v1` — global WM entry point
+//! - `wayray_wm_window_v1` — per-window management
+//! - `wayray_wm_seat_v1` — keybindings and interactive operations
+//! - `wayray_wm_workspace_v1` — virtual desktop / tag management
+//!
+//! Enable the `server` feature for compositor-side bindings or
+//! `client` feature for WM client-side bindings.
+
+#![allow(
+ dead_code,
+ non_camel_case_types,
+ unused_unsafe,
+ unused_variables,
+ non_upper_case_globals,
+ non_snake_case,
+ unused_imports,
+ missing_docs,
+ clippy::all
+)]
+
+#[cfg(feature = "client")]
+pub mod client {
+ use wayland_client;
+ use wayland_client::protocol::*;
+
+ pub mod __interfaces {
+ use wayland_client::protocol::__interfaces::*;
+ wayland_scanner::generate_interfaces!("./protocols/wayray-wm-v1.xml");
+ }
+ use self::__interfaces::*;
+ wayland_scanner::generate_client_code!("./protocols/wayray-wm-v1.xml");
+}
+
+#[cfg(feature = "server")]
+pub mod server {
+ use wayland_server;
+ use wayland_server::protocol::*;
+
+ pub mod __interfaces {
+ use wayland_server::protocol::__interfaces::*;
+ wayland_scanner::generate_interfaces!("./protocols/wayray-wm-v1.xml");
+ }
+ use self::__interfaces::*;
+ wayland_scanner::generate_server_code!("./protocols/wayray-wm-v1.xml");
+}
diff --git a/crates/wr-wm-tiling/Cargo.toml b/crates/wr-wm-tiling/Cargo.toml
new file mode 100644
index 0000000..0dad0f8
--- /dev/null
+++ b/crates/wr-wm-tiling/Cargo.toml
@@ -0,0 +1,12 @@
+[package]
+name = "wr-wm-tiling"
+edition.workspace = true
+version.workspace = true
+license.workspace = true
+description = "Reference tiling window manager for WayRay"
+
+[dependencies]
+wayray-wm-protocol = { workspace = true, features = ["client"] }
+wayland-client = "0.31"
+tracing.workspace = true
+tracing-subscriber.workspace = true
diff --git a/crates/wr-wm-tiling/src/main.rs b/crates/wr-wm-tiling/src/main.rs
new file mode 100644
index 0000000..8cb9003
--- /dev/null
+++ b/crates/wr-wm-tiling/src/main.rs
@@ -0,0 +1,328 @@
+//! Reference tiling window manager for WayRay.
+//!
+//! Demonstrates the WayRay WM protocol with a simple BSP (binary space
+//! partitioning) tiling layout. Windows are tiled into a grid that splits
+//! alternately horizontally and vertically.
+
+use tracing::{info, warn};
+use wayland_client::{Connection, Dispatch, QueueHandle, protocol::wl_registry};
+use wayray_wm_protocol::client::{
+ wayray_wm_manager_v1::{self, WayrayWmManagerV1},
+ wayray_wm_seat_v1::WayrayWmSeatV1,
+ wayray_wm_window_v1::{self, WayrayWmWindowV1},
+ wayray_wm_workspace_v1::WayrayWmWorkspaceV1,
+};
+
+/// Per-window state tracked by the tiling WM.
+#[derive(Debug)]
+struct WindowState {
+ obj: WayrayWmWindowV1,
+ width: i32,
+ height: i32,
+}
+
+/// Central state for the tiling WM client.
+struct TilingWm {
+ manager: Option,
+ windows: Vec,
+ /// Output dimensions (set from output_new event).
+ output_width: i32,
+ output_height: i32,
+ /// Whether we're in the manage phase.
+ in_manage_phase: bool,
+ /// Whether we're in the render phase.
+ in_render_phase: bool,
+}
+
+impl TilingWm {
+ fn new() -> Self {
+ Self {
+ manager: None,
+ windows: Vec::new(),
+ output_width: 1280,
+ output_height: 720,
+ in_manage_phase: false,
+ in_render_phase: false,
+ }
+ }
+
+ /// Calculate BSP tiling positions for all windows.
+ /// Returns (x, y, width, height) for each window.
+ fn calculate_layout(&self) -> Vec<(i32, i32, i32, i32)> {
+ let n = self.windows.len();
+ if n == 0 {
+ return Vec::new();
+ }
+
+ let gap = 4;
+ let mut layouts = Vec::with_capacity(n);
+
+ if n == 1 {
+ layouts.push((
+ gap,
+ gap,
+ self.output_width - 2 * gap,
+ self.output_height - 2 * gap,
+ ));
+ return layouts;
+ }
+
+ bsp_layout(
+ gap,
+ gap,
+ self.output_width - 2 * gap,
+ self.output_height - 2 * gap,
+ n,
+ true,
+ &mut layouts,
+ );
+
+ layouts
+ }
+}
+
+/// Recursive BSP layout: split area alternately horizontal/vertical.
+fn bsp_layout(
+ x: i32,
+ y: i32,
+ w: i32,
+ h: i32,
+ count: usize,
+ horizontal: bool,
+ layouts: &mut Vec<(i32, i32, i32, i32)>,
+) {
+ let gap = 4;
+ if count == 1 {
+ layouts.push((x, y, w, h));
+ return;
+ }
+
+ let first_half = count / 2;
+ let second_half = count - first_half;
+
+ if horizontal {
+ let first_w = w * first_half as i32 / count as i32 - gap / 2;
+ let second_w = w - first_w - gap;
+ bsp_layout(x, y, first_w, h, first_half, !horizontal, layouts);
+ bsp_layout(
+ x + first_w + gap,
+ y,
+ second_w,
+ h,
+ second_half,
+ !horizontal,
+ layouts,
+ );
+ } else {
+ let first_h = h * first_half as i32 / count as i32 - gap / 2;
+ let second_h = h - first_h - gap;
+ bsp_layout(x, y, w, first_h, first_half, !horizontal, layouts);
+ bsp_layout(
+ x,
+ y + first_h + gap,
+ w,
+ second_h,
+ second_half,
+ !horizontal,
+ layouts,
+ );
+ }
+}
+
+// =============================================================================
+// Wayland client dispatch implementations
+// =============================================================================
+
+impl Dispatch for TilingWm {
+ fn event(
+ state: &mut Self,
+ registry: &wl_registry::WlRegistry,
+ event: wl_registry::Event,
+ _data: &(),
+ _conn: &Connection,
+ qh: &QueueHandle,
+ ) {
+ if let wl_registry::Event::Global {
+ name,
+ interface,
+ version,
+ } = event
+ && interface == "wayray_wm_manager_v1"
+ {
+ info!("found wayray_wm_manager_v1 global");
+ let manager = registry.bind::(name, version, qh, ());
+ state.manager = Some(manager);
+ }
+ }
+}
+
+impl Dispatch for TilingWm {
+ fn event(
+ state: &mut Self,
+ manager: &WayrayWmManagerV1,
+ event: wayray_wm_manager_v1::Event,
+ _data: &(),
+ _conn: &Connection,
+ _qh: &QueueHandle,
+ ) {
+ match event {
+ wayray_wm_manager_v1::Event::WindowNew { window } => {
+ info!("new window received from compositor");
+ state.windows.push(WindowState {
+ obj: window,
+ width: 0,
+ height: 0,
+ });
+ }
+ wayray_wm_manager_v1::Event::WindowClosed { window } => {
+ info!("window closed");
+ state.windows.retain(|w| w.obj != window);
+ }
+ wayray_wm_manager_v1::Event::ManageStart => {
+ state.in_manage_phase = true;
+
+ // Calculate dimensions for all windows.
+ let layouts = state.calculate_layout();
+ for (i, (_, _, w, h)) in layouts.iter().enumerate() {
+ if i < state.windows.len() {
+ state.windows[i].obj.propose_dimensions(*w, *h);
+ }
+ }
+
+ // Focus the last (most recent) window.
+ if let Some(last) = state.windows.last() {
+ last.obj.set_focus();
+ }
+
+ manager.manage_done();
+ state.in_manage_phase = false;
+ }
+ wayray_wm_manager_v1::Event::RenderStart => {
+ state.in_render_phase = true;
+
+ // Apply BSP tiling positions.
+ let layouts = state.calculate_layout();
+ for (i, (x, y, _w, _h)) in layouts.iter().enumerate() {
+ if i < state.windows.len() {
+ state.windows[i].obj.set_position(*x, *y);
+ state.windows[i].obj.set_z_top();
+ state.windows[i].obj.show();
+ }
+ }
+
+ manager.render_done();
+ state.in_render_phase = false;
+ }
+ wayray_wm_manager_v1::Event::OutputNew {
+ output_name,
+ width,
+ height,
+ } => {
+ info!(output_name, width, height, "output available");
+ state.output_width = width;
+ state.output_height = height;
+ }
+ wayray_wm_manager_v1::Event::OutputRemoved { output_name } => {
+ info!(output_name, "output removed");
+ }
+ wayray_wm_manager_v1::Event::Replaced => {
+ warn!("replaced by another WM, shutting down");
+ std::process::exit(0);
+ }
+ _ => {}
+ }
+ }
+}
+
+impl Dispatch for TilingWm {
+ fn event(
+ state: &mut Self,
+ window: &WayrayWmWindowV1,
+ event: wayray_wm_window_v1::Event,
+ _data: &(),
+ _conn: &Connection,
+ _qh: &QueueHandle,
+ ) {
+ match event {
+ wayray_wm_window_v1::Event::Title { title } => {
+ info!(title, "window title");
+ }
+ wayray_wm_window_v1::Event::AppId { app_id } => {
+ info!(app_id, "window app_id");
+ }
+ wayray_wm_window_v1::Event::Dimensions { width, height } => {
+ if let Some(ws) = state.windows.iter_mut().find(|w| w.obj == *window) {
+ ws.width = width;
+ ws.height = height;
+ }
+ }
+ wayray_wm_window_v1::Event::Done => {
+ // End of property batch — nothing to do for tiling.
+ }
+ _ => {}
+ }
+ }
+}
+
+impl Dispatch for TilingWm {
+ fn event(
+ _state: &mut Self,
+ _proxy: &WayrayWmSeatV1,
+ _event: ::Event,
+ _data: &(),
+ _conn: &Connection,
+ _qh: &QueueHandle,
+ ) {
+ }
+}
+
+impl Dispatch for TilingWm {
+ fn event(
+ _state: &mut Self,
+ _proxy: &WayrayWmWorkspaceV1,
+ _event: ::Event,
+ _data: &(),
+ _conn: &Connection,
+ _qh: &QueueHandle,
+ ) {
+ }
+}
+
+fn main() {
+ tracing_subscriber::fmt()
+ .with_env_filter(
+ tracing_subscriber::EnvFilter::try_from_default_env()
+ .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")),
+ )
+ .init();
+
+ info!("wr-wm-tiling starting");
+
+ let conn = Connection::connect_to_env().expect("failed to connect to Wayland display");
+ let display = conn.display();
+
+ let mut event_queue = conn.new_event_queue();
+ let qh = event_queue.handle();
+
+ let _registry = display.get_registry(&qh, ());
+
+ let mut state = TilingWm::new();
+
+ // Initial roundtrip to discover globals.
+ event_queue
+ .roundtrip(&mut state)
+ .expect("initial roundtrip failed");
+
+ if state.manager.is_none() {
+ eprintln!("wayray_wm_manager_v1 global not found — is this a WayRay compositor?");
+ std::process::exit(1);
+ }
+
+ info!("connected to WayRay compositor, entering event loop");
+
+ loop {
+ event_queue
+ .blocking_dispatch(&mut state)
+ .expect("event dispatch failed");
+ }
+}
diff --git a/crates/wrsrvd/Cargo.toml b/crates/wrsrvd/Cargo.toml
index fa7594a..e863e2b 100644
--- a/crates/wrsrvd/Cargo.toml
+++ b/crates/wrsrvd/Cargo.toml
@@ -11,6 +11,7 @@ winit = ["smithay/renderer_gl", "smithay/backend_winit"]
[dependencies]
wayray-protocol.workspace = true
+wayray-wm-protocol = { workspace = true, features = ["server"] }
tracing.workspace = true
tracing-subscriber.workspace = true
miette.workspace = true
diff --git a/crates/wrsrvd/src/backend/headless.rs b/crates/wrsrvd/src/backend/headless.rs
index ba2cee5..1873f6e 100644
--- a/crates/wrsrvd/src/backend/headless.rs
+++ b/crates/wrsrvd/src/backend/headless.rs
@@ -237,6 +237,9 @@ fn render_headless_frame(data: &mut CalloopData) {
// Must be called each frame before rendering.
data.state.space.refresh();
+ // Apply WM render phase — positions/z-order before frame capture.
+ data.state.apply_wm_render_commands();
+
let custom_elements: &[TextureRenderElement] = &[];
let render_result = render_output::<_, _, Window, _>(
diff --git a/crates/wrsrvd/src/handlers/mod.rs b/crates/wrsrvd/src/handlers/mod.rs
index 9455711..29f13fa 100644
--- a/crates/wrsrvd/src/handlers/mod.rs
+++ b/crates/wrsrvd/src/handlers/mod.rs
@@ -1,6 +1,7 @@
mod compositor;
mod input;
mod output;
+mod wm_protocol;
mod xdg_shell;
pub use compositor::ClientState;
diff --git a/crates/wrsrvd/src/handlers/wm_protocol.rs b/crates/wrsrvd/src/handlers/wm_protocol.rs
new file mode 100644
index 0000000..9e20a95
--- /dev/null
+++ b/crates/wrsrvd/src/handlers/wm_protocol.rs
@@ -0,0 +1,140 @@
+//! Dispatch delegation for the WayRay WM protocol.
+//!
+//! Connects the generated protocol types to our WmProtocolState implementation.
+
+use smithay::reexports::wayland_server::{
+ Client, DataInit, Dispatch, DisplayHandle, GlobalDispatch, New, Resource, backend::ClientId,
+};
+use wayray_wm_protocol::server::{
+ wayray_wm_manager_v1::WayrayWmManagerV1, wayray_wm_seat_v1::WayrayWmSeatV1,
+ wayray_wm_window_v1::WayrayWmWindowV1, wayray_wm_workspace_v1::WayrayWmWorkspaceV1,
+};
+
+use crate::state::WayRay;
+use crate::wm::protocol::{WmGlobalData, WmProtocolHandler, WmProtocolState, WmWindowData};
+
+impl WmProtocolHandler for WayRay {
+ fn wm_protocol_state(&mut self) -> &mut WmProtocolState {
+ self.wm_state
+ .protocol
+ .as_mut()
+ .expect("WM protocol state not initialized")
+ }
+
+ fn existing_windows(&self) -> Vec {
+ self.window_ids
+ .iter()
+ .filter_map(|(id, window)| {
+ let toplevel = window.toplevel()?;
+ let size = toplevel.current_state().size?;
+ Some((*id, None, None, size.w, size.h))
+ })
+ .collect()
+ }
+}
+
+// Delegate GlobalDispatch for the manager global.
+impl GlobalDispatch for WayRay {
+ fn bind(
+ state: &mut Self,
+ dh: &DisplayHandle,
+ client: &Client,
+ resource: New,
+ global_data: &WmGlobalData,
+ data_init: &mut DataInit<'_, Self>,
+ ) {
+ >::bind(
+ state,
+ dh,
+ client,
+ resource,
+ global_data,
+ data_init,
+ );
+ }
+}
+
+// Delegate Dispatch for manager requests.
+impl Dispatch for WayRay {
+ fn request(
+ state: &mut Self,
+ client: &Client,
+ resource: &WayrayWmManagerV1,
+ request: ::Request,
+ data: &(),
+ dh: &DisplayHandle,
+ data_init: &mut DataInit<'_, Self>,
+ ) {
+ >::request(
+ state, client, resource, request, data, dh, data_init,
+ );
+ }
+
+ fn destroyed(state: &mut Self, client: ClientId, resource: &WayrayWmManagerV1, data: &()) {
+ >::destroyed(
+ state, client, resource, data,
+ );
+ }
+}
+
+// Delegate Dispatch for window requests.
+impl Dispatch for WayRay {
+ fn request(
+ state: &mut Self,
+ client: &Client,
+ resource: &WayrayWmWindowV1,
+ request: ::Request,
+ data: &WmWindowData,
+ dh: &DisplayHandle,
+ data_init: &mut DataInit<'_, Self>,
+ ) {
+ >::request(
+ state, client, resource, request, data, dh, data_init,
+ );
+ }
+
+ fn destroyed(
+ state: &mut Self,
+ client: ClientId,
+ resource: &WayrayWmWindowV1,
+ data: &WmWindowData,
+ ) {
+ >::destroyed(
+ state, client, resource, data,
+ );
+ }
+}
+
+// Delegate Dispatch for seat requests.
+impl Dispatch for WayRay {
+ fn request(
+ state: &mut Self,
+ client: &Client,
+ resource: &WayrayWmSeatV1,
+ request: ::Request,
+ data: &(),
+ dh: &DisplayHandle,
+ data_init: &mut DataInit<'_, Self>,
+ ) {
+ >::request(
+ state, client, resource, request, data, dh, data_init,
+ );
+ }
+}
+
+// Delegate Dispatch for workspace requests.
+impl Dispatch for WayRay {
+ fn request(
+ state: &mut Self,
+ client: &Client,
+ resource: &WayrayWmWorkspaceV1,
+ request: ::Request,
+ data: &(),
+ dh: &DisplayHandle,
+ data_init: &mut DataInit<'_, Self>,
+ ) {
+ >::request(
+ state, client, resource, request, data, dh, data_init,
+ );
+ }
+}
diff --git a/crates/wrsrvd/src/handlers/xdg_shell.rs b/crates/wrsrvd/src/handlers/xdg_shell.rs
index 2abe226..3bebf12 100644
--- a/crates/wrsrvd/src/handlers/xdg_shell.rs
+++ b/crates/wrsrvd/src/handlers/xdg_shell.rs
@@ -14,6 +14,7 @@ use smithay::{
use tracing::info;
use crate::state::WayRay;
+use crate::wm::types::WindowInfo;
impl XdgShellHandler for WayRay {
fn xdg_shell_state(&mut self) -> &mut XdgShellState {
@@ -21,16 +22,46 @@ impl XdgShellHandler for WayRay {
}
fn new_toplevel(&mut self, surface: ToplevelSurface) {
- // Set a suggested size and send the initial configure so the
- // client can start drawing.
+ let output_size = self.output.current_mode().unwrap().size;
+
+ // Gather window info for the WM.
+ let info = WindowInfo {
+ title: None,
+ app_id: None,
+ output_width: output_size.w,
+ output_height: output_size.h,
+ min_size: None,
+ max_size: None,
+ };
+
+ let window = Window::new_wayland_window(surface.clone());
+ let id = self.register_window(window.clone());
+
+ // Ask the WM where to place and how to size this window.
+ let response = self.wm_state.active_wm().on_new_toplevel(id, info);
+
surface.with_pending_state(|state| {
- state.size = Some((800, 600).into());
+ state.size = Some(response.size.into());
});
surface.send_configure();
- let window = Window::new_wayland_window(surface);
- self.space.map_element(window, (0, 0), true);
- info!("new toplevel mapped with suggested size 800x600");
+ self.space
+ .map_element(window.clone(), response.position, true);
+
+ // Set keyboard focus if the WM requested it.
+ if response.focus {
+ let serial = smithay::utils::SERIAL_COUNTER.next_serial();
+ let keyboard = self.seat.get_keyboard().unwrap();
+ let wl_surface = window.toplevel().map(|t| t.wl_surface().clone());
+ keyboard.set_focus(self, wl_surface, serial);
+ }
+
+ info!(
+ ?id,
+ size = ?response.size,
+ pos = ?response.position,
+ "new toplevel mapped via WM"
+ );
}
fn new_popup(&mut self, surface: PopupSurface, _positioner: PositionerState) {
diff --git a/crates/wrsrvd/src/main.rs b/crates/wrsrvd/src/main.rs
index 660c61f..7750e48 100644
--- a/crates/wrsrvd/src/main.rs
+++ b/crates/wrsrvd/src/main.rs
@@ -3,6 +3,7 @@ mod errors;
mod handlers;
pub mod network;
mod state;
+mod wm;
use crate::network::{ServerConfig, start_server};
use crate::state::WayRay;
diff --git a/crates/wrsrvd/src/state.rs b/crates/wrsrvd/src/state.rs
index b88c3d8..744906b 100644
--- a/crates/wrsrvd/src/state.rs
+++ b/crates/wrsrvd/src/state.rs
@@ -25,6 +25,8 @@ use smithay::{
use tracing::info;
use wayray_protocol::messages::InputMessage;
+use crate::wm::{self, WmState};
+
/// Central compositor state holding all Smithay subsystem states.
///
/// This is the "god struct" pattern required by Smithay — a single type that
@@ -40,6 +42,12 @@ pub struct WayRay {
pub seat: Seat,
pub clock: Clock,
pub output: Output,
+ /// Window management state — delegates to built-in or external WM.
+ pub wm_state: WmState,
+ /// Maps Smithay Window to WindowId for WM communication.
+ pub window_ids: Vec<(wm::types::WindowId, smithay::desktop::Window)>,
+ /// Counter for allocating WindowIds.
+ next_window_id: u64,
// Kept alive to maintain their Wayland globals — not accessed directly.
_output_manager_state: OutputManagerState,
_xdg_decoration_state: XdgDecorationState,
@@ -65,6 +73,13 @@ impl WayRay {
info!("all Smithay subsystem states initialized");
+ let output_mode = output.current_mode().unwrap();
+ let mut wm_state = WmState::new(output_mode.size.w, output_mode.size.h);
+
+ // Register the WM protocol global for external window managers.
+ let wm_protocol = wm::protocol::WmProtocolState::new::(&dh);
+ wm_state.init_protocol(wm_protocol);
+
Self {
compositor_state,
xdg_shell_state,
@@ -76,11 +91,69 @@ impl WayRay {
seat,
clock: Clock::new(),
output,
+ wm_state,
+ window_ids: Vec::new(),
+ next_window_id: 1,
_output_manager_state: OutputManagerState::new_with_xdg_output::(&dh),
_xdg_decoration_state: XdgDecorationState::new::(&dh),
}
}
+ /// Allocate a new WindowId and associate it with a Smithay Window.
+ pub fn register_window(&mut self, window: smithay::desktop::Window) -> wm::types::WindowId {
+ let id = wm::types::WindowId::from_raw(self.next_window_id);
+ self.next_window_id += 1;
+ self.window_ids.push((id, window));
+ id
+ }
+
+ /// Find the WindowId for a Smithay Window.
+ pub fn window_id_for(&self, window: &smithay::desktop::Window) -> Option {
+ self.window_ids
+ .iter()
+ .find(|(_, w)| w == window)
+ .map(|(id, _)| *id)
+ }
+
+ /// Find the Smithay Window for a WindowId.
+ #[allow(dead_code)]
+ pub fn window_for_id(&self, id: wm::types::WindowId) -> Option<&smithay::desktop::Window> {
+ self.window_ids
+ .iter()
+ .find(|(wid, _)| *wid == id)
+ .map(|(_, w)| w)
+ }
+
+ /// Remove a window from the id mapping.
+ #[allow(dead_code)]
+ pub fn unregister_window(&mut self, window: &smithay::desktop::Window) {
+ if let Some(id) = self.window_id_for(window) {
+ self.wm_state.active_wm().on_close_toplevel(id);
+ self.window_ids.retain(|(_, w)| w != window);
+ }
+ }
+
+ /// Apply WM render commands to the Space before frame rendering.
+ pub fn apply_wm_render_commands(&mut self) {
+ let ids: Vec<_> = self.window_ids.iter().map(|(id, _)| *id).collect();
+ let commands = self.wm_state.active_wm().on_render(&ids);
+
+ for cmd in commands {
+ if let Some(window) = self
+ .window_ids
+ .iter()
+ .find(|(id, _)| *id == cmd.id)
+ .map(|(_, w)| w.clone())
+ {
+ if cmd.visible {
+ self.space.map_element(window, cmd.position, false);
+ } else {
+ self.space.unmap_elem(&window);
+ }
+ }
+ }
+ }
+
/// Process an input event from the backend and forward it to the appropriate
/// Smithay seat device (keyboard or pointer).
/// Only used by the Winit backend for local input processing.
@@ -128,15 +201,25 @@ impl WayRay {
let serial = SERIAL_COUNTER.next_serial();
let pointer = self.seat.get_pointer().unwrap();
- // On button press, focus the window under the pointer.
+ // On button press, focus the window under the pointer via WM.
if event.state() == ButtonState::Pressed {
let pos = pointer.current_location();
- if let Some((window, _loc)) = self.space.element_under(pos) {
- let window = window.clone();
- self.space.raise_element(&window, true);
-
+ if let Some(focus_window) = self
+ .space
+ .element_under(pos)
+ .map(|(w, _)| w.clone())
+ .and_then(|w| self.window_id_for(&w))
+ .and_then(|wid| self.wm_state.active_wm().on_pointer_focus(wid))
+ .and_then(|fid| {
+ self.window_ids
+ .iter()
+ .find(|(id, _)| *id == fid)
+ .map(|(_, w)| w.clone())
+ })
+ {
+ self.space.raise_element(&focus_window, true);
let keyboard = self.seat.get_keyboard().unwrap();
- let wl_surface = window.toplevel().map(|t| t.wl_surface().clone());
+ let wl_surface = focus_window.toplevel().map(|t| t.wl_surface().clone());
keyboard.set_focus(self, wl_surface, serial);
}
}
@@ -252,15 +335,25 @@ impl WayRay {
wayray_protocol::messages::ButtonState::Released => ButtonState::Released,
};
- // Click-to-focus on button press.
+ // Click-to-focus on button press — delegate to WM.
if state == ButtonState::Pressed {
let pos = pointer.current_location();
- if let Some((window, _loc)) = self.space.element_under(pos) {
- let window = window.clone();
- self.space.raise_element(&window, true);
-
+ if let Some(focus_window) = self
+ .space
+ .element_under(pos)
+ .map(|(w, _)| w.clone())
+ .and_then(|w| self.window_id_for(&w))
+ .and_then(|wid| self.wm_state.active_wm().on_pointer_focus(wid))
+ .and_then(|fid| {
+ self.window_ids
+ .iter()
+ .find(|(id, _)| *id == fid)
+ .map(|(_, w)| w.clone())
+ })
+ {
+ self.space.raise_element(&focus_window, true);
let keyboard = self.seat.get_keyboard().unwrap();
- let wl_surface = window.toplevel().map(|t| t.wl_surface().clone());
+ let wl_surface = focus_window.toplevel().map(|t| t.wl_surface().clone());
keyboard.set_focus(self, wl_surface, serial);
}
}
diff --git a/crates/wrsrvd/src/wm/floating.rs b/crates/wrsrvd/src/wm/floating.rs
new file mode 100644
index 0000000..ba2cc9f
--- /dev/null
+++ b/crates/wrsrvd/src/wm/floating.rs
@@ -0,0 +1,231 @@
+use std::collections::HashMap;
+
+use super::WindowManager;
+use super::types::{DecorationMode, ManageResponse, RenderCommand, WindowId, WindowInfo, ZOrder};
+
+/// State for a single managed window in the floating WM.
+#[derive(Debug, Clone)]
+struct WindowState {
+ position: (i32, i32),
+ size: (i32, i32),
+ /// Stack index: higher means more on top.
+ z_index: u32,
+ visible: bool,
+}
+
+/// Built-in floating window manager providing sane defaults.
+///
+/// Active when no external WM is connected. Centers new windows,
+/// implements click-to-focus, and provides basic keyboard shortcuts.
+pub struct BuiltinFloatingWm {
+ windows: HashMap,
+ /// Focus stack: last element is the focused window.
+ focus_stack: Vec,
+ /// Counter for z-ordering.
+ next_z: u32,
+ /// Output dimensions for centering calculations.
+ output_width: i32,
+ output_height: i32,
+}
+
+impl BuiltinFloatingWm {
+ pub fn new(output_width: i32, output_height: i32) -> Self {
+ Self {
+ windows: HashMap::new(),
+ focus_stack: Vec::new(),
+ next_z: 1,
+ output_width,
+ output_height,
+ }
+ }
+
+ /// Move window to the top of the focus stack.
+ fn raise_to_top(&mut self, id: WindowId) {
+ self.focus_stack.retain(|&w| w != id);
+ self.focus_stack.push(id);
+
+ if let Some(state) = self.windows.get_mut(&id) {
+ state.z_index = self.next_z;
+ self.next_z += 1;
+ }
+ }
+}
+
+impl WindowManager for BuiltinFloatingWm {
+ fn on_new_toplevel(&mut self, id: WindowId, info: WindowInfo) -> ManageResponse {
+ let width = 800.min(info.output_width);
+ let height = 600.min(info.output_height);
+
+ // Respect size hints if present.
+ let width = match (info.min_size, info.max_size) {
+ (Some((min_w, _)), _) if width < min_w => min_w,
+ (_, Some((max_w, _))) if width > max_w => max_w,
+ _ => width,
+ };
+ let height = match (info.min_size, info.max_size) {
+ (Some((_, min_h)), _) if height < min_h => min_h,
+ (_, Some((_, max_h))) if height > max_h => max_h,
+ _ => height,
+ };
+
+ // Center on output.
+ let x = (self.output_width - width) / 2;
+ let y = (self.output_height - height) / 2;
+
+ let z_index = self.next_z;
+ self.next_z += 1;
+
+ self.windows.insert(
+ id,
+ WindowState {
+ position: (x, y),
+ size: (width, height),
+ z_index,
+ visible: true,
+ },
+ );
+ self.focus_stack.push(id);
+
+ ManageResponse {
+ size: (width, height),
+ position: (x, y),
+ focus: true,
+ decoration: DecorationMode::ServerSide,
+ }
+ }
+
+ fn on_close_toplevel(&mut self, id: WindowId) {
+ self.windows.remove(&id);
+ self.focus_stack.retain(|&w| w != id);
+ }
+
+ fn on_fullscreen_request(&mut self, id: WindowId) -> bool {
+ if let Some(state) = self.windows.get_mut(&id) {
+ state.position = (0, 0);
+ state.size = (self.output_width, self.output_height);
+ self.raise_to_top(id);
+ true
+ } else {
+ false
+ }
+ }
+
+ fn on_render(&mut self, windows: &[WindowId]) -> Vec {
+ windows
+ .iter()
+ .filter_map(|&id| {
+ let state = self.windows.get(&id)?;
+ Some(RenderCommand {
+ id,
+ position: state.position,
+ z_order: ZOrder::Preserve,
+ visible: state.visible,
+ })
+ })
+ .collect()
+ }
+
+ fn on_key_binding(&mut self, _key: u32, _modifiers: u32) -> bool {
+ // TODO: Alt+F4 close, Alt+Tab cycle
+ false
+ }
+
+ fn on_pointer_focus(&mut self, id: WindowId) -> Option {
+ if self.windows.contains_key(&id) {
+ self.raise_to_top(id);
+ Some(id)
+ } else {
+ None
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn make_info() -> WindowInfo {
+ WindowInfo {
+ title: None,
+ app_id: None,
+ output_width: 1280,
+ output_height: 720,
+ min_size: None,
+ max_size: None,
+ }
+ }
+
+ #[test]
+ fn new_window_is_centered() {
+ let mut wm = BuiltinFloatingWm::new(1280, 720);
+ let id = WindowId::from_raw(1);
+ let resp = wm.on_new_toplevel(id, make_info());
+
+ assert_eq!(resp.size, (800, 600));
+ // Centered: (1280 - 800) / 2 = 240, (720 - 600) / 2 = 60
+ assert_eq!(resp.position, (240, 60));
+ assert!(resp.focus);
+ }
+
+ #[test]
+ fn small_output_clamps_size() {
+ let mut wm = BuiltinFloatingWm::new(640, 480);
+ let id = WindowId::from_raw(1);
+ let info = WindowInfo {
+ output_width: 640,
+ output_height: 480,
+ ..make_info()
+ };
+ let resp = wm.on_new_toplevel(id, info);
+
+ assert_eq!(resp.size, (640, 480));
+ assert_eq!(resp.position, (0, 0));
+ }
+
+ #[test]
+ fn focus_stack_ordering() {
+ let mut wm = BuiltinFloatingWm::new(1280, 720);
+ let id1 = WindowId::from_raw(1);
+ let id2 = WindowId::from_raw(2);
+ let id3 = WindowId::from_raw(3);
+
+ wm.on_new_toplevel(id1, make_info());
+ wm.on_new_toplevel(id2, make_info());
+ wm.on_new_toplevel(id3, make_info());
+
+ // id3 is on top (last created)
+ assert_eq!(wm.focus_stack.last(), Some(&id3));
+
+ // Click on id1 — should raise it
+ wm.on_pointer_focus(id1);
+ assert_eq!(wm.focus_stack.last(), Some(&id1));
+ assert!(wm.windows[&id1].z_index > wm.windows[&id3].z_index);
+ }
+
+ #[test]
+ fn close_removes_from_focus_stack() {
+ let mut wm = BuiltinFloatingWm::new(1280, 720);
+ let id1 = WindowId::from_raw(1);
+ let id2 = WindowId::from_raw(2);
+
+ wm.on_new_toplevel(id1, make_info());
+ wm.on_new_toplevel(id2, make_info());
+
+ wm.on_close_toplevel(id2);
+ assert!(!wm.focus_stack.contains(&id2));
+ assert!(!wm.windows.contains_key(&id2));
+ assert_eq!(wm.focus_stack.last(), Some(&id1));
+ }
+
+ #[test]
+ fn fullscreen_covers_output() {
+ let mut wm = BuiltinFloatingWm::new(1280, 720);
+ let id = WindowId::from_raw(1);
+ wm.on_new_toplevel(id, make_info());
+
+ let granted = wm.on_fullscreen_request(id);
+ assert!(granted);
+ assert_eq!(wm.windows[&id].position, (0, 0));
+ assert_eq!(wm.windows[&id].size, (1280, 720));
+ }
+}
diff --git a/crates/wrsrvd/src/wm/mod.rs b/crates/wrsrvd/src/wm/mod.rs
new file mode 100644
index 0000000..4d4746b
--- /dev/null
+++ b/crates/wrsrvd/src/wm/mod.rs
@@ -0,0 +1,79 @@
+#[allow(dead_code)]
+pub mod floating;
+pub mod protocol;
+#[allow(dead_code)]
+pub mod types;
+
+use types::{ManageResponse, RenderCommand, WindowId, WindowInfo};
+
+use self::floating::BuiltinFloatingWm;
+use self::protocol::WmProtocolState;
+
+/// Trait abstracting window management behavior.
+///
+/// Both the built-in floating WM and the external protocol adapter implement
+/// this trait, so the compositor core doesn't care which is active.
+#[allow(dead_code)]
+pub trait WindowManager {
+ /// Called when a new toplevel surface is mapped. Returns policy decisions
+ /// (size, position, focus, decorations) for the window.
+ fn on_new_toplevel(&mut self, id: WindowId, info: WindowInfo) -> ManageResponse;
+
+ /// Called when a toplevel is destroyed.
+ fn on_close_toplevel(&mut self, id: WindowId);
+
+ /// Called when a client requests fullscreen. Returns true to grant.
+ fn on_fullscreen_request(&mut self, id: WindowId) -> bool;
+
+ /// Called each frame before rendering. Returns placement commands for all
+ /// managed windows so the compositor can apply positions/z-order atomically.
+ fn on_render(&mut self, windows: &[WindowId]) -> Vec;
+
+ /// Called when a key press occurs. Returns true if the WM consumed the
+ /// binding (preventing delivery to the focused client).
+ fn on_key_binding(&mut self, key: u32, modifiers: u32) -> bool;
+
+ /// Called when the user clicks on a window. The WM should update focus
+ /// and z-order. Returns the id of the window that should receive focus,
+ /// if any.
+ fn on_pointer_focus(&mut self, id: WindowId) -> Option;
+}
+
+/// Coordinator holding both the built-in WM and the protocol server state.
+/// Routes calls to whichever is active.
+pub struct WmState {
+ pub builtin: BuiltinFloatingWm,
+ pub protocol: Option,
+}
+
+impl WmState {
+ /// Create a new WmState with the built-in floating WM active.
+ pub fn new(output_width: i32, output_height: i32) -> Self {
+ Self {
+ builtin: BuiltinFloatingWm::new(output_width, output_height),
+ protocol: None,
+ }
+ }
+
+ /// Initialize the protocol server (call after Display is set up).
+ pub fn init_protocol(&mut self, protocol_state: WmProtocolState) {
+ self.protocol = Some(protocol_state);
+ }
+
+ /// Whether an external WM is connected via the protocol.
+ #[allow(dead_code)]
+ pub fn external_connected(&self) -> bool {
+ self.protocol.as_ref().is_some_and(|p| p.is_wm_connected())
+ }
+
+ /// Get a mutable reference to the currently active WM.
+ /// Returns the built-in WM when no external WM is connected.
+ pub fn active_wm(&mut self) -> &mut dyn WindowManager {
+ // When an external WM is connected, we still use the built-in WM
+ // for the trait interface. The external WM's render commands are
+ // applied separately via the protocol state.
+ // TODO: In a future iteration, create a ProtocolAdapter that
+ // implements WindowManager and bridges to the protocol.
+ &mut self.builtin
+ }
+}
diff --git a/crates/wrsrvd/src/wm/protocol.rs b/crates/wrsrvd/src/wm/protocol.rs
new file mode 100644
index 0000000..728b6ad
--- /dev/null
+++ b/crates/wrsrvd/src/wm/protocol.rs
@@ -0,0 +1,541 @@
+//! Wayland protocol server for the WayRay WM protocol.
+//!
+//! Implements `GlobalDispatch` and `Dispatch` for the four custom WM interfaces,
+//! allowing external WM clients to connect and control window layout.
+
+use std::collections::HashMap;
+
+use smithay::reexports::wayland_server::{
+ Client, DataInit, Dispatch, DisplayHandle, GlobalDispatch, New, Resource,
+ backend::{ClientId, GlobalId},
+};
+use tracing::{info, warn};
+use wayray_wm_protocol::server::{
+ wayray_wm_manager_v1::{self, WayrayWmManagerV1},
+ wayray_wm_seat_v1::{self, WayrayWmSeatV1},
+ wayray_wm_window_v1::{self, WayrayWmWindowV1},
+ wayray_wm_workspace_v1::{self, WayrayWmWorkspaceV1},
+};
+
+use super::types::{RenderCommand, WindowId, ZOrder};
+
+/// Window info tuple for sending to a newly connected WM.
+/// (window_id, title, app_id, width, height)
+pub type WindowSnapshot = (WindowId, Option, Option, i32, i32);
+
+/// Per-window data associated with a `wayray_wm_window_v1` protocol object.
+#[derive(Debug, Clone)]
+pub struct WmWindowData {
+ pub window_id: WindowId,
+}
+
+/// Data associated with the WM manager global.
+pub struct WmGlobalData;
+
+/// State for the WM protocol server.
+///
+/// Tracks the currently connected WM, pending phase operations, and
+/// the mapping between WindowIds and protocol objects.
+#[allow(dead_code)]
+pub struct WmProtocolState {
+ global: GlobalId,
+ /// The currently bound WM manager resource (only one allowed).
+ wm_client: Option,
+ /// Mapping from WindowId to the protocol window object sent to the WM.
+ window_objects: HashMap,
+ /// Pending render commands collected during the render phase.
+ pending_render_commands: Vec,
+ /// Whether a manage phase is currently in progress.
+ manage_phase_active: bool,
+ /// Whether a render phase is currently in progress.
+ render_phase_active: bool,
+ /// Display handle for creating resources.
+ dh: DisplayHandle,
+}
+
+impl std::fmt::Debug for WmProtocolState {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ f.debug_struct("WmProtocolState")
+ .field("wm_connected", &self.wm_client.is_some())
+ .field("windows", &self.window_objects.len())
+ .finish()
+ }
+}
+
+#[allow(dead_code)]
+impl WmProtocolState {
+ /// Create the WM global and register it with the display.
+ pub fn new(dh: &DisplayHandle) -> Self
+ where
+ D: GlobalDispatch
+ + Dispatch
+ + Dispatch
+ + Dispatch
+ + Dispatch
+ + 'static,
+ {
+ let global = dh.create_global::(1, WmGlobalData);
+
+ Self {
+ global,
+ wm_client: None,
+ window_objects: HashMap::new(),
+ pending_render_commands: Vec::new(),
+ manage_phase_active: false,
+ render_phase_active: false,
+ dh: dh.clone(),
+ }
+ }
+
+ /// Whether an external WM is currently connected.
+ pub fn is_wm_connected(&self) -> bool {
+ self.wm_client.is_some()
+ }
+
+ /// Send a `window_new` event to the connected WM for a new toplevel.
+ pub fn notify_new_window(
+ &mut self,
+ window_id: WindowId,
+ title: Option<&str>,
+ app_id: Option<&str>,
+ width: i32,
+ height: i32,
+ ) where
+ D: Dispatch + Dispatch + 'static,
+ {
+ let Some(manager) = &self.wm_client else {
+ return;
+ };
+
+ let Ok(client) = self.dh.get_client(manager.id()) else {
+ return;
+ };
+
+ let data = WmWindowData { window_id };
+
+ let Ok(window_obj) =
+ client.create_resource::(&self.dh, manager.version(), data)
+ else {
+ warn!("failed to create WM window resource");
+ return;
+ };
+
+ // Send window_new event with the new protocol object.
+ manager.window_new(&window_obj);
+
+ // Send initial properties.
+ if let Some(title) = title {
+ window_obj.title(title.to_string());
+ }
+ if let Some(app_id) = app_id {
+ window_obj.app_id(app_id.to_string());
+ }
+ window_obj.dimensions(width, height);
+ window_obj.done();
+
+ self.window_objects.insert(window_id, window_obj);
+ }
+
+ /// Send a `window_closed` event to the connected WM.
+ pub fn notify_window_closed(&mut self, window_id: WindowId) {
+ let Some(manager) = &self.wm_client else {
+ return;
+ };
+
+ if let Some(window_obj) = self.window_objects.remove(&window_id) {
+ manager.window_closed(&window_obj);
+ }
+ }
+
+ /// Send `manage_start` to begin the manage phase.
+ pub fn start_manage_phase(&mut self) {
+ if let Some(manager) = &self.wm_client {
+ manager.manage_start();
+ self.manage_phase_active = true;
+ }
+ }
+
+ /// Send `render_start` to begin the render phase.
+ pub fn start_render_phase(&mut self) {
+ if let Some(manager) = &self.wm_client {
+ self.pending_render_commands.clear();
+ manager.render_start();
+ self.render_phase_active = true;
+ }
+ }
+
+ /// Take the collected render commands from the last render phase.
+ pub fn take_render_commands(&mut self) -> Vec {
+ std::mem::take(&mut self.pending_render_commands)
+ }
+
+ /// Send the full window list to a newly connected WM.
+ fn send_full_window_list(
+ &mut self,
+ manager: &WayrayWmManagerV1,
+ existing_windows: &[WindowSnapshot],
+ ) where
+ D: Dispatch + Dispatch + 'static,
+ {
+ let Ok(client) = self.dh.get_client(manager.id()) else {
+ return;
+ };
+
+ for (window_id, title, app_id, width, height) in existing_windows {
+ let data = WmWindowData {
+ window_id: *window_id,
+ };
+
+ let Ok(window_obj) =
+ client.create_resource::(&self.dh, manager.version(), data)
+ else {
+ continue;
+ };
+
+ manager.window_new(&window_obj);
+
+ if let Some(title) = title {
+ window_obj.title(title.clone());
+ }
+ if let Some(app_id) = app_id {
+ window_obj.app_id(app_id.clone());
+ }
+ window_obj.dimensions(*width, *height);
+ window_obj.done();
+
+ self.window_objects.insert(*window_id, window_obj);
+ }
+ }
+
+ /// Look up a WindowId from a protocol window object.
+ fn window_id_for_resource(&self, resource: &WayrayWmWindowV1) -> Option {
+ self.window_objects
+ .iter()
+ .find(|(_, obj)| *obj == resource)
+ .map(|(id, _)| *id)
+ }
+}
+
+// =============================================================================
+// Dispatch implementations
+// =============================================================================
+
+/// Helper trait for the WayRay compositor state to provide WM protocol state.
+pub trait WmProtocolHandler:
+ GlobalDispatch
+ + Dispatch
+ + Dispatch
+ + Dispatch
+ + Dispatch
+ + 'static
+{
+ fn wm_protocol_state(&mut self) -> &mut WmProtocolState;
+
+ /// Return the list of existing windows for sending to a newly connected WM.
+ /// Each tuple is (window_id, title, app_id, width, height).
+ fn existing_windows(&self) -> Vec;
+}
+
+// --- Manager ---
+
+impl GlobalDispatch for WmProtocolState {
+ fn bind(
+ state: &mut D,
+ _dh: &DisplayHandle,
+ _client: &Client,
+ resource: New,
+ _global_data: &WmGlobalData,
+ data_init: &mut DataInit<'_, D>,
+ ) {
+ let instance = data_init.init(resource, ());
+
+ // Collect existing windows before mutating protocol state.
+ let existing = state.existing_windows();
+
+ let proto = state.wm_protocol_state();
+
+ // Enforce single-WM: replace old WM if present.
+ if let Some(old_manager) = proto.wm_client.take() {
+ old_manager.replaced();
+ proto.window_objects.clear();
+ info!("external WM replaced by new connection");
+ }
+
+ proto.wm_client = Some(instance.clone());
+
+ // Send the full window list so the new WM can reconstruct state.
+ if !existing.is_empty() {
+ proto.send_full_window_list::(&instance, &existing);
+ info!(
+ window_count = existing.len(),
+ "sent existing window list to new WM"
+ );
+ }
+
+ info!("external WM connected");
+ }
+}
+
+impl Dispatch for WmProtocolState {
+ fn request(
+ state: &mut D,
+ _client: &Client,
+ _resource: &WayrayWmManagerV1,
+ request: wayray_wm_manager_v1::Request,
+ _data: &(),
+ _dh: &DisplayHandle,
+ data_init: &mut DataInit<'_, D>,
+ ) {
+ let proto = state.wm_protocol_state();
+ match request {
+ wayray_wm_manager_v1::Request::ManageDone => {
+ proto.manage_phase_active = false;
+ }
+ wayray_wm_manager_v1::Request::RenderDone => {
+ proto.render_phase_active = false;
+ }
+ wayray_wm_manager_v1::Request::GetSeat { id } => {
+ data_init.init(id, ());
+ }
+ wayray_wm_manager_v1::Request::GetWorkspaceManager { id } => {
+ data_init.init(id, ());
+ }
+ wayray_wm_manager_v1::Request::Destroy => {
+ // WM disconnecting gracefully.
+ }
+ _ => {}
+ }
+ }
+
+ fn destroyed(state: &mut D, _client: ClientId, resource: &WayrayWmManagerV1, _data: &()) {
+ let proto = state.wm_protocol_state();
+ if proto.wm_client.as_ref().is_some_and(|wm| wm == resource) {
+ proto.wm_client = None;
+ proto.window_objects.clear();
+ proto.manage_phase_active = false;
+ proto.render_phase_active = false;
+ info!("external WM disconnected");
+ }
+ }
+}
+
+// --- Window ---
+
+impl Dispatch for WmProtocolState {
+ fn request(
+ state: &mut D,
+ _client: &Client,
+ _resource: &WayrayWmWindowV1,
+ request: wayray_wm_window_v1::Request,
+ data: &WmWindowData,
+ _dh: &DisplayHandle,
+ _data_init: &mut DataInit<'_, D>,
+ ) {
+ let proto = state.wm_protocol_state();
+ let window_id = data.window_id;
+
+ match request {
+ wayray_wm_window_v1::Request::ProposeDimensions {
+ width: _,
+ height: _,
+ } => {
+ // TODO: implement dimension proposal tracking
+ }
+ wayray_wm_window_v1::Request::SetFocus => {
+ // TODO: queue focus change
+ }
+ wayray_wm_window_v1::Request::UseSsd => {
+ // TODO: set decoration mode
+ }
+ wayray_wm_window_v1::Request::UseCsd => {
+ // TODO: set decoration mode
+ }
+ wayray_wm_window_v1::Request::GrantFullscreen => {
+ // TODO: grant fullscreen
+ }
+ wayray_wm_window_v1::Request::DenyFullscreen => {
+ // TODO: deny fullscreen
+ }
+ wayray_wm_window_v1::Request::Close => {
+ // TODO: send close to toplevel
+ }
+ wayray_wm_window_v1::Request::SetPosition { x, y } => {
+ proto.pending_render_commands.push(RenderCommand {
+ id: window_id,
+ position: (x, y),
+ z_order: ZOrder::Preserve,
+ visible: true,
+ });
+ }
+ wayray_wm_window_v1::Request::SetZTop => {
+ // Update the last command for this window, or add a new one.
+ if let Some(cmd) = proto
+ .pending_render_commands
+ .iter_mut()
+ .find(|c| c.id == window_id)
+ {
+ cmd.z_order = ZOrder::Top;
+ } else {
+ proto.pending_render_commands.push(RenderCommand {
+ id: window_id,
+ position: (0, 0), // Will use existing position
+ z_order: ZOrder::Top,
+ visible: true,
+ });
+ }
+ }
+ wayray_wm_window_v1::Request::SetZBottom => {
+ if let Some(cmd) = proto
+ .pending_render_commands
+ .iter_mut()
+ .find(|c| c.id == window_id)
+ {
+ cmd.z_order = ZOrder::Bottom;
+ } else {
+ proto.pending_render_commands.push(RenderCommand {
+ id: window_id,
+ position: (0, 0),
+ z_order: ZOrder::Bottom,
+ visible: true,
+ });
+ }
+ }
+ wayray_wm_window_v1::Request::SetZAbove { .. } => {
+ // TODO: relative z-ordering
+ }
+ wayray_wm_window_v1::Request::SetZBelow { .. } => {
+ // TODO: relative z-ordering
+ }
+ wayray_wm_window_v1::Request::SetBorders { .. } => {
+ // TODO: border rendering
+ }
+ wayray_wm_window_v1::Request::Show => {
+ if let Some(cmd) = proto
+ .pending_render_commands
+ .iter_mut()
+ .find(|c| c.id == window_id)
+ {
+ cmd.visible = true;
+ } else {
+ proto.pending_render_commands.push(RenderCommand {
+ id: window_id,
+ position: (0, 0),
+ z_order: ZOrder::Preserve,
+ visible: true,
+ });
+ }
+ }
+ wayray_wm_window_v1::Request::Hide => {
+ if let Some(cmd) = proto
+ .pending_render_commands
+ .iter_mut()
+ .find(|c| c.id == window_id)
+ {
+ cmd.visible = false;
+ } else {
+ proto.pending_render_commands.push(RenderCommand {
+ id: window_id,
+ position: (0, 0),
+ z_order: ZOrder::Preserve,
+ visible: false,
+ });
+ }
+ }
+ wayray_wm_window_v1::Request::SetOutput { .. } => {
+ // TODO: multi-output
+ }
+ wayray_wm_window_v1::Request::Destroy => {}
+ _ => {}
+ }
+ }
+
+ fn destroyed(
+ state: &mut D,
+ _client: ClientId,
+ _resource: &WayrayWmWindowV1,
+ data: &WmWindowData,
+ ) {
+ state
+ .wm_protocol_state()
+ .window_objects
+ .remove(&data.window_id);
+ }
+}
+
+// --- Seat ---
+
+impl Dispatch for WmProtocolState {
+ fn request(
+ _state: &mut D,
+ _client: &Client,
+ _resource: &WayrayWmSeatV1,
+ request: wayray_wm_seat_v1::Request,
+ _data: &(),
+ _dh: &DisplayHandle,
+ _data_init: &mut DataInit<'_, D>,
+ ) {
+ match request {
+ wayray_wm_seat_v1::Request::BindKey {
+ key,
+ modifiers,
+ mode,
+ } => {
+ // TODO: register keybinding
+ info!(key, modifiers, mode, "WM registered keybinding");
+ }
+ wayray_wm_seat_v1::Request::UnbindKey { .. } => {
+ // TODO: unregister keybinding
+ }
+ wayray_wm_seat_v1::Request::CreateMode { .. } => {
+ // TODO: create binding mode
+ }
+ wayray_wm_seat_v1::Request::ActivateMode { .. } => {
+ // TODO: activate binding mode
+ }
+ wayray_wm_seat_v1::Request::StartMove { .. } => {
+ // TODO: interactive move
+ }
+ wayray_wm_seat_v1::Request::StartResize { .. } => {
+ // TODO: interactive resize
+ }
+ wayray_wm_seat_v1::Request::Destroy => {}
+ _ => {}
+ }
+ }
+}
+
+// --- Workspace ---
+
+impl Dispatch for WmProtocolState {
+ fn request(
+ _state: &mut D,
+ _client: &Client,
+ resource: &WayrayWmWorkspaceV1,
+ request: wayray_wm_workspace_v1::Request,
+ _data: &(),
+ _dh: &DisplayHandle,
+ _data_init: &mut DataInit<'_, D>,
+ ) {
+ match request {
+ wayray_wm_workspace_v1::Request::CreateWorkspace { name } => {
+ // TODO: create workspace
+ resource.workspace_created(name);
+ }
+ wayray_wm_workspace_v1::Request::DestroyWorkspace { name } => {
+ // TODO: destroy workspace
+ resource.workspace_destroyed(name);
+ }
+ wayray_wm_workspace_v1::Request::SetActiveWorkspace { .. } => {
+ // TODO: set active workspace
+ }
+ wayray_wm_workspace_v1::Request::AssignWindow { .. } => {
+ // TODO: assign window to workspace
+ }
+ wayray_wm_workspace_v1::Request::SetWindowTags { .. } => {
+ // TODO: set window tags
+ }
+ wayray_wm_workspace_v1::Request::Destroy => {}
+ _ => {}
+ }
+ }
+}
diff --git a/crates/wrsrvd/src/wm/types.rs b/crates/wrsrvd/src/wm/types.rs
new file mode 100644
index 0000000..c46f017
--- /dev/null
+++ b/crates/wrsrvd/src/wm/types.rs
@@ -0,0 +1,72 @@
+/// Unique identifier for a window managed by the WM.
+///
+/// Wraps a monotonically increasing u64 assigned when a toplevel is first mapped.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
+pub struct WindowId(u64);
+
+impl WindowId {
+ /// Create a new WindowId from a raw u64.
+ pub fn from_raw(id: u64) -> Self {
+ Self(id)
+ }
+
+ /// Get the raw u64 value.
+ pub fn raw(self) -> u64 {
+ self.0
+ }
+}
+
+/// Information about a newly created toplevel, sent to the WM during the manage phase.
+#[derive(Debug, Clone)]
+pub struct WindowInfo {
+ pub title: Option,
+ pub app_id: Option,
+ /// Output dimensions the window is being mapped on.
+ pub output_width: i32,
+ pub output_height: i32,
+ /// Size hints from the client (min/max size).
+ pub min_size: Option<(i32, i32)>,
+ pub max_size: Option<(i32, i32)>,
+}
+
+/// The WM's policy response for a new or reconfigured window.
+#[derive(Debug, Clone)]
+pub struct ManageResponse {
+ /// Suggested dimensions (width, height) for the window.
+ pub size: (i32, i32),
+ /// Position (x, y) to place the window on the output.
+ pub position: (i32, i32),
+ /// Whether this window should receive keyboard focus.
+ pub focus: bool,
+ /// Decoration mode preference.
+ pub decoration: DecorationMode,
+}
+
+/// Decoration mode the WM wants for a window.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum DecorationMode {
+ /// Server draws title bar and borders.
+ ServerSide,
+ /// Client draws its own decorations.
+ ClientSide,
+}
+
+/// A command from the WM specifying visual placement for a window during the render phase.
+#[derive(Debug, Clone)]
+pub struct RenderCommand {
+ pub id: WindowId,
+ pub position: (i32, i32),
+ pub z_order: ZOrder,
+ pub visible: bool,
+}
+
+/// Z-ordering directive for a window.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum ZOrder {
+ /// Place at the top of the stack.
+ Top,
+ /// Place at the bottom of the stack.
+ Bottom,
+ /// Keep current z-order (no change).
+ Preserve,
+}