diff options
Diffstat (limited to 'crates/wayland/src/udev')
| -rw-r--r-- | crates/wayland/src/udev/mod.rs | 17 | ||||
| -rw-r--r-- | crates/wayland/src/udev/outputs.rs | 54 | ||||
| -rw-r--r-- | crates/wayland/src/udev/platform.rs | 165 | ||||
| -rw-r--r-- | crates/wayland/src/udev/render.rs | 70 | ||||
| -rw-r--r-- | crates/wayland/src/udev/session.rs | 7 |
5 files changed, 303 insertions, 10 deletions
diff --git a/crates/wayland/src/udev/mod.rs b/crates/wayland/src/udev/mod.rs index d22666d..27a7c2c 100644 --- a/crates/wayland/src/udev/mod.rs +++ b/crates/wayland/src/udev/mod.rs @@ -204,6 +204,21 @@ pub(crate) struct UdevState { /// Pointer position in the *global* space, so it can cross between /// monitors; clamped to the union of all head rectangles. pub(crate) pointer_pos: Point<f64, Logical>, + /// Multi-cursor mode, Phase 1: every physical pointer/trackpad's own + /// last-known position, keyed by its real libinput device identity + /// (`smithay::backend::input::Event::device()`, confirmed `Device: + /// PartialEq + Eq + Hash` by reading smithay's own trait definition). + /// Purely a *visual* addition - `pointer_pos` above is still the one + /// position that actually drives clicks/drags/hit-testing, updated by + /// whichever device moved most recently exactly as before, so nothing + /// about existing interactive behaviour changes. This is what lets a + /// mouse and a trackpad each show their own live cursor sprite instead + /// of only the most-recently-moved device having a visible pointer at + /// all - see `docs/TODO.md`'s "Multi-cursor" plan for what later + /// phases would still need (per-device *interaction*, not just + /// per-device *rendering*, and the real `wl_seat` ecosystem wall a + /// second seat runs into for arbitrary client content). + pub(crate) secondary_cursors: HashMap<smithay::reexports::input::Device, Point<f64, Logical>>, /// A clone of the same `LibSeatSession` `platform.rs` opened the DRM /// device with (`LibSeatSession` is cheaply `Clone` - see its own /// derive - all clones share the same underlying seat connection). @@ -267,7 +282,7 @@ impl UdevState { /// this from outside, but srdwm's own pointer clamp assuming an origin /// no other part of this backend actually enforces is the real bug -- /// fixed here instead of just left for every future caller to avoid. - fn bounds(&self) -> (f64, f64, f64, f64) { + pub(crate) fn bounds(&self) -> (f64, f64, f64, f64) { bounds_of(self.heads.iter().map(|h| (h.location.x, h.location.y, h.size.0, h.size.1))) } } diff --git a/crates/wayland/src/udev/outputs.rs b/crates/wayland/src/udev/outputs.rs index c3291d8..a0169a4 100644 --- a/crates/wayland/src/udev/outputs.rs +++ b/crates/wayland/src/udev/outputs.rs @@ -371,7 +371,7 @@ impl CompState { head.output.change_current_state(None, None, None, Some((x_logical, 0).into())); placed.push((head.output.clone(), head.location)); x_physical += head.size.0; - x_logical += (head.size.0 as f64 / scale).round() as i32; + x_logical = next_logical_x(x_logical, head.size.0, scale); } for (output, location) in placed { if let Some(entry) = self.outputs.iter_mut().find(|e| e.output == output) { @@ -385,3 +385,55 @@ impl CompState { } } +/// The actual arithmetic behind [`CompState::relayout_outputs`]'s logical-x +/// accumulation - pulled out so it's testable without a real `Output`/DRM +/// head, the same reasoning `udev/mod.rs::bounds_of` already applies to +/// `UdevState::bounds`. Takes the previous head's own resulting logical x, +/// this head's physical width, and this head's fractional scale; returns +/// the *next* head's logical x. +fn next_logical_x(prev_logical_x: i32, physical_width: i32, scale: f64) -> i32 { + prev_logical_x + (physical_width as f64 / scale).round() as i32 +} + +#[cfg(test)] +mod relayout_tests { + use super::next_logical_x; + + /// The exact scenario this function exists to fix, using the exact + /// figures a peer session measured live from inside GTK + /// (`Gdk.Display.get_monitors()`) before the fix: `HDMI-A-1` at 1920 + /// physical / ~0.843 scale (2276 logical), `eDP-1` at 1920 physical / + /// 1.0 scale placed after it. The bug this guards against: passing the + /// raw physical accumulator straight into `change_current_state` + /// advertised `eDP-1` at logical x=1920 - inside `HDMI-A-1`'s own + /// logical extent (0..2276), a real, measured ~356px overlap. + #[test] + fn a_sub_one_scale_head_is_not_overrun_by_the_next_heads_logical_x() { + let hdmi_logical_end = next_logical_x(0, 1920, 1920.0 / 2276.0); + assert_eq!(hdmi_logical_end, 2276); + let edp_logical_x = next_logical_x(hdmi_logical_end, 1920, 1.0); + assert!(edp_logical_x >= hdmi_logical_end, "eDP-1 logical x ({edp_logical_x}) must not land inside HDMI-A-1's own logical extent (0..{hdmi_logical_end})"); + assert_eq!(edp_logical_x, 2276 + 1920); + } + + /// Every output at `scale == 1.0` (this machine's actual current, + /// user-chosen configuration - see docs/TODO.md's "HDMI-A-1 forced to + /// scale 1.0" entry) must reduce to plain physical accumulation, byte + /// for byte - this is the case that was already correct before the + /// fix and must stay that way. + #[test] + fn every_output_at_unit_scale_reduces_to_plain_physical_accumulation() { + assert_eq!(next_logical_x(0, 1920, 1.0), 1920); + assert_eq!(next_logical_x(1920, 1920, 1.0), 3840); + } + + /// A scale above 1.0 (a HiDPI output) narrows logical space relative to + /// physical - the next head's logical x must land *before* its own + /// physical offset would suggest, not after. + #[test] + fn a_scale_above_one_narrows_the_next_heads_logical_x() { + let logical_end = next_logical_x(0, 3840, 2.0); + assert_eq!(logical_end, 1920); + } +} + diff --git a/crates/wayland/src/udev/platform.rs b/crates/wayland/src/udev/platform.rs index 6f6cbe8..9023cb9 100644 --- a/crates/wayland/src/udev/platform.rs +++ b/crates/wayland/src/udev/platform.rs @@ -16,6 +16,12 @@ pub struct UdevPlatform { /// Last time the unconditional end-of-cycle `render_udev_frame()` call /// actually ran - see its own call site for why. last_render: Instant, + /// Sticky designation of which connector `monitors()` reports as + /// primary - see that function's own doc comment on `primary_name` + /// for why this can't be recomputed from `udev.heads`' own iteration + /// order every call. `None` until the first `monitors()` call ever + /// runs. + primary_connector: Option<String>, } impl UdevPlatform { @@ -158,6 +164,7 @@ impl UdevPlatform { heads, active: true, pointer_pos: (width as f64 / 2.0, height as f64 / 2.0).into(), + secondary_cursors: std::collections::HashMap::new(), session: session.clone(), disabled_connectors: std::collections::HashSet::new(), last_rendered_workspace: None, @@ -190,6 +197,7 @@ impl UdevPlatform { |_| true, ), _screencopy_state: crate::screencopy::ScreencopyState::new::<CompState>(&display_handle), + _virtual_pointer_state: crate::virtual_pointer::VirtualPointerState::new::<CompState>(&display_handle), screencopy_pending: Vec::new(), _appmenu_state: crate::appmenu::AppmenuManagerState::new::<CompState>(&display_handle), _virtual_keyboard_state: smithay::wayland::virtual_keyboard::VirtualKeyboardManagerState::new::<CompState, _>(&display_handle, |_client| true), @@ -233,6 +241,8 @@ impl UdevPlatform { desktop_icons: None, desktop_icon_buffers: HashMap::new(), desktop_icon_drag: None, + desktop_marquee: None, + marquee_buffers: Default::default(), desktop_menu: None, desktop_menu_buffer: None, last_icon_click: None, @@ -240,6 +250,7 @@ impl UdevPlatform { wm: wm.clone(), surface_to_id: HashMap::new(), id_to_window: HashMap::new(), + virtual_pointers: Vec::new(), dead_layer_surfaces: HashSet::new(), hidden_layer_surfaces: HashMap::new(), layer_surfaces_shown_once: HashSet::new(), @@ -280,6 +291,17 @@ impl UdevPlatform { // the socket a client would need to connect to doesn't exist yet. state.restore_monitor_layout(); + // Per-app remembered window position/size (`window_memory.rs`) -- + // no ordering requirement as strict as the layout restore just + // above (a window can't map before a client connects, and the + // socket isn't even bound yet), but seeded here anyway, at the + // same "before anything else can possibly run" point, so there's + // no window in this compositor's own startup where a first window + // could map before this table is populated. + for (app_id, g) in crate::window_memory::load() { + state.wm.borrow_mut().set_remembered_geometry(app_id, (g.x, g.y, g.width, g.height)); + } + let listener = ListeningSocket::bind_auto("wayland", 0..32).map_err(err)?; if let Some(name) = listener.socket_name() { std::env::set_var("WAYLAND_DISPLAY", name); @@ -326,11 +348,52 @@ impl UdevPlatform { if let Err(e) = register_udev_monitor(&handle, &seat_name) { log::warn!("udev: connector hotplug unavailable ({e}); monitors are fixed at startup"); } - if let Err(e) = crate::xwayland::spawn(&handle, &display_handle) { - log::warn!("XWayland unavailable ({e}); X11-only clients will not run"); - } - - Ok(Self { event_loop, display: dh, state, listener, clients: Vec::new(), pending, ipc, last_ipc_poll: Instant::now(), last_render: Instant::now() }) + // Deferred to the loop's first idle tick, not called here directly. + // This function still runs inside `connect()`, before the caller + // ever calls `event_loop.run()` - so a direct call here forks + // XWayland while nothing is actually dispatching this process's own + // Wayland socket yet. XWayland connects immediately (`WAYLAND_SOCKET` + // is already a live fd, no accept() to wait for) and starts its own + // registry/seat/keyboard handshake right away; if that handshake's + // response - specifically the `wl_keyboard.keymap` event carrying + // this compositor's real `pc105+inet`-derived keymap - doesn't get + // serviced before XWayland's own internal timeout, XWayland falls + // back to compiling a keymap of its own with no real RMLVO behind + // it, which is exactly the "Failed to load keymap. Loading default + // keymap instead" line seen in `xwayland.log` right before "Fatal + // server error: Failed to activate virtual core keyboard: 2" -- + // confirmed to reproduce on every single real startup (53 identical + // crashes across one session's restarts) while an external XWayland + // spawned against this exact same, already-*running* compositor + // (same socket, same keymap, same env-clearing, same `-wm`/ + // `-displayfd` fd-passing - checked by replicating smithay's own + // `XWayland::spawn` byte for byte in a standalone harness) never + // once reproduced it. `insert_idle` runs its callback on the loop's + // own first dispatch pass, which only happens once `event_loop.run` + // is actually pumping this process's sockets - moving the fork + // there closes the exact gap between "child process exists and + // starts talking" and "someone is listening," which nothing else + // about this fix changes. + let handle_for_xwayland = handle.clone(); + let idle_display_handle = display_handle.clone(); + handle.insert_idle(move |_state| { + if let Err(e) = crate::xwayland::spawn(&handle_for_xwayland, &idle_display_handle) { + log::warn!("XWayland unavailable ({e}); X11-only clients will not run"); + } + }); + + Ok(Self { + event_loop, + display: dh, + state, + listener, + clients: Vec::new(), + pending, + ipc, + last_ipc_poll: Instant::now(), + last_render: Instant::now(), + primary_connector: None, + }) } fn accept_clients(&mut self) -> PlatformResult<()> { @@ -550,6 +613,14 @@ impl Platform for UdevPlatform { self.state.disable_connector_by_name(&name); } } + // Applies any `srd dispatch pin input`/`unpin input` IPC requests + // queued since the last poll - Phase 2 of the multi-cursor plan, + // see `virtual_pointer.rs`'s module doc comment and `CompState:: + // set_virtual_pointer_pin`'s own doc comment for the full design. + let pin_requests = self.state.wm.borrow_mut().drain_pin_input_requests(); + for (pid, window) in pin_requests { + self.state.set_virtual_pointer_pin(pid, window); + } // Throttled the same way and for the same underlying reason as the // `ipc.poll()` call above - this is the *other*, larger half of // this cycle's needless work at the dead-pipe-driven spin rate. @@ -597,6 +668,50 @@ impl Platform for UdevPlatform { let wm = wm.borrow(); let mut out = Vec::new(); let mut next_id: u32 = 0; + // Sticky by connector name, not "whichever head is first in `udev. + // heads` this call" - that positional rule looked harmless (heads + // are only ever appended, in probe order, at startup) but + // `enable_connector_by_name` pushes a re-enabled connector back + // onto the *end* of the vec, same as a fresh hotplug - so cycling + // any non-first connector's own enabled state (confirmed live: a + // peer session repeatedly toggling one monitor for unrelated + // testing) never moves it, but disabling the connector that + // currently sits first and re-enabling it does, silently handing + // "primary" to whatever was second. Reported live as this + // session's own desktop icons (pinned to whichever monitor `Platform + // ::monitors()` calls primary) "sometimes showing on the other + // monitor" with no action anyone took that looked related. Once a + // primary connector name is chosen, it keeps that designation + // across every later call as long as it's still connected -- + // falling back to the first head only when it genuinely isn't + // (unplugged, or the very first call this process ever makes). + // The *first* fallback pick (when nothing is sticky yet) used to be + // `udev.heads.first()` - whichever connector DRM happened to probe + // first, which has no relationship to the user's actual layout. + // Reported live on this machine: with an "extend left" saved layout + // (external monitor at negative x, laptop panel at x=0), the + // external monitor still got "primary" at boot whenever it happened + // to probe before the panel, dragging desktop icons and every + // primary-monitor-anchored window placement onto it - exactly the + // "apps open on the wrong monitor" and "icons not showing" symptoms + // reported live, on the very first call this process ever makes, + // before stickiness has anything to preserve. `relayout_outputs`/ + // `output_management::apply_output_position` already keep the + // user's actual anchor monitor at physical `(0, 0)` - that IS the + // position-based definition of "primary" every desktop convention + // (xrandr, wlr-output-management) already uses, and unlike + // enumeration order it's driven by the same saved layout the user + // configured. Preferred over the origin-search only as the initial + // pick; once chosen, `primary_connector` stays sticky exactly as + // before, so a later `relayout_outputs` call temporarily putting a + // different head at `(0, 0)` mid-drag doesn't itself flip primary. + let primary_name = self + .primary_connector + .clone() + .filter(|name| udev.heads.iter().any(|h| &h.output.name() == name)) + .or_else(|| udev.heads.iter().find(|h| h.location == Point::from((0, 0))).map(|h| h.output.name())) + .or_else(|| udev.heads.first().map(|h| h.output.name())); + self.primary_connector = primary_name.clone(); for head in udev.heads.iter() { // Shrunk by whatever a layer-shell surface (bar, dock) has // reserved via `set_exclusive_zone` - reporting the full @@ -630,12 +745,48 @@ impl Platform for UdevPlatform { let zone = layer_map_for_output(&head.output).non_exclusive_zone(); let scale = head.output.current_scale().fractional_scale(); let zone_physical = |v: i32| (v as f64 * scale).round() as i32; - let usable = srdwm_core::Rect::new( + let mut usable = srdwm_core::Rect::new( head.location.x + zone_physical(zone.loc.x), head.location.y + zone_physical(zone.loc.y), zone_physical(zone.size.w).max(0) as u32, zone_physical(zone.size.h).max(0) as u32, ); + // `general.reserve_top`/`_bottom`/`_left`/`_right` - a static + // floor under the real exclusive zone above, not a competing + // claim: only shrinks `usable` further if the configured + // reservation is *larger* than what's already reserved for + // that edge, so a real bar/dock that has actually connected + // and registered its own (equal or bigger) zone always wins. + // See `WindowManager::reserve_top`'s own doc comment for the + // startup-race this exists to close. + let (rt, rb, rl, rr) = + (zone_physical(wm.reserve_top as i32), zone_physical(wm.reserve_bottom as i32), zone_physical(wm.reserve_left as i32), zone_physical(wm.reserve_right as i32)); + let full_top = head.location.y; + let full_left = head.location.x; + let full_bottom = head.location.y + head.size.1; + let full_right = head.location.x + head.size.0; + let want_top = full_top + rt; + let want_left = full_left + rl; + let want_bottom = full_bottom - rb; + let want_right = full_right - rr; + if want_top > usable.y { + let shrink = want_top - usable.y; + usable.y = want_top; + usable.height = usable.height.saturating_sub(shrink.max(0) as u32); + } + if want_left > usable.x { + let shrink = want_left - usable.x; + usable.x = want_left; + usable.width = usable.width.saturating_sub(shrink.max(0) as u32); + } + let usable_bottom = usable.y + usable.height as i32; + if want_bottom < usable_bottom { + usable.height = (want_bottom - usable.y).max(0) as u32; + } + let usable_right = usable.x + usable.width as i32; + if want_right < usable_right { + usable.width = (want_right - usable.x).max(0) as u32; + } // The head's true full rect, ignoring any exclusive zone -- // deliberately *not* defaulted from `usable` the way `Monitor:: // new` alone would (see the fullscreen note below). @@ -668,7 +819,7 @@ impl Platform for UdevPlatform { // erasing the split it was placed to respect. m.full_geometry = srdwm_core::monitor::split_rect(full, part, parts, rows); m.maximize_geometry = srdwm_core::monitor::split_rect(maximize, part, parts, rows); - m.primary = next_id == 0; + m.primary = primary_name.as_deref() == Some(name.as_str()); m.split = parts > 1; m.scale = scale; out.push(m); diff --git a/crates/wayland/src/udev/render.rs b/crates/wayland/src/udev/render.rs index 2d0a063..4bfe04d 100644 --- a/crates/wayland/src/udev/render.rs +++ b/crates/wayland/src/udev/render.rs @@ -7,6 +7,19 @@ impl CompState { /// monitors on different refresh rates each run at their own pace /// instead of the slowest one gating the rest. pub(crate) fn render_udev_frame(&mut self) { + // Real perf instrumentation, not a guess-fix: reported live as + // "resizing seems slow", and this session's own investigation + // (checked decoration-buffer caching, motion-path logging levels, + // GPU-path config) found no smoking gun without an actual + // measurement. Cheap when nothing's slow (one `Instant::now()` and + // one comparison per frame, no allocation, no formatting unless + // the threshold trips) - logs only when a frame actually misses a + // 60fps budget, tagged with whether a resize/drag was in progress + // at the time, so the next real resize either produces real + // evidence this is a genuine per-frame cost during resize + // specifically, or rules that out in favor of something else + // (input latency, client-side redraw cost, a specific app). + let frame_start = Instant::now(); self.tick_animations(); self.tick_hover_glyph_animation(); self.tick_dirty_broadcasts(); @@ -299,6 +312,23 @@ impl CompState { origin, hsize, )); + // Multi-cursor mode, Phase 1: one extra sprite per *other* + // physical pointer device's own last-known position (see + // `UdevState::secondary_cursors`'s own doc comment) - the + // device that drove `pointer_pos` itself is skipped so its + // cursor isn't drawn twice at the same spot. All secondary + // sprites share the one real cursor image/theme + // (`cursor_status`/`cursor_buffers`) rather than each + // device getting its own - a real visual distinction + // between devices is a later-phase refinement, not needed + // to prove multiple live positions render at all. + let active_device = udev.secondary_cursors.iter().find(|&(_, &p)| p == pointer_pos).map(|(d, _)| d.clone()); + for (device, &pos) in &udev.secondary_cursors { + if Some(device) == active_device.as_ref() { + continue; + } + custom_elements.extend(crate::cursor::render_elements(&cursor_status, &cursor_buffers, &mut udev.renderer, pos, origin, hsize)); + } // Night light/reading mode - a translucent full-output // overlay, pushed right after the cursor so it colours // everything else (windows, bars, menus) but never the @@ -415,7 +445,7 @@ impl CompState { // the actual content position still reads `geom`/`band` // directly, since that's already correctly anchored via // `content_offset` below regardless of this correction. - let frame = crate::state::CompState::effective_frame_of(&self.wm, &self.id_to_window, id, geom); + let frame = crate::state::CompState::effective_frame_of(&self.wm, &self.id_to_window, &self.pending_size_configure, id, geom); // Computed here, ahead of the border strips below, // purely so they can know it - the actual content // element that reads this same masked buffer is still @@ -924,6 +954,34 @@ impl CompState { // background-layer push just below (so the wallpaper still // shows through everywhere an icon doesn't draw). See // `desktop_icons.rs`'s own module doc comment. + // The rubber-band marquee outline, above the icons it's + // selecting - four thin solid-colour strips (the same + // `border_side_render_element` primitive window borders + // already use), not a translucent fill: `SolidColorRender + // Element` has no alpha-blend path, and a plain accent- + // coloured outline is still a real, visible selection + // indicator without needing a new element type for one + // feature. + if let Some((start, current)) = self.desktop_marquee { + let (x0, y0) = (start.0.min(current.0), start.1.min(current.1)); + let (x1, y1) = (start.0.max(current.0), start.1.max(current.1)); + let color = self.wm.borrow().theme.default_border_color; + const T: i32 = 1; + let strips = [ + srdwm_core::Rect::new(x0, y0, (x1 - x0).max(0) as u32, T as u32), + srdwm_core::Rect::new(x0, y1 - T, (x1 - x0).max(0) as u32, T as u32), + srdwm_core::Rect::new(x0, y0, T as u32, (y1 - y0).max(0) as u32), + srdwm_core::Rect::new(x1 - T, y0, T as u32, (y1 - y0).max(0) as u32), + ]; + for (strip, buf) in strips.into_iter().zip(self.marquee_buffers.iter_mut()) { + custom_elements.push(crate::elements::OverlayElement::Solid(crate::elements::border_side_render_element( + buf, + strip, + color, + (origin.x, origin.y), + ))); + } + } for (pos, buffer) in &desktop_icon_render_list { let local_pos = ((pos.0 - origin.x) as f64, (pos.1 - origin.y) as f64); match MemoryRenderBufferRenderElement::from_buffer(&mut udev.renderer, local_pos, buffer, None, None, None, Kind::Unspecified) { @@ -1190,6 +1248,16 @@ impl CompState { // otherwise never come (see docs/PANEL_SUPPORT_TODO.md, P1). self.screencopy_pending.extend(captures); } + const FRAME_BUDGET: Duration = Duration::from_millis(16); + let frame_time = frame_start.elapsed(); + if frame_time > FRAME_BUDGET { + let wm = self.wm.borrow(); + log::warn!( + "PERF-RESIZE render_udev_frame took {frame_time:?} (budget {FRAME_BUDGET:?}) - resizing={} dragging={}", + wm.resizing_window().is_some(), + wm.is_dragging() + ); + } } /// Sets a connector's DPMS mode via the generic KMS "DPMS" property -- diff --git a/crates/wayland/src/udev/session.rs b/crates/wayland/src/udev/session.rs index bf6eb67..c80592e 100644 --- a/crates/wayland/src/udev/session.rs +++ b/crates/wayland/src/udev/session.rs @@ -335,6 +335,12 @@ fn handle_libinput_event(state: &mut CompState, event: InputEvent<LibinputInputB udev.pointer_pos.x = (udev.pointer_pos.x + delta.x).clamp(min_x, (max_x - 1.0).max(min_x)); udev.pointer_pos.y = (udev.pointer_pos.y + delta.y).clamp(min_y, (max_y - 1.0).max(min_y)); let pos = udev.pointer_pos; + // Multi-cursor mode, Phase 1 (see `UdevState::secondary_ + // cursors`'s own doc comment): records this specific physical + // device's own position too, purely for rendering its own + // cursor sprite - `pos`/`handle_pointer_position` below are + // still the one interactive position, unchanged. + udev.secondary_cursors.insert(event.device(), pos); handle_pointer_position(state, pos, event.time_msec()); } // Absolute-positioning devices (a touchscreen, a drawing tablet, @@ -362,6 +368,7 @@ fn handle_libinput_event(state: &mut CompState, event: InputEvent<LibinputInputB udev.pointer_pos.x = (pos.x + min_x).clamp(min_x, (max_x - 1.0).max(min_x)); udev.pointer_pos.y = (pos.y + min_y).clamp(min_y, (max_y - 1.0).max(min_y)); let pos = udev.pointer_pos; + udev.secondary_cursors.insert(event.device(), pos); handle_pointer_position(state, pos, event.time_msec()); } InputEvent::PointerButton { event } => { |