diff --git a/crates/wrsrvd/src/backend/headless.rs b/crates/wrsrvd/src/backend/headless.rs index 1873f6e..db4defc 100644 --- a/crates/wrsrvd/src/backend/headless.rs +++ b/crates/wrsrvd/src/backend/headless.rs @@ -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. - data.state.apply_wm_render_commands(); + // 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] = &[]; diff --git a/crates/wrsrvd/src/handlers/wm_protocol.rs b/crates/wrsrvd/src/handlers/wm_protocol.rs index 9e20a95..e862475 100644 --- a/crates/wrsrvd/src/handlers/wm_protocol.rs +++ b/crates/wrsrvd/src/handlers/wm_protocol.rs @@ -2,9 +2,15 @@ //! //! Connects the generated protocol types to our WmProtocolState implementation. -use smithay::reexports::wayland_server::{ - Client, DataInit, Dispatch, DisplayHandle, GlobalDispatch, New, Resource, backend::ClientId, +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. diff --git a/crates/wrsrvd/src/handlers/xdg_shell.rs b/crates/wrsrvd/src/handlers/xdg_shell.rs index 3bebf12..c30893f 100644 --- a/crates/wrsrvd/src/handlers/xdg_shell.rs +++ b/crates/wrsrvd/src/handlers/xdg_shell.rs @@ -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::(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) { diff --git a/crates/wrsrvd/src/state.rs b/crates/wrsrvd/src/state.rs index 744906b..a9ada81 100644 --- a/crates/wrsrvd/src/state.rs +++ b/crates/wrsrvd/src/state.rs @@ -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 { diff --git a/crates/wrsrvd/src/wm/floating.rs b/crates/wrsrvd/src/wm/floating.rs index ba2cc9f..baa994d 100644 --- a/crates/wrsrvd/src/wm/floating.rs +++ b/crates/wrsrvd/src/wm/floating.rs @@ -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 { + 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 { + 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 { @@ -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)); + } } diff --git a/crates/wrsrvd/src/wm/protocol.rs b/crates/wrsrvd/src/wm/protocol.rs index 728b6ad..8acde3d 100644 --- a/crates/wrsrvd/src/wm/protocol.rs +++ b/crates/wrsrvd/src/wm/protocol.rs @@ -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, + /// Currently active binding mode (empty string = default). + active_mode: String, + /// The WM's seat object for sending binding events. + wm_seat: Option, } 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( &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; + + // -- 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 Dispatch 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 Dispatch 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 Dispatch 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 Dispatch 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 Dispatch 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 Dispatch 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 Dispatch for WmPro impl Dispatch 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 Dispatch 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(); + } _ => {} } }