desktop grid client
git clone https://git.lucas.co/cce-grid.git
feat: initial cce-grid — the desktop grid as a cce-ui client
World-anchored grid renderer: draws compositor-issued patches (virtual
rect + px per virtual unit) as Recess wells carved into the gap-colored
rail surface, styled from the same config keys as the compositor's
fallback grid. Ships a session unit (WantedBy=cce-session.target).
Co-Authored-By: Claude Fable 5 <[email protected]>
.gitignore | 2 +
CLAUDE.md | 29 ++++++++
Cargo.toml | 11 +++
cce-grid.service | 12 ++++
src/main.rs | 203 +++++++++++++++++++++++++++++++++++++++++++++++++++++++
5 files changed, 257 insertions(+)
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..96ef6c0
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,2 @@
+/target
+Cargo.lock
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 0000000..ff9bcc0
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,29 @@
+# CLAUDE.md
+
+`cce-grid` is the desktop-grid client of the cce desktop: a cce-ui app the
+compositor world-anchors to the virtual desktop. It renders grid *patches* —
+virtual-rect regions at a compositor-chosen resolution — and is never in the
+pan/zoom loop: the compositor transforms the committed buffer per frame like
+any window content.
+
+The contract (cce window-management protocol, manager v6 / toplevel v4):
+`Application::grid() -> true` declares the role; `grid_patch` events say what
+to render; cce-ui's runner resizes, forwards to `Application::grid_patch`,
+and acks so the next commit latches at the new anchor. The compositor keeps
+its own rect grid as the fallback whenever this client is absent or has not
+latched a patch yet, and its gap-colored backdrop always draws beneath as
+the safety net beyond patch edges.
+
+Rendering is a pure function of (patch, style config): cells are `Recess`
+wells carved into the gap-colored rail surface, reading the same
+`style.surface.desktop.*` / backplate-radius keys as the compositor
+fallback. Keep it that way — no camera state, no timers, no input (the
+surface is input-transparent compositor-side).
+
+This directory is its own git repository (gitsite-published, fetch-only
+origin; committing locally is publishing). `cce-grid.service` autostarts it
+with the session (WantedBy=cce-session.target); ccebuild installs both.
+
+Known MVP gaps: colors are passed to PaintCtx as raw sRGB (likely needs
+srgb_to_linear — the client renders brighter than the fallback), and corner
+sweeps are tighter than the compositor's span-widened superellipse.
diff --git a/Cargo.toml b/Cargo.toml
new file mode 100644
index 0000000..ef39f39
--- /dev/null
+++ b/Cargo.toml
@@ -0,0 +1,11 @@
+[package]
+name = "cce-grid"
+version = "0.1.0"
+edition = "2021"
+
+[dependencies]
+cce-ui = { path = "../cce-ui" }
+calloop = "0.13.0"
+wayland-client = { version = "0.31", features = ["system"] }
+log = "0.4"
+env_logger = "0.11"
diff --git a/cce-grid.service b/cce-grid.service
new file mode 100644
index 0000000..2711564
--- /dev/null
+++ b/cce-grid.service
@@ -0,0 +1,12 @@
+[Unit]
+Description=cce desktop grid (cce-ui world-anchored grid client)
+PartOf=cce-session.target
+After=cce-session.target
+
+[Service]
+ExecStart=%h/.local/bin/cce-grid
+Restart=on-failure
+RestartSec=1
+
+[Install]
+WantedBy=cce-session.target
diff --git a/src/main.rs b/src/main.rs
new file mode 100644
index 0000000..6a777dc
--- /dev/null
+++ b/src/main.rs
@@ -0,0 +1,203 @@
+//! cce-grid — the desktop grid, drawn by cce-ui.
+//!
+//! The compositor world-anchors this surface to the virtual desktop and
+//! pans/zooms it per frame exactly like window content, so this app is never
+//! in the camera loop. It renders only when the compositor hands it a patch
+//! (`Application::grid_patch`): a virtual-desktop rectangle plus a
+//! px-per-virtual-unit scale. Everything here is therefore a pure function
+//! of (patch, style config) — no camera state, no timers.
+//!
+//! The look comes from the same config keys the compositor's fallback grid
+//! reads (`style.surface.desktop.*`, backplate corner radius), but drawn
+//! with cce-ui's own primitives: cells are `Recess` wells carved into the
+//! gap-colored rail surface, under the toolkit's real lighting model —
+//! not the compositor's scenefx approximation of it.
+
+use wayland_client::QueueHandle;
+
+use cce_ui::engine::{Application, EngineState, LogicalPosition, LogicalSize, WindowSettings};
+use cce_ui::scene::layout::Rect;
+use cce_ui::scene::paint::{DisplayList, PaintCtx};
+use cce_ui::widget::{ElementState, KeyEvent, MouseButton, MouseScrollDelta};
+
+#[derive(Debug, Clone)]
+enum Message {}
+
+/// The world region the current buffer must cover, as told by the
+/// compositor: virtual origin/size and surface px per virtual unit.
+#[derive(Debug, Clone, Copy)]
+struct Patch {
+ x: f64,
+ y: f64,
+ w: f64,
+ h: f64,
+ scale: f64,
+}
+
+struct GridApp {
+ patch: Option<Patch>,
+}
+
+/// Style knobs, re-read per frame from the shared config (cheap: cce-ui
+/// caches the parse on mtime), with the same defaults the compositor uses.
+struct Style {
+ cell_size: f64,
+ gap_width: f64,
+ cell_inset: f64,
+ corner_radius: f64,
+ gap_color: [f32; 4],
+ cell_color: [f32; 4],
+}
+
+fn style() -> Style {
+ use cce_ui::config::{get_color, get_i64};
+ Style {
+ cell_size: get_i64("/style/surface/desktop/grid_cell_size", 512) as f64,
+ gap_width: (get_i64("/style/surface/desktop/gap_width", 16).max(0)) as f64,
+ cell_inset: get_i64("/style/surface/desktop/cell_fade_inset", 0) as f64,
+ corner_radius: get_i64("/style/surface/backplate/corner_radius", 12) as f64,
+ gap_color: get_color("/style/surface/desktop/gap_color")
+ .unwrap_or([0.686, 0.796, 0.867, 1.0]),
+ cell_color: get_color("/style/surface/desktop/cell_color")
+ .unwrap_or([0.0, 0.0, 0.0, 1.0]),
+ }
+}
+
+/// Never emit more cells than this per frame, whatever the patch/config says
+/// (a degenerate period must not turn into an unbounded display list).
+const MAX_CELLS: usize = 8192;
+
+impl GridApp {
+ fn paint(&self, pc: &mut PaintCtx, size: LogicalSize) {
+ let Some(p) = self.patch else { return };
+ if p.scale <= 0.0 {
+ return;
+ }
+ let st = style();
+ let period = st.cell_size + st.gap_width;
+ if period < 1.0 {
+ return;
+ }
+
+ // The rail surface: the whole patch in gap color.
+ pc.quad(
+ Rect { x: 0.0, y: 0.0, width: size.width as f32, height: size.height as f32 },
+ st.gap_color,
+ );
+
+ // Visible cell box within its period slot, in virtual units.
+ let inset = st.cell_inset.clamp(0.0, st.cell_size / 2.0 - 1.0);
+ let lo = inset;
+ let len = st.cell_size - 2.0 * inset;
+ let s = p.scale;
+ let radius = (st.corner_radius * s) as f32;
+ let depth = radius.max(1.0);
+
+ let col0 = (p.x / period).floor() as i64;
+ let col1 = ((p.x + p.w) / period).ceil() as i64;
+ let row0 = (p.y / period).floor() as i64;
+ let row1 = ((p.y + p.h) / period).ceil() as i64;
+ let mut cells = 0usize;
+ for col in col0..col1 {
+ for row in row0..row1 {
+ if cells >= MAX_CELLS {
+ return;
+ }
+ cells += 1;
+ let vx = col as f64 * period + lo;
+ let vy = row as f64 * period + lo;
+ let rect = Rect {
+ x: ((vx - p.x) * s) as f32,
+ y: ((vy - p.y) * s) as f32,
+ width: (len * s) as f32,
+ height: (len * s) as f32,
+ };
+ // The cell floor, then the well walls carved down to it.
+ pc.rounded_rect(rect, radius, (true, true, true, true), st.cell_color);
+ pc.recess(rect, (radius, radius, radius, radius), depth);
+ }
+ }
+ }
+}
+
+impl Application for GridApp {
+ type Message = Message;
+
+ fn new(
+ _qh: &QueueHandle<EngineState<Self>>,
+ _sender: calloop::channel::Sender<Self::Message>,
+ ) -> Self {
+ Self { patch: None }
+ }
+
+ fn settings(&self) -> WindowSettings {
+ WindowSettings {
+ title: "Desktop Grid".to_string(),
+ app_id: "cce-grid".to_string(),
+ // Nominal initial size; every real size comes from a patch.
+ width: 640,
+ height: 480,
+ fullscreen: false,
+ min_size: None,
+ }
+ }
+
+ fn grid(&self) -> bool {
+ true
+ }
+
+ fn grid_patch(&mut self, x: f64, y: f64, w: f64, h: f64, scale: f64) {
+ self.patch = Some(Patch { x, y, w, h, scale });
+ }
+
+ fn update(&mut self, _msg: Self::Message, _needs_rebuild: &mut bool, _exit: &mut bool) {}
+
+ fn tick(&mut self, _dt: f32, _needs_rebuild: &mut bool) {}
+
+ // The grid layer is input-transparent compositor-side; nothing ever
+ // reaches these.
+ fn handle_pointer_move(&mut self, _pos: LogicalPosition, _needs_rebuild: &mut bool) {}
+
+ fn handle_mouse_input(
+ &mut self,
+ _button: MouseButton,
+ _state: ElementState,
+ _pos: LogicalPosition,
+ _needs_rebuild: &mut bool,
+ ) -> Option<Self::Message> {
+ None
+ }
+
+ fn handle_mouse_wheel(
+ &mut self,
+ _delta: &MouseScrollDelta,
+ _pos: LogicalPosition,
+ _needs_rebuild: &mut bool,
+ ) {
+ }
+
+ fn handle_key_input(
+ &mut self,
+ _event: &KeyEvent,
+ _needs_rebuild: &mut bool,
+ ) -> Option<Self::Message> {
+ None
+ }
+
+ fn display_list(&mut self, size: LogicalSize, _scale: f64) -> Option<DisplayList> {
+ let mut pc = PaintCtx::new();
+ self.paint(&mut pc, size);
+ Some(pc.finish())
+ }
+
+ fn clear_color(&self) -> [f32; 4] {
+ // Patch edges the display list somehow misses read as rail surface,
+ // matching the compositor's always-on gap backdrop underneath.
+ style().gap_color
+ }
+}
+
+fn main() {
+ env_logger::init();
+ cce_ui::engine::run::<GridApp>();
+}