diff options
| author | srdusr <[email protected]> | 2025-02-15 14:56:00 +0200 |
|---|---|---|
| committer | srdusr <[email protected]> | 2025-02-15 14:56:00 +0200 |
| commit | 0a4c4b5941fe982ccb3d3175e26d9d83f0025ffd (patch) | |
| tree | 7672d0af277664f457c6c9462925c0005fe35dcf /crates/wayland/src/udev | |
| parent | 413daa7ba2ea0ebd1424c024fd0566423aaea3f8 (diff) | |
| download | srdwm-0a4c4b5941fe982ccb3d3175e26d9d83f0025ffd.tar.gz srdwm-0a4c4b5941fe982ccb3d3175e26d9d83f0025ffd.zip | |
Checkpoint: preserve all uncommitted rust-rewrite worktree work
Safety commit before reconciling this worktree with main, which has
diverged with its own separate fixes today. Nothing here is reviewed
or curated yet - this exists purely so none of this work can be lost
to a git operation, disk issue, or worktree cleanup while that
reconciliation happens.
Diffstat (limited to 'crates/wayland/src/udev')
| -rw-r--r-- | crates/wayland/src/udev/capture.rs | 53 | ||||
| -rw-r--r-- | crates/wayland/src/udev/drm.rs | 49 | ||||
| -rw-r--r-- | crates/wayland/src/udev/mod.rs | 294 | ||||
| -rw-r--r-- | crates/wayland/src/udev/outputs.rs | 295 | ||||
| -rw-r--r-- | crates/wayland/src/udev/platform.rs | 316 | ||||
| -rw-r--r-- | crates/wayland/src/udev/render.rs | 484 | ||||
| -rw-r--r-- | crates/wayland/src/udev/session.rs | 49 |
7 files changed, 1367 insertions, 173 deletions
diff --git a/crates/wayland/src/udev/capture.rs b/crates/wayland/src/udev/capture.rs index bcf2aa4..fb4c22f 100644 --- a/crates/wayland/src/udev/capture.rs +++ b/crates/wayland/src/udev/capture.rs @@ -8,22 +8,32 @@ //! workspace that, most of the time, is *not* the one presented. //! //! Deliberately simple, not a small reimplementation of -//! `render_udev_frame`: only window content is drawn, no borders, -//! shadows, titlebars, cursor or layer-shell surfaces - every consumer -//! this was built for (a workspace-switcher tile) draws those tiny, where -//! that detail is imperceptible, and skipping them keeps this from needing -//! to duplicate that function's animation/occlusion bookkeeping. Always -//! renders at the target monitor's native resolution and downscales -//! afterward if a smaller size was requested, rather than trying to get -//! smithay's fractional-output-scale rendering path exactly right for a -//! target with no real `Output` behind it. +//! `render_udev_frame`: no borders, shadows, titlebars or cursor -- +//! every consumer this was built for (a workspace-switcher tile) draws +//! those tiny, where that detail is imperceptible, and skipping them +//! keeps this from needing to duplicate that function's animation/ +//! occlusion bookkeeping. The background/bottom layer-shell surfaces +//! (the wallpaper) *are* included, unlike the rest of that list - a +//! capture with no windows on it and no wallpaper either is +//! indistinguishable from broken, and was reported live as exactly that: +//! "why does current workspace show black background" once measured +//! against a real screenshot of the same moment (mean luminance ~0.5 vs. +//! this capture's own ~0.03, i.e. genuinely near-black, not just "looks +//! dark on this monitor"). An inactive workspace with literally no +//! windows placed on it rendered *exactly* black (mean and variance both +//! zero) for the same reason - there was nothing else in the frame at +//! all to show. Always renders at the target monitor's native resolution +//! and downscales afterward if a smaller size was requested, rather than +//! trying to get smithay's fractional-output-scale rendering path +//! exactly right for a target with no real `Output` behind it. use super::*; use smithay::backend::allocator::Fourcc; -use smithay::backend::renderer::element::surface::{render_elements_from_surface_tree, WaylandSurfaceRenderElement}; +use smithay::backend::renderer::element::surface::render_elements_from_surface_tree; use smithay::backend::renderer::element::Kind; use smithay::backend::renderer::{Bind, ExportMem, Offscreen}; use smithay::utils::{Buffer as BufferCoord, Transform}; +use smithay::wayland::shell::wlr_layer::Layer; impl CompState { /// Services every capture request queued since the last poll. Takes @@ -56,8 +66,8 @@ impl CompState { } let ids = self.wm.borrow().window_ids_on_workspace_front_to_back(req.workspace); - let mut elements: Vec<WaylandSurfaceRenderElement<PixmanRenderer>> = Vec::new(); let Some(udev) = self.udev.as_mut() else { return Err("no udev backend".to_string()) }; + let mut elements: Vec<crate::elements::OverlayElement<PixmanRenderer>> = Vec::new(); for id in ids { let Some(w) = self.id_to_window.get(&id) else { continue }; let Some(surface) = crate::input::dwindow_wl_surface(w) else { continue }; @@ -68,7 +78,26 @@ impl CompState { // a gap in the capture too. let content_offset = w.geometry().loc; let loc = (geom.x - origin.0 - content_offset.x, geom.y - origin.1 - content_offset.y); - elements.extend(render_elements_from_surface_tree(&mut udev.renderer, &surface, loc, 1.0, 1.0, Kind::Unspecified)); + elements.extend(render_elements_from_surface_tree::<_, crate::elements::OverlayElement<PixmanRenderer>>( + &mut udev.renderer, + &surface, + loc, + 1.0, + 1.0, + Kind::Unspecified, + )); + } + // Background/bottom layer-shell (the wallpaper) last - bottommost, + // matching `render_udev_frame`'s own ordering convention (see that + // function's matching comment). The real output behind whichever + // monitor `origin`/`native` came from, matched by location; missing + // entirely (an output that vanished between resolving `origin` + // above and here, a narrow race) just means no wallpaper in this + // one capture, not a hard failure - windows above still render. + if let Some(head) = udev.heads.iter().find(|h| h.location == Point::from(origin)) { + elements.extend(crate::elements::output_layer_elements(&mut udev.renderer, &head.output, |layer| { + matches!(layer, Layer::Background | Layer::Bottom) + })); } let (nw, nh) = (native.0 as i32, native.1 as i32); diff --git a/crates/wayland/src/udev/drm.rs b/crates/wayland/src/udev/drm.rs index fd06619..efe53aa 100644 --- a/crates/wayland/src/udev/drm.rs +++ b/crates/wayland/src/udev/drm.rs @@ -12,12 +12,22 @@ fn mode_refresh_mhz(mode: &DrmMode) -> i32 { /// Brings one connector up: allocates its scanout buffers, sets the mode, /// and creates the `wl_output` global. Shared by startup and hotplug so a /// monitor plugged in later is set up exactly like one present at boot. +/// +/// `scale` is `srd.monitor.scale(name, ...)`'s stored value for this +/// connector, if any - an explicit override always wins. `None` no +/// longer means "always 1.0": it falls through to `srdwm_core::monitor:: +/// auto_scale_for`, computed fresh from this connector's own real EDID +/// physical size and resolution, so a physically large, low-density +/// monitor gets a sensible scale with no per-connector-name config +/// needed at all. pub(crate) fn bring_up_head( card: &Card, dh: &DisplayHandle, probe: &ConnectorProbe, crtc: crtc::Handle, x_offset: i32, + logical_x: i32, + scale: Option<f64>, ) -> PlatformResult<(UdevHead, crate::state::OutputEntry)> { let (width, height) = probe.mode.size(); let (width, height) = (width as i32, height as i32); @@ -32,7 +42,8 @@ pub(crate) fn bring_up_head( // Physical size in millimeters comes straight from EDID via the // connector, not the hardcoded (0, 0) this used to be - some clients // compute their own effective DPI from it (independently of the - // compositor's own scale factor, which srdwm always reports as 1), so + // compositor's own scale factor, which defaults to 1 unless `srd. + // monitor.scale` overrides it for this connector), so // reporting "no physical size at all" was live, wrong data reaching // every client, not just an unfilled-in placeholder. let (phys_w, phys_h) = probe.info.size().unwrap_or((0, 0)); @@ -42,7 +53,28 @@ pub(crate) fn bring_up_head( PhysicalProperties { size: physical_mm.into(), subpixel: Subpixel::Unknown, make: "srdwm".into(), model: "drm".into() }, ); let mode = OutputMode { size: (width, height).into(), refresh: mode_refresh_mhz(&probe.mode) }; - output.change_current_state(Some(mode), Some(Transform::Normal), None, Some((x_offset, 0).into())); + let resolved_scale = scale.unwrap_or_else(|| srdwm_core::monitor::auto_scale_for(physical_mm, (width, height))); + // `x_offset` is physical (the caller accumulates it from real head + // widths - see `UdevHead::location`'s own doc comment for why that's + // the space this compositor tracks output position in internally), + // but `change_current_state`'s own position parameter is a real + // Wayland-protocol value and `wl_output`/`xdg_output` always report + // position to clients in logical points - so it needs the caller's + // own *separately*-accumulated `logical_x`, not a value derived from + // `x_offset` and this head's own scale alone. Dividing `x_offset` by + // just this head's own `resolved_scale` (what this used to do) is only + // correct for the first head in a layout, or when every head shares + // the same scale - for any later head following one with a + // *different* scale, this head's own scale has nothing to do with how + // much logical space the *previous* heads actually occupy, so it + // computed the wrong logical position for anything past the first + // output. Reported live (measured from inside GTK, not inferred) as + // two monitors' logical rectangles overlapping by a few hundred + // pixels whenever one had a non-1.0 scale - ambiguous "which monitor + // is this point on" answers, and hit-testing/screenshots landing on + // the wrong output in the overlap band. See `platform.rs`'s startup + // loop for how `logical_x` is actually accumulated correctly. + output.change_current_state(Some(mode), Some(Transform::Normal), Some(smithay::output::Scale::Fractional(resolved_scale)), Some((logical_x, 0).into())); output.set_preferred(mode); let global = output.create_global::<CompState>(dh); @@ -56,9 +88,11 @@ pub(crate) fn bring_up_head( buffers, front: 0, flip_pending: false, + flip_pending_since: Instant::now(), ages: [0, 0], location, size: (width, height), + mode: probe.mode, }; Ok((head, crate::state::OutputEntry { output, location })) } @@ -87,7 +121,16 @@ pub(crate) fn probe_connected(card: &Card) -> PlatformResult<Vec<ConnectorProbe> if info.state() != connector::State::Connected { continue; } - let name = format!("{:?}-{}", info.interface(), info.interface_id()); + // `info.interface()`'s `Debug` output is Rust's own enum variant + // name (`HDMIA`, `EmbeddedDisplayPort`) - neither string exists + // anywhere else. The kernel, `ddcutil`, `/sys/class/drm`, and any + // config the user already has for another compositor all use the + // strings in `Interface::as_str()` (`HDMI-A`, `eDP`, and so on -- + // taken directly from the kernel's own `drm_connector_enum_list`). + // Reported live: `srd monitors` showed `HDMIA-1`, a name that + // matched nothing, while `/sys/class/drm` and `ddcutil detect` + // both said `HDMI-A-1` for the same physical connector. + let name = format!("{}-{}", info.interface().as_str(), info.interface_id()); // Prefer the mode the display advertises as PREFERRED (its native // resolution) rather than whatever happens to be listed first -- // the list order is not guaranteed, and picking wrong means running diff --git a/crates/wayland/src/udev/mod.rs b/crates/wayland/src/udev/mod.rs index 501e3fc..1490d13 100644 --- a/crates/wayland/src/udev/mod.rs +++ b/crates/wayland/src/udev/mod.rs @@ -33,13 +33,14 @@ use std::rc::Rc; use std::time::{Duration, Instant}; use smithay::backend::input::{ - Axis, ButtonState as BackendButtonState, Event as InputEventTrait, GestureBeginEvent as BackendGestureBeginEvent, + AbsolutePositionEvent, Axis, ButtonState as BackendButtonState, Event as InputEventTrait, GestureBeginEvent as BackendGestureBeginEvent, GestureEndEvent as BackendGestureEndEvent, GesturePinchUpdateEvent as BackendGesturePinchUpdateEvent, InputEvent, PointerAxisEvent, PointerButtonEvent, PointerMotionEvent, }; use smithay::backend::libinput::{LibinputInputBackend, LibinputSessionInterface}; use smithay::backend::renderer::damage::OutputDamageTracker; use smithay::backend::renderer::element::memory::MemoryRenderBufferRenderElement; +use smithay::backend::renderer::element::solid::SolidColorBuffer; use smithay::backend::renderer::element::Kind; use smithay::backend::renderer::pixman::PixmanRenderer; use smithay::backend::renderer::{Bind, ImportDma}; @@ -128,6 +129,11 @@ pub(crate) struct UdevHead { /// A flip is in flight; the next frame for this head waits for the DRM /// page-flip event (matched by `crtc`) before starting. pub(crate) flip_pending: bool, + /// When the current `flip_pending` was set - lets `render_udev_frame` + /// notice a page-flip event that never arrived (or arrived but matched + /// no head - see `FLIP_TIMEOUT`'s own doc comment) instead of waiting + /// on it forever. Meaningless while `flip_pending` is `false`. + pub(crate) flip_pending_since: Instant, /// Per-buffer-slot age passed to `damage_tracker.render_output`: how /// many *damage-producing* renders ago that exact buffer was last /// brought fully up to date. 0 means "never rendered, contents @@ -149,6 +155,16 @@ pub(crate) struct UdevHead { /// Origin of this head in the global coordinate space. pub(crate) location: Point<i32, Logical>, pub(crate) size: (i32, i32), + /// The DRM mode this head was actually brought up with - kept so a VT- + /// switch resume can reassert the CRTC with its real connector and + /// mode (see `register_session_notifier`'s own `ActivateSession` arm), + /// rather than the empty connector list and `None` mode that call used + /// to pass, which does not reassert a CRTC at all - it is DRM/KMS's + /// own shape for *disabling* one. Confirmed live: switching back to + /// srdwm's VT after switching away left the screen black, with no + /// further VT switch (either direction) able to recover it, matching a + /// CRTC left disabled rather than restored. + pub(crate) mode: DrmMode, } /// Everything the DRM/udev backend needs that the nested winit backend @@ -173,14 +189,112 @@ pub(crate) struct UdevState { /// backend needed the session handle after startup, so it was never /// retained anywhere before this. pub(crate) session: LibSeatSession, + /// Connector names administratively disabled via `srd dispatch set + /// output enabled <name> false` - still physically connected (DRM + /// still reports/probes them), just deliberately not driven. Checked + /// by `reprobe_outputs`'s own "added" loop so an unrelated hotplug + /// event doesn't resurrect one of these the next time anything else + /// plugs or unplugs - without this, the very next `Changed` uevent + /// (any connector, not just this one) would see a disabled-but-still- + /// present connector as newly "added" (present in a fresh probe, + /// absent from `heads`, exactly the condition that branch already + /// uses to detect a real hotplug) and bring it straight back up. + pub(crate) disabled_connectors: std::collections::HashSet<String>, + /// `WorkspaceId` this backend last built `custom_elements` for -- + /// compared against `WindowManager::current_workspace()` at the top of + /// every `render_udev_frame` call so a switch can force every head's + /// `ages` back to `[0, 0]` (see that call site's own comment for why). + /// `None` before the very first frame, which already renders fully + /// regardless (every head starts with `ages: [0, 0]` - see + /// `UdevHead`'s own field). + pub(crate) last_rendered_workspace: Option<srdwm_core::WorkspaceId>, } impl UdevState { - /// Bounding box of every head, used to clamp pointer motion. - fn bounds(&self) -> (f64, f64) { - let w = self.heads.iter().map(|h| h.location.x + h.size.0).max().unwrap_or(0); - let h = self.heads.iter().map(|h| h.location.y + h.size.1).max().unwrap_or(0); - (w as f64, h as f64) + /// Bounding box of every head, used to clamp pointer motion -- + /// `(min_x, min_y, max_x, max_y)`, not just a `(width, height)` + /// implicitly anchored at `(0, 0)` (what this used to return, and what + /// every call site clamped into with a hardcoded `0.0` floor). That + /// was only ever correct while every head's `location.x`/`location.y` + /// stayed `>= 0`, true for `reprobe_outputs`' own left-to-right hotplug + /// layout but not guaranteed once `set_output_position` exists: an + /// "extend left"/"extend above" arrangement (a real one, requested and + /// applied live by an AGS peer session's monitor-layout panel) places + /// the newly-added head at a *negative* `x`/`y` relative to whichever + /// one stayed at the origin. With the old `(0, w)` clamp, the pointer + /// could never actually cross into that negative-origin region at + /// all - reported live as "clicked it now I can't go to other + /// monitor at all" once such an arrangement was applied. The AGS + /// side has since started normalising every arrangement it sends so + /// the leftmost/topmost edge lands at `0` again, which works around + /// 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) { + bounds_of(self.heads.iter().map(|h| (h.location.x, h.location.y, h.size.0, h.size.1))) + } +} + +/// The actual arithmetic behind [`UdevState::bounds`], over plain +/// `(x, y, width, height)` tuples rather than real `UdevHead`s - pulled +/// out so it's testable without a real DRM/`Card` handle, which every +/// `UdevHead` in this module otherwise needs to even construct. +fn bounds_of(heads: impl Iterator<Item = (i32, i32, i32, i32)>) -> (f64, f64, f64, f64) { + let mut min_x = 0; + let mut min_y = 0; + let mut max_x = 0; + let mut max_y = 0; + let mut any = false; + for (x, y, w, h) in heads { + if !any { + min_x = x; + min_y = y; + any = true; + } else { + min_x = min_x.min(x); + min_y = min_y.min(y); + } + max_x = max_x.max(x + w); + max_y = max_y.max(y + h); + } + (min_x as f64, min_y as f64, max_x as f64, max_y as f64) +} + +#[cfg(test)] +mod bounds_tests { + use super::bounds_of; + + #[test] + fn single_head_at_origin_matches_the_old_zero_anchored_behaviour() { + assert_eq!(bounds_of([(0, 0, 1920, 1080)].into_iter()), (0.0, 0.0, 1920.0, 1080.0)); + } + + #[test] + fn two_heads_left_to_right_from_origin() { + assert_eq!(bounds_of([(0, 0, 1920, 1080), (1920, 0, 1920, 1080)].into_iter()), (0.0, 0.0, 3840.0, 1080.0)); + } + + #[test] + fn negative_origin_head_is_reflected_in_min_not_clamped_to_zero() { + // The actual regression this exists for: an "extend left" + // arrangement places the new head at a negative x, and the old + // `(width, height)`-only version of this function (implicitly + // anchored at 0) made that head's own region completely + // unreachable by pointer motion - reported live as "clicked it + // now I can't go to other monitor at all". + let (min_x, min_y, max_x, max_y) = bounds_of([(0, 0, 1920, 1080), (-1920, 0, 1920, 1080)].into_iter()); + assert_eq!((min_x, min_y, max_x, max_y), (-1920.0, 0.0, 1920.0, 1080.0)); + } + + #[test] + fn negative_origin_above_is_reflected_in_min_y() { + let (min_x, min_y, max_x, max_y) = bounds_of([(0, 0, 1920, 1080), (0, -1080, 1920, 1080)].into_iter()); + assert_eq!((min_x, min_y, max_x, max_y), (0.0, -1080.0, 1920.0, 1080.0)); + } + + #[test] + fn no_heads_at_all_is_a_degenerate_zero_sized_box_not_a_panic() { + assert_eq!(bounds_of(std::iter::empty()), (0.0, 0.0, 0.0, 0.0)); } } @@ -204,8 +318,33 @@ impl UdevHead { /// buffer (software rendering writes into its own owned image, not the /// scanout memory directly, to avoid tying that image's lifetime to an /// mmap - see this module's docs) and flips to it. - fn copy_and_flip(&mut self, card: &Card, back: usize) -> std::io::Result<()> { - let (src_stride, height) = (self.buffers[back].image.stride(), self.buffers[back].image.height()); + /// `damage` is the exact set of rects `render_output` just re-rendered + /// into `self.buffers[back].image` - an empty slice means "copy + /// everything" (the locked/lock-UI render paths don't bother computing + /// per-rect damage, so this is also the safe fallback for any caller + /// that can't cheaply produce real rects), otherwise only those rows' + /// column ranges are copied. + /// + /// Used to be an unconditional full-buffer copy regardless of how + /// little of the frame actually changed - `render_output`'s own + /// age-based damage tracking already leaves everything outside + /// `damage` untouched in `image` (correct: that buffer's untouched + /// pixels still match what was on screen `ages[back]` frames ago), so + /// `dumb` - this same buffer's DRM-mapped twin, previously brought up + /// to date by this exact function on that same past frame - is + /// already correct everywhere outside `damage` too. Copying the whole + /// buffer anyway meant a full `stride * height` memcpy on every single + /// presented frame, for content as small as a moved cursor or a + /// blinking terminal caret - confirmed as the largest per-frame CPU + /// cost on this software `PixmanRenderer` backend by a direct + /// comparison against niri's DRM-composited present path (which has no + /// equivalent copy step at all) and mutter's native backend (which + /// explicitly restricts its own swap to damaged regions, + /// `swap_buffers_with_damage`) - this is the same technique, adapted + /// to a raw byte copy instead of a GL/EGL damage extension. + fn copy_and_flip(&mut self, card: &Card, back: usize, damage: &[Rectangle<i32, Physical>]) -> std::io::Result<()> { + let (src_stride, height, width) = + (self.buffers[back].image.stride(), self.buffers[back].image.height(), self.buffers[back].image.width()); let byte_len = src_stride * height; // SAFETY: `image` owns this memory and outlives the byte slice we // construct from it here; we only read, and only for the duration @@ -224,23 +363,142 @@ impl UdevHead { let dst_stride = self.buffers[back].dumb.pitch() as usize; { let mut mapping = card.map_dumb_buffer(&mut self.buffers[back].dumb)?; - let dst = mapping.as_mut(); - let row_len = src_stride.min(dst_stride); - for row in 0..height { - let s = row * src_stride; - let d = row * dst_stride; - if s + row_len > src.len() || d + row_len > dst.len() { - break; - } - dst[d..d + row_len].copy_from_slice(&src[s..s + row_len]); - } + copy_damaged_rows(src, mapping.as_mut(), src_stride, dst_stride, width, height, damage); } card.page_flip(self.crtc, self.buffers[back].fb, PageFlipFlags::EVENT, None)?; self.flip_pending = true; + self.flip_pending_since = Instant::now(); Ok(()) } } +/// The row/column copy math behind [`DrmHead::copy_and_flip`], pulled out +/// as a free function over plain slices so it's testable without a real +/// `Card`/dumb buffer - everything else in that method needs live DRM +/// state, this doesn't. `damage` empty means "copy every row in full" +/// (`width`/`height` are pixels, `src_stride`/`dst_stride` bytes); a +/// non-empty `damage` copies only each rect's row/column span, clamped to +/// the narrower of the two strides and to `width`/`height` the same way +/// the full-copy path always has. +fn copy_damaged_rows(src: &[u8], dst: &mut [u8], src_stride: usize, dst_stride: usize, width: usize, height: usize, damage: &[Rectangle<i32, Physical>]) { + let full_row_len = src_stride.min(dst_stride); + let copy_row = |dst: &mut [u8], row: usize, col_start_bytes: usize, col_len: usize| { + let s = row * src_stride + col_start_bytes; + let d = row * dst_stride + col_start_bytes; + let len = col_len.min(full_row_len.saturating_sub(col_start_bytes)); + if len == 0 || s + len > src.len() || d + len > dst.len() { + return; + } + dst[d..d + len].copy_from_slice(&src[s..s + len]); + }; + if damage.is_empty() { + for row in 0..height { + copy_row(dst, row, 0, full_row_len); + } + return; + } + const BPP: usize = 4; // Argb8888/Xrgb8888, same assumption every other raw-buffer path in this codebase makes. + for rect in damage { + let y0 = rect.loc.y.max(0) as usize; + let y1 = (rect.loc.y.saturating_add(rect.size.h).max(0) as usize).min(height); + let x0 = rect.loc.x.max(0) as usize; + let x1 = (rect.loc.x.saturating_add(rect.size.w).max(0) as usize).min(width); + if x1 <= x0 { + continue; + } + let (col_start_bytes, col_len) = (x0 * BPP, (x1 - x0) * BPP); + for row in y0..y1 { + copy_row(dst, row, col_start_bytes, col_len); + } + } +} + +#[cfg(test)] +mod copy_damaged_rows_tests { + use super::copy_damaged_rows; + use smithay::utils::{Physical, Point, Rectangle, Size}; + + fn rect(x: i32, y: i32, w: i32, h: i32) -> Rectangle<i32, Physical> { + Rectangle::new(Point::from((x, y)), Size::from((w, h))) + } + + /// A tiny 4x3 BGRA canvas, one distinct byte value per pixel's blue + /// channel (row * width + col) so a wrong offset or a skipped pixel + /// shows up as the wrong number, not just "still zero". + fn make_src(width: usize, height: usize) -> Vec<u8> { + let mut buf = vec![0u8; width * height * 4]; + for (i, px) in buf.chunks_exact_mut(4).enumerate() { + px[0] = i as u8; + px[3] = 255; + } + buf + } + + #[test] + fn empty_damage_copies_every_row_in_full() { + let (w, h) = (4, 3); + let src = make_src(w, h); + let mut dst = vec![0u8; w * h * 4]; + copy_damaged_rows(&src, &mut dst, w * 4, w * 4, w, h, &[]); + assert_eq!(dst, src); + } + + #[test] + fn a_damage_rect_updates_only_its_own_pixels() { + let (w, h) = (4, 3); + let src = make_src(w, h); + let mut dst = vec![0u8; w * h * 4]; + // Only the single pixel at (1, 1). + copy_damaged_rows(&src, &mut dst, w * 4, w * 4, w, h, &[rect(1, 1, 1, 1)]); + let idx = (1 * w + 1) * 4; + assert_eq!(dst[idx], src[idx], "the damaged pixel must be copied"); + assert_eq!(dst[0], 0, "a pixel outside the damage rect must stay untouched"); + assert_eq!(dst[dst.len() - 4], 0, "the last row's pixel is also outside the rect and must stay untouched"); + } + + #[test] + fn a_full_width_row_rect_copies_that_row_only() { + let (w, h) = (4, 3); + let src = make_src(w, h); + let mut dst = vec![0u8; w * h * 4]; + copy_damaged_rows(&src, &mut dst, w * 4, w * 4, w, h, &[rect(0, 1, w as i32, 1)]); + let row1 = w * 4..w * 4 * 2; + assert_eq!(dst[row1.clone()], src[row1], "row 1 must be fully copied"); + assert_eq!(&dst[..w * 4], &vec![0u8; w * 4][..], "row 0 must stay untouched"); + assert_eq!(&dst[w * 4 * 2..], &vec![0u8; w * 4][..], "row 2 must stay untouched"); + } + + #[test] + fn a_rect_extending_past_the_buffer_is_clamped_not_panicking() { + let (w, h) = (4, 3); + let src = make_src(w, h); + let mut dst = vec![0u8; w * h * 4]; + // Starts inside the buffer but both extends past its right/bottom + // edge and would run off a naive unclamped copy. + copy_damaged_rows(&src, &mut dst, w * 4, w * 4, w, h, &[rect(2, 2, 100, 100)]); + let idx = (2 * w + 2) * 4; + assert_eq!(dst[idx], src[idx], "the in-bounds corner of an oversized rect must still be copied"); + } + + #[test] + fn a_wider_destination_stride_does_not_shear_rows() { + // Destination row padded 4 extra bytes past the source's own + // stride - the same "driver-padded dumb buffer pitch" case the + // full-copy path was already written to handle; damage-restricted + // copying must preserve that, not just the empty-damage fallback. + let (w, h) = (4, 3); + let src = make_src(w, h); + let dst_stride = w * 4 + 4; + let mut dst = vec![0u8; dst_stride * h]; + copy_damaged_rows(&src, &mut dst, w * 4, dst_stride, w, h, &[rect(0, 0, w as i32, h as i32)]); + for row in 0..h { + let s = row * w * 4..row * w * 4 + w * 4; + let d = row * dst_stride..row * dst_stride + w * 4; + assert_eq!(dst[d], src[s], "row {row} must land at the destination's own stride, not the source's"); + } + } +} + mod capture; mod drm; mod outputs; diff --git a/crates/wayland/src/udev/outputs.rs b/crates/wayland/src/udev/outputs.rs index 6e282d6..c3291d8 100644 --- a/crates/wayland/src/udev/outputs.rs +++ b/crates/wayland/src/udev/outputs.rs @@ -2,6 +2,46 @@ use super::*; use super::drm::{bring_up_head, pick_crtc, probe_connected}; impl CompState { + /// Applies whatever monitor layout `monitor_layout::load()` remembers + /// from a previous run, on top of the default left-to-right layout + /// every head was just brought up with. Call once, right after every + /// head exists but before the Wayland socket is bound - see the call + /// site in `platform.rs`'s `connect()` for why that ordering is the + /// entire point (no client, panel or otherwise, gets a chance to see + /// the un-restored arrangement, not even for one frame). + /// + /// A connector with no remembered entry (a monitor plugged in for the + /// first time, or a fresh install with no state file yet) is left + /// exactly where the default layout put it - this only ever narrows + /// toward a remembered position, never invents one. + pub(crate) fn restore_monitor_layout(&mut self) { + let remembered = crate::monitor_layout::load(); + if remembered.is_empty() { + return; + } + // Disables first, deliberately: `disable_connector_by_name` ends + // with its own `relayout_outputs()` call, which recomputes every + // *remaining* head's position from the default left-to-right + // layout - doing that after a position restore below would just + // overwrite it again. Processing every disable up front means + // that default re-layout has already happened, once, before any + // remembered position gets applied on top of it. + for (name, entry) in &remembered { + if !entry.enabled { + self.disable_connector_by_name(name); + } + } + for (name, entry) in &remembered { + if !entry.enabled { + continue; + } + let Some(output) = self.udev.as_ref().and_then(|u| u.heads.iter().find(|h| &h.output.name() == name)).map(|h| h.output.clone()) else { + continue; + }; + crate::output_management::apply_output_position(self, &output, (entry.x, entry.y).into()); + } + } + /// Re-probes connectors after a hotplug and reconciles the head list. /// /// Connectors that vanished have their head torn down (global removed, @@ -23,17 +63,60 @@ impl CompState { let present: Vec<connector::Handle> = probes.iter().map(|p| p.connector).collect(); let existing: Vec<connector::Handle> = udev.heads.iter().map(|h| h.connector).collect(); + // A disabled connector that's genuinely gone from this fresh probe + // was actually unplugged, not just left administratively off -- + // checked (and cleaned up) *before* the `gone.is_empty() && + // added.is_empty()` early-out just below, since a disabled + // connector was never in `existing`/`heads` to begin with and so + // never affects either of those on its own; without this check + // running first, that early-out would fire and this cleanup would + // simply never happen for a hotplug event this narrow. See + // `MonitorInfo::enabled`'s own doc comment for why "off" and "not + // connected" have to be reported differently - this is what + // actually makes that transition happen. + let present_names: Vec<&str> = probes.iter().map(|p| p.name.as_str()).collect(); + let unplugged_while_disabled: Vec<String> = udev.disabled_connectors.iter().filter(|name| !present_names.contains(&name.as_str())).cloned().collect(); + if !unplugged_while_disabled.is_empty() { + let mut wm = self.wm.borrow_mut(); + for name in &unplugged_while_disabled { + log::info!("udev: administratively-disabled output {name} was physically unplugged"); + wm.clear_disabled_monitor(name); + } + } + let gone: Vec<connector::Handle> = existing.iter().copied().filter(|c| !present.contains(c)).collect(); let added: Vec<usize> = probes .iter() .enumerate() - .filter(|(_, p)| !existing.contains(&p.connector)) + // `!udev.disabled_connectors.contains(&p.name)`: without this, + // an administratively-disabled-but-still-connected output + // (`disable_connector_by_name`) looks identical to a genuinely + // new one here - present in a fresh probe, absent from + // `heads` - and this *unrelated* hotplug event (any + // connector, not just the disabled one) would bring it + // straight back up. + .filter(|(_, p)| !existing.contains(&p.connector) && !udev.disabled_connectors.contains(&p.name)) .map(|(i, _)| i) .collect(); - if gone.is_empty() && added.is_empty() { + // `udev` (the outer immutable borrow) is done being read after + // this point, so `disabled_connectors` can be mutated now to drop + // whatever `unplugged_while_disabled` found - deferred this far + // specifically because the `added` filter just above still needed + // to read it first. + if !unplugged_while_disabled.is_empty() { + if let Some(udev) = self.udev.as_mut() { + udev.disabled_connectors.retain(|name| !unplugged_while_disabled.contains(name)); + } + } + if gone.is_empty() && added.is_empty() && unplugged_while_disabled.is_empty() { return; // a "changed" event that didn't change the connector set } - log::info!("udev: hotplug - {} output(s) removed, {} added", gone.len(), added.len()); + log::info!( + "udev: hotplug - {} output(s) removed, {} added, {} disabled-and-unplugged", + gone.len(), + added.len(), + unplugged_while_disabled.len() + ); // ---- removals ---- for connector in &gone { @@ -62,7 +145,8 @@ impl CompState { continue; }; // Placed at 0 for now; the re-layout below assigns real offsets. - match bring_up_head(&card, &self.dh.clone(), probe, crtc, 0) { + let scale = self.wm.borrow().monitor_scale(&probe.name); + match bring_up_head(&card, &self.dh.clone(), probe, crtc, 0, 0, scale) { Ok((head, entry)) => { log::info!("udev: output {} connected ({}x{})", probe.name, head.size.0, head.size.1); let monitor_id = self.outputs.len() as u32; @@ -79,20 +163,215 @@ impl CompState { } } + // Safety net: never leave the session with zero live outputs. + // Real scenario, flagged live before it could actually happen: + // administratively disable the internal/laptop panel (`srd + // dispatch set output enabled ... false`), then physically unplug + // the one remaining external monitor - this same hotplug path + // handles the unplug correctly (the external head is removed + // above, same as any other disconnect), but without this, the + // internal panel stays administratively disabled forever after, + // leaving genuinely nothing to drive at all: no picture, and (a + // laptop having no other input device to fix it from) no way back + // in short of a restart. Re-enabling the most recently disabled + // connector that's still physically present - exactly + // `enable_connector_by_name`'s own normal path, just triggered by + // "we're about to have nothing" instead of an explicit request -- + // trades the administrative disable for actually having a screen, + // which is the only reasonable choice once the alternative is a + // fully dark machine. + let no_live_heads = self.udev.as_ref().is_some_and(|u| u.heads.is_empty()); + if no_live_heads { + let candidates: Vec<&drm::ConnectorProbe> = + self.udev.as_ref().map(|u| probes.iter().filter(|p| u.disabled_connectors.contains(&p.name)).collect()).unwrap_or_default(); + // The internal/laptop panel specifically, if it's one of the + // candidates - `eDP`/`LVDS`/`DSI` are the real DRM connector- + // type prefixes an embedded display reports as, matching the + // exact scenario this exists for (disable the internal panel, + // then lose the external one it was standing in for). Falls + // back to whatever else is available rather than doing + // nothing, on the same "a screen is better than no screen" + // reasoning - an external monitor left administratively + // disabled is still a better fallback than a fully dark + // machine, even if it wasn't the specific one this was + // written for. + let fallback = candidates + .iter() + .find(|p| p.name.starts_with("eDP") || p.name.starts_with("LVDS") || p.name.starts_with("DSI")) + .or_else(|| candidates.first()) + .map(|p| p.name.clone()); + if let Some(name) = fallback { + log::warn!("udev: every output would otherwise be off - re-enabling {name} rather than leaving nothing to drive"); + self.enable_connector_by_name(&name); + return; + } + } + + self.relayout_outputs(); + } + + /// Administratively disables the output named `name` - the backend + /// half of `srd dispatch set output enabled <name> false`. Reuses + /// exactly the same removal steps `reprobe_outputs` already takes for + /// a real unplug just above (destroy the `wl_output` global, unmap + /// from `Space`, drop lock-surface tracking, free the DRM buffers via + /// `head.release`, rehome its windows via a `MonitorRemoved` event) -- + /// the only difference is remembering the connector's *name* + /// afterward, in `UdevState::disabled_connectors`, so `reprobe_ + /// outputs` won't bring it straight back on the next unrelated + /// hotplug, and so `enable_connector_by_name` can find it again later + /// without a real replug. + pub(crate) fn disable_connector_by_name(&mut self, name: &str) { + let Some(udev) = self.udev.as_mut() else { return }; + let card = udev.card.clone(); + let Some(index) = udev.heads.iter().position(|h| h.output.name() == name) else { + log::warn!("udev: set output enabled false: no connected output named {name}"); + return; + }; + // Snapshotted before removal, same computation `Platform:: + // monitors()` itself uses - see `WindowManager:: + // set_disabled_monitor`'s own doc comment for why `srd monitors` + // still wants this after the head is gone (a last-known rect to + // show, not a live one). + let head_ref = &udev.heads[index]; + let zone = layer_map_for_output(&head_ref.output).non_exclusive_zone(); + // `zone` is logical (scale-divided), `head_ref.location`/`size` are + // raw physical pixels - same unit mismatch `Platform::monitors()` + // itself had to be fixed for, and the same fix: scale `zone` back + // into physical pixels before combining. See that function's own + // doc comment for the live symptom this caused when left + // unconverted (a scaled output's reported geometry overlapping its + // neighbor's). + let scale = head_ref.output.current_scale().fractional_scale(); + let zone_physical = |v: i32| (v as f64 * scale).round() as i32; + let usable_geometry = srdwm_core::Rect::new( + head_ref.location.x + zone_physical(zone.loc.x), + head_ref.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, + ); + let full_geometry = srdwm_core::Rect::new(head_ref.location.x, head_ref.location.y, head_ref.size.0 as u32, head_ref.size.1 as u32); + let was_primary = index == 0; + let head = udev.heads.remove(index); + log::info!("udev: output {name} administratively disabled"); + self.dh.remove_global::<CompState>(head.global.clone()); + self.space.unmap_output(&head.output); + self.outputs.retain(|e| e.output != head.output); + self.lock.surfaces.remove(&head.output.name()); + self.lock.presented.remove(&head.output.name()); + head.release(&card); + self.pending.borrow_mut().push(CoreEvent::MonitorRemoved(index as u32)); + if let Some(udev) = self.udev.as_mut() { + udev.disabled_connectors.insert(name.to_string()); + } + self.wm.borrow_mut().set_disabled_monitor(name.to_string(), usable_geometry, full_geometry, was_primary); + self.relayout_outputs(); + // Last-known physical position kept alongside `enabled: false` -- + // re-enabling this same connector later (`enable_connector_by_name` + // below) restores it, rather than a disable silently discarding + // where it used to be. + crate::monitor_layout::save_output(name, crate::monitor_layout::PersistedOutput { x: full_geometry.x, y: full_geometry.y, enabled: false }); + } + + /// The other half of `disable_connector_by_name` - brings a + /// previously-disabled-but-still-connected output back up exactly the + /// way `reprobe_outputs` brings up a genuinely new one, since nothing + /// about the underlying hardware actually changed in between (the + /// connector was never really unplugged, just not driven). + pub(crate) fn enable_connector_by_name(&mut self, name: &str) { + let Some(udev) = self.udev.as_ref() else { return }; + let card = udev.card.clone(); + if !udev.disabled_connectors.contains(name) { + log::warn!("udev: set output enabled true: {name} isn't administratively disabled (already on, or never connected)"); + return; + } + let probes = match probe_connected(&card) { + Ok(p) => p, + Err(e) => { + log::warn!("udev: re-enable probe for {name} failed: {e}"); + return; + } + }; + let Some(probe) = probes.iter().find(|p| p.name == name) else { + log::warn!("udev: set output enabled true: {name} is no longer physically connected"); + if let Some(udev) = self.udev.as_mut() { + udev.disabled_connectors.remove(name); + } + // "Off" and "not connected" have to read differently to a + // listener (see `MonitorInfo::enabled`'s own doc comment) -- + // this output is now the latter, so it stops being listed at + // all, same as a genuine unplug always has. + self.wm.borrow_mut().clear_disabled_monitor(name); + return; + }; + let used: Vec<crtc::Handle> = udev.heads.iter().map(|h| h.crtc).collect(); + let Some(crtc) = pick_crtc(&card, probe, &used) else { + log::warn!("udev: no free CRTC to re-enable {name}"); + return; + }; + // Placed at 0 for now; `relayout_outputs` below assigns real + // offsets, same as a genuine hotplug addition. + let scale = self.wm.borrow().monitor_scale(name); + match bring_up_head(&card, &self.dh.clone(), probe, crtc, 0, 0, scale) { + Ok((head, entry)) => { + log::info!("udev: output {name} re-enabled ({}x{})", head.size.0, head.size.1); + let monitor_id = self.outputs.len() as u32; + let geometry = srdwm_core::Rect::new(0, 0, head.size.0 as u32, head.size.1 as u32); + if let Some(udev) = self.udev.as_mut() { + udev.heads.push(head); + udev.disabled_connectors.remove(name); + } + self.outputs.push(entry); + self.pending.borrow_mut().push(CoreEvent::MonitorAdded(srdwm_core::Monitor::new(monitor_id, name.to_string(), geometry))); + // It's live again - `monitors()` reports it directly now, + // so it has no business also showing up in the separate + // disabled-outputs listing. + self.wm.borrow_mut().clear_disabled_monitor(name); + } + Err(e) => log::warn!("udev: failed to re-enable {name}: {e}"), + } self.relayout_outputs(); + // Read back after `relayout_outputs` has assigned this head its + // real position, not the `(0, 0)` placeholder it was brought up + // at above. + if let Some(location) = self.udev.as_ref().and_then(|u| u.heads.iter().find(|h| h.output.name() == name)).map(|h| h.location) { + crate::monitor_layout::save_output(name, crate::monitor_layout::PersistedOutput { x: location.x, y: location.y, enabled: true }); + } } /// Repositions every head left-to-right and republishes the new /// positions to the output globals, the `Space`, and the layer maps. fn relayout_outputs(&mut self) { let Some(udev) = self.udev.as_mut() else { return }; - let mut x = 0; + // Two separate accumulators, not one - `x_physical` is this + // compositor's own internal placement convention (`head.location`, + // `Space`, everything else), `x_logical` is what actually goes out + // over the wire via `change_current_state`, which the Wayland + // protocol always specifies in logical points. At `scale == 1.0` + // for every output these are numerically identical, which is why + // this was invisible until a non-1.0 scale existed: passing the + // *physical* offset straight into `change_current_state` here + // (this used to do exactly that, unconditionally) put a second + // output's *logical* position short of where the first output's + // own *logical* width actually ends whenever a scale below 1.0 was + // involved - e.g. a first output that's 1920 physical but 2276 + // logical (0.843 scale) left the second output advertised at + // logical x=1920, deep inside the first one's own logical extent, + // not past it. Reported live (measured from inside GTK, not + // inferred) as the two outputs' logical rectangles overlapping by + // a few hundred pixels - ambiguous "which monitor is this point + // on" answers, and hit-testing/screenshots landing on the wrong + // output entirely in the overlap band. + let mut x_physical = 0; + let mut x_logical = 0; let mut placed: Vec<(Output, Point<i32, Logical>)> = Vec::new(); for head in &mut udev.heads { - head.location = (x, 0).into(); - head.output.change_current_state(None, None, None, Some((x, 0).into())); + let scale = head.output.current_scale().fractional_scale(); + head.location = (x_physical, 0).into(); + head.output.change_current_state(None, None, None, Some((x_logical, 0).into())); placed.push((head.output.clone(), head.location)); - x += head.size.0; + x_physical += head.size.0; + x_logical += (head.size.0 as f64 / scale).round() as i32; } for (output, location) in placed { if let Some(entry) = self.outputs.iter_mut().find(|e| e.output == output) { diff --git a/crates/wayland/src/udev/platform.rs b/crates/wayland/src/udev/platform.rs index 88c0d8f..cbcee64 100644 --- a/crates/wayland/src/udev/platform.rs +++ b/crates/wayland/src/udev/platform.rs @@ -10,6 +10,12 @@ pub struct UdevPlatform { clients: Vec<Client>, pending: Rc<RefCell<Vec<CoreEvent>>>, ipc: Option<srdwm_platform::IpcServer>, + /// Last time `ipc.poll()` actually ran - see its call site in + /// `poll_events` for why this exists at all. + last_ipc_poll: Instant, + /// Last time the unconditional end-of-cycle `render_udev_frame()` call + /// actually ran - see its own call site for why. + last_render: Instant, } impl UdevPlatform { @@ -52,16 +58,24 @@ impl UdevPlatform { let mut heads: Vec<UdevHead> = Vec::new(); let mut output_entries: Vec<crate::state::OutputEntry> = Vec::new(); let mut used_crtcs: Vec<crtc::Handle> = Vec::new(); + // Two accumulators - see `bring_up_head`'s own doc comment on its + // `logical_x` parameter for why a second head's logical position + // can't just be derived from the physical offset and its own + // scale alone once an earlier head has a *different* scale. let mut x_offset = 0; + let mut logical_x = 0; for probe in &connected { let Some(crtc) = pick_crtc(&card, probe, &used_crtcs) else { log::warn!("udev: no free CRTC left for connector {}; not driving it", probe.name); continue; }; - let (head, entry) = bring_up_head(&card, &display_handle, probe, crtc, x_offset)?; - log::info!("udev: head {}: {} {}x{} at x={x_offset}", heads.len(), probe.name, head.size.0, head.size.1); + let scale = wm.borrow().monitor_scale(&probe.name); + let (head, entry) = bring_up_head(&card, &display_handle, probe, crtc, x_offset, logical_x, scale)?; + log::info!("udev: head {}: {} {}x{} at x={x_offset} (logical x={logical_x})", heads.len(), probe.name, head.size.0, head.size.1); used_crtcs.push(crtc); + let resolved_scale = head.output.current_scale().fractional_scale(); x_offset += head.size.0; + logical_x += (head.size.0 as f64 / resolved_scale).round() as i32; heads.push(head); output_entries.push(entry); } @@ -114,9 +128,11 @@ impl UdevPlatform { active: true, pointer_pos: (width as f64 / 2.0, height as f64 / 2.0).into(), session: session.clone(), + disabled_connectors: std::collections::HashSet::new(), + last_rendered_workspace: None, }; - let state = CompState { + let mut state = CompState { compositor_state, xdg_shell_state, _xdg_decoration_state: xdg_decoration_state, @@ -143,6 +159,7 @@ impl UdevPlatform { _screencopy_state: crate::screencopy::ScreencopyState::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), _foreign_toplevel_state: crate::foreign_toplevel::ForeignToplevelState::new::<CompState>(&display_handle), foreign_toplevel_managers: Vec::new(), foreign_toplevel_handles: HashMap::new(), @@ -172,6 +189,7 @@ impl UdevPlatform { last_broadcast_workspace: None, lock: Default::default(), cursor_status: smithay::input::pointer::CursorImageStatus::default_named(), + decoration_cursor_active: false, cursor_buffers: crate::cursor::make_buffers(), last_titlebar_click: None, gesture_swipe: None, @@ -189,12 +207,15 @@ impl UdevPlatform { border_top_decorations: HashMap::new(), border_bottom_decorations: HashMap::new(), decoration_signatures: HashMap::new(), + hovered_titlebar_button: None, shadow_buffers: HashMap::new(), rounded_corners_program: None, content_epoch: HashMap::new(), rounded_content_buffers: HashMap::new(), border_side_buffers: HashMap::new(), + color_filter_buffers: HashMap::new(), last_synced_size: HashMap::new(), + pending_size_configure: HashMap::new(), pending: pending.clone(), bound_keys: Rc::new(bound_keys.iter().cloned().collect::<HashSet<_>>()), repeat_keys: Rc::new(repeat_keys.iter().cloned().collect::<HashSet<_>>()), @@ -209,6 +230,15 @@ impl UdevPlatform { appmenu_registrar: None, }; + // Before the Wayland socket even binds, deliberately - see + // `restore_monitor_layout`'s and `monitor_layout`'s own doc + // comments for why this compositor restores its own remembered + // layout itself rather than leaving it to whichever panel happens + // to be running: no client can possibly connect and see the + // default, un-restored arrangement, not even for one frame, since + // the socket a client would need to connect to doesn't exist yet. + state.restore_monitor_layout(); + 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); @@ -248,7 +278,7 @@ impl UdevPlatform { log::warn!("XWayland unavailable ({e}); X11-only clients will not run"); } - Ok(Self { event_loop, display: dh, state, listener, clients: Vec::new(), pending, ipc }) + Ok(Self { event_loop, display: dh, state, listener, clients: Vec::new(), pending, ipc, last_ipc_poll: Instant::now(), last_render: Instant::now() }) } fn accept_clients(&mut self) -> PlatformResult<()> { @@ -268,13 +298,97 @@ impl Platform for UdevPlatform { fn poll_events(&mut self) -> PlatformResult<Vec<CoreEvent>> { self.accept_clients()?; + let dispatch_start = Instant::now(); self.event_loop.dispatch(Some(Duration::from_millis(16)), &mut self.state).map_err(err)?; + // `dispatch`'s `Duration::from_millis(16)` argument is a *maximum* + // wait, not a guarantee - calloop returns the moment any + // registered source looks ready, however long or short that takes. + // A source stuck permanently "ready" (an fd calloop never removes + // even though every read on it comes back EOF/HUP - confirmed live + // via `strace`, traced to the libseat session notifier's internal + // ping channel, and reproducible on a bare tty1 login within the + // first second of every single srdwm start, independent of which + // libseat backend - seatd or the logind fallback - is active) + // makes `dispatch` return in microseconds forever, turning this + // loop into an unthrottled spin that burns 70-90% of a core doing + // nothing: `accept_clients`/`tick_repeat`/`dispatch_clients` all + // still run their own (cheap) work on every single one of those + // spurious wakeups, thousands of times a second, instead of the + // ~60 times a second the 16ms figure was meant to cap it at. + // + // This doesn't fix *why* that source never goes away - that's + // upstream, in calloop/libseat's own channel-notification internals + // - but it puts a floor under the symptom regardless of which + // source eventually turns out to cause it. + // + // Sleeping the full remainder of a 16ms cycle on *every* fast + // return (an earlier version of this did exactly that) blocks this + // thread against everything, not just the next spurious wakeup -- + // a genuine DRM page-flip completion or a client committing its + // next video frame that becomes ready *during* the sleep sits + // unprocessed until the sleep ends, instead of being picked up + // immediately. Reported live as choppy/laggy video playback: up to + // 16ms of pure, avoidable latency added to every frame's worth of + // real work that happened to land in that window. + // + // A per-iteration streak counter was tried first, throttling only + // once several fast returns in a row looked like true idle + // spinning rather than one-off real work - but `dispatch`'s + // return time can't actually distinguish the two here: the dead + // pipe is *always* ready, so every call returns in microseconds + // whether or not it also picked up something real, and a streak + // built on that timing never resets during genuine activity + // either. Telling real work apart from the spurious wakeup would + // need a signal from *inside* dispatch (e.g. the render path + // flagging "a frame actually went out this tick"), which is real + // plumbing, not a one-line fix. + // + // Short of that: cap the sleep itself far below 16ms instead of + // trying to skip it selectively. `MIN_CYCLE` (~3ms) still turns + // the true spin (unbounded, thousands of empty iterations/sec) + // into a bounded few hundred/sec - a real, if smaller, win over + // no floor at all - while capping how long any genuinely-ready + // event can ever sit blocked to something well under one frame at + // 60Hz, rather than up to a full frame's worth of latency. + const MIN_CYCLE: Duration = Duration::from_millis(3); + let elapsed = dispatch_start.elapsed(); + if elapsed < MIN_CYCLE { + std::thread::sleep(MIN_CYCLE - elapsed); + } // Held bindings that repeat - see `CompState::tick_repeat`. self.state.tick_repeat(); self.display.dispatch_clients(&mut self.state).map_err(err)?; self.display.flush_clients().map_err(err)?; self.state.apply_registrar_events(); - if let Some(ipc) = self.ipc.as_mut() { + self.state.poll_global_menu_properties(); + // Throttled to ~60Hz, not run on every single `poll_events` cycle -- + // `IpcServer::poll` unconditionally rebuilds and diffs a full + // `client_snapshot`/`workspace_snapshot` on every call (cloning each + // window's title, app_id, global-menu data, ...) even when nothing + // has changed and nobody is subscribed, purely so a real change is + // never missed. Cheap at a sane call rate; not cheap at the rate + // this loop actually runs at - see `MIN_CYCLE`'s own doc comment + // just above: the dead libseat pipe that makes `dispatch` return in + // microseconds forever means this whole function's "rest of the + // cycle" work already runs at whatever `dispatch` gets bounced to + // (a few hundred times a second, floor-capped by `MIN_CYCLE`, not + // the ~60 times a second one `Duration::from_millis(16)` above was + // meant to imply), and that snapshot/diff cost was riding along at + // that same needlessly high rate - measured live as a continuous, + // unwavering ~20% of a core even at complete idle, unaffected by + // toggling shadows/rounded_corners/animations (all purely per- + // render-frame costs, not per-cycle ones, so none of them could + // have explained a cost that never budged with the screen doing + // nothing). A real `srd dispatch`/`srd set` command still lands + // within one throttled window (well under a human's own reaction + // time), not delayed by anything close to what would read as + // input lag. + const IPC_POLL_INTERVAL: Duration = Duration::from_millis(16); + let ipc_due = self.last_ipc_poll.elapsed() >= IPC_POLL_INTERVAL; + if ipc_due { + self.last_ipc_poll = Instant::now(); + } + if let Some(ipc) = self.ipc.as_mut().filter(|_| ipc_due) { if ipc.poll(&self.state.wm) { self.pending.borrow_mut().push(CoreEvent::WorkspaceChanged); // `ipc.rs`'s `handle_request` (`"focus"`, `"toggle @@ -295,9 +409,16 @@ impl Platform for UdevPlatform { // unconditionally on any IPC mutation, not just ones that // are definitely focus changes - raising an already-topmost // element is a no-op reinsertion. + // + // `raise_in_space`, not the full `focus_window` - that one + // also re-runs `WindowManager::focus_window`'s workspace- + // follow side effect on the already-focused window, which + // silently reverted any `activate_workspace` IPC dispatch + // within this same cycle (see `raise_in_space`'s own doc + // comment for the full story). let focused = self.state.wm.borrow().focused_id(); if let Some(id) = focused { - crate::input::focus_window(&mut self.state, id); + crate::input::raise_in_space(&mut self.state, id); } } } @@ -344,6 +465,10 @@ impl Platform for UdevPlatform { log::warn!("udev: set_output_position: no head at index {id}"); continue; }; + // `(x, y)` is whatever `srd dispatch set output position` + // sent, unconverted - that command's own contract is to + // match `srd monitors`' `full_x`/`full_y` (physical), + // which is exactly what `apply_output_position` wants. crate::output_management::apply_output_position(&mut self.state, &output, (x, y).into()); any_applied = true; } @@ -361,57 +486,144 @@ impl Platform for UdevPlatform { self.pending.borrow_mut().push(CoreEvent::MonitorAdded(srdwm_core::Monitor::new(0, "", srdwm_core::Rect::new(0, 0, 0, 0)))); } } - self.state.render_udev_frame(); + // Applies any `srd set_output_enabled` IPC requests queued since + // the last poll - `disable_connector_by_name`/`enable_connector_ + // by_name` already push their own `MonitorRemoved`/`MonitorAdded` + // event, so nothing further is needed here beyond calling them. + let enable_requests = self.state.wm.borrow_mut().drain_output_enable_requests(); + for (name, enabled) in enable_requests { + if enabled { + self.state.enable_connector_by_name(&name); + } else { + self.state.disable_connector_by_name(&name); + } + } + // 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. + // `render_udev_frame` isn't only called from here: a real DRM + // page-flip completion (`session.rs`), a VT-switch resume, and an + // output hotplug each call it directly, immediately, completely + // unthrottled by this - those are genuine, comparatively rare + // events that should redraw the instant they happen. This one + // specific call site is different: it's the unconditional catch- + // all that used to run at the end of *every* cycle regardless of + // whether `dispatch` actually picked up anything real, which at + // this loop's dead-pipe-driven rate meant re-walking every visible + // window, rebuilding the whole `custom_elements` list, and running + // Pixman's own damage tracking against it a few hundred times a + // second, forever - `has_damage` already meant an idle desktop's + // *page flip* was skipped, but computing "no, still nothing to + // flip" this often is itself most of the cost this whole function + // was found burning at idle. `RENDER_INTERVAL` (~8ms, ~120Hz) is + // comfortably above any real display's refresh rate - a head can + // never actually present faster than its own vblank allows + // regardless (`flip_pending` already gates that) - so this cannot + // cap real, on-screen frame rate on any hardware this backend + // targets; it only stops the redundant "check again" calls in + // between. + const RENDER_INTERVAL: Duration = Duration::from_millis(8); + if self.last_render.elapsed() >= RENDER_INTERVAL { + self.last_render = Instant::now(); + self.state.render_udev_frame(); + } Ok(self.pending.borrow_mut().drain(..).collect()) } - /// One `srdwm_core::Monitor` per head, positioned in the global space. - /// This is what makes core's layout engine multi-monitor-aware in - /// practice: `arrange_workspace` groups windows by `monitor` and lays - /// each group out inside that monitor's rectangle. + /// One `srdwm_core::Monitor` per head, positioned in the global space + /// - or several, when `srd.monitor.split` has requested that head be + /// divided into logical sub-monitors ("monitors inside monitors"; see + /// `srdwm_core::monitor::MonitorSplit`'s own doc comment). This is + /// what makes core's layout engine multi-monitor-aware in practice: + /// `arrange_workspace` groups windows by `monitor` and lays each group + /// out inside that monitor's rectangle - a split just means more, + /// smaller rectangles feeding the same grouping, no other core-side + /// change needed. fn monitors(&mut self) -> PlatformResult<Vec<srdwm_core::Monitor>> { let Some(udev) = self.state.udev.as_ref() else { return Ok(Vec::new()) }; - Ok(udev - .heads - .iter() - .enumerate() - .map(|(i, head)| { - // Shrunk by whatever a layer-shell surface (bar, dock) has - // reserved via `set_exclusive_zone` - reporting the full - // head size here otherwise means core's placement/tiling - // treats that strip as ordinary free space, so a new - // window's titlebar lands right where the bar renders on - // top of it, unreachable to drag. `non_exclusive_zone()` is - // output-local, so it's translated into this head's - // position in the shared global space the same way - // `head.location` already is. - let zone = layer_map_for_output(&head.output).non_exclusive_zone(); - let rect = srdwm_core::Rect::new( - head.location.x + zone.loc.x, - head.location.y + zone.loc.y, - zone.size.w as u32, - zone.size.h as u32, - ); - let mut m = srdwm_core::Monitor::new(i as u32, head.output.name(), rect); - // `Monitor::new` defaults `full_geometry` to whatever - // `geometry` was constructed with - correct for a monitor - // with no layer-shell client at all, wrong the moment one - // exists, since `rect` above is already zone-shrunk. Without - // this, `full_geometry` was silently identical to `geometry` - // for every real monitor this backend ever reported, which - // made `toggle_fullscreen`'s whole "ignore the reserved - // zone" design a no-op in practice: fullscreen still - // stopped at the bar/dock exactly like maximize does. - // Reported live as "fullscreen isn't actually going - // fullscreen" - confirmed by triggering it and reading - // the resulting geometry back over IPC, not just from - // reading this code. - m.full_geometry = srdwm_core::Rect::new(head.location.x, head.location.y, head.size.0 as u32, head.size.1 as u32); - m.maximize_geometry = crate::input::maximize_geometry_for(&head.output, m.full_geometry); - m.primary = i == 0; - m - }) - .collect()) + let wm = self.state.wm.clone(); + let wm = wm.borrow(); + let mut out = Vec::new(); + let mut next_id: u32 = 0; + for head in udev.heads.iter() { + // Shrunk by whatever a layer-shell surface (bar, dock) has + // reserved via `set_exclusive_zone` - reporting the full + // head size here otherwise means core's placement/tiling + // treats that strip as ordinary free space, so a new + // window's titlebar lands right where the bar renders on + // top of it, unreachable to drag. `non_exclusive_zone()` is + // output-local, so it's translated into this head's + // position in the shared global space the same way + // `head.location` already is. + // + // `non_exclusive_zone()` is in *logical* (scale-divided) + // units - a bar reports its own reserved strip the way every + // layer-shell client does, in logical points - while `head. + // location`/`head.size` are raw physical pixels straight from + // the DRM mode, never touched by `srd.monitor.scale`. Left + // unconverted, `usable` silently mixed the two units on any + // output with a scale other than exactly `1.0`: at scale + // `0.712`, a 1920-physical-pixel-wide head's own `zone.size.w` + // came back as ~2697 (logical), reported as this monitor's + // *usable* width - larger than its own *full* width, and + // large enough to overlap whichever real monitor sat next to + // it in the shared global space. Reported live as "Firefox + // maximized on one monitor also shows partially on the + // other" and general visual glitching on the scaled output -- + // both are this: placement math trusting an oversized rect + // that reached into a neighboring monitor's real screen. + // Scaling `zone` back into physical pixels here keeps `usable` + // in the same unit as `full`/`maximize`/`head.location` + // everywhere else in this compositor. + 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( + 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, + ); + // 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). + let full = srdwm_core::Rect::new(head.location.x, head.location.y, head.size.0 as u32, head.size.1 as u32); + let maximize = crate::input::maximize_geometry_for(&head.output, full); + let name = head.output.name(); + let split = wm.monitor_split(&name); + let parts = split.map(|s| s.parts).unwrap_or(1).max(1); + let rows = split.map(|s| s.rows).unwrap_or(false); + for part in 0..parts { + let sub_name = if parts <= 1 { name.clone() } else { format!("{name}-{}", part + 1) }; + let mut m = srdwm_core::Monitor::new(next_id, sub_name, srdwm_core::monitor::split_rect(usable, part, parts, rows)); + // `Monitor::new` defaults `full_geometry`/`maximize_ + // geometry` to whatever `geometry` was constructed with -- + // correct for a monitor with no layer-shell client and no + // split at all, wrong the moment either exists, since the + // rect above may already be zone-shrunk and/or a sub- + // region. Without this, `full_geometry` was silently + // identical to `geometry` for every real monitor this + // backend ever reported, which made `toggle_fullscreen`'s + // whole "ignore the reserved zone" design a no-op in + // practice: fullscreen still stopped at the bar/dock + // exactly like maximize does. Reported live as "fullscreen + // isn't actually going fullscreen" - confirmed by + // triggering it and reading the resulting geometry back + // over IPC, not just from reading this code. Each split + // part gets its *own* full/maximize rect too - without + // this, fullscreening a window in either half of a split + // head would cover the *entire* physical panel, silently + // 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.split = parts > 1; + m.scale = scale; + out.push(m); + next_id += 1; + } + } + Ok(out) } fn apply_geometry(&mut self, window: srdwm_core::WindowId, _geometry: srdwm_core::Rect) -> PlatformResult<()> { diff --git a/crates/wayland/src/udev/render.rs b/crates/wayland/src/udev/render.rs index 6c9c7d2..d5d776d 100644 --- a/crates/wayland/src/udev/render.rs +++ b/crates/wayland/src/udev/render.rs @@ -8,6 +8,7 @@ impl CompState { /// instead of the slowest one gating the rest. pub(crate) fn render_udev_frame(&mut self) { self.tick_animations(); + self.tick_hover_glyph_animation(); self.tick_dirty_broadcasts(); let locked = self.lock.locked; let elapsed = self.start_time.elapsed(); @@ -47,9 +48,22 @@ impl CompState { // looked up fresh per head (head-local `origin` translation). let ids: Vec<srdwm_core::WindowId> = if locked { Vec::new() } else { self.wm.borrow().visible_windows_front_to_back().map(|w| w.id).collect() }; let focused = self.wm.borrow().focused_id(); - // Default `false` here, unlike winit's `unwrap_or(true)` - see - // `rounded_corners_pixman`'s module doc comment for the CPU cost - // that makes this backend opt-in rather than on by default. + // Stays default `false` here, unlike winit's `unwrap_or(true)` -- + // see `rounded_corners_pixman`'s module doc comment for the real + // CPU cost this backend's masking technique has: a full row-by-row + // buffer copy on *every commit* of a constantly-repainting client, + // and that doc comment names video specifically as the case that + // pays it in full, every frame, for as long as the feature is on. + // Flipping this default was tried and reverted in the same pass + // that fixed this backend's render-loop latency (see `poll_events`' + // own history) - turning it on here would have directly undone + // that fix for exactly the content (video) it mattered most for. + // The actual "not all windows curved" complaint this was meant to + // address (an undecorated/CSD window like Firefox, with no + // compositor-drawn titlebar and only a thin border strip to look + // rounded at all) is better addressed by giving that border strip + // enough rows to show a real curve - see `ThemeConfig:: + // default_border_width`'s own doc comment. let rounded_corners_enabled = self.wm.borrow().rounded_corners_enabled.unwrap_or(false); let popup_targets = if locked { Vec::new() } else { crate::elements::popup_targets(self) }; @@ -58,7 +72,7 @@ impl CompState { // `captures` taken above nowhere to go this pass - put them back // rather than silently dropping a client's pending screenshot // because a VT switch happened to be in progress at that instant. - let Some(udev) = self.udev.as_ref() else { + let Some(udev) = self.udev.as_mut() else { self.screencopy_pending.extend(captures); return; }; @@ -66,6 +80,62 @@ impl CompState { self.screencopy_pending.extend(captures); return; } + // A workspace switch changes *which windows* `custom_elements` + // includes as drastically as a VT switch changes what's been + // scanned out in the meantime (see `register_session_notifier`'s + // own `head.ages = [0, 0]` for that case) - reported live as + // visible corruption (stale, wrong-coloured blocks, worst on a + // window that was actively repainting - a scrolling terminal -- + // right as the switch happened) confined to exactly the frame or + // two around a switch, then never self-correcting, consistent with + // one transient frame's content getting baked into a buffer slot + // and never fully overwritten again since later frames only patch + // whatever's *actually* still changing. `render_output`'s own + // per-element diffing (`elements_gone`/moved-element damage, plus + // each element's own `damage_since`) should in principle already + // produce correct total damage for a completely different element + // list - this is a defensive belt-and-braces reset, not a + // fallback for a specific proven bug in that diffing, matched to + // the one other place in this codebase that already resets `ages` + // for the same underlying reason ("what's in this buffer might not + // be what the tracker's own history thinks it is"). + let current_workspace = self.wm.borrow().current_workspace(); + if udev.last_rendered_workspace != Some(current_workspace) { + udev.last_rendered_workspace = Some(current_workspace); + for head in &mut udev.heads { + head.ages = [0, 0]; + } + } + // A head whose page-flip event never arrives (kernel-dropped, or a + // DRM event this driver never sends for reasons this backend has no + // visibility into) would otherwise sit in `flip_pending` forever: + // `session.rs`'s DRM-fd handler is the only other place that clears + // it, and it can only do that in response to an event that actually + // shows up. A head stuck this way is excluded from `ready` below on + // every single tick from then on - silently frozen on whatever it + // last displayed, with no error logged anywhere (the flip that set + // `flip_pending` had already succeeded when it was issued), which + // is exactly what a real second monitor did live: it rendered + // nothing but its own initial clear colour for the rest of the + // session, from moments after being connected. `FLIP_TIMEOUT` is + // far above any real vblank interval (even 30Hz is ~33ms) but short + // enough that a genuine loss is invisible in practice; forcing + // `flip_pending` back to `false` here just lets the normal path + // below retry - if a flip is still genuinely in flight, the + // kernel's own EBUSY on the next `page_flip` call surfaces as the + // existing "udev: page flip failed" log line instead of a silent + // freeze. + const FLIP_TIMEOUT: Duration = Duration::from_millis(200); + for head in udev.heads.iter_mut() { + if head.flip_pending && head.flip_pending_since.elapsed() > FLIP_TIMEOUT { + log::warn!( + "udev: no page-flip event for output {} after {:?}; forcing recovery", + head.output.name(), + head.flip_pending_since.elapsed() + ); + head.flip_pending = false; + } + } let ready: Vec<(usize, Output)> = udev .heads .iter() @@ -83,7 +153,7 @@ impl CompState { // frame-callback loop below (after `udev` is no longer borrowed) // can notify only the windows that damage actually overlapped -- // see `windows_touched_by_damage`'s doc comment in elements.rs. - let mut presented: Vec<(Output, Vec<Rectangle<i32, Physical>>)> = Vec::new(); + let mut presented: Vec<(Output, Point<i32, Logical>, Vec<Rectangle<i32, Physical>>)> = Vec::new(); for (index, output) in ready { let lock_surface = self.lock_surface_for(&output).cloned(); // Extracted before the `self.udev` borrow below starts - see @@ -116,6 +186,16 @@ impl CompState { 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 + // pointer itself. See `color_filter::render_element` for + // why an overlay rather than a true per-pixel shader. + let color_filter = self.wm.borrow().color_filter; + let buf = self.color_filter_buffers.entry(output.name()).or_insert_with(SolidColorBuffer::default); + if let Some(elem) = crate::color_filter::render_element(buf, color_filter, hsize) { + custom_elements.push(crate::elements::OverlayElement::Solid(elem)); + } // The right-click titlebar menu, if open - pushed right // after the cursor so it's still topmost over every window // but never hides the pointer itself (you need to see what @@ -164,7 +244,6 @@ impl CompState { custom_elements.extend(crate::elements::output_layer_elements( &mut udev.renderer, &output, - (origin.x, origin.y), |layer| matches!(layer, Layer::Top | Layer::Overlay), )); } @@ -203,23 +282,165 @@ impl CompState { // windows) has to agree with what `sync_geometry` mapped // the content to, or they drift apart again. let geom = self.window_anims.get(&id).map(crate::state::WindowAnim::current_rect).unwrap_or(w.geometry); - // Drawn first among this window's own decoration, and - // positioned from the same animated `geom` as everything - // else here - not `w.geometry` - for the identical - // reason: a shadow that stayed at the pre-tween rect - // while the window slid past it would look exactly as - // detached as the border did before that fix. Not - // fragment-clipped against `occluders` like the titlebar/ - // border below: at `SHADOW_MAX_ALPHA`'s low opacity, a - // shadow bleeding slightly onto a window stacked in front - // of this one reads as a soft edge, not the hard-line - // bleed-through that made the titlebar/border need it. - if let Some(shadow) = self.shadow_buffers.get(&id) { - let rect = decoration::shadow_rect(geom); - let pos = ((rect.x - origin.x) as f64, (rect.y - origin.y) as f64); - match MemoryRenderBufferRenderElement::from_buffer(&mut udev.renderer, pos, shadow, None, None, None, Kind::Unspecified) { - Ok(elem) => custom_elements.push(crate::elements::OverlayElement::Memory(elem)), - Err(e) => log::warn!("udev: failed to import shadow buffer: {e}"), + // `geom` above is this compositor's own request/target; + // `frame` corrects its far edge to match what the + // client's surface really committed (a terminal's + // cell-quantized size, most commonly) - see + // `effective_frame`'s own doc comment. Everything below + // that has to visually hug the real edge (titlebar/ + // border placement, the shadow, the occlusion test + // against windows behind this one) reads `frame`; only + // 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); + // 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 + // pushed later, in its own usual place in the loop, and + // gets a cheap cache hit from `rounded_content_buffer`'s + // own `epoch`/`radius_bits` check rather than doing the + // masking work twice. `w.decorated` alone used to gate + // whether the border strips' own "extra" rows (see + // `decoration::border_top_visible_rows`'s doc comment) + // were safe to draw past their nominal `border_width` -- + // correct for a decorated window (a titlebar band + // absorbs them) but not for an undecorated one, which + // relies on content-masking instead, and several real + // clients (Firefox, confirmed live) never actually get + // masked at all (`masked_content_buffer`'s own + // subsurface early-out). Cropping unconditionally + // whenever undecorated (the fix's first version) closed + // the wedge bug but cost every undecorated window its + // own visible corner curve even when masking *did* + // succeed, which is unnecessary - this makes that + // decision follow the real per-window, per-frame + // outcome instead of just the static `decorated` flag. + // `masked.is_some()` alone used to be the whole check, + // back when masking meant identifying and reading one + // specific client subsurface directly - wrong the + // moment the resolved child excluded more of the root + // than the client's own declared shadow margin (a GTK4 + // client legitimately reserves an invisible margin for + // its own drop shadow, but Firefox's tab strip/title row + // is painted on the *root* surface outside its content + // child, and once that surface-picking heuristic got + // permissive enough to mask Firefox too, it silently + // deleted Firefox's real tab strip - reported live as + // "Firefox's titlebar turned invisible", confirmed by + // toggling `general.rounded_corners` off, which brought + // it straight back). `rounded_corners_pixman::masked_ + // content_buffer` no longer has that failure mode at + // all: it renders the window's *whole* surface tree into + // its own off-screen buffer and masks the composited + // result, the same thing a GPU shader-based compositor + // does by construction - so `.is_some()` is genuinely + // the whole answer again. `loc`/`content_size` mirror + // the real content push's own `content_offset`/`band` + // correction below (`pos`'s own doc comment) - both + // call sites have to agree on the origin/size a mask was + // built at, or `rounded_content_buffer`'s cache would + // never consider one stale after a resize. + let content_will_be_masked = if rounded_corners_enabled && self.wm.borrow().resizing_window() != Some(id) { + let content_offset = self.id_to_window.get(&id).map(|dw| dw.geometry().loc).unwrap_or_default(); + let band = if w.decorated { srdwm_core::TITLEBAR_HEIGHT as i32 } else { 0 }; + let content_size = (frame.width as i32, (frame.height as i32 - band).max(0)); + let loc = (-content_offset.x, -content_offset.y); + self.id_to_window + .get(&id) + .and_then(crate::elements::window_wl_surface) + .map(|surface| { + let epoch = self.content_epoch.get(&id).copied().unwrap_or(0); + let corners = if w.decorated { crate::rounded_corners::RoundedCorners::BOTTOM_ONLY } else { crate::rounded_corners::RoundedCorners::ALL }; + crate::elements::rounded_content_buffer(&mut self.rounded_content_buffers, &mut udev.renderer, epoch, id, &surface, loc, content_size, w.corner_radius as f32, corners).is_some() + }) + .unwrap_or(false) + } else { + false + }; + let border_curve_is_safe = w.decorated || content_will_be_masked; + // Temporary: a peer session precisely measured a real + // window's border curving correctly while its content + // stayed hard-square (radius 0), despite both this + // probe and the real content-render call ~200 lines + // below passing identical arguments against the same + // cache - logs the three inputs that decide which + // branch each one actually takes, so a live repro + // says definitively whether `w.decorated` is really + // `false` here (the rule's own intent) or the mask + // genuinely succeeds-then-somehow-doesn't-render. + // Remove once resolved. + log::debug!( + "udev::render: corner-mask state for {} (id {id:?}): decorated={} content_will_be_masked={content_will_be_masked} border_curve_is_safe={border_curve_is_safe} resizing={}", + w.app_id, + w.decorated, + self.wm.borrow().resizing_window() == Some(id) + ); + // Pushed *before* the titlebar band below, deliberately -- + // unlike the bottom/side strips further down, this one + // isn't confined to `geometry`'s own outside: whenever + // `corner_radius > border_width` (the common case: 12 vs + // 4 by default), `border_top_visible_rows` deliberately + // extends this buffer `corner_radius - border_width` rows + // *past* its nominal thickness, straight down into the + // titlebar band's own top rows, so the one shared curve + // has room to finish (see that function's and `render_ + // border_top`'s own doc comments). For that overlap to + // read as one continuous curve rather than the titlebar's + // own, differently-centred corner mask poking a square + // notch through it, this element's border-coloured + // corner columns have to actually paint over the + // titlebar's own attempt at those same pixels - which + // only happens if this pushes first. Reported live, + // confirmed via a zoomed screenshot: pushed after the + // titlebar (the previous order), the titlebar's own + // smaller, square-under-the-curve corner rendered on top + // instead, since `custom_elements` composites earlier- + // pushed entries over later ones - exactly backwards + // from what this overlap needs. + if w.border_width > 0 { + let strips = decoration::border_strips(frame, w.border_width); + // Strip 0 (top) rounded on its own two corners - see + // `render_border_top`'s own doc comment - so it's a + // cached bitmap (rebuilt only in `redraw_decoration_ + // buffer`, same as the titlebar itself), not + // rasterized fresh here every frame. Not fragment- + // clipped like the left/right strips further down -- + // cropping a bitmap's source rect per fragment is + // real extra work for a strip that's only `border_ + // width` pixels tall to begin with, so this only + // handles the all-or-nothing case: skip entirely + // once *fully* covered, accept a small residual + // bleed while only partially covered. + if strips[0].width > 0 && strips[0].height > 0 && !strips[0].subtract_all(&occluders).is_empty() { + if let Some(buffer) = self.border_top_decorations.get(&id) { + // See `decoration::border_top_visible_rows`'s + // own doc comment: an undecorated window's + // top strip crops away this buffer's + // titlebar-band-only "extra" rows, which + // otherwise paint a border-coloured wedge + // straight onto its real content - reported + // live on a real Firefox window, confirmed + // via a screenshot to be neither Firefox's + // own rendering nor the separate content- + // mask feature. + let (row0, rows, shift) = decoration::border_top_visible_rows(border_curve_is_safe, w.border_width, w.corner_radius); + let pos = ((strips[0].x - origin.x) as f64, (strips[0].y - origin.y + shift as i32) as f64); + let src = Some(Rectangle::new(Point::from((0.0, row0 as f64)), Size::from((strips[0].width as f64, rows as f64)))); + // Temporary: chasing a live report that the + // bottom two corners render square while the + // top two curve correctly, on the same + // window, same frame. Logs this strip's own + // computed rows/shift/position so a live + // repro can be compared directly against the + // matching bottom-strip line below. Remove + // once resolved. + log::debug!("udev::render: TOP border strip for {} (id {id:?}): row0={row0} rows={rows} shift={shift} pos={pos:?} strip_rect={:?}", w.app_id, strips[0]); + match MemoryRenderBufferRenderElement::from_buffer(&mut udev.renderer, pos, buffer, None, src, None, Kind::Unspecified) { + Ok(elem) => custom_elements.push(crate::elements::OverlayElement::Memory(elem)), + Err(e) => log::warn!("udev: failed to import top border buffer: {e}"), + } + } } } if let Some(deco) = self.decorations.get(&id) { @@ -235,7 +456,7 @@ impl CompState { // visible fragment can come from the matching // sub-rect of the source image rather than the // whole thing. - let titlebar_rect = srdwm_core::Rect::new(geom.x, geom.y, geom.width, srdwm_core::TITLEBAR_HEIGHT); + let titlebar_rect = srdwm_core::Rect::new(frame.x, frame.y, frame.width, srdwm_core::TITLEBAR_HEIGHT); for fragment in crate::elements::visible_border_fragments(titlebar_rect, &occluders) { let pos = ((fragment.x - origin.x) as f64, (fragment.y - origin.y) as f64); let src = Rectangle::new( @@ -248,47 +469,47 @@ impl CompState { } } } - // Border strips sit entirely outside this window's own - // `geometry` (see `decoration::border_strips`), so they - // never overlap its own decoration/content - draw - // order against those doesn't matter here, only against - // other windows', which iterating `ids` in stacking - // order already gets right *for windows also drawn via - // this same custom_elements loop* - but not against - // any window's own *content*, which is why `occluders` - // below is still needed even with that ordering. + // The bottom strip sits entirely outside this window's + // own `geometry` with no titlebar-style overlap into + // content the way the top strip's own "extra" rows do + // above, so push order against the titlebar doesn't + // matter for it - only against other windows', which + // iterating `ids` in stacking order already gets right + // *for windows also drawn via this same custom_elements + // loop* - but not against any window's own *content*, + // which is why `occluders` below is still needed even + // with that ordering. + // + // The left/right side strips are a different story -- + // see their own push site further down for why they + // (unlike the bottom strip) *do* need cropping against + // this same top/bottom-strip overlap, a real bug this + // comment used to claim didn't exist here at all. if w.border_width > 0 { - let color = crate::state::effective_border_color(w.border_color, focused == Some(id)); - let strips = decoration::border_strips(geom, w.border_width); - // Strips 0/1 (top/bottom) rounded on their own two - // corners - see `render_border_top`/ - // `render_border_bottom`'s doc comments - so both - // are cached bitmaps (rebuilt only in - // `redraw_decoration_buffer`, same as the titlebar - // itself), not rasterized fresh here every frame. - // Not fragment-clipped like the left/right strips - // below - cropping a bitmap's source rect per - // fragment is real extra work for a strip that's - // only `border_width` pixels tall to begin with, so - // this only handles the all-or-nothing case: skip - // entirely once *fully* covered, accept a small - // residual bleed while only partially covered. - if strips[0].width > 0 && strips[0].height > 0 && !strips[0].subtract_all(&occluders).is_empty() { - if let Some(buffer) = self.border_top_decorations.get(&id) { - let pos = ((strips[0].x - origin.x) as f64, (strips[0].y - origin.y) as f64); - match MemoryRenderBufferRenderElement::from_buffer(&mut udev.renderer, pos, buffer, None, None, None, Kind::Unspecified) { - Ok(elem) => custom_elements.push(crate::elements::OverlayElement::Memory(elem)), - Err(e) => log::warn!("udev: failed to import top border buffer: {e}"), - } - } - } - // Same all-or-nothing bitmap treatment as the top - // strip, for its own two corners - see - // `decoration::render_border_bottom`'s doc comment. + let color = crate::state::effective_border_color(w.border_color, focused == Some(id), self.wm.borrow().theme.border_inactive_dim); + let strips = decoration::border_strips(frame, w.border_width); + // Strip 1 (bottom), the top strip's own mirror -- + // see `decoration::render_border_bottom`'s doc + // comment. Same all-or-nothing bitmap treatment. if strips[1].width > 0 && strips[1].height > 0 && !strips[1].subtract_all(&occluders).is_empty() { if let Some(buffer) = self.border_bottom_decorations.get(&id) { - let pos = ((strips[1].x - origin.x) as f64, (strips[1].y - origin.y) as f64); - match MemoryRenderBufferRenderElement::from_buffer(&mut udev.renderer, pos, buffer, None, None, None, Kind::Unspecified) { + // See `decoration::border_bottom_visible_ + // rows`'s own doc comment: relies on + // `BOTTOM_ONLY` content-masking having made + // this corner of a decorated window's + // content transparent already, which several + // real undecorated clients (Firefox, + // confirmed live) never actually get - same + // wedge bug as the top strip, confirmed on + // the same window's bottom-left corner via a + // real screenshot, not assumed. + let (row0, rows, shift) = decoration::border_bottom_visible_rows(border_curve_is_safe, w.border_width, w.corner_radius); + let pos = ((strips[1].x - origin.x) as f64, (strips[1].y - origin.y - shift as i32) as f64); + let src = Some(Rectangle::new(Point::from((0.0, row0 as f64)), Size::from((strips[1].width as f64, rows as f64)))); + // Temporary: see the matching TOP border log + // above. Remove once resolved. + log::debug!("udev::render: BOTTOM border strip for {} (id {id:?}): row0={row0} rows={rows} shift={shift} pos={pos:?} strip_rect={:?}", w.app_id, strips[1]); + match MemoryRenderBufferRenderElement::from_buffer(&mut udev.renderer, pos, buffer, None, src, None, Kind::Unspecified) { Ok(elem) => custom_elements.push(crate::elements::OverlayElement::Memory(elem)), Err(e) => log::warn!("udev: failed to import bottom border buffer: {e}"), } @@ -304,9 +525,45 @@ impl CompState { // visible after subtracting `occluders`, since a // whole unclipped strip is exactly the bug fixed // here. + // + // Cropped top and bottom by `extra` - the same + // `corner_radius - border_width` gap `border_top_ + // visible_rows`/`border_bottom_visible_rows` extend + // the top/bottom strips *into* whenever the radius + // exceeds the border's own nominal thickness (the + // common case: 12+ vs 4 at this theme's defaults). + // These side strips are plain flat fills with no + // curve awareness of their own (see this file's own + // stale comment just below, corrected here: "sit + // entirely outside... no titlebar-style overlap" + // was wrong - they *do* overlap the top/bottom + // strip's own extended, curved region), and used to + // span the window's full nominal height + // unconditionally. Since the top/bottom strip is + // pushed *before* these (earlier = topmost, see + // this loop's own ordering), its own curve's + // transparent cutout should be what shows through + // there - but a flat, uncropped side strip sitting + // directly underneath filled that same "supposed to + // be cut away" region with solid colour instead, + // which the curve's transparency does nothing to + // hide, since the side strip isn't part of what the + // curve is cutting *out of*. Reported live as a + // straight vertical line poking out from inside an + // otherwise-correctly-curved corner, confirmed via + // raw pixel sampling: solid border colour at a + // fixed x, starting right at the window's nominal + // top edge, running in parallel with the real + // curve rather than being replaced by it. + let extra = if border_curve_is_safe { w.border_width.max(w.corner_radius).saturating_sub(w.border_width) } else { 0 }; + let mut side_strips = [strips[2], strips[3]]; + for s in &mut side_strips { + s.y += extra as i32; + s.height = s.height.saturating_sub(2 * extra); + } let pool = self.border_side_buffers.entry(id).or_default(); let mut buf_index = 0; - for strip in &strips[2..] { + for strip in &side_strips { if strip.width == 0 || strip.height == 0 { continue; } @@ -317,6 +574,43 @@ impl CompState { } } } + // Shadow, positioned from the same animated `geom` as + // everything else here - not `w.geometry` - for the + // same reason a stale-position border read as detached + // from a mid-tween window before that fix: see `geom`'s + // own doc comment above. Pushed *after* the titlebar/ + // border above, not before - `custom_elements` treats + // earlier-pushed as topmost (see `border_side_render_ + // element`'s doc comment), and a shadow pushed first + // rendered on top of this same window's own border + // strips, alpha-blending black over them and muting the + // configured border colour into a hazy, indistinct + // smear instead of a crisp line. Reported live as + // "spacing before the border" - confirmed by sampling + // pixels straight across a window's edge: no run of the + // configured border colour appeared anywhere, just a + // gradient straight from content black into the + // shadow's own falloff. `shadow_bitmap`'s own doc + // comment already assumed "the window's own border/ + // titlebar/content always draws over it" - true for + // content (spatially disjoint from the shadow's + // rendered rect either way) but not for the border, + // which sits inside the shadow's footprint and needs + // the *later* push, not the earlier one, to actually + // end up on top of it. Not fragment-clipped against + // `occluders` like the titlebar/border above: at + // `SHADOW_MAX_ALPHA`'s low opacity, a shadow bleeding + // slightly onto a window stacked in front of this one + // reads as a soft edge, not the hard-line bleed-through + // that made the titlebar/border need it. + if let Some(shadow) = self.shadow_buffers.get(&id) { + let rect = decoration::shadow_rect(frame); + let pos = ((rect.x - origin.x) as f64, (rect.y - origin.y) as f64); + match MemoryRenderBufferRenderElement::from_buffer(&mut udev.renderer, pos, shadow, None, None, None, Kind::Unspecified) { + Ok(elem) => custom_elements.push(crate::elements::OverlayElement::Memory(elem)), + Err(e) => log::warn!("udev: failed to import shadow buffer: {e}"), + } + } // The window's own content, at its own `opacity` -- // this, not decoration, is the entire reason content // moved into this loop at all (see the doc comment on @@ -354,7 +648,35 @@ impl CompState { let content_offset = dwindow.geometry().loc; let pos = (geom.x - origin.x - content_offset.x, geom.y + band - origin.y - content_offset.y); let mut rounded_elem = None; - if rounded_corners_enabled { + // Skipped for whichever window is being + // interactively resized right now, specifically + // (not gated on `is_resizing()` alone, which + // would also blank every *other* window's own + // masking for the duration): `rounded_content_ + // buffer`'s own doc comment already flagged this + // backend's real CPU cost - a full row-by-row + // copy of the surface's *entire* pixel buffer on + // every commit, unlike the free-on-GPU winit/ + // GLES path - and a resize is exactly the case + // that pays it hardest: content reflows and + // recommits on every single frame of the drag, + // not just once. Reported live as "resizing is + // very laggy" the first time this session real + // hardware actually exercised `general. + // rounded_corners` turned on at all (it defaults + // off for exactly this reason). The corner mask + // is cosmetic and this is the one moment its + // absence is least likely to be noticed -- + // attention is on the edge being dragged, not + // the opposite corner's curve - so skipping it + // for the resize's duration and letting it + // reappear the instant it ends (no cache + // invalidation needed either way: `epoch` + // already only rebuilds on a real content + // change) is a real fix, not a visible + // regression. + let being_resized = self.wm.borrow().resizing_window() == Some(id); + if rounded_corners_enabled && !being_resized { let epoch = self.content_epoch.get(&id).copied().unwrap_or(0); // Bottom-only for a decorated window, same // reasoning as `winit/render.rs`'s identical split: @@ -362,11 +684,24 @@ impl CompState { // under the titlebar band's own rounded // bitmap. let corners = if w.decorated { crate::rounded_corners::RoundedCorners::BOTTOM_ONLY } else { crate::rounded_corners::RoundedCorners::ALL }; - if let Some(buffer) = - crate::elements::rounded_content_buffer(&mut self.rounded_content_buffers, epoch, id, &surface, w.corner_radius as f32, corners) - { - match MemoryRenderBufferRenderElement::from_buffer(&mut udev.renderer, (pos.0 as f64, pos.1 as f64), buffer, Some(w.opacity), None, None, Kind::Unspecified) - { + // `content_offset`/`band` above already give + // this window's own content origin/size; the + // mask's own off-screen buffer is rendered + // and sized to match exactly, so (unlike the + // old per-subsurface-buffer approach) the + // result can simply be placed at plain `pos` + // below - see `rounded_corners_pixman`'s own + // module doc comment for why this no longer + // needs a separate offset or a safety check + // against `content_offset` at all: the whole + // surface tree is what gets masked now, not + // one guessed-at subsurface, so there is + // nothing left it could silently exclude. + let content_size = (frame.width as i32, (frame.height as i32 - band).max(0)); + let loc = (-content_offset.x, -content_offset.y); + let masked = crate::elements::rounded_content_buffer(&mut self.rounded_content_buffers, &mut udev.renderer, epoch, id, &surface, loc, content_size, w.corner_radius as f32, corners); + if let Some(buffer) = masked { + match MemoryRenderBufferRenderElement::from_buffer(&mut udev.renderer, (pos.0 as f64, pos.1 as f64), buffer, Some(w.opacity), None, None, Kind::Unspecified) { Ok(elem) => rounded_elem = Some(elem), Err(e) => log::warn!("udev: failed to import rounded content buffer: {e}"), } @@ -380,7 +715,7 @@ impl CompState { } } } - occluders.push(geom); + occluders.push(frame); } // Background/bottom layer-shell (wallpaper engines) last -- // bottommost, matching smithay's own `space_render_elements` @@ -388,7 +723,6 @@ impl CompState { custom_elements.extend(crate::elements::output_layer_elements( &mut udev.renderer, &output, - (origin.x, origin.y), |layer| matches!(layer, Layer::Background | Layer::Bottom), )); } @@ -499,7 +833,7 @@ impl CompState { }; if has_damage { let head = &mut udev.heads[index]; - if let Err(e) = head.copy_and_flip(&udev.card, back) { + if let Err(e) = head.copy_and_flip(&udev.card, back, &damage_rects) { log::error!("udev: page flip failed: {e}"); continue; } @@ -523,7 +857,7 @@ impl CompState { // reason not to redraw at whatever rate this loop cycled, // forever, since it kept getting told a new frame was // wanted whether or not the screen had changed at all. - presented.push((output, damage_rects)); + presented.push((output, origin, damage_rects)); } } @@ -537,7 +871,7 @@ impl CompState { } // Frame callbacks + lock confirmation, once the `udev` borrow is done. - for (output, damage_rects) in presented { + for (output, origin, damage_rects) in presented { if locked { let surface = self.lock_surface_for(&output).cloned(); crate::lock::send_lock_frame(surface.as_ref(), &output, elapsed); @@ -545,7 +879,7 @@ impl CompState { } else { let out = output.clone(); let scale = Scale::from(out.current_scale().fractional_scale()); - for w in crate::elements::windows_touched_by_damage(&self.space, &damage_rects, scale) { + for w in crate::elements::windows_touched_by_damage(&self.space, &damage_rects, origin, scale) { w.send_frame(&out, elapsed, None, |_, _| Some(out.clone())); } } diff --git a/crates/wayland/src/udev/session.rs b/crates/wayland/src/udev/session.rs index 4dd6db0..561ba7a 100644 --- a/crates/wayland/src/udev/session.rs +++ b/crates/wayland/src/udev/session.rs @@ -89,9 +89,17 @@ pub(crate) fn register_session_notifier(handle: &LoopHandle<'static, CompState>, // Some drivers reset mode-setting state across a VT // switch; reassert every head before rendering again. + // + // The real connector and mode, not an empty connector + // list and no mode - that shape is DRM/KMS's own way + // to *disable* a CRTC, not reassert it, and was + // confirmed live to leave the screen black after + // switching back with no further VT switch, either + // direction, able to recover it. See `UdevHead::mode`'s + // own doc comment. for head in &mut udev.heads { let fb = head.buffers[head.front].fb; - if let Err(e) = card.set_crtc(head.crtc, Some(fb), (0, 0), &[], None) { + if let Err(e) = card.set_crtc(head.crtc, Some(fb), (0, 0), &[head.connector], Some(head.mode)) { log::warn!("udev: failed to reassert crtc on resume: {e}"); } // Force a full repaint: contents are undefined after @@ -139,10 +147,41 @@ fn handle_libinput_event(state: &mut CompState, event: InputEvent<LibinputInputB let Some(udev) = state.udev.as_mut() else { return }; let delta = event.delta(); // Clamped to the union of every head, so the pointer travels - // between monitors instead of stopping at the first one's edge. - let (w, h) = udev.bounds(); - udev.pointer_pos.x = (udev.pointer_pos.x + delta.x).clamp(0.0, (w - 1.0).max(0.0)); - udev.pointer_pos.y = (udev.pointer_pos.y + delta.y).clamp(0.0, (h - 1.0).max(0.0)); + // between monitors instead of stopping at the first one's edge + // - `min_x`/`min_y`, not a hardcoded `0.0` floor, so a head + // placed at a negative origin (a real "extend left"/"extend + // above" arrangement) is actually reachable. See `bounds`'s own + // doc comment for the live bug this fixes. + let (min_x, min_y, max_x, max_y) = udev.bounds(); + 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; + handle_pointer_position(state, pos, event.time_msec()); + } + // Absolute-positioning devices (a touchscreen, a drawing tablet, + // and - confirmed live via a `WAYLAND_DEBUG=1` trace from a peer + // session - ydotool's virtual uinput device, used throughout this + // whole debugging effort) had no handler here at all: this match + // only ever covered `PointerMotion` (relative deltas), so every + // `PointerMotionAbsolute` event fell through to the catch-all + // below and was silently dropped. The winit (nested) backend + // already handles this exact event via `event.position_transformed` + // (see `winit/events.rs`'s matching arm); this is that same + // pattern for the bare-metal backend, which never got it. Uses the + // same union-of-every-head bounds `PointerMotion` above clamps + // into, so a single absolute-positioning device still addresses + // the whole multi-monitor span, not just the first head. + InputEvent::PointerMotionAbsolute { event } => { + let Some(udev) = state.udev.as_mut() else { return }; + // `position_transformed` maps the device's own normalized + // [0,1] position into a `(0, 0)`-anchored size - offset by + // `min_x`/`min_y` afterward, same reasoning as `PointerMotion` + // above, so this still addresses a negative-origin head. + let (min_x, min_y, max_x, max_y) = udev.bounds(); + let size = Size::from(((max_x - min_x) as i32, (max_y - min_y) as i32)); + let pos = event.position_transformed(size); + 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; handle_pointer_position(state, pos, event.time_msec()); } |