srdusr
aboutsummaryrefslogtreecommitdiffstats
path: root/crates/wayland/src/udev
diff options
context:
space:
mode:
authorsrdusr <[email protected]>2025-02-15 14:56:00 +0200
committersrdusr <[email protected]>2025-02-15 14:56:00 +0200
commit0a4c4b5941fe982ccb3d3175e26d9d83f0025ffd (patch)
tree7672d0af277664f457c6c9462925c0005fe35dcf /crates/wayland/src/udev
parent413daa7ba2ea0ebd1424c024fd0566423aaea3f8 (diff)
downloadsrdwm-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.rs53
-rw-r--r--crates/wayland/src/udev/drm.rs49
-rw-r--r--crates/wayland/src/udev/mod.rs294
-rw-r--r--crates/wayland/src/udev/outputs.rs295
-rw-r--r--crates/wayland/src/udev/platform.rs316
-rw-r--r--crates/wayland/src/udev/render.rs484
-rw-r--r--crates/wayland/src/udev/session.rs49
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());
}