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

commit72b3574d2092bff76d89147468c3ac866bc0a319
parent301735b9d2
authorLucas Galante <[email protected]>
date2026-09-18 13:01
feat(items): corner resize handles on pinned images in adjust mode

While the compositor's window-adjust mode is on (its new `adjust` status
topic, subscribed from a std thread with backoff), every desktop image
draws four corner discs in the windows' handle colours and diameter
(style.surface.border color_focused / color_hover / handle_width), the
hovered one lit. A press on a disc starts a proportional resize — the
mean of the two edge ratios the drag asks for, anchored on the opposite
corner, floor 16 units — saved to the sidecar on release; a press on the
body still moves the image.

Verified in a shadow: handles appear on Super, hover lights a disc, a
bottom-right drag of +60,+40 took a 300x200 image to 360x240 with its
top-left fixed, a body drag still moved it, Super up cleared the discs.

Co-Authored-By: Claude Fable 5.1 <[email protected]>

 CLAUDE.md   |  14 ++++
 src/main.rs | 261 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
 2 files changed, 271 insertions(+), 4 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index e324f30..a630494 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -77,6 +77,20 @@ popup at `ccectl pointer-location`, whose reply comes back as
 that menu blocks on its own thread, and the list can be reordered by a drag or
 grown by a drop while it is open.
 
+**Resize handles.** The compositor's `adjust` status topic (`on`/`off` as
+window-adjust mode — overview, or Super held — comes and goes) is the one
+thing this client subscribes to (`spawn_adjust_listener`, a plain std
+thread on the status socket, reconnecting with backoff): it never holds
+keyboard focus, so it cannot read Super for itself. While on, every item
+draws four corner discs (`handle_discs`) in the same `style.surface.border`
+colours and `handle_width` as the windows' handles, the hovered one lit;
+a press on a disc starts a `Resize`, which scales the image
+PROPORTIONALLY (the mean of the two edge ratios the drag asks for),
+anchored on the opposite corner, and saves the sidecar on release. A press
+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.
+
 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/main.rs b/src/main.rs
index f5fa0d6..6a2a81d 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -37,6 +37,95 @@ enum Message {
     /// 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 compositor's window-adjust mode (overview, or Super held) came
+    /// on or went off — the `adjust` status topic. While it is on every
+    /// pinned image shows its four corner handles.
+    AdjustMode(bool),
+}
+
+/// Which corner handle of an item.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+enum Corner {
+    TopLeft,
+    TopRight,
+    BottomLeft,
+    BottomRight,
+}
+
+impl Corner {
+    const ALL: [Corner; 4] = [Corner::TopLeft, Corner::TopRight, Corner::BottomLeft, Corner::BottomRight];
+
+    /// Which way the corner faces: +1 on the right/bottom edge, -1 on the
+    /// left/top. Dragging the corner by (dx, dy) grows the item by
+    /// (sx*dx, sy*dy).
+    fn sign(self) -> (f64, f64) {
+        match self {
+            Corner::TopLeft => (-1.0, -1.0),
+            Corner::TopRight => (1.0, -1.0),
+            Corner::BottomLeft => (-1.0, 1.0),
+            Corner::BottomRight => (1.0, 1.0),
+        }
+    }
+}
+
+/// An item's corner handles in VIRTUAL units: the disc radius, and each
+/// corner's centre. A disc is tangent to both of its edges — the same
+/// placement the compositor gives a window's corner handles when the
+/// silhouette has no corner radius — and never wider than a quarter of the
+/// image, so a thumbnail is not all handle.
+fn handle_discs(item: &items::DesktopItem, diameter: f64) -> (f64, [(Corner, f64, f64); 4]) {
+    let r = (diameter / 2.0).min(item.w / 4.0).min(item.h / 4.0).max(1.0);
+    let (x0, y0, x1, y1) = (item.x + r, item.y + r, item.x + item.w - r, item.y + item.h - r);
+    (
+        r,
+        [
+            (Corner::TopLeft, x0, y0),
+            (Corner::TopRight, x1, y0),
+            (Corner::BottomLeft, x0, y1),
+            (Corner::BottomRight, x1, y1),
+        ],
+    )
+}
+
+/// 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>) {
+    use std::io::{BufRead, Write};
+    std::thread::spawn(move || {
+        let mut retry_s = 1u64;
+        loop {
+            let path = {
+                let primary = cce_ui::ipc::socket_path("cce-status-interface");
+                if std::path::Path::new(&primary).exists() {
+                    primary
+                } else {
+                    cce_ui::ipc::socket_path("cce-status")
+                }
+            };
+            if let Ok(mut stream) = std::os::unix::net::UnixStream::connect(&path) {
+                if stream.write_all(b"adjust\n").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;
+                        }
+                        line.clear();
+                    }
+                }
+            }
+            std::thread::sleep(std::time::Duration::from_secs(retry_s));
+            retry_s = (retry_s * 2).min(30);
+        }
+    });
 }
 
 /// The world region the current buffer must cover, as told by the
@@ -60,6 +149,13 @@ struct GridApp {
     /// 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>,
+    /// The compositor's window-adjust mode (`adjust` status topic): while
+    /// on, every item shows its corner handles and a press on one resizes.
+    adjust: bool,
+    /// The corner handle under the pointer, drawn in the hover colour.
+    hover: Option<(usize, Corner)>,
+    /// An in-flight corner resize, delta-driven like `Drag`.
+    resizing: Option<Resize>,
     /// 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.
@@ -112,6 +208,16 @@ struct Drag {
     moved: bool,
 }
 
+/// An in-flight corner resize. Delta-driven for the same reason `Drag` is:
+/// the patch can be re-issued under the pointer mid-gesture.
+struct Resize {
+    index: usize,
+    corner: Corner,
+    last_pos: (f32, f32),
+    last_origin: (f64, f64),
+    moved: bool,
+}
+
 /// The `line_relief` key's three states — see [`Style::line_relief`].
 enum LineRelief {
     /// Key absent: follow the DE-wide relief material.
@@ -138,6 +244,12 @@ struct Style {
     /// `(relief)` value, or absent = the DE-wide material. Negative integers
     /// mean unset.
     line_relief: LineRelief,
+    /// The image resize handles, from the same `border` keys the windows'
+    /// handles use: diameter in virtual units (logical px at zoom 1), and
+    /// the resting and hovered colours.
+    handle_width: f64,
+    handle_color: [f32; 4],
+    handle_hover_color: [f32; 4],
 }
 
 fn style() -> Style {
@@ -183,6 +295,13 @@ fn style() -> Style {
                 v => LineRelief::Width(v as f64),
             },
         },
+        handle_width: cce_ui::config::get_f32("/style/surface/border/handle_width", 32.0).max(4.0) as f64,
+        handle_color: linear(
+            get_color("/style/surface/border/color_focused").unwrap_or([0.478, 0.635, 0.969, 1.0]),
+        ),
+        handle_hover_color: linear(
+            get_color("/style/surface/border/color_hover").unwrap_or([0.659, 0.780, 0.980, 1.0]),
+        ),
     }
 }
 
@@ -358,7 +477,7 @@ 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() {
+        for (index, (item, id)) in self.items.iter().enumerate() {
             let Some(id) = *id else { continue };
             let rect = Rect {
                 x: ((item.x - p.x) * s) as f32,
@@ -377,10 +496,53 @@ impl GridApp {
                 continue;
             }
             pc.image(id, rect, 1.0);
+            // Window-adjust mode: the four corner handles, in the same
+            // colours as the windows' handles, the hovered one lit. 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.
+            if self.adjust {
+                let (r, discs) = handle_discs(item, st.handle_width);
+                for (corner, cx, cy) in discs {
+                    let color = if self.hover == Some((index, corner)) {
+                        st.handle_hover_color
+                    } else {
+                        st.handle_color
+                    };
+                    pc.circle(((cx - p.x) * s) as f32, ((cy - p.y) * s) as f32, (r * s) as f32, color);
+                }
+            }
         }
     }
 }
 
+impl GridApp {
+    /// The corner handle under a virtual-canvas point, topmost item first,
+    /// with a unit of slack around the disc's antialiased rim.
+    fn corner_at(&self, vx: f64, vy: f64) -> Option<(usize, Corner)> {
+        if !self.adjust {
+            return None;
+        }
+        let diameter = style().handle_width;
+        for (index, (item, _)) in self.items.iter().enumerate().rev() {
+            let (r, discs) = handle_discs(item, diameter);
+            let reach = (r + 1.0) * (r + 1.0);
+            for (corner, cx, cy) in discs {
+                let (dx, dy) = (vx - cx, vy - cy);
+                if dx * dx + dy * dy <= reach {
+                    return Some((index, corner));
+                }
+            }
+        }
+        None
+    }
+
+    fn save_items(&self) {
+        let model: Vec<items::DesktopItem> = self.items.iter().map(|(i, _)| i.clone()).collect();
+        items::save(&model);
+    }
+}
+
 impl Application for GridApp {
     type Message = Message;
 
@@ -388,11 +550,15 @@ impl Application for GridApp {
         _qh: &QueueHandle<EngineState<Self>>,
         _sender: calloop::channel::Sender<Self::Message>,
     ) -> Self {
+        spawn_adjust_listener(_sender.clone());
         Self {
             patch: None,
             items: items::load().into_iter().map(|i| (i, None)).collect(),
             sender: _sender,
             dragging: None,
+            adjust: false,
+            hover: None,
+            resizing: None,
             applied_relief: None,
             base_depth: None,
             base_height: None,
@@ -440,10 +606,12 @@ impl Application for GridApp {
                 let Some(pos) = self.items.iter().position(|(i, _)| i.path == path) else {
                     return;
                 };
-                // A drag on the removed item cannot outlive it.
+                // A drag or resize on the removed item cannot outlive it.
                 if self.dragging.is_some() {
                     self.dragging = None;
                 }
+                self.resizing = None;
+                self.hover = None;
                 let (item, id) = self.items.remove(pos);
                 if let Some(id) = id {
                     cce_ui::vk::free_image(id);
@@ -456,6 +624,18 @@ impl Application for GridApp {
                 log::info!("[items] removed {} from the desktop", item.path.display());
                 *needs_rebuild = true;
             }
+            Message::AdjustMode(on) => {
+                if self.adjust == on {
+                    return;
+                }
+                self.adjust = on;
+                if !on {
+                    // A resize in flight finishes on its release; only the
+                    // highlight goes with the handles.
+                    self.hover = None;
+                }
+                *needs_rebuild = true;
+            }
         }
     }
 
@@ -600,12 +780,61 @@ impl Application for GridApp {
     }
 
     fn handle_pointer_move(&mut self, pos: LogicalPosition, needs_rebuild: &mut bool) {
-        let Some(drag) = self.dragging.as_mut() else { return };
         let Some(p) = self.patch else { return };
         if p.scale <= 0.0 {
             return;
         }
         let origin = (p.x, p.y);
+        let s = p.surface_per_virtual();
+
+        if let Some(rs) = self.resizing.as_mut() {
+            let last_pos = rs.last_pos;
+            rs.last_pos = (pos.x, pos.y);
+            if rs.last_origin != origin {
+                rs.last_origin = origin;
+                return;
+            }
+            let dx = (pos.x - last_pos.0) as f64 / s;
+            let dy = (pos.y - last_pos.1) as f64 / s;
+            if dx == 0.0 && dy == 0.0 {
+                return;
+            }
+            rs.moved = true;
+            let (index, corner) = (rs.index, rs.corner);
+            if let Some((item, _)) = self.items.get_mut(index) {
+                // Corners scale the image PROPORTIONALLY — an image stretched
+                // out of its aspect is a different picture — by the mean of
+                // the two edge ratios the drag asks for, anchored on the
+                // opposite corner.
+                let (sx, sy) = corner.sign();
+                let kw = (item.w + sx * dx) / item.w.max(1.0);
+                let kh = (item.h + sy * dy) / item.h.max(1.0);
+                let k = ((kw + kh) / 2.0).max(MIN_ITEM_SIZE / item.w.max(item.h).max(1.0));
+                let (old_w, old_h) = (item.w, item.h);
+                item.w = (old_w * k).max(MIN_ITEM_SIZE);
+                item.h = old_h * (item.w / old_w.max(1.0));
+                if sx < 0.0 {
+                    item.x += old_w - item.w;
+                }
+                if sy < 0.0 {
+                    item.y += old_h - item.h;
+                }
+                *needs_rebuild = true;
+            }
+            return;
+        }
+
+        let Some(drag) = self.dragging.as_mut() else {
+            // Idle motion: light the handle under the pointer.
+            let vx = p.x + pos.x as f64 / s;
+            let vy = p.y + pos.y as f64 / s;
+            let hover = self.corner_at(vx, vy);
+            if hover != self.hover {
+                self.hover = hover;
+                *needs_rebuild = true;
+            }
+            return;
+        };
         let last_pos = drag.last_pos;
         drag.last_pos = (pos.x, pos.y);
         if drag.last_origin != origin {
@@ -614,7 +843,6 @@ impl Application for GridApp {
             drag.last_origin = origin;
             return;
         }
-        let s = p.surface_per_virtual();
         let dx = (pos.x - last_pos.0) as f64 / s;
         let dy = (pos.y - last_pos.1) as f64 / s;
         if dx == 0.0 && dy == 0.0 {
@@ -673,6 +901,17 @@ impl Application for GridApp {
                 let s = p.surface_per_virtual();
                 let vx = p.x + pos.x as f64 / s;
                 let vy = p.y + pos.y as f64 / s;
+                // A corner handle (adjust mode only) resizes; the body moves.
+                if let Some((index, corner)) = self.corner_at(vx, vy) {
+                    self.resizing = Some(Resize {
+                        index,
+                        corner,
+                        last_pos: (pos.x, pos.y),
+                        last_origin: (p.x, p.y),
+                        moved: false,
+                    });
+                    return None;
+                }
                 // Last drawn is on top, so search backwards and take the
                 // first hit.
                 let hit = self.items.iter().rposition(|(i, _)| {
@@ -691,6 +930,20 @@ impl Application for GridApp {
                 *needs_rebuild = true;
             }
             ElementState::Released => {
+                if let Some(rs) = self.resizing.take() {
+                    if rs.moved {
+                        self.save_items();
+                        if let Some((item, _)) = self.items.get(rs.index) {
+                            log::info!(
+                                "[items] resized {} to {:.0}x{:.0}",
+                                item.path.display(),
+                                item.w,
+                                item.h
+                            );
+                        }
+                    }
+                    return None;
+                }
                 if let Some(drag) = self.dragging.take() {
                     if drag.moved {
                         let model: Vec<items::DesktopItem> =