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