diff options
Diffstat (limited to 'crates/wayland')
| -rw-r--r-- | crates/wayland/src/decoration.rs | 183 | ||||
| -rw-r--r-- | crates/wayland/src/desktop_icons.rs | 274 | ||||
| -rw-r--r-- | crates/wayland/src/desktop_icons_state.rs | 105 | ||||
| -rw-r--r-- | crates/wayland/src/desktop_menu.rs | 126 | ||||
| -rw-r--r-- | crates/wayland/src/input/pointer.rs | 59 | ||||
| -rw-r--r-- | crates/wayland/src/lib.rs | 3 | ||||
| -rw-r--r-- | crates/wayland/src/state/desktop_icons.rs | 344 | ||||
| -rw-r--r-- | crates/wayland/src/state/mod.rs | 33 | ||||
| -rw-r--r-- | crates/wayland/src/udev/platform.rs | 6 | ||||
| -rw-r--r-- | crates/wayland/src/udev/render.rs | 28 | ||||
| -rw-r--r-- | crates/wayland/src/winit/connect.rs | 6 | ||||
| -rw-r--r-- | crates/wayland/src/winit/render.rs | 22 |
12 files changed, 1189 insertions, 0 deletions
diff --git a/crates/wayland/src/decoration.rs b/crates/wayland/src/decoration.rs index 98bb9d1..bbd6817 100644 --- a/crates/wayland/src/decoration.rs +++ b/crates/wayland/src/decoration.rs @@ -194,5 +194,188 @@ pub fn render_snap_flyout(columns: u32, cell_width: u32, cell_height: u32, label buf } +/// One desktop icon's cell: a hand-drawn glyph (no icon-theme artwork +/// exists anywhere in this workspace - see `desktop_icons.rs`'s own doc +/// comment) plus a centred label underneath, on an otherwise fully +/// transparent `width`x`height` canvas so the wallpaper shows through +/// everywhere the glyph/label don't draw. Same "deliberately plain, no +/// icon-theme fidelity" first-pass philosophy as `render_context_menu`. +/// +/// `selected` draws an opaque highlight box behind the label only (not the +/// glyph) - matching the classic file-manager convention that the label, +/// not the whole cell, is what visibly marks a selection - rather than a +/// translucent overlay across the glyph, which would need premultiplied- +/// 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> { + 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); + let border = color::darken(icon_color); + match kind { + IconKind::Home => draw_home_glyph(&mut buf, width, height, glyph_box, icon_color, border), + IconKind::Computer => draw_computer_glyph(&mut buf, width, height, glyph_box, icon_color, border), + IconKind::Trash => draw_trash_glyph(&mut buf, width, height, glyph_box, icon_color, border), + IconKind::Folder => draw_folder_glyph(&mut buf, width, height, glyph_box, icon_color, border), + IconKind::File => draw_file_glyph(&mut buf, width, height, glyph_box, 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(); + let mut total = 0.0f32; + for ch in label.chars() { + let (m, _) = font.rasterize(ch, FONT_PIXELS); + widths.push(m.advance_width); + total += m.advance_width; + } + 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() { + pen_x += adv; + continue; + } + let (metrics, coverage) = font.rasterize(ch, FONT_PIXELS); + 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); + } + pen_x += adv; + if pen_x as usize >= width { + break; + } + } + } + buf +} + +/// Fills a straight-alpha `color` at `alpha` into every pixel of the given +/// rect, clamped to the canvas - the one primitive every glyph below is +/// built from. `alpha` is only ever `255` from any call site in this file +/// (every icon glyph is drawn fully opaque against an otherwise-transparent +/// canvas), so there's no premultiplication to get right here - straight +/// and premultiplied colour are identical at full opacity. +#[allow(clippy::too_many_arguments)] +fn fill_rect(buf: &mut [u8], width: usize, height: usize, x0: i32, y0: i32, x1: i32, y1: i32, color: (u8, u8, u8), alpha: u8) { + let px = rgb_to_bgra(color, alpha); + for y in y0.max(0)..y1.min(height as i32) { + for x in x0.max(0)..x1.min(width as i32) { + let idx = (y as usize * width + x as usize) * 4; + buf[idx..idx + 4].copy_from_slice(&px); + } + } +} + +/// Same job as `font::blit_glyph`, but for a canvas that starts fully +/// transparent rather than a known solid `background` colour to blend +/// toward - `blit_glyph` always writes full alpha, blended toward that +/// assumed background, which is wrong here: a partially-covered edge pixel +/// needs to stay partially *transparent*, not opaque-and-blended. Written +/// as real premultiplied-alpha BGRA (`rgb * alpha / 255`, matching alpha) +/// rather than straight colour at a partial alpha - see this session's own +/// `rounded_corners_pixman.rs` doc comments for why an un-premultiplied +/// partial-alpha pixel is a real, previously-hit correctness bug here, not +/// a style choice. +#[allow(clippy::too_many_arguments)] +fn blit_glyph_on_transparent(buf: &mut [u8], width: usize, height: usize, glyph_x: i32, glyph_y: i32, metrics: &fontdue::Metrics, coverage: &[u8], color: (u8, u8, u8)) { + for row in 0..metrics.height { + let y = glyph_y + row as i32; + if y < 0 || y as usize >= height { + continue; + } + for col in 0..metrics.width { + let x = glyph_x + col as i32; + if x < 0 || x as usize >= width { + continue; + } + let alpha = coverage[row * metrics.width + col]; + if alpha == 0 { + continue; + } + let premul = |c: u8| ((c as u32 * alpha as u32 + 127) / 255) as u8; + let idx = (y as usize * width + x as usize) * 4; + buf[idx..idx + 4].copy_from_slice(&rgb_to_bgra((premul(color.0), premul(color.1), premul(color.2)), alpha)); + } + } +} + +fn draw_folder_glyph(buf: &mut [u8], width: usize, height: usize, b: (i32, i32, i32, i32), fill: (u8, u8, u8), border: (u8, u8, u8)) { + let (x0, y0, x1, y1) = b; + let tab_w = (x1 - x0) * 2 / 5; + let tab_h = 5; + fill_rect(buf, width, height, x0, y0, x0 + tab_w, y0 + tab_h, fill, 255); + fill_rect(buf, width, height, x0, y0 + tab_h, x1, y1, fill, 255); + fill_rect(buf, width, height, x0, y0 + tab_h, x1, y0 + tab_h + 2, border, 255); +} + +fn draw_computer_glyph(buf: &mut [u8], width: usize, height: usize, b: (i32, i32, i32, i32), fill: (u8, u8, u8), border: (u8, u8, u8)) { + let (x0, y0, x1, y1) = b; + let screen_bottom = y0 + (y1 - y0) * 3 / 4; + fill_rect(buf, width, height, x0, y0, x1, screen_bottom, border, 255); + fill_rect(buf, width, height, x0 + 2, y0 + 2, x1 - 2, screen_bottom - 2, fill, 255); + let stand_w = (x1 - x0) / 4; + let stand_x0 = x0 + (x1 - x0 - stand_w) / 2; + fill_rect(buf, width, height, stand_x0, screen_bottom, stand_x0 + stand_w, y1 - 2, border, 255); + fill_rect(buf, width, height, x0 + 2, y1 - 2, x1 - 2, y1, border, 255); +} + +fn draw_trash_glyph(buf: &mut [u8], width: usize, height: usize, b: (i32, i32, i32, i32), fill: (u8, u8, u8), border: (u8, u8, u8)) { + let (x0, y0, x1, y1) = b; + let lid_h = 4; + fill_rect(buf, width, height, x0, y0, x1, y0 + lid_h, border, 255); + let handle_w = (x1 - x0) / 3; + let handle_x0 = x0 + (x1 - x0 - handle_w) / 2; + fill_rect(buf, width, height, handle_x0, y0 - 3, handle_x0 + handle_w, y0, border, 255); + let body_x0 = x0 + 2; + let body_x1 = x1 - 2; + fill_rect(buf, width, height, body_x0, y0 + lid_h, body_x1, y1, fill, 255); + // Three vertical ridge lines, the classic trash-can silhouette detail. + let ridge_w = 2; + for i in 0..3 { + let rx = body_x0 + (body_x1 - body_x0) * (i * 2 + 1) / 6; + fill_rect(buf, width, height, rx, y0 + lid_h + 3, rx + ridge_w, y1 - 3, border, 255); + } +} + +fn draw_file_glyph(buf: &mut [u8], width: usize, height: usize, b: (i32, i32, i32, i32), fill: (u8, u8, u8), border: (u8, u8, u8)) { + let (x0, y0, x1, y1) = b; + fill_rect(buf, width, height, x0, y0, x1, y1, border, 255); + fill_rect(buf, width, height, x0 + 2, y0 + 2, x1 - 2, y1 - 2, fill, 255); + // A header strip near the top, the same "document" cue `draw_folder_ + // glyph`'s tab gives a folder - deliberately no folded-corner detail, + // which would need a diagonal (not axis-aligned) fill this file's other + // glyphs never need. + fill_rect(buf, width, height, x0 + 4, y0 + 5, x1 - 4, y0 + 8, border, 255); +} + +fn draw_home_glyph(buf: &mut [u8], width: usize, height: usize, b: (i32, i32, i32, i32), fill: (u8, u8, u8), border: (u8, u8, u8)) { + let (x0, y0, x1, y1) = b; + let mid_x = (x0 + x1) / 2; + let roof_y = y0 + (y1 - y0) / 3; + // A simple triangular roof built from shrinking horizontal strips + // (this file's only axis-aligned primitive is a filled rect) rather + // than a real diagonal line - coarse at this size, but reads clearly + // as a roof over the body rect below it. + let steps = (roof_y - y0).max(1); + for i in 0..steps { + let y = y0 + i; + let inset = (i * (mid_x - x0)) / steps; + fill_rect(buf, width, height, mid_x - inset - 1, y, mid_x + inset + 1, y + 1, border, 255); + } + fill_rect(buf, width, height, x0 + 2, roof_y, x1 - 2, y1, fill, 255); + fill_rect(buf, width, height, x0 + 2, roof_y, x1 - 2, roof_y + 2, border, 255); + let door_w = (x1 - x0) / 4; + let door_x0 = mid_x - door_w / 2; + fill_rect(buf, width, height, door_x0, y1 - 10, door_x0 + door_w, y1, border, 255); +} + #[cfg(test)] mod tests; diff --git a/crates/wayland/src/desktop_icons.rs b/crates/wayland/src/desktop_icons.rs new file mode 100644 index 0000000..470a965 --- /dev/null +++ b/crates/wayland/src/desktop_icons.rs @@ -0,0 +1,274 @@ +//! Real desktop icons - Home/Computer/Trash plus one per real `~/Desktop` +//! entry. Same "compositor-owned floating UI, not tied to a client window" +//! shape as `context_menu.rs`: a plain data struct with its own open/hit- +//! test, no smithay dependency at all, rasterized separately in +//! `decoration.rs` and glued into `CompState` the same way that file's own +//! `ContextMenu` is. +//! +//! Positions are grid cells (column, row), not raw pixels - a dropped drag +//! always snaps to one, and `desktop_icons_state.rs`'s persistence stores +//! cells, not pixels, so a later change to `CELL_WIDTH`/`CELL_HEIGHT` +//! doesn't scatter every saved position off-grid. + +use std::collections::{HashMap, HashSet}; +use std::path::{Path, PathBuf}; + +pub(crate) const CELL_WIDTH: i32 = 88; +pub(crate) const CELL_HEIGHT: i32 = 88; +/// Gap between the primary monitor's own usable-area edge and the first +/// column/row of icons - purely cosmetic, keeps icons off a bar/dock's +/// exclusive-zone edge rather than flush against it. +pub(crate) const GRID_MARGIN: i32 = 16; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum IconKind { + Home, + Computer, + Trash, + Folder, + File, +} + +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct DesktopIcon { + /// Stable identity: `"home"`/`"computer"`/`"trash"` for the three fixed + /// icons, or the real filename for a `~/Desktop` entry - doubles as + /// the JSON-persistence key and the per-icon render-buffer cache key, + /// so it must stay stable across a rescan for anything the user hasn't + /// renamed or deleted on disk. + pub(crate) id: String, + pub(crate) label: String, + pub(crate) kind: IconKind, + /// What double-click/"Open" launches: `$HOME`, `/`, the trash folder, + /// or the real path under `~/Desktop`. + pub(crate) target: PathBuf, + pub(crate) cell: (i32, i32), + pub(crate) selected: bool, +} + +impl DesktopIcon { + pub(crate) fn top_left(&self, origin: (i32, i32)) -> (i32, i32) { + (origin.0 + self.cell.0 * CELL_WIDTH, origin.1 + self.cell.1 * CELL_HEIGHT) + } + + pub(crate) fn contains(&self, origin: (i32, i32), x: i32, y: i32) -> bool { + let (left, top) = self.top_left(origin); + x >= left && x < left + CELL_WIDTH && y >= top && y < top + CELL_HEIGHT + } +} + +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), + 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)) + } +} + +/// `$HOME`, or `None` if genuinely unset - callers degrade to "no desktop +/// icons at all" rather than guessing, same as `monitor_layout.rs::state_ +/// dir()`'s own fallback chain does for a missing `$HOME`. +fn home_dir() -> Option<PathBuf> { + std::env::var("HOME").ok().map(PathBuf::from) +} + +fn desktop_dir(home: &Path) -> PathBuf { + home.join("Desktop") +} + +/// `$XDG_DATA_HOME/Trash/files`, else `~/.local/share/Trash/files` - the +/// freedesktop.org Trash spec's home-filesystem trash directory. Only the +/// same-filesystem case is handled anywhere in this codebase (see this +/// feature's own plan doc for why the per-mountpoint `.Trash-$uid` +/// fallback is out of scope for now); this is purely where the Trash +/// desktop icon opens to, nothing currently moves a file into it. +fn trash_files_dir(home: &Path) -> PathBuf { + let data_home = std::env::var("XDG_DATA_HOME").map(PathBuf::from).unwrap_or_else(|_| home.join(".local/share")); + data_home.join("Trash/files") +} + +/// Rebuilds the full icon list from the real filesystem: the three fixed +/// icons first, then one per direct, non-hidden entry of `~/Desktop` +/// (creating that directory if it doesn't exist yet, matching how a real +/// desktop environment bootstraps an empty one on first run), sorted by +/// name. `saved` is `desktop_icons_state`'s own persisted `id -> cell` map +/// - an icon with a saved entry keeps that exact cell; every other icon +/// (new files, or a first run with nothing saved yet) fills the next free +/// cell in top-to-bottom, then wrap-to-next-column order, skipping any +/// cell a saved icon already claims. +/// +/// `rows_per_column` bounds how many icons stack vertically before +/// wrapping - derived from the primary monitor's own usable height, see +/// this module's caller in `state/desktop_icons.rs`. +pub(crate) fn rescan(saved: &HashMap<String, (i32, i32)>, rows_per_column: i32) -> Vec<DesktopIcon> { + let rows_per_column = rows_per_column.max(1); + let mut icons = vec![ + DesktopIcon { + id: "home".to_string(), + label: "Home".to_string(), + kind: IconKind::Home, + target: home_dir().unwrap_or_else(|| PathBuf::from("/")), + cell: (0, 0), + selected: false, + }, + DesktopIcon { + id: "computer".to_string(), + label: "Computer".to_string(), + kind: IconKind::Computer, + target: PathBuf::from("/"), + cell: (0, 0), + selected: false, + }, + DesktopIcon { + id: "trash".to_string(), + label: "Trash".to_string(), + kind: IconKind::Trash, + target: home_dir().map(|h| trash_files_dir(&h)).unwrap_or_else(|| PathBuf::from("/")), + cell: (0, 0), + selected: false, + }, + ]; + if let Some(home) = home_dir() { + let desktop = desktop_dir(&home); + if std::fs::create_dir_all(&desktop).is_ok() { + if let Ok(entries) = std::fs::read_dir(&desktop) { + let mut files: Vec<(String, bool)> = entries + .filter_map(|e| e.ok()) + .filter_map(|e| { + let name = e.file_name().to_string_lossy().into_owned(); + if name.starts_with('.') { + return None; + } + let is_dir = e.file_type().map(|t| t.is_dir()).unwrap_or(false); + Some((name, is_dir)) + }) + .collect(); + files.sort_by(|a, b| a.0.cmp(&b.0)); + for (name, is_dir) in files { + let target = desktop.join(&name); + icons.push(DesktopIcon { + id: name.clone(), + label: name, + kind: if is_dir { IconKind::Folder } else { IconKind::File }, + target, + cell: (0, 0), + selected: false, + }); + } + } + } + } + assign_cells(&mut icons, saved, rows_per_column); + icons +} + +/// Splits `icons` into "has a saved cell" and "needs a default one", places +/// the saved ones first (so they occupy their cells before any default +/// assignment can land on the same one), then walks column-major order +/// filling the rest into whatever's still free. +fn assign_cells(icons: &mut [DesktopIcon], saved: &HashMap<String, (i32, i32)>, rows_per_column: i32) { + let mut used: HashSet<(i32, i32)> = HashSet::new(); + let mut unplaced: Vec<usize> = Vec::new(); + for (i, icon) in icons.iter_mut().enumerate() { + match saved.get(&icon.id) { + Some(&cell) => { + icon.cell = cell; + used.insert(cell); + } + None => unplaced.push(i), + } + } + let mut col = 0; + let mut row = 0; + for i in unplaced { + while used.contains(&(col, row)) { + row += 1; + if row >= rows_per_column { + row = 0; + col += 1; + } + } + icons[i].cell = (col, row); + used.insert((col, row)); + row += 1; + if row >= rows_per_column { + row = 0; + col += 1; + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn fixed_icons_always_come_first_in_a_stable_order() { + let icons = rescan(&HashMap::new(), 10); + assert!(icons.len() >= 3, "at least the three fixed icons"); + assert_eq!(icons[0].id, "home"); + assert_eq!(icons[1].id, "computer"); + assert_eq!(icons[2].id, "trash"); + } + + #[test] + fn default_cells_fill_top_to_bottom_then_wrap_to_the_next_column() { + let mut icons = vec![ + DesktopIcon { id: "a".into(), label: "a".into(), kind: IconKind::File, target: PathBuf::new(), cell: (0, 0), selected: false }, + DesktopIcon { id: "b".into(), label: "b".into(), kind: IconKind::File, target: PathBuf::new(), cell: (0, 0), selected: false }, + DesktopIcon { id: "c".into(), label: "c".into(), kind: IconKind::File, target: PathBuf::new(), cell: (0, 0), selected: false }, + ]; + assign_cells(&mut icons, &HashMap::new(), 2); + assert_eq!(icons[0].cell, (0, 0)); + assert_eq!(icons[1].cell, (0, 1)); + assert_eq!(icons[2].cell, (1, 0), "third icon wraps to the next column once the first is full"); + } + + #[test] + fn a_saved_cell_is_kept_and_default_placement_skips_it() { + let mut icons = vec![ + DesktopIcon { id: "a".into(), label: "a".into(), kind: IconKind::File, target: PathBuf::new(), cell: (0, 0), selected: false }, + DesktopIcon { id: "b".into(), label: "b".into(), kind: IconKind::File, target: PathBuf::new(), cell: (0, 0), selected: false }, + ]; + let mut saved = HashMap::new(); + saved.insert("a".to_string(), (0, 0)); + assign_cells(&mut icons, &saved, 3); + assert_eq!(icons[0].cell, (0, 0), "a keeps its saved cell"); + assert_eq!(icons[1].cell, (0, 1), "b's default placement skips a's occupied cell"); + } + + #[test] + fn hidden_desktop_entries_are_never_listed() { + // Pure unit test of the filter logic without touching the real + // filesystem: `rescan` itself reads `$HOME`, which parallel + // `cargo test` runs can't safely override (same reasoning `monitor_ + // layout.rs`'s own tests give for staying off real env vars) - so + // this only locks in the *rule*, matching `corrupt_json_falls_ + // back_to_an_empty_layout_not_an_error`'s own "shape, not the real + // I/O" pattern. + let name = ".hidden"; + assert!(name.starts_with('.'), "sanity: this is the exact condition rescan's own filter checks"); + } + + #[test] + fn icon_at_matches_only_its_own_cell() { + let icons = DesktopIcons { + origin: (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"); + 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"); + } +} diff --git a/crates/wayland/src/desktop_icons_state.rs b/crates/wayland/src/desktop_icons_state.rs new file mode 100644 index 0000000..964c316 --- /dev/null +++ b/crates/wayland/src/desktop_icons_state.rs @@ -0,0 +1,105 @@ +//! Persists desktop icons' grid cells across restarts - same shape as +//! `monitor_layout.rs`, deliberately not sharing code with it (its own doc +//! comment gives the reasoning: two small, independent files beat a +//! cross-module dependency for four lines of env-var lookup). +//! +//! Only icons the user has actually dragged get an entry here - see +//! `desktop_icons::rescan`'s own doc comment for why a fresh/unmoved icon +//! deliberately has no row in this file at all. + +use std::collections::HashMap; +use std::path::PathBuf; + +use serde::{Deserialize, Serialize}; + +#[derive(Serialize, Deserialize, Default)] +struct PersistedIcons { + /// Keyed by `DesktopIcon::id` - `"home"`/`"computer"`/`"trash"`, or a + /// real `~/Desktop` filename. + icons: HashMap<String, (i32, i32)>, +} + +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 icons_path() -> PathBuf { + state_dir().join("desktop-icons.json") +} + +/// Every remembered icon cell, by icon id. Empty (not an error) if the file +/// doesn't exist yet or is unreadable/corrupt - a bad state file degrades +/// to "every icon gets its default grid slot", not a startup failure. +pub(crate) fn load() -> HashMap<String, (i32, i32)> { + let path = icons_path(); + let Ok(bytes) = std::fs::read(&path) else { return HashMap::new() }; + match serde_json::from_slice::<PersistedIcons>(&bytes) { + Ok(persisted) => persisted.icons, + Err(e) => { + log::warn!("desktop_icons_state: couldn't parse {path:?} ({e}); starting with default icon positions instead"); + HashMap::new() + } + } +} + +/// Overwrites one icon's remembered cell and rewrites the whole file -- +/// read-modify-write, same reasoning as `monitor_layout.rs::save_output`: +/// a drag-and-drop is a rare, human-paced event, not a per-frame one, so +/// re-reading the small file each time costs nothing. +pub(crate) fn save_icon(id: &str, cell: (i32, i32)) { + let mut persisted = PersistedIcons { icons: load() }; + persisted.icons.insert(id.to_string(), cell); + let dir = state_dir(); + if let Err(e) = std::fs::create_dir_all(&dir) { + log::warn!("desktop_icons_state: couldn't create {dir:?} ({e}); this icon's position won't survive a restart"); + return; + } + let Ok(bytes) = serde_json::to_vec_pretty(&persisted) else { return }; + let path = icons_path(); + // `.tmp`-sibling-then-rename, same crash-safety reasoning as + // `monitor_layout.rs::save_output`. + let tmp = path.with_extension("json.tmp"); + if let Err(e) = std::fs::write(&tmp, &bytes) { + log::warn!("desktop_icons_state: couldn't write {tmp:?} ({e}); this icon's position won't survive a restart"); + return; + } + if let Err(e) = std::fs::rename(&tmp, &path) { + log::warn!("desktop_icons_state: couldn't rename {tmp:?} to {path:?} ({e}); this icon's position won't survive a restart"); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + // Same reasoning as `monitor_layout.rs`'s own tests: only the pure JSON + // round-trip is exercised here, not `load()`/`save_icon()` themselves, + // since both touch real environment variables and the filesystem that + // parallel `cargo test` runs can't safely share. + #[test] + fn a_persisted_icon_map_survives_a_json_round_trip() { + let mut icons = HashMap::new(); + icons.insert("home".to_string(), (0, 0)); + icons.insert("report.txt".to_string(), (2, 3)); + let persisted = PersistedIcons { icons }; + let bytes = serde_json::to_vec(&persisted).unwrap(); + let parsed: PersistedIcons = serde_json::from_slice(&bytes).unwrap(); + assert_eq!(parsed.icons.get("home"), Some(&(0, 0))); + assert_eq!(parsed.icons.get("report.txt"), Some(&(2, 3))); + } + + #[test] + fn corrupt_json_falls_back_to_an_empty_map_not_an_error() { + let result = serde_json::from_slice::<PersistedIcons>(b"not valid json"); + assert!(result.is_err(), "sanity: this fixture must actually fail to parse"); + } +} diff --git a/crates/wayland/src/desktop_menu.rs b/crates/wayland/src/desktop_menu.rs new file mode 100644 index 0000000..810e639 --- /dev/null +++ b/crates/wayland/src/desktop_menu.rs @@ -0,0 +1,126 @@ +//! Right-click desktop icon / bare-desktop menu - the sibling of +//! `context_menu.rs`'s titlebar window menu, same shape (own `open`/`row_ +//! at`, rasterized via `decoration::render_context_menu`), for the two new +//! right-click targets desktop icons add: an icon itself, or bare desktop. + +use crate::desktop_icons::{DesktopIcon, IconKind}; + +#[derive(Clone)] +pub(crate) enum DesktopMenuAction { + /// Open the icon with this id - same action a double-click runs. + OpenIcon(String), + /// Shell out to `general.wallpaper_command` with this icon's path -- + /// only ever offered for an image-file icon, and only when that + /// config key is actually set (see `WindowManager::wallpaper_command`'s + /// own doc comment). + SetWallpaper(String), + NewFolder, + Refresh, +} + +pub(crate) struct DesktopMenu { + pub(crate) pos: (i32, i32), + pub(crate) width: u32, + pub(crate) row_height: u32, + pub(crate) items: Vec<(&'static str, DesktopMenuAction)>, +} + +const MENU_WIDTH: u32 = 170; +const ROW_HEIGHT: u32 = 28; + +/// Extensions `render_desktop_icon`'s `IconKind::File` icons treat as an +/// image for "Set as Wallpaper" purposes - not a real mimetype sniff (no +/// such capability exists anywhere in this workspace, see `desktop_icons. +/// rs`'s own doc comment on why icon art itself is hand-drawn, not +/// decoded), just the common raster formats a wallpaper tool actually +/// accepts. +const IMAGE_EXTENSIONS: &[&str] = &["png", "jpg", "jpeg", "webp", "bmp", "gif"]; + +fn is_image_path(path: &std::path::Path) -> bool { + path.extension().and_then(|e| e.to_str()).map(|e| IMAGE_EXTENSIONS.iter().any(|ext| e.eq_ignore_ascii_case(ext))).unwrap_or(false) +} + +impl DesktopMenu { + /// Right-click on `icon` itself: "Open" always, plus "Set as Wallpaper" + /// when `icon` is an image file and `wallpaper_command` is non-empty. + pub(crate) fn open_for_icon(icon: &DesktopIcon, pos: (i32, i32), wallpaper_command: &str) -> Self { + let mut items = vec![("Open", DesktopMenuAction::OpenIcon(icon.id.clone()))]; + if icon.kind == IconKind::File && !wallpaper_command.is_empty() && is_image_path(&icon.target) { + items.push(("Set as Wallpaper", DesktopMenuAction::SetWallpaper(icon.id.clone()))); + } + Self { pos, width: MENU_WIDTH, row_height: ROW_HEIGHT, items } + } + + /// Right-click on bare desktop (no icon under the pointer): "New + /// Folder" and "Refresh". + pub(crate) fn open_for_desktop(pos: (i32, i32)) -> Self { + let items = vec![("New Folder", DesktopMenuAction::NewFolder), ("Refresh", DesktopMenuAction::Refresh)]; + Self { pos, width: MENU_WIDTH, row_height: ROW_HEIGHT, items } + } + + pub(crate) fn height(&self) -> i32 { + self.row_height as i32 * self.items.len() as i32 + } + + /// Same shape as `ContextMenu::row_at`. + pub(crate) fn row_at(&self, x: i32, y: i32) -> Option<usize> { + if x < self.pos.0 || x >= self.pos.0 + self.width as i32 { + return None; + } + let rel_y = y - self.pos.1; + if rel_y < 0 || rel_y >= self.height() { + return None; + } + Some((rel_y / self.row_height as i32) as usize) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::PathBuf; + + fn icon(kind: IconKind, target: &str) -> DesktopIcon { + DesktopIcon { id: "x".into(), label: "x".into(), kind, target: PathBuf::from(target), cell: (0, 0), selected: false } + } + + #[test] + fn image_file_with_a_configured_command_gets_the_wallpaper_row() { + let menu = DesktopMenu::open_for_icon(&icon(IconKind::File, "pic.png"), (0, 0), "swww img"); + assert_eq!(menu.items.len(), 2); + assert_eq!(menu.items[1].0, "Set as Wallpaper"); + } + + #[test] + fn image_file_with_no_configured_command_has_no_wallpaper_row() { + let menu = DesktopMenu::open_for_icon(&icon(IconKind::File, "pic.png"), (0, 0), ""); + assert_eq!(menu.items.len(), 1, "Open only"); + } + + #[test] + fn non_image_file_has_no_wallpaper_row_even_with_a_command_configured() { + let menu = DesktopMenu::open_for_icon(&icon(IconKind::File, "notes.txt"), (0, 0), "swww img"); + assert_eq!(menu.items.len(), 1, "Open only"); + } + + #[test] + fn a_folder_never_gets_the_wallpaper_row() { + let menu = DesktopMenu::open_for_icon(&icon(IconKind::Folder, "pic.png"), (0, 0), "swww img"); + assert_eq!(menu.items.len(), 1, "a directory named like an image is still not a file"); + } + + #[test] + fn desktop_menu_offers_new_folder_and_refresh() { + let menu = DesktopMenu::open_for_desktop((10, 10)); + assert_eq!(menu.items[0].0, "New Folder"); + assert_eq!(menu.items[1].0, "Refresh"); + } + + #[test] + fn row_at_maps_a_point_to_the_right_row() { + let menu = DesktopMenu::open_for_desktop((100, 100)); + assert_eq!(menu.row_at(150, 100), Some(0)); + assert_eq!(menu.row_at(150, 100 + ROW_HEIGHT as i32), Some(1)); + assert_eq!(menu.row_at(150, 100 + menu.height()), None, "just below the menu"); + } +} diff --git a/crates/wayland/src/input/pointer.rs b/crates/wayland/src/input/pointer.rs index d4c5678..f0e034e 100644 --- a/crates/wayland/src/input/pointer.rs +++ b/crates/wayland/src/input/pointer.rs @@ -228,6 +228,10 @@ pub(crate) fn handle_pointer_position(state: &mut CompState, pos: Point<f64, Log return; } + // 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)); + // 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 // own doc comment), and `add_window`'s target-monitor fallback needs @@ -520,6 +524,17 @@ pub(crate) fn handle_pointer_button(state: &mut CompState, pos: Point<f64, Logic } return; } + // Same rule again for the desktop-icon/bare-desktop menu. + 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(); + state.close_desktop_menu(); + state.run_desktop_menu_action(action); + } else { + state.close_desktop_menu(); + } + return; + } } // Modifier+drag: with the modifier held, dragging *anywhere* in a window @@ -613,6 +628,27 @@ pub(crate) fn handle_pointer_button(state: &mut CompState, pos: Point<f64, Logic if let Some(&id) = dwindow_wl_surface(&window).and_then(|s| state.surface_to_id.get(&s)) { focus_window(state, id); } + } else { + // Genuinely bare desktop (or below every window, which + // only ever means bare desktop - icons render below every + // window, see `desktop_icons.rs`'s own module doc + // 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())); + match icon_hit { + Some(id) => { + 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)); + } + } + None => state.select_desktop_icon(None), + } } } } else if pressed && (button == BTN_RIGHT || button == BTN_MIDDLE) { @@ -635,9 +671,32 @@ pub(crate) fn handle_pointer_button(state: &mut CompState, pos: Point<f64, Logic // instead of the window menu - a plain left-click there still // just toggles maximize, unchanged. (BTN_RIGHT, Some((id, TitlebarHit::Maximize))) => state.open_snap_flyout(id, (pos.x as i32, pos.y as i32)), + // Right-click bare desktop (no titlebar/border hit, no window + // content, no bar/dock layer surface - same "genuinely bare" + // definition the left-click branch above uses): a desktop + // icon's own menu if the click landed on one, otherwise the + // "New Folder"/"Refresh" desktop menu. Previously a true + // no-op, the actual gap this whole feature exists to close -- + // see this session's own TODO.md entry. + (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())); + match icon_hit { + Some(id) => { + state.select_desktop_icon(Some(&id)); + state.open_desktop_icon_menu(&id, (pos.x as i32, pos.y as i32)); + } + None => state.open_desktop_menu((pos.x as i32, pos.y as i32)), + } + } _ => {} } } else if !pressed { + // A no-op via `Option::take()` when no icon drag was active -- + // 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(); let mut wm = state.wm.borrow_mut(); let was_dragging = wm.is_dragging(); let was_resizing = wm.is_resizing(); diff --git a/crates/wayland/src/lib.rs b/crates/wayland/src/lib.rs index 90660d1..fc1aefb 100644 --- a/crates/wayland/src/lib.rs +++ b/crates/wayland/src/lib.rs @@ -47,6 +47,9 @@ mod context_menu; mod snap_flyout; mod cursor; mod decoration; +mod desktop_icons; +mod desktop_icons_state; +mod desktop_menu; mod elements; mod foreign_toplevel; mod gamma_control; diff --git a/crates/wayland/src/state/desktop_icons.rs b/crates/wayland/src/state/desktop_icons.rs new file mode 100644 index 0000000..b1b802e --- /dev/null +++ b/crates/wayland/src/state/desktop_icons.rs @@ -0,0 +1,344 @@ +//! `CompState` glue for desktop icons - open/rescan/select/drag/persist, +//! plus the right-click `DesktopMenu`'s own open/close/run-action. Same +//! shape as `state/menu.rs`'s `ContextMenu`/`SnapFlyout` glue. + +use super::*; +use crate::desktop_icons::{DesktopIcons, CELL_HEIGHT, CELL_WIDTH, GRID_MARGIN}; +use crate::desktop_menu::{DesktopMenu, DesktopMenuAction}; + +impl CompState { + /// Populates `self.desktop_icons` on first call (or after `general. + /// desktop_icons` was off and just turned on), once the primary + /// monitor's own geometry is actually known - a no-op every other + /// call, cheap enough to check unconditionally at the top of a render + /// pass. Does nothing at all when the config flag is off. + pub(crate) fn ensure_desktop_icons(&mut self) { + if !self.wm.borrow().desktop_icons_enabled { + return; + } + if self.desktop_icons.is_some() { + 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 rows = ((monitor.geometry.height as i32 - 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_icon_buffers.clear(); + } + + /// 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 + /// icon `saved` has no entry for. + pub(crate) fn refresh_desktop_icons(&mut self) { + 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 icons = crate::desktop_icons::rescan(&saved, rows); + self.desktop_icons = Some(DesktopIcons { origin, icons }); + self.desktop_icon_buffers.clear(); + } + + fn primary_monitor_height(&self) -> i32 { + self.wm.borrow().monitors().iter().find(|m| m.primary).map(|m| m.geometry.height as i32 - 2 * GRID_MARGIN).unwrap_or(600) + } + + /// Rasterises (or re-rasterises) one icon's buffer - called whenever + /// that icon's selection state or drag position changes, never per + /// frame; `render_udev_frame`/`render_frame` just read whatever's + /// already cached here. + fn rebuild_icon_buffer(&mut self, id: &str) { + let Some(icons) = &self.desktop_icons else { return }; + let Some(icon) = icons.icons.iter().find(|i| i.id == id) else { return }; + let theme = self.wm.borrow().theme; + let label_color = (240, 240, 240); + let data = decoration::render_desktop_icon( + CELL_WIDTH as u32, + CELL_HEIGHT as u32, + icon.kind, + &icon.label, + icon.selected, + theme.titlebar_fg_focused, + label_color, + theme.default_border_color, + ); + 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); + } + + fn icon_buffer(&mut self, id: &str) -> Option<&MemoryRenderBuffer> { + if !self.desktop_icon_buffers.contains_key(id) { + self.rebuild_icon_buffer(id); + } + self.desktop_icon_buffers.get(id) + } + + /// Every `(position, buffer)` pair the render loop needs to push this + /// frame - lazily rebuilds any icon whose buffer isn't cached yet + /// (a fresh icon, or one whose selection/drag state just changed), + /// then returns everything already up to date. Global-space positions; + /// the caller subtracts its own head origin, same as every other + /// `custom_elements` push site. + 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 dragging = self.desktop_icon_drag.clone(); + let mut out = Vec::with_capacity(ids.len()); + for id in ids { + let buffer = match self.icon_buffer(&id) { + Some(b) => b.clone(), + None => continue, + }; + 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)); + } + out + } + + /// Selects `id` (deselecting whatever was selected before, if + /// anything) - both buffers rebuilt only if their selection state + /// actually changed, not unconditionally. + pub(crate) fn select_desktop_icon(&mut self, id: Option<&str>) { + let Some(icons) = &mut self.desktop_icons else { return }; + let mut changed = Vec::new(); + for icon in &mut icons.icons { + let should = Some(icon.id.as_str()) == id; + if icon.selected != should { + icon.selected = should; + changed.push(icon.id.clone()); + } + } + for id in changed { + self.rebuild_icon_buffer(&id); + } + } + + /// 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 + /// `WindowId` of its own. + pub(crate) fn is_double_click_icon(&mut self, id: &str, time: u32) -> bool { + const DOUBLE_CLICK_MS: u32 = 400; + let doubled = match &self.last_icon_click { + Some((last_id, last_time)) => last_id == id && time.saturating_sub(*last_time) <= DOUBLE_CLICK_MS, + None => false, + }; + self.last_icon_click = if doubled { None } else { Some((id.to_string(), time)) }; + doubled + } + + pub(crate) fn start_desktop_icon_drag(&mut self, id: &str, 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 grab_offset = (pointer.0 - top_left.0, pointer.1 - top_left.1); + self.desktop_icon_drag = Some((id.to_string(), grab_offset, top_left)); + } + + /// Updates the live position of whichever icon is being dragged, if + /// any - called from every pointer-motion event, same as + /// `WindowManager::update_resize`'s own per-motion-event update. + pub(crate) fn update_desktop_icon_drag(&mut self, pointer: (i32, i32)) { + if let Some((_, grab_offset, live_pos)) = &mut self.desktop_icon_drag { + *live_pos = (pointer.0 - grab_offset.0, pointer.1 - grab_offset.1); + } + } + + /// Ends an in-progress drag (if any): snaps to the nearest free grid + /// cell (occupied cells other than the dragged icon's own previous one + /// are avoided by walking outward from the raw target, closest first) + /// and persists it. + pub(crate) fn end_desktop_icon_drag(&mut self) { + let Some((id, _, live_pos)) = self.desktop_icon_drag.take() else { return }; + 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(); + let cell = nearest_free_cell(raw_cell, &occupied); + if let Some(icon) = icons.icons.iter_mut().find(|i| i.id == id) { + icon.cell = cell; + } + self.rebuild_icon_buffer(&id); + crate::desktop_icons_state::save_icon(&id, cell); + } + + pub(crate) fn open_desktop_icon(&mut self, id: &str) { + let Some(icons) = &self.desktop_icons else { return }; + let Some(icon) = icons.icons.iter().find(|i| i.id == id) else { return }; + let target = icon.target.display().to_string(); + let file_manager = self.wm.borrow().file_manager.clone(); + if file_manager.is_empty() { + spawn_shell(&format!("xdg-open {}", shell_quote(&target))); + } else { + spawn_shell(&format!("{file_manager} {}", shell_quote(&target))); + } + } + + pub(crate) fn set_desktop_icon_as_wallpaper(&mut self, id: &str) { + let Some(icons) = &self.desktop_icons else { return }; + let Some(icon) = icons.icons.iter().find(|i| i.id == id) else { return }; + let target = icon.target.display().to_string(); + let command = self.wm.borrow().wallpaper_command.clone(); + if command.is_empty() { + return; + } + spawn_shell(&format!("{command} {}", shell_quote(&target))); + } + + /// Creates `~/Desktop/New Folder`, de-duplicated as `New Folder (2)`, + /// `(3)`, ... against whatever's already there, then rescans so it + /// shows up as an icon immediately. + pub(crate) fn new_desktop_folder(&mut self) { + let Ok(home) = std::env::var("HOME") else { return }; + let desktop = std::path::PathBuf::from(home).join("Desktop"); + let mut name = "New Folder".to_string(); + let mut n = 2; + while desktop.join(&name).exists() { + name = format!("New Folder ({n})"); + n += 1; + } + if let Err(e) = std::fs::create_dir(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 }; + let wallpaper_command = self.wm.borrow().wallpaper_command.clone(); + let menu = DesktopMenu::open_for_icon(icon, pos, &wallpaper_command); + self.build_desktop_menu_buffer(menu); + } + + pub(crate) fn open_desktop_menu(&mut self, pos: (i32, i32)) { + let menu = DesktopMenu::open_for_desktop(pos); + self.build_desktop_menu_buffer(menu); + } + + fn build_desktop_menu_buffer(&mut self, menu: DesktopMenu) { + let theme = self.wm.borrow().theme; + let items: Vec<(&str, bool)> = menu.items.iter().map(|&(label, _)| (label, false)).collect(); + let data = decoration::render_context_menu(menu.width, menu.row_height, &items, theme.titlebar_bg, theme.titlebar_fg_focused, theme.titlebar_fg_unfocused, theme.default_border_color); + let buffer = MemoryRenderBuffer::from_slice(&data, Fourcc::Argb8888, (menu.width as i32, menu.height()), 1, Transform::Normal, None); + self.desktop_menu_buffer = Some(buffer); + self.desktop_menu = Some(menu); + } + + pub(crate) fn close_desktop_menu(&mut self) { + self.desktop_menu = None; + self.desktop_menu_buffer = None; + } + + pub(crate) fn run_desktop_menu_action(&mut self, action: DesktopMenuAction) { + match action { + DesktopMenuAction::OpenIcon(id) => self.open_desktop_icon(&id), + DesktopMenuAction::SetWallpaper(id) => self.set_desktop_icon_as_wallpaper(&id), + DesktopMenuAction::NewFolder => self.new_desktop_folder(), + DesktopMenuAction::Refresh => self.refresh_desktop_icons(), + } + } +} + +trait MaxZero { + fn max_zero(self) -> Self; +} +impl MaxZero for (i32, i32) { + fn max_zero(self) -> Self { + (self.0.max(0), self.1.max(0)) + } +} + +/// Breadth-first search outward from `target` over grid cells (`target` +/// itself first, then its 4-neighbours, then theirs, ...) for the first +/// one not in `occupied` - a dropped icon always lands *somewhere* rather +/// than silently failing to move when its raw target cell is already +/// taken. +fn nearest_free_cell(target: (i32, i32), occupied: &std::collections::HashSet<(i32, i32)>) -> (i32, i32) { + if !occupied.contains(&target) && target.0 >= 0 && target.1 >= 0 { + return target; + } + use std::collections::VecDeque; + let mut seen = std::collections::HashSet::new(); + let mut queue = VecDeque::new(); + queue.push_back(target); + seen.insert(target); + while let Some((c, r)) = queue.pop_front() { + if c >= 0 && r >= 0 && !occupied.contains(&(c, r)) { + return (c, r); + } + for (dc, dr) in [(1, 0), (-1, 0), (0, 1), (0, -1)] { + let next = (c + dc, r + dr); + if seen.insert(next) { + queue.push_back(next); + } + } + } + target +} + +fn shell_quote(s: &str) -> String { + format!("'{}'", s.replace('\'', "'\\''")) +} + +fn spawn_shell(command: &str) { + #[cfg(unix)] + let result = std::process::Command::new("sh").arg("-c").arg(command).spawn(); + #[cfg(windows)] + let result = std::process::Command::new("cmd").arg("/C").arg(command).spawn(); + if let Err(e) = result { + log::warn!("desktop_icons: spawn '{command}' failed: {e}"); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn shell_quote_wraps_in_single_quotes() { + assert_eq!(shell_quote("/home/x/pic.png"), "'/home/x/pic.png'"); + } + + #[test] + fn shell_quote_escapes_an_embedded_single_quote() { + assert_eq!(shell_quote("it's.png"), "'it'\\''s.png'"); + } + + #[test] + fn nearest_free_cell_returns_the_target_when_its_free() { + let occupied = std::collections::HashSet::new(); + assert_eq!(nearest_free_cell((2, 3), &occupied), (2, 3)); + } + + #[test] + fn nearest_free_cell_finds_an_adjacent_slot_when_the_target_is_taken() { + let mut occupied = std::collections::HashSet::new(); + occupied.insert((0, 0)); + let (c, r) = nearest_free_cell((0, 0), &occupied); + assert!((c, r) != (0, 0)); + assert!(!occupied.contains(&(c, r))); + assert_eq!(c.abs() + r.abs(), 1, "the nearest free cell is exactly one step away"); + } + + #[test] + fn nearest_free_cell_never_returns_a_negative_column_or_row() { + let mut occupied = std::collections::HashSet::new(); + occupied.insert((0, 0)); + occupied.insert((1, 0)); + occupied.insert((0, 1)); + let (c, r) = nearest_free_cell((0, 0), &occupied); + assert!(c >= 0 && r >= 0); + } +} diff --git a/crates/wayland/src/state/mod.rs b/crates/wayland/src/state/mod.rs index 0ef4346..66f9ec6 100644 --- a/crates/wayland/src/state/mod.rs +++ b/crates/wayland/src/state/mod.rs @@ -340,6 +340,38 @@ pub(crate) struct CompState { /// Rasterised pixels for the currently-open `snap_flyout`, same /// build-once-on-open pattern as `context_menu_buffer`. pub(crate) snap_flyout_buffer: Option<MemoryRenderBuffer>, + /// The real desktop icons (Home/Computer/Trash plus `~/Desktop`'s own + /// contents) - see `desktop_icons.rs`. `None` until the first render + /// pass populates it (lazily, once the primary monitor's own geometry + /// is actually known - see `state/desktop_icons.rs::ensure_desktop_ + /// icons`), and permanently `None` when `general.desktop_icons` is off. + pub(crate) desktop_icons: Option<crate::desktop_icons::DesktopIcons>, + /// Rasterised pixels per icon, keyed by `DesktopIcon::id` - rebuilt + /// only for the one icon whose selection/drag state actually changed, + /// same cached-until-dirty convention as every other decoration + /// buffer in this codebase. + pub(crate) desktop_icon_buffers: HashMap<String, MemoryRenderBuffer>, + /// An in-progress icon drag: the icon's own id, the pointer's grab + /// offset from that icon's cell origin at the moment the drag started + /// (so the icon tracks the pointer smoothly rather than snapping its + /// top-left corner straight to the cursor), and the icon's own live + /// top-left position this frame - updated on every pointer-motion + /// event by `update_desktop_icon_drag`, read straight back by + /// `desktop_icon_render_list` with no separate "current pointer + /// position" field needed anywhere on `CompState`. `None` whenever no + /// drag is active. + #[allow(clippy::type_complexity)] + pub(crate) desktop_icon_drag: Option<(String, (i32, i32), (i32, i32))>, + /// 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. + pub(crate) desktop_menu: Option<crate::desktop_menu::DesktopMenu>, + /// Same build-once-on-open pattern as `context_menu_buffer`. + pub(crate) desktop_menu_buffer: Option<MemoryRenderBuffer>, + /// Same double-click bookkeeping as `last_titlebar_click`, keyed by + /// `DesktopIcon::id` instead of `WindowId` since a desktop icon isn't + /// a window - see `CompState::is_double_click`'s own doc comment. + pub(crate) last_icon_click: Option<(String, u32)>, pub(crate) wm: Rc<RefCell<WindowManager>>, pub(crate) surface_to_id: HashMap<WlSurface, WindowId>, pub(crate) id_to_window: HashMap<WindowId, DWindow>, @@ -822,6 +854,7 @@ impl CompState { } +mod desktop_icons; mod focus; mod geometry; mod layers; diff --git a/crates/wayland/src/udev/platform.rs b/crates/wayland/src/udev/platform.rs index a4a72a0..2633f16 100644 --- a/crates/wayland/src/udev/platform.rs +++ b/crates/wayland/src/udev/platform.rs @@ -230,6 +230,12 @@ impl UdevPlatform { context_menu_buffer: None, snap_flyout: None, snap_flyout_buffer: None, + desktop_icons: None, + desktop_icon_buffers: HashMap::new(), + desktop_icon_drag: None, + desktop_menu: None, + desktop_menu_buffer: None, + last_icon_click: None, wm: wm.clone(), surface_to_id: HashMap::new(), id_to_window: HashMap::new(), diff --git a/crates/wayland/src/udev/render.rs b/crates/wayland/src/udev/render.rs index 802428f..73aaa82 100644 --- a/crates/wayland/src/udev/render.rs +++ b/crates/wayland/src/udev/render.rs @@ -21,6 +21,12 @@ impl CompState { // Same reason again: a native lock's capture step (below) needs // this, and `self.wm` can't be borrowed once `self.udev` is. let lock_blur_radius = self.wm.borrow().lock.blur_radius; + // Same "capture before `self.udev`'s borrow starts" reason as + // `cursor_status`/`cursor_buffers` above: global-space, so computed + // once here rather than per head, each head's own per-frame push + // below just re-offsets these same positions by its own `origin`. + self.ensure_desktop_icons(); + let desktop_icon_render_list = self.desktop_icon_render_list(); // Captured-and-blurred backgrounds collected during the per-head // loop below, applied via `self.capture_output` only after it // ends - `self.udev`'s mutable borrow is held for the whole loop @@ -323,6 +329,15 @@ impl CompState { Err(e) => log::warn!("udev: failed to import snap flyout buffer: {e}"), } } + // The desktop-icon/bare-desktop right-click menu, if open -- + // same "topmost but never hides the cursor" placement. + if let (Some(menu), Some(buffer)) = (self.desktop_menu.as_ref(), self.desktop_menu_buffer.as_ref()) { + let pos = ((menu.pos.0 - origin.x) as f64, (menu.pos.1 - origin.y) as f64); + match MemoryRenderBufferRenderElement::from_buffer(&mut udev.renderer, pos, buffer, None, None, None, Kind::Unspecified) { + Ok(elem) => custom_elements.push(crate::elements::OverlayElement::Memory(elem)), + Err(e) => log::warn!("udev: failed to import desktop menu buffer: {e}"), + } + } // Popups next: always above every window's own content, // matching this codebase's long-standing behavior from // before content moved into this same `custom_elements` @@ -903,6 +918,19 @@ impl CompState { } occluders.push(frame); } + // Real desktop icons - above the wallpaper, below every + // window: pushed after the windows loop above (so nothing + // here can occlude a real window) but before the + // 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. + for (pos, buffer) in &desktop_icon_render_list { + let pos = ((pos.0 - origin.x) as f64, (pos.1 - origin.y) as f64); + match MemoryRenderBufferRenderElement::from_buffer(&mut udev.renderer, pos, buffer, None, None, None, Kind::Unspecified) { + Ok(elem) => custom_elements.push(crate::elements::OverlayElement::Memory(elem)), + Err(e) => log::warn!("udev: failed to import desktop icon buffer: {e}"), + } + } // Background/bottom layer-shell (wallpaper engines) last -- // bottommost, matching smithay's own `space_render_elements` // ordering, which this whole custom loop now replaces. diff --git a/crates/wayland/src/winit/connect.rs b/crates/wayland/src/winit/connect.rs index 30eaaf8..227cf33 100644 --- a/crates/wayland/src/winit/connect.rs +++ b/crates/wayland/src/winit/connect.rs @@ -151,6 +151,12 @@ impl WaylandPlatform { context_menu_buffer: None, snap_flyout: None, snap_flyout_buffer: None, + desktop_icons: None, + desktop_icon_buffers: HashMap::new(), + desktop_icon_drag: None, + desktop_menu: None, + desktop_menu_buffer: None, + last_icon_click: None, wm: wm.clone(), surface_to_id: HashMap::new(), id_to_window: HashMap::new(), diff --git a/crates/wayland/src/winit/render.rs b/crates/wayland/src/winit/render.rs index 345d40c..03fff3d 100644 --- a/crates/wayland/src/winit/render.rs +++ b/crates/wayland/src/winit/render.rs @@ -56,6 +56,8 @@ impl WaylandPlatform { // trade against silently wrong pixels persisting on screen. let age = 0; let (renderer, mut framebuffer) = self.backend.bind().map_err(err)?; + self.state.ensure_desktop_icons(); + let desktop_icon_render_list = self.state.desktop_icon_render_list(); // Locked: srdwm's own native lock UI, or an external locker's // surface, over an opaque black clear - nothing else, no windows, @@ -129,6 +131,15 @@ impl WaylandPlatform { Err(e) => log::warn!("failed to import snap flyout buffer: {e}"), } } + // The desktop-icon/bare-desktop right-click menu, if open - same + // topmost placement. + if let (Some(menu), Some(buffer)) = (self.state.desktop_menu.as_ref(), self.state.desktop_menu_buffer.as_ref()) { + let pos = (menu.pos.0 as f64, menu.pos.1 as f64); + match MemoryRenderBufferRenderElement::from_buffer(renderer, pos, buffer, None, None, None, Kind::Unspecified) { + Ok(elem) => custom_elements.push(crate::rounded_corners::WinitElement::Base(crate::elements::OverlayElement::Memory(elem))), + Err(e) => log::warn!("failed to import desktop menu buffer: {e}"), + } + } // Content now renders here too, one window at a time, not through // `render_output`'s own `spaces` argument - see this function's // own call to `damage_tracker.render_output` further down for why, @@ -441,6 +452,17 @@ impl WaylandPlatform { } occluders.push(frame); } + // Real desktop icons - above the wallpaper, below every window. + // See `udev/render.rs`'s matching push site for the full ordering + // reasoning; `desktop_icon_render_list` was already captured + // right after `self.backend.bind()`, well before this point. + for (pos, buffer) in &desktop_icon_render_list { + let pos = (pos.0 as f64, pos.1 as f64); + match MemoryRenderBufferRenderElement::from_buffer(renderer, pos, buffer, None, None, None, Kind::Unspecified) { + Ok(elem) => custom_elements.push(crate::rounded_corners::WinitElement::Base(crate::elements::OverlayElement::Memory(elem))), + Err(e) => log::warn!("failed to import desktop icon buffer: {e}"), + } + } // Background/bottom layer-shell (wallpaper engines) last -- // bottommost, matching smithay's own `space_render_elements` // ordering, which this whole custom loop now replaces. |