srdusr
aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorsrdusr <[email protected]>2025-08-17 01:54:00 +0200
committersrdusr <[email protected]>2025-08-17 01:54:00 +0200
commitb8376bd631a72f7484c24663699a7d4c813f600e (patch)
treec8c84725e3c66598df3f619dd96aa69b6eede26e
parentbd6aadf4d166c05f22f1ab6cdfe2e815e071f162 (diff)
downloadsrdwm-b8376bd631a72f7484c24663699a7d4c813f600e.tar.gz
srdwm-b8376bd631a72f7484c24663699a7d4c813f600e.zip
Fix desktop icons v1 regressions: bar overlap, wrong order; add proper menus
Live testing found v1 genuinely broken, not just rough: 1. Icons weren't rendering reliably at all - ensure_desktop_icons only ever computed the grid's origin once, on whichever render pass happened to be first. AGS's own top bar registers its exclusive zone after that first pass, so origin got permanently baked in at the pre-bar geometry. Confirmed live via a temporary diagnostic log. Fixed by re-deriving origin from the primary monitor's current geometry on every call instead of just the first. 2. Fixed icons (Home/Computer/Trash) always sorted before real files -- confirmed wrong via direct question. The whole list now sorts alphabetically by label, case-insensitive, fixed icons included. 3. "Set as Wallpaper" was the wrong feature: removed entirely (DesktopMenuAction::SetWallpaper, general.wallpaper_command, is_image_path). The user wants that handled by their real file manager once opened, not reimplemented here. Also adds real menu functionality per "where are all the options": Rename (inline text edit, new CompState::renaming_icon field and keyboard redirect mirroring NativeLock::password's existing precedent), Delete (moves to ~/.local/share/Trash per the freedesktop.org spec, new trash.rs module, same-filesystem case, no confirmation - this is the reversible move-to-trash, not a permanent delete), Empty Trash on the Trash icon, and Open Terminal Here / Open in File Manager on the bare-desktop menu (new general.terminal config key). 133 wayland-crate tests (up from 106), full workspace build and clippy clean.
-rw-r--r--crates/config/src/engine/support.rs7
-rw-r--r--crates/core/src/manager/mod.rs18
-rw-r--r--crates/srdwm/src/main.rs4
-rw-r--r--crates/wayland/src/desktop_icons.rs52
-rw-r--r--crates/wayland/src/desktop_menu.rs110
-rw-r--r--crates/wayland/src/input/keyboard.rs19
-rw-r--r--crates/wayland/src/lib.rs1
-rw-r--r--crates/wayland/src/state/desktop_icons.rs200
-rw-r--r--crates/wayland/src/state/mod.rs8
-rw-r--r--crates/wayland/src/trash.rs228
-rw-r--r--crates/wayland/src/udev/platform.rs1
-rw-r--r--crates/wayland/src/udev/render.rs4
-rw-r--r--crates/wayland/src/winit/connect.rs1
-rw-r--r--docs/DEFAULTS.md60
-rw-r--r--docs/TODO.md92
15 files changed, 665 insertions, 140 deletions
diff --git a/crates/config/src/engine/support.rs b/crates/config/src/engine/support.rs
index 76fbe62..7537fce 100644
--- a/crates/config/src/engine/support.rs
+++ b/crates/config/src/engine/support.rs
@@ -166,10 +166,9 @@ pub(super) fn default_config() -> HashMap<String, ConfigValue> {
// Double-click by default - see `WindowManager::desktop_icon_single_
// click`'s own doc comment.
set("general.desktop_icon_single_click", Bool(false));
- // Empty by default - see `WindowManager::wallpaper_command`'s own doc
- // comment: empty means "Set as Wallpaper" doesn't appear at all, not
- // "configured to do nothing".
- set("general.wallpaper_command", String(std::string::String::new()));
+ // Empty by default - see `WindowManager::terminal`'s own doc comment:
+ // empty tries a short list of common terminals on `$PATH`.
+ set("general.terminal", String(std::string::String::new()));
// Deliberately *not* seeded here, unlike every other `general.*` key --
// its actual default differs by backend (GLES/winit: on; udev/Pixman:
// off, an untested-on-real-hardware CPU cost too real to default to on
diff --git a/crates/core/src/manager/mod.rs b/crates/core/src/manager/mod.rs
index c2f67e4..848eca5 100644
--- a/crates/core/src/manager/mod.rs
+++ b/crates/core/src/manager/mod.rs
@@ -231,16 +231,12 @@ pub struct WindowManager {
/// file managers) default the other way, hence this being a real
/// config option rather than a hardcoded choice.
pub desktop_icon_single_click: bool,
- /// External program a desktop image-file icon's "Set as Wallpaper"
- /// action shells out to, read from `general.wallpaper_command`. Empty
- /// (the default) means that action doesn't appear at all - srdwm
- /// never draws the wallpaper itself (an external layer-shell client's
- /// job, see `WaylandState`'s own doc comment on viewporter), so unlike
- /// `file_manager`'s `xdg-open` fallback there is no universal command
- /// to guess here; the user names their own wallpaper tool (`swww img`,
- /// `awww img`, ...) or the action stays hidden rather than silently
- /// doing nothing when clicked.
- pub wallpaper_command: String,
+ /// External program the bare-desktop menu's "Open Terminal Here"
+ /// action shells out to (with `~/Desktop` as its working directory),
+ /// read from `general.terminal`. Empty (the default) tries a short
+ /// list of common terminals on `$PATH` - there's no `xdg-open`-
+ /// equivalent for "a shell", unlike `file_manager`.
+ pub terminal: String,
/// The whole-screen colour treatment currently active (night light's
/// warm tint or reading mode's desaturation), live-settable via `srd
/// set night_light`/`srd set reading_mode` - see [`ColorFilter`]. Off
@@ -405,7 +401,7 @@ impl WindowManager {
desktop_icons_enabled: true,
file_manager: String::new(),
desktop_icon_single_click: false,
- wallpaper_command: String::new(),
+ terminal: String::new(),
color_filter: ColorFilter::None,
focus_follows_mouse: false,
auto_raise: false,
diff --git a/crates/srdwm/src/main.rs b/crates/srdwm/src/main.rs
index 0f55c00..bc5c840 100644
--- a/crates/srdwm/src/main.rs
+++ b/crates/srdwm/src/main.rs
@@ -175,7 +175,7 @@ fn apply_general_settings(engine: &Engine, wm: &Rc<RefCell<WindowManager>>) {
let desktop_icons = engine.get_bool("general.desktop_icons", true);
let file_manager = engine.get_string("general.file_manager", "");
let desktop_icon_single_click = engine.get_bool("general.desktop_icon_single_click", false);
- let wallpaper_command = engine.get_string("general.wallpaper_command", "");
+ let terminal = engine.get_string("general.terminal", "");
let focus_follows_mouse = engine.get_bool("general.focus_follows_mouse", false);
let auto_raise = engine.get_bool("general.auto_raise", false);
@@ -304,7 +304,7 @@ fn apply_general_settings(engine: &Engine, wm: &Rc<RefCell<WindowManager>>) {
wm.desktop_icons_enabled = desktop_icons;
wm.file_manager = file_manager;
wm.desktop_icon_single_click = desktop_icon_single_click;
- wm.wallpaper_command = wallpaper_command;
+ wm.terminal = terminal;
wm.focus_follows_mouse = focus_follows_mouse;
wm.auto_raise = auto_raise;
wm.theme = theme;
diff --git a/crates/wayland/src/desktop_icons.rs b/crates/wayland/src/desktop_icons.rs
index 470a965..4ba54f4 100644
--- a/crates/wayland/src/desktop_icons.rs
+++ b/crates/wayland/src/desktop_icons.rs
@@ -85,17 +85,6 @@ 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
@@ -132,7 +121,7 @@ pub(crate) fn rescan(saved: &HashMap<String, (i32, i32)>, rows_per_column: i32)
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("/")),
+ target: home_dir().map(|h| crate::trash::files_dir(&h)).unwrap_or_else(|| PathBuf::from("/")),
cell: (0, 0),
selected: false,
},
@@ -141,7 +130,7 @@ pub(crate) fn rescan(saved: &HashMap<String, (i32, i32)>, rows_per_column: i32)
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
+ let files: Vec<(String, bool)> = entries
.filter_map(|e| e.ok())
.filter_map(|e| {
let name = e.file_name().to_string_lossy().into_owned();
@@ -152,7 +141,6 @@ pub(crate) fn rescan(saved: &HashMap<String, (i32, i32)>, rows_per_column: i32)
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 {
@@ -167,6 +155,14 @@ pub(crate) fn rescan(saved: &HashMap<String, (i32, i32)>, rows_per_column: i32)
}
}
}
+ // One alphabetical list, fixed icons included - not fixed-three-then-
+ // files. Confirmed directly: the fixed shortcuts shouldn't always come
+ // first just because they're synthetic rather than real files.
+ // Case-insensitive so "computer"/"Computer" and a real lowercase
+ // filename interleave the way a user actually expects, not by raw
+ // byte value (which would put every uppercase name before any
+ // lowercase one).
+ icons.sort_by_key(|a| a.label.to_lowercase());
assign_cells(&mut icons, saved, rows_per_column);
icons
}
@@ -212,12 +208,30 @@ mod tests {
use super::*;
#[test]
- fn fixed_icons_always_come_first_in_a_stable_order() {
+ fn the_three_fixed_icons_sort_alphabetically_among_themselves() {
+ // Reported live, confirmed via direct question: fixed icons must
+ // NOT always come before real files just because they're
+ // synthetic - the whole list sorts by label together. This
+ // checks that sort using only the three fixed icons (present on
+ // any machine, unlike a specific `~/Desktop` file), whose labels
+ // - "Computer", "Home", "Trash" - already happen to be in
+ // alphabetical order, so a correct sort leaves them exactly
+ // where `rescan` built them.
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");
+ let fixed: Vec<&str> = icons.iter().filter(|i| matches!(i.kind, IconKind::Home | IconKind::Computer | IconKind::Trash)).map(|i| i.label.as_str()).collect();
+ assert_eq!(fixed, vec!["Computer", "Home", "Trash"]);
+ }
+
+ #[test]
+ fn sorting_is_case_insensitive_and_covers_the_whole_list() {
+ let mut icons = [
+ DesktopIcon { id: "zebra".into(), label: "zebra".into(), kind: IconKind::File, target: PathBuf::new(), cell: (0, 0), selected: false },
+ DesktopIcon { id: "Home".into(), label: "Home".into(), kind: IconKind::Home, target: PathBuf::new(), cell: (0, 0), selected: false },
+ DesktopIcon { id: "apple".into(), label: "apple".into(), kind: IconKind::File, target: PathBuf::new(), cell: (0, 0), selected: false },
+ ];
+ icons.sort_by_key(|a| a.label.to_lowercase());
+ let labels: Vec<&str> = icons.iter().map(|i| i.label.as_str()).collect();
+ assert_eq!(labels, vec!["apple", "Home", "zebra"], "a fixed icon's label interleaves with real filenames, not always first");
}
#[test]
diff --git a/crates/wayland/src/desktop_menu.rs b/crates/wayland/src/desktop_menu.rs
index 810e639..bd0bf34 100644
--- a/crates/wayland/src/desktop_menu.rs
+++ b/crates/wayland/src/desktop_menu.rs
@@ -9,12 +9,24 @@ use crate::desktop_icons::{DesktopIcon, IconKind};
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),
+ /// Enter inline rename mode for this real file/folder icon - see
+ /// `CompState::renaming_icon`'s own doc comment.
+ Rename(String),
+ /// Move this real file/folder into `~/.local/share/Trash` - no
+ /// confirmation, same as every mainstream file manager: this is the
+ /// reversible move-to-trash, not a permanent delete.
+ Delete(String),
+ /// Empties `~/.local/share/Trash` entirely - same no-confirmation
+ /// convention as `Delete`.
+ EmptyTrash,
NewFolder,
+ /// Spawns a terminal with `~/Desktop` as its working directory --
+ /// `general.terminal`, or a common-binary fallback list if unset.
+ OpenTerminalHere,
+ /// Opens `~/Desktop` itself in `general.file_manager`/`xdg-open` - the
+ /// concrete path to a real file manager's own richer menu (cut/copy/
+ /// paste, properties, ...), deliberately not reimplemented here.
+ OpenInFileManager,
Refresh,
}
@@ -28,33 +40,36 @@ pub(crate) struct DesktopMenu {
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())));
- }
+ /// Right-click on `icon` itself - the action set depends on what kind
+ /// of icon it is, not one fixed list: a real file/folder gets Open/
+ /// Rename/Delete (real filesystem operations); Home/Computer (fixed
+ /// shortcuts to somewhere, not real files of their own) get Open only,
+ /// since renaming or deleting the shortcut itself isn't a meaningful
+ /// action; Trash gets Open/Empty Trash instead of Rename/Delete, since
+ /// "delete the trash" and "rename the trash" aren't real trash
+ /// operations the way "empty it" is.
+ pub(crate) fn open_for_icon(icon: &DesktopIcon, pos: (i32, i32)) -> Self {
+ let items = match icon.kind {
+ IconKind::Trash => vec![("Open", DesktopMenuAction::OpenIcon(icon.id.clone())), ("Empty Trash", DesktopMenuAction::EmptyTrash)],
+ IconKind::Home | IconKind::Computer => vec![("Open", DesktopMenuAction::OpenIcon(icon.id.clone()))],
+ IconKind::Folder | IconKind::File => vec![
+ ("Open", DesktopMenuAction::OpenIcon(icon.id.clone())),
+ ("Rename", DesktopMenuAction::Rename(icon.id.clone())),
+ ("Delete", DesktopMenuAction::Delete(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".
+ /// Right-click on bare desktop (no icon under the pointer).
pub(crate) fn open_for_desktop(pos: (i32, i32)) -> Self {
- let items = vec![("New Folder", DesktopMenuAction::NewFolder), ("Refresh", DesktopMenuAction::Refresh)];
+ let items = vec![
+ ("New Folder", DesktopMenuAction::NewFolder),
+ ("Open Terminal Here", DesktopMenuAction::OpenTerminalHere),
+ ("Open in File Manager", DesktopMenuAction::OpenInFileManager),
+ ("Refresh", DesktopMenuAction::Refresh),
+ ];
Self { pos, width: MENU_WIDTH, row_height: ROW_HEIGHT, items }
}
@@ -80,40 +95,45 @@ 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 }
+ fn icon(kind: IconKind) -> DesktopIcon {
+ DesktopIcon { id: "x".into(), label: "x".into(), kind, target: PathBuf::new(), 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");
+ fn a_real_file_gets_open_rename_and_delete() {
+ let menu = DesktopMenu::open_for_icon(&icon(IconKind::File), (0, 0));
+ let labels: Vec<&str> = menu.items.iter().map(|(l, _)| *l).collect();
+ assert_eq!(labels, vec!["Open", "Rename", "Delete"]);
}
#[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");
+ fn a_real_folder_gets_open_rename_and_delete_too() {
+ let menu = DesktopMenu::open_for_icon(&icon(IconKind::Folder), (0, 0));
+ let labels: Vec<&str> = menu.items.iter().map(|(l, _)| *l).collect();
+ assert_eq!(labels, vec!["Open", "Rename", "Delete"]);
}
#[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");
+ fn home_and_computer_get_open_only() {
+ for kind in [IconKind::Home, IconKind::Computer] {
+ let menu = DesktopMenu::open_for_icon(&icon(kind), (0, 0));
+ let labels: Vec<&str> = menu.items.iter().map(|(l, _)| *l).collect();
+ assert_eq!(labels, vec!["Open"], "shortcuts aren't real files - no rename/delete");
+ }
}
#[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");
+ fn trash_gets_open_and_empty_trash_not_rename_or_delete() {
+ let menu = DesktopMenu::open_for_icon(&icon(IconKind::Trash), (0, 0));
+ let labels: Vec<&str> = menu.items.iter().map(|(l, _)| *l).collect();
+ assert_eq!(labels, vec!["Open", "Empty Trash"]);
}
#[test]
- fn desktop_menu_offers_new_folder_and_refresh() {
+ fn desktop_menu_offers_the_full_set() {
let menu = DesktopMenu::open_for_desktop((10, 10));
- assert_eq!(menu.items[0].0, "New Folder");
- assert_eq!(menu.items[1].0, "Refresh");
+ 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"]);
}
#[test]
diff --git a/crates/wayland/src/input/keyboard.rs b/crates/wayland/src/input/keyboard.rs
index ae367f4..f1b117f 100644
--- a/crates/wayland/src/input/keyboard.rs
+++ b/crates/wayland/src/input/keyboard.rs
@@ -65,6 +65,25 @@ pub(crate) fn handle_keyboard_key_event<B: smithay::backend::input::InputBackend
return;
}
+ // An in-progress desktop-icon rename claims every keystroke the same
+ // way the native lock's password entry does just above - see
+ // `CompState::renaming_icon`'s own doc comment. Not gated by `state.
+ // lock.locked` (already excluded, since that branch already returned)
+ // - this only ever runs during ordinary, unlocked interactive use.
+ if state.renaming_icon.is_some() {
+ if key_state == BackendKeyState::Pressed {
+ keyboard.input::<(), _>(state, keycode, key_state, serial, time, |data, _mods, handle| {
+ let utf8 = xkbcommon::xkb::keysym_to_utf8(handle.modified_sym());
+ let name = keysym_name_for(handle).unwrap_or_default();
+ data.desktop_icon_rename_key(&name, &utf8);
+ FilterResult::Intercept(())
+ });
+ } else {
+ keyboard.input::<(), _>(state, keycode, key_state, serial, time, |_, _, _| FilterResult::Intercept(()));
+ }
+ return;
+ }
+
let bound_keys = state.bound_keys.clone();
let matched: Option<(String, Modifiers)> =
keyboard.input(state, keycode, key_state, serial, time, move |data, mods, handle| {
diff --git a/crates/wayland/src/lib.rs b/crates/wayland/src/lib.rs
index fc1aefb..91168a1 100644
--- a/crates/wayland/src/lib.rs
+++ b/crates/wayland/src/lib.rs
@@ -66,6 +66,7 @@ mod rounded_corners;
mod rounded_corners_pixman;
mod screencopy;
mod state;
+mod trash;
mod udev;
mod winit;
mod workspace;
diff --git a/crates/wayland/src/state/desktop_icons.rs b/crates/wayland/src/state/desktop_icons.rs
index b1b802e..bcef073 100644
--- a/crates/wayland/src/state/desktop_icons.rs
+++ b/crates/wayland/src/state/desktop_icons.rs
@@ -3,24 +3,49 @@
//! 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_icons::{DesktopIcons, IconKind, CELL_HEIGHT, CELL_WIDTH, GRID_MARGIN};
use crate::desktop_menu::{DesktopMenu, DesktopMenuAction};
+/// Common terminal binaries tried, in order, when `general.terminal` is
+/// unset - first one actually found on `$PATH` wins. There's no `xdg-
+/// open`-equivalent dispatcher for "a shell" the way there is for a file,
+/// so unlike `file_manager`'s empty-means-`xdg-open` fallback, this needs
+/// a real candidate list.
+const TERMINAL_CANDIDATES: &[&str] = &["alacritty", "kitty", "wezterm", "foot", "gnome-terminal", "konsole", "xterm"];
+
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.
+ /// Populates `self.desktop_icons` on first call, then keeps its
+ /// `origin` continuously re-anchored to the primary monitor's own
+ /// *current* usable geometry on every later call - cheap enough
+ /// (one tuple comparison) to run unconditionally at the top of every
+ /// render pass. Does nothing at all when the config flag is off.
+ ///
+ /// The re-anchoring is the fix for a real, reported bug: this used to
+ /// return immediately once `self.desktop_icons` was `Some`, computing
+ /// `origin` exactly once, on whichever render pass happened to be
+ /// first. AGS's own top bar registers its exclusive zone (`general.
+ /// desktop_icons`' fix's own PR: `srd monitors`, `Monitor::geometry`
+ /// already excludes it) only once that separate client has connected
+ /// and committed - reliably *after* this compositor's own first
+ /// render pass, confirmed live via a temporary diagnostic log: origin
+ /// baked in at `(1936, 16)` (bar not yet registered, `geometry.y` was
+ /// still `0`) and never moved again even once `srd monitors` reported
+ /// the bar's real 34-40px reservation moments later - reported live
+ /// as "Home is still being overlapped by AGS's top bar." Re-deriving
+ /// `origin` every call (not rebuilding the icon list - render
+ /// position reads `origin` fresh at push time, never baked into a
+ /// cached glyph buffer) closes this permanently, for the bar, a dock
+ /// on any edge, or any later exclusive-zone change alike.
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);
+ if let Some(icons) = &mut self.desktop_icons {
+ icons.origin = origin;
+ return;
+ }
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);
@@ -55,11 +80,19 @@ impl CompState {
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);
+ // While this exact icon is mid-rename, show the live-edited buffer
+ // (with a trailing caret) instead of its real, on-disk label - so
+ // typing is visible without touching the filesystem until Enter
+ // actually commits it (`desktop_icon_rename_key`).
+ let label: std::borrow::Cow<str> = match &self.renaming_icon {
+ Some((rid, buf)) if rid == id => format!("{buf}_").into(),
+ _ => icon.label.as_str().into(),
+ };
let data = decoration::render_desktop_icon(
CELL_WIDTH as u32,
CELL_HEIGHT as u32,
icon.kind,
- &icon.label,
+ &label,
icon.selected,
theme.titlebar_fg_focused,
label_color,
@@ -184,15 +217,120 @@ impl CompState {
}
}
- pub(crate) fn set_desktop_icon_as_wallpaper(&mut self, id: &str) {
+ /// Enters inline rename mode for `id`, pre-filled with its current
+ /// label - only ever called for a real file/folder icon (`desktop_
+ /// menu.rs`'s own `open_for_icon` never offers Rename for anything
+ /// else), but harmless if that assumption is ever wrong: `commit_
+ /// icon_rename` re-checks the icon's kind before touching the
+ /// filesystem.
+ pub(crate) fn start_rename_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 command = self.wm.borrow().wallpaper_command.clone();
- if command.is_empty() {
+ self.renaming_icon = Some((id.to_string(), icon.label.clone()));
+ self.rebuild_icon_buffer(id);
+ }
+
+ /// Routes one keystroke into the in-progress rename buffer - same
+ /// shape as `native_lock_key`'s own `BackSpace`/`Return`/`Escape`/
+ /// printable-character handling.
+ pub(crate) fn desktop_icon_rename_key(&mut self, name: &str, utf8: &str) {
+ let Some((id, mut buffer)) = self.renaming_icon.take() else { return };
+ match name {
+ "BackSpace" => {
+ buffer.pop();
+ self.renaming_icon = Some((id.clone(), buffer));
+ self.rebuild_icon_buffer(&id);
+ }
+ "Return" | "KP_Enter" => self.commit_icon_rename(&id, &buffer),
+ "Escape" => self.rebuild_icon_buffer(&id),
+ _ => {
+ if !utf8.is_empty() && utf8.chars().all(|c| !c.is_control()) {
+ buffer.push_str(utf8);
+ }
+ self.renaming_icon = Some((id.clone(), buffer));
+ self.rebuild_icon_buffer(&id);
+ }
+ }
+ }
+
+ /// Renames the real file/folder on disk and carries its saved grid
+ /// cell forward under the new name, so a rename doesn't also bump the
+ /// icon to a fresh default position. A blank name, or renaming
+ /// anything other than a real file/folder (shouldn't happen - see
+ /// `start_rename_icon`'s own doc comment), just cancels with no
+ /// filesystem change.
+ fn commit_icon_rename(&mut self, id: &str, new_name: &str) {
+ let new_name = new_name.trim();
+ let Some(icons) = &self.desktop_icons else { return };
+ let Some(icon) = icons.icons.iter().find(|i| i.id == id) else { return };
+ if new_name.is_empty() || !matches!(icon.kind, IconKind::Folder | IconKind::File) {
+ self.rebuild_icon_buffer(id);
+ return;
+ }
+ let Some(parent) = icon.target.parent() else {
+ self.rebuild_icon_buffer(id);
+ return;
+ };
+ let new_path = parent.join(new_name);
+ if let Err(e) = std::fs::rename(&icon.target, &new_path) {
+ log::warn!("desktop_icons: couldn't rename {:?} to {new_name:?}: {e}", icon.target);
+ self.rebuild_icon_buffer(id);
+ return;
+ }
+ let cell = icon.cell;
+ crate::desktop_icons_state::save_icon(new_name, cell);
+ self.desktop_icon_buffers.remove(id);
+ self.refresh_desktop_icons();
+ }
+
+ /// Moves the real file/folder into `~/.local/share/Trash` - see
+ /// `trash.rs`'s own module doc comment for why this needs no
+ /// confirmation.
+ pub(crate) fn delete_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 };
+ if let Err(e) = crate::trash::move_to_trash(&icon.target) {
+ log::warn!("desktop_icons: couldn't move {:?} to trash: {e}", icon.target);
return;
}
- spawn_shell(&format!("{command} {}", shell_quote(&target)));
+ self.desktop_icon_buffers.remove(id);
+ self.refresh_desktop_icons();
+ }
+
+ pub(crate) fn empty_trash(&mut self) {
+ let Ok(home) = std::env::var("HOME").map(std::path::PathBuf::from) else { return };
+ crate::trash::empty(&home);
+ self.desktop_icon_buffers.remove("trash");
+ }
+
+ /// Spawns `general.terminal` (or the first of `TERMINAL_CANDIDATES`
+ /// found on `$PATH`) with `~/Desktop` as its working directory.
+ pub(crate) fn open_terminal_here(&mut self) {
+ let Ok(home) = std::env::var("HOME") else { return };
+ let desktop = format!("{home}/Desktop");
+ let configured = self.wm.borrow().terminal.clone();
+ let command = if !configured.is_empty() {
+ Some(configured)
+ } else {
+ TERMINAL_CANDIDATES.iter().find(|bin| on_path(bin)).map(|s| s.to_string())
+ };
+ match command {
+ Some(command) => spawn_shell_in_dir(&command, &desktop),
+ None => log::warn!("desktop_icons: no terminal found on $PATH and general.terminal is unset"),
+ }
+ }
+
+ /// Opens `~/Desktop` itself via `file_manager`/`xdg-open` - the
+ /// concrete path to a real file manager's own richer menu.
+ pub(crate) fn open_desktop_in_file_manager(&mut self) {
+ let Ok(home) = std::env::var("HOME") else { return };
+ let desktop = format!("{home}/Desktop");
+ let file_manager = self.wm.borrow().file_manager.clone();
+ if file_manager.is_empty() {
+ spawn_shell(&format!("xdg-open {}", shell_quote(&desktop)));
+ } else {
+ spawn_shell(&format!("{file_manager} {}", shell_quote(&desktop)));
+ }
}
/// Creates `~/Desktop/New Folder`, de-duplicated as `New Folder (2)`,
@@ -217,8 +355,7 @@ impl CompState {
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);
+ let menu = DesktopMenu::open_for_icon(icon, pos);
self.build_desktop_menu_buffer(menu);
}
@@ -244,8 +381,12 @@ impl CompState {
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::Rename(id) => self.start_rename_icon(&id),
+ DesktopMenuAction::Delete(id) => self.delete_desktop_icon(&id),
+ DesktopMenuAction::EmptyTrash => self.empty_trash(),
DesktopMenuAction::NewFolder => self.new_desktop_folder(),
+ DesktopMenuAction::OpenTerminalHere => self.open_terminal_here(),
+ DesktopMenuAction::OpenInFileManager => self.open_desktop_in_file_manager(),
DesktopMenuAction::Refresh => self.refresh_desktop_icons(),
}
}
@@ -302,6 +443,29 @@ fn spawn_shell(command: &str) {
}
}
+/// Same fire-and-forget shell spawn as `spawn_shell`, but with a working
+/// directory - `open_terminal_here`'s own reason to exist as a separate
+/// function rather than reusing `spawn_shell` with a `cd` prefix, which
+/// would need its own quoting for `dir`.
+fn spawn_shell_in_dir(command: &str, dir: &str) {
+ #[cfg(unix)]
+ let result = std::process::Command::new("sh").arg("-c").arg(command).current_dir(dir).spawn();
+ #[cfg(windows)]
+ let result = std::process::Command::new("cmd").arg("/C").arg(command).current_dir(dir).spawn();
+ if let Err(e) = result {
+ log::warn!("desktop_icons: spawn '{command}' in {dir:?} failed: {e}");
+ }
+}
+
+/// Whether `bin` resolves to a real, regular file somewhere on `$PATH` --
+/// enough to pick a terminal candidate without a full `which`-equivalent
+/// (no executable-bit/`PATHEXT` check on Windows; this whole feature is
+/// Linux/BSD-desktop-shaped already, see `TERMINAL_CANDIDATES`' own
+/// entries).
+fn on_path(bin: &str) -> bool {
+ std::env::var_os("PATH").map(|paths| std::env::split_paths(&paths).any(|dir| dir.join(bin).is_file())).unwrap_or(false)
+}
+
#[cfg(test)]
mod tests {
use super::*;
diff --git a/crates/wayland/src/state/mod.rs b/crates/wayland/src/state/mod.rs
index 66f9ec6..0fb303e 100644
--- a/crates/wayland/src/state/mod.rs
+++ b/crates/wayland/src/state/mod.rs
@@ -372,6 +372,14 @@ pub(crate) struct CompState {
/// `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)>,
+ /// An in-progress inline rename: the icon's own id, and the live-
+ /// edited name buffer - same shape as `NativeLock::password`
+ /// (`native_lock.rs`), the existing precedent for redirecting real
+ /// keyboard input into a plain string buffer instead of the normally-
+ /// focused client. `None` whenever no rename is in progress; the
+ /// keyboard handler checks this the same way it already checks
+ /// `state.lock.locked`.
+ pub(crate) renaming_icon: Option<(String, String)>,
pub(crate) wm: Rc<RefCell<WindowManager>>,
pub(crate) surface_to_id: HashMap<WlSurface, WindowId>,
pub(crate) id_to_window: HashMap<WindowId, DWindow>,
diff --git a/crates/wayland/src/trash.rs b/crates/wayland/src/trash.rs
new file mode 100644
index 0000000..a44d9c8
--- /dev/null
+++ b/crates/wayland/src/trash.rs
@@ -0,0 +1,228 @@
+//! freedesktop.org Trash specification - move-to-trash and empty-trash.
+//! Same-filesystem case only: `~/Desktop` and `~/.local/share/Trash` are
+//! virtually always the same filesystem in practice, and a cross-
+//! filesystem move needs its own per-mountpoint `<mountpoint>/.Trash-$uid`
+//! directory the spec defines separately - not attempted here.
+//! `std::fs::rename` across filesystems fails with a clear `EXDEV` error
+//! rather than silently doing the wrong thing, so this degrades to a
+//! reported failure, never silent data loss.
+//!
+//! No confirmation prompt anywhere in this module, by design: moving
+//! something to trash is the reversible operation, not the destructive
+//! one - every mainstream file manager (Nemo included) treats it exactly
+//! this way, gating only a *permanent* delete (which nothing here does)
+//! behind a dialog.
+
+use std::path::{Path, PathBuf};
+
+/// `$XDG_DATA_HOME/Trash`, else `~/.local/share/Trash`.
+fn trash_root(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")
+}
+
+pub(crate) fn files_dir(home: &Path) -> PathBuf {
+ trash_root(home).join("files")
+}
+
+fn info_dir(home: &Path) -> PathBuf {
+ trash_root(home).join("info")
+}
+
+// Not called yet - reserved for the Trash icon's own full/empty glyph
+// selection, landing later in this same round alongside real icon-theme
+// rendering.
+#[allow(dead_code)]
+pub(crate) fn is_empty(home: &Path) -> bool {
+ std::fs::read_dir(files_dir(home)).map(|mut d| d.next().is_none()).unwrap_or(true)
+}
+
+/// De-duplicates `name` against whatever's already in `dir` - same
+/// `"name (2)"`, `"name (3)"`, ... scheme as `desktop_icons::new_desktop_
+/// folder`'s own "New Folder (2)". The spec requires unique names in
+/// `files/` but doesn't mandate a specific collision scheme.
+fn dedup_name(dir: &Path, name: &str) -> String {
+ if !dir.join(name).exists() {
+ return name.to_string();
+ }
+ let (stem, ext) = match name.rsplit_once('.') {
+ Some((s, e)) if !s.is_empty() => (s.to_string(), Some(e.to_string())),
+ _ => (name.to_string(), None),
+ };
+ let mut n = 2;
+ loop {
+ let candidate = match &ext {
+ Some(e) => format!("{stem} ({n}).{e}"),
+ None => format!("{stem} ({n})"),
+ };
+ if !dir.join(&candidate).exists() {
+ return candidate;
+ }
+ n += 1;
+ }
+}
+
+/// Moves `path` into the trash, writing its `.trashinfo` metadata first --
+/// the spec requires that file to exist before the item lands in `files/`,
+/// so a reader never sees a trashed item with no metadata explaining it.
+/// Rolls the info file back if the actual move then fails, so a failure
+/// here never leaves an orphaned `.trashinfo` for something that's still
+/// sitting exactly where it started.
+pub(crate) fn move_to_trash(path: &Path) -> std::io::Result<()> {
+ let home = std::env::var("HOME").map(PathBuf::from).map_err(|_| std::io::Error::other("HOME not set"))?;
+ let files = files_dir(&home);
+ let info = info_dir(&home);
+ std::fs::create_dir_all(&files)?;
+ std::fs::create_dir_all(&info)?;
+ let name = path.file_name().and_then(|n| n.to_str()).ok_or_else(|| std::io::Error::new(std::io::ErrorKind::InvalidInput, "no filename"))?;
+ let trashed_name = dedup_name(&files, name);
+ let info_content = format!("[Trash Info]\nPath={}\nDeletionDate={}\n", percent_encode_path(path), now_iso8601());
+ let info_path = info.join(format!("{trashed_name}.trashinfo"));
+ std::fs::write(&info_path, info_content)?;
+ if let Err(e) = std::fs::rename(path, files.join(&trashed_name)) {
+ let _ = std::fs::remove_file(&info_path);
+ return Err(e);
+ }
+ Ok(())
+}
+
+/// Removes every entry under both `files/` and `info/`.
+pub(crate) fn empty(home: &Path) {
+ for dir in [files_dir(home), info_dir(home)] {
+ let Ok(entries) = std::fs::read_dir(&dir) else { continue };
+ for entry in entries.flatten() {
+ let path = entry.path();
+ let result = if path.is_dir() { std::fs::remove_dir_all(&path) } else { std::fs::remove_file(&path) };
+ if let Err(e) = result {
+ log::warn!("trash: couldn't remove {path:?}: {e}");
+ }
+ }
+ }
+}
+
+/// Minimal percent-encoding for the `.trashinfo` `Path=` field - only
+/// needs to be a *safe* encoding (every byte a later parser could choke
+/// on gets escaped), not a byte-perfect implementation, since nothing in
+/// this codebase ever reads this field back; a real trash-viewing tool
+/// (Nemo) is what actually parses it.
+fn percent_encode_path(path: &Path) -> String {
+ let s = path.to_string_lossy();
+ let mut out = String::with_capacity(s.len());
+ for b in s.bytes() {
+ let safe = b.is_ascii_alphanumeric() || matches!(b, b'/' | b'-' | b'_' | b'.' | b'~');
+ if safe {
+ out.push(b as char);
+ } else {
+ out.push_str(&format!("%{b:02X}"));
+ }
+ }
+ out
+}
+
+/// `YYYY-MM-DDTHH:MM:SS`, local system clock - hand-rolled rather than
+/// pulling in `chrono`/`time` for one timestamp field. `civil_from_days`
+/// is Howard Hinnant's well-known public-domain algorithm for converting
+/// a day count into a proleptic-Gregorian (year, month, day); this
+/// codebase has no other date-formatting need that would justify a real
+/// date/time dependency.
+fn now_iso8601() -> String {
+ let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap_or_default();
+ let days = (now.as_secs() / 86400) as i64;
+ let secs_of_day = now.as_secs() % 86400;
+ let (y, m, d) = civil_from_days(days);
+ let (h, mi, s) = (secs_of_day / 3600, (secs_of_day % 3600) / 60, secs_of_day % 60);
+ format!("{y:04}-{m:02}-{d:02}T{h:02}:{mi:02}:{s:02}")
+}
+
+fn civil_from_days(z: i64) -> (i64, u32, u32) {
+ let z = z + 719468;
+ let era = if z >= 0 { z } else { z - 146096 } / 146097;
+ let doe = (z - era * 146097) as u64;
+ let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
+ let y = yoe as i64 + era * 400;
+ let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
+ let mp = (5 * doy + 2) / 153;
+ let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
+ let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
+ (if m <= 2 { y + 1 } else { y }, m, d)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn dedup_name_keeps_an_unused_name_as_is() {
+ let dir = std::env::temp_dir().join(format!("srdwm-trash-test-{}", std::process::id()));
+ let _ = std::fs::remove_dir_all(&dir);
+ std::fs::create_dir_all(&dir).unwrap();
+ assert_eq!(dedup_name(&dir, "report.txt"), "report.txt");
+ std::fs::remove_dir_all(&dir).unwrap();
+ }
+
+ #[test]
+ fn dedup_name_numbers_a_collision_preserving_the_extension() {
+ let dir = std::env::temp_dir().join(format!("srdwm-trash-test-ext-{}", std::process::id()));
+ let _ = std::fs::remove_dir_all(&dir);
+ std::fs::create_dir_all(&dir).unwrap();
+ std::fs::write(dir.join("report.txt"), b"").unwrap();
+ assert_eq!(dedup_name(&dir, "report.txt"), "report (2).txt");
+ std::fs::remove_dir_all(&dir).unwrap();
+ }
+
+ #[test]
+ fn dedup_name_numbers_a_collision_with_no_extension() {
+ let dir = std::env::temp_dir().join(format!("srdwm-trash-test-noext-{}", std::process::id()));
+ let _ = std::fs::remove_dir_all(&dir);
+ std::fs::create_dir_all(&dir).unwrap();
+ std::fs::write(dir.join("New Folder"), b"").unwrap();
+ assert_eq!(dedup_name(&dir, "New Folder"), "New Folder (2)");
+ std::fs::remove_dir_all(&dir).unwrap();
+ }
+
+ #[test]
+ fn percent_encode_path_leaves_ordinary_path_characters_alone() {
+ assert_eq!(percent_encode_path(Path::new("/home/x/Desktop/report.txt")), "/home/x/Desktop/report.txt");
+ }
+
+ #[test]
+ fn percent_encode_path_escapes_a_space() {
+ assert_eq!(percent_encode_path(Path::new("/home/x/New Folder")), "/home/x/New%20Folder");
+ }
+
+ #[test]
+ fn civil_from_days_matches_the_unix_epoch() {
+ assert_eq!(civil_from_days(0), (1970, 1, 1));
+ }
+
+ #[test]
+ fn civil_from_days_matches_a_known_recent_date() {
+ // 2026-08-26 is 20,691 days after the epoch (verified independently:
+ // `python3 -c "import datetime; print((datetime.date(2026,8,26) -
+ // datetime.date(1970,1,1)).days)"`).
+ assert_eq!(civil_from_days(20691), (2026, 8, 26));
+ }
+
+ #[test]
+ fn move_to_trash_and_empty_round_trip_on_a_real_temp_file() {
+ let home = std::env::temp_dir().join(format!("srdwm-trash-home-{}", std::process::id()));
+ let _ = std::fs::remove_dir_all(&home);
+ std::fs::create_dir_all(&home).unwrap();
+ // `move_to_trash` reads real $HOME/$XDG_DATA_HOME - not safely
+ // parallel-testable via env var override (same reasoning `monitor_
+ // layout.rs`'s own tests give), so this test exercises `empty`/
+ // `is_empty` directly against a fabricated trash layout instead of
+ // going through `move_to_trash`'s own env lookup.
+ let files = files_dir(&home);
+ let info = info_dir(&home);
+ std::fs::create_dir_all(&files).unwrap();
+ std::fs::create_dir_all(&info).unwrap();
+ std::fs::write(files.join("old.txt"), b"x").unwrap();
+ std::fs::write(info.join("old.txt.trashinfo"), b"[Trash Info]\n").unwrap();
+ assert!(!is_empty(&home));
+ empty(&home);
+ assert!(is_empty(&home));
+ assert_eq!(std::fs::read_dir(&info).unwrap().count(), 0);
+ std::fs::remove_dir_all(&home).unwrap();
+ }
+}
diff --git a/crates/wayland/src/udev/platform.rs b/crates/wayland/src/udev/platform.rs
index 2633f16..6f6cbe8 100644
--- a/crates/wayland/src/udev/platform.rs
+++ b/crates/wayland/src/udev/platform.rs
@@ -236,6 +236,7 @@ impl UdevPlatform {
desktop_menu: None,
desktop_menu_buffer: None,
last_icon_click: None,
+ renaming_icon: 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 73aaa82..2d0a063 100644
--- a/crates/wayland/src/udev/render.rs
+++ b/crates/wayland/src/udev/render.rs
@@ -925,8 +925,8 @@ impl CompState {
// 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) {
+ 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) {
Ok(elem) => custom_elements.push(crate::elements::OverlayElement::Memory(elem)),
Err(e) => log::warn!("udev: failed to import desktop icon buffer: {e}"),
}
diff --git a/crates/wayland/src/winit/connect.rs b/crates/wayland/src/winit/connect.rs
index 227cf33..140dc58 100644
--- a/crates/wayland/src/winit/connect.rs
+++ b/crates/wayland/src/winit/connect.rs
@@ -157,6 +157,7 @@ impl WaylandPlatform {
desktop_menu: None,
desktop_menu_buffer: None,
last_icon_click: None,
+ renaming_icon: None,
wm: wm.clone(),
surface_to_id: HashMap::new(),
id_to_window: HashMap::new(),
diff --git a/docs/DEFAULTS.md b/docs/DEFAULTS.md
index 5f99446..2597840 100644
--- a/docs/DEFAULTS.md
+++ b/docs/DEFAULTS.md
@@ -20,7 +20,7 @@ srd.set("general.gpu", false) -- Default: false - udev
srd.set("general.desktop_icons", true) -- Default: true - see "Desktop icons" below
srd.set("general.file_manager", "") -- Default: "" - empty means dispatch via `xdg-open`
srd.set("general.desktop_icon_single_click", false) -- Default: false - double-click opens an icon
-srd.set("general.wallpaper_command", "") -- Default: "" - empty hides "Set as Wallpaper" entirely
+srd.set("general.terminal", "") -- Default: "" - empty tries a common terminal on $PATH
```
`general.smart_placement`/`general.border_width` are not listed: neither
is implemented - new-window placement always uses smart placement
@@ -73,29 +73,51 @@ capability exists anywhere in this codebase, so folder/computer/trash/file
icons are simple flat shapes, the same technique the titlebar's own
buttons use.
+Icons sort into one alphabetical list by label, case-insensitive - the
+three fixed shortcuts interleave with real filenames rather than always
+coming first, e.g. "Computer" and "Documents" and "Home" and "Trash" sort
+exactly where their names put them.
+
Double-click (or a single click when `general.desktop_icon_single_click`
-is `true`) opens an icon: `$general.file_manager <path>` if that key is
+is `true`) opens an icon: `general.file_manager <path>` if that key is
set, otherwise `xdg-open <path>`. Dragging an icon snaps it to the nearest
free grid cell on release and persists that cell to
`$XDG_STATE_HOME/srd/desktop-icons.json` (else `~/.local/state/srd/...`) --
only icons the user has actually moved get an entry there; everything
-else keeps recomputing its default slot on every rescan.
-
-Right-click an icon: "Open", plus "Set as Wallpaper" when it's an image
-file (`.png`/`.jpg`/`.jpeg`/`.webp`/`.bmp`/`.gif`) and `general.
-wallpaper_command` is set - shells out to `<wallpaper_command> <path>`.
-srdwm never draws the wallpaper itself (an external layer-shell client's
-job - `swww`, `awww`, or similar), so there is no universal default to
-guess here the way `xdg-open` covers `file_manager`; this action simply
-doesn't appear when the key is empty. Right-click bare desktop: "New
-Folder" (creates `~/Desktop/New Folder`, de-duplicated as `New Folder
-(2)`, `(3)`, ...) and "Refresh" (re-scans `~/Desktop`).
-
-Not implemented in this first pass, deliberately: moving a file to trash,
-emptying the trash, filesystem watching (a file added to `~/Desktop` by
-another program needs "Refresh" or a restart to appear), multi-select,
-and per-mimetype icon art. The first two are destructive/hard-to-reverse
-actions with no confirmation-dialog primitive to gate them on yet.
+else keeps recomputing its default slot on every rescan. The grid's own
+origin is re-derived from the primary monitor's current usable geometry
+every frame, so it always sits clear of a bar/dock's reserved strip on
+whichever edge it's anchored to, even if that reservation only appears
+after srdwm's first render (a real startup race with panels like AGS
+that connect and register their own exclusive zone after the compositor
+is already up).
+
+Right-click an icon: a real file or folder gets **Open**, **Rename**
+(inline, Enter to commit/Escape to cancel), and **Delete** (moves it to
+`~/.local/share/Trash` per the freedesktop.org Trash spec, same-filesystem
+case only - no confirmation prompt, since this is the reversible move-to-
+trash, not a permanent delete, the same convention every mainstream file
+manager uses). **Home**/**Computer** get **Open** only - they're
+shortcuts, not real files, so rename/delete don't apply. **Trash** gets
+**Open** and **Empty Trash** (also no prompt, same reversibility
+framing - this is the intentional final step, not a slip). Right-click
+bare desktop: **New Folder** (creates `~/Desktop/New Folder`, de-
+duplicated as `New Folder (2)`, `(3)`, ...), **Open Terminal Here**
+(`general.terminal`, or the first of alacritty/kitty/wezterm/foot/gnome-
+terminal/konsole/xterm found on `$PATH`, with `~/Desktop` as its working
+directory), **Open in File Manager** (opens `~/Desktop` itself in
+`general.file_manager`/`xdg-open` - the concrete path to a real file
+manager's own richer menu: cut/copy/paste, properties, set-as-wallpaper,
+deliberately not reimplemented here), and **Refresh** (re-scans
+`~/Desktop`).
+
+Not implemented, deliberately: Cut/Copy/Paste (real interop with a file
+manager needs the Wayland `wl_data_device`/`text/uri-list` clipboard
+protocol, a separate substantial feature - an srdwm-only internal
+clipboard wouldn't achieve real interop anyway), filesystem watching (a
+file added to `~/Desktop` by another program needs "Refresh" or a restart
+to appear), multi-select, View/Sort submenus (no nested-menu UI exists),
+and icons on any monitor but the primary one.
### Monitor Settings (`monitor.*`)
```lua
diff --git a/docs/TODO.md b/docs/TODO.md
index 7a11584..d2d8a49 100644
--- a/docs/TODO.md
+++ b/docs/TODO.md
@@ -13,35 +13,87 @@ that has the full story. Keep this list current as items close or open;
update the source doc's own entry too, don't let this drift into a
second stale copy the way `PANEL_SUPPORT_TODO.md` did.
-## Feature, implemented: real desktop icons plus the right-click desktop/icon menus (2026-08-25)
+## Feature, implemented, v2: real desktop icons plus proper right-click desktop/icon menus (2026-08-25/26)
Closes the "Right-click on bare desktop" item that used to sit under
"Explicitly requested, not yet started" - previously a true no-op
(`_ => {}` in `input/pointer.rs`'s button handler), nothing rendered above
the wallpaper at all. Follow-up request, asked directly: real desktop
-icons "just like windows does" - Home/Computer/Trash plus one per real
-`~/Desktop` entry, draggable, right-click menus, a New Folder action, and
-(a later addition to the same round) a "Set as Wallpaper" action for
-image files.
+icons "just like windows does".
Built as the sibling of the existing `context_menu.rs`/`snap_flyout.rs`
"compositor-owned floating UI" pattern - see `desktop_icons.rs`'s and
`desktop_menu.rs`'s own module doc comments for the full architecture.
-Four new config keys (`general.desktop_icons`, default `true`;
-`general.file_manager`, `general.desktop_icon_single_click`, `general.
-wallpaper_command`, all default off/empty) - see DEFAULTS.md's own
-"Desktop icons" section for the complete behavior writeup.
-
-Deliberately cut from this pass, stated up front rather than discovered
-later: no move-to-trash and no "Empty Trash" (both destructive/hard-to-
-reverse with no confirmation-dialog primitive to gate them on yet), no
-filesystem watching (manual "Refresh" or a restart picks up an externally
-added file), no multi-select, no per-mimetype icon art, icons on the
-primary monitor only.
-
-Built, `cargo test --workspace` (124 wayland-crate tests, up from 106) and
-`cargo clippy --workspace --all-targets` clean; installed, pending a live
-restart to confirm.
+Config keys: `general.desktop_icons` (default `true`), `general.
+file_manager`, `general.desktop_icon_single_click`, `general.terminal` --
+see DEFAULTS.md's own "Desktop icons" section for the complete behavior
+writeup.
+
+**v1 (2026-08-25) live-tested and found genuinely broken in three ways,
+fixed in v2 (2026-08-26):**
+
+1. **Icons weren't rendering at all, only sometimes.** Root cause:
+ `ensure_desktop_icons` computed the grid's `origin` exactly once, on
+ whichever render pass happened to be first - and AGS's own top bar
+ registers its exclusive zone only once that separate client connects
+ and commits, reliably *after* this compositor's first render pass.
+ Confirmed via a temporary diagnostic log: origin baked in at `(1936,
+ 16)` (bar not yet registered) and never moved again even once `srd
+ monitors` reported the bar's real reservation moments later --
+ reported live as "Home is still being overlapped by AGS's top bar."
+ Fixed by re-deriving `origin` from the primary monitor's *current*
+ geometry on every call (cheap: one tuple comparison, no rescan), not
+ just the first.
+2. **Fixed icons (Home/Computer/Trash) always sorted before real files.**
+ Confirmed via direct question this was wrong: the whole list - fixed
+ icons included - now sorts alphabetically by label, case-insensitive,
+ in one pass.
+3. **"Set as Wallpaper" was the wrong feature to build.** The user wants
+ that handled by their real file manager (Nemo) once opened, not
+ reimplemented here - removed entirely (`DesktopMenuAction::
+ SetWallpaper`, `general.wallpaper_command`, `is_image_path`).
+
+**Also added in v2, since "where are all the options" was the direct
+complaint about v1's too-thin menu:** a real file/folder icon's menu
+gained **Rename** (inline text edit - new `CompState::renaming_icon`
+field and keyboard-redirect, mirroring `NativeLock::password`'s own
+existing precedent for routing raw keystrokes into a plain string buffer
+instead of the focused client) and **Delete** (moves to `~/.local/share/
+Trash` per the freedesktop.org Trash spec - new `trash.rs` module,
+same-filesystem case only, no confirmation prompt: this is the
+reversible move-to-trash, not a permanent delete, the same convention
+every mainstream file manager uses - v1's caution here conflated
+"destructive" with "irreversible"). The Trash icon's own menu gained
+**Empty Trash** (same reversibility framing). The bare-desktop menu
+gained **Open Terminal Here** (new `general.terminal` config key, falls
+back to the first of a short common-terminal list found on `$PATH`) and
+**Open in File Manager** (opens `~/Desktop` itself - the concrete path
+to Nemo's own richer menu, directly supporting "let Nemo handle it"
+rather than reimplementing Cut/Copy/Paste/Properties here).
+
+Still deliberately cut, stated up front: Cut/Copy/Paste (real interop
+needs the Wayland `wl_data_device`/`text/uri-list` clipboard protocol, a
+separate substantial feature - an srdwm-only internal clipboard
+wouldn't achieve real interop with Nemo anyway), filesystem watching,
+multi-select, View/Sort submenus (no nested-menu UI exists), icons on
+any monitor but the primary one (requested as an "optional" follow-up,
+not yet built - real per-monitor grids need `desktop_icons` to become
+`Vec<DesktopIcons>` plus monitor-scoped persistence keys, a genuine
+structural change deferred to its own round rather than rushed alongside
+everything else here).
+
+Built, `cargo test --workspace` (133 wayland-crate tests, up from 106)
+and `cargo clippy --workspace --all-targets` clean; installed, pending a
+live restart to confirm.
+
+Separately, while investigating: `~/.local/state/srd/monitor-layout.json`
+was found with both `eDP-1` and `HDMI-A-1` persisted at the identical
+position `(1920, 0)` - clearly wrong (would stack them). The live
+arrangement is currently correct (`eDP-1` at `1920,0`, `HDMI-A-1` at
+`0,0` - genuine extend-left), so this stale file isn't actually being
+trusted/applied at startup right now, but the corruption itself isn't
+root-caused yet - not investigated further this round, flagged for
+follow-up.
## Real bug, root-caused and fixed: a resize's own rapid-fire commits could get a window's rounded-corner content mask cached as blank, hiding real content until the next real content change (2026-08-25)