Wire WM protocol requests to compositor and add keybinding support

Complete the Phase 2.5 protocol wiring so external WMs can actually
control the compositor:

- Add compositor action methods to WmProtocolHandler: configure_window,
  focus_window, close_window, set_fullscreen, set_decoration
- Wire manage-phase requests (propose_dimensions, set_focus, close,
  fullscreen grant/deny, decorations) through to Smithay ToplevelSurface
- Trigger manage_start on new toplevel, render_start each frame, and
  notify_window_closed on toplevel destruction when external WM connected
- Apply external WM render commands in headless render loop
- Keybinding dispatch: bind_key/unbind_key storage, mode support,
  binding_pressed/released events sent to WM seat
- Built-in floating WM: Alt+F4 close, Alt+Tab focus cycling with tests
This commit is contained in:
Till Wegmueller 2026-04-07 22:51:50 +02:00
parent f2aebe04a6
commit f648a8af39
6 changed files with 392 additions and 42 deletions

View file

@ -238,7 +238,36 @@ fn render_headless_frame(data: &mut CalloopData) {
data.state.space.refresh();
// Apply WM render phase — positions/z-order before frame capture.
// If an external WM is connected, trigger the render phase protocol
// and apply its commands instead of the built-in WM's.
if let Some(proto) = &mut data.state.wm_state.protocol {
if proto.is_wm_connected() {
proto.start_render_phase();
// Note: The external WM responds via protocol dispatch in the
// next display.dispatch_clients() call. For this frame, apply
// any commands accumulated from previous dispatches.
let commands = proto.take_render_commands();
for cmd in commands {
if let Some(window) = data
.state
.window_ids
.iter()
.find(|(id, _)| *id == cmd.id)
.map(|(_, w)| w.clone())
{
if cmd.visible {
data.state.space.map_element(window, cmd.position, false);
} else {
data.state.space.unmap_elem(&window);
}
}
}
} else {
data.state.apply_wm_render_commands();
}
} else {
data.state.apply_wm_render_commands();
}
let custom_elements: &[TextureRenderElement<PixmanTexture>] = &[];

View file

@ -2,9 +2,15 @@
//!
//! Connects the generated protocol types to our WmProtocolState implementation.
use smithay::reexports::wayland_server::{
use smithay::reexports::{
wayland_protocols::xdg::decoration::zv1::server::zxdg_toplevel_decoration_v1::Mode as SmithayDecorationMode,
wayland_protocols::xdg::shell::server::xdg_toplevel,
wayland_server::{
Client, DataInit, Dispatch, DisplayHandle, GlobalDispatch, New, Resource, backend::ClientId,
},
};
use smithay::utils::SERIAL_COUNTER;
use tracing::warn;
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,
@ -12,6 +18,7 @@ use wayray_wm_protocol::server::{
use crate::state::WayRay;
use crate::wm::protocol::{WmGlobalData, WmProtocolHandler, WmProtocolState, WmWindowData};
use crate::wm::types::DecorationMode;
impl WmProtocolHandler for WayRay {
fn wm_protocol_state(&mut self) -> &mut WmProtocolState {
@ -31,6 +38,75 @@ impl WmProtocolHandler for WayRay {
})
.collect()
}
fn configure_window(&mut self, id: crate::wm::types::WindowId, width: i32, height: i32) {
let Some(window) = self.window_for_id(id).cloned() else {
warn!(?id, "configure_window: unknown window");
return;
};
if let Some(toplevel) = window.toplevel() {
toplevel.with_pending_state(|state| {
state.size = Some((width, height).into());
});
toplevel.send_pending_configure();
}
}
fn focus_window(&mut self, id: crate::wm::types::WindowId) {
let Some(window) = self.window_for_id(id).cloned() else {
warn!(?id, "focus_window: unknown window");
return;
};
self.space.raise_element(&window, true);
let serial = 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);
}
fn close_window(&mut self, id: crate::wm::types::WindowId) {
let Some(window) = self.window_for_id(id).cloned() else {
warn!(?id, "close_window: unknown window");
return;
};
if let Some(toplevel) = window.toplevel() {
toplevel.send_close();
}
}
fn set_fullscreen(&mut self, id: crate::wm::types::WindowId, granted: bool) {
let Some(window) = self.window_for_id(id).cloned() else {
warn!(?id, "set_fullscreen: unknown window");
return;
};
if let Some(toplevel) = window.toplevel() {
toplevel.with_pending_state(|state| {
if granted {
state.states.set(xdg_toplevel::State::Fullscreen);
} else {
state.states.unset(xdg_toplevel::State::Fullscreen);
}
});
toplevel.send_pending_configure();
}
}
fn set_decoration(&mut self, id: crate::wm::types::WindowId, mode: DecorationMode) {
let Some(window) = self.window_for_id(id).cloned() else {
warn!(?id, "set_decoration: unknown window");
return;
};
if let Some(toplevel) = window.toplevel() {
let smithay_mode = match mode {
DecorationMode::ServerSide => SmithayDecorationMode::ServerSide,
DecorationMode::ClientSide => SmithayDecorationMode::ClientSide,
};
toplevel.with_pending_state(|state| {
state.decoration_mode = Some(smithay_mode);
});
toplevel.send_pending_configure();
}
}
}
// Delegate GlobalDispatch for the manager global.

View file

@ -62,6 +62,37 @@ impl XdgShellHandler for WayRay {
pos = ?response.position,
"new toplevel mapped via WM"
);
// Notify external WM if connected, triggering a manage phase.
if let Some(proto) = &mut self.wm_state.protocol
&& proto.is_wm_connected()
{
proto.notify_new_window::<WayRay>(id, None, None, response.size.0, response.size.1);
proto.start_manage_phase();
}
}
fn toplevel_destroyed(&mut self, surface: ToplevelSurface) {
// Find and remove the window from our tracking.
let wl_surface = surface.wl_surface();
if let Some(pos) = self.window_ids.iter().position(|(_, w)| {
w.toplevel()
.map(|t| t.wl_surface() == wl_surface)
.unwrap_or(false)
}) {
let (id, window) = self.window_ids.remove(pos);
self.wm_state.active_wm().on_close_toplevel(id);
self.space.unmap_elem(&window);
// Notify external WM if connected.
if let Some(proto) = &mut self.wm_state.protocol
&& proto.is_wm_connected()
{
proto.notify_window_closed(id);
}
info!(?id, "toplevel destroyed");
}
}
fn new_popup(&mut self, surface: PopupSurface, _positioner: PositionerState) {

View file

@ -283,6 +283,39 @@ impl WayRay {
pub fn inject_network_input(&mut self, msg: InputMessage) {
match msg {
InputMessage::Keyboard(ev) => {
let pressed = matches!(ev.state, wayray_protocol::messages::KeyState::Pressed);
// Check if an external WM wants this key.
if let Some(proto) = &self.wm_state.protocol
&& proto.check_key_binding(ev.keycode, 0, pressed)
{
return;
}
// Check if the built-in WM wants this key (only on press).
if pressed && self.wm_state.active_wm().on_key_binding(ev.keycode, 0) {
// Alt+F4: close the focused window.
if ev.keycode == crate::wm::floating::KEY_F4
&& let Some(focused) = self.wm_state.builtin.focused()
&& let Some(window) = self.window_for_id(focused).cloned()
&& let Some(toplevel) = window.toplevel()
{
toplevel.send_close();
}
// Alt+Tab: focus the next window.
if ev.keycode == crate::wm::floating::KEY_TAB
&& let Some(new_focus) = self.wm_state.builtin.focused()
&& let Some(window) = self.window_for_id(new_focus).cloned()
{
self.space.raise_element(&window, true);
let serial = 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);
}
return;
}
let serial = SERIAL_COUNTER.next_serial();
let keyboard = self.seat.get_keyboard().unwrap();
let state = match ev.state {

View file

@ -3,6 +3,14 @@ use std::collections::HashMap;
use super::WindowManager;
use super::types::{DecorationMode, ManageResponse, RenderCommand, WindowId, WindowInfo, ZOrder};
/// Modifier bitmask for Alt (Mod1 in X11/XKB terms).
pub const MOD_ALT: u32 = 0x08;
/// Linux evdev keycode for F4.
pub const KEY_F4: u32 = 62;
/// Linux evdev keycode for Tab.
pub const KEY_TAB: u32 = 15;
/// State for a single managed window in the floating WM.
#[derive(Debug, Clone)]
struct WindowState {
@ -39,6 +47,32 @@ impl BuiltinFloatingWm {
}
}
/// Get the currently focused window (top of focus stack).
pub fn focused(&self) -> Option<WindowId> {
self.focus_stack.last().copied()
}
/// Cycle focus to the next window in the stack.
/// Returns the newly focused window id, if any.
pub fn focus_next(&mut self) -> Option<WindowId> {
if self.focus_stack.len() < 2 {
return self.focus_stack.last().copied();
}
// Move the top (focused) window to the bottom of the stack.
let top = self.focus_stack.pop().unwrap();
self.focus_stack.insert(0, top);
// The new top is now focused — raise it.
let new_focus = *self.focus_stack.last().unwrap();
if let Some(state) = self.windows.get_mut(&new_focus) {
state.z_index = self.next_z;
self.next_z += 1;
}
Some(new_focus)
}
/// Move window to the top of the focus stack.
fn raise_to_top(&mut self, id: WindowId) {
self.focus_stack.retain(|&w| w != id);
@ -125,9 +159,25 @@ impl WindowManager for BuiltinFloatingWm {
.collect()
}
fn on_key_binding(&mut self, _key: u32, _modifiers: u32) -> bool {
// TODO: Alt+F4 close, Alt+Tab cycle
false
fn on_key_binding(&mut self, key: u32, modifiers: u32) -> bool {
if modifiers & MOD_ALT == 0 {
return false;
}
match key {
KEY_F4 => {
// Alt+F4: signal that the focused window should close.
// The actual close is handled by the compositor via
// the returned `true` + checking focused().
true
}
KEY_TAB => {
// Alt+Tab: cycle focus to the next window.
self.focus_next();
true
}
_ => false,
}
}
fn on_pointer_focus(&mut self, id: WindowId) -> Option<WindowId> {
@ -228,4 +278,54 @@ mod tests {
assert_eq!(wm.windows[&id].position, (0, 0));
assert_eq!(wm.windows[&id].size, (1280, 720));
}
#[test]
fn alt_f4_signals_close() {
let mut wm = BuiltinFloatingWm::new(1280, 720);
let id = WindowId::from_raw(1);
wm.on_new_toplevel(id, make_info());
// Alt+F4 should be consumed.
assert!(wm.on_key_binding(KEY_F4, MOD_ALT));
// The focused window should still exist (compositor handles actual close).
assert_eq!(wm.focused(), Some(id));
}
#[test]
fn alt_tab_cycles_focus() {
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());
assert_eq!(wm.focused(), Some(id3));
// Alt+Tab should cycle to id2.
assert!(wm.on_key_binding(KEY_TAB, MOD_ALT));
assert_eq!(wm.focused(), Some(id2));
// Again -> id1.
assert!(wm.on_key_binding(KEY_TAB, MOD_ALT));
assert_eq!(wm.focused(), Some(id1));
// Again -> back to id3.
assert!(wm.on_key_binding(KEY_TAB, MOD_ALT));
assert_eq!(wm.focused(), Some(id3));
}
#[test]
fn non_alt_keys_not_consumed() {
let mut wm = BuiltinFloatingWm::new(1280, 720);
let id = WindowId::from_raw(1);
wm.on_new_toplevel(id, make_info());
// F4 without Alt should not be consumed.
assert!(!wm.on_key_binding(KEY_F4, 0));
// Random key with Alt should not be consumed.
assert!(!wm.on_key_binding(42, MOD_ALT));
}
}

View file

@ -3,7 +3,7 @@
//! 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 std::collections::{HashMap, HashSet};
use smithay::reexports::wayland_server::{
Client, DataInit, Dispatch, DisplayHandle, GlobalDispatch, New, Resource,
@ -17,7 +17,7 @@ use wayray_wm_protocol::server::{
wayray_wm_workspace_v1::{self, WayrayWmWorkspaceV1},
};
use super::types::{RenderCommand, WindowId, ZOrder};
use super::types::{DecorationMode, RenderCommand, WindowId, ZOrder};
/// Window info tuple for sending to a newly connected WM.
/// (window_id, title, app_id, width, height)
@ -51,6 +51,14 @@ pub struct WmProtocolState {
render_phase_active: bool,
/// Display handle for creating resources.
dh: DisplayHandle,
/// Registered keybindings: (key, modifiers, mode) -> active.
keybindings: HashSet<(u32, u32, String)>,
/// Available binding modes.
binding_modes: HashSet<String>,
/// Currently active binding mode (empty string = default).
active_mode: String,
/// The WM's seat object for sending binding events.
wm_seat: Option<WayrayWmSeatV1>,
}
impl std::fmt::Debug for WmProtocolState {
@ -84,6 +92,10 @@ impl WmProtocolState {
manage_phase_active: false,
render_phase_active: false,
dh: dh.clone(),
keybindings: HashSet::new(),
binding_modes: HashSet::new(),
active_mode: String::new(),
wm_seat: None,
}
}
@ -169,6 +181,32 @@ impl WmProtocolState {
std::mem::take(&mut self.pending_render_commands)
}
/// Check if a key+modifiers combination is registered as a WM binding.
/// If so, send the binding_pressed event to the WM and return true.
pub fn check_key_binding(&self, key: u32, modifiers: u32, pressed: bool) -> bool {
// Check default mode bindings and active mode bindings.
let default_match = self.keybindings.contains(&(key, modifiers, String::new()));
let mode_match = !self.active_mode.is_empty()
&& self
.keybindings
.contains(&(key, modifiers, self.active_mode.clone()));
if !default_match && !mode_match {
return false;
}
// Send binding event to the WM's seat object.
if let Some(seat) = &self.wm_seat {
if pressed {
seat.binding_pressed(key, modifiers);
} else {
seat.binding_released(key, modifiers);
}
}
true
}
/// Send the full window list to a newly connected WM.
fn send_full_window_list<D>(
&mut self,
@ -234,6 +272,23 @@ pub trait WmProtocolHandler:
/// 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<WindowSnapshot>;
// -- Compositor actions: let protocol dispatch reach back into Smithay --
/// Send a configure with proposed dimensions to the window's toplevel.
fn configure_window(&mut self, id: WindowId, width: i32, height: i32);
/// Move keyboard focus to the specified window.
fn focus_window(&mut self, id: WindowId);
/// Ask the client to close the specified window.
fn close_window(&mut self, id: WindowId);
/// Set or unset fullscreen state on a window.
fn set_fullscreen(&mut self, id: WindowId, granted: bool);
/// Set the decoration mode (server-side or client-side) for a window.
fn set_decoration(&mut self, id: WindowId, mode: DecorationMode);
}
// --- Manager ---
@ -314,6 +369,9 @@ impl<D: WmProtocolHandler> Dispatch<WayrayWmManagerV1, (), D> for WmProtocolStat
proto.window_objects.clear();
proto.manage_phase_active = false;
proto.render_phase_active = false;
proto.keybindings.clear();
proto.wm_seat = None;
proto.active_mode.clear();
info!("external WM disconnected");
}
}
@ -331,34 +389,46 @@ impl<D: WmProtocolHandler> Dispatch<WayrayWmWindowV1, WmWindowData, D> for WmPro
_dh: &DisplayHandle,
_data_init: &mut DataInit<'_, D>,
) {
let proto = state.wm_protocol_state();
let window_id = data.window_id;
// Manage-phase requests: call compositor actions directly on state.
// These need &mut access to the full compositor, not just the protocol state.
match request {
wayray_wm_window_v1::Request::ProposeDimensions {
width: _,
height: _,
} => {
// TODO: implement dimension proposal tracking
wayray_wm_window_v1::Request::ProposeDimensions { width, height } => {
state.configure_window(window_id, width, height);
return;
}
wayray_wm_window_v1::Request::SetFocus => {
// TODO: queue focus change
state.focus_window(window_id);
return;
}
wayray_wm_window_v1::Request::UseSsd => {
// TODO: set decoration mode
state.set_decoration(window_id, DecorationMode::ServerSide);
return;
}
wayray_wm_window_v1::Request::UseCsd => {
// TODO: set decoration mode
state.set_decoration(window_id, DecorationMode::ClientSide);
return;
}
wayray_wm_window_v1::Request::GrantFullscreen => {
// TODO: grant fullscreen
state.set_fullscreen(window_id, true);
return;
}
wayray_wm_window_v1::Request::DenyFullscreen => {
// TODO: deny fullscreen
state.set_fullscreen(window_id, false);
return;
}
wayray_wm_window_v1::Request::Close => {
// TODO: send close to toplevel
state.close_window(window_id);
return;
}
_ => {}
}
// Render-phase requests: accumulate into pending render commands.
let proto = state.wm_protocol_state();
match request {
wayray_wm_window_v1::Request::SetPosition { x, y } => {
proto.pending_render_commands.push(RenderCommand {
id: window_id,
@ -368,7 +438,6 @@ impl<D: WmProtocolHandler> Dispatch<WayrayWmWindowV1, WmWindowData, D> for WmPro
});
}
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()
@ -378,7 +447,7 @@ impl<D: WmProtocolHandler> Dispatch<WayrayWmWindowV1, WmWindowData, D> for WmPro
} else {
proto.pending_render_commands.push(RenderCommand {
id: window_id,
position: (0, 0), // Will use existing position
position: (0, 0),
z_order: ZOrder::Top,
visible: true,
});
@ -400,15 +469,6 @@ impl<D: WmProtocolHandler> Dispatch<WayrayWmWindowV1, WmWindowData, D> for WmPro
});
}
}
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
@ -441,8 +501,15 @@ impl<D: WmProtocolHandler> Dispatch<WayrayWmWindowV1, WmWindowData, D> for WmPro
});
}
}
wayray_wm_window_v1::Request::SetZAbove { .. }
| wayray_wm_window_v1::Request::SetZBelow { .. } => {
// TODO: relative z-ordering (needs sibling window lookup)
}
wayray_wm_window_v1::Request::SetBorders { .. } => {
// TODO: border rendering
}
wayray_wm_window_v1::Request::SetOutput { .. } => {
// TODO: multi-output
// TODO: multi-output support
}
wayray_wm_window_v1::Request::Destroy => {}
_ => {}
@ -466,31 +533,42 @@ impl<D: WmProtocolHandler> Dispatch<WayrayWmWindowV1, WmWindowData, D> for WmPro
impl<D: WmProtocolHandler> Dispatch<WayrayWmSeatV1, (), D> for WmProtocolState {
fn request(
_state: &mut D,
state: &mut D,
_client: &Client,
_resource: &WayrayWmSeatV1,
resource: &WayrayWmSeatV1,
request: wayray_wm_seat_v1::Request,
_data: &(),
_dh: &DisplayHandle,
_data_init: &mut DataInit<'_, D>,
) {
let proto = state.wm_protocol_state();
// Store the seat object for sending binding events later.
if proto.wm_seat.is_none() {
proto.wm_seat = Some(resource.clone());
}
match request {
wayray_wm_seat_v1::Request::BindKey {
key,
modifiers,
mode,
} => {
// TODO: register keybinding
proto.keybindings.insert((key, modifiers, mode.clone()));
info!(key, modifiers, mode, "WM registered keybinding");
}
wayray_wm_seat_v1::Request::UnbindKey { .. } => {
// TODO: unregister keybinding
wayray_wm_seat_v1::Request::UnbindKey {
key,
modifiers,
mode,
} => {
proto.keybindings.remove(&(key, modifiers, mode));
}
wayray_wm_seat_v1::Request::CreateMode { .. } => {
// TODO: create binding mode
wayray_wm_seat_v1::Request::CreateMode { name } => {
proto.binding_modes.insert(name);
}
wayray_wm_seat_v1::Request::ActivateMode { .. } => {
// TODO: activate binding mode
wayray_wm_seat_v1::Request::ActivateMode { name } => {
proto.active_mode = name;
}
wayray_wm_seat_v1::Request::StartMove { .. } => {
// TODO: interactive move
@ -498,7 +576,10 @@ impl<D: WmProtocolHandler> Dispatch<WayrayWmSeatV1, (), D> for WmProtocolState {
wayray_wm_seat_v1::Request::StartResize { .. } => {
// TODO: interactive resize
}
wayray_wm_seat_v1::Request::Destroy => {}
wayray_wm_seat_v1::Request::Destroy => {
proto.wm_seat = None;
proto.keybindings.clear();
}
_ => {}
}
}