srdusr
aboutsummaryrefslogtreecommitdiffstats
path: root/crates/wayland
diff options
context:
space:
mode:
Diffstat (limited to 'crates/wayland')
-rw-r--r--crates/wayland/Cargo.toml3
-rw-r--r--crates/wayland/src/decoration.rs217
-rw-r--r--crates/wayland/src/decoration/tests.rs37
-rw-r--r--crates/wayland/src/desktop_icons.rs54
-rw-r--r--crates/wayland/src/desktop_menu.rs15
-rw-r--r--crates/wayland/src/icon_theme.rs278
-rw-r--r--crates/wayland/src/input/pointer.rs45
-rw-r--r--crates/wayland/src/lib.rs3
-rw-r--r--crates/wayland/src/state/desktop_icons.rs188
-rw-r--r--crates/wayland/src/state/geometry.rs40
-rw-r--r--crates/wayland/src/state/lifecycle.rs5
-rw-r--r--crates/wayland/src/state/menu.rs18
-rw-r--r--crates/wayland/src/state/mod.rs27
-rw-r--r--crates/wayland/src/state/toplevel.rs4
-rw-r--r--crates/wayland/src/udev/mod.rs17
-rw-r--r--crates/wayland/src/udev/outputs.rs54
-rw-r--r--crates/wayland/src/udev/platform.rs165
-rw-r--r--crates/wayland/src/udev/render.rs70
-rw-r--r--crates/wayland/src/udev/session.rs7
-rw-r--r--crates/wayland/src/virtual_pointer.rs511
-rw-r--r--crates/wayland/src/window_memory.rs124
-rw-r--r--crates/wayland/src/winit/connect.rs14
-rw-r--r--crates/wayland/src/winit/nested_platform.rs10
-rw-r--r--crates/wayland/src/xwayland.rs55
24 files changed, 1856 insertions, 105 deletions
diff --git a/crates/wayland/Cargo.toml b/crates/wayland/Cargo.toml
index ac6f771..5eb82ac 100644
--- a/crates/wayland/Cargo.toml
+++ b/crates/wayland/Cargo.toml
@@ -52,6 +52,9 @@ xcursor = "0.3.11"
# other "pinned to smithay's own version" dependency above - both need to
# see one identical `Keysym` type, not two incompatible ones.
xkbcommon = { version = "0.8", features = ["wayland"] }
+resvg = "0.48.1"
+usvg = "0.48.1"
+tiny-skia = "0.12.0"
[dependencies.smithay]
version = "0.7"
diff --git a/crates/wayland/src/decoration.rs b/crates/wayland/src/decoration.rs
index d81132b..56852cb 100644
--- a/crates/wayland/src/decoration.rs
+++ b/crates/wayland/src/decoration.rs
@@ -74,25 +74,56 @@ pub(crate) const CORNER_RADIUS: u32 = 12;
/// held). `items` is `(label, highlighted)`; `row_height` matches
/// `TITLEBAR_HEIGHT` by convention at the call site, not enforced here.
///
-/// Deliberately plain: solid rows, left-padded text, a 1px border for
-/// definition against whatever's behind it - no submenus, no icons, no
-/// separators. A context menu widget with real visual polish is a project
-/// of its own; this is the minimum that makes the actions discoverable and
-/// clickable at all, which is the actual gap.
+/// Rounded floating panel with a per-row rounded hover highlight, matching
+/// the reference this project's own AGS panel already settled on for its
+/// global-menu dropdown (`widget/Bar/components/GlobalMenu/style.scss`'s
+/// `popover box.menu-list`): flat rows with no border/outline at rest, a
+/// soft tinted fill (not a frame) on the highlighted one, inset padding so
+/// rows don't touch the panel's own edge, real gaps between rows. Reported
+/// live as looking "squished, no spacing/padding/margining, not at all
+/// polished" - the previous version drew edge-to-edge square rows with a
+/// single hard 1px border around the whole menu, exactly what that
+/// complaint (raised about the AGS dropdown, fixed there first) describes.
+/// Still no submenus/icons/separators - real gaps beyond this pass' own
+/// scope, not attempted blind.
pub fn render_context_menu(width: u32, row_height: u32, items: &[(&str, bool)], bg: (u8, u8, u8), fg: (u8, u8, u8), highlight_bg: (u8, u8, u8), border: (u8, u8, u8)) -> Vec<u8> {
+ let _ = border; // No outline anywhere now - see this function's own doc comment. Kept as a parameter so callers/themes don't need updating for a look this function no longer draws.
+ const PANEL_RADIUS: f32 = 10.0;
+ const ROW_INSET: i32 = 4;
+ const ROW_RADIUS: f32 = 6.0;
+
let (width, row_height) = (width.max(1) as usize, row_height.max(1) as usize);
+ // Exactly `row_height * items.len()`, same as before this pass --
+ // `ContextMenu`/`DesktopMenu`'s own `height()` and `row_at()` (which
+ // this function has no access to and mustn't get out of sync with)
+ // assume row `i` starts at `i * row_height` with no extra top/bottom
+ // inset, so all of this rework happens *inside* that unchanged canvas
+ // rather than by growing it.
let height = (row_height * items.len().max(1)).max(1);
let mut buf = vec![0u8; width * height * 4];
+ // The panel itself: one flat rounded-rect fill on an otherwise fully
+ // transparent canvas, so the corners genuinely show whatever's behind
+ // the menu (desktop/window content) rather than a hard-edged square.
+ fill_rounded_rect(&mut buf, width, height, 0, 0, width as i32, height as i32, PANEL_RADIUS, bg, bg);
+
let font = find_system_font();
for (i, (label, highlighted)) in items.iter().enumerate() {
+ let row_top = (i * row_height) as i32;
+ // The background text actually sits on, for `blit_glyph`'s own
+ // blend-toward-a-known-solid-colour contract - the row's own
+ // highlight fill (already baked into `buf` by this point, above)
+ // when highlighted, otherwise the panel's shared flat fill.
+ // `blit_glyph_on_transparent` would be wrong here even though most
+ // of `buf` started transparent: every row itself sits on the
+ // panel's own opaque fill, not bare transparency, and that
+ // blitter's whole design assumes the latter (see its own doc
+ // comment) - used correctly, it would leave a visible dark
+ // fringe around every character's anti-aliased edge instead of a
+ // clean blend into the row's real colour.
let row_bg = if *highlighted { highlight_bg } else { bg };
- let row_top = i * row_height;
- for y in row_top..(row_top + row_height).min(height) {
- for x in 0..width {
- let idx = (y * width + x) * 4;
- buf[idx..idx + 4].copy_from_slice(&rgb_to_bgra(row_bg, 255));
- }
+ if *highlighted {
+ fill_rounded_rect_over(&mut buf, width, height, ROW_INSET, row_top, width as i32 - ROW_INSET, row_top + row_height as i32, ROW_RADIUS, highlight_bg);
}
if let Some(font) = &font {
let baseline = row_top as f32 + row_height as f32 * 0.72;
@@ -114,21 +145,6 @@ pub fn render_context_menu(width: u32, row_height: u32, items: &[(&str, bool)],
}
}
}
-
- // A 1px border around the whole menu, drawn last so it isn't overdrawn
- // by any row's background fill.
- let border_px = rgb_to_bgra(border, 255);
- for x in 0..width {
- buf[x * 4..x * 4 + 4].copy_from_slice(&border_px);
- let last_row = (height - 1) * width + x;
- buf[last_row * 4..last_row * 4 + 4].copy_from_slice(&border_px);
- }
- for y in 0..height {
- let left = y * width;
- buf[left * 4..left * 4 + 4].copy_from_slice(&border_px);
- let right = y * width + width - 1;
- buf[right * 4..right * 4 + 4].copy_from_slice(&border_px);
- }
buf
}
@@ -208,33 +224,76 @@ pub fn render_snap_flyout(columns: u32, cell_width: u32, cell_height: u32, label
/// alpha blending this function has no other reason to do (every other
/// pixel here is drawn fully opaque or left fully transparent).
#[allow(clippy::too_many_arguments)]
-pub(crate) fn render_desktop_icon(width: u32, height: u32, kind: crate::desktop_icons::IconKind, label: &str, selected: bool, icon_color: (u8, u8, u8), label_color: (u8, u8, u8), selected_bg: (u8, u8, u8)) -> Vec<u8> {
+/// The glyph area within a desktop-icon cell, shared between `render_
+/// desktop_icon` (where it draws into) and its caller (which needs the
+/// exact same box to know what size to rasterize a real theme icon at --
+/// a mismatch would either leave a gap or need cropping, neither of which
+/// `render_desktop_icon`'s own straight-copy blend handles).
+pub(crate) fn desktop_icon_glyph_box(width: u32, height: u32) -> (i32, i32, i32, i32) {
+ let _ = height;
+ ((width as i32 - 40) / 2, 8, (width as i32 + 40) / 2, 44)
+}
+
+#[allow(clippy::too_many_arguments)]
+pub(crate) fn render_desktop_icon(
+ width: u32,
+ height: u32,
+ kind: crate::desktop_icons::IconKind,
+ label: &str,
+ selected: bool,
+ icon_color: (u8, u8, u8),
+ label_color: (u8, u8, u8),
+ selected_bg: (u8, u8, u8),
+ real_icon: Option<&[u8]>,
+) -> Vec<u8> {
use crate::desktop_icons::IconKind;
let (width, height) = (width.max(1) as usize, height.max(1) as usize);
let mut buf = vec![0u8; width * height * 4];
- let glyph_box = ((width as i32 - 40) / 2, 8, (width as i32 + 40) / 2, 44);
- // A top-lighter/bottom-`icon_color` vertical gradient, not one flat
- // fill - the same subtle top-to-bottom light-source cue `buttons.rs`'s
- // own `glossy_shade` uses for the titlebar dots, applied here as a
- // plain linear gradient (`fill_rounded_rect`'s own job) rather than a
- // radial highlight, which reads just as "polished" at this icon size
- // for a lot less code. `border` (outline/detail colour) stays a flat
- // darken of the base, unchanged.
- let top = color::brighten(icon_color);
- let border = color::darken(icon_color);
- match kind {
- IconKind::Home => draw_home_glyph(&mut buf, width, height, glyph_box, top, icon_color, border),
- IconKind::Computer => draw_computer_glyph(&mut buf, width, height, glyph_box, top, icon_color, border),
- IconKind::Trash => draw_trash_glyph(&mut buf, width, height, glyph_box, top, icon_color, border),
- IconKind::Folder => draw_folder_glyph(&mut buf, width, height, glyph_box, top, icon_color, border),
- IconKind::File => draw_file_glyph(&mut buf, width, height, glyph_box, top, icon_color, border),
+ let glyph_box = desktop_icon_glyph_box(width as u32, height as u32);
+ if let Some(real_icon) = real_icon {
+ // A real theme icon, already rasterized by `icon_theme::
+ // rasterize_svg` at exactly this box's own size - premultiplied
+ // BGRA8 straight from `resvg`, the same convention `blit_glyph_on_
+ // transparent` below already uses, and `buf` starts fully
+ // transparent (freshly zeroed) everywhere this box covers, so
+ // "blend over" and "copy" are the same operation here: no alpha
+ // math needed, unlike compositing onto a real background would.
+ let box_w = (glyph_box.2 - glyph_box.0).max(0) as usize;
+ let box_h = (glyph_box.3 - glyph_box.1).max(0) as usize;
+ for row in 0..box_h {
+ let y = glyph_box.1 + row as i32;
+ if y < 0 || y as usize >= height {
+ continue;
+ }
+ let src_row = &real_icon[row * box_w * 4..(row + 1) * box_w * 4];
+ let dst_start = (y as usize * width + glyph_box.0.max(0) as usize) * 4;
+ let copy_w = box_w.min(width.saturating_sub(glyph_box.0.max(0) as usize));
+ buf[dst_start..dst_start + copy_w * 4].copy_from_slice(&src_row[..copy_w * 4]);
+ }
+ } else {
+ // A top-lighter/bottom-`icon_color` vertical gradient, not one flat
+ // fill - the same subtle top-to-bottom light-source cue `buttons.rs`'s
+ // own `glossy_shade` uses for the titlebar dots, applied here as a
+ // plain linear gradient (`fill_rounded_rect`'s own job) rather than a
+ // radial highlight, which reads just as "polished" at this icon size
+ // for a lot less code. `border` (outline/detail colour) stays a flat
+ // darken of the base, unchanged. Only reached when no real icon-theme
+ // artwork resolved at all (`icon_theme::find_icon` found nothing, or
+ // the file it found failed to parse/render) - the fallback, not the
+ // normal path on a machine with any real icon theme installed.
+ let top = color::brighten(icon_color);
+ let border = color::darken(icon_color);
+ match kind {
+ IconKind::Home => draw_home_glyph(&mut buf, width, height, glyph_box, top, icon_color, border),
+ IconKind::Computer => draw_computer_glyph(&mut buf, width, height, glyph_box, top, icon_color, border),
+ IconKind::Trash => draw_trash_glyph(&mut buf, width, height, glyph_box, top, icon_color, border),
+ IconKind::Folder => draw_folder_glyph(&mut buf, width, height, glyph_box, top, icon_color, border),
+ IconKind::File => draw_file_glyph(&mut buf, width, height, glyph_box, top, icon_color, border),
+ }
}
let label_top = 50i32;
- if selected {
- fill_rect(&mut buf, width, height, 2, label_top, width as i32 - 2, height as i32 - 2, selected_bg, 255);
- }
if let Some(font) = find_system_font() {
let baseline = label_top as f32 + 14.0;
let mut widths = Vec::new();
@@ -244,6 +303,28 @@ pub(crate) fn render_desktop_icon(width: u32, height: u32, kind: crate::desktop_
widths.push(m.advance_width);
total += m.advance_width;
}
+ // Reported live: the old selection highlight was a flat, edge-to-edge
+ // rectangle spanning the label's whole vertical band (`label_top` to
+ // `height - 2`, full cell width minus 2px either side) - "big
+ // highlighting... for some reason", next to a saturated theme accent
+ // colour (`selected_bg` is `theme.default_border_color`, e.g.
+ // Catppuccin's mauve) it read as an oversized, disproportionate
+ // block rather than a label being picked out. Real file managers
+ // (Nautilus, Explorer) size the highlight to the text itself plus a
+ // small margin, not to the cell - snug and rounded, same "fill, not
+ // a frame" principle already applied to the context-menu rewrite.
+ const LABEL_PAD_X: f32 = 6.0;
+ const LABEL_PAD_Y: f32 = 3.0;
+ const LABEL_RADIUS: f32 = 5.0;
+ let text_height = FONT_PIXELS; // close enough for a snug box; exact ascent/descent isn't worth tracking here.
+ if selected {
+ let box_x0 = ((width as f32 - total) / 2.0 - LABEL_PAD_X).max(0.0);
+ let box_x1 = ((width as f32 + total) / 2.0 + LABEL_PAD_X).min(width as f32);
+ let box_y0 = (baseline - text_height - LABEL_PAD_Y).max(0.0);
+ let box_y1 = (baseline + LABEL_PAD_Y).min(height as f32);
+ fill_rounded_rect(&mut buf, width, height, box_x0.round() as i32, box_y0.round() as i32, box_x1.round() as i32, box_y1.round() as i32, LABEL_RADIUS, selected_bg, selected_bg);
+ }
+ let row_bg = selected_bg; // only meaningful when `selected`; `blit_glyph_on_transparent` below is used otherwise.
let mut pen_x = ((width as f32 - total) / 2.0).max(2.0);
for (ch, adv) in label.chars().zip(widths) {
if ch.is_control() {
@@ -254,7 +335,11 @@ pub(crate) fn render_desktop_icon(width: u32, height: u32, kind: crate::desktop_
if metrics.width > 0 && metrics.height > 0 {
let glyph_x = pen_x + metrics.xmin as f32;
let glyph_y = baseline - metrics.height as f32 - metrics.ymin as f32;
- blit_glyph_on_transparent(&mut buf, width, height, glyph_x.round() as i32, glyph_y.round() as i32, &metrics, &coverage, label_color);
+ if selected {
+ blit_glyph(&mut buf, width, height, glyph_x.round() as i32, glyph_y.round() as i32, &metrics, &coverage, row_bg, label_color);
+ } else {
+ blit_glyph_on_transparent(&mut buf, width, height, glyph_x.round() as i32, glyph_y.round() as i32, &metrics, &coverage, label_color);
+ }
}
pen_x += adv;
if pen_x as usize >= width {
@@ -352,6 +437,44 @@ fn fill_rounded_rect(buf: &mut [u8], width: usize, height: usize, x0: i32, y0: i
}
}
+/// Same rounded-rect antialiasing as `fill_rounded_rect`, but blends its
+/// edge pixels against whatever is *already* in `buf` instead of assuming
+/// a transparent canvas - needed for a menu row's hover highlight, which
+/// paints on top of the panel's own already-opaque background fill.
+/// `fill_rounded_rect` itself can't be reused there: its edge pixels
+/// premultiply toward black (correct on a blank canvas, where "not fully
+/// covered" means "let the transparent backdrop show through"), which
+/// would show up as a visible dark seam around every rounded hover chip
+/// sitting on top of an opaque panel instead of a clean blend into it.
+#[allow(clippy::too_many_arguments)]
+fn fill_rounded_rect_over(buf: &mut [u8], width: usize, height: usize, x0: i32, y0: i32, x1: i32, y1: i32, radius: f32, color: (u8, u8, u8)) {
+ let (w, h) = (width as i32, height as i32);
+ let radius = radius.min((x1 - x0) as f32 / 2.0).min((y1 - y0) as f32 / 2.0).max(0.0);
+ let new_px = rgb_to_bgra(color, 255);
+ for y in y0.max(0)..y1.min(h) {
+ for x in x0.max(0)..x1.min(w) {
+ let px = x as f32 + 0.5;
+ let py = y as f32 + 0.5;
+ let cx = px.clamp(x0 as f32 + radius, x1 as f32 - radius);
+ let cy = py.clamp(y0 as f32 + radius, y1 as f32 - radius);
+ let dist = ((px - cx).powi(2) + (py - cy).powi(2)).sqrt() - radius;
+ if dist >= 1.0 {
+ continue;
+ }
+ let idx = (y as usize * width + x as usize) * 4;
+ if dist <= -1.0 {
+ buf[idx..idx + 4].copy_from_slice(&new_px);
+ } else {
+ let t = 1.0 - smoothstep(-1.0, 1.0, dist);
+ let existing = &buf[idx..idx + 4];
+ let lerp = |a: u8, b: u8| (a as f32 + (b as f32 - a as f32) * t).round() as u8;
+ let blended = (lerp(existing[0], new_px[0]), lerp(existing[1], new_px[1]), lerp(existing[2], new_px[2]), lerp(existing[3], new_px[3]));
+ buf[idx..idx + 4].copy_from_slice(&[blended.0, blended.1, blended.2, blended.3]);
+ }
+ }
+ }
+}
+
fn smoothstep(edge0: f32, edge1: f32, x: f32) -> f32 {
let t = ((x - edge0) / (edge1 - edge0)).clamp(0.0, 1.0);
t * t * (3.0 - 2.0 * t)
diff --git a/crates/wayland/src/decoration/tests.rs b/crates/wayland/src/decoration/tests.rs
index c8eb9b2..5cbc12f 100644
--- a/crates/wayland/src/decoration/tests.rs
+++ b/crates/wayland/src/decoration/tests.rs
@@ -813,14 +813,26 @@ fn context_menu_highlighted_row_has_a_different_background_than_the_rest() {
}
#[test]
-fn context_menu_border_is_opaque_at_every_edge() {
+fn context_menu_panel_is_opaque_in_the_middle_but_rounded_at_the_corners() {
+ // Reported live: the old hard-square panel with a single 1px border
+ // read as "squished... not at all polished" next to this project's own
+ // AGS reference (`GlobalMenu/style.scss`'s `popover box.menu-list`,
+ // flat rows with no border, only the panel itself rounded). A real
+ // rounded corner means the exact corner pixel is now transparent, not
+ // opaque - the opposite of what this test used to assert - while an
+ // edge's midpoint (away from any corner's curve) and the panel's own
+ // interior stay fully opaque either way.
let items = [("Close", false)];
let buf = render_context_menu(100, 28, &items, (0, 0, 0), (0xff, 0xff, 0xff), (0, 0, 0), (0x99, 0x99, 0x99));
let alpha_at = |x: usize, y: usize| buf[(y * 100 + x) * 4 + 3];
- assert_eq!(alpha_at(0, 0), 255);
- assert_eq!(alpha_at(99, 0), 255);
- assert_eq!(alpha_at(0, 27), 255);
- assert_eq!(alpha_at(99, 27), 255);
+ assert_eq!(alpha_at(0, 0), 0, "the exact corner pixel is now outside the rounded curve, not a hard square");
+ // 2px in from the flat top/bottom edges, at the midpoint (far enough
+ // from either corner's own curve, and past this helper's own ~1px
+ // antialiasing band around every edge - see `fill_rounded_rect`'s
+ // shared smoothstep construction, not specific to this test).
+ assert_eq!(alpha_at(50, 2), 255, "just inside the flat top edge, away from either corner, is opaque");
+ assert_eq!(alpha_at(50, 25), 255, "just inside the flat bottom edge is opaque");
+ assert_eq!(alpha_at(50, 14), 255, "the panel's interior stays opaque");
}
#[test]
@@ -845,6 +857,21 @@ fn snap_flyout_border_is_opaque_at_every_outer_edge() {
}
#[test]
+fn render_desktop_icon_actually_draws_something_visible_for_every_kind() {
+ // Reported live: desktop icons construct and push correctly (confirmed
+ // via a live diagnostic: non-empty element list, correct position,
+ // no import error) but nothing visible shows up on screen - this
+ // isolates whether the rasterizer itself is the problem, offline,
+ // without needing a live compositor round-trip to check.
+ use crate::desktop_icons::IconKind;
+ for kind in [IconKind::Home, IconKind::Computer, IconKind::Trash, IconKind::Folder, IconKind::File] {
+ let buf = render_desktop_icon(88, 88, kind, "Test", false, (74, 144, 226), (240, 240, 240), (100, 100, 100), None);
+ let opaque_pixels = buf.chunks_exact(4).filter(|px| px[3] > 0).count();
+ assert!(opaque_pixels > 100, "{kind:?} drew only {opaque_pixels} non-transparent pixels out of {}", buf.len() / 4);
+ }
+}
+
+#[test]
fn snap_flyout_has_an_internal_grid_line_between_columns() {
let labels = ["A", "B", "C", "D", "E", "F"];
let (cell_w, cell_h) = (90, 60);
diff --git a/crates/wayland/src/desktop_icons.rs b/crates/wayland/src/desktop_icons.rs
index 4ba54f4..7cfcbd1 100644
--- a/crates/wayland/src/desktop_icons.rs
+++ b/crates/wayland/src/desktop_icons.rs
@@ -58,19 +58,35 @@ impl DesktopIcon {
}
pub(crate) struct DesktopIcons {
- /// Top-left of the grid's own `(0, 0)` cell, in global space - the
- /// primary monitor's usable-area origin plus `GRID_MARGIN`.
- pub(crate) origin: (i32, i32),
+ /// Top-left of the grid's own `(0, 0)` cell, in global space, one per
+ /// participating monitor - each monitor's own usable-area origin plus
+ /// `GRID_MARGIN`. The same `icons` list is mirrored at every origin
+ /// (`general.desktop_icons_all_monitors`, see `ensure_desktop_icons`):
+ /// one shared set of icons/cells, rendered and hit-tested again at each
+ /// monitor's own corner, rather than a separate icon set per monitor --
+ /// dragging a mirrored copy on any monitor moves the one underlying
+ /// icon, which then shows in its new cell everywhere it's mirrored.
+ /// Exactly one entry (the primary monitor's) when that config flag is
+ /// off, matching the original single-monitor behaviour.
+ pub(crate) origins: Vec<(i32, i32)>,
pub(crate) icons: Vec<DesktopIcon>,
}
impl DesktopIcons {
- /// Which icon (if any) global-space point `(x, y)` falls on - same
- /// shape as `ContextMenu::row_at`. Returns an index into `self.icons`,
- /// not the icon itself, so a caller holding `&mut self` can still
- /// mutate the match without a borrow conflict.
- pub(crate) fn icon_at(&self, x: i32, y: i32) -> Option<usize> {
- self.icons.iter().position(|icon| icon.contains(self.origin, x, y))
+ /// Which icon (if any) global-space point `(x, y)` falls on, checked
+ /// against every mirrored origin - same shape as `ContextMenu::
+ /// row_at`. Returns an index into `self.icons` plus the origin it
+ /// matched (needed by drag-start to grab the copy actually clicked,
+ /// not always the primary monitor's), not the icon itself, so a caller
+ /// holding `&mut self` can still mutate the match without a borrow
+ /// conflict.
+ pub(crate) fn icon_at(&self, x: i32, y: i32) -> Option<(usize, (i32, i32))> {
+ for &origin in &self.origins {
+ if let Some(i) = self.icons.iter().position(|icon| icon.contains(origin, x, y)) {
+ return Some((i, origin));
+ }
+ }
+ None
}
}
@@ -276,13 +292,25 @@ mod tests {
#[test]
fn icon_at_matches_only_its_own_cell() {
let icons = DesktopIcons {
- origin: (100, 100),
+ origins: vec![(100, 100)],
icons: vec![DesktopIcon { id: "a".into(), label: "a".into(), kind: IconKind::File, target: PathBuf::new(), cell: (1, 0), selected: false }],
};
- let (left, top) = icons.icons[0].top_left(icons.origin);
- assert_eq!(icons.icon_at(left, top), Some(0), "top-left corner of the cell");
- assert_eq!(icons.icon_at(left + CELL_WIDTH - 1, top + CELL_HEIGHT - 1), Some(0), "bottom-right pixel of the cell");
+ let origin = icons.origins[0];
+ let (left, top) = icons.icons[0].top_left(origin);
+ assert_eq!(icons.icon_at(left, top), Some((0, origin)), "top-left corner of the cell");
+ assert_eq!(icons.icon_at(left + CELL_WIDTH - 1, top + CELL_HEIGHT - 1), Some((0, origin)), "bottom-right pixel of the cell");
assert_eq!(icons.icon_at(left - 1, top), None, "just left of the cell");
assert_eq!(icons.icon_at(left + CELL_WIDTH, top), None, "just right of the cell");
}
+
+ #[test]
+ fn icon_at_checks_every_mirrored_origin() {
+ let icons = DesktopIcons {
+ origins: vec![(0, 0), (2000, 0)],
+ icons: vec![DesktopIcon { id: "a".into(), label: "a".into(), kind: IconKind::File, target: PathBuf::new(), cell: (0, 0), selected: false }],
+ };
+ assert_eq!(icons.icon_at(10, 10), Some((0, (0, 0))), "matches the first monitor's mirror");
+ assert_eq!(icons.icon_at(2010, 10), Some((0, (2000, 0))), "matches the second monitor's mirror, with its own origin");
+ assert_eq!(icons.icon_at(1000, 10), None, "the gap between the two monitors matches neither");
+ }
}
diff --git a/crates/wayland/src/desktop_menu.rs b/crates/wayland/src/desktop_menu.rs
index bd0bf34..3cb90ed 100644
--- a/crates/wayland/src/desktop_menu.rs
+++ b/crates/wayland/src/desktop_menu.rs
@@ -20,6 +20,9 @@ pub(crate) enum DesktopMenuAction {
/// convention as `Delete`.
EmptyTrash,
NewFolder,
+ /// "not even new file" - see `CompState::new_desktop_text_file`'s own
+ /// doc comment.
+ NewTextFile,
/// Spawns a terminal with `~/Desktop` as its working directory --
/// `general.terminal`, or a common-binary fallback list if unset.
OpenTerminalHere,
@@ -28,6 +31,10 @@ pub(crate) enum DesktopMenuAction {
/// paste, properties, ...), deliberately not reimplemented here.
OpenInFileManager,
Refresh,
+ /// A purely visual divider row - see `context_menu::MenuAction::
+ /// Separator`'s own doc comment (same shape, same reason, separate
+ /// enum since this menu and the titlebar one don't share one).
+ Separator,
}
pub(crate) struct DesktopMenu {
@@ -66,8 +73,11 @@ impl DesktopMenu {
pub(crate) fn open_for_desktop(pos: (i32, i32)) -> Self {
let items = vec![
("New Folder", DesktopMenuAction::NewFolder),
+ ("New Text Document", DesktopMenuAction::NewTextFile),
+ ("\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}", DesktopMenuAction::Separator),
("Open Terminal Here", DesktopMenuAction::OpenTerminalHere),
("Open in File Manager", DesktopMenuAction::OpenInFileManager),
+ ("\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}", DesktopMenuAction::Separator),
("Refresh", DesktopMenuAction::Refresh),
];
Self { pos, width: MENU_WIDTH, row_height: ROW_HEIGHT, items }
@@ -132,8 +142,9 @@ mod tests {
#[test]
fn desktop_menu_offers_the_full_set() {
let menu = DesktopMenu::open_for_desktop((10, 10));
- let labels: Vec<&str> = menu.items.iter().map(|(l, _)| *l).collect();
- assert_eq!(labels, vec!["New Folder", "Open Terminal Here", "Open in File Manager", "Refresh"]);
+ let real_actions: Vec<&str> =
+ menu.items.iter().filter(|(_, a)| !matches!(a, DesktopMenuAction::Separator)).map(|(l, _)| *l).collect();
+ assert_eq!(real_actions, vec!["New Folder", "New Text Document", "Open Terminal Here", "Open in File Manager", "Refresh"]);
}
#[test]
diff --git a/crates/wayland/src/icon_theme.rs b/crates/wayland/src/icon_theme.rs
new file mode 100644
index 0000000..eaecdad
--- /dev/null
+++ b/crates/wayland/src/icon_theme.rs
@@ -0,0 +1,278 @@
+//! Real desktop-icon artwork loaded from the user's installed freedesktop
+//! icon theme (GTK's own configured theme - WhiteSur on this machine),
+//! rendered from SVG via `resvg`. `decoration::render_desktop_icon`'s
+//! hand-drawn glyphs remain as the fallback for whatever this can't
+//! resolve (no icon theme installed at all, a name no theme in the chain
+//! ships, a corrupt SVG) - reported live as the hand-drawn glyphs reading
+//! as generic placeholder art next to every real desktop's own icons
+//! (GNOME/KDE/macOS/Windows all ship real theme artwork, not procedural
+//! shapes), so this is the real fix, not a redraw of the same shapes.
+//!
+//! Deliberately narrow: this compositor has no use for icon lookup beyond
+//! desktop icons (no taskbar/app-list that needs `.desktop`-file icon
+//! resolution), so this only ever looks up the five fixed names `desktop_
+//! icons.rs`'s own `IconKind` needs, not a general-purpose icon-theme
+//! library. A real (if partial) implementation of the freedesktop icon
+//! theme spec: theme `Inherits=` chains walked recursively, `hicolor`
+//! always searched last, `scalable` preferred over any fixed raster size
+//! since every lookup here wants one specific pixel size rendered from
+//! source, not picked from a handful of pre-baked ones.
+
+use std::collections::HashSet;
+use std::path::{Path, PathBuf};
+
+use crate::desktop_icons::IconKind;
+
+/// Canonical freedesktop icon name for each `IconKind` - the name every
+/// spec-compliant theme (GNOME's Adwaita, KDE's Breeze, macOS-style
+/// WhiteSur, etc.) ships its own artwork under.
+pub(crate) fn icon_name(kind: IconKind) -> &'static str {
+ match kind {
+ IconKind::Home => "user-home",
+ IconKind::Computer => "computer",
+ IconKind::Trash => "user-trash",
+ IconKind::Folder => "folder",
+ IconKind::File => "text-x-generic",
+ }
+}
+
+/// GTK's configured icon theme name - read the same places GTK itself
+/// would, so this follows whatever the user actually has set (WhiteSur
+/// here) rather than a hardcoded choice: `$GTK_THEME`'s icon-theme
+/// sibling doesn't exist as its own env var, so `gtk-3.0/settings.ini`
+/// (present even in a GNOME-less session, unlike `gsettings`, which needs
+/// a working dconf backend) is checked first, `gsettings` second, falling
+/// back to `"hicolor"` - the one theme the spec guarantees exists
+/// alongside any other, so a lookup always has *something* to search
+/// rather than an empty chain.
+fn configured_theme_name() -> String {
+ let home = std::env::var("HOME").unwrap_or_default();
+ let candidates = [format!("{home}/.config/gtk-3.0/settings.ini"), format!("{home}/.config/gtk-4.0/settings.ini")];
+ for path in candidates {
+ if let Ok(contents) = std::fs::read_to_string(&path) {
+ for line in contents.lines() {
+ let line = line.trim();
+ if let Some(value) = line.strip_prefix("gtk-icon-theme-name=") {
+ let value = value.trim();
+ if !value.is_empty() {
+ return value.to_string();
+ }
+ }
+ }
+ }
+ }
+ if let Ok(output) = std::process::Command::new("gsettings").args(["get", "org.gnome.desktop.interface", "icon-theme"]).output() {
+ if output.status.success() {
+ let value = String::from_utf8_lossy(&output.stdout);
+ let value = value.trim().trim_matches('\'');
+ if !value.is_empty() {
+ return value.to_string();
+ }
+ }
+ }
+ "hicolor".to_string()
+}
+
+/// Base directories icon themes live under, in the priority XDG's own
+/// icon theme spec defines: the user's own override directories first,
+/// then every `$XDG_DATA_DIRS` entry's `icons` subdirectory, then the
+/// hardcoded system fallback last.
+fn icon_base_dirs() -> Vec<PathBuf> {
+ let home = std::env::var("HOME").unwrap_or_default();
+ let mut dirs = vec![PathBuf::from(format!("{home}/.local/share/icons")), PathBuf::from(format!("{home}/.icons"))];
+ let data_dirs = std::env::var("XDG_DATA_DIRS").unwrap_or_else(|_| "/usr/local/share:/usr/share".to_string());
+ for dir in data_dirs.split(':') {
+ if !dir.is_empty() {
+ dirs.push(PathBuf::from(dir).join("icons"));
+ }
+ }
+ dirs.push(PathBuf::from("/usr/share/icons"));
+ dirs
+}
+
+/// `<theme>/index.theme`'s own `Inherits=a,b,c` line, parsed into a plain
+/// list - empty if the theme has no index (a raw, index-less icon
+/// directory, or a name that doesn't resolve to anything installed at
+/// all) or no `Inherits` key of its own.
+fn theme_inherits(theme_dir: &Path) -> Vec<String> {
+ let Ok(contents) = std::fs::read_to_string(theme_dir.join("index.theme")) else { return Vec::new() };
+ for line in contents.lines() {
+ if let Some(value) = line.trim().strip_prefix("Inherits=") {
+ return value.split(',').map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).collect();
+ }
+ }
+ Vec::new()
+}
+
+/// Every theme name to search, in order: `theme` itself, then its own
+/// `Inherits=` chain walked recursively (a theme installed under more
+/// than one base directory only needs to contribute its name once), with
+/// `hicolor` appended at the end if nothing in the chain already named it
+/// - the spec's own explicit last-resort fallback.
+fn theme_search_chain(theme: &str) -> Vec<String> {
+ let bases = icon_base_dirs();
+ let mut chain = Vec::new();
+ let mut seen = HashSet::new();
+ let mut queue = vec![theme.to_string()];
+ while let Some(name) = queue.pop() {
+ if !seen.insert(name.clone()) {
+ continue;
+ }
+ chain.push(name.clone());
+ for base in &bases {
+ let dir = base.join(&name);
+ if dir.is_dir() {
+ queue.extend(theme_inherits(&dir));
+ break;
+ }
+ }
+ }
+ if !seen.contains("hicolor") {
+ chain.push("hicolor".to_string());
+ }
+ chain
+}
+
+/// Walks every subdirectory of `theme_dir` (unbounded depth is
+/// unnecessary - real theme trees are `<context>/<size-or-scalable>/`,
+/// two levels deep) looking for `<name>.svg` or `<name>.png`, preferring
+/// an exact `scalable` directory match (vector art renders correctly at
+/// any size this compositor asks for) over a fixed-size raster one.
+fn find_in_theme(theme_dir: &Path, name: &str) -> Option<PathBuf> {
+ let mut scalable_hit = None;
+ let mut raster_hit = None;
+ let Ok(contexts) = std::fs::read_dir(theme_dir) else { return None };
+ for context in contexts.flatten() {
+ let context_path = context.path();
+ if !context_path.is_dir() {
+ continue;
+ }
+ let Ok(sizes) = std::fs::read_dir(&context_path) else { continue };
+ for size_dir in sizes.flatten() {
+ let size_path = size_dir.path();
+ if !size_path.is_dir() {
+ continue;
+ }
+ let is_scalable = size_dir.file_name().to_string_lossy().contains("scalable");
+ for ext in ["svg", "png"] {
+ let candidate = size_path.join(format!("{name}.{ext}"));
+ if candidate.is_file() {
+ if is_scalable && ext == "svg" {
+ scalable_hit.get_or_insert(candidate);
+ } else {
+ raster_hit.get_or_insert(candidate);
+ }
+ }
+ }
+ }
+ }
+ scalable_hit.or(raster_hit)
+}
+
+/// Resolves `name` (one of this module's own fixed canonical names) to a
+/// real on-disk icon file, searching the configured theme's full
+/// inheritance chain. `None` means "no installed theme ships this icon at
+/// all" - a legitimate, expected outcome on a minimal system, not an
+/// error; the caller falls back to the hand-drawn glyph.
+pub(crate) fn find_icon(name: &str) -> Option<PathBuf> {
+ let theme = configured_theme_name();
+ let bases = icon_base_dirs();
+ for theme_name in theme_search_chain(&theme) {
+ for base in &bases {
+ let theme_dir = base.join(&theme_name);
+ if theme_dir.is_dir() {
+ if let Some(found) = find_in_theme(&theme_dir, name) {
+ return Some(found);
+ }
+ }
+ }
+ }
+ // `/usr/share/pixmaps` is flat (no theme/context/size structure at
+ // all) - the spec's own final fallback location, checked last.
+ let flat = PathBuf::from(format!("/usr/share/pixmaps/{name}.png"));
+ flat.is_file().then_some(flat)
+}
+
+/// Rasterizes the SVG at `path` into a `width`x`height` BGRA8 buffer --
+/// the same byte order every other rasterizer in `decoration.rs` produces
+/// (see that module's own doc comment) - so the caller can hand the
+/// result straight to `MemoryRenderBuffer::from_slice` unchanged. `None`
+/// on any read/parse/render failure (corrupt file, a `resvg`/`usvg`
+/// feature gap): the caller degrades to the hand-drawn glyph rather than
+/// propagating an error, the same "a missing/bad theme icon shouldn't be
+/// worse than not trying" stance `find_icon` itself takes.
+pub(crate) fn rasterize_svg(path: &Path, width: u32, height: u32) -> Option<Vec<u8>> {
+ let data = std::fs::read(path).ok()?;
+ let opt = usvg::Options::default();
+ let tree = usvg::Tree::from_data(&data, &opt).ok()?;
+ let mut pixmap = tiny_skia::Pixmap::new(width, height)?;
+ let size = tree.size();
+ let scale = (width as f32 / size.width()).min(height as f32 / size.height());
+ let offset_x = (width as f32 - size.width() * scale) / 2.0;
+ let offset_y = (height as f32 - size.height() * scale) / 2.0;
+ let transform = tiny_skia::Transform::from_translate(offset_x, offset_y).pre_scale(scale, scale);
+ resvg::render(&tree, transform, &mut pixmap.as_mut());
+ let rgba = pixmap.data();
+ let mut bgra = vec![0u8; rgba.len()];
+ for (dst, src) in bgra.chunks_exact_mut(4).zip(rgba.chunks_exact(4)) {
+ dst[0] = src[2];
+ dst[1] = src[1];
+ dst[2] = src[0];
+ dst[3] = src[3];
+ }
+ Some(bgra)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn every_icon_kind_maps_to_a_real_freedesktop_name() {
+ // Locks in the spec names themselves - a typo here would silently
+ // never match any real theme, degrading permanently to the
+ // hand-drawn fallback with no obvious error anywhere.
+ assert_eq!(icon_name(IconKind::Home), "user-home");
+ assert_eq!(icon_name(IconKind::Computer), "computer");
+ assert_eq!(icon_name(IconKind::Trash), "user-trash");
+ assert_eq!(icon_name(IconKind::Folder), "folder");
+ assert_eq!(icon_name(IconKind::File), "text-x-generic");
+ }
+
+ #[test]
+ fn hicolor_is_always_in_the_search_chain_even_for_an_unrelated_theme() {
+ let chain = theme_search_chain("a-theme-name-that-does-not-exist-anywhere");
+ assert!(chain.contains(&"hicolor".to_string()), "the spec's own last-resort fallback must always be searched");
+ }
+
+ #[test]
+ #[ignore = "visual spot-check only, dumps PNGs to /tmp - not run in CI"]
+ fn dump_every_icon_for_visual_inspection() {
+ for kind in [IconKind::Home, IconKind::Computer, IconKind::Trash, IconKind::Folder, IconKind::File] {
+ let name = icon_name(kind);
+ let Some(path) = find_icon(name) else {
+ eprintln!("{name}: not found in any theme");
+ continue;
+ };
+ eprintln!("{name}: {path:?}");
+ let mut pixmap = tiny_skia::Pixmap::new(40, 36).unwrap();
+ let opt = usvg::Options::default();
+ let data = std::fs::read(&path).unwrap();
+ let tree = usvg::Tree::from_data(&data, &opt).unwrap();
+ let size = tree.size();
+ let scale = (40.0 / size.width()).min(36.0 / size.height());
+ let transform = tiny_skia::Transform::from_scale(scale, scale);
+ resvg::render(&tree, transform, &mut pixmap.as_mut());
+ pixmap.save_png(format!("/tmp/icon-{name}.png")).unwrap();
+ }
+ }
+
+ #[test]
+ fn the_chain_has_no_duplicate_even_if_a_theme_inherits_hicolor_explicitly() {
+ // `WhiteSur-dark`'s own real `index.theme` (checked live on this
+ // machine) already lists `Inherits=hicolor,breeze` - this locks
+ // in that appending hicolor again afterward doesn't happen.
+ let chain = theme_search_chain("hicolor");
+ assert_eq!(chain.iter().filter(|n| n.as_str() == "hicolor").count(), 1);
+ }
+}
diff --git a/crates/wayland/src/input/pointer.rs b/crates/wayland/src/input/pointer.rs
index f0e034e..602f613 100644
--- a/crates/wayland/src/input/pointer.rs
+++ b/crates/wayland/src/input/pointer.rs
@@ -231,6 +231,8 @@ pub(crate) fn handle_pointer_position(state: &mut CompState, pos: Point<f64, Log
// A no-op whenever no desktop icon is currently being dragged - see
// `CompState::update_desktop_icon_drag`'s own doc comment.
state.update_desktop_icon_drag((pos.x as i32, pos.y as i32));
+ // Likewise a no-op whenever no marquee selection is in progress.
+ state.update_desktop_marquee((pos.x as i32, pos.y as i32));
// Tells core which monitor the pointer is physically over right now --
// core has no pointer of its own to know this (see `pointer_monitor`'s
@@ -505,6 +507,17 @@ pub(crate) fn handle_pointer_button(state: &mut CompState, pos: Point<f64, Logic
if let Some(menu) = state.context_menu.take() {
if let Some(row) = menu.row_at(pos.x as i32, pos.y as i32) {
let (_, action) = menu.items[row];
+ // A separator row occupies real space (`row_at` resolves a
+ // click on it same as any other) but isn't a real action --
+ // same "click does nothing, menu stays open" convention any
+ // native menu's own divider follows, rather than either
+ // running a no-op action or dismissing the whole menu on
+ // what was very possibly a slightly-off click at a real
+ // item just above/below it.
+ if matches!(action, crate::context_menu::MenuAction::Separator) {
+ state.context_menu = Some(menu);
+ return;
+ }
state.close_context_menu();
state.run_context_menu_action(menu.window, action);
} else {
@@ -528,6 +541,10 @@ pub(crate) fn handle_pointer_button(state: &mut CompState, pos: Point<f64, Logic
if let Some(menu) = state.desktop_menu.take() {
if let Some(row) = menu.row_at(pos.x as i32, pos.y as i32) {
let (_, action) = menu.items[row].clone();
+ if matches!(action, crate::desktop_menu::DesktopMenuAction::Separator) {
+ state.desktop_menu = Some(menu);
+ return;
+ }
state.close_desktop_menu();
state.run_desktop_menu_action(action);
} else {
@@ -635,19 +652,28 @@ pub(crate) fn handle_pointer_button(state: &mut CompState, pos: Point<f64, Logic
// comment): a desktop icon here, single- or double-click
// per `general.desktop_icon_single_click`, otherwise clear
// whatever was selected.
- let icon_hit = state.desktop_icons.as_ref().and_then(|icons| icons.icon_at(pos.x as i32, pos.y as i32).map(|i| icons.icons[i].id.clone()));
+ let icon_hit = state
+ .desktop_icons
+ .as_ref()
+ .and_then(|icons| icons.icon_at(pos.x as i32, pos.y as i32).map(|(i, origin)| (icons.icons[i].id.clone(), origin)));
match icon_hit {
- Some(id) => {
+ Some((id, origin)) => {
let single_click_opens = state.wm.borrow().desktop_icon_single_click;
if single_click_opens || state.is_double_click_icon(&id, time) {
state.select_desktop_icon(Some(&id));
state.open_desktop_icon(&id);
} else {
state.select_desktop_icon(Some(&id));
- state.start_desktop_icon_drag(&id, (pos.x as i32, pos.y as i32));
+ state.start_desktop_icon_drag(&id, origin, (pos.x as i32, pos.y as i32));
}
}
- None => state.select_desktop_icon(None),
+ // Genuinely bare desktop, not just "no icon under the
+ // pointer" - starts a rubber-band selection instead of
+ // only clearing whatever was selected before. The one
+ // "click and drag" desktop interaction this compositor
+ // never had (reported live next to "missing click and
+ // drag stuff like from windows").
+ None => state.start_desktop_marquee((pos.x as i32, pos.y as i32)),
}
}
}
@@ -681,7 +707,7 @@ pub(crate) fn handle_pointer_button(state: &mut CompState, pos: Point<f64, Logic
(BTN_RIGHT, None)
if layer_surface_under(state, pos).is_none() && !state.space.element_under(pos).is_some_and(|(w, _)| dwindow_is_visible(state, w)) =>
{
- let icon_hit = state.desktop_icons.as_ref().and_then(|icons| icons.icon_at(pos.x as i32, pos.y as i32).map(|i| icons.icons[i].id.clone()));
+ let icon_hit = state.desktop_icons.as_ref().and_then(|icons| icons.icon_at(pos.x as i32, pos.y as i32).map(|(i, _origin)| icons.icons[i].id.clone()));
match icon_hit {
Some(id) => {
state.select_desktop_icon(Some(&id));
@@ -697,6 +723,7 @@ pub(crate) fn handle_pointer_button(state: &mut CompState, pos: Point<f64, Logic
// always checked on release, same as `was_dragging`/`was_resizing`
// below, just for a desktop icon instead of a window.
state.end_desktop_icon_drag();
+ state.end_desktop_marquee();
let mut wm = state.wm.borrow_mut();
let was_dragging = wm.is_dragging();
let was_resizing = wm.is_resizing();
@@ -712,6 +739,14 @@ pub(crate) fn handle_pointer_button(state: &mut CompState, pos: Point<f64, Logic
} else if was_resizing {
wm.end_resize();
}
+ // Persists whatever `end_drag`/`end_resize` just updated in
+ // `remembered_geometry` - a real user action (button released
+ // after a drag/resize), not a per-frame event, so writing the
+ // whole small table each time is cheap and needs no separate
+ // dirty-tracking. See `window_memory.rs`'s own doc comment.
+ if was_dragging || was_resizing {
+ crate::window_memory::save_all(wm.all_remembered_geometry());
+ }
drop(wm);
// `end_drag` can snap the geometry one more time (edge/top-of-
// screen snapping, `SmartPlacement::snap_zone`) *after* the last
diff --git a/crates/wayland/src/lib.rs b/crates/wayland/src/lib.rs
index 91168a1..e396491 100644
--- a/crates/wayland/src/lib.rs
+++ b/crates/wayland/src/lib.rs
@@ -55,6 +55,7 @@ mod foreign_toplevel;
mod gamma_control;
mod gtk_shell;
mod gtk_shell_protocol;
+mod icon_theme;
mod input;
mod lock;
mod monitor_layout;
@@ -68,6 +69,8 @@ mod screencopy;
mod state;
mod trash;
mod udev;
+mod virtual_pointer;
+mod window_memory;
mod winit;
mod workspace;
mod xkb_config;
diff --git a/crates/wayland/src/state/desktop_icons.rs b/crates/wayland/src/state/desktop_icons.rs
index bdaff43..f111336 100644
--- a/crates/wayland/src/state/desktop_icons.rs
+++ b/crates/wayland/src/state/desktop_icons.rs
@@ -52,19 +52,44 @@ impl CompState {
if !self.wm.borrow().desktop_icons_enabled {
return;
}
- let Some(monitor) = self.wm.borrow().monitors().iter().find(|m| m.primary).cloned() else { return };
- let origin = (monitor.geometry.x + GRID_MARGIN, monitor.geometry.y + GRID_MARGIN);
+ let origins = self.desktop_icon_origins();
+ if origins.is_empty() {
+ return;
+ }
+ // Rows (for the default-cell-assignment grid) are always derived
+ // from the *primary* monitor's own height, even when mirroring onto
+ // every monitor - one shared cell layout for the shared icon list,
+ // not a different grid shape per monitor.
+ let rows_source = self.wm.borrow().monitors().iter().find(|m| m.primary).map(|m| m.geometry.height as i32).unwrap_or(600);
if let Some(icons) = &mut self.desktop_icons {
- icons.origin = origin;
+ icons.origins = origins;
return;
}
- let rows = ((monitor.geometry.height as i32 - 2 * GRID_MARGIN) / CELL_HEIGHT).max(1);
+ let rows = ((rows_source - 2 * GRID_MARGIN) / CELL_HEIGHT).max(1);
let saved = crate::desktop_icons_state::load();
let icons = crate::desktop_icons::rescan(&saved, rows);
- self.desktop_icons = Some(DesktopIcons { origin, icons });
+ self.desktop_icons = Some(DesktopIcons { origins, icons });
self.desktop_icon_buffers.clear();
}
+ /// One grid origin per monitor icons should mirror onto: every enabled
+ /// monitor when `general.desktop_icons_all_monitors` is on (the
+ /// default - see that field's own doc comment), otherwise just the
+ /// primary monitor's, matching the original single-monitor behaviour.
+ /// Sorted by monitor id so the list (and therefore which origin
+ /// `icon_at` matches first) is stable call to call, not at the mercy of
+ /// `WindowManager::monitors()`'s own iteration order.
+ fn desktop_icon_origins(&self) -> Vec<(i32, i32)> {
+ let wm = self.wm.borrow();
+ let mut monitors = wm.monitors().to_vec();
+ monitors.sort_by_key(|m| m.id);
+ if wm.desktop_icons_all_monitors {
+ monitors.iter().map(|m| (m.geometry.x + GRID_MARGIN, m.geometry.y + GRID_MARGIN)).collect()
+ } else {
+ monitors.iter().find(|m| m.primary).map(|m| vec![(m.geometry.x + GRID_MARGIN, m.geometry.y + GRID_MARGIN)]).unwrap_or_default()
+ }
+ }
+
/// Re-derives the icon list from the real filesystem (a new/removed
/// `~/Desktop` entry) without disturbing any already-persisted cell --
/// `rescan` itself already only assigns a fresh default cell to an
@@ -73,9 +98,9 @@ impl CompState {
let Some(icons) = &self.desktop_icons else { return };
let rows = ((self.primary_monitor_height()) / CELL_HEIGHT).max(1);
let saved = crate::desktop_icons_state::load();
- let origin = icons.origin;
+ let origins = icons.origins.clone();
let icons = crate::desktop_icons::rescan(&saved, rows);
- self.desktop_icons = Some(DesktopIcons { origin, icons });
+ self.desktop_icons = Some(DesktopIcons { origins, icons });
self.desktop_icon_buffers.clear();
}
@@ -100,6 +125,19 @@ impl CompState {
Some((rid, buf)) if rid == id => format!("{buf}_").into(),
_ => icon.label.as_str().into(),
};
+ // Real icon-theme artwork (WhiteSur, or whatever the user has
+ // configured) when it resolves; `None` - no installed theme ships
+ // this name, or the file it found didn't parse/render - falls
+ // back to `render_desktop_icon`'s own hand-drawn glyph rather than
+ // a blank box. Re-looked-up on every rebuild (icon selection
+ // toggling, a rename) rather than cached separately: rebuilds are
+ // already infrequent (see this function's own doc comment), and a
+ // theme change while running should just work on the next one
+ // without a separate cache-invalidation path to get wrong.
+ let glyph_box = decoration::desktop_icon_glyph_box(CELL_WIDTH as u32, CELL_HEIGHT as u32);
+ let (glyph_w, glyph_h) = ((glyph_box.2 - glyph_box.0).max(1) as u32, (glyph_box.3 - glyph_box.1).max(1) as u32);
+ let real_icon = crate::icon_theme::find_icon(crate::icon_theme::icon_name(icon.kind))
+ .and_then(|path| crate::icon_theme::rasterize_svg(&path, glyph_w, glyph_h));
let data = decoration::render_desktop_icon(
CELL_WIDTH as u32,
CELL_HEIGHT as u32,
@@ -109,6 +147,7 @@ impl CompState {
ICON_COLOR,
label_color,
theme.default_border_color,
+ real_icon.as_deref(),
);
let buffer = MemoryRenderBuffer::from_slice(&data, Fourcc::Argb8888, (CELL_WIDTH, CELL_HEIGHT), 1, Transform::Normal, None);
self.desktop_icon_buffers.insert(id.to_string(), buffer);
@@ -130,9 +169,9 @@ impl CompState {
pub(crate) fn desktop_icon_render_list(&mut self) -> Vec<((i32, i32), MemoryRenderBuffer)> {
let Some(icons) = &self.desktop_icons else { return Vec::new() };
let ids: Vec<String> = icons.icons.iter().map(|i| i.id.clone()).collect();
- let origin = icons.origin;
+ let origins = icons.origins.clone();
let dragging = self.desktop_icon_drag.clone();
- let mut out = Vec::with_capacity(ids.len());
+ let mut out = Vec::with_capacity(ids.len() * origins.len());
for id in ids {
let buffer = match self.icon_buffer(&id) {
Some(b) => b.clone(),
@@ -140,11 +179,19 @@ impl CompState {
};
let icons = self.desktop_icons.as_ref().unwrap();
let icon = icons.icons.iter().find(|i| i.id == id).unwrap();
- let pos = match &dragging {
- Some((drag_id, _, live_pos)) if *drag_id == id => *live_pos,
- _ => icon.top_left(origin),
- };
- out.push((pos, buffer));
+ // The dragged copy follows the live pointer on whichever
+ // monitor it's actually being dragged over; every other
+ // mirror (if any) stays put at its own origin's cell position
+ // - dragging on one monitor doesn't yank the icon's other
+ // mirrors around mid-gesture, only the one actually grabbed.
+ match &dragging {
+ Some((drag_id, _, live_pos)) if *drag_id == id => out.push((*live_pos, buffer)),
+ _ => {
+ for &origin in &origins {
+ out.push((icon.top_left(origin), buffer.clone()));
+ }
+ }
+ }
}
out
}
@@ -167,6 +214,66 @@ impl CompState {
}
}
+ /// Starts a rubber-band selection at `pos` (global space) - clears
+ /// whatever was selected before, matching real desktop convention
+ /// (Windows/GNOME/macOS all start a fresh marquee selection, not an
+ /// additive one, unless a modifier like Ctrl/Shift is held - not
+ /// implemented here, same as this menu's own already-documented "no
+ /// multi-select via click" gap, just now closed for the drag case).
+ pub(crate) fn start_desktop_marquee(&mut self, pos: (i32, i32)) {
+ self.select_desktop_icon(None);
+ self.desktop_marquee = Some((pos, pos));
+ }
+
+ /// Updates the live end corner of an in-progress marquee and re-
+ /// selects whatever icon cells the resulting rectangle now overlaps --
+ /// called from every pointer-motion event while a marquee is active,
+ /// same shape as `update_desktop_icon_drag`.
+ pub(crate) fn update_desktop_marquee(&mut self, pos: (i32, i32)) {
+ let Some((start, _)) = self.desktop_marquee else { return };
+ self.desktop_marquee = Some((start, pos));
+ let (x0, y0) = (start.0.min(pos.0), start.1.min(pos.1));
+ let (x1, y1) = (start.0.max(pos.0), start.1.max(pos.1));
+ // Only the mirror on whichever monitor the marquee itself *started*
+ // on - a drag on one monitor selecting another monitor's mirrored
+ // copies (or double-selecting both) would be actively confusing,
+ // not a feature. Falls back to every origin if no monitor claims
+ // the start point at all (shouldn't happen in practice; `Compositor
+ // ::pointer_monitor`-style clamping already keeps the pointer
+ // inside some monitor's bounds).
+ let origins: Vec<(i32, i32)> = {
+ let wm = self.wm.borrow();
+ match wm.monitors().iter().find(|m| m.full_geometry.contains_point(start.0, start.1)) {
+ Some(m) => vec![(m.geometry.x + GRID_MARGIN, m.geometry.y + GRID_MARGIN)],
+ None => self.desktop_icons.as_ref().map(|i| i.origins.clone()).unwrap_or_default(),
+ }
+ };
+ let Some(icons) = &mut self.desktop_icons else { return };
+ let mut changed = Vec::new();
+ for icon in &mut icons.icons {
+ let overlaps = origins.iter().any(|&origin| {
+ let (left, top) = icon.top_left(origin);
+ let (right, bottom) = (left + CELL_WIDTH, top + CELL_HEIGHT);
+ left < x1 && right > x0 && top < y1 && bottom > y0
+ });
+ if icon.selected != overlaps {
+ icon.selected = overlaps;
+ changed.push(icon.id.clone());
+ }
+ }
+ for id in changed {
+ self.rebuild_icon_buffer(&id);
+ }
+ }
+
+ /// Ends an in-progress marquee, if any - the final `update_desktop_
+ /// marquee` call already left the right icons selected, so this only
+ /// needs to clear the drag state itself (and the rendered outline with
+ /// it).
+ pub(crate) fn end_desktop_marquee(&mut self) {
+ self.desktop_marquee = None;
+ }
+
/// True when this press is the second of a double-click on the same
/// icon - same 400ms threshold and reset-after-a-double shape as
/// `is_double_click`, keyed by `DesktopIcon::id` since an icon has no
@@ -181,10 +288,16 @@ impl CompState {
doubled
}
- pub(crate) fn start_desktop_icon_drag(&mut self, id: &str, pointer: (i32, i32)) {
+ /// `origin` is whichever mirror was actually clicked (`icon_at`'s own
+ /// return value, threaded through by the caller) - with the icon
+ /// mirrored onto several monitors, grabbing it relative to the copy
+ /// under the pointer, not always the primary monitor's, is what makes
+ /// the drag track the cursor instead of jumping to a different
+ /// monitor's copy the instant the drag starts.
+ pub(crate) fn start_desktop_icon_drag(&mut self, id: &str, origin: (i32, i32), pointer: (i32, i32)) {
let Some(icons) = &self.desktop_icons else { return };
let Some(icon) = icons.icons.iter().find(|i| i.id == id) else { return };
- let top_left = icon.top_left(icons.origin);
+ let top_left = icon.top_left(origin);
let grab_offset = (pointer.0 - top_left.0, pointer.1 - top_left.1);
self.desktop_icon_drag = Some((id.to_string(), grab_offset, top_left));
}
@@ -204,8 +317,22 @@ impl CompState {
/// and persists it.
pub(crate) fn end_desktop_icon_drag(&mut self) {
let Some((id, _, live_pos)) = self.desktop_icon_drag.take() else { return };
+ // The cell math below needs the origin of whichever monitor the
+ // icon was actually dropped on, not always the first mirror --
+ // recomputed fresh (same formula `desktop_icon_origins` uses)
+ // rather than searched for in `icons.origins`, since a drop
+ // just past every monitor's own strict icon-grid rect (but still
+ // on-screen) should still resolve to that monitor's grid, not fall
+ // through to a stale/wrong one.
+ let origin = self
+ .wm
+ .borrow()
+ .monitors()
+ .iter()
+ .find(|m| m.full_geometry.contains_point(live_pos.0, live_pos.1))
+ .map(|m| (m.geometry.x + GRID_MARGIN, m.geometry.y + GRID_MARGIN))
+ .unwrap_or(self.desktop_icons.as_ref().map(|i| i.origins.first().copied().unwrap_or((0, 0))).unwrap_or((0, 0)));
let Some(icons) = &mut self.desktop_icons else { return };
- let origin = icons.origin;
let raw = (live_pos.0 - origin.0, live_pos.1 - origin.1);
let raw_cell = ((raw.0 as f64 / CELL_WIDTH as f64).round() as i32, (raw.1 as f64 / CELL_HEIGHT as f64).round() as i32).max_zero();
let occupied: std::collections::HashSet<(i32, i32)> = icons.icons.iter().filter(|i| i.id != id).map(|i| i.cell).collect();
@@ -364,6 +491,27 @@ impl CompState {
self.refresh_desktop_icons();
}
+ /// "New > Text Document" (Windows) / a blank file (macOS's own desktop
+ /// menu has no direct equivalent, but every mainstream file manager
+ /// does) - the concrete gap behind "not even new file" reported live
+ /// against this menu next to Windows'/macOS' own. Same collision-
+ /// avoidance and refresh as `new_desktop_folder` just above.
+ pub(crate) fn new_desktop_text_file(&mut self) {
+ let Ok(home) = std::env::var("HOME") else { return };
+ let desktop = std::path::PathBuf::from(home).join("Desktop");
+ let mut name = "New Text Document.txt".to_string();
+ let mut n = 2;
+ while desktop.join(&name).exists() {
+ name = format!("New Text Document ({n}).txt");
+ n += 1;
+ }
+ if let Err(e) = std::fs::write(desktop.join(&name), "") {
+ log::warn!("desktop_icons: couldn't create {name:?}: {e}");
+ return;
+ }
+ self.refresh_desktop_icons();
+ }
+
pub(crate) fn open_desktop_icon_menu(&mut self, icon_id: &str, pos: (i32, i32)) {
let Some(icons) = &self.desktop_icons else { return };
let Some(icon) = icons.icons.iter().find(|i| i.id == icon_id) else { return };
@@ -397,9 +545,15 @@ impl CompState {
DesktopMenuAction::Delete(id) => self.delete_desktop_icon(&id),
DesktopMenuAction::EmptyTrash => self.empty_trash(),
DesktopMenuAction::NewFolder => self.new_desktop_folder(),
+ DesktopMenuAction::NewTextFile => self.new_desktop_text_file(),
DesktopMenuAction::OpenTerminalHere => self.open_terminal_here(),
DesktopMenuAction::OpenInFileManager => self.open_desktop_in_file_manager(),
DesktopMenuAction::Refresh => self.refresh_desktop_icons(),
+ // Never actually reached - the click-dispatch site intercepts
+ // `Separator` first, same as `context_menu::MenuAction::
+ // Separator`'s own dispatch. Handled here too so this match
+ // stays exhaustive.
+ DesktopMenuAction::Separator => {}
}
}
}
diff --git a/crates/wayland/src/state/geometry.rs b/crates/wayland/src/state/geometry.rs
index 26c23fe..3d201dd 100644
--- a/crates/wayland/src/state/geometry.rs
+++ b/crates/wayland/src/state/geometry.rs
@@ -36,7 +36,13 @@ impl CompState {
/// through the inherent method below wherever a plain `&self` is
/// available (input handling, `redraw_decoration_buffer`); this
/// version exists for the render loops specifically.
- pub(crate) fn effective_frame_of(wm: &Rc<RefCell<WindowManager>>, id_to_window: &HashMap<WindowId, DWindow>, id: WindowId, geom: srdwm_core::Rect) -> srdwm_core::Rect {
+ pub(crate) fn effective_frame_of(
+ wm: &Rc<RefCell<WindowManager>>,
+ id_to_window: &HashMap<WindowId, DWindow>,
+ pending_size_configure: &HashMap<WindowId, ((i32, i32), Instant)>,
+ id: WindowId,
+ geom: srdwm_core::Rect,
+ ) -> srdwm_core::Rect {
// A version of this function briefly (this same session) skipped
// the committed-size correction below entirely during an active
// resize, on the reasoning that trusting the client's stale last
@@ -81,6 +87,36 @@ impl CompState {
if wm.borrow().resizing_window() == Some(id) {
return geom;
}
+ // Same reasoning as the active-resize branch just above, for a gap
+ // that isn't a drag at all: a plain cross-monitor *move* between two
+ // differently-scaled outputs still forces a size-changing configure
+ // (see `sync_geometry`'s own doc comment - a window's physical
+ // footprint stays constant across the move, so its *logical* size,
+ // what the client is actually told, necessarily changes with the
+ // new monitor's scale). Until the client catches up, `bbox()` below
+ // still reflects its *last real commit* - content sized for the
+ // *old* monitor's scale - but `w.monitor` has already flipped to
+ // the *new* one (updated live, every drag tick, independently of
+ // any commit). Multiplying the stale logical content by the new
+ // scale produces neither the old physical size nor the new one, a
+ // real mismatch between where the border/shadow get drawn and where
+ // the client's actual pixels are. Reported live, confirmed with
+ // before/after screenshots: a window's left border missing and its
+ // content clipped for one frame right after crossing from a
+ // scale-0.843 output onto a scale-1.0 one, self-correcting once the
+ // client's own commit landed - see `docs/TODO.md`. `pending_size_
+ // configure` (see its own doc comment) already tracks exactly this
+ // gap for the throttle above; reusing it here instead of adding new
+ // state closes it: while a configure is outstanding, trust this
+ // compositor's own live target (`geom`) the same way an active
+ // resize already does, rather than reconstructing physical size
+ // from a logical value that was never committed under this scale.
+ if pending_size_configure.get(&id).is_some_and(|(pending_size, sent_at)| {
+ let caught_up = id_to_window.get(&id).is_some_and(|w| w.geometry().size.w == pending_size.0 && w.geometry().size.h == pending_size.1);
+ !caught_up && sent_at.elapsed() < CONFIGURE_THROTTLE_TIMEOUT
+ }) {
+ return geom;
+ }
let Some(w) = wm.borrow().window(id).cloned() else { return geom };
let Some(dwindow) = id_to_window.get(&id) else { return geom };
// `dwindow.geometry()` - `xdg_surface::set_window_geometry` - is,
@@ -206,7 +242,7 @@ impl CompState {
/// compositor's own bookkeeping staying self-consistent, not about
/// matching a client's real pixels.
pub(crate) fn effective_frame(&self, id: WindowId, geom: srdwm_core::Rect) -> srdwm_core::Rect {
- Self::effective_frame_of(&self.wm, &self.id_to_window, id, geom)
+ Self::effective_frame_of(&self.wm, &self.id_to_window, &self.pending_size_configure, id, geom)
}
pub(crate) fn sync_geometry(&mut self, id: WindowId) {
diff --git a/crates/wayland/src/state/lifecycle.rs b/crates/wayland/src/state/lifecycle.rs
index 53a5e86..838e308 100644
--- a/crates/wayland/src/state/lifecycle.rs
+++ b/crates/wayland/src/state/lifecycle.rs
@@ -156,6 +156,11 @@ impl CompState {
traffic_light_buttons: theme.traffic_light_buttons,
is_dialog,
};
+ log::warn!(
+ "DECO-DIAG redraw_decoration_buffer id={id} w.decorated={} early_return={}",
+ w.decorated,
+ self.decoration_signatures.get(&id) == Some(&signature)
+ );
if self.decoration_signatures.get(&id) == Some(&signature) {
return;
}
diff --git a/crates/wayland/src/state/menu.rs b/crates/wayland/src/state/menu.rs
index 9f645a8..b8cf094 100644
--- a/crates/wayland/src/state/menu.rs
+++ b/crates/wayland/src/state/menu.rs
@@ -45,14 +45,32 @@ impl CompState {
self.sync_geometry(window);
foreign_toplevel::send_state(self, window);
}
+ MenuAction::ToggleFullscreen => {
+ self.wm.borrow_mut().toggle_fullscreen(window);
+ self.sync_geometry(window);
+ foreign_toplevel::send_state(self, window);
+ }
+ MenuAction::ToggleFloating => {
+ self.wm.borrow_mut().toggle_floating(window);
+ self.sync_geometry(window);
+ }
MenuAction::ToggleAlwaysOnTop => {
self.wm.borrow_mut().toggle_always_on_top(window);
}
+ MenuAction::MoveToWorkspace(workspace) => {
+ self.wm.borrow_mut().move_window_to_workspace(window, workspace);
+ }
MenuAction::Close => {
if let Some(w) = self.id_to_window.get(&window) {
crate::input::close_dwindow(w);
}
}
+ // Never actually reached: the click-dispatch site
+ // (`input/pointer.rs`) intercepts `Separator` before calling
+ // this function at all. Handled here too so this match stays
+ // exhaustive without a catch-all that would silently swallow a
+ // real future variant added without updating this function.
+ MenuAction::Separator => {}
}
}
diff --git a/crates/wayland/src/state/mod.rs b/crates/wayland/src/state/mod.rs
index 0fb303e..3519297 100644
--- a/crates/wayland/src/state/mod.rs
+++ b/crates/wayland/src/state/mod.rs
@@ -39,6 +39,7 @@ use smithay::wayland::shell::xdg::decoration::XdgDecorationState;
use smithay::wayland::dmabuf::DmabufState;
use smithay::wayland::shm::ShmState;
use smithay::wayland::xdg_activation::XdgActivationState;
+use wayland_protocols_wlr::virtual_pointer::v1::server::zwlr_virtual_pointer_v1::ZwlrVirtualPointerV1;
use srdwm_core::{Event as CoreEvent, SnapZoneKind, Window as CoreWindow, WindowId, WindowManager, TITLEBAR_HEIGHT};
@@ -217,6 +218,7 @@ pub(crate) struct CompState {
pub(crate) _fractional_scale_state: smithay::wayland::fractional_scale::FractionalScaleManagerState,
pub(crate) _cursor_shape_state: smithay::wayland::cursor_shape::CursorShapeManagerState,
pub(crate) _screencopy_state: screencopy::ScreencopyState,
+ pub(crate) _virtual_pointer_state: crate::virtual_pointer::VirtualPointerState,
/// Captures requested via `wlr-screencopy` but not yet serviced; drained
/// inside the render pass (see `screencopy::service_pending`).
pub(crate) screencopy_pending: Vec<screencopy::PendingCapture>,
@@ -362,6 +364,20 @@ pub(crate) struct CompState {
/// drag is active.
#[allow(clippy::type_complexity)]
pub(crate) desktop_icon_drag: Option<(String, (i32, i32), (i32, i32))>,
+ /// An active rubber-band/marquee selection drag on bare desktop --
+ /// `(start, current)`, both global-space pointer positions. The one
+ /// "click and drag" desktop interaction this compositor never had at
+ /// all (only single-icon click-select existed) - reported live next
+ /// to "missing click and drag stuff like from windows". `None`
+ /// whenever no marquee is active. See `start_desktop_marquee`/
+ /// `update_desktop_marquee`/`end_desktop_marquee`.
+ pub(crate) desktop_marquee: Option<((i32, i32), (i32, i32))>,
+ /// Four thin solid-color strips forming the marquee's own rectangle
+ /// outline (top/bottom/left/right) - same "keep a persistent `Solid
+ /// ColorBuffer` per strip, update it in place every frame" pattern
+ /// `border_side_buffers` already uses for window borders, reused here
+ /// rather than allocating a fresh buffer on every motion tick.
+ pub(crate) marquee_buffers: [SolidColorBuffer; 4],
/// The right-click desktop-icon/bare-desktop menu, if one is currently
/// open - see `desktop_menu.rs`. Same lifecycle/mutual-exclusion
/// story as `context_menu`/`snap_flyout` above.
@@ -383,6 +399,17 @@ pub(crate) struct CompState {
pub(crate) wm: Rc<RefCell<WindowManager>>,
pub(crate) surface_to_id: HashMap<WlSurface, WindowId>,
pub(crate) id_to_window: HashMap<WindowId, DWindow>,
+ /// Every live `zwlr_virtual_pointer_v1` object, so `set_virtual_pointer_
+ /// pin` (`virtual_pointer.rs`) can find every pointer a given client
+ /// (identified by pid, via `Client::get_credentials`) owns without a
+ /// second, redundant per-client map - see that module's own doc
+ /// comment for why pid, not an opaque per-object id nothing outside
+ /// this compositor could otherwise learn, is the pinning handle. Pruned
+ /// lazily (a destroyed resource's own methods become no-ops, and dead
+ /// entries are filtered out the next time this is walked) rather than
+ /// on every single destroy - this list is only ever touched by an
+ /// infrequent pin/unpin request, never a hot path.
+ pub(crate) virtual_pointers: Vec<ZwlrVirtualPointerV1>,
/// Surfaces whose `zwlr_layer_surface_v1` role has been destroyed --
/// consulted by the pre-commit hook `CompositorHandler::new_surface`
/// registers (see its doc comment) to work around a real smithay bug
diff --git a/crates/wayland/src/state/toplevel.rs b/crates/wayland/src/state/toplevel.rs
index 2504ad2..90cf688 100644
--- a/crates/wayland/src/state/toplevel.rs
+++ b/crates/wayland/src/state/toplevel.rs
@@ -73,7 +73,9 @@ pub(crate) fn sync_toplevel_metadata(state: &mut CompState, id: WindowId, surfac
// window back to the front any time its title happened to
// update - reported live as an older window jumping in front of
// a newer, focused one with no user action to explain it.
- if state.wm.borrow_mut().reapply_rules_if_pending(id) {
+ let reapplied = state.wm.borrow_mut().reapply_rules_if_pending(id);
+ log::warn!("DECO-DIAG sync_toplevel_metadata id={id} changed=true reapplied={reapplied}");
+ if reapplied {
state.redraw_decoration_buffer(id);
state.sync_geometry(id);
}
diff --git a/crates/wayland/src/udev/mod.rs b/crates/wayland/src/udev/mod.rs
index d22666d..27a7c2c 100644
--- a/crates/wayland/src/udev/mod.rs
+++ b/crates/wayland/src/udev/mod.rs
@@ -204,6 +204,21 @@ pub(crate) struct UdevState {
/// Pointer position in the *global* space, so it can cross between
/// monitors; clamped to the union of all head rectangles.
pub(crate) pointer_pos: Point<f64, Logical>,
+ /// Multi-cursor mode, Phase 1: every physical pointer/trackpad's own
+ /// last-known position, keyed by its real libinput device identity
+ /// (`smithay::backend::input::Event::device()`, confirmed `Device:
+ /// PartialEq + Eq + Hash` by reading smithay's own trait definition).
+ /// Purely a *visual* addition - `pointer_pos` above is still the one
+ /// position that actually drives clicks/drags/hit-testing, updated by
+ /// whichever device moved most recently exactly as before, so nothing
+ /// about existing interactive behaviour changes. This is what lets a
+ /// mouse and a trackpad each show their own live cursor sprite instead
+ /// of only the most-recently-moved device having a visible pointer at
+ /// all - see `docs/TODO.md`'s "Multi-cursor" plan for what later
+ /// phases would still need (per-device *interaction*, not just
+ /// per-device *rendering*, and the real `wl_seat` ecosystem wall a
+ /// second seat runs into for arbitrary client content).
+ pub(crate) secondary_cursors: HashMap<smithay::reexports::input::Device, Point<f64, Logical>>,
/// A clone of the same `LibSeatSession` `platform.rs` opened the DRM
/// device with (`LibSeatSession` is cheaply `Clone` - see its own
/// derive - all clones share the same underlying seat connection).
@@ -267,7 +282,7 @@ impl UdevState {
/// this from outside, but srdwm's own pointer clamp assuming an origin
/// no other part of this backend actually enforces is the real bug --
/// fixed here instead of just left for every future caller to avoid.
- fn bounds(&self) -> (f64, f64, f64, f64) {
+ pub(crate) fn bounds(&self) -> (f64, f64, f64, f64) {
bounds_of(self.heads.iter().map(|h| (h.location.x, h.location.y, h.size.0, h.size.1)))
}
}
diff --git a/crates/wayland/src/udev/outputs.rs b/crates/wayland/src/udev/outputs.rs
index c3291d8..a0169a4 100644
--- a/crates/wayland/src/udev/outputs.rs
+++ b/crates/wayland/src/udev/outputs.rs
@@ -371,7 +371,7 @@ impl CompState {
head.output.change_current_state(None, None, None, Some((x_logical, 0).into()));
placed.push((head.output.clone(), head.location));
x_physical += head.size.0;
- x_logical += (head.size.0 as f64 / scale).round() as i32;
+ x_logical = next_logical_x(x_logical, head.size.0, scale);
}
for (output, location) in placed {
if let Some(entry) = self.outputs.iter_mut().find(|e| e.output == output) {
@@ -385,3 +385,55 @@ impl CompState {
}
}
+/// The actual arithmetic behind [`CompState::relayout_outputs`]'s logical-x
+/// accumulation - pulled out so it's testable without a real `Output`/DRM
+/// head, the same reasoning `udev/mod.rs::bounds_of` already applies to
+/// `UdevState::bounds`. Takes the previous head's own resulting logical x,
+/// this head's physical width, and this head's fractional scale; returns
+/// the *next* head's logical x.
+fn next_logical_x(prev_logical_x: i32, physical_width: i32, scale: f64) -> i32 {
+ prev_logical_x + (physical_width as f64 / scale).round() as i32
+}
+
+#[cfg(test)]
+mod relayout_tests {
+ use super::next_logical_x;
+
+ /// The exact scenario this function exists to fix, using the exact
+ /// figures a peer session measured live from inside GTK
+ /// (`Gdk.Display.get_monitors()`) before the fix: `HDMI-A-1` at 1920
+ /// physical / ~0.843 scale (2276 logical), `eDP-1` at 1920 physical /
+ /// 1.0 scale placed after it. The bug this guards against: passing the
+ /// raw physical accumulator straight into `change_current_state`
+ /// advertised `eDP-1` at logical x=1920 - inside `HDMI-A-1`'s own
+ /// logical extent (0..2276), a real, measured ~356px overlap.
+ #[test]
+ fn a_sub_one_scale_head_is_not_overrun_by_the_next_heads_logical_x() {
+ let hdmi_logical_end = next_logical_x(0, 1920, 1920.0 / 2276.0);
+ assert_eq!(hdmi_logical_end, 2276);
+ let edp_logical_x = next_logical_x(hdmi_logical_end, 1920, 1.0);
+ assert!(edp_logical_x >= hdmi_logical_end, "eDP-1 logical x ({edp_logical_x}) must not land inside HDMI-A-1's own logical extent (0..{hdmi_logical_end})");
+ assert_eq!(edp_logical_x, 2276 + 1920);
+ }
+
+ /// Every output at `scale == 1.0` (this machine's actual current,
+ /// user-chosen configuration - see docs/TODO.md's "HDMI-A-1 forced to
+ /// scale 1.0" entry) must reduce to plain physical accumulation, byte
+ /// for byte - this is the case that was already correct before the
+ /// fix and must stay that way.
+ #[test]
+ fn every_output_at_unit_scale_reduces_to_plain_physical_accumulation() {
+ assert_eq!(next_logical_x(0, 1920, 1.0), 1920);
+ assert_eq!(next_logical_x(1920, 1920, 1.0), 3840);
+ }
+
+ /// A scale above 1.0 (a HiDPI output) narrows logical space relative to
+ /// physical - the next head's logical x must land *before* its own
+ /// physical offset would suggest, not after.
+ #[test]
+ fn a_scale_above_one_narrows_the_next_heads_logical_x() {
+ let logical_end = next_logical_x(0, 3840, 2.0);
+ assert_eq!(logical_end, 1920);
+ }
+}
+
diff --git a/crates/wayland/src/udev/platform.rs b/crates/wayland/src/udev/platform.rs
index 6f6cbe8..9023cb9 100644
--- a/crates/wayland/src/udev/platform.rs
+++ b/crates/wayland/src/udev/platform.rs
@@ -16,6 +16,12 @@ pub struct UdevPlatform {
/// Last time the unconditional end-of-cycle `render_udev_frame()` call
/// actually ran - see its own call site for why.
last_render: Instant,
+ /// Sticky designation of which connector `monitors()` reports as
+ /// primary - see that function's own doc comment on `primary_name`
+ /// for why this can't be recomputed from `udev.heads`' own iteration
+ /// order every call. `None` until the first `monitors()` call ever
+ /// runs.
+ primary_connector: Option<String>,
}
impl UdevPlatform {
@@ -158,6 +164,7 @@ impl UdevPlatform {
heads,
active: true,
pointer_pos: (width as f64 / 2.0, height as f64 / 2.0).into(),
+ secondary_cursors: std::collections::HashMap::new(),
session: session.clone(),
disabled_connectors: std::collections::HashSet::new(),
last_rendered_workspace: None,
@@ -190,6 +197,7 @@ impl UdevPlatform {
|_| true,
),
_screencopy_state: crate::screencopy::ScreencopyState::new::<CompState>(&display_handle),
+ _virtual_pointer_state: crate::virtual_pointer::VirtualPointerState::new::<CompState>(&display_handle),
screencopy_pending: Vec::new(),
_appmenu_state: crate::appmenu::AppmenuManagerState::new::<CompState>(&display_handle),
_virtual_keyboard_state: smithay::wayland::virtual_keyboard::VirtualKeyboardManagerState::new::<CompState, _>(&display_handle, |_client| true),
@@ -233,6 +241,8 @@ impl UdevPlatform {
desktop_icons: None,
desktop_icon_buffers: HashMap::new(),
desktop_icon_drag: None,
+ desktop_marquee: None,
+ marquee_buffers: Default::default(),
desktop_menu: None,
desktop_menu_buffer: None,
last_icon_click: None,
@@ -240,6 +250,7 @@ impl UdevPlatform {
wm: wm.clone(),
surface_to_id: HashMap::new(),
id_to_window: HashMap::new(),
+ virtual_pointers: Vec::new(),
dead_layer_surfaces: HashSet::new(),
hidden_layer_surfaces: HashMap::new(),
layer_surfaces_shown_once: HashSet::new(),
@@ -280,6 +291,17 @@ impl UdevPlatform {
// the socket a client would need to connect to doesn't exist yet.
state.restore_monitor_layout();
+ // Per-app remembered window position/size (`window_memory.rs`) --
+ // no ordering requirement as strict as the layout restore just
+ // above (a window can't map before a client connects, and the
+ // socket isn't even bound yet), but seeded here anyway, at the
+ // same "before anything else can possibly run" point, so there's
+ // no window in this compositor's own startup where a first window
+ // could map before this table is populated.
+ for (app_id, g) in crate::window_memory::load() {
+ state.wm.borrow_mut().set_remembered_geometry(app_id, (g.x, g.y, g.width, g.height));
+ }
+
let listener = ListeningSocket::bind_auto("wayland", 0..32).map_err(err)?;
if let Some(name) = listener.socket_name() {
std::env::set_var("WAYLAND_DISPLAY", name);
@@ -326,11 +348,52 @@ impl UdevPlatform {
if let Err(e) = register_udev_monitor(&handle, &seat_name) {
log::warn!("udev: connector hotplug unavailable ({e}); monitors are fixed at startup");
}
- if let Err(e) = crate::xwayland::spawn(&handle, &display_handle) {
- log::warn!("XWayland unavailable ({e}); X11-only clients will not run");
- }
-
- Ok(Self { event_loop, display: dh, state, listener, clients: Vec::new(), pending, ipc, last_ipc_poll: Instant::now(), last_render: Instant::now() })
+ // Deferred to the loop's first idle tick, not called here directly.
+ // This function still runs inside `connect()`, before the caller
+ // ever calls `event_loop.run()` - so a direct call here forks
+ // XWayland while nothing is actually dispatching this process's own
+ // Wayland socket yet. XWayland connects immediately (`WAYLAND_SOCKET`
+ // is already a live fd, no accept() to wait for) and starts its own
+ // registry/seat/keyboard handshake right away; if that handshake's
+ // response - specifically the `wl_keyboard.keymap` event carrying
+ // this compositor's real `pc105+inet`-derived keymap - doesn't get
+ // serviced before XWayland's own internal timeout, XWayland falls
+ // back to compiling a keymap of its own with no real RMLVO behind
+ // it, which is exactly the "Failed to load keymap. Loading default
+ // keymap instead" line seen in `xwayland.log` right before "Fatal
+ // server error: Failed to activate virtual core keyboard: 2" --
+ // confirmed to reproduce on every single real startup (53 identical
+ // crashes across one session's restarts) while an external XWayland
+ // spawned against this exact same, already-*running* compositor
+ // (same socket, same keymap, same env-clearing, same `-wm`/
+ // `-displayfd` fd-passing - checked by replicating smithay's own
+ // `XWayland::spawn` byte for byte in a standalone harness) never
+ // once reproduced it. `insert_idle` runs its callback on the loop's
+ // own first dispatch pass, which only happens once `event_loop.run`
+ // is actually pumping this process's sockets - moving the fork
+ // there closes the exact gap between "child process exists and
+ // starts talking" and "someone is listening," which nothing else
+ // about this fix changes.
+ let handle_for_xwayland = handle.clone();
+ let idle_display_handle = display_handle.clone();
+ handle.insert_idle(move |_state| {
+ if let Err(e) = crate::xwayland::spawn(&handle_for_xwayland, &idle_display_handle) {
+ log::warn!("XWayland unavailable ({e}); X11-only clients will not run");
+ }
+ });
+
+ Ok(Self {
+ event_loop,
+ display: dh,
+ state,
+ listener,
+ clients: Vec::new(),
+ pending,
+ ipc,
+ last_ipc_poll: Instant::now(),
+ last_render: Instant::now(),
+ primary_connector: None,
+ })
}
fn accept_clients(&mut self) -> PlatformResult<()> {
@@ -550,6 +613,14 @@ impl Platform for UdevPlatform {
self.state.disable_connector_by_name(&name);
}
}
+ // Applies any `srd dispatch pin input`/`unpin input` IPC requests
+ // queued since the last poll - Phase 2 of the multi-cursor plan,
+ // see `virtual_pointer.rs`'s module doc comment and `CompState::
+ // set_virtual_pointer_pin`'s own doc comment for the full design.
+ let pin_requests = self.state.wm.borrow_mut().drain_pin_input_requests();
+ for (pid, window) in pin_requests {
+ self.state.set_virtual_pointer_pin(pid, window);
+ }
// Throttled the same way and for the same underlying reason as the
// `ipc.poll()` call above - this is the *other*, larger half of
// this cycle's needless work at the dead-pipe-driven spin rate.
@@ -597,6 +668,50 @@ impl Platform for UdevPlatform {
let wm = wm.borrow();
let mut out = Vec::new();
let mut next_id: u32 = 0;
+ // Sticky by connector name, not "whichever head is first in `udev.
+ // heads` this call" - that positional rule looked harmless (heads
+ // are only ever appended, in probe order, at startup) but
+ // `enable_connector_by_name` pushes a re-enabled connector back
+ // onto the *end* of the vec, same as a fresh hotplug - so cycling
+ // any non-first connector's own enabled state (confirmed live: a
+ // peer session repeatedly toggling one monitor for unrelated
+ // testing) never moves it, but disabling the connector that
+ // currently sits first and re-enabling it does, silently handing
+ // "primary" to whatever was second. Reported live as this
+ // session's own desktop icons (pinned to whichever monitor `Platform
+ // ::monitors()` calls primary) "sometimes showing on the other
+ // monitor" with no action anyone took that looked related. Once a
+ // primary connector name is chosen, it keeps that designation
+ // across every later call as long as it's still connected --
+ // falling back to the first head only when it genuinely isn't
+ // (unplugged, or the very first call this process ever makes).
+ // The *first* fallback pick (when nothing is sticky yet) used to be
+ // `udev.heads.first()` - whichever connector DRM happened to probe
+ // first, which has no relationship to the user's actual layout.
+ // Reported live on this machine: with an "extend left" saved layout
+ // (external monitor at negative x, laptop panel at x=0), the
+ // external monitor still got "primary" at boot whenever it happened
+ // to probe before the panel, dragging desktop icons and every
+ // primary-monitor-anchored window placement onto it - exactly the
+ // "apps open on the wrong monitor" and "icons not showing" symptoms
+ // reported live, on the very first call this process ever makes,
+ // before stickiness has anything to preserve. `relayout_outputs`/
+ // `output_management::apply_output_position` already keep the
+ // user's actual anchor monitor at physical `(0, 0)` - that IS the
+ // position-based definition of "primary" every desktop convention
+ // (xrandr, wlr-output-management) already uses, and unlike
+ // enumeration order it's driven by the same saved layout the user
+ // configured. Preferred over the origin-search only as the initial
+ // pick; once chosen, `primary_connector` stays sticky exactly as
+ // before, so a later `relayout_outputs` call temporarily putting a
+ // different head at `(0, 0)` mid-drag doesn't itself flip primary.
+ let primary_name = self
+ .primary_connector
+ .clone()
+ .filter(|name| udev.heads.iter().any(|h| &h.output.name() == name))
+ .or_else(|| udev.heads.iter().find(|h| h.location == Point::from((0, 0))).map(|h| h.output.name()))
+ .or_else(|| udev.heads.first().map(|h| h.output.name()));
+ self.primary_connector = primary_name.clone();
for head in udev.heads.iter() {
// Shrunk by whatever a layer-shell surface (bar, dock) has
// reserved via `set_exclusive_zone` - reporting the full
@@ -630,12 +745,48 @@ impl Platform for UdevPlatform {
let zone = layer_map_for_output(&head.output).non_exclusive_zone();
let scale = head.output.current_scale().fractional_scale();
let zone_physical = |v: i32| (v as f64 * scale).round() as i32;
- let usable = srdwm_core::Rect::new(
+ let mut usable = srdwm_core::Rect::new(
head.location.x + zone_physical(zone.loc.x),
head.location.y + zone_physical(zone.loc.y),
zone_physical(zone.size.w).max(0) as u32,
zone_physical(zone.size.h).max(0) as u32,
);
+ // `general.reserve_top`/`_bottom`/`_left`/`_right` - a static
+ // floor under the real exclusive zone above, not a competing
+ // claim: only shrinks `usable` further if the configured
+ // reservation is *larger* than what's already reserved for
+ // that edge, so a real bar/dock that has actually connected
+ // and registered its own (equal or bigger) zone always wins.
+ // See `WindowManager::reserve_top`'s own doc comment for the
+ // startup-race this exists to close.
+ let (rt, rb, rl, rr) =
+ (zone_physical(wm.reserve_top as i32), zone_physical(wm.reserve_bottom as i32), zone_physical(wm.reserve_left as i32), zone_physical(wm.reserve_right as i32));
+ let full_top = head.location.y;
+ let full_left = head.location.x;
+ let full_bottom = head.location.y + head.size.1;
+ let full_right = head.location.x + head.size.0;
+ let want_top = full_top + rt;
+ let want_left = full_left + rl;
+ let want_bottom = full_bottom - rb;
+ let want_right = full_right - rr;
+ if want_top > usable.y {
+ let shrink = want_top - usable.y;
+ usable.y = want_top;
+ usable.height = usable.height.saturating_sub(shrink.max(0) as u32);
+ }
+ if want_left > usable.x {
+ let shrink = want_left - usable.x;
+ usable.x = want_left;
+ usable.width = usable.width.saturating_sub(shrink.max(0) as u32);
+ }
+ let usable_bottom = usable.y + usable.height as i32;
+ if want_bottom < usable_bottom {
+ usable.height = (want_bottom - usable.y).max(0) as u32;
+ }
+ let usable_right = usable.x + usable.width as i32;
+ if want_right < usable_right {
+ usable.width = (want_right - usable.x).max(0) as u32;
+ }
// The head's true full rect, ignoring any exclusive zone --
// deliberately *not* defaulted from `usable` the way `Monitor::
// new` alone would (see the fullscreen note below).
@@ -668,7 +819,7 @@ impl Platform for UdevPlatform {
// erasing the split it was placed to respect.
m.full_geometry = srdwm_core::monitor::split_rect(full, part, parts, rows);
m.maximize_geometry = srdwm_core::monitor::split_rect(maximize, part, parts, rows);
- m.primary = next_id == 0;
+ m.primary = primary_name.as_deref() == Some(name.as_str());
m.split = parts > 1;
m.scale = scale;
out.push(m);
diff --git a/crates/wayland/src/udev/render.rs b/crates/wayland/src/udev/render.rs
index 2d0a063..4bfe04d 100644
--- a/crates/wayland/src/udev/render.rs
+++ b/crates/wayland/src/udev/render.rs
@@ -7,6 +7,19 @@ impl CompState {
/// monitors on different refresh rates each run at their own pace
/// instead of the slowest one gating the rest.
pub(crate) fn render_udev_frame(&mut self) {
+ // Real perf instrumentation, not a guess-fix: reported live as
+ // "resizing seems slow", and this session's own investigation
+ // (checked decoration-buffer caching, motion-path logging levels,
+ // GPU-path config) found no smoking gun without an actual
+ // measurement. Cheap when nothing's slow (one `Instant::now()` and
+ // one comparison per frame, no allocation, no formatting unless
+ // the threshold trips) - logs only when a frame actually misses a
+ // 60fps budget, tagged with whether a resize/drag was in progress
+ // at the time, so the next real resize either produces real
+ // evidence this is a genuine per-frame cost during resize
+ // specifically, or rules that out in favor of something else
+ // (input latency, client-side redraw cost, a specific app).
+ let frame_start = Instant::now();
self.tick_animations();
self.tick_hover_glyph_animation();
self.tick_dirty_broadcasts();
@@ -299,6 +312,23 @@ impl CompState {
origin,
hsize,
));
+ // Multi-cursor mode, Phase 1: one extra sprite per *other*
+ // physical pointer device's own last-known position (see
+ // `UdevState::secondary_cursors`'s own doc comment) - the
+ // device that drove `pointer_pos` itself is skipped so its
+ // cursor isn't drawn twice at the same spot. All secondary
+ // sprites share the one real cursor image/theme
+ // (`cursor_status`/`cursor_buffers`) rather than each
+ // device getting its own - a real visual distinction
+ // between devices is a later-phase refinement, not needed
+ // to prove multiple live positions render at all.
+ let active_device = udev.secondary_cursors.iter().find(|&(_, &p)| p == pointer_pos).map(|(d, _)| d.clone());
+ for (device, &pos) in &udev.secondary_cursors {
+ if Some(device) == active_device.as_ref() {
+ continue;
+ }
+ custom_elements.extend(crate::cursor::render_elements(&cursor_status, &cursor_buffers, &mut udev.renderer, pos, origin, hsize));
+ }
// Night light/reading mode - a translucent full-output
// overlay, pushed right after the cursor so it colours
// everything else (windows, bars, menus) but never the
@@ -415,7 +445,7 @@ impl CompState {
// the actual content position still reads `geom`/`band`
// directly, since that's already correctly anchored via
// `content_offset` below regardless of this correction.
- let frame = crate::state::CompState::effective_frame_of(&self.wm, &self.id_to_window, id, geom);
+ let frame = crate::state::CompState::effective_frame_of(&self.wm, &self.id_to_window, &self.pending_size_configure, id, geom);
// Computed here, ahead of the border strips below,
// purely so they can know it - the actual content
// element that reads this same masked buffer is still
@@ -924,6 +954,34 @@ impl CompState {
// background-layer push just below (so the wallpaper still
// shows through everywhere an icon doesn't draw). See
// `desktop_icons.rs`'s own module doc comment.
+ // The rubber-band marquee outline, above the icons it's
+ // selecting - four thin solid-colour strips (the same
+ // `border_side_render_element` primitive window borders
+ // already use), not a translucent fill: `SolidColorRender
+ // Element` has no alpha-blend path, and a plain accent-
+ // coloured outline is still a real, visible selection
+ // indicator without needing a new element type for one
+ // feature.
+ if let Some((start, current)) = self.desktop_marquee {
+ let (x0, y0) = (start.0.min(current.0), start.1.min(current.1));
+ let (x1, y1) = (start.0.max(current.0), start.1.max(current.1));
+ let color = self.wm.borrow().theme.default_border_color;
+ const T: i32 = 1;
+ let strips = [
+ srdwm_core::Rect::new(x0, y0, (x1 - x0).max(0) as u32, T as u32),
+ srdwm_core::Rect::new(x0, y1 - T, (x1 - x0).max(0) as u32, T as u32),
+ srdwm_core::Rect::new(x0, y0, T as u32, (y1 - y0).max(0) as u32),
+ srdwm_core::Rect::new(x1 - T, y0, T as u32, (y1 - y0).max(0) as u32),
+ ];
+ for (strip, buf) in strips.into_iter().zip(self.marquee_buffers.iter_mut()) {
+ custom_elements.push(crate::elements::OverlayElement::Solid(crate::elements::border_side_render_element(
+ buf,
+ strip,
+ color,
+ (origin.x, origin.y),
+ )));
+ }
+ }
for (pos, buffer) in &desktop_icon_render_list {
let local_pos = ((pos.0 - origin.x) as f64, (pos.1 - origin.y) as f64);
match MemoryRenderBufferRenderElement::from_buffer(&mut udev.renderer, local_pos, buffer, None, None, None, Kind::Unspecified) {
@@ -1190,6 +1248,16 @@ impl CompState {
// otherwise never come (see docs/PANEL_SUPPORT_TODO.md, P1).
self.screencopy_pending.extend(captures);
}
+ const FRAME_BUDGET: Duration = Duration::from_millis(16);
+ let frame_time = frame_start.elapsed();
+ if frame_time > FRAME_BUDGET {
+ let wm = self.wm.borrow();
+ log::warn!(
+ "PERF-RESIZE render_udev_frame took {frame_time:?} (budget {FRAME_BUDGET:?}) - resizing={} dragging={}",
+ wm.resizing_window().is_some(),
+ wm.is_dragging()
+ );
+ }
}
/// Sets a connector's DPMS mode via the generic KMS "DPMS" property --
diff --git a/crates/wayland/src/udev/session.rs b/crates/wayland/src/udev/session.rs
index bf6eb67..c80592e 100644
--- a/crates/wayland/src/udev/session.rs
+++ b/crates/wayland/src/udev/session.rs
@@ -335,6 +335,12 @@ fn handle_libinput_event(state: &mut CompState, event: InputEvent<LibinputInputB
udev.pointer_pos.x = (udev.pointer_pos.x + delta.x).clamp(min_x, (max_x - 1.0).max(min_x));
udev.pointer_pos.y = (udev.pointer_pos.y + delta.y).clamp(min_y, (max_y - 1.0).max(min_y));
let pos = udev.pointer_pos;
+ // Multi-cursor mode, Phase 1 (see `UdevState::secondary_
+ // cursors`'s own doc comment): records this specific physical
+ // device's own position too, purely for rendering its own
+ // cursor sprite - `pos`/`handle_pointer_position` below are
+ // still the one interactive position, unchanged.
+ udev.secondary_cursors.insert(event.device(), pos);
handle_pointer_position(state, pos, event.time_msec());
}
// Absolute-positioning devices (a touchscreen, a drawing tablet,
@@ -362,6 +368,7 @@ fn handle_libinput_event(state: &mut CompState, event: InputEvent<LibinputInputB
udev.pointer_pos.x = (pos.x + min_x).clamp(min_x, (max_x - 1.0).max(min_x));
udev.pointer_pos.y = (pos.y + min_y).clamp(min_y, (max_y - 1.0).max(min_y));
let pos = udev.pointer_pos;
+ udev.secondary_cursors.insert(event.device(), pos);
handle_pointer_position(state, pos, event.time_msec());
}
InputEvent::PointerButton { event } => {
diff --git a/crates/wayland/src/virtual_pointer.rs b/crates/wayland/src/virtual_pointer.rs
new file mode 100644
index 0000000..e626e7d
--- /dev/null
+++ b/crates/wayland/src/virtual_pointer.rs
@@ -0,0 +1,511 @@
+//! `zwlr_virtual_pointer_unstable_v1`: lets a client emulate a physical
+//! pointer device - motion, buttons and scroll - through a real Wayland
+//! protocol, the same job `zwp_virtual_keyboard_manager_v1` already does
+//! for synthetic keystrokes (see `protocols/virtual_keyboard.rs`). Smithay
+//! 0.7 ships no helper for this one, same situation as `screencopy.rs` and
+//! `output_management.rs`, so the `GlobalDispatch`/`Dispatch` plumbing
+//! below is written out by hand against the raw `wayland-protocols-wlr`
+//! server bindings, following the same shape those two files already
+//! established.
+//!
+//! Real, scoped gap this closes - not a nice-to-have: `docs/TODO.md`'s
+//! "ydotool's `--absolute` is unusable on this machine" entry and the
+//! "wl_pointer motion/button coordinates" investigation both trace back to
+//! the same root problem, that this compositor had no real protocol path
+//! for synthetic pointer input at all. `ydotool`'s own uinput device has
+//! no `EV_ABS` capability on this hardware (relative-only, and libinput's
+//! pointer-acceleration curve warps even that), which is why every
+//! synthetic-click verification this project has ever done needed a
+//! fragile corner-clamp-then-walk workaround instead of a precise,
+//! reliable placement. A virtual pointer client (a `wlrctl`/custom tool
+//! built against this protocol, or a future `ydotool` that speaks it)
+//! sidesteps all of that: `motion_absolute` lands exactly where asked, and
+//! `motion` (relative) is a raw compositor-space delta with no libinput
+//! acceleration applied, since it never touches a uinput device at all.
+//!
+//! Every request is fed through the exact same `handle_pointer_position`/
+//! `handle_pointer_button` entry points a real libinput hardware event
+//! goes through (`udev/session.rs::handle_libinput_event`) - a virtual
+//! pointer is indistinguishable from a real mouse to every other part of
+//! this compositor (hit-testing, drag/resize, focus-follows-mouse, all of
+//! it), by construction, rather than a second, easily-drifting code path.
+//!
+//! Scroll is the one piece that can't reuse an existing entry point: real
+//! scroll handling (`udev/session.rs`'s `InputEvent::PointerAxis` arm)
+//! reads its values through smithay's `PointerAxisEvent` trait, which is
+//! implemented for real backend event types, not something a synthetic
+//! caller can construct. Built directly against `AxisFrame` instead (the
+//! same builder that trait ultimately feeds into) - accumulated across
+//! this protocol's own `axis`/`axis_source`/`axis_stop`/`axis_discrete`
+//! requests exactly as the protocol groups them, and committed on `frame`.
+//!
+//! One real limitation, not silently glossed over: unpinned motion is only
+//! ever applied when `CompState::udev` is live (the real-hardware
+//! backend). The winit/nested backend has no equivalent multi-monitor
+//! `bounds()` to clamp against and no daily-driver use case for synthetic
+//! input, so a virtual pointer bound there is accepted (the global still
+//! exists, a client's `create_virtual_pointer` still succeeds) but its
+//! unpinned motion requests are no-ops - documented here rather than
+//! silently dropped with no explanation, matching this codebase's own
+//! "degrade honestly" convention elsewhere (`monitor_layout::load`,
+//! `icon_theme::find_icon`). Pinned motion (below) has no such limitation
+//! - it never touches `udev`/`bounds()` at all, so it works identically
+//! on both backends, which is what makes the winit/nested backend a real
+//! place to validate it.
+//!
+//! **Phase 2 of this project's own multi-cursor plan** (see
+//! `docs/TODO.md`'s "Multi-cursor Phase 2" entry for the full reasoning):
+//! pinning a virtual pointer object to a specific window so its
+//! motion/button events reach that window directly, independent of
+//! wherever the shared seat's real focus/`pointer_pos` currently is. This
+//! is the concrete answer to "an agent could operate one window while the
+//! user works another, genuinely simultaneously" - confirmed against
+//! smithay 0.7.0's own source that a second `wl_seat` would be invisible
+//! to every real client (GTK/Qt/Electron only ever bind the first one
+//! advertised), so the fix has to work *through* the one seat every
+//! client already binds, not around it.
+//!
+//! `CompState::set_virtual_pointer_pin` (queued via a `pin_input`/
+//! `unpin_input` IPC dispatch, `crates/platform/src/ipc.rs`, and drained
+//! the same one-poll-tick-later way `set_output_position` already is)
+//! finds every virtual pointer object owned by a given client pid --
+//! `Client::get_credentials` - and sets its `pinned_window`. A pid, not
+//! an opaque per-object id, is the pinning handle: nothing outside this
+//! compositor could ever learn a `zwlr_virtual_pointer_v1` object's own
+//! internal id to pass back in, whereas a controlling tool already knows
+//! its own pid (`std::process::id()`) for free.
+//!
+//! A pinned object's `motion`/`motion_absolute`/`button` requests bypass
+//! `handle_pointer_position`/`handle_pointer_button` entirely - they
+//! never move `pointer_pos`, change focus, or raise the target window.
+//! Instead they hand-roll the real `wl_pointer.enter`/`motion`/`button`/
+//! `frame`/`leave` wire messages directly against every `WlPointer`
+//! resource the target surface's own client has bound
+//! (`PointerHandle::client_pointers`, a real smithay-public API for
+//! exactly this) - the same "construct the protocol object by hand, this
+//! is a narrow case smithay's higher-level seat model wasn't built for"
+//! shape this module's own unpinned path already is. From the target
+//! client's own point of view this is indistinguishable from an ordinary,
+//! correctly-interleaved pointer entering and moving over its surface; the
+//! human's real seat, focus and cursor are never touched.
+
+use std::sync::Mutex;
+
+use smithay::backend::input::{Axis, AxisSource};
+use smithay::input::pointer::AxisFrame;
+use smithay::output::Output;
+use smithay::reexports::wayland_server::protocol::wl_pointer;
+use smithay::reexports::wayland_server::protocol::wl_surface::WlSurface;
+use smithay::reexports::wayland_server::{Client, DataInit, Dispatch, DisplayHandle, GlobalDispatch, New, Resource};
+use smithay::utils::{Coordinate, Logical, Point, SERIAL_COUNTER};
+use wayland_protocols_wlr::virtual_pointer::v1::server::zwlr_virtual_pointer_manager_v1::{self, ZwlrVirtualPointerManagerV1};
+use wayland_protocols_wlr::virtual_pointer::v1::server::zwlr_virtual_pointer_v1::{self, ZwlrVirtualPointerV1};
+
+use srdwm_core::WindowId;
+
+use crate::elements::window_wl_surface;
+use crate::input::{handle_pointer_button, handle_pointer_position, last_pointer_pos};
+use crate::state::CompState;
+
+/// The virtual pointer manager global. Held by `CompState` purely to keep
+/// the global alive for the compositor's lifetime, same as `ScreencopyState`.
+#[derive(Debug)]
+pub struct VirtualPointerState {
+ _global: smithay::reexports::wayland_server::backend::GlobalId,
+}
+
+impl VirtualPointerState {
+ pub fn new<D>(dh: &DisplayHandle) -> Self
+ where
+ D: GlobalDispatch<ZwlrVirtualPointerManagerV1, ()> + 'static,
+ {
+ Self { _global: dh.create_global::<D, ZwlrVirtualPointerManagerV1, _>(2, ()) }
+ }
+}
+
+/// State attached to each `zwlr_virtual_pointer_v1`. `output` is only ever
+/// set by `create_virtual_pointer_with_output`, and only changes
+/// `motion_absolute`'s own mapping (see that handler) - everything else
+/// about a virtual pointer is identical regardless of which constructor
+/// made it.
+#[derive(Debug, Default)]
+pub struct VirtualPointerData {
+ output: Option<Output>,
+ /// Accumulated across `axis`/`axis_source`/`axis_stop`/`axis_discrete`
+ /// requests until this same object's own `frame` request commits it --
+ /// A `Mutex`, not a plain `RefCell`, because `wayland-server`'s own
+ /// `DataInit::init` requires per-object user data to be `Send + Sync`
+ /// - `Dispatch::request` only ever hands out `&self`, not `&mut
+ /// self`, for the object the request arrived on, so interior
+ /// mutability is unavoidable either way.
+ pending_axis: Mutex<Option<AxisFrame>>,
+ /// Set by `CompState::set_virtual_pointer_pin` - see this module's
+ /// own doc comment for the full Phase 2 design. `Some(id)` routes
+ /// every motion/button request on this object straight to that
+ /// window's surface instead of the shared seat path.
+ pinned_window: Mutex<Option<WindowId>>,
+ /// This pinned stream's own local position, physical pixels relative
+ /// to the target window's content top-left - entirely separate from
+ /// `pointer_pos`. `None` until the first motion after being pinned (or
+ /// after the target window changes), at which point it starts at the
+ /// window's own center, the same "start somewhere reasonable, not at
+ /// a corner" convention a real pointer entering a window has no
+ /// equivalent need for (it already has a real position to carry in).
+ pinned_pos: Mutex<Option<Point<f64, Logical>>>,
+ /// The surface a real `wl_pointer.enter` has actually been sent to for
+ /// this pinned stream, if any - so a `leave` reaches the right place
+ /// when unpinned, re-pinned elsewhere, or destroyed, matching a real
+ /// pointer's own enter/leave discipline instead of leaving a client's
+ /// idea of pointer presence stuck forever.
+ pinned_entered: Mutex<Option<WlSurface>>,
+}
+
+impl GlobalDispatch<ZwlrVirtualPointerManagerV1, ()> for CompState {
+ fn bind(_state: &mut Self, _dh: &DisplayHandle, _client: &Client, manager: New<ZwlrVirtualPointerManagerV1>, _data: &(), data_init: &mut DataInit<'_, Self>) {
+ data_init.init(manager, ());
+ }
+}
+
+impl Dispatch<ZwlrVirtualPointerManagerV1, ()> for CompState {
+ fn request(
+ state: &mut Self,
+ _client: &Client,
+ _manager: &ZwlrVirtualPointerManagerV1,
+ request: zwlr_virtual_pointer_manager_v1::Request,
+ _data: &(),
+ _dh: &DisplayHandle,
+ data_init: &mut DataInit<'_, Self>,
+ ) {
+ use zwlr_virtual_pointer_manager_v1::Request;
+ match request {
+ // `seat` is documented as "a suggestion to the compositor" --
+ // this compositor has exactly one real `Seat`, so there is
+ // nothing to route between and the suggestion is a no-op by
+ // construction, not an oversight.
+ Request::CreateVirtualPointer { seat: _, id } => {
+ let resource = data_init.init(id, VirtualPointerData::default());
+ // Registered so `set_virtual_pointer_pin` (Phase 2, this
+ // module's own doc comment) can find it later by the
+ // owning client's pid - see that doc comment for why pid
+ // rather than a per-object id.
+ state.virtual_pointers.push(resource);
+ }
+ Request::CreateVirtualPointerWithOutput { seat: _, output, id } => {
+ let output = output.as_ref().and_then(Output::from_resource);
+ let resource = data_init.init(id, VirtualPointerData { output, ..Default::default() });
+ state.virtual_pointers.push(resource);
+ }
+ Request::Destroy => {}
+ _ => {}
+ }
+ }
+}
+
+impl Dispatch<ZwlrVirtualPointerV1, VirtualPointerData> for CompState {
+ fn request(
+ state: &mut Self,
+ _client: &Client,
+ _pointer: &ZwlrVirtualPointerV1,
+ request: zwlr_virtual_pointer_v1::Request,
+ data: &VirtualPointerData,
+ _dh: &DisplayHandle,
+ _data_init: &mut DataInit<'_, Self>,
+ ) {
+ use zwlr_virtual_pointer_v1::Request;
+ match request {
+ Request::Motion { time, dx, dy } => {
+ if let Some(window) = *data.pinned_window.lock().unwrap() {
+ let (Some((w, h)), Some(surface)) = (pinned_window_size(state, window), pinned_target_surface(state, window)) else { return };
+ let base = data.pinned_pos.lock().unwrap().unwrap_or_else(|| Point::from((w / 2.0, h / 2.0)));
+ let target = Point::<f64, Logical>::from((
+ (base.x + dx.to_f64()).clamp(0.0, (w - 1.0).max(0.0)),
+ (base.y + dy.to_f64()).clamp(0.0, (h - 1.0).max(0.0)),
+ ));
+ *data.pinned_pos.lock().unwrap() = Some(target);
+ pinned_move_to(state, data, &surface, target, time);
+ return;
+ }
+ let Some(udev) = state.udev.as_ref() else { return };
+ let (min_x, min_y, max_x, max_y) = udev.bounds();
+ let pos = last_pointer_pos(state);
+ let target = Point::<f64, Logical>::from((
+ (pos.x + dx.to_f64()).clamp(min_x, (max_x - 1.0).max(min_x)),
+ (pos.y + dy.to_f64()).clamp(min_y, (max_y - 1.0).max(min_y)),
+ ));
+ if let Some(udev) = state.udev.as_mut() {
+ udev.pointer_pos = target;
+ }
+ handle_pointer_position(state, target, time);
+ }
+ Request::MotionAbsolute { time, x, y, x_extent, y_extent } => {
+ if x_extent == 0 || y_extent == 0 {
+ return;
+ }
+ if let Some(window) = *data.pinned_window.lock().unwrap() {
+ let (Some((w, h)), Some(surface)) = (pinned_window_size(state, window), pinned_target_surface(state, window)) else { return };
+ let (nx, ny) = (x as f64 / x_extent as f64, y as f64 / y_extent as f64);
+ let target = Point::<f64, Logical>::from(((nx * w).clamp(0.0, (w - 1.0).max(0.0)), (ny * h).clamp(0.0, (h - 1.0).max(0.0))));
+ *data.pinned_pos.lock().unwrap() = Some(target);
+ pinned_move_to(state, data, &surface, target, time);
+ return;
+ }
+ if state.udev.is_none() {
+ return;
+ }
+ let (nx, ny) = (x as f64 / x_extent as f64, y as f64 / y_extent as f64);
+ // Mapped onto the requested output's own full geometry if
+ // `create_virtual_pointer_with_output` named one, otherwise
+ // the union of every head - the same "whole addressable
+ // span" `PointerMotionAbsolute`'s real-hardware handling
+ // already uses (`udev/session.rs`), just picked per-request
+ // instead of always being the full union.
+ let (min_x, min_y, w, h) = if let Some(output) = &data.output {
+ let name = output.name();
+ match state.wm.borrow().monitors().iter().find(|m| m.name == name) {
+ Some(m) => (m.full_geometry.x as f64, m.full_geometry.y as f64, m.full_geometry.width as f64, m.full_geometry.height as f64),
+ None => {
+ let (min_x, min_y, max_x, max_y) = state.udev.as_ref().unwrap().bounds();
+ (min_x, min_y, max_x - min_x, max_y - min_y)
+ }
+ }
+ } else {
+ let (min_x, min_y, max_x, max_y) = state.udev.as_ref().unwrap().bounds();
+ (min_x, min_y, max_x - min_x, max_y - min_y)
+ };
+ let target = Point::<f64, Logical>::from(((min_x + nx * w).clamp(min_x, min_x + w - 1.0), (min_y + ny * h).clamp(min_y, min_y + h - 1.0)));
+ if let Some(udev) = state.udev.as_mut() {
+ udev.pointer_pos = target;
+ }
+ handle_pointer_position(state, target, time);
+ }
+ Request::Button { time, button, state: button_state } => {
+ let Ok(button_state) = button_state.into_result() else { return };
+ let pressed = button_state == wl_pointer::ButtonState::Pressed;
+ if let Some(window) = *data.pinned_window.lock().unwrap() {
+ pinned_deliver_button(state, data, window, button, pressed, time);
+ return;
+ }
+ let pos = last_pointer_pos(state);
+ handle_pointer_button(state, pos, button, pressed, time);
+ }
+ Request::Axis { time, axis, value } => {
+ let Ok(axis) = axis.into_result() else { return };
+ let axis = wire_axis(axis);
+ let mut pending = data.pending_axis.lock().unwrap();
+ let frame = pending.take().unwrap_or_else(|| AxisFrame::new(time));
+ *pending = Some(frame.value(axis, value.to_f64()));
+ }
+ Request::AxisSource { axis_source } => {
+ let Ok(axis_source) = axis_source.into_result() else { return };
+ let Some(source) = wire_axis_source(axis_source) else { return };
+ let mut pending = data.pending_axis.lock().unwrap();
+ let frame = pending.take().unwrap_or_else(|| AxisFrame::new(0));
+ *pending = Some(frame.source(source));
+ }
+ Request::AxisStop { time, axis } => {
+ let Ok(axis) = axis.into_result() else { return };
+ let axis = wire_axis(axis);
+ let mut pending = data.pending_axis.lock().unwrap();
+ let frame = pending.take().unwrap_or_else(|| AxisFrame::new(time));
+ *pending = Some(frame.stop(axis));
+ }
+ Request::AxisDiscrete { time, axis, value, discrete } => {
+ let Ok(axis) = axis.into_result() else { return };
+ let ax = wire_axis(axis);
+ let mut pending = data.pending_axis.lock().unwrap();
+ let frame = pending.take().unwrap_or_else(|| AxisFrame::new(time));
+ // v120 is the modern wl_pointer convention for "discrete
+ // steps" (120 units per notch) - `discrete` here is the
+ // older plain step count, so it's scaled the same way
+ // smithay's own libinput backend already does for a real
+ // wheel (see `input/gestures.rs`).
+ *pending = Some(frame.value(ax, value.to_f64()).v120(ax, discrete * 120));
+ }
+ Request::Frame => {
+ let Some(frame) = data.pending_axis.lock().unwrap().take() else { return };
+ let Some(pointer) = state.seat.get_pointer() else { return };
+ pointer.axis(state, frame);
+ pointer.frame(state);
+ }
+ Request::Destroy => {}
+ _ => {}
+ }
+ }
+
+ /// The client that owns this object disconnected, or the object was
+ /// otherwise dropped without an explicit `destroy` request - either
+ /// way, if this pinned stream had a real `wl_pointer.enter` on record
+ /// somewhere, that target's client is owed a `leave` (it may well be
+ /// a completely different, still-alive client - the one being
+ /// controlled, not the one that just went away) so it doesn't keep
+ /// thinking a pointer is present forever.
+ fn destroyed(state: &mut Self, _client: smithay::reexports::wayland_server::backend::ClientId, pointer: &ZwlrVirtualPointerV1, data: &VirtualPointerData) {
+ state.virtual_pointers.retain(|p| p.id() != pointer.id());
+ pinned_leave_current(state, data);
+ }
+}
+
+impl CompState {
+ /// Pins (`window` is `Some`) or unpins (`None`) every virtual pointer
+ /// object owned by the client with process id `pid` - see `virtual_
+ /// pointer.rs`'s own module doc comment for the full Phase 2 design.
+ /// Queued via the `pin_input`/`unpin_input` IPC dispatch
+ /// (`crates/platform/src/ipc.rs`) and drained the same one-poll-tick-
+ /// later way `set_output_position` already is (`WindowManager::drain_
+ /// pin_input_requests`).
+ pub(crate) fn set_virtual_pointer_pin(&mut self, pid: i32, window: Option<WindowId>) {
+ self.virtual_pointers.retain(|p| p.is_alive());
+ let matching: Vec<ZwlrVirtualPointerV1> = self
+ .virtual_pointers
+ .iter()
+ .filter(|p| p.client().and_then(|c| c.get_credentials(&self.dh).ok()).is_some_and(|c| c.pid == pid))
+ .cloned()
+ .collect();
+ for pointer in matching {
+ let Some(data) = pointer.data::<VirtualPointerData>() else { continue };
+ *data.pinned_window.lock().unwrap() = window;
+ *data.pinned_pos.lock().unwrap() = None;
+ // Re-pinning to a *different* window is handled lazily, by
+ // `pinned_move_to`'s own leave-before-re-enter check on the
+ // next motion - but unpinning outright has no next motion to
+ // do that on, so the leave has to happen right here instead,
+ // immediately, rather than leaving the old target thinking a
+ // pointer is still present until whenever (if ever) this same
+ // pid is pinned somewhere else again.
+ if window.is_none() {
+ pinned_leave_current(self, data);
+ }
+ }
+ }
+}
+
+/// This pinned stream's target window's own current content size,
+/// physical pixels - `core::Window::geometry` is already physical, the
+/// same convention `MotionEvent.location` and everything else in this
+/// pointer pipeline uses (see this module's own doc comment on the
+/// separate, already-documented `wl_pointer` client-scale gap this shares
+/// rather than compounds). `None` once the window no longer exists.
+fn pinned_window_size(state: &CompState, window: WindowId) -> Option<(f64, f64)> {
+ state.wm.borrow().windows().find(|w| w.id == window).map(|w| (w.geometry.width as f64, w.geometry.height as f64))
+}
+
+/// This pinned stream's target window's own main surface, if it still
+/// exists - shared with `raise_pinned`'s own `id_to_window` lookup
+/// (`state/geometry.rs`), reusing `elements::window_wl_surface` for the
+/// Wayland/X11-both-kinds resolution every other caller of it already
+/// needs.
+fn pinned_target_surface(state: &CompState, window: WindowId) -> Option<WlSurface> {
+ state.id_to_window.get(&window).and_then(window_wl_surface)
+}
+
+/// Every real `WlPointer` resource the client owning `surface` has bound
+/// on the one real seat - `PointerHandle::client_pointers`, a genuine
+/// smithay-public API for exactly this (not something hand-rolled around
+/// its back). Empty if the surface has no client (already destroyed) or
+/// that client never bound a pointer on this seat at all.
+fn client_pointers_for(state: &CompState, surface: &WlSurface) -> Vec<wl_pointer::WlPointer> {
+ let Some(client) = surface.client() else { return Vec::new() };
+ let Some(pointer) = state.seat.get_pointer() else { return Vec::new() };
+ pointer.client_pointers(&client).collect()
+}
+
+/// Sends `leave` (plus `frame`) to whatever surface this pinned stream
+/// last actually entered, if any, and clears that record - called before
+/// re-entering a *different* surface, on an explicit unpin, and on
+/// destroy. A real pointer's own enter/leave discipline, applied to a
+/// synthetic one: a client that never gets a matching `leave` has no
+/// reason to believe the pointer it saw `enter` ever went away.
+fn pinned_leave_current(state: &CompState, data: &VirtualPointerData) {
+ let Some(prev) = data.pinned_entered.lock().unwrap().take() else { return };
+ let serial = u32::from(SERIAL_COUNTER.next_serial());
+ for p in client_pointers_for(state, &prev) {
+ p.leave(serial, &prev);
+ p.frame();
+ }
+}
+
+/// Ensures `surface` has a real `wl_pointer.enter` on record for this
+/// pinned stream (sending `leave` first to whatever it was previously
+/// entered into, if that was a *different* surface - re-pinned to
+/// another window with no intervening unpin), then sends `motion` and
+/// `frame` to every bound pointer resource. `local` is content-relative,
+/// physical pixels, already clamped to the target window's own bounds by
+/// every caller.
+fn pinned_move_to(state: &mut CompState, data: &VirtualPointerData, surface: &WlSurface, local: Point<f64, Logical>, time: u32) {
+ let pointers = client_pointers_for(state, surface);
+ if pointers.is_empty() {
+ return;
+ }
+ let needs_enter = data.pinned_entered.lock().unwrap().as_ref() != Some(surface);
+ if needs_enter {
+ pinned_leave_current(state, data);
+ let serial = u32::from(SERIAL_COUNTER.next_serial());
+ for p in &pointers {
+ p.enter(serial, surface, local.x, local.y);
+ }
+ *data.pinned_entered.lock().unwrap() = Some(surface.clone());
+ }
+ for p in &pointers {
+ p.motion(time, local.x, local.y);
+ p.frame();
+ }
+}
+
+/// Delivers a pinned `button` request: makes sure the target window has
+/// actually been entered at *some* known position first (a button press
+/// with no prior motion on this pinned stream still needs a real
+/// enter/motion pair before a button event makes sense to a client, same
+/// as a real pointer that had just appeared over a window), defaulting to
+/// its content center the same way a fresh pin with no motion yet does,
+/// then sends the real `button`/`frame` wire events.
+fn pinned_deliver_button(state: &mut CompState, data: &VirtualPointerData, window: WindowId, button: u32, pressed: bool, time: u32) {
+ let Some(surface) = pinned_target_surface(state, window) else { return };
+ let local = data.pinned_pos.lock().unwrap().unwrap_or_else(|| {
+ let (w, h) = pinned_window_size(state, window).unwrap_or((0.0, 0.0));
+ Point::from((w / 2.0, h / 2.0))
+ });
+ *data.pinned_pos.lock().unwrap() = Some(local);
+ pinned_move_to(state, data, &surface, local, time);
+ let serial = u32::from(SERIAL_COUNTER.next_serial());
+ let button_state = if pressed { wl_pointer::ButtonState::Pressed } else { wl_pointer::ButtonState::Released };
+ for p in client_pointers_for(state, &surface) {
+ p.button(serial, time, button, button_state);
+ p.frame();
+ }
+}
+
+fn wire_axis(axis: wl_pointer::Axis) -> Axis {
+ match axis {
+ wl_pointer::Axis::HorizontalScroll => Axis::Horizontal,
+ _ => Axis::Vertical,
+ }
+}
+
+fn wire_axis_source(source: wl_pointer::AxisSource) -> Option<AxisSource> {
+ Some(match source {
+ wl_pointer::AxisSource::Wheel => AxisSource::Wheel,
+ wl_pointer::AxisSource::Finger => AxisSource::Finger,
+ wl_pointer::AxisSource::Continuous => AxisSource::Continuous,
+ wl_pointer::AxisSource::WheelTilt => AxisSource::WheelTilt,
+ _ => return None,
+ })
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn wire_axis_maps_horizontal_and_vertical_correctly() {
+ assert_eq!(wire_axis(wl_pointer::Axis::HorizontalScroll), Axis::Horizontal);
+ assert_eq!(wire_axis(wl_pointer::Axis::VerticalScroll), Axis::Vertical);
+ }
+
+ #[test]
+ fn wire_axis_source_maps_every_known_source() {
+ assert!(wire_axis_source(wl_pointer::AxisSource::Wheel).is_some());
+ assert!(wire_axis_source(wl_pointer::AxisSource::Finger).is_some());
+ assert!(wire_axis_source(wl_pointer::AxisSource::Continuous).is_some());
+ assert!(wire_axis_source(wl_pointer::AxisSource::WheelTilt).is_some());
+ }
+}
diff --git a/crates/wayland/src/window_memory.rs b/crates/wayland/src/window_memory.rs
new file mode 100644
index 0000000..a92c57c
--- /dev/null
+++ b/crates/wayland/src/window_memory.rs
@@ -0,0 +1,124 @@
+//! Persists `WindowManager::remembered_geometry` (per-`app_id` last
+//! floating position+size) across a restart - see that field's own doc
+//! comment in `srdwm_core` for what it is and why it's read at window-map
+//! time. Same load/save-at-the-platform-layer split, same JSON-file-under-
+//! `$XDG_STATE_HOME/srd` shape, and same atomic tmp-then-rename write this
+//! project already established twice (`monitor_layout.rs`, `desktop_icons_
+//! state.rs`) for exactly this kind of small, rarely-written, must-survive-
+//! a-crash state - deliberately not sharing code with either, matching
+//! this codebase's own "a few duplicated lines beats a shared abstraction
+//! for three near-identical small stores" precedent.
+
+use std::collections::HashMap;
+use std::path::PathBuf;
+
+use serde::{Deserialize, Serialize};
+
+#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq)]
+pub(crate) struct PersistedGeometry {
+ pub(crate) x: i32,
+ pub(crate) y: i32,
+ pub(crate) width: u32,
+ pub(crate) height: u32,
+}
+
+#[derive(Serialize, Deserialize, Default)]
+struct PersistedWindowMemory {
+ /// Keyed by `app_id` - the same identifier `remembered_geometry`
+ /// itself is keyed by, and the one thing guaranteed stable across a
+ /// restart that a per-session `WindowId` is not.
+ apps: HashMap<String, PersistedGeometry>,
+}
+
+fn state_dir() -> PathBuf {
+ if let Ok(p) = std::env::var("SRDWM_STATE_PATH") {
+ return PathBuf::from(p);
+ }
+ if let Ok(xdg) = std::env::var("XDG_STATE_HOME") {
+ return PathBuf::from(xdg).join("srd");
+ }
+ if let Ok(home) = std::env::var("HOME") {
+ return PathBuf::from(home).join(".local/state/srd");
+ }
+ PathBuf::from("state/srd")
+}
+
+fn memory_path() -> PathBuf {
+ state_dir().join("window-memory.json")
+}
+
+/// Every remembered app's geometry, by `app_id`. Empty (not an error) if
+/// the file doesn't exist yet or is present but unreadable/corrupt - a
+/// bad state file degrades to "nothing remembered yet", not a startup
+/// failure.
+pub(crate) fn load() -> HashMap<String, PersistedGeometry> {
+ let path = memory_path();
+ let Ok(bytes) = std::fs::read(&path) else { return HashMap::new() };
+ match serde_json::from_slice::<PersistedWindowMemory>(&bytes) {
+ Ok(memory) => memory.apps,
+ Err(e) => {
+ log::warn!("window_memory: couldn't parse {path:?} ({e}); starting with nothing remembered");
+ HashMap::new()
+ }
+ }
+}
+
+/// Overwrites the whole persisted table from `entries` - called after
+/// every drag/resize-end (see `input/pointer.rs`'s call site), which are
+/// rare, real user actions, not a per-frame event, so writing the whole
+/// small file each time costs nothing and needs no separate dirty-tracking
+/// story (the same reasoning `monitor_layout::save_output` and `desktop_
+/// icons_state`'s own saver already settled on for the identical shape of
+/// problem).
+pub(crate) fn save_all<'a>(entries: impl Iterator<Item = (&'a str, (i32, i32, u32, u32))>) {
+ let apps: HashMap<String, PersistedGeometry> =
+ entries.map(|(app_id, (x, y, width, height))| (app_id.to_string(), PersistedGeometry { x, y, width, height })).collect();
+ let memory = PersistedWindowMemory { apps };
+ let dir = state_dir();
+ if let Err(e) = std::fs::create_dir_all(&dir) {
+ log::warn!("window_memory: couldn't create {dir:?} ({e}); this session's window positions/sizes won't survive a restart");
+ return;
+ }
+ let Ok(bytes) = serde_json::to_vec_pretty(&memory) else { return };
+ let path = memory_path();
+ let tmp = path.with_extension("json.tmp");
+ if let Err(e) = std::fs::write(&tmp, &bytes) {
+ log::warn!("window_memory: couldn't write {tmp:?} ({e}); this session's window positions/sizes won't survive a restart");
+ return;
+ }
+ if let Err(e) = std::fs::rename(&tmp, &path) {
+ log::warn!("window_memory: couldn't rename {tmp:?} to {path:?} ({e}); this session's window positions/sizes won't survive a restart");
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ // Only the pure JSON round-trip is exercised here - `state_dir()`/
+ // `load()`/`save_all()` all touch real environment variables and the
+ // filesystem, which parallel `cargo test` execution can't safely share
+ // - same reasoning `monitor_layout.rs`'s own tests give for staying
+ // off real env vars.
+ #[test]
+ fn a_persisted_table_survives_a_json_round_trip() {
+ let mut apps = HashMap::new();
+ apps.insert("alacritty".to_string(), PersistedGeometry { x: 100, y: 100, width: 800, height: 600 });
+ apps.insert("firefox".to_string(), PersistedGeometry { x: -500, y: 0, width: 1280, height: 900 });
+ let memory = PersistedWindowMemory { apps };
+ let bytes = serde_json::to_vec(&memory).unwrap();
+ let parsed: PersistedWindowMemory = serde_json::from_slice(&bytes).unwrap();
+ assert_eq!(parsed.apps.get("alacritty"), Some(&PersistedGeometry { x: 100, y: 100, width: 800, height: 600 }));
+ assert_eq!(parsed.apps.get("firefox"), Some(&PersistedGeometry { x: -500, y: 0, width: 1280, height: 900 }));
+ }
+
+ #[test]
+ fn corrupt_json_falls_back_to_an_empty_table_not_an_error() {
+ let result = serde_json::from_slice::<PersistedWindowMemory>(b"not valid json");
+ assert!(result.is_err(), "sanity: this fixture must actually fail to parse");
+ // `load()` itself can't be called here (touches the real
+ // filesystem/env) - this locks in the *shape* of the fallback
+ // `load()` relies on: a parse error, not a panic, is what lets it
+ // degrade to `HashMap::new()` instead of taking the compositor down.
+ }
+}
diff --git a/crates/wayland/src/winit/connect.rs b/crates/wayland/src/winit/connect.rs
index 140dc58..39eda5a 100644
--- a/crates/wayland/src/winit/connect.rs
+++ b/crates/wayland/src/winit/connect.rs
@@ -111,6 +111,7 @@ impl WaylandPlatform {
data_control_state,
session_lock_state: SessionLockManagerState::new::<CompState, _>(&dh, |_| true),
_screencopy_state: screencopy::ScreencopyState::new::<CompState>(&dh),
+ _virtual_pointer_state: crate::virtual_pointer::VirtualPointerState::new::<CompState>(&dh),
screencopy_pending: Vec::new(),
_appmenu_state: crate::appmenu::AppmenuManagerState::new::<CompState>(&dh),
_virtual_keyboard_state: smithay::wayland::virtual_keyboard::VirtualKeyboardManagerState::new::<CompState, _>(&dh, |_client| true),
@@ -154,6 +155,8 @@ impl WaylandPlatform {
desktop_icons: None,
desktop_icon_buffers: HashMap::new(),
desktop_icon_drag: None,
+ desktop_marquee: None,
+ marquee_buffers: Default::default(),
desktop_menu: None,
desktop_menu_buffer: None,
last_icon_click: None,
@@ -161,6 +164,7 @@ impl WaylandPlatform {
wm: wm.clone(),
surface_to_id: HashMap::new(),
id_to_window: HashMap::new(),
+ virtual_pointers: Vec::new(),
dead_layer_surfaces: HashSet::new(),
hidden_layer_surfaces: HashMap::new(),
layer_surfaces_shown_once: HashSet::new(),
@@ -192,6 +196,16 @@ impl WaylandPlatform {
appmenu_registrar: None,
};
+ // Same per-app remembered position/size seeding as the udev
+ // backend's own `connect()` - see that call site's own comment
+ // and `window_memory.rs` for what/why. The nested backend has no
+ // daily-driver use case of its own, but a window mapped while
+ // testing under it should still honor whatever a real session
+ // already remembered, not silently ignore the same store.
+ for (app_id, g) in crate::window_memory::load() {
+ wm.borrow_mut().set_remembered_geometry(app_id, (g.x, g.y, g.width, g.height));
+ }
+
let listener = ListeningSocket::bind_auto("wayland", 0..32).map_err(err)?;
if let Some(name) = listener.socket_name() {
std::env::set_var("WAYLAND_DISPLAY", name);
diff --git a/crates/wayland/src/winit/nested_platform.rs b/crates/wayland/src/winit/nested_platform.rs
index 316b1b5..40d03ff 100644
--- a/crates/wayland/src/winit/nested_platform.rs
+++ b/crates/wayland/src/winit/nested_platform.rs
@@ -69,6 +69,16 @@ impl Platform for WaylandPlatform {
self.state.begin_native_lock();
}
self.state.poll_native_lock_auth();
+ // Same pin-input draining as `udev/platform.rs`'s matching block --
+ // see its own comment and `virtual_pointer.rs`'s module doc
+ // comment for the full Phase 2 design. Pinned virtual-pointer
+ // delivery never touches `udev`/`bounds()` at all (unlike this
+ // backend's own unpinned motion, which is a documented no-op
+ // here), so this is exercised here too - genuinely the way to
+ // validate it in a nested instance rather than the live session.
+ for (pid, window) in self.wm.borrow_mut().drain_pin_input_requests() {
+ self.state.set_virtual_pointer_pin(pid, window);
+ }
let wait = TARGET_FRAME_TIME.saturating_sub(self.last_frame.elapsed());
let _ = self.idle_event_loop.dispatch(Some(wait), &mut self.state);
self.last_frame = Instant::now();
diff --git a/crates/wayland/src/xwayland.rs b/crates/wayland/src/xwayland.rs
index 7834f16..5b537d2 100644
--- a/crates/wayland/src/xwayland.rs
+++ b/crates/wayland/src/xwayland.rs
@@ -92,8 +92,31 @@ pub(crate) fn spawn(handle: &LoopHandle<'static, CompState>, display_handle: &sm
std::process::Stdio::null()
})
};
- let (xwayland, client) =
- XWayland::spawn(display_handle, None, std::iter::empty::<(String, String)>(), true, stdio(&xwayland_log), stdio(&xwayland_log), |_| ())?;
+ // `XWayland::spawn` itself clears the child's entire environment except
+ // `PATH`/`XDG_RUNTIME_DIR` before applying whatever's passed here (see
+ // smithay 0.7.0's own `xwayland/xserver.rs::spawn`) - deliberate
+ // isolation, but too aggressive for this compositor's own keyboard
+ // setup: `xkb_config.rs` reads the real `pc105+inet`-style model/layout
+ // from `/etc/X11/xorg.conf.d/00-keyboard.conf` and feeds it to
+ // `smithay::input::keyboard::XkbConfig`, whose compiled keymap is what
+ // XWayland receives over Wayland and re-compiles internally via its own
+ // `xkbcomp` subprocess (visible as "The XKEYBOARD keymap compiler
+ // (xkbcomp) reports" in `xwayland.log`) - and that subprocess, with no
+ // `HOME`/`LANG` at all, hit "Keyboard initialization failed... Fatal
+ // server error: Failed to activate virtual core keyboard: 2" on every
+ // single spawn in a live session (confirmed: 53 identical crashes in
+ // one `xwayland.log`), while the exact same binary spawned by hand with
+ // a normal environment against the same running compositor stayed up
+ // and answered `xdpyinfo` - narrowing the difference to the
+ // environment `XWayland::spawn` hands the child, not the keymap
+ // content or the binary itself. `HOME`/`LANG`/`LC_ALL` are what
+ // `xkbcomp` and the C locale layer it runs under (`iconv`, `setlocale`)
+ // actually consult; passed through only if this process itself has
+ // them, same "degrade to `spawn`'s own already-safe default rather
+ // than pass an empty string" shape as everywhere else in this file.
+ let xwayland_envs: Vec<(String, String)> =
+ ["HOME", "LANG", "LC_ALL", "LC_CTYPE"].into_iter().filter_map(|k| std::env::var(k).ok().map(|v| (k.to_string(), v))).collect();
+ let (xwayland, client) = XWayland::spawn(display_handle, None, xwayland_envs, true, stdio(&xwayland_log), stdio(&xwayland_log), |_| ())?;
let handle_for_ready = handle.clone();
handle
@@ -576,7 +599,33 @@ fn ensure_shm_wrapper_on_path() -> std::io::Result<()> {
let wrapper_path = wrapper_dir.join("Xwayland");
let quoted = shell_single_quote(&real_xwayland.to_string_lossy());
- std::fs::write(&wrapper_path, format!("#!/bin/sh\nexec {quoted} -shm \"$@\"\n"))?;
+ // `< /dev/null` on the final `exec`, not just `-shm` - `smithay::
+ // xwayland::XWayland::spawn` sets `stdout`/`stderr` on the child
+ // (redirected to `xwayland_log` above) but has no parameter for `stdin`
+ // at all, so Rust's `Command` default (`Stdio::inherit()`) applies:
+ // Xwayland's own fd 0 is whatever *this* process's fd 0 is. On the real
+ // udev/DRM backend that's the real active VT (`/dev/tty1`, confirmed by
+ // reading `/proc/<pid>/fd/0` on a live session) - srdwm itself already
+ // holds that VT's keyboard mode exclusively via libseat for its own
+ // DRM/KMS session. A generic X server's keyboard-driver bring-up still
+ // probes whatever's on its own stdin as a possible console device
+ // before falling back to its Wayland-only input path, and inheriting a
+ // *real, already-owned* VT there is exactly the shape of "Failed to
+ // activate virtual core keyboard: 2" - confirmed reproducing 100% of
+ // cold starts (54 identical crashes across every restart this session,
+ // including one after the previous, insufficient fix), never once
+ // reproducing under a manual invocation from an interactive shell
+ // (whose own stdin is a pty, not a VT) the same way this file's own
+ // env-passthrough fix never reproduced it either. A shell wrapper can
+ // redirect its own stdin before the final `exec` in a way `Command`'s
+ // public API here cannot - the same reason this wrapper exists at all
+ // for `-shm`.
+ // DECO-DIAG/XWAYLAND-DIAG temporary: `strace -f` (follows forked
+ // children, e.g. xkbcomp) around the real binary, to see exactly what
+ // xkbcomp's own execve/openat/etc. calls do differently here than in
+ // an interactive-shell reproduction of the identical command that
+ // never fails - remove once the real cause is found.
+ std::fs::write(&wrapper_path, format!("#!/bin/sh\nexec strace -f -o /tmp/xwayland-strace.log {quoted} -shm \"$@\" < /dev/null\n"))?;
let mut perms = std::fs::metadata(&wrapper_path)?.permissions();
perms.set_mode(0o755);
std::fs::set_permissions(&wrapper_path, perms)?;