git.lucas.co / cce-grid
desktop grid client
git clone https://git.lucas.co/cce-grid.git

commit77d1d9390fd137f7d5596d01bc462d13f9415bad
parentcf0483c42f
authorLucas Galante <[email protected]>
date2026-08-26 12:59
feat: right-click a desktop image to remove it

Right-click raises a context menu titled with the file name and offering
"Remove from Desktop". It is a cce-cloud --json popup, the same
mechanism the desktop and app context menus use, so it looks and behaves
like every other menu in the DE.

Remove unpins the image and leaves the file where it was saved. These
images live in the desktop FOLDER as real files the user asked for, so
taking one off the canvas must not delete it — deleting is a separate,
destructive act and belongs behind a separate decision.

Two details: the pointer's screen position has to be asked for with
ccectl, because a client knows where its own surface was touched but
never where that is on screen; and the removal message carries the path
rather than an index, since the menu blocks on its own thread while the
list can be reordered by a drag or grown by a drop.

Shadow-verified: menu opens on the image, remove empties the sidecar and
clears the canvas, and the file is still on disk afterwards.

Co-Authored-By: Claude Opus 5 <[email protected]>

 src/items.rs | 42 ++++++++++++++++++++++++++++++++++++++++++
 src/main.rs  | 54 +++++++++++++++++++++++++++++++++++++++++++++++++++---
 2 files changed, 93 insertions(+), 3 deletions(-)

diff --git a/src/items.rs b/src/items.rs
index f5f74f7..48bee0c 100644
--- a/src/items.rs
+++ b/src/items.rs
@@ -315,6 +315,48 @@ pub fn save_to_desktop(bytes: &[u8], name: &str) -> std::io::Result<PathBuf> {
     Ok(path)
 }
 
+/// Show the context menu for one desktop item and return the chosen action
+/// id, if any. Runs on a worker thread: it blocks until the menu closes.
+///
+/// The menu is a `cce-cloud --json` popup, the same mechanism the desktop and
+/// app context menus use, so it looks and behaves like every other menu in the
+/// DE rather than something this client drew for itself. The pointer's screen
+/// position has to be asked for — a client knows where its own surface was
+/// touched, never where that is on the screen.
+pub fn item_menu(name: &str) -> Option<String> {
+    use std::io::Write;
+
+    let loc = std::process::Command::new("ccectl").arg("pointer-location").output().ok()?;
+    let loc = String::from_utf8_lossy(&loc.stdout);
+    let coord = |key: &str| -> Option<i32> {
+        loc.split_whitespace()
+            .find_map(|t| t.strip_prefix(key))
+            .and_then(|v| v.trim().parse::<f64>().ok())
+            .map(|v| v.round() as i32)
+    };
+    let (x, y) = (coord("x=")?, coord("y=")?);
+
+    // The filename is the title so it is clear WHICH image is about to go.
+    let layout = format!(
+        r#"{{"pages":[{{"title":{},"justify":"left","widgets":[
+            {{"type":"button","text":"Remove from Desktop","id":"remove"}}
+        ]}}]}}"#,
+        serde_json::to_string(name).ok()?
+    );
+
+    let mut child = std::process::Command::new("cce-cloud")
+        .args(["--json", "-x", &x.to_string(), "-y", &y.to_string()])
+        .stdin(std::process::Stdio::piped())
+        .stdout(std::process::Stdio::piped())
+        .stderr(std::process::Stdio::null())
+        .spawn()
+        .ok()?;
+    child.stdin.take()?.write_all(layout.as_bytes()).ok()?;
+    let out = child.wait_with_output().ok()?;
+    let reply: serde_json::Value = serde_json::from_slice(&out.stdout).ok()?;
+    reply.get("button")?.as_str().map(|s| s.to_string())
+}
+
 /// Tell the user a drop failed, and why. A drop that silently does nothing
 /// is indistinguishable from one the desktop never received, so every failure
 /// path goes through here rather than only into the log.
diff --git a/src/main.rs b/src/main.rs
index a117c8b..25f168d 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -33,6 +33,10 @@ enum Message {
         px_w: u32,
         px_h: u32,
     },
+    /// The context menu closed on "remove". Carries the path rather than an
+    /// index: the menu is modal on its own thread, and the list can be
+    /// reordered by a drag (or grown by a drop) while it is open.
+    RemoveItem(std::path::PathBuf),
 }
 
 /// The world region the current buffer must cover, as told by the
@@ -389,6 +393,26 @@ impl Application for GridApp {
                 items::save(&model);
                 *needs_rebuild = true;
             }
+            Message::RemoveItem(path) => {
+                let Some(pos) = self.items.iter().position(|(i, _)| i.path == path) else {
+                    return;
+                };
+                // A drag on the removed item cannot outlive it.
+                if self.dragging.is_some() {
+                    self.dragging = None;
+                }
+                let (item, id) = self.items.remove(pos);
+                if let Some(id) = id {
+                    cce_ui::vk::free_image(id);
+                }
+                let model: Vec<items::DesktopItem> =
+                    self.items.iter().map(|(i, _)| i.clone()).collect();
+                items::save(&model);
+                // The file itself stays where it was saved: this unpins the
+                // image from the desktop, it does not delete the user's file.
+                log::info!("[items] removed {} from the desktop", item.path.display());
+                *needs_rebuild = true;
+            }
         }
     }
 
@@ -566,13 +590,37 @@ impl Application for GridApp {
         pos: LogicalPosition,
         needs_rebuild: &mut bool,
     ) -> Option<Self::Message> {
-        if button != MouseButton::Left {
-            return None;
-        }
         let Some(p) = self.patch else { return None };
         if p.scale <= 0.0 {
             return None;
         }
+        if button == MouseButton::Right {
+            if state != ElementState::Pressed {
+                return None;
+            }
+            let vx = p.x + pos.x as f64 / p.scale;
+            let vy = p.y + pos.y as f64 / p.scale;
+            let hit = self.items.iter().rposition(|(i, _)| {
+                vx >= i.x && vx < i.x + i.w && vy >= i.y && vy < i.y + i.h
+            })?;
+            let path = self.items[hit].0.path.clone();
+            let name = path
+                .file_name()
+                .map(|n| n.to_string_lossy().into_owned())
+                .unwrap_or_else(|| "Image".to_string());
+            // The menu blocks until it is dismissed, so it cannot run on the
+            // loop that has to keep drawing the desktop behind it.
+            let sender = self.sender.clone();
+            std::thread::spawn(move || {
+                if items::item_menu(&name).as_deref() == Some("remove") {
+                    let _ = sender.send(Message::RemoveItem(path));
+                }
+            });
+            return None;
+        }
+        if button != MouseButton::Left {
+            return None;
+        }
         match state {
             ElementState::Pressed => {
                 let vx = p.x + pos.x as f64 / p.scale;