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

src/main.rs (83.8K)

   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 
  17 use cce_ui::engine::{Application, LogicalPosition, LogicalSize, WindowSettings};
  18 use cce_ui::scene::layout::Rect;
  19 use cce_ui::scene::paint::{Cap, DisplayList, PaintCtx};
  20 use cce_ui::widget::markdown::{self, Layout as CardLayout, ShapingMeasure, Theme};
  21 use cce_ui::widget::{ElementState, KeyEvent, MouseButton, MouseScrollDelta};
  22 
  23 mod board;
  24 mod items;
  25 
  26 use board::Edge;
  27 use items::Kind;
  28 
  29 /// A note or text card's content box inset, and the label above a card, in
  30 /// virtual units (logical px at zoom 1).
  31 const CARD_PAD: f64 = 14.0;
  32 const CARD_RADIUS: f64 = 10.0;
  33 const CARD_TEXT: f32 = 15.0;
  34 const CARD_LABEL: f64 = 13.0;
  35 /// A new note or text card's size.
  36 const CARD_W: f64 = 400.0;
  37 const CARD_H: f64 = 300.0;
  38 /// Smallest a card may be resized to.
  39 const MIN_CARD: f64 = 80.0;
  40 const EDGE_WIDTH: f64 = 2.0;
  41 const ARROW_LEN: f64 = 14.0;
  42 /// Two presses on one item within this read as a double-click.
  43 const DOUBLE_CLICK: std::time::Duration = std::time::Duration::from_millis(400);
  44 
  45 /// JSON Canvas's six preset colours (Obsidian's), else a `#rrggbb` value.
  46 fn canvas_color(c: Option<&str>) -> Option<[f32; 4]> {
  47     let srgb = match c? {
  48         "1" => [0.91, 0.30, 0.33, 1.0],
  49         "2" => [0.93, 0.56, 0.24, 1.0],
  50         "3" => [0.92, 0.79, 0.27, 1.0],
  51         "4" => [0.27, 0.75, 0.42, 1.0],
  52         "5" => [0.31, 0.72, 0.82, 1.0],
  53         "6" => [0.62, 0.48, 0.91, 1.0],
  54         hex => cce_ui::color::parse_hex_rgba(hex)?,
  55     };
  56     Some(cce_ui::colors::to_linear(srgb))
  57 }
  58 
  59 /// A laid-out card, and what it was laid out from.
  60 struct CardCache {
  61     source: String,
  62     width: f64,
  63     layout: CardLayout,
  64 }
  65 
  66 #[derive(Debug, Clone)]
  67 enum Message {
  68     /// A dropped image finished fetching, saving and decoding on its worker
  69     /// thread. Carried as pixels rather than an image id because the GPU
  70     /// upload has to happen on the main loop.
  71     ItemReady {
  72         item: items::DesktopItem,
  73         pixels: Vec<u8>,
  74         px_w: u32,
  75         px_h: u32,
  76     },
  77     /// An item's context menu closed on an action. Carries the canvas node
  78     /// id rather than an index: the menu is modal on its own thread, and the
  79     /// list can be reordered by a drag (or grown by a drop) while it is open.
  80     MenuAction { node: String, action: String },
  81     /// A drop that became a card without pixels to upload (a note, text, a
  82     /// link): ready to pin.
  83     AddItem(items::DesktopItem),
  84     /// Files changed in the vault: the board itself (Obsidian, a sync) or a
  85     /// note a card shows.
  86     VaultChanged(Vec<std::path::PathBuf>),
  87     /// The compositor's window-adjust mode (overview, or Super held) came
  88     /// on or went off — the `adjust` status topic. While it is on every
  89     /// pinned image shows its four corner handles.
  90     AdjustMode(bool),
  91     /// The compositor's overview drag-selection is carrying some of the
  92     /// pinned images (`selection` status topic, `move <id>:<x>:<y> ...`):
  93     /// where each is now, in virtual units. The compositor moves the rects
  94     /// it was reported and draws the selection; this side just follows.
  95     SelectionMove(Vec<(u64, f64, f64)>),
  96     /// The group move released (`drop`): the positions stand, so save the
  97     /// sidecar and report the list afresh.
  98     SelectionDrop,
  99 }
 100 
 101 /// Which corner handle of an item.
 102 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
 103 enum Corner {
 104     TopLeft,
 105     TopRight,
 106     BottomLeft,
 107     BottomRight,
 108 }
 109 
 110 impl Corner {
 111     const ALL: [Corner; 4] = [Corner::TopLeft, Corner::TopRight, Corner::BottomLeft, Corner::BottomRight];
 112 
 113     /// Which way the corner faces: +1 on the right/bottom edge, -1 on the
 114     /// left/top. Dragging the corner by (dx, dy) grows the item by
 115     /// (sx*dx, sy*dy).
 116     fn sign(self) -> (f64, f64) {
 117         match self {
 118             Corner::TopLeft => (-1.0, -1.0),
 119             Corner::TopRight => (1.0, -1.0),
 120             Corner::BottomLeft => (-1.0, 1.0),
 121             Corner::BottomRight => (1.0, 1.0),
 122         }
 123     }
 124 }
 125 
 126 /// An item's corner handles in VIRTUAL units: the disc radius, and each
 127 /// corner's centre. A disc is tangent to both of its edges — the same
 128 /// placement the compositor gives a window's corner handles when the
 129 /// silhouette has no corner radius — and never wider than a quarter of the
 130 /// image, so a thumbnail is not all handle.
 131 fn handle_discs(item: &items::DesktopItem, diameter: f64) -> (f64, [(Corner, f64, f64); 4]) {
 132     let r = (diameter / 2.0).min(item.w / 4.0).min(item.h / 4.0).max(1.0);
 133     let (x0, y0, x1, y1) = (item.x + r, item.y + r, item.x + item.w - r, item.y + item.h - r);
 134     (
 135         r,
 136         [
 137             (Corner::TopLeft, x0, y0),
 138             (Corner::TopRight, x1, y0),
 139             (Corner::BottomLeft, x0, y1),
 140             (Corner::BottomRight, x1, y1),
 141         ],
 142     )
 143 }
 144 
 145 /// Smallest an image may be resized to, in virtual units.
 146 const MIN_ITEM_SIZE: f64 = 16.0;
 147 
 148 /// Subscribe to one of the compositor's status topics and forward every
 149 /// push through `parse` as a message, reconnecting with backoff until the
 150 /// socket is there (the compositor may come up after this service, and
 151 /// restarts at login). `adjust` is a state topic: a new subscriber is sent
 152 /// the current state at once, so the very first line settles whether the
 153 /// handles should already be up. `selection` is one-shot: its lines come
 154 /// only while a group move is carrying this client's images.
 155 fn spawn_topic_listener(
 156     topic: &'static str,
 157     sender: calloop::channel::Sender<Message>,
 158     parse: fn(&str) -> Option<Message>,
 159 ) {
 160     use std::io::{BufRead, Write};
 161     std::thread::spawn(move || {
 162         let mut retry_s = 1u64;
 163         loop {
 164             let path = {
 165                 let primary = cce_ui::ipc::socket_path("cce-status-interface");
 166                 if std::path::Path::new(&primary).exists() {
 167                     primary
 168                 } else {
 169                     cce_ui::ipc::socket_path("cce-status")
 170                 }
 171             };
 172             if let Ok(mut stream) = std::os::unix::net::UnixStream::connect(&path) {
 173                 if stream.write_all(format!("{topic}\n").as_bytes()).is_ok() {
 174                     let mut reader = std::io::BufReader::new(stream);
 175                     let mut line = String::new();
 176                     while reader.read_line(&mut line).map(|n| n > 0).unwrap_or(false) {
 177                         retry_s = 1;
 178                         if let Some(msg) = parse(line.trim()) {
 179                             if sender.send(msg).is_err() {
 180                                 return;
 181                             }
 182                         }
 183                         line.clear();
 184                     }
 185                 }
 186             }
 187             std::thread::sleep(std::time::Duration::from_secs(retry_s));
 188             retry_s = (retry_s * 2).min(30);
 189         }
 190     });
 191 }
 192 
 193 /// A `selection` topic line: `move <id>:<x>:<y> ...` or `drop`. Anything
 194 /// else — or a `move` with nothing parseable on it — is ignored.
 195 fn parse_selection_line(line: &str) -> Option<Message> {
 196     let mut words = line.split_whitespace();
 197     match words.next()? {
 198         "drop" => Some(Message::SelectionDrop),
 199         "move" => {
 200             let moves: Vec<(u64, f64, f64)> = words
 201                 .filter_map(|tok| {
 202                     let mut f = tok.split(':');
 203                     let id = f.next()?.parse::<u64>().ok()?;
 204                     let x = f.next()?.parse::<f64>().ok()?;
 205                     let y = f.next()?.parse::<f64>().ok()?;
 206                     Some((id, x, y))
 207                 })
 208                 .collect();
 209             if moves.is_empty() {
 210                 None
 211             } else {
 212                 Some(Message::SelectionMove(moves))
 213             }
 214         }
 215         _ => None,
 216     }
 217 }
 218 
 219 /// The thread that tells the compositor what is pinned (`grid-items`, the
 220 /// whole list on every change — see `GridApp::report_items`). Its own
 221 /// thread because the control socket is request/reply and the reply waits
 222 /// on the compositor's main loop, which must never stall the paint loop
 223 /// here; a channel because reports must land in order. A report that
 224 /// cannot be delivered (the compositor not up yet) is retried with backoff,
 225 /// and only the LATEST pending report is ever sent — a stale list is worse
 226 /// than a late one.
 227 fn spawn_reporter() -> std::sync::mpsc::Sender<String> {
 228     let (tx, rx) = std::sync::mpsc::channel::<String>();
 229     std::thread::spawn(move || {
 230         let mut retry_s = 1u64;
 231         let mut pending: Option<String> = None;
 232         loop {
 233             // Block for the next report unless one is waiting to be
 234             // retried, in which case just drain whatever is newer.
 235             if pending.is_none() {
 236                 match rx.recv() {
 237                     Ok(line) => pending = Some(line),
 238                     Err(_) => return,
 239                 }
 240             }
 241             while let Ok(newer) = rx.try_recv() {
 242                 pending = Some(newer);
 243             }
 244             let line = pending.take().unwrap_or_default();
 245             match cce_ui::ipc::send_command("cce", &line) {
 246                 Ok(_) => retry_s = 1,
 247                 Err(e) => {
 248                     log::debug!("[items] report not delivered ({e}); retrying in {retry_s}s");
 249                     pending = Some(line);
 250                     std::thread::sleep(std::time::Duration::from_secs(retry_s));
 251                     retry_s = (retry_s * 2).min(30);
 252                 }
 253             }
 254         }
 255     });
 256     tx
 257 }
 258 
 259 /// The world region the current buffer must cover, as told by the
 260 /// compositor: virtual origin/size and surface px per virtual unit.
 261 #[derive(Debug, Clone, Copy, PartialEq)]
 262 struct Patch {
 263     x: f64,
 264     y: f64,
 265     w: f64,
 266     h: f64,
 267     scale: f64,
 268 }
 269 
 270 struct GridApp {
 271     patch: Option<Patch>,
 272     /// Everything pinned to the canvas, in z-order, each image paired with
 273     /// its uploaded texture id (`None` until the renderer exists — see
 274     /// `renderer_init` — and always for cards).
 275     items: Vec<(items::DesktopItem, Option<u32>)>,
 276     /// The links between items (JSON Canvas edges).
 277     edges: Vec<Edge>,
 278     /// Where items and edges are kept: `Desktop.canvas` in the vault.
 279     board: board::Board,
 280     _watcher: Option<cce_vault::VaultWatcher>,
 281     /// Laid-out note and text cards, by node id.
 282     cards: std::collections::HashMap<String, CardCache>,
 283     measure: Option<ShapingMeasure>,
 284     /// "Connect to…" was picked on this node: the next press on another
 285     /// item draws an edge to it.
 286     connecting: Option<String>,
 287     /// The last press, for double-click: (node, when).
 288     last_press: Option<(String, std::time::Instant)>,
 289     /// Worker threads post finished drops back through this.
 290     sender: calloop::channel::Sender<Message>,
 291     /// The `grid-items` reports go out through this (`spawn_reporter`).
 292     reporter: std::sync::mpsc::Sender<String>,
 293     /// The next `DesktopItem::id` to hand out.
 294     next_id: u64,
 295     /// The item being dragged, and where inside it the pointer grabbed —
 296     /// held in VIRTUAL units so the drag survives a pan or zoom mid-gesture.
 297     dragging: Option<Drag>,
 298     /// The compositor's window-adjust mode (`adjust` status topic): while
 299     /// on, the item under the pointer shows its corner handles and a press
 300     /// on one resizes.
 301     adjust: bool,
 302     /// The item under the pointer (body or handle) — the one that shows its
 303     /// handles, like the compositor's ring on the hovered window. Cleared by
 304     /// the off-screen move cce-ui synthesizes on pointer leave, so it drops
 305     /// the moment the pointer is on the background or a window.
 306     hover_item: Option<usize>,
 307     /// The corner handle under the pointer, drawn in the hover colour.
 308     hover: Option<(usize, Corner)>,
 309     /// An in-flight corner resize, delta-driven like `Drag`.
 310     resizing: Option<Resize>,
 311     /// What changed since the last painted frame — see [`Damage`].
 312     damage: Damage,
 313     /// Everything the last painted frame was a function of besides the
 314     /// items: a frame whose inputs differ is repainted in full, whatever
 315     /// `damage` says.
 316     painted: Option<(Patch, (f64, f64), String)>,
 317     /// The raw `(relief)` string currently installed process-wide (depth +
 318     /// wall profile LUT) — a change detector, so the registry is only
 319     /// touched when the config value actually changes.
 320     applied_relief: Option<String>,
 321     /// The DE-wide `bevel_depth` captured before the first spec override,
 322     /// restored if the key later reverts to a plain width or is removed.
 323     base_depth: Option<f32>,
 324     /// The DE-wide pinned carve height (0 = follow) while a `(relief)` value's
 325     /// `h=` is installed, restored when the key reverts.
 326     base_height: Option<f32>,
 327 }
 328 
 329 impl Patch {
 330     /// Surface-local px per virtual unit — `Patch::scale` itself.
 331     ///
 332     /// The grid surface is PINNED at buffer_scale 1 (cce-ui ignores scale
 333     /// events for grid apps; patch.scale is the sole resolution authority),
 334     /// so surface-local coordinates ARE buffer px at every output scale:
 335     /// pointer events arrive in that space and input regions are interpreted
 336     /// in it. The /ui division that used to live here calibrated against the
 337     /// compositor's old hit-test, which handed out raw layout offsets —
 338     /// numerically buffer/ui only at zoom 1 on the pow2 patch quantization —
 339     /// and at any other camera state it displaced the input region off the
 340     /// items (presses read as background) and tore the press position apart
 341     /// from the drag deltas (the flung-item bug). The compositor now speaks
 342     /// true surface coordinates, so the patch scale is used unmodified.
 343     fn surface_per_virtual(&self) -> f64 {
 344         self.scale
 345     }
 346 }
 347 
 348 /// What changed on the canvas since the last painted frame, in VIRTUAL
 349 /// units. The surface is the whole patch — 7552x8160 px on a HiDPI laptop —
 350 /// and an image dragged across it changes a few hundred pixels a frame;
 351 /// repainting and re-compositing all sixty million for each pointer event is
 352 /// what made the drag stutter. So item edits record the rects they touched
 353 /// (`GridApp::touch`), and `take_damage` hands cce-ui their union.
 354 #[derive(Debug, Clone, Copy, PartialEq)]
 355 enum Damage {
 356     /// Nothing the items did.
 357     Nothing,
 358     /// The union of the item rects touched: (x0, y0, x1, y1).
 359     Rect(f64, f64, f64, f64),
 360     /// Anything else: repaint it all.
 361     Full,
 362 }
 363 
 364 /// An in-flight item drag.
 365 ///
 366 /// The item follows pointer DELTAS, not `patch + position`. The compositor can
 367 /// re-issue the grid patch at any moment — it did so on the very first motion
 368 /// event of a drag during testing, moving the patch origin by half a screen —
 369 /// and the pointer events in flight are still in the OLD surface's coordinate
 370 /// space, so an absolute mapping teleports the item by the origin delta. A
 371 /// delta is the same number in either space.
 372 struct Drag {
 373     index: usize,
 374     /// Previous pointer position, surface-local.
 375     last_pos: (f32, f32),
 376     /// Patch origin the previous position was measured against. When this
 377     /// changes, the incoming position is in a different space than the last
 378     /// one, so that step is used only to re-baseline.
 379     last_origin: (f64, f64),
 380     /// Set once the pointer actually travels, so a plain click does not
 381     /// rewrite the sidecar.
 382     moved: bool,
 383 }
 384 
 385 /// An in-flight corner resize. Delta-driven for the same reason `Drag` is:
 386 /// the patch can be re-issued under the pointer mid-gesture.
 387 struct Resize {
 388     index: usize,
 389     corner: Corner,
 390     last_pos: (f32, f32),
 391     last_origin: (f64, f64),
 392     moved: bool,
 393 }
 394 
 395 /// The `line_relief` key's three states — see [`Style::line_relief`].
 396 enum LineRelief {
 397     /// Key absent: follow the DE-wide relief material.
 398     Material,
 399     /// Plain integer: lip width in virtual units, 0 = no lip.
 400     Width(f64),
 401     /// A `(relief)` value: its own width/depth/profile, editable in place
 402     /// with `cce-relief --key style.surface.desktop.line_relief`. The raw
 403     /// string rides along as the change detector.
 404     Spec(String, cce_ui::relief_spec::ReliefSpec),
 405 }
 406 
 407 /// Style knobs, re-read per frame from the shared config (cheap: cce-ui
 408 /// caches the parse on mtime), with the same defaults the compositor uses.
 409 struct Style {
 410     cell_w: f64,
 411     cell_h: f64,
 412     gap_width: f64,
 413     cell_inset: f64,
 414     corner_radius: f64,
 415     gap_color: [f32; 4],
 416     cell_color: [f32; 4],
 417     /// Grid-line lip material: a plain integer width (0 = no lip), a full
 418     /// `(relief)` value, or absent = the DE-wide material. Negative integers
 419     /// mean unset.
 420     line_relief: LineRelief,
 421     /// The image resize handles, from the same `border` keys the windows'
 422     /// handles use: diameter in virtual units (logical px at zoom 1), and
 423     /// the resting and hovered colours.
 424     handle_width: f64,
 425     handle_color: [f32; 4],
 426     handle_hover_color: [f32; 4],
 427 }
 428 
 429 fn style() -> Style {
 430     use cce_ui::config::{get_color, get_i64};
 431     // get_color returns raw sRGB; the render pipeline (like every cce-ui
 432     // widget color) expects linear.
 433     let linear = |c: [f32; 4]| {
 434         let f = cce_ui::color::srgb_to_linear;
 435         [f(c[0]), f(c[1]), f(c[2]), c[3]]
 436     };
 437     Style {
 438         // Per-axis sizes; the legacy square grid_cell_size is the fallback
 439         // for both, mirroring the compositor's config resolution.
 440         cell_w: {
 441             let legacy = get_i64("/style/surface/desktop/grid_cell_size", 512);
 442             get_i64("/style/surface/desktop/grid_cell_width", legacy) as f64
 443         },
 444         cell_h: {
 445             let legacy = get_i64("/style/surface/desktop/grid_cell_size", 512);
 446             get_i64("/style/surface/desktop/grid_cell_height", legacy) as f64
 447         },
 448         gap_width: (get_i64("/style/surface/desktop/gap_width", 16).max(0)) as f64,
 449         cell_inset: get_i64("/style/surface/desktop/cell_fade_inset", 0) as f64,
 450         // The silhouette radius (cce-ui RFC Phase 7a spelling).
 451         corner_radius: get_i64("/style/surface/plate/root/corner_radius", 12) as f64,
 452         gap_color: linear(
 453             get_color("/style/surface/desktop/gap_color")
 454                 .unwrap_or([0.686, 0.796, 0.867, 1.0]),
 455         ),
 456         cell_color: linear(
 457             get_color("/style/surface/desktop/cell_color")
 458                 .unwrap_or([0.0, 0.0, 0.0, 1.0]),
 459         ),
 460         line_relief: match cce_ui::config::get_string("/style/surface/desktop/line_relief") {
 461             // A string value is a (relief) spec; an unparseable one reads
 462             // as unset rather than as some accidental width.
 463             Some(s) => match cce_ui::relief_spec::ReliefSpec::parse(&s) {
 464                 Some(spec) => LineRelief::Spec(s, spec),
 465                 None => LineRelief::Material,
 466             },
 467             None => match get_i64("/style/surface/desktop/line_relief", -1) {
 468                 v if v < 0 => LineRelief::Material,
 469                 v => LineRelief::Width(v as f64),
 470             },
 471         },
 472         handle_width: cce_ui::config::get_f32("/style/surface/border/handle_width", 32.0).max(4.0) as f64,
 473         handle_color: linear(
 474             get_color("/style/surface/border/color_focused").unwrap_or([0.478, 0.635, 0.969, 1.0]),
 475         ),
 476         handle_hover_color: linear(
 477             get_color("/style/surface/border/color_hover").unwrap_or([0.659, 0.780, 0.980, 1.0]),
 478         ),
 479     }
 480 }
 481 
 482 /// Never emit more cells than this per frame, whatever the patch/config says
 483 /// (a degenerate period must not turn into an unbounded display list).
 484 const MAX_CELLS: usize = 8192;
 485 
 486 impl GridApp {
 487     /// Install (or roll back) the process-wide material a `(relief)` value
 488     /// carries — depth into the style registry, profile into the wall LUT.
 489     /// This app draws nothing but the grid, so process-global IS
 490     /// per-feature; a change detector keeps it idempotent per frame.
 491     fn sync_relief_material(&mut self, line_relief: &LineRelief) {
 492         match line_relief {
 493             LineRelief::Spec(raw, spec) => {
 494                 if self.applied_relief.as_deref() == Some(raw.as_str()) {
 495                     return;
 496                 }
 497                 if self.base_depth.is_none() {
 498                     self.base_depth = Some(cce_ui::layout::bevel_depth());
 499                 }
 500                 if self.base_height.is_none() {
 501                     self.base_height = Some(cce_ui::layout::bevel_height().unwrap_or(0.0));
 502                 }
 503                 if let Ok(mut reg) = cce_ui::layout::get_style_registry().write() {
 504                     if let Some(d) = spec.light {
 505                         reg.set_float("bevel_depth", d);
 506                     }
 507                     // A pinned drop (`h=0.5mm`) is a length: stored as one,
 508                     // so it re-resolves if the display metric changes.
 509                     match spec.height {
 510                         Some(h) => reg.set_len("bevel_height", h),
 511                         None => {
 512                             if let Some(b) = self.base_height {
 513                                 reg.set_float("bevel_height", b);
 514                             }
 515                         }
 516                     }
 517                 }
 518                 cce_ui::layout::install_wall_profile_spec(spec.profile.as_deref());
 519                 self.applied_relief = Some(raw.clone());
 520             }
 521             _ if self.applied_relief.is_some() => {
 522                 // The key reverted to a plain width or vanished: back to
 523                 // the DE-wide material the registry still carries.
 524                 if let Ok(mut reg) = cce_ui::layout::get_style_registry().write() {
 525                     if let Some(d) = self.base_depth.take() {
 526                         reg.set_float("bevel_depth", d);
 527                     }
 528                     if let Some(h) = self.base_height.take() {
 529                         reg.set_float("bevel_height", h);
 530                     }
 531                 }
 532                 let global = cce_ui::layout::get_style_registry()
 533                     .read()
 534                     .ok()
 535                     .and_then(|reg| reg.get_string("bevel_profile_spec"));
 536                 cce_ui::layout::install_wall_profile_spec(global.as_deref());
 537                 self.applied_relief = None;
 538             }
 539             _ => {}
 540         }
 541     }
 542 
 543     fn paint(&mut self, pc: &mut PaintCtx, size: LogicalSize) {
 544         let Some(p) = self.patch else { return };
 545         if p.scale <= 0.0 {
 546             return;
 547         }
 548         let st = style();
 549         let period_x = st.cell_w + st.gap_width;
 550         let period_y = st.cell_h + st.gap_width;
 551         if period_x < 1.0 || period_y < 1.0 {
 552             return;
 553         }
 554 
 555         // The rail surface: the whole patch in gap color.
 556         pc.quad(
 557             Rect { x: 0.0, y: 0.0, width: size.width as f32, height: size.height as f32 },
 558             st.gap_color,
 559         );
 560 
 561         // Visible cell box within its period slot, in virtual units.
 562         let inset_x = st.cell_inset.clamp(0.0, (st.cell_w / 2.0 - 1.0).max(0.0));
 563         let inset_y = st.cell_inset.clamp(0.0, (st.cell_h / 2.0 - 1.0).max(0.0));
 564         let len_w = st.cell_w - 2.0 * inset_x;
 565         let len_h = st.cell_h - 2.0 * inset_y;
 566         let s = p.scale;
 567         // Span-widened like every other corner in the DE (window clips,
 568         // fallback cells, cce-ui plates): at corner_shape > 2 a raw-radius
 569         // superellipse hugs the corner and reads nearly square, and a tiled
 570         // window's widened arc must land exactly on its cell's. Clamped to a
 571         // quarter sweep like the compositor's widen_corner_radius.
 572         let cell_px = len_w.min(len_h) * s;
 573         let radius = ((st.corner_radius * s)
 574             * cce_ui::layout::corner_span_factor() as f64)
 575             .min(cell_px / 2.0) as f32;
 576         // The relief lives on the LINES, never the cells: each cell's recess
 577         // rect is expanded past the cell edge so the wall sits in the rail
 578         // band, rolling down from the rail face to the cell floor. The roll
 579         // is the ROOT_PLATE-EDGE treatment — `layout::bevel_width` clamped to
 580         // a fraction of the rail, the widget convention — so the rail reads
 581         // as a flat plate face with a narrow lip where it meets each sunken
 582         // cell. (A half-gap-wide wall turned the whole rail into a ramp and
 583         // read far heavier than any plate edge in the toolkit.) Rings stay
 584         // inside their own half-rail, so neighbors never overlap; the outer
 585         // radius offsets by the roll to stay concentric with the cell arc.
 586         // style.surface.desktop.line_relief overrides the roll: a plain
 587         // width (0 = no lip), or a full (relief) value carrying its own
 588         // width/depth/profile. Explicit widths clamp to the half-rail (the
 589         // rings' geometric budget); the material default keeps the tighter
 590         // root plate clamp.
 591         self.sync_relief_material(&st.line_relief);
 592         let roll = match &st.line_relief {
 593             LineRelief::Material => {
 594                 (cce_ui::layout::bevel_width() as f64).min(st.gap_width * 0.25)
 595             }
 596             LineRelief::Width(w) => w.min(st.gap_width / 2.0),
 597             LineRelief::Spec(_, spec) => (spec.width as f64).min(st.gap_width / 2.0),
 598         }
 599         .max(0.0)
 600             * s;
 601         let lip = roll >= 0.5;
 602 
 603         // One extra ring of cells beyond the patch: a border cell outside the
 604         // patch still owns the inner half of the boundary rail's shading.
 605         let col0 = (p.x / period_x).floor() as i64 - 1;
 606         let col1 = ((p.x + p.w) / period_x).ceil() as i64 + 1;
 607         let row0 = (p.y / period_y).floor() as i64 - 1;
 608         let row1 = ((p.y + p.h) / period_y).ceil() as i64 + 1;
 609         let mut cells = 0usize;
 610         for col in col0..col1 {
 611             for row in row0..row1 {
 612                 if cells >= MAX_CELLS {
 613                     return;
 614                 }
 615                 cells += 1;
 616                 let vx = col as f64 * period_x + inset_x;
 617                 let vy = row as f64 * period_y + inset_y;
 618                 let rect = Rect {
 619                     x: ((vx - p.x) * s) as f32,
 620                     y: ((vy - p.y) * s) as f32,
 621                     width: (len_w * s) as f32,
 622                     height: (len_h * s) as f32,
 623                 };
 624                 pc.rounded_rect(rect, radius, (true, true, true, true), st.cell_color);
 625             }
 626         }
 627         // The relief on the LINES is ONE primitive for the whole patch: a
 628         // lattice carve, folded per pixel to the nearest cell, so the rail
 629         // between two cells and the crossing where four meet are a single
 630         // profile evaluation — true mitres. This replaced one recess ring per
 631         // cell: N free overlays whose rounded corners stacked in colour space
 632         // at every crossing and read as overlapping effects, and whose ring
 633         // walls (straddling a boundary inflated by the roll) overlapped each
 634         // other down the rail centre whenever the roll exceeded a quarter
 635         // gap. The wall runs from each cell's edge outward over `roll`.
 636         if lip {
 637             let origin = (
 638                 ((inset_x + len_w * 0.5 - p.x) * s) as f32,
 639                 ((inset_y + len_h * 0.5 - p.y) * s) as f32,
 640             );
 641             pc.lattice(
 642                 Rect { x: 0.0, y: 0.0, width: size.width as f32, height: size.height as f32 },
 643                 ((period_x * s) as f32, (period_y * s) as f32),
 644                 origin,
 645                 ((len_w * s) as f32, (len_h * s) as f32),
 646                 radius,
 647                 (roll as f32).max(1.0),
 648             );
 649         }
 650 
 651         // Pinned items sit ON the canvas, so they are placed by the same
 652         // world->patch mapping as the cells and drawn after them. The whole
 653         // grid surface is below every window, so an item never covers an app.
 654         self.layout_cards(&p, size);
 655         let screen = |x: f64, y: f64, w: f64, h: f64| Rect {
 656             x: ((x - p.x) * s) as f32,
 657             y: ((y - p.y) * s) as f32,
 658             width: (w * s) as f32,
 659             height: (h * s) as f32,
 660         };
 661         // Cull off-patch items: at a far zoom-out the patch can hold
 662         // hundreds of squares, and an item that is not on it costs a draw
 663         // for nothing. A margin keeps a card's label (drawn above it).
 664         let on_patch = |r: &Rect| {
 665             let m = (CARD_LABEL * 2.0 * s) as f32;
 666             !(r.x + r.width < -m || r.y + r.height < -m || r.x > size.width as f32 + m || r.y > size.height as f32 + m)
 667         };
 668         // Groups are frames behind everything else.
 669         for (item, _) in &self.items {
 670             let Kind::Group(label) = &item.kind else { continue };
 671             let r = screen(item.x, item.y, item.w, item.h);
 672             if !on_patch(&r) {
 673                 continue;
 674             }
 675             let mut c = canvas_color(item.color.as_deref()).unwrap_or([0.55, 0.55, 0.6, 1.0]);
 676             let rad = (CARD_RADIUS * s) as f32;
 677             let mut fill = c;
 678             fill[3] = 0.07;
 679             c[3] = 0.6;
 680             pc.border(r, (rad, rad, rad, rad), fill, c, (2.0 * s).max(1.0) as f32);
 681             if !label.is_empty() {
 682                 let size = (CARD_LABEL * 1.4 * s) as f32;
 683                 pc.text_with(label.clone(), r.x, r.y - size * 1.5, size, srgb_u8(cce_ui::colors::TEXT_FG), Some("sans-serif".into()), None);
 684             }
 685         }
 686         // Edges under the cards, as Obsidian draws them.
 687         self.paint_edges(pc, &p);
 688         for (index, (item, id)) in self.items.iter().enumerate() {
 689             let rect = screen(item.x, item.y, item.w, item.h);
 690             if !on_patch(&rect) {
 691                 continue;
 692             }
 693             match &item.kind {
 694                 Kind::Image(_) => {
 695                     if let Some(id) = *id {
 696                         pc.image(id, rect, 1.0);
 697                     }
 698                 }
 699                 Kind::Group(_) | Kind::Other(_) => continue,
 700                 _ => {
 701                     // Text draws after every shape, so an item above cannot
 702                     // hide this card's text by covering it: clip the text to
 703                     // what no higher item covers instead.
 704                     let above: Vec<Rect> = self.items[index + 1..]
 705                         .iter()
 706                         .filter(|(o, _)| o.interactive())
 707                         .map(|(o, _)| screen(o.x, o.y, o.w, o.h))
 708                         .collect();
 709                     self.paint_card(pc, &p, item, rect, &above)
 710                 }
 711             }
 712             // "Connect to…" is waiting for its far end: ring the near one.
 713             if self.connecting.as_deref() == Some(item.node.as_str()) {
 714                 let rad = (CARD_RADIUS * s) as f32;
 715                 pc.border(rect, (rad, rad, rad, rad), [0.0, 0.0, 0.0, 0.0], st.handle_hover_color, (3.0 * s).max(1.5) as f32);
 716             }
 717             // Window-adjust mode: the hovered item's four corner handles, in
 718             // the same colours as the windows' handles, the hovered one lit.
 719             // Sized in virtual units, so they scale with the canvas rather
 720             // than holding a screen size the way the compositor's do — this
 721             // client never learns the camera zoom.
 722             if self.adjust && self.hover_item == Some(index) {
 723                 let (r, discs) = handle_discs(item, st.handle_width);
 724                 for (corner, cx, cy) in discs {
 725                     let color = if self.hover == Some((index, corner)) {
 726                         st.handle_hover_color
 727                     } else {
 728                         st.handle_color
 729                     };
 730                     pc.circle(((cx - p.x) * s) as f32, ((cy - p.y) * s) as f32, (r * s) as f32, color);
 731                 }
 732             }
 733         }
 734     }
 735 
 736     /// Lay out every on-patch note and text card that has no layout yet, or
 737     /// one for another width or text. A note's text is read here, once;
 738     /// a change to it on disk drops the cache (`vault_changed`).
 739     fn layout_cards(&mut self, p: &Patch, size: LogicalSize) {
 740         let s = p.scale;
 741         let mut jobs: Vec<(String, String, f64)> = Vec::new();
 742         for (item, _) in &self.items {
 743             let lead = if matches!(item.kind, Kind::Link(_)) { link_glyph_room() as f64 } else { 0.0 };
 744             let width = (item.w - 2.0 * CARD_PAD - lead).max(20.0);
 745             let source = match &item.kind {
 746                 Kind::Note(path) => {
 747                     if self.cards.get(&item.node).is_some_and(|c| (c.width - width).abs() < 0.5) {
 748                         continue;
 749                     }
 750                     std::fs::read_to_string(path).unwrap_or_else(|_| format!("*{} is missing*", item.name()))
 751                 }
 752                 Kind::Text(t) => t.clone(),
 753                 // The URL alone; `paint_card` leads it with the `link` glyph.
 754                 Kind::Link(u) => u.clone(),
 755                 Kind::File(_) => format!("**{}**", item.name()),
 756                 _ => continue,
 757             };
 758             let (rx, ry) = ((item.x - p.x) * s, (item.y - p.y) * s);
 759             if rx + item.w * s < 0.0 || ry + item.h * s < 0.0 || rx > size.width as f64 || ry > size.height as f64 {
 760                 continue;
 761             }
 762             if self.cards.get(&item.node).is_some_and(|c| c.source == source && (c.width - width).abs() < 0.5) {
 763                 continue;
 764             }
 765             jobs.push((item.node.clone(), source, width));
 766         }
 767         if jobs.is_empty() {
 768             return;
 769         }
 770         let theme = Theme { body_font: "sans-serif".into(), mono_font: "monospace".into(), size: CARD_TEXT };
 771         let m = self.measure.get_or_insert_with(|| ShapingMeasure::new(true));
 772         for (node, source, width) in jobs {
 773             let blocks = markdown::blocks(&source);
 774             // No index here to resolve links against: they all draw as links.
 775             let layout = markdown::layout(&blocks, width as f32, &theme, m, &|_| true);
 776             self.cards.insert(node, CardCache { source, width, layout });
 777         }
 778     }
 779 
 780     fn paint_card(&self, pc: &mut PaintCtx, p: &Patch, item: &items::DesktopItem, r: Rect, above: &[Rect]) {
 781         let s = p.scale;
 782         let rad = (CARD_RADIUS * s) as f32;
 783         let border = canvas_color(item.color.as_deref()).unwrap_or([0.42, 0.42, 0.48, 1.0]);
 784         pc.border(r, (rad, rad, rad, rad), [0.06, 0.06, 0.08, 1.0], border, (1.5 * s).max(1.0) as f32);
 785         // A file card is labelled with its name above it, as in Obsidian.
 786         if matches!(item.kind, Kind::Note(_) | Kind::File(_)) {
 787             let size = (CARD_LABEL * s) as f32;
 788             let name = item.name();
 789             let name = name.strip_suffix(".md").unwrap_or(&name).to_string();
 790             let strip = Rect { x: r.x, y: r.y - size * 1.8, width: r.width, height: size * 1.8 };
 791             for frag in uncovered(strip, above) {
 792                 pc.clip(frag, |pc| {
 793                     pc.text_with(name.clone(), r.x + rad * 0.5, r.y - size * 1.6, size, srgb_u8(cce_ui::colors::TEXT_DIM), Some("sans-serif".into()), None)
 794                 });
 795             }
 796         }
 797         if let Some(c) = self.cards.get(&item.node) {
 798             let pad = (CARD_PAD * s) as f32;
 799             let inner = Rect { x: r.x + pad * 0.5, y: r.y + pad * 0.5, width: r.width - pad, height: r.height - pad };
 800             // A link card's URL is led by the `link` glyph, in the colour
 801             // the URL is drawn in, centred on its first line — the way
 802             // cce-ui's markdown leads an embed it cannot show inline. The
 803             // URL was laid out `link_glyph_room` narrower to make the room.
 804             let glyph = matches!(item.kind, Kind::Link(_)).then(|| {
 805                 let (size, side) = (CARD_TEXT, link_glyph_side());
 806                 let (y, color) = c
 807                     .layout
 808                     .draws
 809                     .iter()
 810                     .find_map(|d| match d {
 811                         markdown::Draw::Text { y, color, .. } => Some((*y, *color)),
 812                         _ => None,
 813                     })
 814                     .unwrap_or((0.0, cce_ui::colors::TEXT_FG));
 815                 // A text draw's colour is linear (it is painted through
 816                 // `srgb_u8`); a glyph is tinted in sRGB, as a text colour.
 817                 let color = cce_ui::colors::to_srgb(color);
 818                 let sf = s as f32;
 819                 let rect = Rect {
 820                     x: r.x + pad,
 821                     y: r.y + pad + (y + 0.5 * (size * 1.3 - side)) * sf,
 822                     width: side * sf,
 823                     height: side * sf,
 824                 };
 825                 (rect, color)
 826             });
 827             let lead = if glyph.is_some() { link_glyph_room() * s as f32 } else { 0.0 };
 828             for frag in uncovered(inner, above) {
 829                 pc.clip(frag, |pc| {
 830                     if let Some((rect, color)) = glyph {
 831                         pc.icon("link", rect, color);
 832                     }
 833                     c.layout.paint_scaled(pc, (r.x + pad + lead, r.y + pad), s as f32, frag)
 834                 });
 835             }
 836         }
 837     }
 838 
 839     /// Straight edges between side midpoints, an arrowhead at the far end.
 840     fn paint_edges(&self, pc: &mut PaintCtx, p: &Patch) {
 841         let s = p.scale;
 842         let by_node: std::collections::HashMap<&str, &items::DesktopItem> =
 843             self.items.iter().map(|(i, _)| (i.node.as_str(), i)).collect();
 844         let to_screen = |(x, y): (f64, f64)| (((x - p.x) * s) as f32, ((y - p.y) * s) as f32);
 845         let color = [0.62, 0.62, 0.68, 0.9];
 846         let width = (EDGE_WIDTH * s).max(1.0) as f32;
 847         // Edges lie under every card, so a label's text is clipped to what
 848         // the cards leave uncovered (text would otherwise draw over them).
 849         let cards: Vec<Rect> = self
 850             .items
 851             .iter()
 852             .filter(|(i, _)| i.interactive())
 853             .map(|(i, _)| Rect {
 854                 x: ((i.x - p.x) * s) as f32,
 855                 y: ((i.y - p.y) * s) as f32,
 856                 width: (i.w * s) as f32,
 857                 height: (i.h * s) as f32,
 858             })
 859             .collect();
 860         for e in &self.edges {
 861             let (Some(a), Some(b)) = (by_node.get(e.from.as_str()), by_node.get(e.to.as_str())) else { continue };
 862             let (pa, _) = anchor(a, e.from_side.as_deref(), center(b));
 863             let (pb, nb) = anchor(b, e.to_side.as_deref(), center(a));
 864             let (sa, sb) = (to_screen(pa), to_screen(pb));
 865             pc.vector(sa.0, sa.1, sb.0, sb.1, width, color, Cap::Round);
 866             if e.arrow {
 867                 // The head points into the far node, along its side's normal.
 868                 let len = ARROW_LEN;
 869                 let base = (pb.0 + nb.0 * len, pb.1 + nb.1 * len);
 870                 let perp = (-nb.1 * len * 0.5, nb.0 * len * 0.5);
 871                 for side in [1.0, -1.0] {
 872                     let wing = to_screen((base.0 + perp.0 * side, base.1 + perp.1 * side));
 873                     pc.vector(sb.0, sb.1, wing.0, wing.1, width, color, Cap::Round);
 874                 }
 875             }
 876             if let Some(label) = e.label.as_deref().filter(|l| !l.is_empty()) {
 877                 let size = (CARD_LABEL * s) as f32;
 878                 let mid = ((sa.0 + sb.0) / 2.0, (sa.1 + sb.1) / 2.0);
 879                 let w = label.chars().count() as f32 * size * 0.55;
 880                 pc.rounded_rect(
 881                     Rect { x: mid.0 - w / 2.0 - 4.0, y: mid.1 - size * 0.7, width: w + 8.0, height: size * 1.4 },
 882                     size * 0.3,
 883                     (true, true, true, true),
 884                     [0.06, 0.06, 0.08, 0.9],
 885                 );
 886                 let strip = Rect { x: mid.0 - w / 2.0 - 4.0, y: mid.1 - size * 0.7, width: w + 8.0, height: size * 1.4 };
 887                 for frag in uncovered(strip, &cards) {
 888                     pc.clip(frag, |pc| {
 889                         pc.text_with(label.to_string(), mid.0 - w / 2.0, mid.1 - size * 0.5, size, srgb_u8(cce_ui::colors::TEXT_FG), Some("sans-serif".into()), None)
 890                     });
 891                 }
 892             }
 893         }
 894     }
 895 }
 896 
 897 /// The parts of `r` that none of `over` covers, as rectangles: each
 898 /// overlapping rect splits a fragment into up to four bands around it.
 899 fn uncovered(r: Rect, over: &[Rect]) -> Vec<Rect> {
 900     let mut frags = vec![r];
 901     for o in over {
 902         let mut next = Vec::with_capacity(frags.len());
 903         for f in frags {
 904             let (fx1, fy1, ox1, oy1) = (f.x + f.width, f.y + f.height, o.x + o.width, o.y + o.height);
 905             if o.x >= fx1 || ox1 <= f.x || o.y >= fy1 || oy1 <= f.y {
 906                 next.push(f);
 907                 continue;
 908             }
 909             let (top, bottom) = (o.y.max(f.y), oy1.min(fy1));
 910             let bands = [
 911                 Rect { x: f.x, y: f.y, width: f.width, height: top - f.y },
 912                 Rect { x: f.x, y: bottom, width: f.width, height: fy1 - bottom },
 913                 Rect { x: f.x, y: top, width: o.x - f.x, height: bottom - top },
 914                 Rect { x: ox1, y: top, width: fx1 - ox1, height: bottom - top },
 915             ];
 916             next.extend(bands.into_iter().filter(|b| b.width > 0.5 && b.height > 0.5));
 917         }
 918         frags = next;
 919     }
 920     frags
 921 }
 922 
 923 fn center(i: &items::DesktopItem) -> (f64, f64) {
 924     (i.x + i.w / 2.0, i.y + i.h / 2.0)
 925 }
 926 
 927 /// Where an edge meets an item: the midpoint of the named side, or of the
 928 /// side facing `toward`; with that side's outward normal.
 929 fn anchor(i: &items::DesktopItem, side: Option<&str>, toward: (f64, f64)) -> ((f64, f64), (f64, f64)) {
 930     let (cx, cy) = center(i);
 931     let side = side.map(str::to_string).unwrap_or_else(|| {
 932         let (dx, dy) = (toward.0 - cx, toward.1 - cy);
 933         if dx.abs() * i.h >= dy.abs() * i.w {
 934             if dx >= 0.0 { "right" } else { "left" }.to_string()
 935         } else if dy >= 0.0 {
 936             "bottom".to_string()
 937         } else {
 938             "top".to_string()
 939         }
 940     });
 941     match side.as_str() {
 942         "left" => ((i.x, cy), (-1.0, 0.0)),
 943         "top" => ((cx, i.y), (0.0, -1.0)),
 944         "bottom" => ((cx, i.y + i.h), (0.0, 1.0)),
 945         _ => ((i.x + i.w, cy), (1.0, 0.0)),
 946     }
 947 }
 948 
 949 /// The `link` glyph's side on a link card, in card units (the size cce-ui's
 950 /// markdown gives the glyph leading an embed).
 951 fn link_glyph_side() -> f32 {
 952     (CARD_TEXT * 0.85).round()
 953 }
 954 
 955 /// What a link card's URL is moved right by: the glyph and a gap.
 956 fn link_glyph_room() -> f32 {
 957     link_glyph_side() + (CARD_TEXT * 0.4).round()
 958 }
 959 
 960 fn srgb_u8(linear: [f32; 4]) -> [u8; 3] {
 961     let c = cce_ui::colors::to_srgb(linear);
 962     [(c[0] * 255.0) as u8, (c[1] * 255.0) as u8, (c[2] * 255.0) as u8]
 963 }
 964 
 965 impl GridApp {
 966     /// Record that item `index`'s rect, as it is NOW, differs from the last
 967     /// painted frame. A move or resize calls it before and after the edit.
 968     /// The handle discs lie inside the rect, so they are covered too.
 969     fn touch(&mut self, index: usize) {
 970         let Some((item, _)) = self.items.get(index) else { return };
 971         // A card's label sits above it, and an edge's head and label stray
 972         // a little past the rects it joins.
 973         let m = CARD_LABEL * 2.5 + ARROW_LEN;
 974         let (mut x0, mut y0, mut x1, mut y1) = (item.x - m, item.y - m, item.x + item.w + m, item.y + item.h + m);
 975         // An edge runs between its two items, so moving one end repaints the
 976         // box spanning both.
 977         let node = item.node.as_str();
 978         for e in &self.edges {
 979             let other = if e.from == node {
 980                 &e.to
 981             } else if e.to == node {
 982                 &e.from
 983             } else {
 984                 continue;
 985             };
 986             if let Some((o, _)) = self.items.iter().find(|(o, _)| o.node == *other) {
 987                 x0 = x0.min(o.x - m);
 988                 y0 = y0.min(o.y - m);
 989                 x1 = x1.max(o.x + o.w + m);
 990                 y1 = y1.max(o.y + o.h + m);
 991             }
 992         }
 993         self.damage = match self.damage {
 994             Damage::Nothing => Damage::Rect(x0, y0, x1, y1),
 995             Damage::Rect(a, b, c, d) => Damage::Rect(a.min(x0), b.min(y0), c.max(x1), d.max(y1)),
 996             Damage::Full => Damage::Full,
 997         };
 998     }
 999 
1000     /// The topmost item under a virtual-canvas point.
1001     fn item_at(&self, vx: f64, vy: f64) -> Option<usize> {
1002         self.items.iter().rposition(|(i, _)| {
1003             i.interactive() && vx >= i.x && vx < i.x + i.w && vy >= i.y && vy < i.y + i.h
1004         })
1005     }
1006 
1007     /// The corner handle under a virtual-canvas point, with a unit of slack
1008     /// around the disc's antialiased rim. Only the hovered item's handles
1009     /// are up, so only its discs can be hit.
1010     fn corner_at(&self, vx: f64, vy: f64) -> Option<(usize, Corner)> {
1011         if !self.adjust {
1012             return None;
1013         }
1014         let diameter = style().handle_width;
1015         for (index, (item, _)) in self.items.iter().enumerate().rev() {
1016             if self.hover_item != Some(index) {
1017                 continue;
1018             }
1019             let (r, discs) = handle_discs(item, diameter);
1020             let reach = (r + 1.0) * (r + 1.0);
1021             for (corner, cx, cy) in discs {
1022                 let (dx, dy) = (vx - cx, vy - cy);
1023                 if dx * dx + dy * dy <= reach {
1024                     return Some((index, corner));
1025                 }
1026             }
1027         }
1028         None
1029     }
1030 
1031     /// The board file's vault path (`Desktop.canvas`), what an attachment
1032     /// for it is placed relative to.
1033     fn board_file(&self) -> String {
1034         let vault = self.board.vault.as_deref();
1035         vault
1036             .and_then(|v| self.board.path.strip_prefix(v).ok())
1037             .map(|p| p.to_string_lossy().into_owned())
1038             .unwrap_or_else(|| "Desktop.canvas".to_string())
1039     }
1040 
1041     /// Images pinned before the board kept them in the vault point outside
1042     /// it (the desktop folder), so they reach no other device and Obsidian
1043     /// shows them missing. Copy each in — the original stays put — and
1044     /// repoint its node. Runs at startup; a no-op once all are inside.
1045     fn adopt_outside_images(&mut self) {
1046         let Some(vault) = self.board.vault.clone() else { return };
1047         let board_file = self.board_file();
1048         let mut moved = 0;
1049         for (item, _) in &mut self.items {
1050             if let Kind::Image(p) = &item.kind {
1051                 if let Some(new) = items::adopt_into_vault(p, &vault, &board_file) {
1052                     log::info!("[board] copied {} into the vault as {}", p.display(), new.display());
1053                     item.kind = Kind::Image(new);
1054                     moved += 1;
1055                 }
1056             }
1057         }
1058         if moved > 0 {
1059             self.save_items();
1060         }
1061     }
1062 
1063     fn save_items(&mut self) {
1064         let model: Vec<items::DesktopItem> = self.items.iter().map(|(i, _)| i.clone()).collect();
1065         self.board.save(&model, &self.edges);
1066     }
1067 
1068     /// Double-click: a note opens in cce-notes, a link or a file in its
1069     /// default app.
1070     fn activate(&mut self, index: usize) {
1071         let Some((item, _)) = self.items.get(index) else { return };
1072         match &item.kind {
1073             Kind::Note(p) => items::open_in_notes(p),
1074             Kind::Image(p) | Kind::File(p) => items::open_externally(&p.to_string_lossy()),
1075             Kind::Link(u) => items::open_externally(u),
1076             _ => {}
1077         }
1078     }
1079 
1080     /// Pin a card next to item `index` (or at the patch centre).
1081     fn place_beside(&self, index: Option<usize>) -> (f64, f64) {
1082         match index.and_then(|i| self.items.get(i)) {
1083             Some((it, _)) => (it.x + it.w + 40.0, it.y),
1084             None => self.patch.map(|p| (p.x + p.w / 2.0 - CARD_W / 2.0, p.y + p.h / 2.0 - CARD_H / 2.0)).unwrap_or((0.0, 0.0)),
1085         }
1086     }
1087 
1088     /// A new note in the vault ("Desktop note.md", numbered if taken),
1089     /// pinned as a card and opened in cce-notes for writing.
1090     fn new_note_card(&mut self, beside: Option<usize>) {
1091         let Some(vault) = self.board.vault.clone() else {
1092             items::report_failure("note cards need a notes vault (vault { path } in config.kdl)");
1093             return;
1094         };
1095         let mut path = vault.join("Desktop note.md");
1096         let mut n = 2;
1097         while path.exists() {
1098             path = vault.join(format!("Desktop note {n}.md"));
1099             n += 1;
1100         }
1101         if let Err(e) = std::fs::write(&path, "") {
1102             items::report_failure(&format!("could not create the note: {e}"));
1103             return;
1104         }
1105         let (x, y) = self.place_beside(beside);
1106         let mut item = items::DesktopItem::new(board::new_id(), Kind::Note(path.clone()), x, y, CARD_W, CARD_H);
1107         self.assign_id(&mut item);
1108         self.items.push((item, None));
1109         self.save_items();
1110         self.report_items();
1111         items::open_in_notes(&path);
1112     }
1113 
1114     /// A text card becomes a note in the vault, named by its first line,
1115     /// and the card a note card of it — editable in cce-notes, where a
1116     /// text card is not editable here at all.
1117     fn convert_to_note(&mut self, index: usize) {
1118         let Some(vault) = self.board.vault.clone() else { return };
1119         let Some((item, _)) = self.items.get(index) else { return };
1120         let Kind::Text(text) = item.kind.clone() else { return };
1121         let stem: String = item
1122             .name()
1123             .chars()
1124             .map(|c| if "/\\:*?\"<>|#^[]".contains(c) { ' ' } else { c })
1125             .collect::<String>()
1126             .trim()
1127             .trim_start_matches(['#', ' '])
1128             .to_string();
1129         let stem = if stem.is_empty() { "Desktop note".to_string() } else { stem };
1130         let mut path = vault.join(format!("{stem}.md"));
1131         let mut n = 2;
1132         while path.exists() {
1133             path = vault.join(format!("{stem} {n}.md"));
1134             n += 1;
1135         }
1136         if let Err(e) = std::fs::write(&path, &text) {
1137             items::report_failure(&format!("could not create the note: {e}"));
1138             return;
1139         }
1140         self.items[index].0.kind = Kind::Note(path);
1141         self.cards.remove(&self.items[index].0.node);
1142         self.save_items();
1143     }
1144 
1145     fn menu_action(&mut self, node: &str, action: &str) {
1146         let Some(index) = self.items.iter().position(|(i, _)| i.node == node) else { return };
1147         match action {
1148             "open" => self.activate(index),
1149             "connect" => self.connecting = Some(node.to_string()),
1150             "disconnect" => {
1151                 self.edges.retain(|e| e.from != node && e.to != node);
1152                 self.save_items();
1153             }
1154             "new_note" => self.new_note_card(Some(index)),
1155             "convert" => self.convert_to_note(index),
1156             "remove" => {
1157                 // A drag or resize on the removed item cannot outlive it.
1158                 self.dragging = None;
1159                 self.resizing = None;
1160                 self.hover = None;
1161                 self.hover_item = None;
1162                 let (item, id) = self.items.remove(index);
1163                 if let Some(id) = id {
1164                     cce_ui::vk::free_image(id);
1165                 }
1166                 self.edges.retain(|e| e.from != node && e.to != node);
1167                 self.cards.remove(node);
1168                 self.save_items();
1169                 // The file itself stays where it was: this unpins it from the
1170                 // desktop, it does not delete the user's file.
1171                 log::info!("[items] removed {} from the desktop", item.name());
1172                 self.report_items();
1173             }
1174             _ => {}
1175         }
1176     }
1177 
1178     /// The board or a card's note changed on disk.
1179     fn vault_changed(&mut self, paths: &[std::path::PathBuf]) -> bool {
1180         let mut changed = false;
1181         // A card's note: lay it out again from the new text.
1182         for (item, _) in &self.items {
1183             if let Kind::Note(p) = &item.kind {
1184                 if paths.iter().any(|c| c == p) {
1185                     self.cards.remove(&item.node);
1186                     changed = true;
1187                 }
1188             }
1189         }
1190         if paths.iter().any(|c| *c == self.board.path) && self.board.changed_on_disk() {
1191             match self.board.read() {
1192                 Ok((fresh, edges)) => {
1193                     // Keep uploaded textures for images that are still there.
1194                     let mut old: Vec<(items::DesktopItem, Option<u32>)> = std::mem::take(&mut self.items);
1195                     for mut item in fresh {
1196                         let tex = old
1197                             .iter()
1198                             .position(|(o, _)| o.node == item.node && o.kind == item.kind)
1199                             .and_then(|i| old.swap_remove(i).1);
1200                         self.assign_id(&mut item);
1201                         self.items.push((item, tex));
1202                     }
1203                     for (_, id) in old {
1204                         if let Some(id) = id {
1205                             cce_ui::vk::free_image(id);
1206                         }
1207                     }
1208                     self.edges = edges;
1209                     self.cards.clear();
1210                     self.dragging = None;
1211                     self.resizing = None;
1212                     self.hover = None;
1213                     self.hover_item = None;
1214                     self.upload_missing();
1215                     self.report_items();
1216                     log::info!("[board] reloaded {} after an outside edit", self.board.path.display());
1217                     changed = true;
1218                 }
1219                 Err(e) => log::warn!("[board] {} changed but does not read ({e}); keeping what is shown", self.board.path.display()),
1220             }
1221         }
1222         changed
1223     }
1224 
1225     /// Decode and upload images that have no texture yet (restored, or
1226     /// arrived by an outside edit).
1227     fn upload_missing(&mut self) {
1228         for (item, id) in self.items.iter_mut() {
1229             if id.is_some() {
1230                 continue;
1231             }
1232             let Kind::Image(path) = &item.kind else { continue };
1233             let Ok(bytes) = std::fs::read(path) else {
1234                 log::warn!("[items] {} is gone; not drawing it", path.display());
1235                 continue;
1236             };
1237             match items::decode_rgba(&bytes) {
1238                 Some((pixels, w, h)) => *id = Some(cce_ui::vk::upload_rgba(pixels, w, h)),
1239                 None => log::warn!("[items] {} did not decode", path.display()),
1240             }
1241         }
1242     }
1243 
1244     /// Name an item for the compositor's benefit (`DesktopItem::id`).
1245     fn assign_id(&mut self, item: &mut items::DesktopItem) {
1246         self.next_id += 1;
1247         item.id = self.next_id;
1248     }
1249 
1250     /// Tell the compositor what is pinned and where: `grid-items
1251     /// <id>:<x>:<y>:<w>:<h> ...`, virtual units, in draw order (last on
1252     /// top), the whole list every time. It is what lets the overview
1253     /// drag-selection pick images up beside windows and carry them in a
1254     /// group move — the compositor otherwise knows the images only as this
1255     /// surface's input region. Sent on every change to the list or to a
1256     /// rect this client made itself; the compositor moves its own copy
1257     /// during a group move and hears the settled list on `drop`.
1258     fn report_items(&self) {
1259         let mut line = String::from("grid-items");
1260         for (item, _) in self.items.iter().filter(|(i, _)| i.interactive()) {
1261             line.push_str(&format!(
1262                 " {}:{:.2}:{:.2}:{:.2}:{:.2}",
1263                 item.id, item.x, item.y, item.w, item.h
1264             ));
1265         }
1266         let _ = self.reporter.send(line);
1267     }
1268 }
1269 
1270 impl Application for GridApp {
1271     type Message = Message;
1272 
1273     fn create(_sender: cce_ui::engine::AppSender<Self::Message>) -> Self {
1274         // The app keeps calloop's sender; `AppSender` converts into it.
1275         let _sender: calloop::channel::Sender<Self::Message> = _sender.into();
1276         spawn_topic_listener("adjust", _sender.clone(), |line| {
1277             Some(Message::AdjustMode(line == "on"))
1278         });
1279         spawn_topic_listener("selection", _sender.clone(), parse_selection_line);
1280         let (board, loaded, edges) = board::Board::open();
1281         // The board's folder is watched when it is a vault: the board file
1282         // itself (an Obsidian edit, a sync) and the notes cards show.
1283         let watcher = board.vault.as_ref().and_then(|v| {
1284             let tx = _sender.clone();
1285             cce_vault::VaultWatcher::spawn(v, move |paths| {
1286                 let _ = tx.send(Message::VaultChanged(paths));
1287             })
1288             .map_err(|e| log::warn!("[board] vault watcher: {e}"))
1289             .ok()
1290         });
1291         let mut app = Self {
1292             patch: None,
1293             items: Vec::new(),
1294             edges,
1295             board,
1296             _watcher: watcher,
1297             cards: std::collections::HashMap::new(),
1298             measure: None,
1299             connecting: None,
1300             last_press: None,
1301             sender: _sender,
1302             reporter: spawn_reporter(),
1303             next_id: 0,
1304             dragging: None,
1305             adjust: false,
1306             hover_item: None,
1307             hover: None,
1308             resizing: None,
1309             damage: Damage::Full,
1310             painted: None,
1311             applied_relief: None,
1312             base_depth: None,
1313             base_height: None,
1314         };
1315         for mut item in loaded {
1316             app.assign_id(&mut item);
1317             app.items.push((item, None));
1318         }
1319         app.adopt_outside_images();
1320         app.report_items();
1321         app
1322     }
1323 
1324     fn settings(&self) -> WindowSettings {
1325         WindowSettings {
1326             title: "Desktop Grid".to_string(),
1327             app_id: "cce-grid".to_string(),
1328             // Nominal initial size; every real size comes from a patch.
1329             width: 640,
1330             height: 480,
1331             fullscreen: false,
1332             min_size: None,
1333         }
1334     }
1335 
1336     fn grid(&self) -> bool {
1337         true
1338     }
1339 
1340     fn grid_patch(&mut self, x: f64, y: f64, w: f64, h: f64, scale: f64) {
1341         self.patch = Some(Patch { x, y, w, h, scale });
1342     }
1343 
1344     fn update(&mut self, msg: Self::Message, needs_rebuild: &mut bool, _exit: &mut bool) {
1345         // A group move step is one pointer event's worth of motion and
1346         // records the rects it touched, like a drag of this client's own.
1347         // The rest are rare, and each changes more than one item's rect (a
1348         // new texture, a reordered list, every handle): not worth a rect.
1349         if !matches!(msg, Message::SelectionMove(_)) {
1350             self.damage = Damage::Full;
1351         }
1352         match msg {
1353             Message::SelectionMove(moves) => {
1354                 for (id, x, y) in moves {
1355                     let Some(index) = self.items.iter().position(|(i, _)| i.id == id) else {
1356                         continue;
1357                     };
1358                     self.touch(index);
1359                     let (item, _) = &mut self.items[index];
1360                     item.x = x;
1361                     item.y = y;
1362                     self.touch(index);
1363                 }
1364                 *needs_rebuild = true;
1365             }
1366             Message::MenuAction { node, action } => {
1367                 self.menu_action(&node, &action);
1368                 *needs_rebuild = true;
1369             }
1370             Message::AddItem(mut item) => {
1371                 self.assign_id(&mut item);
1372                 log::info!("[items] pinned {} at ({:.0}, {:.0})", item.name(), item.x, item.y);
1373                 self.items.push((item, None));
1374                 self.save_items();
1375                 self.report_items();
1376                 *needs_rebuild = true;
1377             }
1378             Message::VaultChanged(paths) => {
1379                 if self.vault_changed(&paths) {
1380                     *needs_rebuild = true;
1381                 } else {
1382                     // Nothing this client shows: keep the frame as it is.
1383                     self.damage = Damage::Nothing;
1384                 }
1385             }
1386             Message::SelectionDrop => {
1387                 self.save_items();
1388                 self.report_items();
1389                 log::info!("[items] group move dropped; sidecar saved");
1390                 *needs_rebuild = true;
1391             }
1392             Message::ItemReady { mut item, pixels, px_w, px_h } => {
1393                 self.assign_id(&mut item);
1394                 let id = cce_ui::vk::upload_rgba(pixels, px_w, px_h);
1395                 log::info!("[items] pinned {} at ({:.0}, {:.0})", item.name(), item.x, item.y);
1396                 self.items.push((item, Some(id)));
1397                 // Persist only the model — the texture id is per-process.
1398                 self.save_items();
1399                 self.report_items();
1400                 *needs_rebuild = true;
1401             }
1402             Message::AdjustMode(on) => {
1403                 if self.adjust == on {
1404                     return;
1405                 }
1406                 self.adjust = on;
1407                 if !on {
1408                     // A resize in flight finishes on its release; only the
1409                     // highlight goes with the handles.
1410                     self.hover = None;
1411                 }
1412                 *needs_rebuild = true;
1413             }
1414         }
1415     }
1416 
1417     fn tick(&mut self, _dt: f32, _needs_rebuild: &mut bool) {}
1418 
1419     /// Uploads happen here, not in `new`: a reconnect builds a fresh renderer
1420     /// and does not replay earlier uploads, so items restored from the sidecar
1421     /// (and any pinned before the reconnect) have to be handed over again.
1422     fn renderer_init(&mut self, _renderer: &mut cce_ui::vk::VkRenderer) {
1423         for (_, id) in self.items.iter_mut() {
1424             *id = None;
1425         }
1426         self.upload_missing();
1427     }
1428 
1429     /// Note cards draw text, which this surface never did before: the
1430     /// glyph pass is on, and system fonts carry the sans's bold and italic
1431     /// faces (the bundle has only its regular). `ShapingMeasure::new(true)`
1432     /// loads the same set.
1433     fn display_list_text(&self) -> bool {
1434         true
1435     }
1436 
1437     fn load_system_fonts(&self) -> bool {
1438         true
1439     }
1440 
1441     /// What a browser offers for an image on a page, best first: the raw
1442     /// bytes if the source has them, else a link to fetch.
1443     fn drop_mimes(&self) -> &'static [&'static str] {
1444         &[
1445             // Pixels beat links: no fetch, no ambiguity. Firefox offers these
1446             // for an image on a page; Chrome usually does not.
1447             "image/png",
1448             "image/jpeg",
1449             "image/gif",
1450             "image/webp",
1451             // Preferred over text/uri-list because it names the IMAGE. When a
1452             // thumbnail is wrapped in a link — Google Images' exact markup —
1453             // uri-list is the result page and fetching it yields HTML, not a
1454             // picture. For an unwrapped image the two agree, so this never
1455             // does worse.
1456             "text/html",
1457             "text/uri-list",
1458             "text/x-moz-url",
1459             "text/plain;charset=utf-8",
1460             "text/plain",
1461         ]
1462     }
1463 
1464     fn handle_drop(
1465         &mut self,
1466         mime: &str,
1467         data: &[u8],
1468         pos: LogicalPosition,
1469         _needs_rebuild: &mut bool,
1470     ) {
1471         let Some(patch) = self.patch else { return };
1472         if patch.scale <= 0.0 {
1473             return;
1474         }
1475         // Plain text that is not a link or a path becomes a text card,
1476         // whole (the URI parsing below would keep only its first line).
1477         if mime.starts_with("text/plain") {
1478             let text = String::from_utf8_lossy(data).trim().to_string();
1479             let first = text.lines().next().unwrap_or("").trim();
1480             let linkish = first.contains("://") || first.starts_with('/') || first.starts_with("data:");
1481             if !text.is_empty() && !linkish {
1482                 let s = patch.surface_per_virtual();
1483                 let (vx, vy) = (patch.x + pos.x as f64 / s, patch.y + pos.y as f64 / s);
1484                 let item = items::DesktopItem::new(board::new_id(), Kind::Text(text), vx - 125.0, vy - 60.0, 250.0, 120.0);
1485                 let _ = self.sender.send(Message::AddItem(item));
1486                 return;
1487             }
1488         }
1489         let Some(payload) = items::parse_payload(mime, data) else {
1490             items::report_failure(&format!("nothing usable in the dropped {mime}"));
1491             return;
1492         };
1493         // A Markdown file is a note card: nothing to fetch or decode.
1494         if let items::Payload::Uri(uri) = &payload {
1495             if let Some(path) = uri.strip_prefix("file://").map(items::percent_decode_path) {
1496                 if path.extension().is_some_and(|e| e.eq_ignore_ascii_case("md")) {
1497                     let s = patch.surface_per_virtual();
1498                     let (vx, vy) = (patch.x + pos.x as f64 / s, patch.y + pos.y as f64 / s);
1499                     let item = items::DesktopItem::new(
1500                         board::new_id(),
1501                         Kind::Note(path),
1502                         vx - CARD_W / 2.0,
1503                         vy - CARD_H / 2.0,
1504                         CARD_W,
1505                         CARD_H,
1506                     );
1507                     let _ = self.sender.send(Message::AddItem(item));
1508                     return;
1509                 }
1510             }
1511         }
1512         let link = match &payload {
1513             items::Payload::Uri(u) if u.starts_with("http://") || u.starts_with("https://") => Some(u.clone()),
1514             _ => None,
1515         };
1516 
1517         // The drop point in world coordinates — the inverse of the mapping
1518         // `paint` uses to place cells, so the image lands under the cursor
1519         // whatever the camera is doing.
1520         let s = patch.surface_per_virtual();
1521         let vx = patch.x + pos.x as f64 / s;
1522         let vy = patch.y + pos.y as f64 / s;
1523 
1524         // Sized to fit inside one grid cell, keeping aspect: a phone
1525         // screenshot would otherwise land several squares wide.
1526         let st = style();
1527         let (cell_w, cell_h) = (st.cell_w.max(16.0), st.cell_h.max(16.0));
1528         let sender = self.sender.clone();
1529         let mime = mime.to_string();
1530         let vault = self.board.vault.clone();
1531         let board_file = self.board_file();
1532         std::thread::spawn(move || {
1533             let (bytes, name) = match items::fetch(payload) {
1534                 Ok(v) => v,
1535                 Err(e) => {
1536                     items::report_failure(&format!("could not fetch it: {e}"));
1537                     return;
1538                 }
1539             };
1540             let Some((pixels, px_w, px_h)) = items::decode_rgba(&bytes) else {
1541                 // A web page rather than a picture: pin it as a link card.
1542                 if let Some(url) = link {
1543                     let item = items::DesktopItem::new(board::new_id(), Kind::Link(url), vx - 150.0, vy - 40.0, 300.0, 80.0);
1544                     let _ = sender.send(Message::AddItem(item));
1545                     return;
1546                 }
1547                 items::report_failure(&format!(
1548                     "the dropped {mime} is not an image cce can read ({} bytes)",
1549                     bytes.len()
1550                 ));
1551                 return;
1552             };
1553             // Save even though it is already decoded: the board points at a
1554             // file, and in a vault that file is what syncs with it.
1555             let path = match items::save_image(&bytes, &name, vault.as_deref(), &board_file) {
1556                 Ok(p) => p,
1557                 Err(e) => {
1558                     items::report_failure(&format!("could not save the image: {e}"));
1559                     return;
1560                 }
1561             };
1562             let fit = (cell_w / px_w as f64).min(cell_h / px_h as f64).min(1.0);
1563             let w = px_w as f64 * fit;
1564             let h = px_h as f64 * fit;
1565             // Centred on the drop point.
1566             let item = items::DesktopItem::new(board::new_id(), Kind::Image(path), vx - w / 2.0, vy - h / 2.0, w, h);
1567             let _ = sender.send(Message::ItemReady { item, pixels, px_w, px_h });
1568         });
1569     }
1570 
1571     /// Exactly the pinned items, in surface-local px. Everything else on this
1572     /// surface stays click-through: the compositor no longer forces the grid
1573     /// layer transparent, it just misses this region, so the desktop keeps its
1574     /// background clicks (menu, overview exit, panning) while a press ON an
1575     /// image reaches this client. An empty region — the no-items case — is
1576     /// wholly transparent, which is the old behaviour exactly.
1577     fn input_regions(&self) -> Option<Vec<(i32, i32, i32, i32)>> {
1578         let Some(p) = self.patch else { return Some(Vec::new()) };
1579         if p.scale <= 0.0 {
1580             return Some(Vec::new());
1581         }
1582         let s = p.surface_per_virtual();
1583         let out: Vec<(i32, i32, i32, i32)> = self
1584             .items
1585             .iter()
1586             .filter(|(item, _)| item.interactive())
1587             .map(|(item, _)| {
1588                 (
1589                     ((item.x - p.x) * s).round() as i32,
1590                     ((item.y - p.y) * s).round() as i32,
1591                     (item.w * s).round().max(1.0) as i32,
1592                     (item.h * s).round().max(1.0) as i32,
1593                 )
1594             })
1595             .collect();
1596         Some(out)
1597     }
1598 
1599     fn handle_pointer_move(&mut self, pos: LogicalPosition, needs_rebuild: &mut bool) {
1600         let Some(p) = self.patch else { return };
1601         if p.scale <= 0.0 {
1602             return;
1603         }
1604         let origin = (p.x, p.y);
1605         let s = p.surface_per_virtual();
1606 
1607         if let Some(rs) = self.resizing.as_mut() {
1608             let last_pos = rs.last_pos;
1609             rs.last_pos = (pos.x, pos.y);
1610             if rs.last_origin != origin {
1611                 rs.last_origin = origin;
1612                 return;
1613             }
1614             let dx = (pos.x - last_pos.0) as f64 / s;
1615             let dy = (pos.y - last_pos.1) as f64 / s;
1616             if dx == 0.0 && dy == 0.0 {
1617                 return;
1618             }
1619             rs.moved = true;
1620             let (index, corner) = (rs.index, rs.corner);
1621             self.touch(index);
1622             if let Some((item, _)) = self.items.get_mut(index) {
1623                 // Corners scale the image PROPORTIONALLY — an image stretched
1624                 // out of its aspect is a different picture — by the mean of
1625                 // the two edge ratios the drag asks for, anchored on the
1626                 // opposite corner.
1627                 let (sx, sy) = corner.sign();
1628                 if !item.is_image() {
1629                     // A card has no aspect to keep: each edge follows.
1630                     let (old_w, old_h) = (item.w, item.h);
1631                     item.w = (old_w + sx * dx).max(MIN_CARD);
1632                     item.h = (old_h + sy * dy).max(MIN_CARD);
1633                     if sx < 0.0 {
1634                         item.x += old_w - item.w;
1635                     }
1636                     if sy < 0.0 {
1637                         item.y += old_h - item.h;
1638                     }
1639                     *needs_rebuild = true;
1640                     self.touch(index);
1641                     return;
1642                 }
1643                 let kw = (item.w + sx * dx) / item.w.max(1.0);
1644                 let kh = (item.h + sy * dy) / item.h.max(1.0);
1645                 let k = ((kw + kh) / 2.0).max(MIN_ITEM_SIZE / item.w.max(item.h).max(1.0));
1646                 let (old_w, old_h) = (item.w, item.h);
1647                 item.w = (old_w * k).max(MIN_ITEM_SIZE);
1648                 item.h = old_h * (item.w / old_w.max(1.0));
1649                 if sx < 0.0 {
1650                     item.x += old_w - item.w;
1651                 }
1652                 if sy < 0.0 {
1653                     item.y += old_h - item.h;
1654                 }
1655                 *needs_rebuild = true;
1656             }
1657             self.touch(index);
1658             return;
1659         }
1660 
1661         let Some(drag) = self.dragging.as_mut() else {
1662             // Idle motion: the item under the pointer gets the handles, and
1663             // the handle under it lights. A leave arrives as an off-screen
1664             // position and clears both.
1665             let vx = p.x + pos.x as f64 / s;
1666             let vy = p.y + pos.y as f64 / s;
1667             let item = self.item_at(vx, vy);
1668             if item != self.hover_item {
1669                 // The handles leave one item and appear on another.
1670                 for index in [self.hover_item, item].into_iter().flatten() {
1671                     self.touch(index);
1672                 }
1673                 self.hover_item = item;
1674                 *needs_rebuild = true;
1675             }
1676             let hover = self.corner_at(vx, vy);
1677             if hover != self.hover {
1678                 for (index, _) in [self.hover, hover].into_iter().flatten() {
1679                     self.touch(index);
1680                 }
1681                 self.hover = hover;
1682                 *needs_rebuild = true;
1683             }
1684             return;
1685         };
1686         let last_pos = drag.last_pos;
1687         drag.last_pos = (pos.x, pos.y);
1688         if drag.last_origin != origin {
1689             // The surface moved under the pointer; this position cannot be
1690             // compared with the previous one. Re-baseline and wait.
1691             drag.last_origin = origin;
1692             return;
1693         }
1694         let dx = (pos.x - last_pos.0) as f64 / s;
1695         let dy = (pos.y - last_pos.1) as f64 / s;
1696         if dx == 0.0 && dy == 0.0 {
1697             return;
1698         }
1699         drag.moved = true;
1700         let index = drag.index;
1701         self.touch(index);
1702         if let Some((item, _)) = self.items.get_mut(index) {
1703             item.x += dx;
1704             item.y += dy;
1705             *needs_rebuild = true;
1706         }
1707         self.touch(index);
1708     }
1709 
1710     fn handle_mouse_input(
1711         &mut self,
1712         button: MouseButton,
1713         state: ElementState,
1714         pos: LogicalPosition,
1715         needs_rebuild: &mut bool,
1716     ) -> Option<Self::Message> {
1717         let Some(p) = self.patch else { return None };
1718         if p.scale <= 0.0 {
1719             return None;
1720         }
1721         if button == MouseButton::Right {
1722             if state != ElementState::Pressed {
1723                 return None;
1724             }
1725             let s = p.surface_per_virtual();
1726             let vx = p.x + pos.x as f64 / s;
1727             let vy = p.y + pos.y as f64 / s;
1728             let hit = self.item_at(vx, vy)?;
1729             let item = &self.items[hit].0;
1730             let node = item.node.clone();
1731             let name = item.name();
1732             let mut entries: Vec<(&'static str, &'static str)> = Vec::new();
1733             match &item.kind {
1734                 Kind::Note(_) => entries.push(("open", "Open in Notes")),
1735                 Kind::Link(_) => entries.push(("open", "Open link")),
1736                 Kind::Text(_) if self.board.vault.is_some() => entries.push(("convert", "Convert to note")),
1737                 _ => {}
1738             }
1739             entries.push(("connect", "Connect to…"));
1740             if self.edges.iter().any(|e| e.from == node || e.to == node) {
1741                 entries.push(("disconnect", "Disconnect"));
1742             }
1743             if self.board.vault.is_some() {
1744                 entries.push(("new_note", "New note card"));
1745             }
1746             entries.push(("remove", "Remove from Desktop"));
1747             // The menu blocks until it is dismissed, so it cannot run on the
1748             // loop that has to keep drawing the desktop behind it.
1749             let sender = self.sender.clone();
1750             std::thread::spawn(move || {
1751                 if let Some(action) = items::item_menu(&name, &entries) {
1752                     let _ = sender.send(Message::MenuAction { node, action });
1753                 }
1754             });
1755             return None;
1756         }
1757         if button != MouseButton::Left {
1758             return None;
1759         }
1760         match state {
1761             ElementState::Pressed => {
1762                 let s = p.surface_per_virtual();
1763                 let vx = p.x + pos.x as f64 / s;
1764                 let vy = p.y + pos.y as f64 / s;
1765                 // Finishing a "Connect to…": this press names the far end.
1766                 if let Some(from) = self.connecting.take() {
1767                     if let Some(hit) = self.item_at(vx, vy) {
1768                         let to = self.items[hit].0.node.clone();
1769                         if to != from {
1770                             self.edges.push(Edge {
1771                                 id: board::new_id(),
1772                                 from,
1773                                 to,
1774                                 from_side: None,
1775                                 to_side: None,
1776                                 label: None,
1777                                 arrow: true,
1778                             });
1779                             self.save_items();
1780                             log::info!("[board] connected two items");
1781                         }
1782                     }
1783                     self.damage = Damage::Full;
1784                     *needs_rebuild = true;
1785                     return None;
1786                 }
1787                 // A second press on the same item, quickly: open it.
1788                 if let Some(hit) = self.item_at(vx, vy) {
1789                     let node = self.items[hit].0.node.clone();
1790                     let now = std::time::Instant::now();
1791                     let double = self
1792                         .last_press
1793                         .as_ref()
1794                         .is_some_and(|(n, at)| *n == node && now.duration_since(*at) < DOUBLE_CLICK);
1795                     self.last_press = Some((node, now));
1796                     if double {
1797                         self.last_press = None;
1798                         self.activate(hit);
1799                         return None;
1800                     }
1801                 }
1802                 // A corner handle (adjust mode only) resizes; the body moves.
1803                 if let Some((index, corner)) = self.corner_at(vx, vy) {
1804                     self.resizing = Some(Resize {
1805                         index,
1806                         corner,
1807                         last_pos: (pos.x, pos.y),
1808                         last_origin: (p.x, p.y),
1809                         moved: false,
1810                     });
1811                     return None;
1812                 }
1813                 // Last drawn is on top, so search backwards and take the
1814                 // first hit.
1815                 let hit = self.item_at(vx, vy)?;
1816                 // Raise it: the one you grabbed should be the one you see,
1817                 // and the next press should find it first. The handles
1818                 // follow it to its new index.
1819                 let item = self.items.remove(hit);
1820                 self.items.push(item);
1821                 // Raised over whatever overlapped it, and the handles of
1822                 // the item that had them are gone.
1823                 if let Some(index) = self.hover_item {
1824                     let index = if index > hit { index - 1 } else { index };
1825                     if index != hit {
1826                         self.touch(index);
1827                     }
1828                 }
1829                 self.touch(self.items.len() - 1);
1830                 self.hover_item = Some(self.items.len() - 1);
1831                 self.dragging = Some(Drag {
1832                     index: self.items.len() - 1,
1833                     last_pos: (pos.x, pos.y),
1834                     last_origin: (p.x, p.y),
1835                     moved: false,
1836                 });
1837                 *needs_rebuild = true;
1838             }
1839             ElementState::Released => {
1840                 if let Some(rs) = self.resizing.take() {
1841                     if rs.moved {
1842                         self.save_items();
1843                         self.report_items();
1844                         if let Some((item, _)) = self.items.get(rs.index) {
1845                             log::info!("[items] resized {} to {:.0}x{:.0}", item.name(), item.w, item.h);
1846                         }
1847                     }
1848                     return None;
1849                 }
1850                 if let Some(drag) = self.dragging.take() {
1851                     if drag.moved {
1852                         self.save_items();
1853                         if let Some((item, _)) = self.items.get(drag.index) {
1854                             log::info!("[items] moved {} to ({:.0}, {:.0})", item.name(), item.x, item.y);
1855                         }
1856                     }
1857                     // The press raised the item even if it never moved,
1858                     // and the compositor's hit test wants the new order.
1859                     self.report_items();
1860                 }
1861             }
1862         }
1863         None
1864     }
1865 
1866     fn handle_mouse_wheel(
1867         &mut self,
1868         _delta: &MouseScrollDelta,
1869         _pos: LogicalPosition,
1870         _needs_rebuild: &mut bool,
1871     ) {
1872     }
1873 
1874     fn handle_key_input(
1875         &mut self,
1876         _event: &KeyEvent,
1877         _needs_rebuild: &mut bool,
1878     ) -> Option<Self::Message> {
1879         None
1880     }
1881 
1882     // style-audit: opt-out the desktop grid overlay draws the compositor cells, not a window
1883 
1884     fn display_list(&mut self, size: LogicalSize, scale: f64) -> Option<DisplayList> {
1885         // The runner renders this surface at scale 1 (patch.scale is the
1886         // resolution) but the toolkit-wide factor follows the output, and
1887         // text shapes at that factor: pin it to this surface's, or every
1888         // card's text would shape at twice the size it is placed at.
1889         cce_ui::scale::set_scale_factor(scale as f32);
1890         let mut pc = PaintCtx::new();
1891         self.paint(&mut pc, size);
1892         Some(pc.finish())
1893     }
1894 
1895     /// The item rects touched since the last frame, as one surface rect —
1896     /// or None (everything) when the frame's other inputs moved: the patch,
1897     /// the surface size, or any style value the paint reads.
1898     fn take_damage(&mut self, size: LogicalSize, _scale: f64) -> Option<(f32, f32, f32, f32)> {
1899         let damage = std::mem::replace(&mut self.damage, Damage::Nothing);
1900         let p = self.patch?;
1901         let st = style();
1902         let inputs = (
1903             p,
1904             (size.width as f64, size.height as f64),
1905             format!(
1906                 "{} {} {} {} {} {:?} {:?} {} {:?} {:?} {} {} {:?}",
1907                 st.cell_w,
1908                 st.cell_h,
1909                 st.gap_width,
1910                 st.cell_inset,
1911                 st.corner_radius,
1912                 st.gap_color,
1913                 st.cell_color,
1914                 st.handle_width,
1915                 st.handle_color,
1916                 st.handle_hover_color,
1917                 cce_ui::layout::corner_span_factor(),
1918                 cce_ui::layout::bevel_width(),
1919                 self.applied_relief,
1920             ),
1921         );
1922         let same = self.painted.as_ref() == Some(&inputs);
1923         self.painted = Some(inputs);
1924         if !same {
1925             return None;
1926         }
1927         match damage {
1928             Damage::Full => None,
1929             Damage::Nothing => Some((0.0, 0.0, 0.0, 0.0)),
1930             Damage::Rect(x0, y0, x1, y1) => {
1931                 let s = p.scale;
1932                 Some((
1933                     ((x0 - p.x) * s) as f32,
1934                     ((y0 - p.y) * s) as f32,
1935                     ((x1 - x0) * s) as f32,
1936                     ((y1 - y0) * s) as f32,
1937                 ))
1938             }
1939         }
1940     }
1941 
1942     fn clear_color(&self) -> [f32; 4] {
1943         // Patch edges the display list somehow misses read as rail surface,
1944         // matching the compositor's always-on gap backdrop underneath.
1945         style().gap_color
1946     }
1947 }
1948 
1949 fn main() {
1950     // Default to info, not env_logger's error-only: this process runs
1951     // unattended as a session service, and a drop that quietly fails with
1952     // nothing in the log is indistinguishable from a drop that never
1953     // happened — which is exactly how the first Chrome failure presented.
1954     env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init();
1955     cce_ui::engine::run::<GridApp>();
1956 }
1957 
1958 #[cfg(test)]
1959 mod tests {
1960     use super::*;
1961 
1962     fn area(rs: &[Rect]) -> f32 {
1963         rs.iter().map(|r| r.width * r.height).sum()
1964     }
1965 
1966     #[test]
1967     fn uncovered_subtracts_overlaps() {
1968         let r = Rect { x: 0.0, y: 0.0, width: 100.0, height: 100.0 };
1969         assert_eq!(uncovered(r, &[]).len(), 1);
1970         // A square in the middle leaves the frame around it.
1971         let hole = Rect { x: 40.0, y: 40.0, width: 20.0, height: 20.0 };
1972         let frags = uncovered(r, &[hole]);
1973         assert_eq!(frags.len(), 4);
1974         assert!((area(&frags) - (10000.0 - 400.0)).abs() < 0.01);
1975         // Covered whole: nothing left. Missed: untouched.
1976         assert!(uncovered(r, &[Rect { x: -5.0, y: -5.0, width: 200.0, height: 200.0 }]).is_empty());
1977         assert_eq!(uncovered(r, &[Rect { x: 200.0, y: 0.0, width: 10.0, height: 10.0 }]).len(), 1);
1978     }
1979 
1980     #[test]
1981     fn edges_meet_the_facing_side() {
1982         let a = items::DesktopItem::new("a".into(), Kind::Text(String::new()), 0.0, 0.0, 100.0, 50.0);
1983         let b = items::DesktopItem::new("b".into(), Kind::Text(String::new()), 300.0, 0.0, 100.0, 50.0);
1984         assert_eq!(anchor(&a, None, center(&b)), ((100.0, 25.0), (1.0, 0.0)));
1985         assert_eq!(anchor(&b, None, center(&a)), ((300.0, 25.0), (-1.0, 0.0)));
1986         assert_eq!(anchor(&a, Some("bottom"), center(&b)), ((50.0, 50.0), (0.0, 1.0)));
1987     }
1988 }