git.lucas.co / cce-compositor
Wayland compositor (wlroots)
git clone https://git.lucas.co/cce-compositor.git

commit60dabae451a907d8ce1d4dd5b2cc717218ffd30c
parentec52903ad1
authorLucas Galante <[email protected]>
date2026-08-13 17:34
feat: label every desktop square in overview, chess style

Overview now writes each square's name (A1, C-9, -B2) in its top-left
corner, so the coordinates ccectl reports and accepts are visible where you
navigate. Labels are children of the grid tree, so they inherit its modulo
shift and ride along with a pan for free; policy's GridFrame::first_col/row
says which world square each drawn cell is.

The compositor had no way to draw text — fontdue was in Cargo.toml but
unused, and nothing here rasterizes glyphs. Added the smallest path that
works:

- text.rs discovers a font (CCE_GRID_LABEL_FONT wins, else the DE's
  Berkeley Mono, else the usual monospace suspects, else anything that
  parses), rasterizes a label to premultiplied ARGB8888 with a dark halo so
  it reads over both the light gaps and the dark cells, and caches the
  result by (text, size) — labels repeat every frame and change only with
  the camera.
- wlroots_log_wrapper.c gains a minimal wlr_buffer implementation backed by
  CPU pixels (wlroots exposes no public constructor for one), so the
  renderer can upload the rasterized label like any shm buffer.

Sizing is a fixed fraction of the on-screen cell, clamped, and labels switch
off entirely when a cell is too small to hold three glyphs — as well as
whenever overview is closed, since this is a navigation aid rather than
desktop furniture.

Each pool entry remembers the buffer it currently shows:
wlr_scene_buffer_set_buffer damages the node even when handed the buffer
already on it, and this walk runs every frame while the overview camera
moves. Clearing the label cache resets those records, because a freed
buffer's address can be handed straight back to the next rasterization.

 src/lib.rs                       |   2 +
 src/server/output.rs             | 135 ++++++++++++++++++
 src/server/text.rs               | 291 +++++++++++++++++++++++++++++++++++++++
 src/server/wlroots_log_wrapper.c |  71 ++++++++++
 wrapper.h                        |   1 +
 5 files changed, 500 insertions(+)

diff --git a/src/lib.rs b/src/lib.rs
index 61ea145..057af15 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -20,6 +20,8 @@ pub mod xkb_bindings;
 pub mod layer_shell;
 #[path = "server/scene.rs"]
 pub mod scene;
+#[path = "server/text.rs"]
+pub mod text;
 // The window-management policy layer lives in the sibling crate
 // `cce-window-manager` (pure Rust, no FFI). The aliases keep the historical
 // `crate::policy::…` / `crate::tiling` / `crate::slotmap` paths working.
diff --git a/src/server/output.rs b/src/server/output.rs
index 2bbad61..98c7f46 100644
--- a/src/server/output.rs
+++ b/src/server/output.rs
@@ -172,6 +172,17 @@ pub struct Output {
     pub last_grid_spec: Option<crate::policy::api::BackgroundSpec>,
     pub grid_rect_pool: Vec<*mut ffi::wlr_scene_rect>,
     pub grid_force_redraw_frames: u8,
+    /// Scene nodes for the per-square chess-style labels (overview only), and
+    /// the rasterized glyph buffers behind them. Pooled exactly like
+    /// `grid_rect_pool`: reused across frames, disabled past the live count.
+    /// Each entry remembers the buffer it currently shows, because
+    /// `wlr_scene_buffer_set_buffer` damages the node unconditionally — even
+    /// when handed the buffer already on it — and these are re-walked every
+    /// frame while the overview camera moves.
+    pub cell_label_pool: Vec<(*mut ffi::wlr_scene_buffer, *mut ffi::wlr_buffer)>,
+    pub cell_labels: crate::text::LabelCache,
+    /// Label point size actually in use, so a zoom change can re-rasterize.
+    pub last_label_px: u32,
 
     pub destroy: ffi::wl_listener,
     pub request_state: ffi::wl_listener,
@@ -428,6 +439,9 @@ impl Output {
             last_grid_spec: None,
             grid_rect_pool: Vec::new(),
             grid_force_redraw_frames: 0,
+            cell_label_pool: Vec::new(),
+            cell_labels: Default::default(),
+            last_label_px: 0,
             destroy: std::mem::zeroed(),
             request_state: std::mem::zeroed(),
             frame: std::mem::zeroed(),
@@ -870,6 +884,127 @@ impl Output {
                 ffi::wlr_scene_node_set_enabled(pool[i] as *mut ffi::wlr_scene_node, false);
             }
         }
+
+        self.draw_cell_labels();
+    }
+
+    /// Name every visible desktop square, chess style, while overview is open.
+    ///
+    /// The labels live in the grid tree, so they inherit its modulo shift and
+    /// ride along with a pan for free; only the world index of the first drawn
+    /// cell (`GridFrame::first_col/row`, computed in policy) is needed to know
+    /// what to write. Outside overview every node is disabled — this is a
+    /// navigation aid, not desktop furniture.
+    unsafe fn draw_cell_labels(&mut self) {
+        let wm = &(*self.server).wm;
+        let overview = wm.mode == crate::window_manager::WindowManagerMode::Overview;
+
+        if !overview {
+            if !self.cell_label_pool.is_empty() {
+                for &(node, _) in &self.cell_label_pool {
+                    ffi::wlr_scene_node_set_enabled(node as *mut ffi::wlr_scene_node, false);
+                }
+            }
+            return;
+        }
+        if self.grid_tree.is_null() {
+            return;
+        }
+
+        let (viewport_w, viewport_h) = self.current.dimensions();
+        let spec = wm.layout.background_spec();
+        let crate::policy::api::BackgroundSpec::Grid(grid) = &spec else {
+            self.disable_cell_labels();
+            return;
+        };
+        let frame = crate::policy::background::grid_frame(
+            grid,
+            wm.camera(),
+            viewport_w,
+            viewport_h,
+            self.sent.x,
+            self.sent.y,
+        );
+        let Some(cells) = &frame.cells else {
+            self.disable_cell_labels();
+            return;
+        };
+
+        // A fixed fraction of the on-screen cell, clamped so labels stay
+        // readable when zoomed far out and don't swell into billboards when
+        // near. Below the floor there is no room for glyphs at all.
+        let px = ((cells.cell_px as f32) * 0.16).clamp(9.0, 40.0);
+        if px * 3.0 > cells.cell_px as f32 {
+            self.disable_cell_labels();
+            return;
+        }
+        let px_key = px.round() as u32;
+        if px_key != self.last_label_px || self.cell_labels.len() > 512 {
+            self.cell_labels.clear();
+            self.last_label_px = px_key;
+            // The freed buffers' addresses can be handed straight back to the
+            // next rasterization, so a stale pointer here would compare equal
+            // to a different label and skip the update.
+            for entry in self.cell_label_pool.iter_mut() {
+                entry.1 = std::ptr::null_mut();
+            }
+        }
+
+        let inset = (cells.cell_px as f64 * 0.06).round() as i32;
+        let mut idx = 0usize;
+        for col in 0..=cells.cols {
+            let rel_x = (col as f64 * frame.period_px_exact).round() as i32;
+            for row in 0..=cells.rows {
+                let rel_y = (row as f64 * frame.period_px_exact).round() as i32;
+                let text = crate::policy::cells::square_label(
+                    frame.first_col + col,
+                    frame.first_row + row,
+                );
+                let Some(label) = self.cell_labels.get(&text, px) else {
+                    continue;
+                };
+                let (buf, lw, lh) = (label.buffer, label.width, label.height);
+
+                let node = if idx < self.cell_label_pool.len() {
+                    let (node, shown) = self.cell_label_pool[idx];
+                    if shown != buf {
+                        ffi::wlr_scene_buffer_set_buffer(node, buf);
+                        self.cell_label_pool[idx].1 = buf;
+                    }
+                    ffi::wlr_scene_node_set_enabled(node as *mut ffi::wlr_scene_node, true);
+                    node
+                } else {
+                    let node = ffi::wlr_scene_buffer_create(self.grid_tree, buf);
+                    if node.is_null() {
+                        continue;
+                    }
+                    self.cell_label_pool.push((node, buf));
+                    node
+                };
+                ffi::wlr_scene_buffer_set_dest_size(node, lw, lh);
+                // Top-left corner of the cell, inside the fade inset.
+                ffi::river_scene_node_set_position_if_changed(
+                    node as *mut ffi::wlr_scene_node,
+                    rel_x + inset,
+                    rel_y + inset,
+                );
+                let _ = lh;
+                idx += 1;
+            }
+        }
+
+        for i in idx..self.cell_label_pool.len() {
+            ffi::wlr_scene_node_set_enabled(
+                self.cell_label_pool[i].0 as *mut ffi::wlr_scene_node,
+                false,
+            );
+        }
+    }
+
+    unsafe fn disable_cell_labels(&self) {
+        for &(node, _) in &self.cell_label_pool {
+            ffi::wlr_scene_node_set_enabled(node as *mut ffi::wlr_scene_node, false);
+        }
     }
 }
 
diff --git a/src/server/text.rs b/src/server/text.rs
new file mode 100644
index 0000000..8430c6b
--- /dev/null
+++ b/src/server/text.rs
@@ -0,0 +1,291 @@
+//! Minimal CPU text rendering, for the desktop-grid square labels.
+//!
+//! The compositor has no toolkit — clients own their own text (cce-ui does
+//! Vulkan + glyphon). The one thing the compositor itself has to letter is the
+//! desktop grid, so this is deliberately the smallest thing that works:
+//! fontdue rasterizes a short ASCII label into an ARGB8888 buffer, which
+//! `river_data_buffer_create` wraps as a `wlr_buffer` for a scene node.
+//!
+//! Labels are short and repeat across frames, so rasterized buffers are cached
+//! by (text, size); the cache is swept whenever the label set changes size
+//! enough to matter (see `Output::draw_cell_labels`).
+
+use std::collections::HashMap;
+use std::sync::OnceLock;
+
+use crate::ffi;
+
+/// Where to look for a font file, in order of preference. The DE's own font
+/// wins; the rest are the usual monospace suspects so a machine without it
+/// still gets labels. `CCE_GRID_LABEL_FONT` overrides everything.
+const FONT_HINTS: &[&str] = &[
+    "berkeleymono",
+    "jetbrainsmono",
+    "dejavusansmono",
+    "liberationmono",
+    "notosansmono",
+    "firacode",
+    "hack",
+];
+
+fn font_dirs() -> Vec<std::path::PathBuf> {
+    let mut dirs = Vec::new();
+    if let Ok(home) = std::env::var("HOME") {
+        let data = std::env::var("XDG_DATA_HOME")
+            .unwrap_or_else(|_| format!("{home}/.local/share"));
+        dirs.push(std::path::PathBuf::from(format!("{data}/fonts")));
+        dirs.push(std::path::PathBuf::from(format!("{home}/.fonts")));
+        // The DE keeps its own fonts in Dropbox on this machine; harmless
+        // elsewhere since a missing dir is simply skipped.
+        dirs.push(std::path::PathBuf::from(format!("{home}/Dropbox/Fonts")));
+    }
+    dirs.push(std::path::PathBuf::from("/usr/local/share/fonts"));
+    dirs.push(std::path::PathBuf::from("/usr/share/fonts"));
+    dirs
+}
+
+/// Recursively collect font files, cheaply bounded so a pathological font tree
+/// can't stall startup.
+fn collect_fonts(dir: &std::path::Path, out: &mut Vec<std::path::PathBuf>, depth: u32) {
+    if depth > 4 || out.len() > 4000 {
+        return;
+    }
+    let Ok(entries) = std::fs::read_dir(dir) else {
+        return;
+    };
+    for entry in entries.flatten() {
+        let path = entry.path();
+        if path.is_dir() {
+            collect_fonts(&path, out, depth + 1);
+        } else if matches!(
+            path.extension().and_then(|e| e.to_str()).map(|e| e.to_ascii_lowercase()).as_deref(),
+            Some("ttf") | Some("otf")
+        ) {
+            out.push(path);
+        }
+    }
+}
+
+fn normalized_stem(path: &std::path::Path) -> String {
+    path.file_stem()
+        .and_then(|s| s.to_str())
+        .unwrap_or("")
+        .chars()
+        .filter(|c| c.is_ascii_alphanumeric())
+        .collect::<String>()
+        .to_ascii_lowercase()
+}
+
+fn load_font() -> Option<fontdue::Font> {
+    let try_file = |path: &std::path::Path| -> Option<fontdue::Font> {
+        let bytes = std::fs::read(path).ok()?;
+        // fontdue rejects fonts it cannot parse; keep looking rather than
+        // giving up on labels entirely.
+        fontdue::Font::from_bytes(bytes, fontdue::FontSettings::default()).ok()
+    };
+
+    if let Ok(explicit) = std::env::var("CCE_GRID_LABEL_FONT") {
+        if let Some(font) = try_file(std::path::Path::new(&explicit)) {
+            log::info!("grid labels: using font {explicit}");
+            return Some(font);
+        }
+        log::warn!("grid labels: CCE_GRID_LABEL_FONT={explicit} could not be loaded");
+    }
+
+    let mut candidates = Vec::new();
+    for dir in font_dirs() {
+        collect_fonts(&dir, &mut candidates, 0);
+    }
+    // Preferred families first, then a regular-weight fallback.
+    for hint in FONT_HINTS {
+        for path in &candidates {
+            let stem = normalized_stem(path);
+            if stem.contains(hint) && (stem.contains("regular") || !stem.contains("italic")) {
+                if let Some(font) = try_file(path) {
+                    log::info!("grid labels: using font {}", path.display());
+                    return Some(font);
+                }
+            }
+        }
+    }
+    for path in &candidates {
+        if let Some(font) = try_file(path) {
+            log::info!("grid labels: falling back to font {}", path.display());
+            return Some(font);
+        }
+    }
+    log::warn!("grid labels: no usable font found, labels disabled");
+    None
+}
+
+fn font() -> Option<&'static fontdue::Font> {
+    static FONT: OnceLock<Option<fontdue::Font>> = OnceLock::new();
+    FONT.get_or_init(load_font).as_ref()
+}
+
+/// One rasterized label, owning the scene-side buffer.
+pub struct Label {
+    pub buffer: *mut ffi::wlr_buffer,
+    pub width: i32,
+    pub height: i32,
+}
+
+/// Rasterize `text` at `px` and wrap it in a wlr_buffer. White glyphs with a
+/// soft dark halo so the label stays legible over both the light grid gaps and
+/// the dark cells; ARGB8888 premultiplied, as the renderer expects.
+fn rasterize(text: &str, px: f32) -> Option<Label> {
+    let font = font()?;
+    if text.is_empty() || !(4.0..=200.0).contains(&px) {
+        return None;
+    }
+
+    // Lay the glyphs out on a common baseline.
+    let mut glyphs = Vec::new();
+    let mut pen_x = 0i32;
+    let (mut top, mut bottom) = (i32::MAX, i32::MIN);
+    for ch in text.chars() {
+        let (metrics, bitmap) = font.rasterize(ch, px);
+        let x = pen_x + metrics.xmin;
+        // fontdue's ymin is the offset of the bitmap's BOTTOM from the
+        // baseline, y-up; the buffer is y-down.
+        let y = -(metrics.height as i32 + metrics.ymin);
+        top = top.min(y);
+        bottom = bottom.max(y + metrics.height as i32);
+        glyphs.push((x, y, metrics.width as i32, metrics.height as i32, bitmap));
+        pen_x += metrics.advance_width.round() as i32;
+    }
+    if glyphs.is_empty() || pen_x <= 0 || top >= bottom {
+        return None;
+    }
+
+    // One pixel of padding all round so the halo has somewhere to land.
+    const PAD: i32 = 2;
+    let width = pen_x + 2 * PAD;
+    let height = (bottom - top) + 2 * PAD;
+    if width <= 0 || height <= 0 || width > 4096 || height > 4096 {
+        return None;
+    }
+
+    // Coverage first, then two passes: halo from blurred coverage, glyph on
+    // top. Keeping coverage separate avoids the halo eating the glyph.
+    let (w, h) = (width as usize, height as usize);
+    let mut cov = vec![0u8; w * h];
+    for (gx, gy, gw, gh, bitmap) in &glyphs {
+        for row in 0..*gh {
+            for col in 0..*gw {
+                let a = bitmap[(row * gw + col) as usize];
+                if a == 0 {
+                    continue;
+                }
+                let px_x = gx + col + PAD;
+                let px_y = gy - top + row + PAD;
+                if px_x < 0 || px_y < 0 || px_x >= width || px_y >= height {
+                    continue;
+                }
+                let idx = px_y as usize * w + px_x as usize;
+                cov[idx] = cov[idx].max(a);
+            }
+        }
+    }
+
+    let mut data = vec![0u8; w * h * 4];
+    for y in 0..h {
+        for x in 0..w {
+            // Halo = max coverage of the 8 neighbours, dimmed.
+            let mut halo = 0u32;
+            for dy in -1i32..=1 {
+                for dx in -1i32..=1 {
+                    let (nx, ny) = (x as i32 + dx, y as i32 + dy);
+                    if nx < 0 || ny < 0 || nx >= w as i32 || ny >= h as i32 {
+                        continue;
+                    }
+                    halo = halo.max(cov[ny as usize * w + nx as usize] as u32);
+                }
+            }
+            let glyph = cov[y * w + x] as u32;
+            // Composite: black halo under white glyph, both premultiplied.
+            let halo_a = (halo * 180) / 255;
+            let out_a = (glyph + halo_a * (255 - glyph) / 255).min(255);
+            let out_rgb = glyph; // white premultiplied by its own alpha
+            let idx = (y * w + x) * 4;
+            // ARGB8888 little-endian byte order: B, G, R, A.
+            data[idx] = out_rgb as u8;
+            data[idx + 1] = out_rgb as u8;
+            data[idx + 2] = out_rgb as u8;
+            data[idx + 3] = out_a as u8;
+        }
+    }
+
+    let stride = w * 4;
+    let buffer = unsafe {
+        ffi::river_data_buffer_create(
+            width,
+            height,
+            stride,
+            data.as_ptr() as *const std::ffi::c_void,
+        )
+    };
+    if buffer.is_null() {
+        return None;
+    }
+    Some(Label { buffer, width, height })
+}
+
+/// Rasterized-label cache. Labels repeat every frame and change only as the
+/// camera moves, so this keeps the per-frame cost to a hash lookup.
+#[derive(Default)]
+pub struct LabelCache {
+    entries: HashMap<(String, u32), Option<Label>>,
+}
+
+impl LabelCache {
+    /// Look up (or rasterize) a label. `None` means "cannot draw this" — no
+    /// font, or an unrasterizable string — and is cached too, so a missing
+    /// font costs one lookup per label rather than a filesystem scan.
+    pub fn get(&mut self, text: &str, px: f32) -> Option<&Label> {
+        let key = (text.to_string(), px.round() as u32);
+        self.entries
+            .entry(key)
+            .or_insert_with(|| rasterize(text, px))
+            .as_ref()
+    }
+
+    /// Drop everything (font size changed, or the cache grew unreasonably).
+    pub fn clear(&mut self) {
+        for (_, label) in self.entries.drain() {
+            if let Some(label) = label {
+                unsafe { ffi::wlr_buffer_drop(label.buffer) };
+            }
+        }
+    }
+
+    pub fn len(&self) -> usize {
+        self.entries.len()
+    }
+}
+
+impl Drop for LabelCache {
+    fn drop(&mut self) {
+        self.clear();
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    // Rasterization needs a font on the machine; skip rather than fail on a
+    // bare build host.
+    #[test]
+    fn glyph_layout_produces_sane_extents() {
+        let Some(font) = font() else {
+            eprintln!("no font available, skipping");
+            return;
+        };
+        // A label the desktop actually uses.
+        let (metrics, bitmap) = font.rasterize('C', 24.0);
+        assert!(metrics.width > 0 && metrics.height > 0);
+        assert_eq!(bitmap.len(), metrics.width * metrics.height);
+        assert!(bitmap.iter().any(|&a| a > 0), "glyph rasterized blank");
+    }
+}
diff --git a/src/server/wlroots_log_wrapper.c b/src/server/wlroots_log_wrapper.c
index c946a4a..4d6857e 100644
--- a/src/server/wlroots_log_wrapper.c
+++ b/src/server/wlroots_log_wrapper.c
@@ -47,6 +47,9 @@ void river_init_wlroots_log(enum wlr_log_importance importance) {
 
 #include <time.h>
 #include <scenefx/types/wlr_scene.h>
+#include <wlr/types/wlr_buffer.h>
+#include <wlr/interfaces/wlr_buffer.h>
+#include <drm_fourcc.h>
 #include <wlr/types/wlr_output.h>
 #include <wlr/util/region.h>
 #include <wlr/types/wlr_compositor.h>
@@ -978,3 +981,71 @@ void river_scene_shadow_dbg(struct wlr_scene_shadow *shadow, const char *tag) {
 		shadow->clipped_region.area.x, shadow->clipped_region.area.y,
 		shadow->clipped_region.area.width, shadow->clipped_region.area.height);
 }
+
+/* ------------------------------------------------------------------
+ * CPU-backed buffer: lets the compositor hand the renderer pixels it
+ * rasterized itself (the desktop-grid square labels). wlroots has no public
+ * constructor for this, so implement the minimal wlr_buffer: the renderer
+ * reaches the pixels through data-ptr access and uploads them like any shm
+ * buffer. The data is copied in, so the caller's Rust Vec can be dropped.
+ * ------------------------------------------------------------------ */
+struct cce_data_buffer {
+	struct wlr_buffer base;
+	void *data;
+	uint32_t format;
+	size_t stride;
+};
+
+static void cce_data_buffer_destroy(struct wlr_buffer *wlr_buffer) {
+	struct cce_data_buffer *buf = (struct cce_data_buffer *)wlr_buffer;
+	free(buf->data);
+	free(buf);
+}
+
+static bool cce_data_buffer_begin_data_ptr_access(struct wlr_buffer *wlr_buffer,
+		uint32_t flags, void **data, uint32_t *format, size_t *stride) {
+	struct cce_data_buffer *buf = (struct cce_data_buffer *)wlr_buffer;
+	if (flags & WLR_BUFFER_DATA_PTR_ACCESS_WRITE) {
+		return false; /* immutable once built */
+	}
+	*data = buf->data;
+	*format = buf->format;
+	*stride = buf->stride;
+	return true;
+}
+
+static void cce_data_buffer_end_data_ptr_access(struct wlr_buffer *wlr_buffer) {
+	/* nothing to unmap */
+}
+
+static const struct wlr_buffer_impl cce_data_buffer_impl = {
+	.destroy = cce_data_buffer_destroy,
+	.begin_data_ptr_access = cce_data_buffer_begin_data_ptr_access,
+	.end_data_ptr_access = cce_data_buffer_end_data_ptr_access,
+};
+
+/* Copy `data` (ARGB8888, premultiplied, `stride` bytes per row) into a new
+ * buffer. Returns NULL on allocation failure. The buffer starts with one
+ * reference, as wlr_buffer_init leaves it: pass it to a scene buffer and then
+ * drop this reference with wlr_buffer_drop(). */
+struct wlr_buffer *river_data_buffer_create(int width, int height,
+		size_t stride, const void *data) {
+	if (width <= 0 || height <= 0 || stride == 0) {
+		return NULL;
+	}
+	struct cce_data_buffer *buf = calloc(1, sizeof(*buf));
+	if (!buf) {
+		return NULL;
+	}
+	size_t size = stride * (size_t)height;
+	buf->data = malloc(size);
+	if (!buf->data) {
+		free(buf);
+		return NULL;
+	}
+	memcpy(buf->data, data, size);
+	buf->format = DRM_FORMAT_ARGB8888;
+	buf->stride = stride;
+	wlr_buffer_init(&buf->base, &cce_data_buffer_impl, width, height);
+	return &buf->base;
+}
diff --git a/wrapper.h b/wrapper.h
index ef14609..41aa44b 100644
--- a/wrapper.h
+++ b/wrapper.h
@@ -283,3 +283,4 @@ void river_scene_rect_set_corner_radius(struct wlr_scene_rect *rect, int radius)
 #endif // WRAPPER_H
 void river_scene_ovdbg_dump(struct wlr_scene_node *node, const char *tag);
 void river_scene_shadow_dbg(struct wlr_scene_shadow *shadow, const char *tag);
+struct wlr_buffer *river_data_buffer_create(int width, int height, size_t stride, const void *data);