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

src/main.rs (42K)

   1 //! cce-grid — the desktop grid, drawn by cce-ui.
   2 //!
   3 //! The compositor world-anchors this surface to the virtual desktop and
   4 //! pans/zooms it per frame exactly like window content, so this app is never
   5 //! in the camera loop. It renders only when the compositor hands it a patch
   6 //! (`Application::grid_patch`): a virtual-desktop rectangle plus a
   7 //! px-per-virtual-unit scale. Everything here is therefore a pure function
   8 //! of (patch, style config) — no camera state, no timers.
   9 //!
  10 //! The look comes from the same config keys the compositor's fallback grid
  11 //! reads (`style.surface.desktop.*`, root plate corner radius): flat
  12 //! rounded cells, with the relief on the LINES — the gap rails read as
  13 //! raised grout (per-cell half-gap-expanded `Recess` rings that abut at
  14 //! the rail centerlines), while every cell floor stays flat.
  15 
  16 use wayland_client::QueueHandle;
  17 
  18 use cce_ui::engine::{Application, EngineState, LogicalPosition, LogicalSize, WindowSettings};
  19 use cce_ui::scene::layout::Rect;
  20 use cce_ui::scene::paint::{DisplayList, PaintCtx};
  21 use cce_ui::widget::{ElementState, KeyEvent, MouseButton, MouseScrollDelta};
  22 
  23 mod items;
  24 
  25 #[derive(Debug, Clone)]
  26 enum Message {
  27     /// A dropped image finished fetching, saving and decoding on its worker
  28     /// thread. Carried as pixels rather than an image id because the GPU
  29     /// upload has to happen on the main loop.
  30     ItemReady {
  31         item: items::DesktopItem,
  32         pixels: Vec<u8>,
  33         px_w: u32,
  34         px_h: u32,
  35     },
  36     /// The context menu closed on "remove". Carries the path rather than an
  37     /// index: the menu is modal on its own thread, and the list can be
  38     /// reordered by a drag (or grown by a drop) while it is open.
  39     RemoveItem(std::path::PathBuf),
  40     /// The compositor's window-adjust mode (overview, or Super held) came
  41     /// on or went off — the `adjust` status topic. While it is on every
  42     /// pinned image shows its four corner handles.
  43     AdjustMode(bool),
  44 }
  45 
  46 /// Which corner handle of an item.
  47 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
  48 enum Corner {
  49     TopLeft,
  50     TopRight,
  51     BottomLeft,
  52     BottomRight,
  53 }
  54 
  55 impl Corner {
  56     const ALL: [Corner; 4] = [Corner::TopLeft, Corner::TopRight, Corner::BottomLeft, Corner::BottomRight];
  57 
  58     /// Which way the corner faces: +1 on the right/bottom edge, -1 on the
  59     /// left/top. Dragging the corner by (dx, dy) grows the item by
  60     /// (sx*dx, sy*dy).
  61     fn sign(self) -> (f64, f64) {
  62         match self {
  63             Corner::TopLeft => (-1.0, -1.0),
  64             Corner::TopRight => (1.0, -1.0),
  65             Corner::BottomLeft => (-1.0, 1.0),
  66             Corner::BottomRight => (1.0, 1.0),
  67         }
  68     }
  69 }
  70 
  71 /// An item's corner handles in VIRTUAL units: the disc radius, and each
  72 /// corner's centre. A disc is tangent to both of its edges — the same
  73 /// placement the compositor gives a window's corner handles when the
  74 /// silhouette has no corner radius — and never wider than a quarter of the
  75 /// image, so a thumbnail is not all handle.
  76 fn handle_discs(item: &items::DesktopItem, diameter: f64) -> (f64, [(Corner, f64, f64); 4]) {
  77     let r = (diameter / 2.0).min(item.w / 4.0).min(item.h / 4.0).max(1.0);
  78     let (x0, y0, x1, y1) = (item.x + r, item.y + r, item.x + item.w - r, item.y + item.h - r);
  79     (
  80         r,
  81         [
  82             (Corner::TopLeft, x0, y0),
  83             (Corner::TopRight, x1, y0),
  84             (Corner::BottomLeft, x0, y1),
  85             (Corner::BottomRight, x1, y1),
  86         ],
  87     )
  88 }
  89 
  90 /// Smallest an image may be resized to, in virtual units.
  91 const MIN_ITEM_SIZE: f64 = 16.0;
  92 
  93 /// Subscribe to the compositor's `adjust` status topic and forward every
  94 /// push as a message, reconnecting with backoff until the socket is there
  95 /// (the compositor may come up after this service, and restarts at login).
  96 /// A new subscriber is sent the current state at once, so the very first
  97 /// line settles whether the handles should already be up.
  98 fn spawn_adjust_listener(sender: calloop::channel::Sender<Message>) {
  99     use std::io::{BufRead, Write};
 100     std::thread::spawn(move || {
 101         let mut retry_s = 1u64;
 102         loop {
 103             let path = {
 104                 let primary = cce_ui::ipc::socket_path("cce-status-interface");
 105                 if std::path::Path::new(&primary).exists() {
 106                     primary
 107                 } else {
 108                     cce_ui::ipc::socket_path("cce-status")
 109                 }
 110             };
 111             if let Ok(mut stream) = std::os::unix::net::UnixStream::connect(&path) {
 112                 if stream.write_all(b"adjust\n").is_ok() {
 113                     let mut reader = std::io::BufReader::new(stream);
 114                     let mut line = String::new();
 115                     while reader.read_line(&mut line).map(|n| n > 0).unwrap_or(false) {
 116                         retry_s = 1;
 117                         let on = line.trim() == "on";
 118                         if sender.send(Message::AdjustMode(on)).is_err() {
 119                             return;
 120                         }
 121                         line.clear();
 122                     }
 123                 }
 124             }
 125             std::thread::sleep(std::time::Duration::from_secs(retry_s));
 126             retry_s = (retry_s * 2).min(30);
 127         }
 128     });
 129 }
 130 
 131 /// The world region the current buffer must cover, as told by the
 132 /// compositor: virtual origin/size and surface px per virtual unit.
 133 #[derive(Debug, Clone, Copy)]
 134 struct Patch {
 135     x: f64,
 136     y: f64,
 137     w: f64,
 138     h: f64,
 139     scale: f64,
 140 }
 141 
 142 struct GridApp {
 143     patch: Option<Patch>,
 144     /// Images pinned to the canvas, paired with their uploaded texture id
 145     /// (`None` until the renderer exists — see `renderer_init`).
 146     items: Vec<(items::DesktopItem, Option<u32>)>,
 147     /// Worker threads post finished drops back through this.
 148     sender: calloop::channel::Sender<Message>,
 149     /// The item being dragged, and where inside it the pointer grabbed —
 150     /// held in VIRTUAL units so the drag survives a pan or zoom mid-gesture.
 151     dragging: Option<Drag>,
 152     /// The compositor's window-adjust mode (`adjust` status topic): while
 153     /// on, the item under the pointer shows its corner handles and a press
 154     /// on one resizes.
 155     adjust: bool,
 156     /// The item under the pointer (body or handle) — the one that shows its
 157     /// handles, like the compositor's ring on the hovered window. Cleared by
 158     /// the off-screen move cce-ui synthesizes on pointer leave, so it drops
 159     /// the moment the pointer is on the background or a window.
 160     hover_item: Option<usize>,
 161     /// The corner handle under the pointer, drawn in the hover colour.
 162     hover: Option<(usize, Corner)>,
 163     /// An in-flight corner resize, delta-driven like `Drag`.
 164     resizing: Option<Resize>,
 165     /// The raw `(relief)` string currently installed process-wide (depth +
 166     /// wall profile LUT) — a change detector, so the registry is only
 167     /// touched when the config value actually changes.
 168     applied_relief: Option<String>,
 169     /// The DE-wide `bevel_depth` captured before the first spec override,
 170     /// restored if the key later reverts to a plain width or is removed.
 171     base_depth: Option<f32>,
 172     /// The DE-wide pinned carve height (0 = follow) while a `(relief)` value's
 173     /// `h=` is installed, restored when the key reverts.
 174     base_height: Option<f32>,
 175 }
 176 
 177 impl Patch {
 178     /// Surface-local px per virtual unit — `Patch::scale` itself.
 179     ///
 180     /// The grid surface is PINNED at buffer_scale 1 (cce-ui ignores scale
 181     /// events for grid apps; patch.scale is the sole resolution authority),
 182     /// so surface-local coordinates ARE buffer px at every output scale:
 183     /// pointer events arrive in that space and input regions are interpreted
 184     /// in it. The /ui division that used to live here calibrated against the
 185     /// compositor's old hit-test, which handed out raw layout offsets —
 186     /// numerically buffer/ui only at zoom 1 on the pow2 patch quantization —
 187     /// and at any other camera state it displaced the input region off the
 188     /// items (presses read as background) and tore the press position apart
 189     /// from the drag deltas (the flung-item bug). The compositor now speaks
 190     /// true surface coordinates, so the patch scale is used unmodified.
 191     fn surface_per_virtual(&self) -> f64 {
 192         self.scale
 193     }
 194 }
 195 
 196 /// An in-flight item drag.
 197 ///
 198 /// The item follows pointer DELTAS, not `patch + position`. The compositor can
 199 /// re-issue the grid patch at any moment — it did so on the very first motion
 200 /// event of a drag during testing, moving the patch origin by half a screen —
 201 /// and the pointer events in flight are still in the OLD surface's coordinate
 202 /// space, so an absolute mapping teleports the item by the origin delta. A
 203 /// delta is the same number in either space.
 204 struct Drag {
 205     index: usize,
 206     /// Previous pointer position, surface-local.
 207     last_pos: (f32, f32),
 208     /// Patch origin the previous position was measured against. When this
 209     /// changes, the incoming position is in a different space than the last
 210     /// one, so that step is used only to re-baseline.
 211     last_origin: (f64, f64),
 212     /// Set once the pointer actually travels, so a plain click does not
 213     /// rewrite the sidecar.
 214     moved: bool,
 215 }
 216 
 217 /// An in-flight corner resize. Delta-driven for the same reason `Drag` is:
 218 /// the patch can be re-issued under the pointer mid-gesture.
 219 struct Resize {
 220     index: usize,
 221     corner: Corner,
 222     last_pos: (f32, f32),
 223     last_origin: (f64, f64),
 224     moved: bool,
 225 }
 226 
 227 /// The `line_relief` key's three states — see [`Style::line_relief`].
 228 enum LineRelief {
 229     /// Key absent: follow the DE-wide relief material.
 230     Material,
 231     /// Plain integer: lip width in virtual units, 0 = no lip.
 232     Width(f64),
 233     /// A `(relief)` value: its own width/depth/profile, editable in place
 234     /// with `cce-relief --key style.surface.desktop.line_relief`. The raw
 235     /// string rides along as the change detector.
 236     Spec(String, cce_ui::relief_spec::ReliefSpec),
 237 }
 238 
 239 /// Style knobs, re-read per frame from the shared config (cheap: cce-ui
 240 /// caches the parse on mtime), with the same defaults the compositor uses.
 241 struct Style {
 242     cell_w: f64,
 243     cell_h: f64,
 244     gap_width: f64,
 245     cell_inset: f64,
 246     corner_radius: f64,
 247     gap_color: [f32; 4],
 248     cell_color: [f32; 4],
 249     /// Grid-line lip material: a plain integer width (0 = no lip), a full
 250     /// `(relief)` value, or absent = the DE-wide material. Negative integers
 251     /// mean unset.
 252     line_relief: LineRelief,
 253     /// The image resize handles, from the same `border` keys the windows'
 254     /// handles use: diameter in virtual units (logical px at zoom 1), and
 255     /// the resting and hovered colours.
 256     handle_width: f64,
 257     handle_color: [f32; 4],
 258     handle_hover_color: [f32; 4],
 259 }
 260 
 261 fn style() -> Style {
 262     use cce_ui::config::{get_color, get_i64};
 263     // get_color returns raw sRGB; the render pipeline (like every cce-ui
 264     // widget color) expects linear.
 265     let linear = |c: [f32; 4]| {
 266         let f = cce_ui::color::srgb_to_linear;
 267         [f(c[0]), f(c[1]), f(c[2]), c[3]]
 268     };
 269     Style {
 270         // Per-axis sizes; the legacy square grid_cell_size is the fallback
 271         // for both, mirroring the compositor's config resolution.
 272         cell_w: {
 273             let legacy = get_i64("/style/surface/desktop/grid_cell_size", 512);
 274             get_i64("/style/surface/desktop/grid_cell_width", legacy) as f64
 275         },
 276         cell_h: {
 277             let legacy = get_i64("/style/surface/desktop/grid_cell_size", 512);
 278             get_i64("/style/surface/desktop/grid_cell_height", legacy) as f64
 279         },
 280         gap_width: (get_i64("/style/surface/desktop/gap_width", 16).max(0)) as f64,
 281         cell_inset: get_i64("/style/surface/desktop/cell_fade_inset", 0) as f64,
 282         // The silhouette radius (cce-ui RFC Phase 7a spelling).
 283         corner_radius: get_i64("/style/surface/plate/root/corner_radius", 12) as f64,
 284         gap_color: linear(
 285             get_color("/style/surface/desktop/gap_color")
 286                 .unwrap_or([0.686, 0.796, 0.867, 1.0]),
 287         ),
 288         cell_color: linear(
 289             get_color("/style/surface/desktop/cell_color")
 290                 .unwrap_or([0.0, 0.0, 0.0, 1.0]),
 291         ),
 292         line_relief: match cce_ui::config::get_string("/style/surface/desktop/line_relief") {
 293             // A string value is a (relief) spec; an unparseable one reads
 294             // as unset rather than as some accidental width.
 295             Some(s) => match cce_ui::relief_spec::ReliefSpec::parse(&s) {
 296                 Some(spec) => LineRelief::Spec(s, spec),
 297                 None => LineRelief::Material,
 298             },
 299             None => match get_i64("/style/surface/desktop/line_relief", -1) {
 300                 v if v < 0 => LineRelief::Material,
 301                 v => LineRelief::Width(v as f64),
 302             },
 303         },
 304         handle_width: cce_ui::config::get_f32("/style/surface/border/handle_width", 32.0).max(4.0) as f64,
 305         handle_color: linear(
 306             get_color("/style/surface/border/color_focused").unwrap_or([0.478, 0.635, 0.969, 1.0]),
 307         ),
 308         handle_hover_color: linear(
 309             get_color("/style/surface/border/color_hover").unwrap_or([0.659, 0.780, 0.980, 1.0]),
 310         ),
 311     }
 312 }
 313 
 314 /// Never emit more cells than this per frame, whatever the patch/config says
 315 /// (a degenerate period must not turn into an unbounded display list).
 316 const MAX_CELLS: usize = 8192;
 317 
 318 impl GridApp {
 319     /// Install (or roll back) the process-wide material a `(relief)` value
 320     /// carries — depth into the style registry, profile into the wall LUT.
 321     /// This app draws nothing but the grid, so process-global IS
 322     /// per-feature; a change detector keeps it idempotent per frame.
 323     fn sync_relief_material(&mut self, line_relief: &LineRelief) {
 324         match line_relief {
 325             LineRelief::Spec(raw, spec) => {
 326                 if self.applied_relief.as_deref() == Some(raw.as_str()) {
 327                     return;
 328                 }
 329                 if self.base_depth.is_none() {
 330                     self.base_depth = Some(cce_ui::layout::bevel_depth());
 331                 }
 332                 if self.base_height.is_none() {
 333                     self.base_height = Some(cce_ui::layout::bevel_height().unwrap_or(0.0));
 334                 }
 335                 if let Ok(mut reg) = cce_ui::layout::get_style_registry().write() {
 336                     if let Some(d) = spec.light {
 337                         reg.set_float("bevel_depth", d);
 338                     }
 339                     // A pinned drop (`h=0.5mm`) is a length: stored as one,
 340                     // so it re-resolves if the display metric changes.
 341                     match spec.height {
 342                         Some(h) => reg.set_len("bevel_height", h),
 343                         None => {
 344                             if let Some(b) = self.base_height {
 345                                 reg.set_float("bevel_height", b);
 346                             }
 347                         }
 348                     }
 349                 }
 350                 cce_ui::layout::install_wall_profile_spec(spec.profile.as_deref());
 351                 self.applied_relief = Some(raw.clone());
 352             }
 353             _ if self.applied_relief.is_some() => {
 354                 // The key reverted to a plain width or vanished: back to
 355                 // the DE-wide material the registry still carries.
 356                 if let Ok(mut reg) = cce_ui::layout::get_style_registry().write() {
 357                     if let Some(d) = self.base_depth.take() {
 358                         reg.set_float("bevel_depth", d);
 359                     }
 360                     if let Some(h) = self.base_height.take() {
 361                         reg.set_float("bevel_height", h);
 362                     }
 363                 }
 364                 let global = cce_ui::layout::get_style_registry()
 365                     .read()
 366                     .ok()
 367                     .and_then(|reg| reg.get_string("bevel_profile_spec"));
 368                 cce_ui::layout::install_wall_profile_spec(global.as_deref());
 369                 self.applied_relief = None;
 370             }
 371             _ => {}
 372         }
 373     }
 374 
 375     fn paint(&mut self, pc: &mut PaintCtx, size: LogicalSize) {
 376         let Some(p) = self.patch else { return };
 377         if p.scale <= 0.0 {
 378             return;
 379         }
 380         let st = style();
 381         let period_x = st.cell_w + st.gap_width;
 382         let period_y = st.cell_h + st.gap_width;
 383         if period_x < 1.0 || period_y < 1.0 {
 384             return;
 385         }
 386 
 387         // The rail surface: the whole patch in gap color.
 388         pc.quad(
 389             Rect { x: 0.0, y: 0.0, width: size.width as f32, height: size.height as f32 },
 390             st.gap_color,
 391         );
 392 
 393         // Visible cell box within its period slot, in virtual units.
 394         let inset_x = st.cell_inset.clamp(0.0, (st.cell_w / 2.0 - 1.0).max(0.0));
 395         let inset_y = st.cell_inset.clamp(0.0, (st.cell_h / 2.0 - 1.0).max(0.0));
 396         let len_w = st.cell_w - 2.0 * inset_x;
 397         let len_h = st.cell_h - 2.0 * inset_y;
 398         let s = p.scale;
 399         // Span-widened like every other corner in the DE (window clips,
 400         // fallback cells, cce-ui plates): at corner_shape > 2 a raw-radius
 401         // superellipse hugs the corner and reads nearly square, and a tiled
 402         // window's widened arc must land exactly on its cell's. Clamped to a
 403         // quarter sweep like the compositor's widen_corner_radius.
 404         let cell_px = len_w.min(len_h) * s;
 405         let radius = ((st.corner_radius * s)
 406             * cce_ui::layout::corner_span_factor() as f64)
 407             .min(cell_px / 2.0) as f32;
 408         // The relief lives on the LINES, never the cells: each cell's recess
 409         // rect is expanded past the cell edge so the wall sits in the rail
 410         // band, rolling down from the rail face to the cell floor. The roll
 411         // is the ROOT_PLATE-EDGE treatment — `layout::bevel_width` clamped to
 412         // a fraction of the rail, the widget convention — so the rail reads
 413         // as a flat plate face with a narrow lip where it meets each sunken
 414         // cell. (A half-gap-wide wall turned the whole rail into a ramp and
 415         // read far heavier than any plate edge in the toolkit.) Rings stay
 416         // inside their own half-rail, so neighbors never overlap; the outer
 417         // radius offsets by the roll to stay concentric with the cell arc.
 418         // style.surface.desktop.line_relief overrides the roll: a plain
 419         // width (0 = no lip), or a full (relief) value carrying its own
 420         // width/depth/profile. Explicit widths clamp to the half-rail (the
 421         // rings' geometric budget); the material default keeps the tighter
 422         // root plate clamp.
 423         self.sync_relief_material(&st.line_relief);
 424         let roll = match &st.line_relief {
 425             LineRelief::Material => {
 426                 (cce_ui::layout::bevel_width() as f64).min(st.gap_width * 0.25)
 427             }
 428             LineRelief::Width(w) => w.min(st.gap_width / 2.0),
 429             LineRelief::Spec(_, spec) => (spec.width as f64).min(st.gap_width / 2.0),
 430         }
 431         .max(0.0)
 432             * s;
 433         let lip = roll >= 0.5;
 434 
 435         // One extra ring of cells beyond the patch: a border cell outside the
 436         // patch still owns the inner half of the boundary rail's shading.
 437         let col0 = (p.x / period_x).floor() as i64 - 1;
 438         let col1 = ((p.x + p.w) / period_x).ceil() as i64 + 1;
 439         let row0 = (p.y / period_y).floor() as i64 - 1;
 440         let row1 = ((p.y + p.h) / period_y).ceil() as i64 + 1;
 441         let mut cells = 0usize;
 442         for col in col0..col1 {
 443             for row in row0..row1 {
 444                 if cells >= MAX_CELLS {
 445                     return;
 446                 }
 447                 cells += 1;
 448                 let vx = col as f64 * period_x + inset_x;
 449                 let vy = row as f64 * period_y + inset_y;
 450                 let rect = Rect {
 451                     x: ((vx - p.x) * s) as f32,
 452                     y: ((vy - p.y) * s) as f32,
 453                     width: (len_w * s) as f32,
 454                     height: (len_h * s) as f32,
 455                 };
 456                 pc.rounded_rect(rect, radius, (true, true, true, true), st.cell_color);
 457             }
 458         }
 459         // The relief on the LINES is ONE primitive for the whole patch: a
 460         // lattice carve, folded per pixel to the nearest cell, so the rail
 461         // between two cells and the crossing where four meet are a single
 462         // profile evaluation — true mitres. This replaced one recess ring per
 463         // cell: N free overlays whose rounded corners stacked in colour space
 464         // at every crossing and read as overlapping effects, and whose ring
 465         // walls (straddling a boundary inflated by the roll) overlapped each
 466         // other down the rail centre whenever the roll exceeded a quarter
 467         // gap. The wall runs from each cell's edge outward over `roll`.
 468         if lip {
 469             let origin = (
 470                 ((inset_x + len_w * 0.5 - p.x) * s) as f32,
 471                 ((inset_y + len_h * 0.5 - p.y) * s) as f32,
 472             );
 473             pc.lattice(
 474                 Rect { x: 0.0, y: 0.0, width: size.width as f32, height: size.height as f32 },
 475                 ((period_x * s) as f32, (period_y * s) as f32),
 476                 origin,
 477                 ((len_w * s) as f32, (len_h * s) as f32),
 478                 radius,
 479                 (roll as f32).max(1.0),
 480             );
 481         }
 482 
 483         // Pinned images sit ON the canvas, so they are placed by the same
 484         // world->patch mapping as the cells and drawn after them. The whole
 485         // grid surface is below every window, so an item never covers an app.
 486         for (index, (item, id)) in self.items.iter().enumerate() {
 487             let Some(id) = *id else { continue };
 488             let rect = Rect {
 489                 x: ((item.x - p.x) * s) as f32,
 490                 y: ((item.y - p.y) * s) as f32,
 491                 width: (item.w * s) as f32,
 492                 height: (item.h * s) as f32,
 493             };
 494             // Cull off-patch items: at a far zoom-out the patch can hold
 495             // hundreds of squares, and an image that is not on it costs a
 496             // draw for nothing.
 497             if rect.x + rect.width < 0.0
 498                 || rect.y + rect.height < 0.0
 499                 || rect.x > size.width as f32
 500                 || rect.y > size.height as f32
 501             {
 502                 continue;
 503             }
 504             pc.image(id, rect, 1.0);
 505             // Window-adjust mode: the hovered item's four corner handles, in
 506             // the same colours as the windows' handles, the hovered one lit.
 507             // Sized in virtual units, so they scale with the canvas rather
 508             // than holding a screen size the way the compositor's do — this
 509             // client never learns the camera zoom.
 510             if self.adjust && self.hover_item == Some(index) {
 511                 let (r, discs) = handle_discs(item, st.handle_width);
 512                 for (corner, cx, cy) in discs {
 513                     let color = if self.hover == Some((index, corner)) {
 514                         st.handle_hover_color
 515                     } else {
 516                         st.handle_color
 517                     };
 518                     pc.circle(((cx - p.x) * s) as f32, ((cy - p.y) * s) as f32, (r * s) as f32, color);
 519                 }
 520             }
 521         }
 522     }
 523 }
 524 
 525 impl GridApp {
 526     /// The topmost item under a virtual-canvas point.
 527     fn item_at(&self, vx: f64, vy: f64) -> Option<usize> {
 528         self.items
 529             .iter()
 530             .rposition(|(i, _)| vx >= i.x && vx < i.x + i.w && vy >= i.y && vy < i.y + i.h)
 531     }
 532 
 533     /// The corner handle under a virtual-canvas point, with a unit of slack
 534     /// around the disc's antialiased rim. Only the hovered item's handles
 535     /// are up, so only its discs can be hit.
 536     fn corner_at(&self, vx: f64, vy: f64) -> Option<(usize, Corner)> {
 537         if !self.adjust {
 538             return None;
 539         }
 540         let diameter = style().handle_width;
 541         for (index, (item, _)) in self.items.iter().enumerate().rev() {
 542             if self.hover_item != Some(index) {
 543                 continue;
 544             }
 545             let (r, discs) = handle_discs(item, diameter);
 546             let reach = (r + 1.0) * (r + 1.0);
 547             for (corner, cx, cy) in discs {
 548                 let (dx, dy) = (vx - cx, vy - cy);
 549                 if dx * dx + dy * dy <= reach {
 550                     return Some((index, corner));
 551                 }
 552             }
 553         }
 554         None
 555     }
 556 
 557     fn save_items(&self) {
 558         let model: Vec<items::DesktopItem> = self.items.iter().map(|(i, _)| i.clone()).collect();
 559         items::save(&model);
 560     }
 561 }
 562 
 563 impl Application for GridApp {
 564     type Message = Message;
 565 
 566     fn new(
 567         _qh: &QueueHandle<EngineState<Self>>,
 568         _sender: calloop::channel::Sender<Self::Message>,
 569     ) -> Self {
 570         spawn_adjust_listener(_sender.clone());
 571         Self {
 572             patch: None,
 573             items: items::load().into_iter().map(|i| (i, None)).collect(),
 574             sender: _sender,
 575             dragging: None,
 576             adjust: false,
 577             hover_item: None,
 578             hover: None,
 579             resizing: None,
 580             applied_relief: None,
 581             base_depth: None,
 582             base_height: None,
 583         }
 584     }
 585 
 586     fn settings(&self) -> WindowSettings {
 587         WindowSettings {
 588             title: "Desktop Grid".to_string(),
 589             app_id: "cce-grid".to_string(),
 590             // Nominal initial size; every real size comes from a patch.
 591             width: 640,
 592             height: 480,
 593             fullscreen: false,
 594             min_size: None,
 595         }
 596     }
 597 
 598     fn grid(&self) -> bool {
 599         true
 600     }
 601 
 602     fn grid_patch(&mut self, x: f64, y: f64, w: f64, h: f64, scale: f64) {
 603         self.patch = Some(Patch { x, y, w, h, scale });
 604     }
 605 
 606     fn update(&mut self, msg: Self::Message, needs_rebuild: &mut bool, _exit: &mut bool) {
 607         match msg {
 608             Message::ItemReady { item, pixels, px_w, px_h } => {
 609                 let id = cce_ui::vk::upload_rgba(pixels, px_w, px_h);
 610                 log::info!(
 611                     "[items] pinned {} at ({:.0}, {:.0})",
 612                     item.path.display(),
 613                     item.x,
 614                     item.y
 615                 );
 616                 self.items.push((item, Some(id)));
 617                 // Persist only the model — the texture id is per-process.
 618                 let model: Vec<items::DesktopItem> =
 619                     self.items.iter().map(|(i, _)| i.clone()).collect();
 620                 items::save(&model);
 621                 *needs_rebuild = true;
 622             }
 623             Message::RemoveItem(path) => {
 624                 let Some(pos) = self.items.iter().position(|(i, _)| i.path == path) else {
 625                     return;
 626                 };
 627                 // A drag or resize on the removed item cannot outlive it.
 628                 if self.dragging.is_some() {
 629                     self.dragging = None;
 630                 }
 631                 self.resizing = None;
 632                 self.hover = None;
 633                 self.hover_item = None;
 634                 let (item, id) = self.items.remove(pos);
 635                 if let Some(id) = id {
 636                     cce_ui::vk::free_image(id);
 637                 }
 638                 let model: Vec<items::DesktopItem> =
 639                     self.items.iter().map(|(i, _)| i.clone()).collect();
 640                 items::save(&model);
 641                 // The file itself stays where it was saved: this unpins the
 642                 // image from the desktop, it does not delete the user's file.
 643                 log::info!("[items] removed {} from the desktop", item.path.display());
 644                 *needs_rebuild = true;
 645             }
 646             Message::AdjustMode(on) => {
 647                 if self.adjust == on {
 648                     return;
 649                 }
 650                 self.adjust = on;
 651                 if !on {
 652                     // A resize in flight finishes on its release; only the
 653                     // highlight goes with the handles.
 654                     self.hover = None;
 655                 }
 656                 *needs_rebuild = true;
 657             }
 658         }
 659     }
 660 
 661     fn tick(&mut self, _dt: f32, _needs_rebuild: &mut bool) {}
 662 
 663     /// Uploads happen here, not in `new`: a reconnect builds a fresh renderer
 664     /// and does not replay earlier uploads, so items restored from the sidecar
 665     /// (and any pinned before the reconnect) have to be handed over again.
 666     fn renderer_init(&mut self, _renderer: &mut cce_ui::vk::VkRenderer) {
 667         for (item, id) in self.items.iter_mut() {
 668             let Ok(bytes) = std::fs::read(&item.path) else {
 669                 log::warn!("[items] {} is gone; not drawing it", item.path.display());
 670                 *id = None;
 671                 continue;
 672             };
 673             match items::decode_rgba(&bytes) {
 674                 Some((pixels, w, h)) => *id = Some(cce_ui::vk::upload_rgba(pixels, w, h)),
 675                 None => {
 676                     log::warn!("[items] {} did not decode", item.path.display());
 677                     *id = None;
 678                 }
 679             }
 680         }
 681     }
 682 
 683     /// What a browser offers for an image on a page, best first: the raw
 684     /// bytes if the source has them, else a link to fetch.
 685     fn drop_mimes(&self) -> &'static [&'static str] {
 686         &[
 687             // Pixels beat links: no fetch, no ambiguity. Firefox offers these
 688             // for an image on a page; Chrome usually does not.
 689             "image/png",
 690             "image/jpeg",
 691             "image/gif",
 692             "image/webp",
 693             // Preferred over text/uri-list because it names the IMAGE. When a
 694             // thumbnail is wrapped in a link — Google Images' exact markup —
 695             // uri-list is the result page and fetching it yields HTML, not a
 696             // picture. For an unwrapped image the two agree, so this never
 697             // does worse.
 698             "text/html",
 699             "text/uri-list",
 700             "text/x-moz-url",
 701             "text/plain;charset=utf-8",
 702             "text/plain",
 703         ]
 704     }
 705 
 706     fn handle_drop(
 707         &mut self,
 708         mime: &str,
 709         data: &[u8],
 710         pos: LogicalPosition,
 711         _needs_rebuild: &mut bool,
 712     ) {
 713         let Some(patch) = self.patch else { return };
 714         if patch.scale <= 0.0 {
 715             return;
 716         }
 717         let Some(payload) = items::parse_payload(mime, data) else {
 718             items::report_failure(&format!("nothing usable in the dropped {mime}"));
 719             return;
 720         };
 721 
 722         // The drop point in world coordinates — the inverse of the mapping
 723         // `paint` uses to place cells, so the image lands under the cursor
 724         // whatever the camera is doing.
 725         let s = patch.surface_per_virtual();
 726         let vx = patch.x + pos.x as f64 / s;
 727         let vy = patch.y + pos.y as f64 / s;
 728 
 729         // Sized to fit inside one grid cell, keeping aspect: a phone
 730         // screenshot would otherwise land several squares wide.
 731         let st = style();
 732         let (cell_w, cell_h) = (st.cell_w.max(16.0), st.cell_h.max(16.0));
 733         let sender = self.sender.clone();
 734         let mime = mime.to_string();
 735         std::thread::spawn(move || {
 736             let (bytes, name) = match items::fetch(payload) {
 737                 Ok(v) => v,
 738                 Err(e) => {
 739                     items::report_failure(&format!("could not fetch it: {e}"));
 740                     return;
 741                 }
 742             };
 743             let Some((pixels, px_w, px_h)) = items::decode_rgba(&bytes) else {
 744                 items::report_failure(&format!(
 745                     "the dropped {mime} is not an image cce can read ({} bytes)",
 746                     bytes.len()
 747                 ));
 748                 return;
 749             };
 750             // Save even though it is already decoded: the user asked for the
 751             // file on their desktop, not just a picture on the canvas.
 752             let path = match items::save_to_desktop(&bytes, &name) {
 753                 Ok(p) => p,
 754                 Err(e) => {
 755                     items::report_failure(&format!("could not save it to the desktop: {e}"));
 756                     return;
 757                 }
 758             };
 759             let fit = (cell_w / px_w as f64).min(cell_h / px_h as f64).min(1.0);
 760             let w = px_w as f64 * fit;
 761             let h = px_h as f64 * fit;
 762             let item = items::DesktopItem {
 763                 path,
 764                 // Centred on the drop point.
 765                 x: vx - w / 2.0,
 766                 y: vy - h / 2.0,
 767                 w,
 768                 h,
 769             };
 770             let _ = sender.send(Message::ItemReady { item, pixels, px_w, px_h });
 771         });
 772     }
 773 
 774     /// Exactly the pinned items, in surface-local px. Everything else on this
 775     /// surface stays click-through: the compositor no longer forces the grid
 776     /// layer transparent, it just misses this region, so the desktop keeps its
 777     /// background clicks (menu, overview exit, panning) while a press ON an
 778     /// image reaches this client. An empty region — the no-items case — is
 779     /// wholly transparent, which is the old behaviour exactly.
 780     fn input_regions(&self) -> Option<Vec<(i32, i32, i32, i32)>> {
 781         let Some(p) = self.patch else { return Some(Vec::new()) };
 782         if p.scale <= 0.0 {
 783             return Some(Vec::new());
 784         }
 785         let s = p.surface_per_virtual();
 786         let out: Vec<(i32, i32, i32, i32)> = self
 787             .items
 788             .iter()
 789             .map(|(item, _)| {
 790                 (
 791                     ((item.x - p.x) * s).round() as i32,
 792                     ((item.y - p.y) * s).round() as i32,
 793                     (item.w * s).round().max(1.0) as i32,
 794                     (item.h * s).round().max(1.0) as i32,
 795                 )
 796             })
 797             .collect();
 798         Some(out)
 799     }
 800 
 801     fn handle_pointer_move(&mut self, pos: LogicalPosition, needs_rebuild: &mut bool) {
 802         let Some(p) = self.patch else { return };
 803         if p.scale <= 0.0 {
 804             return;
 805         }
 806         let origin = (p.x, p.y);
 807         let s = p.surface_per_virtual();
 808 
 809         if let Some(rs) = self.resizing.as_mut() {
 810             let last_pos = rs.last_pos;
 811             rs.last_pos = (pos.x, pos.y);
 812             if rs.last_origin != origin {
 813                 rs.last_origin = origin;
 814                 return;
 815             }
 816             let dx = (pos.x - last_pos.0) as f64 / s;
 817             let dy = (pos.y - last_pos.1) as f64 / s;
 818             if dx == 0.0 && dy == 0.0 {
 819                 return;
 820             }
 821             rs.moved = true;
 822             let (index, corner) = (rs.index, rs.corner);
 823             if let Some((item, _)) = self.items.get_mut(index) {
 824                 // Corners scale the image PROPORTIONALLY — an image stretched
 825                 // out of its aspect is a different picture — by the mean of
 826                 // the two edge ratios the drag asks for, anchored on the
 827                 // opposite corner.
 828                 let (sx, sy) = corner.sign();
 829                 let kw = (item.w + sx * dx) / item.w.max(1.0);
 830                 let kh = (item.h + sy * dy) / item.h.max(1.0);
 831                 let k = ((kw + kh) / 2.0).max(MIN_ITEM_SIZE / item.w.max(item.h).max(1.0));
 832                 let (old_w, old_h) = (item.w, item.h);
 833                 item.w = (old_w * k).max(MIN_ITEM_SIZE);
 834                 item.h = old_h * (item.w / old_w.max(1.0));
 835                 if sx < 0.0 {
 836                     item.x += old_w - item.w;
 837                 }
 838                 if sy < 0.0 {
 839                     item.y += old_h - item.h;
 840                 }
 841                 *needs_rebuild = true;
 842             }
 843             return;
 844         }
 845 
 846         let Some(drag) = self.dragging.as_mut() else {
 847             // Idle motion: the item under the pointer gets the handles, and
 848             // the handle under it lights. A leave arrives as an off-screen
 849             // position and clears both.
 850             let vx = p.x + pos.x as f64 / s;
 851             let vy = p.y + pos.y as f64 / s;
 852             let item = self.item_at(vx, vy);
 853             if item != self.hover_item {
 854                 self.hover_item = item;
 855                 *needs_rebuild = true;
 856             }
 857             let hover = self.corner_at(vx, vy);
 858             if hover != self.hover {
 859                 self.hover = hover;
 860                 *needs_rebuild = true;
 861             }
 862             return;
 863         };
 864         let last_pos = drag.last_pos;
 865         drag.last_pos = (pos.x, pos.y);
 866         if drag.last_origin != origin {
 867             // The surface moved under the pointer; this position cannot be
 868             // compared with the previous one. Re-baseline and wait.
 869             drag.last_origin = origin;
 870             return;
 871         }
 872         let dx = (pos.x - last_pos.0) as f64 / s;
 873         let dy = (pos.y - last_pos.1) as f64 / s;
 874         if dx == 0.0 && dy == 0.0 {
 875             return;
 876         }
 877         drag.moved = true;
 878         let index = drag.index;
 879         if let Some((item, _)) = self.items.get_mut(index) {
 880             item.x += dx;
 881             item.y += dy;
 882             *needs_rebuild = true;
 883         }
 884     }
 885 
 886     fn handle_mouse_input(
 887         &mut self,
 888         button: MouseButton,
 889         state: ElementState,
 890         pos: LogicalPosition,
 891         needs_rebuild: &mut bool,
 892     ) -> Option<Self::Message> {
 893         let Some(p) = self.patch else { return None };
 894         if p.scale <= 0.0 {
 895             return None;
 896         }
 897         if button == MouseButton::Right {
 898             if state != ElementState::Pressed {
 899                 return None;
 900             }
 901             let s = p.surface_per_virtual();
 902             let vx = p.x + pos.x as f64 / s;
 903             let vy = p.y + pos.y as f64 / s;
 904             let hit = self.items.iter().rposition(|(i, _)| {
 905                 vx >= i.x && vx < i.x + i.w && vy >= i.y && vy < i.y + i.h
 906             })?;
 907             let path = self.items[hit].0.path.clone();
 908             let name = path
 909                 .file_name()
 910                 .map(|n| n.to_string_lossy().into_owned())
 911                 .unwrap_or_else(|| "Image".to_string());
 912             // The menu blocks until it is dismissed, so it cannot run on the
 913             // loop that has to keep drawing the desktop behind it.
 914             let sender = self.sender.clone();
 915             std::thread::spawn(move || {
 916                 if items::item_menu(&name).as_deref() == Some("remove") {
 917                     let _ = sender.send(Message::RemoveItem(path));
 918                 }
 919             });
 920             return None;
 921         }
 922         if button != MouseButton::Left {
 923             return None;
 924         }
 925         match state {
 926             ElementState::Pressed => {
 927                 let s = p.surface_per_virtual();
 928                 let vx = p.x + pos.x as f64 / s;
 929                 let vy = p.y + pos.y as f64 / s;
 930                 // A corner handle (adjust mode only) resizes; the body moves.
 931                 if let Some((index, corner)) = self.corner_at(vx, vy) {
 932                     self.resizing = Some(Resize {
 933                         index,
 934                         corner,
 935                         last_pos: (pos.x, pos.y),
 936                         last_origin: (p.x, p.y),
 937                         moved: false,
 938                     });
 939                     return None;
 940                 }
 941                 // Last drawn is on top, so search backwards and take the
 942                 // first hit.
 943                 let hit = self.items.iter().rposition(|(i, _)| {
 944                     vx >= i.x && vx < i.x + i.w && vy >= i.y && vy < i.y + i.h
 945                 })?;
 946                 // Raise it: the one you grabbed should be the one you see,
 947                 // and the next press should find it first. The handles
 948                 // follow it to its new index.
 949                 let item = self.items.remove(hit);
 950                 self.items.push(item);
 951                 self.hover_item = Some(self.items.len() - 1);
 952                 self.dragging = Some(Drag {
 953                     index: self.items.len() - 1,
 954                     last_pos: (pos.x, pos.y),
 955                     last_origin: (p.x, p.y),
 956                     moved: false,
 957                 });
 958                 *needs_rebuild = true;
 959             }
 960             ElementState::Released => {
 961                 if let Some(rs) = self.resizing.take() {
 962                     if rs.moved {
 963                         self.save_items();
 964                         if let Some((item, _)) = self.items.get(rs.index) {
 965                             log::info!(
 966                                 "[items] resized {} to {:.0}x{:.0}",
 967                                 item.path.display(),
 968                                 item.w,
 969                                 item.h
 970                             );
 971                         }
 972                     }
 973                     return None;
 974                 }
 975                 if let Some(drag) = self.dragging.take() {
 976                     if drag.moved {
 977                         let model: Vec<items::DesktopItem> =
 978                             self.items.iter().map(|(i, _)| i.clone()).collect();
 979                         items::save(&model);
 980                         if let Some((item, _)) = self.items.get(drag.index) {
 981                             log::info!(
 982                                 "[items] moved {} to ({:.0}, {:.0})",
 983                                 item.path.display(),
 984                                 item.x,
 985                                 item.y
 986                             );
 987                         }
 988                     }
 989                 }
 990             }
 991         }
 992         None
 993     }
 994 
 995     fn handle_mouse_wheel(
 996         &mut self,
 997         _delta: &MouseScrollDelta,
 998         _pos: LogicalPosition,
 999         _needs_rebuild: &mut bool,
1000     ) {
1001     }
1002 
1003     fn handle_key_input(
1004         &mut self,
1005         _event: &KeyEvent,
1006         _needs_rebuild: &mut bool,
1007     ) -> Option<Self::Message> {
1008         None
1009     }
1010 
1011     // style-audit: opt-out the desktop grid overlay draws the compositor cells, not a window
1012 
1013     fn display_list(&mut self, size: LogicalSize, _scale: f64) -> Option<DisplayList> {
1014         let mut pc = PaintCtx::new();
1015         self.paint(&mut pc, size);
1016         Some(pc.finish())
1017     }
1018 
1019     fn clear_color(&self) -> [f32; 4] {
1020         // Patch edges the display list somehow misses read as rail surface,
1021         // matching the compositor's always-on gap backdrop underneath.
1022         style().gap_color
1023     }
1024 }
1025 
1026 fn main() {
1027     // Default to info, not env_logger's error-only: this process runs
1028     // unattended as a session service, and a drop that quietly fails with
1029     // nothing in the log is indistinguishable from a drop that never
1030     // happened — which is exactly how the first Chrome failure presented.
1031     env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init();
1032     cce_ui::engine::run::<GridApp>();
1033 }