diff options
Diffstat (limited to 'crates/wayland')
| -rw-r--r-- | crates/wayland/src/desktop_icons.rs | 52 | ||||
| -rw-r--r-- | crates/wayland/src/desktop_menu.rs | 110 | ||||
| -rw-r--r-- | crates/wayland/src/input/keyboard.rs | 19 | ||||
| -rw-r--r-- | crates/wayland/src/lib.rs | 1 | ||||
| -rw-r--r-- | crates/wayland/src/state/desktop_icons.rs | 200 | ||||
| -rw-r--r-- | crates/wayland/src/state/mod.rs | 8 | ||||
| -rw-r--r-- | crates/wayland/src/trash.rs | 228 | ||||
| -rw-r--r-- | crates/wayland/src/udev/platform.rs | 1 | ||||
| -rw-r--r-- | crates/wayland/src/udev/render.rs | 4 | ||||
| -rw-r--r-- | crates/wayland/src/winit/connect.rs | 1 |
10 files changed, 540 insertions, 84 deletions
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(), |