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 }