diff options
Diffstat (limited to 'crates/wayland/src/foreign_toplevel.rs')
| -rw-r--r-- | crates/wayland/src/foreign_toplevel.rs | 379 |
1 files changed, 379 insertions, 0 deletions
diff --git a/crates/wayland/src/foreign_toplevel.rs b/crates/wayland/src/foreign_toplevel.rs new file mode 100644 index 0000000..c6913a5 --- /dev/null +++ b/crates/wayland/src/foreign_toplevel.rs @@ -0,0 +1,379 @@ +//! `wlr-foreign-toplevel-management-unstable-v1`: enumerates every managed +//! window (title, app_id, activated/maximized/minimized state) to any +//! client that binds it, and lets that client request activation, close, +//! maximize/minimize - what a dock or taskbar needs to be interactive +//! rather than just a launcher. Requested by AGS's dock (see +//! `docs/PANEL_SUPPORT_TODO.md`'s P1 list): without it there is no running- +//! app indicator, no click-to-focus in the dock, no alt-tab list. +//! +//! Chose this over the newer split `ext_foreign_toplevel_list_v1` (list +//! only) + a separate management protocol: the wlr protocol covers +//! enumeration *and* activate/close/maximize/minimize in one interface, and +//! it is what was actually asked for - smithay 0.7 has no built-in helper +//! for either shape, but has one for the `ext_` list-only half, which would +//! have meant standing up two protocols to get what this one already does +//! alone. Hand-written against the raw `wayland-protocols-wlr` server +//! bindings, same reasoning and the same crate as `screencopy.rs`. +//! +//! One `ZwlrForeignToplevelHandleV1` is created per (bound manager, window) +//! pair - a dock running in one client only ever sees the handles that +//! client's own manager object created, so `CompState` tracks a `Vec` of +//! live handles per `WindowId` to broadcast state changes (activation, +//! maximized, minimized, fullscreen) to every one of them, from whichever +//! call site actually changed that state - this module's own requests, +//! the pointer-driven titlebar handlers, or a client's own +//! `xdg_toplevel`/X11 request. See `update_activated`'s doc comment for +//! the one trigger that still doesn't re-broadcast. +//! +//! Override-redirect X11 windows are deliberately never announced here, +//! matching `_NET_CLIENT_LIST` and ICCCM: a dock has no business listing +//! menus/tooltips/drag images as if they were real applications. + +use smithay::reexports::wayland_server::protocol::wl_surface::WlSurface; +use smithay::reexports::wayland_server::{Client, DataInit, Dispatch, DisplayHandle, GlobalDispatch, New, Resource}; +use wayland_protocols_wlr::foreign_toplevel::v1::server::zwlr_foreign_toplevel_handle_v1::{self, State, ZwlrForeignToplevelHandleV1}; +use wayland_protocols_wlr::foreign_toplevel::v1::server::zwlr_foreign_toplevel_manager_v1::{self, ZwlrForeignToplevelManagerV1}; + +use srdwm_core::WindowId; + +use crate::input::{close_dwindow, focus_window}; +use crate::state::CompState; + +const PROTOCOL_VERSION: u32 = 3; + +pub struct ForeignToplevelState { + _global: smithay::reexports::wayland_server::backend::GlobalId, +} + +impl ForeignToplevelState { + pub fn new<D>(dh: &DisplayHandle) -> Self + where + D: GlobalDispatch<ZwlrForeignToplevelManagerV1, ()> + 'static, + { + Self { _global: dh.create_global::<D, ZwlrForeignToplevelManagerV1, _>(PROTOCOL_VERSION, ()) } + } +} + +/// Per-handle data: which window this specific `ZwlrForeignToplevelHandleV1` +/// (one of possibly several, one per bound manager) speaks for. +pub struct ToplevelHandleData { + window: WindowId, +} + +impl GlobalDispatch<ZwlrForeignToplevelManagerV1, ()> for CompState { + fn bind(state: &mut Self, dh: &DisplayHandle, client: &Client, manager: New<ZwlrForeignToplevelManagerV1>, _data: &(), data_init: &mut DataInit<'_, Self>) { + let manager = data_init.init(manager, ()); + // Catch this client's dock/switcher up on every window that already + // exists - without this, only windows opened *after* the client + // bound the global would ever be announced. + let existing: Vec<WindowId> = state.wm.borrow().windows().map(|w| w.id).collect(); + for id in existing { + announce(state, &manager, id, client, dh); + } + state.foreign_toplevel_managers.push(manager); + } +} + +impl Dispatch<ZwlrForeignToplevelManagerV1, ()> for CompState { + fn request(state: &mut Self, _client: &Client, manager: &ZwlrForeignToplevelManagerV1, request: zwlr_foreign_toplevel_manager_v1::Request, _data: &(), _dh: &DisplayHandle, _data_init: &mut DataInit<'_, Self>) { + if let zwlr_foreign_toplevel_manager_v1::Request::Stop = request { + manager.finished(); + state.foreign_toplevel_managers.retain(|m| m != manager); + } + } + + fn destroyed(state: &mut Self, _client: smithay::reexports::wayland_server::backend::ClientId, manager: &ZwlrForeignToplevelManagerV1, _data: &()) { + state.foreign_toplevel_managers.retain(|m| m != manager); + } +} + +impl Dispatch<ZwlrForeignToplevelHandleV1, ToplevelHandleData> for CompState { + fn request( + state: &mut Self, + _client: &Client, + _handle: &ZwlrForeignToplevelHandleV1, + request: zwlr_foreign_toplevel_handle_v1::Request, + data: &ToplevelHandleData, + _dh: &DisplayHandle, + _data_init: &mut DataInit<'_, Self>, + ) { + let id = data.window; + // Every branch that actually changes state re-announces it + // (`send_state`) rather than waiting for some other path to notice + // - `activate`/`close` are the two the dock actually needs (per + // `docs/PANEL_SUPPORT_TODO.md`), the rest are handled for protocol + // completeness since a dock is free to send them (a maximize + // context-menu entry, say) even if AGS's dock doesn't today. + match request { + zwlr_foreign_toplevel_handle_v1::Request::Activate { seat: _ } => { + // No per-seat distinction - this compositor has exactly + // one seat, same simplification `srd.window.focus()` and + // every other single-window-target API already makes. + focus_window(state, id); + } + zwlr_foreign_toplevel_handle_v1::Request::Close => { + if let Some(w) = state.id_to_window.get(&id) { + close_dwindow(w); + } + } + zwlr_foreign_toplevel_handle_v1::Request::SetMaximized => set_maximized(state, id, true), + zwlr_foreign_toplevel_handle_v1::Request::UnsetMaximized => set_maximized(state, id, false), + zwlr_foreign_toplevel_handle_v1::Request::SetMinimized => set_minimized(state, id, true), + zwlr_foreign_toplevel_handle_v1::Request::UnsetMinimized => set_minimized(state, id, false), + // `output` is a hint only (per the protocol doc: "only a hint to + // the compositor") - ignored, same as `fullscreen_request`'s + // `_output` in `protocols.rs`, and for the same reason: single- + // seat, single-fullscreen-path, matching every other fullscreen + // entry point instead of introducing an output-aware one just + // for this request. + zwlr_foreign_toplevel_handle_v1::Request::SetFullscreen { output: _ } => set_fullscreen(state, id, true), + zwlr_foreign_toplevel_handle_v1::Request::UnsetFullscreen => set_fullscreen(state, id, false), + // The rectangle hint and Destroy: no srdwm-side effect. The + // rectangle is an optional hint we're not obligated to act on; + // `Destroy` just ends this protocol object, handled by + // `destroyed` below. + _ => {} + } + } + + fn destroyed(state: &mut Self, _client: smithay::reexports::wayland_server::backend::ClientId, handle: &ZwlrForeignToplevelHandleV1, data: &ToplevelHandleData) { + if let Some(handles) = state.foreign_toplevel_handles.get_mut(&data.window) { + handles.retain(|h| h != handle); + } + } +} + +fn set_maximized(state: &mut CompState, id: WindowId, maximized: bool) { + let already = state.wm.borrow().window(id).is_some_and(|w| w.maximized); + if already != maximized { + state.wm.borrow_mut().toggle_maximize(id); + state.sync_geometry(id); + } + send_state(state, id); +} + +fn set_minimized(state: &mut CompState, id: WindowId, minimized: bool) { + let mut wm = state.wm.borrow_mut(); + if minimized { + wm.minimize_window(id); + } else { + wm.restore_window(id); + } + drop(wm); + send_state(state, id); +} + +/// Same `redraw_decoration_buffer`-before-`sync_geometry` ordering as every +/// other fullscreen entry point (`protocols.rs`'s `fullscreen_request`, +/// `xwayland.rs`'s equivalent): `toggle_fullscreen` also flips +/// `Window.decorated`, and the decoration buffer needs to actually be +/// dropped, not just left stale for `sync_geometry`'s resize-only redraw +/// check to skip. +fn set_fullscreen(state: &mut CompState, id: WindowId, fullscreen: bool) { + let already = state.wm.borrow().is_fullscreen(id); + if already != fullscreen { + state.wm.borrow_mut().toggle_fullscreen(id); + state.redraw_decoration_buffer(id); + state.sync_geometry(id); + } + send_state(state, id); +} + +/// Creates and sends a new handle for `id` to `manager`, with its full +/// initial state (title, app_id, activated/maximized/minimized, done) -- +/// the sequence `new_toplevel`'s own doc comment requires: "all initial +/// details... will be sent immediately after this event". +fn announce(state: &mut CompState, manager: &ZwlrForeignToplevelManagerV1, id: WindowId, client: &Client, dh: &DisplayHandle) { + let Ok(handle) = client.create_resource::<ZwlrForeignToplevelHandleV1, ToplevelHandleData, CompState>(dh, manager.version(), ToplevelHandleData { window: id }) else { + return; + }; + manager.toplevel(&handle); + state.foreign_toplevel_handles.entry(id).or_default().push(handle.clone()); + send_state_to(state, id, &[handle]); +} + +/// Re-sends title/app_id/state/done to every handle a window currently has +/// - one per bound manager. Used both right after `announce` creates a +/// fresh handle and whenever state actually changes (`set_maximized`, +/// `update_activated`). +pub(crate) fn send_state(state: &mut CompState, id: WindowId) { + let Some(handles) = state.foreign_toplevel_handles.get(&id).cloned() else { return }; + send_state_to(state, id, &handles); +} + +fn send_state_to(state: &mut CompState, id: WindowId, handles: &[ZwlrForeignToplevelHandleV1]) { + let Some(w) = state.wm.borrow().window(id).cloned() else { return }; + let focused = state.wm.borrow().focused_id() == Some(id); + let fullscreen = state.wm.borrow().is_fullscreen(id); + let bytes = state_flags(w.maximized, w.minimized, focused, fullscreen); + + for handle in handles { + handle.title(w.title.clone()); + handle.app_id(w.app_id.clone()); + handle.state(bytes.clone()); + handle.done(); + } +} + +/// Packs the flags a window currently has into the wire format +/// `zwlr_foreign_toplevel_handle_v1.state` expects: the raw bytes of +/// however many native-endian u32 enum values apply, back to back. Pulled +/// out of `send_state_to` as the one piece of this module that's pure +/// logic rather than protocol I/O, so it's unit-testable the way +/// `decoration.rs`'s rasterizers are. +fn state_flags(maximized: bool, minimized: bool, activated: bool, fullscreen: bool) -> Vec<u8> { + let mut flags = Vec::new(); + if maximized { + flags.push(State::Maximized as u32); + } + if minimized { + flags.push(State::Minimized as u32); + } + if activated { + flags.push(State::Activated as u32); + } + if fullscreen { + flags.push(State::Fullscreen as u32); + } + let mut bytes = Vec::with_capacity(flags.len() * 4); + for f in flags { + bytes.extend_from_slice(&f.to_ne_bytes()); + } + bytes +} + +/// Called from `new_managed_window`/`finish_x11_window_setup`. Announces +/// the new window to every currently-bound manager - `GlobalDispatch::bind` +/// above only covers managers that bind *before* a window exists; this is +/// the other half, for ones that were already bound. +pub(crate) fn window_created(state: &mut CompState, id: WindowId) { + let managers = state.foreign_toplevel_managers.clone(); + for manager in &managers { + let Some(client) = manager.client() else { continue }; + let dh = state.dh.clone(); + announce(state, manager, id, &client, &dh); + } +} + +/// Called from the native/X11 `remove_window` paths. Every real Wayland +/// compositor's foreign-toplevel implementation must send `closed` before +/// a handle becomes unusable - skipping it would leave a dock's entry for +/// this window dangling with no signal to ever remove it. +pub(crate) fn window_closed(state: &mut CompState, id: WindowId) { + if let Some(handles) = state.foreign_toplevel_handles.remove(&id) { + for handle in handles { + handle.closed(); + } + } +} + +/// Called from `set_keyboard_focus`, the single chokepoint every focus +/// change already goes through - re-announces state for whichever window +/// lost `Activated` and whichever gained it, so a dock's focused-app +/// highlight tracks real focus instead of only ever reflecting whatever was +/// focused at creation time. +/// +/// Only `Activated` is kept live this way - maximized/minimized/fullscreen +/// changes are re-broadcast from their own call sites instead (the pointer- +/// driven titlebar handlers in `input.rs`, and the client-request handlers +/// in `protocols.rs`/`xwayland.rs`, all call `send_state` directly). The one +/// trigger that still doesn't re-broadcast is a *compositor keybinding* +/// (`srd.window.maximize()`/`.fullscreen()`/`.minimize()`): `crates/config` +/// only holds a `WindowManager` reference, not `CompState`, so it has no way +/// to reach this module. See `docs/PANEL_SUPPORT_TODO.md`'s P1 section. +pub(crate) fn update_activated(state: &mut CompState, old: Option<WlSurface>, new: Option<&WlSurface>) { + let old_id = old.as_ref().and_then(|s| window_id_for_surface(state, s)); + let new_id = new.and_then(|s| window_id_for_surface(state, s)); + if old_id == new_id { + return; + } + if let Some(id) = old_id { + send_state(state, id); + } + if let Some(id) = new_id { + send_state(state, id); + } +} + +fn window_id_for_surface(state: &CompState, surface: &WlSurface) -> Option<WindowId> { + state.surface_to_id.get(surface).copied() +} + +/// Diff-broadcasts maximized/minimized/fullscreen against what was last +/// sent for each window, called once per frame from `CompState:: +/// tick_dirty_broadcasts` (same poll-loop cadence as `tick_animations`). +/// +/// Every *known* trigger already re-broadcasts immediately at its own call +/// site: this module's own requests (`set_maximized`/`set_minimized`/ +/// `set_fullscreen`), the pointer-driven titlebar handlers in `input.rs`, +/// and a client's own `xdg_toplevel`/X11 request in `protocols.rs`/ +/// `xwayland.rs`. The one trigger documented as a real gap in +/// `docs/PANEL_SUPPORT_TODO.md` is a *compositor keybinding* +/// (`srd.window.maximize()`/`.fullscreen()`/`.minimize()`): `crates/config` +/// is the platform-agnostic scripting engine, shared with the X11/macOS +/// backends, and only ever holds a `WindowManager` reference - it cannot +/// reach this Wayland-protocol-specific module directly, so state it +/// changes went stale in every dock/panel until *something else* +/// coincidentally re-broadcast that window. Rather than thread a callback +/// through `WindowManager` for every future call site that might change +/// this state (and inevitably miss the next one), this diffs against +/// `WindowManager`'s live state once a frame and catches all of them +/// uniformly, including ones that don't exist yet. +/// +/// One accepted redundancy: a brand-new window has no prior entry in +/// `last_broadcast_flags`, so its first tick after creation sends one +/// extra (identical) `send_state` on top of the one `window_created` +/// already sent - harmless per-window, one-time, and far simpler than +/// seeding the cache at creation just to suppress it. +pub(crate) fn broadcast_dirty_state(state: &mut CompState) { + let current: Vec<(WindowId, bool, bool, bool)> = + state.wm.borrow().windows().map(|w| (w.id, w.maximized, w.minimized, w.fullscreen)).collect(); + let mut changed = Vec::new(); + for (id, maximized, minimized, fullscreen) in ¤t { + let key = (*maximized, *minimized, *fullscreen); + if state.last_broadcast_flags.get(id) != Some(&key) { + state.last_broadcast_flags.insert(*id, key); + changed.push(*id); + } + } + // Drops entries for windows that no longer exist - `window_closed` + // already tells clients the handle itself is gone; this just stops the + // map growing forever across the life of a session. + let live: std::collections::HashSet<WindowId> = current.iter().map(|(id, ..)| *id).collect(); + state.last_broadcast_flags.retain(|id, _| live.contains(id)); + for id in changed { + send_state(state, id); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn as_u32s(bytes: &[u8]) -> Vec<u32> { + bytes.chunks_exact(4).map(|c| u32::from_ne_bytes(c.try_into().unwrap())).collect() + } + + #[test] + fn no_flags_is_an_empty_array() { + assert_eq!(state_flags(false, false, false, false), Vec::<u8>::new()); + } + + #[test] + fn each_flag_packs_its_own_enum_value() { + assert_eq!(as_u32s(&state_flags(true, false, false, false)), vec![State::Maximized as u32]); + assert_eq!(as_u32s(&state_flags(false, true, false, false)), vec![State::Minimized as u32]); + assert_eq!(as_u32s(&state_flags(false, false, true, false)), vec![State::Activated as u32]); + assert_eq!(as_u32s(&state_flags(false, false, false, true)), vec![State::Fullscreen as u32]); + } + + #[test] + fn all_flags_combine_in_one_array() { + let values = as_u32s(&state_flags(true, true, true, true)); + assert_eq!(values.len(), 4); + assert!(values.contains(&(State::Maximized as u32))); + assert!(values.contains(&(State::Minimized as u32))); + assert!(values.contains(&(State::Activated as u32))); + assert!(values.contains(&(State::Fullscreen as u32))); + } +} |