From 1c175642d073689ca11b9252411ea5f8446007d0 Mon Sep 17 00:00:00 2001 From: srdusr <99972264+srdusr@users.noreply.github.com> Date: Thu, 30 May 2024 16:10:00 +0200 Subject: 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). --- crates/wayland/src/input.rs | 299 ++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 291 insertions(+), 8 deletions(-) (limited to 'crates/wayland/src/input.rs') diff --git a/crates/wayland/src/input.rs b/crates/wayland/src/input.rs index 17e94d4..264c986 100644 --- a/crates/wayland/src/input.rs +++ b/crates/wayland/src/input.rs @@ -17,6 +17,7 @@ use smithay::input::pointer::{ButtonEvent, MotionEvent}; use smithay::reexports::wayland_server::protocol::wl_surface::WlSurface; use smithay::utils::{Logical, Point, SERIAL_COUNTER}; use smithay::wayland::shell::wlr_layer::{KeyboardInteractivity, Layer}; +use std::time::{Duration, Instant}; use srdwm_core::{Event as CoreEvent, Modifiers, TitlebarHit, WindowId}; @@ -41,12 +42,28 @@ pub(crate) fn last_pointer_pos(state: &CompState) -> Point { /// `pos` is in the global space; layer geometry is relative to its own /// output, so the pointer is translated into output-local coordinates /// before hit-testing and the result translated back out. -pub(crate) fn layer_surface_under(state: &CompState, pos: Point) -> Option<(WlSurface, Point)> { +/// Only checked for `Overlay`/`Top` before a window hit-test, and again for +/// `Bottom`/`Background` after one comes up empty - see the two call +/// sites in `handle_pointer_button`/`handle_pointer_position` for why it's +/// split rather than one four-layer loop here. A `Bottom`/`Background` +/// surface (a desktop-icons layer, a wallpaper daemon that wants clicks) is +/// meant to sit *behind* normal windows, so a window covering that point +/// should still get the click; `Overlay`/`Top` (an on-screen keyboard, a +/// bar, a dock) are meant to sit in front of everything, windows included. +/// +/// Was `Overlay`/`Top` only, full stop - a `Bottom`-layer surface was +/// silently unclickable no matter what, since nothing else in +/// `handle_pointer_button` ever checked layers at all. Not the cause of +/// the live "clicking the dock does nothing" report (confirmed: that dock +/// uses `Layer::Top`, which was already checked), but a real, separate gap +/// found while chasing it - worth closing regardless of whether anything +/// currently deployed sits at `Bottom`/`Background` yet. +pub(crate) fn layer_surface_under_layers(state: &CompState, pos: Point, layers: [Layer; 2]) -> Option<(WlSurface, Point)> { let entry = state.output_at(pos)?; let origin = entry.location; let local = pos - origin.to_f64(); let map = layer_map_for_output(&entry.output); - for layer_kind in [Layer::Overlay, Layer::Top] { + for layer_kind in layers { let Some(layer) = map.layer_under(layer_kind, local) else { continue }; let Some(geo) = map.layer_geometry(layer) else { continue }; if let Some((surface, surface_loc)) = layer.surface_under(local - geo.loc.to_f64(), WindowSurfaceType::ALL) { @@ -56,7 +73,49 @@ pub(crate) fn layer_surface_under(state: &CompState, pos: Point) - None } +pub(crate) fn layer_surface_under(state: &CompState, pos: Point) -> Option<(WlSurface, Point)> { + layer_surface_under_layers(state, pos, [Layer::Overlay, Layer::Top]) +} + +/// The `Bottom`/`Background` half of the same lookup - see +/// `layer_surface_under_layers`'s doc comment for the ordering rationale. +pub(crate) fn background_layer_surface_under(state: &CompState, pos: Point) -> Option<(WlSurface, Point)> { + layer_surface_under_layers(state, pos, [Layer::Bottom, Layer::Background]) +} + +/// `ext_idle_notify_v1`'s whole job is answering "has the user touched an +/// input device recently" - `IdleNotifierState` does the actual timer +/// bookkeeping (see its own doc comment), this just has to be called from +/// every real input path, deliberately including while the session is +/// locked: idle activity is about the seat, not about which surface (if +/// any) an event ends up delivered to, and a lock daemon watching this +/// protocol to decide when to re-dim/re-lock still needs to see real +/// movement even though nothing else happens with it at a locked screen. +/// +/// Throttled to once per 250ms: `notify_activity` removes and re-inserts a +/// calloop timer for every live notification, every call, with no +/// throttling of its own - fine at keypress/click frequency, but pointer +/// motion can fire far more often than that during a drag, and idle +/// timeouts are measured in minutes, not milliseconds, so nothing about +/// idle detection needs - or can even perceive - finer resolution than +/// this. The same class of hot-path-on-every-motion-event cost that made +/// this session's earlier diagnostic logging a real, measured regression +/// (see `docs/IMPLEMENTATION_STATUS.md`), just cheap enough here (in-memory +/// bookkeeping, not synchronous I/O) that throttling rather than removing +/// it outright is the right amount of caution. +fn notify_idle_activity(state: &mut CompState) { + const THROTTLE: Duration = Duration::from_millis(250); + let now = Instant::now(); + if state.last_idle_notify.is_some_and(|last| now.duration_since(last) < THROTTLE) { + return; + } + state.last_idle_notify = Some(now); + let seat = state.seat.clone(); + state.idle_notifier_state.notify_activity(&seat); +} + pub(crate) fn handle_pointer_position(state: &mut CompState, pos: Point, time: u32) { + notify_idle_activity(state); // Locked: pointer motion goes to the lock surface only. No hit-testing // against windows/decorations, so no hover, no drag, no resize. if state.lock.locked { @@ -64,13 +123,16 @@ pub(crate) fn handle_pointer_position(state: &mut CompState, pos: Point, over_layer_surface: bool, over_content: bool) { + use smithay::input::pointer::{CursorIcon, CursorImageStatus}; + + if over_layer_surface { + return; + } + let edge = match hit { + Some((_, TitlebarHit::Resize(edge))) => Some(edge), + _ => state.wm.borrow().resize_edge(), + }; + let icon = match edge { + Some(edge) => resize_cursor_icon(edge), + // Hovering our own decoration but not an edge (the drag area, a + // button) and not actively resizing: back to the plain arrow. + None if hit.is_some() => CursorIcon::Default, + // Over a client's own content: leave `cursor_status` alone, per the + // doc comment above - the client drives it. + None if over_content => return, + // Bare desktop: nothing else will ever reset this, so we have to. + None => CursorIcon::Default, + }; + state.cursor_status = CursorImageStatus::Named(icon); +} + +fn resize_cursor_icon(edge: srdwm_core::ResizeEdge) -> smithay::input::pointer::CursorIcon { + use smithay::input::pointer::CursorIcon; + use srdwm_core::ResizeEdge; + match edge { + ResizeEdge::Left | ResizeEdge::Right => CursorIcon::EwResize, + ResizeEdge::Top | ResizeEdge::Bottom => CursorIcon::NsResize, + ResizeEdge::TopLeft | ResizeEdge::BottomRight => CursorIcon::NwseResize, + ResizeEdge::TopRight | ResizeEdge::BottomLeft => CursorIcon::NeswResize, + } +} + /// The underlying `wl_surface` for a mapped window, regardless of whether /// it's a native `xdg-shell` toplevel or an XWayland `X11Surface` -- /// `desktop::Window` exposes these as two separate accessors with no @@ -137,9 +298,33 @@ pub(crate) fn focus_window(state: &mut CompState, id: WindowId) { state.set_keyboard_focus(surface); } +/// Re-syncs real Wayland/X11 keyboard focus to whatever `WindowManager` +/// already considers focused, without changing what that is. +/// +/// For callers where core's own focus already moved on its own -- +/// specifically `WindowManager::remove_window`'s fallback to +/// `self.order.last()` when the just-closed window was the focused one -- +/// and only the Wayland/X11 side needs to catch up to it. Without this, the +/// window core now considers focused (and renders as such) never actually +/// receives a keystroke until it's clicked, since nothing told +/// `set_keyboard_focus` focus had moved. +/// +/// `focus_window` above is for the opposite direction: driving core's +/// focus deliberately (a click, a keybinding) and syncing outward from +/// that. This is "core already decided, catch the rest of the compositor +/// up" - `wm.focus_window` must not be called again here, since the id +/// core picked (or `None`, if nothing is left) is exactly what should win. +pub(crate) fn sync_keyboard_focus(state: &mut CompState) { + let focused = state.wm.borrow().focused_id(); + let surface = focused.and_then(|id| state.id_to_window.get(&id)).and_then(dwindow_wl_surface); + state.set_keyboard_focus(surface); +} + pub(crate) fn handle_pointer_button(state: &mut CompState, pos: Point, button: u32, pressed: bool, time: u32) { + notify_idle_activity(state); const BTN_LEFT: u32 = 0x110; const BTN_RIGHT: u32 = 0x111; + const BTN_MIDDLE: u32 = 0x112; let serial = SERIAL_COUNTER.next_serial(); // Locked: forward the click to the lock surface (it may have a button or @@ -148,10 +333,31 @@ pub(crate) fn handle_pointer_button(state: &mut CompState, pos: Point { state.wm.borrow_mut().toggle_maximize(id); state.sync_geometry(id); + crate::foreign_toplevel::send_state(state, id); + } + TitlebarHit::Minimize => { + state.wm.borrow_mut().minimize_window(id); + crate::foreign_toplevel::send_state(state, id); } - TitlebarHit::Minimize => state.wm.borrow_mut().minimize_window(id), TitlebarHit::Resize(edge) => state.wm.borrow_mut().start_resize(id, edge, pos.x as i32, pos.y as i32), } } else if layer_hit.is_none() { @@ -240,18 +451,73 @@ pub(crate) fn handle_pointer_button(state: &mut CompState, pos: Point>(state: &mut CompState, event: &E) { + notify_idle_activity(state); let keycode = event.key_code(); let key_state = event.state(); let time = event.time_msec(); @@ -340,6 +607,7 @@ where { use smithay::backend::input::Axis; + notify_idle_activity(state); if state.lock.locked { return false; } @@ -358,5 +626,20 @@ where // Scrolling down (positive) advances, matching `workspace, e+1`. let next = if v > 0.0 { (current + 1) % ids.len() } else { (current + ids.len() - 1) % ids.len() }; wm.switch_workspace(ids[next]); + drop(wm); + // Without this, the switch above is invisible: nothing shows or hides + // a single window for the new workspace until `main.rs`'s `sync()` + // runs, which only happens when a polled event sets `dirty` - see + // `srdwm_core::Event::WorkspaceChanged`'s doc comment. Found live- + // testing the unrelated `ext_workspace_v1` protocol's own `activate` + // request, which has the identical problem; this gesture had the exact + // same bug already, just never one anyone traced back this far. + state.pending.borrow_mut().push(srdwm_core::Event::WorkspaceChanged); + // Same reasoning as `foreign_toplevel::send_state`'s call sites: without + // this, a dock's workspace pill only ever tracked switches driven + // through `ext_workspace_handle_v1.activate` itself, going stale the + // moment this gesture (or any other non-protocol trigger) changed the + // active workspace instead. + crate::workspace::broadcast_active_workspace(state); true } -- cgit v1.2.3