diff options
| author | srdusr <[email protected]> | 2024-05-30 16:10:00 +0200 |
|---|---|---|
| committer | srdusr <[email protected]> | 2024-05-30 16:10:00 +0200 |
| commit | 1c175642d073689ca11b9252411ea5f8446007d0 (patch) | |
| tree | 00b8f9062cf1261d2d2642c059a14ff5790522e5 /crates/wayland/src/workspace.rs | |
| parent | 3d3057ae384ef7389284af8988410889e99c6bb9 (diff) | |
| download | srdwm-1c175642d073689ca11b9252411ea5f8446007d0.tar.gz srdwm-1c175642d073689ca11b9252411ea5f8446007d0.zip | |
Fix decoration drift during animated transitions; checkpoint IPC/global-menu/output-management work
Border/titlebar decoration was built from Window.geometry (the animation's
final target) in both wayland backends' render loops, while sync_geometry
already draws a window's actual content at window_anims' interpolated rect
during any maximize/fullscreen/open-slide tween. Border and content read two
different rectangles for the whole transition, so the border visibly
detached from the window it was outlining - reported as "borders aren't
flush." Both udev.rs and winit.rs now read the same animated rect for
titlebar placement, border-strip placement, and the occlusion test against
later windows in stacking order. Verified: cargo build --workspace, cargo
clippy (0 new warnings), cargo test -p srdwm-core (111/111).
Also checkpoints substantial protocol/IPC work from prior sessions that had
accumulated uncommitted: gtk-shell1 support (gtk_shell.rs, the vendored
gtk-shell.xml, xwayland.rs's X11-side mirror) backing the global app menu;
zwlr_foreign_toplevel_manager_v1 (foreign_toplevel.rs) broadcasting distinct
maximized/minimized/fullscreen/activated state per window; output_management
(ext-output-management + layer-shell exclusive-zone reservation tracking);
workspace.rs and context_menu.rs; a Unix-socket IPC crate (platform/src/
ipc.rs) and a `srd` control-CLI crate (crates/ctl); xkb_config.rs; and a
theme module (core/src/theme.rs). A peer session working the AGS shell
concurrently verified several of these live against a running srdwm: the
global menu rendering a real app's File/Edit menu over gtk-shell1, and
foreign-toplevel correctly reporting maximized and fullscreen as independent,
non-simultaneous states with the geometry each implies (maximize stops at a
reserved top bar and past a dock; fullscreen reaches the true monitor edge).
Diffstat (limited to 'crates/wayland/src/workspace.rs')
| -rw-r--r-- | crates/wayland/src/workspace.rs | 251 |
1 files changed, 251 insertions, 0 deletions
diff --git a/crates/wayland/src/workspace.rs b/crates/wayland/src/workspace.rs new file mode 100644 index 0000000..ce9cd04 --- /dev/null +++ b/crates/wayland/src/workspace.rs @@ -0,0 +1,251 @@ +//! `ext-workspace-v1`: enumerates srdwm's workspaces (name, active/urgent/ +//! hidden state) to any client that binds it, and lets that client request +//! activation - the workspace pill/switcher half of a dock, requested +//! alongside `foreign_toplevel.rs` (see `docs/PANEL_SUPPORT_TODO.md`'s P1 +//! list). smithay 0.7 has no built-in helper for this protocol either; +//! hand-written against the raw `wayland-protocols` server bindings, same +//! pattern as `screencopy.rs`/`foreign_toplevel.rs`. +//! +//! srdwm has exactly one flat, global list of workspaces shared by every +//! output (`WindowManager::current_workspace`/`workspaces()`), not a +//! separate set per monitor - so this always advertises exactly one +//! `ext_workspace_group_handle_v1`, entered by every output, containing +//! every workspace. A compositor with per-output workspaces would need one +//! group per output instead; that's not this one. +//! +//! Only `activate` is implemented as a request: srdwm's workspaces are a +//! fixed, config-defined set (created once at startup, not created/removed/ +//! reassigned at runtime), so `create_workspace`/`remove`/`assign` have +//! nothing meaningful to do and their capability bits are simply not +//! advertised - a client is expected to hide the UI for a request whose +//! capability bit is unset, per the protocol's own `capabilities` event +//! doc comment, rather than send it and be ignored. +//! +//! `deactivate` is also not advertised: srdwm always has exactly one +//! current workspace, there is no "no workspace active" state to request. + +use smithay::reexports::wayland_server::{Client, DataInit, Dispatch, DisplayHandle, GlobalDispatch, New, Resource}; +use wayland_protocols::ext::workspace::v1::server::ext_workspace_group_handle_v1::{self, ExtWorkspaceGroupHandleV1, GroupCapabilities}; +use wayland_protocols::ext::workspace::v1::server::ext_workspace_handle_v1::{self, ExtWorkspaceHandleV1, State, WorkspaceCapabilities}; +use wayland_protocols::ext::workspace::v1::server::ext_workspace_manager_v1::{self, ExtWorkspaceManagerV1}; + +use srdwm_core::WorkspaceId; + +use crate::state::CompState; + +const PROTOCOL_VERSION: u32 = 1; + +pub struct WorkspaceManagerState { + _global: smithay::reexports::wayland_server::backend::GlobalId, +} + +impl WorkspaceManagerState { + pub fn new<D>(dh: &DisplayHandle) -> Self + where + D: GlobalDispatch<ExtWorkspaceManagerV1, ()> + 'static, + { + Self { _global: dh.create_global::<D, ExtWorkspaceManagerV1, _>(PROTOCOL_VERSION, ()) } + } +} + +pub struct WorkspaceHandleData { + workspace: WorkspaceId, +} + +impl GlobalDispatch<ExtWorkspaceManagerV1, ()> for CompState { + fn bind(state: &mut Self, dh: &DisplayHandle, client: &Client, manager: New<ExtWorkspaceManagerV1>, _data: &(), data_init: &mut DataInit<'_, Self>) { + let manager = data_init.init(manager, ()); + + let Ok(group) = client.create_resource::<ExtWorkspaceGroupHandleV1, (), CompState>(dh, manager.version(), ()) else { + return; + }; + manager.workspace_group(&group); + // Nothing here is dynamically created/removed/reassigned - see the + // module doc comment on why this is always empty. + group.capabilities(GroupCapabilities::empty()); + // Every output enters the one group, since workspaces span all of + // them. Only reaches outputs this client has *already* bound + // `wl_output` for - a client binding `ext_workspace_manager_v1` + // before any `wl_output` global would see no `output_enter` here, + // and none later either, since nothing currently re-checks this on + // a subsequent `wl_output` bind. Real clients bind their globals up + // front, so not fixing that ordering-dependency is an accepted gap + // rather than a deliberate design choice. + for output in state.outputs() { + for wl_output in output.client_outputs(client) { + group.output_enter(&wl_output); + } + } + + let ids: Vec<WorkspaceId> = state.wm.borrow().workspaces().iter().map(|w| w.id).collect(); + for id in ids { + announce_workspace(state, &manager, &group, id, client, dh); + } + manager.done(); + + state.workspace_managers.push(manager); + state.workspace_groups.push(group); + } +} + +impl Dispatch<ExtWorkspaceManagerV1, ()> for CompState { + fn request(state: &mut Self, _client: &Client, manager: &ExtWorkspaceManagerV1, request: ext_workspace_manager_v1::Request, _data: &(), _dh: &DisplayHandle, _data_init: &mut DataInit<'_, Self>) { + match request { + // Every request below is handled the moment it arrives rather + // than batched, so there is nothing left for `commit` itself to + // flush - a plain acknowledgement. + ext_workspace_manager_v1::Request::Commit => {} + ext_workspace_manager_v1::Request::Stop => { + manager.finished(); + state.workspace_managers.retain(|m| m != manager); + } + _ => {} + } + } + + fn destroyed(state: &mut Self, _client: smithay::reexports::wayland_server::backend::ClientId, manager: &ExtWorkspaceManagerV1, _data: &()) { + state.workspace_managers.retain(|m| m != manager); + } +} + +impl Dispatch<ExtWorkspaceGroupHandleV1, ()> for CompState { + fn request(state: &mut Self, _client: &Client, group: &ExtWorkspaceGroupHandleV1, request: ext_workspace_group_handle_v1::Request, _data: &(), _dh: &DisplayHandle, _data_init: &mut DataInit<'_, Self>) { + // `create_workspace` is deliberately unimplemented - its + // capability bit is never advertised (see the module doc comment), + // so a well-behaved client never sends it; ignored either way + // rather than erroring a client that sends it anyway. + if let ext_workspace_group_handle_v1::Request::Destroy = request { + state.workspace_groups.retain(|g| g != group); + } + } + + fn destroyed(state: &mut Self, _client: smithay::reexports::wayland_server::backend::ClientId, group: &ExtWorkspaceGroupHandleV1, _data: &()) { + state.workspace_groups.retain(|g| g != group); + } +} + +impl Dispatch<ExtWorkspaceHandleV1, WorkspaceHandleData> for CompState { + fn request(state: &mut Self, _client: &Client, _handle: &ExtWorkspaceHandleV1, request: ext_workspace_handle_v1::Request, data: &WorkspaceHandleData, _dh: &DisplayHandle, _data_init: &mut DataInit<'_, Self>) { + if let ext_workspace_handle_v1::Request::Activate = request { + let mut wm = state.wm.borrow_mut(); + if wm.current_workspace() != data.workspace { + wm.switch_workspace(data.workspace); + drop(wm); + // `switch_workspace` alone only updates core's own state -- + // nothing re-renders or shows/hides windows for the new + // workspace without `main.rs`'s `sync()` running, which + // only happens when a polled event sets `dirty`. Pushing + // `WorkspaceChanged` is what makes that happen; see its + // definition in `srdwm_core::event` for the fuller story + // (this request path has the exact same problem the + // pre-existing `SUPER+scroll` workspace gesture already + // had, found while wiring this up). + state.pending.borrow_mut().push(srdwm_core::Event::WorkspaceChanged); + broadcast_active_workspace(state); + } + } + // Deactivate/Assign/Remove: not advertised as available (see the + // module doc comment), so real clients don't send them. Destroy + // ends this protocol object, handled by `destroyed` below. + } + + fn destroyed(state: &mut Self, _client: smithay::reexports::wayland_server::backend::ClientId, handle: &ExtWorkspaceHandleV1, data: &WorkspaceHandleData) { + if let Some(handles) = state.workspace_handles.get_mut(&data.workspace) { + handles.retain(|h| h != handle); + } + } +} + +/// Packs a workspace's current flags into `ext_workspace_handle_v1.state`'s +/// wire format. srdwm's workspaces are never `Urgent` (nothing in this +/// codebase has an "urgent"/attention-request concept for a workspace) or +/// `Hidden` (all of them are always real, switchable workspaces, never an +/// internal/scratch one that should stay out of a switcher UI) - only +/// `Active` ever varies. +fn workspace_state(active: bool) -> State { + if active { + State::Active + } else { + State::empty() + } +} + +fn announce_workspace(state: &mut CompState, manager: &ExtWorkspaceManagerV1, group: &ExtWorkspaceGroupHandleV1, id: WorkspaceId, client: &Client, dh: &DisplayHandle) { + let Ok(handle) = client.create_resource::<ExtWorkspaceHandleV1, WorkspaceHandleData, CompState>(dh, manager.version(), WorkspaceHandleData { workspace: id }) else { + return; + }; + manager.workspace(&handle); + group.workspace_enter(&handle); + + let Some(w) = state.wm.borrow().workspaces().iter().find(|w| w.id == id).cloned() else { return }; + let active = state.wm.borrow().current_workspace() == id; + handle.name(w.name); + // 1D: srdwm numbers workspaces without any geometric/grid arrangement + // - see this event's own doc comment on why a flat index is the + // correct thing to send here, not a guess at one. + handle.coordinates((id as u32).to_ne_bytes().to_vec()); + handle.state(workspace_state(active)); + handle.capabilities(WorkspaceCapabilities::Activate); + + state.workspace_handles.entry(id).or_default().push(handle); +} + +/// Called after `switch_workspace` actually changes which workspace is +/// current - re-sends `state` (and `done`) for every workspace handle +/// across every bound client, so a switcher's active-workspace highlight +/// follows real changes instead of only ever reflecting whatever was +/// active when each handle was created. Broadcasts to *all* workspaces +/// (not just the old/new pair) since, unlike `foreign_toplevel`'s +/// per-window handle lookup, resolving "which workspace lost `Active`" has +/// no direct handle to key off here - this only fires on a real +/// workspace switch, not on every input event, so the cost is one small +/// loop per switch, not per frame. +pub(crate) fn broadcast_active_workspace(state: &mut CompState) { + let current = state.wm.borrow().current_workspace(); + let ids: Vec<WorkspaceId> = state.workspace_handles.keys().copied().collect(); + for id in ids { + let Some(handles) = state.workspace_handles.get(&id).cloned() else { continue }; + for handle in &handles { + handle.state(workspace_state(id == current)); + } + } + for manager in state.workspace_managers.clone() { + manager.done(); + } +} + +/// Calls `broadcast_active_workspace` only when the current workspace has +/// actually changed since the last broadcast (by *any* means), called once +/// per frame from `CompState::tick_dirty_broadcasts`. +/// +/// `input.rs`'s `SUPER+scroll` cycle gesture and this protocol's own +/// `activate` request both already call `broadcast_active_workspace` +/// directly. The gap, same shape and same root cause as `foreign_toplevel:: +/// broadcast_dirty_state`'s (see its doc comment): the Lua `srd.workspace. +/// next()`/`.prev()`/`.switch()` API changes `WindowManager` state through +/// `crates/config`, which has no reachable path to this Wayland-specific +/// module, so a switch driven from a keybinding went stale in a dock's +/// workspace pill until some unrelated scroll or `activate` request +/// happened to resync it. `broadcast_active_workspace` itself is not safe +/// to call unconditionally every frame - it always re-sends `state` to +/// every handle and `done` to every manager, real protocol traffic, not +/// just a cheap comparison - so this gates it on an actual change first. +pub(crate) fn broadcast_dirty_active(state: &mut CompState) { + let current = state.wm.borrow().current_workspace(); + if state.last_broadcast_workspace == Some(current) { + return; + } + state.last_broadcast_workspace = Some(current); + broadcast_active_workspace(state); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn only_the_active_workspace_gets_the_active_bit() { + assert_eq!(workspace_state(true), State::Active); + assert_eq!(workspace_state(false), State::empty()); + } +} |