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

commitc0c961b86d6337f8a379a8c9d5f5b7cae9aedc65
parent05444c695a
authorLucas Galante <[email protected]>
date2026-08-26 10:47
feat: pin dropped images to the canvas and save them to the desktop

Dropping an image on the desktop background now saves it to the desktop
folder AND pins it where it landed: the item is recorded in virtual
canvas coordinates, so it pans and zooms with the grid and comes back in
the same world spot next session.

The grid client is the right home for this. It is already the desktop's
drop target compositor-side, it already renders in virtual coordinates
(so a pinned image is world-anchored for free), and cce-ui can decode
and upload it — the compositor has no image decoder and no notion of an
object that is not a window.

Details worth keeping:
- Browsers hand over a LINK for an image on a page; the pixels only
  travel directly when the source made them (a canvas, an editor), so
  both shapes are handled, including Firefox's UTF-16 text/x-moz-url.
- Fetching shells out to curl. This is a background renderer that needs
  no network otherwise, and an async runtime plus a TLS stack would be
  the largest thing in the binary for a once-in-a-while user action.
- Uploads happen in renderer_init, not new: a reconnect builds a fresh
  renderer and does not replay earlier uploads.
- The sidecar is written rename-over-temp and a corrupt one is never
  overwritten, so a bad parse cannot cost the user their other items.

Shadow-verified end to end: a Firefox drag saved ~/Desktop/dropped-image.png
and pinned it centred on the drop point, and a restored item renders at
its world position through pans and zooms.

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

 Cargo.toml   |   5 ++
 src/items.rs | 287 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 src/main.rs  | 168 ++++++++++++++++++++++++++++++++--
 3 files changed, 455 insertions(+), 5 deletions(-)

diff --git a/Cargo.toml b/Cargo.toml
index ef39f39..efa0403 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -9,3 +9,8 @@ calloop = "0.13.0"
 wayland-client = { version = "0.31", features = ["system"] }
 log = "0.4"
 env_logger = "0.11"
+# Dropped desktop images: PNG and JPEG cover what a browser hands over. The
+# same pair cce-files and cce-preview already decode with.
+image = { version = "0.25", default-features = false, features = ["png", "jpeg"] }
+serde = { version = "1", features = ["derive"] }
+serde_json = "1"
diff --git a/src/items.rs b/src/items.rs
new file mode 100644
index 0000000..4fe43ae
--- /dev/null
+++ b/src/items.rs
@@ -0,0 +1,287 @@
+// Desktop items: images pinned to the world canvas.
+//
+// A drop on the desktop background lands here (the compositor routes drags
+// over the background onto the grid client — see its `Scene::at_including_grid`).
+// Each item is saved to the desktop folder AND recorded in a sidecar with the
+// virtual-canvas position it was dropped at, so it reappears in the same world
+// spot next session. Nothing here touches the GPU: the caller uploads the
+// decoded pixels, because that must happen on the main loop.
+//
+// Fetching shells out to curl rather than linking an HTTP stack. This process
+// is a background renderer that otherwise needs no network at all, and a
+// dropped web image is a once-in-a-while user action where process startup is
+// far below the notice threshold — an async runtime and a TLS stack would be
+// the largest thing in the binary, for that.
+
+use std::path::{Path, PathBuf};
+
+/// One pinned image, in virtual-surface coordinates (the same space windows
+/// and grid squares live in), so items pan and zoom with the desktop.
+#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
+pub struct DesktopItem {
+    /// Where the image was saved — the sidecar stores a path, not pixels.
+    pub path: PathBuf,
+    pub x: f64,
+    pub y: f64,
+    pub w: f64,
+    pub h: f64,
+}
+
+/// `$XDG_DATA_HOME/cce/desktop-items.json`.
+pub fn sidecar_path() -> PathBuf {
+    cce_ui::config::data_home().join("cce").join("desktop-items.json")
+}
+
+/// The desktop folder: `$XDG_DESKTOP_DIR` when the user-dirs config exports
+/// one, else `~/Desktop`.
+pub fn desktop_dir() -> PathBuf {
+    if let Ok(dir) = std::env::var("XDG_DESKTOP_DIR") {
+        if !dir.is_empty() {
+            return PathBuf::from(dir);
+        }
+    }
+    PathBuf::from(std::env::var("HOME").unwrap_or_default()).join("Desktop")
+}
+
+pub fn load() -> Vec<DesktopItem> {
+    let path = sidecar_path();
+    let Ok(text) = std::fs::read_to_string(&path) else { return Vec::new() };
+    match serde_json::from_str::<Vec<DesktopItem>>(&text) {
+        Ok(items) => items,
+        Err(e) => {
+            // A corrupt sidecar must not cost the user their other items on
+            // the next write, so refuse to start from an empty list: keep the
+            // file untouched and run with nothing until it is fixed.
+            log::error!("[items] {} is unreadable ({e}); not loading or rewriting it", path.display());
+            Vec::new()
+        }
+    }
+}
+
+pub fn save(items: &[DesktopItem]) {
+    let path = sidecar_path();
+    if let Some(parent) = path.parent() {
+        let _ = std::fs::create_dir_all(parent);
+    }
+    let Ok(text) = serde_json::to_string_pretty(items) else { return };
+    // Write-then-rename: a crash mid-write would otherwise leave a truncated
+    // sidecar, which is exactly the corrupt-file case above.
+    let tmp = path.with_extension("json.tmp");
+    if std::fs::write(&tmp, text).is_ok() {
+        let _ = std::fs::rename(&tmp, &path);
+    }
+}
+
+/// The URI (or raw image bytes) a drop payload actually carries.
+pub enum Payload {
+    Uri(String),
+    Bytes(Vec<u8>),
+}
+
+/// Interpret a drop by mime type. Browsers hand over a *link* for an image on
+/// a page — the pixels only travel directly when the source made them itself
+/// (a canvas, an image editor), which is why both shapes are handled.
+pub fn parse_payload(mime: &str, data: &[u8]) -> Option<Payload> {
+    if mime.starts_with("image/") {
+        return Some(Payload::Bytes(data.to_vec()));
+    }
+    let text = if mime == "text/x-moz-url" {
+        // Firefox's own flavour is UTF-16LE, "url\ntitle".
+        let units: Vec<u16> = data
+            .chunks_exact(2)
+            .map(|p| u16::from_le_bytes([p[0], p[1]]))
+            .collect();
+        String::from_utf16_lossy(&units)
+    } else {
+        String::from_utf8_lossy(data).into_owned()
+    };
+    // text/uri-list is line-based with '#' comments; the other text flavours
+    // are a bare URL. Taking the first usable line covers both.
+    let uri = text
+        .lines()
+        .map(|l| l.trim())
+        .find(|l| !l.is_empty() && !l.starts_with('#'))?;
+    Some(Payload::Uri(uri.to_string()))
+}
+
+/// Percent-decode enough of a `file://` URI to get a real path back.
+fn percent_decode(s: &str) -> String {
+    let bytes = s.as_bytes();
+    let mut out = Vec::with_capacity(bytes.len());
+    let mut i = 0;
+    while i < bytes.len() {
+        if bytes[i] == b'%' && i + 2 < bytes.len() {
+            if let Ok(b) = u8::from_str_radix(&s[i + 1..i + 3], 16) {
+                out.push(b);
+                i += 3;
+                continue;
+            }
+        }
+        out.push(bytes[i]);
+        i += 1;
+    }
+    String::from_utf8_lossy(&out).into_owned()
+}
+
+/// A filename for the saved copy: the URI's last path segment when it looks
+/// like a filename, else a generic name. Query strings and fragments are
+/// stripped — plenty of image URLs end in `?w=800`.
+fn file_name_for(uri: &str, fallback_ext: &str) -> String {
+    let trimmed = uri.split(['?', '#']).next().unwrap_or(uri);
+    let last = trimmed.rsplit('/').next().unwrap_or("");
+    let last = percent_decode(last);
+    let looks_named = !last.is_empty() && last.contains('.') && last.len() <= 128;
+    if looks_named {
+        last
+    } else {
+        format!("dropped-image.{fallback_ext}")
+    }
+}
+
+/// A path in `dir` that does not exist yet, suffixing `-2`, `-3`, … A drop
+/// must never overwrite a file the user already has.
+fn unique_path(dir: &Path, name: &str) -> PathBuf {
+    let candidate = dir.join(name);
+    if !candidate.exists() {
+        return candidate;
+    }
+    let (stem, ext) = match name.rsplit_once('.') {
+        Some((s, e)) => (s.to_string(), format!(".{e}")),
+        None => (name.to_string(), String::new()),
+    };
+    for n in 2..10_000 {
+        let candidate = dir.join(format!("{stem}-{n}{ext}"));
+        if !candidate.exists() {
+            return candidate;
+        }
+    }
+    dir.join(name)
+}
+
+/// Sniff a container from magic bytes — the extension in a URL is a guess and
+/// content-type is not carried through a drop.
+fn extension_for(bytes: &[u8]) -> &'static str {
+    if bytes.starts_with(&[0x89, b'P', b'N', b'G']) {
+        "png"
+    } else if bytes.starts_with(&[0xFF, 0xD8, 0xFF]) {
+        "jpg"
+    } else {
+        "bin"
+    }
+}
+
+/// Fetch the drop's bytes: a local file is read, anything else goes through
+/// curl. Returns the bytes and the name to save them under.
+pub fn fetch(payload: Payload) -> std::io::Result<(Vec<u8>, String)> {
+    match payload {
+        Payload::Bytes(bytes) => {
+            let ext = extension_for(&bytes);
+            Ok((bytes, format!("dropped-image.{ext}")))
+        }
+        Payload::Uri(uri) if uri.starts_with("file://") => {
+            let path = percent_decode(uri.trim_start_matches("file://"));
+            let bytes = std::fs::read(&path)?;
+            let name = Path::new(&path)
+                .file_name()
+                .map(|n| n.to_string_lossy().into_owned())
+                .unwrap_or_else(|| file_name_for(&uri, extension_for(&bytes)));
+            Ok((bytes, name))
+        }
+        Payload::Uri(uri) => {
+            if !uri.starts_with("http://") && !uri.starts_with("https://") {
+                return Err(std::io::Error::new(
+                    std::io::ErrorKind::InvalidInput,
+                    format!("unsupported URI scheme: {uri}"),
+                ));
+            }
+            let out = std::process::Command::new("curl")
+                .args([
+                    "--location",
+                    "--fail",
+                    "--silent",
+                    "--show-error",
+                    // A drop should not be able to hang a renderer thread
+                    // forever on a dead host.
+                    "--max-time",
+                    "30",
+                    "--max-filesize",
+                    "67108864",
+                    &uri,
+                ])
+                .output()?;
+            if !out.status.success() {
+                return Err(std::io::Error::other(format!(
+                    "curl failed for {uri}: {}",
+                    String::from_utf8_lossy(&out.stderr).trim()
+                )));
+            }
+            let name = file_name_for(&uri, extension_for(&out.stdout));
+            Ok((out.stdout, name))
+        }
+    }
+}
+
+/// Save bytes into the desktop folder under a non-colliding name.
+pub fn save_to_desktop(bytes: &[u8], name: &str) -> std::io::Result<PathBuf> {
+    let dir = desktop_dir();
+    std::fs::create_dir_all(&dir)?;
+    let path = unique_path(&dir, name);
+    std::fs::write(&path, bytes)?;
+    Ok(path)
+}
+
+/// Decode to straight RGBA8 for `cce_ui::vk::upload_rgba`.
+pub fn decode_rgba(bytes: &[u8]) -> Option<(Vec<u8>, u32, u32)> {
+    let img = image::load_from_memory(bytes).ok()?;
+    let rgba = img.to_rgba8();
+    let (w, h) = (rgba.width(), rgba.height());
+    Some((rgba.into_raw(), w, h))
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn uri_list_takes_the_first_real_line() {
+        let data = b"# comment\r\nhttps://example.com/cat.png\r\nhttps://other\r\n";
+        let Some(Payload::Uri(u)) = parse_payload("text/uri-list", data) else {
+            panic!("expected a URI")
+        };
+        assert_eq!(u, "https://example.com/cat.png");
+    }
+
+    #[test]
+    fn moz_url_is_utf16() {
+        // "http://a/b.png\nTitle" in UTF-16LE, as Firefox sends it.
+        let s = "http://a/b.png\nTitle";
+        let data: Vec<u8> = s.encode_utf16().flat_map(|u| u.to_le_bytes()).collect();
+        let Some(Payload::Uri(u)) = parse_payload("text/x-moz-url", &data) else {
+            panic!("expected a URI")
+        };
+        assert_eq!(u, "http://a/b.png");
+    }
+
+    #[test]
+    fn image_mimes_carry_bytes_not_links() {
+        let Some(Payload::Bytes(b)) = parse_payload("image/png", &[1, 2, 3]) else {
+            panic!("expected bytes")
+        };
+        assert_eq!(b, vec![1, 2, 3]);
+    }
+
+    #[test]
+    fn file_names_survive_query_strings_and_fall_back() {
+        assert_eq!(file_name_for("https://x.com/a/cat.png?w=800", "png"), "cat.png");
+        assert_eq!(file_name_for("https://x.com/a/photo%20one.jpg", "jpg"), "photo one.jpg");
+        // No filename in the path at all.
+        assert_eq!(file_name_for("https://x.com/render?id=9", "png"), "dropped-image.png");
+    }
+
+    #[test]
+    fn extension_is_sniffed_from_content() {
+        assert_eq!(extension_for(&[0x89, b'P', b'N', b'G', 0]), "png");
+        assert_eq!(extension_for(&[0xFF, 0xD8, 0xFF, 0]), "jpg");
+        assert_eq!(extension_for(b"not an image"), "bin");
+    }
+}
diff --git a/src/main.rs b/src/main.rs
index 539bd21..b979958 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -20,8 +20,20 @@ use cce_ui::scene::layout::Rect;
 use cce_ui::scene::paint::{DisplayList, PaintCtx};
 use cce_ui::widget::{ElementState, KeyEvent, MouseButton, MouseScrollDelta};
 
+mod items;
+
 #[derive(Debug, Clone)]
-enum Message {}
+enum Message {
+    /// A dropped image finished fetching, saving and decoding on its worker
+    /// thread. Carried as pixels rather than an image id because the GPU
+    /// upload has to happen on the main loop.
+    ItemReady {
+        item: items::DesktopItem,
+        pixels: Vec<u8>,
+        px_w: u32,
+        px_h: u32,
+    },
+}
 
 /// The world region the current buffer must cover, as told by the
 /// compositor: virtual origin/size and surface px per virtual unit.
@@ -36,6 +48,11 @@ struct Patch {
 
 struct GridApp {
     patch: Option<Patch>,
+    /// Images pinned to the canvas, paired with their uploaded texture id
+    /// (`None` until the renderer exists — see `renderer_init`).
+    items: Vec<(items::DesktopItem, Option<u32>)>,
+    /// Worker threads post finished drops back through this.
+    sender: calloop::channel::Sender<Message>,
     /// The raw `(relief)` string currently installed process-wide (depth +
     /// wall profile LUT) — a change detector, so the registry is only
     /// touched when the config value actually changes.
@@ -267,6 +284,30 @@ impl GridApp {
                 }
             }
         }
+
+        // Pinned images sit ON the canvas, so they are placed by the same
+        // world->patch mapping as the cells and drawn after them. The whole
+        // grid surface is below every window, so an item never covers an app.
+        for (item, id) in self.items.iter() {
+            let Some(id) = *id else { continue };
+            let rect = Rect {
+                x: ((item.x - p.x) * s) as f32,
+                y: ((item.y - p.y) * s) as f32,
+                width: (item.w * s) as f32,
+                height: (item.h * s) as f32,
+            };
+            // Cull off-patch items: at a far zoom-out the patch can hold
+            // hundreds of squares, and an image that is not on it costs a
+            // draw for nothing.
+            if rect.x + rect.width < 0.0
+                || rect.y + rect.height < 0.0
+                || rect.x > size.width as f32
+                || rect.y > size.height as f32
+            {
+                continue;
+            }
+            pc.image(id, rect, 1.0);
+        }
     }
 }
 
@@ -277,7 +318,13 @@ impl Application for GridApp {
         _qh: &QueueHandle<EngineState<Self>>,
         _sender: calloop::channel::Sender<Self::Message>,
     ) -> Self {
-        Self { patch: None, applied_relief: None, base_depth: None }
+        Self {
+            patch: None,
+            items: items::load().into_iter().map(|i| (i, None)).collect(),
+            sender: _sender,
+            applied_relief: None,
+            base_depth: None,
+        }
     }
 
     fn settings(&self) -> WindowSettings {
@@ -300,12 +347,123 @@ impl Application for GridApp {
         self.patch = Some(Patch { x, y, w, h, scale });
     }
 
-    fn update(&mut self, _msg: Self::Message, _needs_rebuild: &mut bool, _exit: &mut bool) {}
+    fn update(&mut self, msg: Self::Message, needs_rebuild: &mut bool, _exit: &mut bool) {
+        match msg {
+            Message::ItemReady { item, pixels, px_w, px_h } => {
+                let id = cce_ui::vk::upload_rgba(pixels, px_w, px_h);
+                log::info!(
+                    "[items] pinned {} at ({:.0}, {:.0})",
+                    item.path.display(),
+                    item.x,
+                    item.y
+                );
+                self.items.push((item, Some(id)));
+                // Persist only the model — the texture id is per-process.
+                let model: Vec<items::DesktopItem> =
+                    self.items.iter().map(|(i, _)| i.clone()).collect();
+                items::save(&model);
+                *needs_rebuild = true;
+            }
+        }
+    }
 
     fn tick(&mut self, _dt: f32, _needs_rebuild: &mut bool) {}
 
-    // The grid layer is input-transparent compositor-side; nothing ever
-    // reaches these.
+    /// Uploads happen here, not in `new`: a reconnect builds a fresh renderer
+    /// and does not replay earlier uploads, so items restored from the sidecar
+    /// (and any pinned before the reconnect) have to be handed over again.
+    fn renderer_init(&mut self, _renderer: &mut cce_ui::vk::VkRenderer) {
+        for (item, id) in self.items.iter_mut() {
+            let Ok(bytes) = std::fs::read(&item.path) else {
+                log::warn!("[items] {} is gone; not drawing it", item.path.display());
+                *id = None;
+                continue;
+            };
+            match items::decode_rgba(&bytes) {
+                Some((pixels, w, h)) => *id = Some(cce_ui::vk::upload_rgba(pixels, w, h)),
+                None => {
+                    log::warn!("[items] {} did not decode", item.path.display());
+                    *id = None;
+                }
+            }
+        }
+    }
+
+    /// What a browser offers for an image on a page, best first: the raw
+    /// bytes if the source has them, else a link to fetch.
+    fn drop_mimes(&self) -> &'static [&'static str] {
+        &[
+            "image/png",
+            "image/jpeg",
+            "text/uri-list",
+            "text/x-moz-url",
+            "text/plain;charset=utf-8",
+            "text/plain",
+        ]
+    }
+
+    fn handle_drop(
+        &mut self,
+        mime: &str,
+        data: &[u8],
+        pos: LogicalPosition,
+        _needs_rebuild: &mut bool,
+    ) {
+        let Some(patch) = self.patch else { return };
+        if patch.scale <= 0.0 {
+            return;
+        }
+        let Some(payload) = items::parse_payload(mime, data) else { return };
+
+        // The drop point in world coordinates — the inverse of the mapping
+        // `paint` uses to place cells, so the image lands under the cursor
+        // whatever the camera is doing.
+        let vx = patch.x + pos.x as f64 / patch.scale;
+        let vy = patch.y + pos.y as f64 / patch.scale;
+
+        // Sized to fit inside one grid cell, keeping aspect: a phone
+        // screenshot would otherwise land several squares wide.
+        let st = style();
+        let (cell_w, cell_h) = (st.cell_w.max(16.0), st.cell_h.max(16.0));
+        let sender = self.sender.clone();
+        std::thread::spawn(move || {
+            let (bytes, name) = match items::fetch(payload) {
+                Ok(v) => v,
+                Err(e) => {
+                    log::warn!("[items] fetch failed: {e}");
+                    return;
+                }
+            };
+            let Some((pixels, px_w, px_h)) = items::decode_rgba(&bytes) else {
+                log::warn!("[items] dropped data is not a decodable image");
+                return;
+            };
+            // Save even though it is already decoded: the user asked for the
+            // file on their desktop, not just a picture on the canvas.
+            let path = match items::save_to_desktop(&bytes, &name) {
+                Ok(p) => p,
+                Err(e) => {
+                    log::warn!("[items] could not save to the desktop folder: {e}");
+                    return;
+                }
+            };
+            let fit = (cell_w / px_w as f64).min(cell_h / px_h as f64).min(1.0);
+            let w = px_w as f64 * fit;
+            let h = px_h as f64 * fit;
+            let item = items::DesktopItem {
+                path,
+                // Centred on the drop point.
+                x: vx - w / 2.0,
+                y: vy - h / 2.0,
+                w,
+                h,
+            };
+            let _ = sender.send(Message::ItemReady { item, pixels, px_w, px_h });
+        });
+    }
+
+    // The grid layer is input-transparent compositor-side; nothing but a drag
+    // ever reaches these.
     fn handle_pointer_move(&mut self, _pos: LogicalPosition, _needs_rebuild: &mut bool) {}
 
     fn handle_mouse_input(