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

commitb310368415aaf9abe81cc6421d15a7437e8351e8
parent95cac528fc
authorLucas Galante <lsgalante12@gmail.com>
date2026-09-30 12:11
items: report the pinned images to the compositor and follow its group moves

The overview drag-selection now picks images up beside windows, but the
compositor knew them only as this surface's input region. Every change to
the list (load, drop, remove, a drag or resize of our own) reports it as
`grid-items <id>:<x>:<y>:<w>:<h> ...` on the control socket, through one
retrying thread so reports land in order and survive a compositor that is
not up yet; DesktopItem::id is a per-process counter, not persisted. The
`selection` status topic comes back the other way — `move <id>:<x>:<y>
...` as a group move steps (rect damage, like a drag of our own) and
`drop` at the release, on which the sidecar is saved and the list
reported afresh. The `adjust` listener is generalised into
spawn_topic_listener for both topics.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

 CLAUDE.md    |  22 +++++++
 src/items.rs |   6 ++
 src/main.rs  | 186 +++++++++++++++++++++++++++++++++++++++++++++++++++++------
 3 files changed, 197 insertions(+), 17 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index d328ff6..bab14c5 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -96,6 +96,28 @@ on the body still moves. The discs are sized in virtual units, so they
 scale with the canvas rather than holding a screen size the way the
 compositor's do — this client never learns the camera zoom.
 
+**Selection.** The compositor's overview drag-selection picks the items
+up beside windows and a group move carries them (since 2026-09-30; see
+`cce-compositor/CLAUDE.md`, "Overview drag-selection"). The compositor
+knows the items only as this surface's input region, so this client
+REPORTS them: `grid-items <id>:<x>:<y>:<w>:<h> ...` on the control socket
+(`report_items`), virtual units, the whole list in draw order on every
+change — load, a drop, a remove, a drag or resize of its own (a drag
+release re-reports even unmoved, since the press raised the item and the
+compositor's hit test wants the order). `DesktopItem::id` is a
+per-process counter (`assign_id`, `#[serde(skip)]`): the sidecar's
+identity is the path. Reports go through one thread (`spawn_reporter`) so
+they land in order and a compositor that is not up yet is retried, only
+the latest pending. The other direction is the `selection` status topic
+(`spawn_topic_listener`, which `adjust` now shares): `move <id>:<x>:<y>
+...` sets the positions as a group move steps (rect damage, like a drag of
+this client's own — `Message::SelectionMove`) and `drop` saves the sidecar
+and reports afresh (`Message::SelectionDrop`). The highlight is the
+compositor's, drawn over the image like a window's wash; this client draws
+nothing for it. A press on a selected item never reaches this client (the
+compositor takes it as the group grab); a press on an unselected one drops
+the selection there and arrives here as an ordinary drag.
+
 One trap, spelled out on `Patch::surface_per_virtual`: pointer events and
 input regions are surface-local px, and for THIS surface that means BUFFER
 px at every output scale — the grid surface is pinned at buffer_scale 1
diff --git a/src/items.rs b/src/items.rs
index ecf5d0b..f41af77 100644
--- a/src/items.rs
+++ b/src/items.rs
@@ -22,6 +22,12 @@ use std::path::{Path, PathBuf};
 pub struct DesktopItem {
     /// Where the image was saved — the sidecar stores a path, not pixels.
     pub path: PathBuf,
+    /// This process's name for the item in its reports to the compositor
+    /// (`grid-items`), which addresses a group move's `move` lines by it.
+    /// Assigned when the item enters the list (`GridApp::assign_id`); not
+    /// persisted, a path is the identity across sessions.
+    #[serde(skip)]
+    pub id: u64,
     pub x: f64,
     pub y: f64,
     pub w: f64,
diff --git a/src/main.rs b/src/main.rs
index 4c60251..69bc7a0 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -41,6 +41,14 @@ enum Message {
     /// on or went off — the `adjust` status topic. While it is on every
     /// pinned image shows its four corner handles.
     AdjustMode(bool),
+    /// The compositor's overview drag-selection is carrying some of the
+    /// pinned images (`selection` status topic, `move <id>:<x>:<y> ...`):
+    /// where each is now, in virtual units. The compositor moves the rects
+    /// it was reported and draws the selection; this side just follows.
+    SelectionMove(Vec<(u64, f64, f64)>),
+    /// The group move released (`drop`): the positions stand, so save the
+    /// sidecar and report the list afresh.
+    SelectionDrop,
 }
 
 /// Which corner handle of an item.
@@ -90,12 +98,18 @@ fn handle_discs(item: &items::DesktopItem, diameter: f64) -> (f64, [(Corner, f64
 /// Smallest an image may be resized to, in virtual units.
 const MIN_ITEM_SIZE: f64 = 16.0;
 
-/// Subscribe to the compositor's `adjust` status topic and forward every
-/// push as a message, reconnecting with backoff until the socket is there
-/// (the compositor may come up after this service, and restarts at login).
-/// A new subscriber is sent the current state at once, so the very first
-/// line settles whether the handles should already be up.
-fn spawn_adjust_listener(sender: calloop::channel::Sender<Message>) {
+/// Subscribe to one of the compositor's status topics and forward every
+/// push through `parse` as a message, reconnecting with backoff until the
+/// socket is there (the compositor may come up after this service, and
+/// restarts at login). `adjust` is a state topic: a new subscriber is sent
+/// the current state at once, so the very first line settles whether the
+/// handles should already be up. `selection` is one-shot: its lines come
+/// only while a group move is carrying this client's images.
+fn spawn_topic_listener(
+    topic: &'static str,
+    sender: calloop::channel::Sender<Message>,
+    parse: fn(&str) -> Option<Message>,
+) {
     use std::io::{BufRead, Write};
     std::thread::spawn(move || {
         let mut retry_s = 1u64;
@@ -109,14 +123,15 @@ fn spawn_adjust_listener(sender: calloop::channel::Sender<Message>) {
                 }
             };
             if let Ok(mut stream) = std::os::unix::net::UnixStream::connect(&path) {
-                if stream.write_all(b"adjust\n").is_ok() {
+                if stream.write_all(format!("{topic}\n").as_bytes()).is_ok() {
                     let mut reader = std::io::BufReader::new(stream);
                     let mut line = String::new();
                     while reader.read_line(&mut line).map(|n| n > 0).unwrap_or(false) {
                         retry_s = 1;
-                        let on = line.trim() == "on";
-                        if sender.send(Message::AdjustMode(on)).is_err() {
-                            return;
+                        if let Some(msg) = parse(line.trim()) {
+                            if sender.send(msg).is_err() {
+                                return;
+                            }
                         }
                         line.clear();
                     }
@@ -128,6 +143,72 @@ fn spawn_adjust_listener(sender: calloop::channel::Sender<Message>) {
     });
 }
 
+/// A `selection` topic line: `move <id>:<x>:<y> ...` or `drop`. Anything
+/// else — or a `move` with nothing parseable on it — is ignored.
+fn parse_selection_line(line: &str) -> Option<Message> {
+    let mut words = line.split_whitespace();
+    match words.next()? {
+        "drop" => Some(Message::SelectionDrop),
+        "move" => {
+            let moves: Vec<(u64, f64, f64)> = words
+                .filter_map(|tok| {
+                    let mut f = tok.split(':');
+                    let id = f.next()?.parse::<u64>().ok()?;
+                    let x = f.next()?.parse::<f64>().ok()?;
+                    let y = f.next()?.parse::<f64>().ok()?;
+                    Some((id, x, y))
+                })
+                .collect();
+            if moves.is_empty() {
+                None
+            } else {
+                Some(Message::SelectionMove(moves))
+            }
+        }
+        _ => None,
+    }
+}
+
+/// The thread that tells the compositor what is pinned (`grid-items`, the
+/// whole list on every change — see `GridApp::report_items`). Its own
+/// thread because the control socket is request/reply and the reply waits
+/// on the compositor's main loop, which must never stall the paint loop
+/// here; a channel because reports must land in order. A report that
+/// cannot be delivered (the compositor not up yet) is retried with backoff,
+/// and only the LATEST pending report is ever sent — a stale list is worse
+/// than a late one.
+fn spawn_reporter() -> std::sync::mpsc::Sender<String> {
+    let (tx, rx) = std::sync::mpsc::channel::<String>();
+    std::thread::spawn(move || {
+        let mut retry_s = 1u64;
+        let mut pending: Option<String> = None;
+        loop {
+            // Block for the next report unless one is waiting to be
+            // retried, in which case just drain whatever is newer.
+            if pending.is_none() {
+                match rx.recv() {
+                    Ok(line) => pending = Some(line),
+                    Err(_) => return,
+                }
+            }
+            while let Ok(newer) = rx.try_recv() {
+                pending = Some(newer);
+            }
+            let line = pending.take().unwrap_or_default();
+            match cce_ui::ipc::send_command("cce", &line) {
+                Ok(_) => retry_s = 1,
+                Err(e) => {
+                    log::debug!("[items] report not delivered ({e}); retrying in {retry_s}s");
+                    pending = Some(line);
+                    std::thread::sleep(std::time::Duration::from_secs(retry_s));
+                    retry_s = (retry_s * 2).min(30);
+                }
+            }
+        }
+    });
+    tx
+}
+
 /// The world region the current buffer must cover, as told by the
 /// compositor: virtual origin/size and surface px per virtual unit.
 #[derive(Debug, Clone, Copy, PartialEq)]
@@ -146,6 +227,10 @@ struct GridApp {
     items: Vec<(items::DesktopItem, Option<u32>)>,
     /// Worker threads post finished drops back through this.
     sender: calloop::channel::Sender<Message>,
+    /// The `grid-items` reports go out through this (`spawn_reporter`).
+    reporter: std::sync::mpsc::Sender<String>,
+    /// The next `DesktopItem::id` to hand out.
+    next_id: u64,
     /// The item being dragged, and where inside it the pointer grabbed —
     /// held in VIRTUAL units so the drag survives a pan or zoom mid-gesture.
     dragging: Option<Drag>,
@@ -593,6 +678,31 @@ impl GridApp {
         let model: Vec<items::DesktopItem> = self.items.iter().map(|(i, _)| i.clone()).collect();
         items::save(&model);
     }
+
+    /// Name an item for the compositor's benefit (`DesktopItem::id`).
+    fn assign_id(&mut self, item: &mut items::DesktopItem) {
+        self.next_id += 1;
+        item.id = self.next_id;
+    }
+
+    /// Tell the compositor what is pinned and where: `grid-items
+    /// <id>:<x>:<y>:<w>:<h> ...`, virtual units, in draw order (last on
+    /// top), the whole list every time. It is what lets the overview
+    /// drag-selection pick images up beside windows and carry them in a
+    /// group move — the compositor otherwise knows the images only as this
+    /// surface's input region. Sent on every change to the list or to a
+    /// rect this client made itself; the compositor moves its own copy
+    /// during a group move and hears the settled list on `drop`.
+    fn report_items(&self) {
+        let mut line = String::from("grid-items");
+        for (item, _) in self.items.iter() {
+            line.push_str(&format!(
+                " {}:{:.2}:{:.2}:{:.2}:{:.2}",
+                item.id, item.x, item.y, item.w, item.h
+            ));
+        }
+        let _ = self.reporter.send(line);
+    }
 }
 
 impl Application for GridApp {
@@ -602,11 +712,16 @@ impl Application for GridApp {
         _qh: &QueueHandle<EngineState<Self>>,
         _sender: calloop::channel::Sender<Self::Message>,
     ) -> Self {
-        spawn_adjust_listener(_sender.clone());
-        Self {
+        spawn_topic_listener("adjust", _sender.clone(), |line| {
+            Some(Message::AdjustMode(line == "on"))
+        });
+        spawn_topic_listener("selection", _sender.clone(), parse_selection_line);
+        let mut app = Self {
             patch: None,
-            items: items::load().into_iter().map(|i| (i, None)).collect(),
+            items: Vec::new(),
             sender: _sender,
+            reporter: spawn_reporter(),
+            next_id: 0,
             dragging: None,
             adjust: false,
             hover_item: None,
@@ -617,7 +732,13 @@ impl Application for GridApp {
             applied_relief: None,
             base_depth: None,
             base_height: None,
+        };
+        for mut item in items::load() {
+            app.assign_id(&mut item);
+            app.items.push((item, None));
         }
+        app.report_items();
+        app
     }
 
     fn settings(&self) -> WindowSettings {
@@ -641,11 +762,35 @@ impl Application for GridApp {
     }
 
     fn update(&mut self, msg: Self::Message, needs_rebuild: &mut bool, _exit: &mut bool) {
-        // Rare, and each changes more than one item's rect (a new texture, a
-        // reordered list, every handle): not worth a rect.
-        self.damage = Damage::Full;
+        // A group move step is one pointer event's worth of motion and
+        // records the rects it touched, like a drag of this client's own.
+        // The rest are rare, and each changes more than one item's rect (a
+        // new texture, a reordered list, every handle): not worth a rect.
+        if !matches!(msg, Message::SelectionMove(_)) {
+            self.damage = Damage::Full;
+        }
         match msg {
-            Message::ItemReady { item, pixels, px_w, px_h } => {
+            Message::SelectionMove(moves) => {
+                for (id, x, y) in moves {
+                    let Some(index) = self.items.iter().position(|(i, _)| i.id == id) else {
+                        continue;
+                    };
+                    self.touch(index);
+                    let (item, _) = &mut self.items[index];
+                    item.x = x;
+                    item.y = y;
+                    self.touch(index);
+                }
+                *needs_rebuild = true;
+            }
+            Message::SelectionDrop => {
+                self.save_items();
+                self.report_items();
+                log::info!("[items] group move dropped; sidecar saved");
+                *needs_rebuild = true;
+            }
+            Message::ItemReady { mut item, pixels, px_w, px_h } => {
+                self.assign_id(&mut item);
                 let id = cce_ui::vk::upload_rgba(pixels, px_w, px_h);
                 log::info!(
                     "[items] pinned {} at ({:.0}, {:.0})",
@@ -658,6 +803,7 @@ impl Application for GridApp {
                 let model: Vec<items::DesktopItem> =
                     self.items.iter().map(|(i, _)| i.clone()).collect();
                 items::save(&model);
+                self.report_items();
                 *needs_rebuild = true;
             }
             Message::RemoveItem(path) => {
@@ -681,6 +827,7 @@ impl Application for GridApp {
                 // 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());
+                self.report_items();
                 *needs_rebuild = true;
             }
             Message::AdjustMode(on) => {
@@ -801,6 +948,7 @@ impl Application for GridApp {
             let h = px_h as f64 * fit;
             let item = items::DesktopItem {
                 path,
+                id: 0,
                 // Centred on the drop point.
                 x: vx - w / 2.0,
                 y: vy - h / 2.0,
@@ -1021,6 +1169,7 @@ impl Application for GridApp {
                 if let Some(rs) = self.resizing.take() {
                     if rs.moved {
                         self.save_items();
+                        self.report_items();
                         if let Some((item, _)) = self.items.get(rs.index) {
                             log::info!(
                                 "[items] resized {} to {:.0}x{:.0}",
@@ -1046,6 +1195,9 @@ impl Application for GridApp {
                             );
                         }
                     }
+                    // The press raised the item even if it never moved,
+                    // and the compositor's hit test wants the new order.
+                    self.report_items();
                 }
             }
         }