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

commit738e7a5b94241eb483e4b4babaf9bc00cb12bdbe
parent1feaf773f9
authorLucas Galante <[email protected]>
date2026-08-29 13:12
fix(handles): pin the ring's thickness to screen, not world, space

The ring was drawn at a world thickness, so it scaled with the window. But
handles exist only in overview, which is zoomed OUT — so they were at their
thinnest exactly where they are the only way to resize a window. At a
typical overview zoom the 16px band rendered as 7px and its thin corners as
2.5px: too fine to see, and too fine to hit.

The thickness is now a screen width, floored at HOVER_BAND_MIN. The lengths
along a side — corner_len and gap — still scale with the window, so the
composition holds at any zoom; only the thickness is pinned.

Both halves had the same bug, and both are fixed together: draw_borders
sized the four catcher rects through `apply`, which scales, and
get_border_zone multiplied its band by the same factor. That is why the ring
was as hard to grab as it was to see. The corner-length floor is new too,
so a corner zone can never end up narrower than the band it belongs to.

Verified at an overview zoom of ~0.38: the ring reads clearly, a 50px screen
drag on the inside right edge grows the window by 129 world px (50/0.38),
and the bottom-right corner moves both axes. Sizes land on grid cells rather
than exactly on the drag delta — the magnetic resize snap, not a miss.

Co-Authored-By: Claude Opus 5 <[email protected]>

 CLAUDE.md            |  8 ++++++++
 src/server/cursor.rs | 14 +++++++++-----
 src/server/window.rs | 34 +++++++++++++++++++++-------------
 3 files changed, 38 insertions(+), 18 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index 866297a..ad507d1 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -329,6 +329,14 @@ where the old band sat outside them.
   change (tens of px) while a side is hundreds long, so it reads as a bump
   near the centre rather than a swell. `border.segments`' 12 rects are what it
   replaced; they stay allocated but disabled.
+- **The ring's thickness is a SCREEN width, not a world one**, floored at
+  `HOVER_BAND_MIN`. Handles exist only in overview, which is zoomed *out*, so
+  a band that scaled with the window would be at its thinnest exactly where it
+  is the only way to resize: 16px renders as 7 at a typical overview zoom and
+  the thin corners as 2.5, which is neither visible nor clickable. The lengths
+  along a side (`corner_len`, `gap`) still scale, so the composition holds at
+  any zoom — only the thickness is pinned. `draw_borders` and
+  `cursor::get_border_zone` each derive it the same way and must stay in step.
 - Two knobs shape it, both under `border` in config.kdl: `corner_length` sets
   how far the thin corner run extends before the swell begins, and `taper`
   (new) is the corner thickness as a fraction of the middle's — 1.0 is an even
diff --git a/src/server/cursor.rs b/src/server/cursor.rs
index 80d1246..aeef783 100644
--- a/src/server/cursor.rs
+++ b/src/server/cursor.rs
@@ -2811,10 +2811,13 @@ pub unsafe fn get_border_zone(window: *mut crate::window::Window, lx: f64, ly: f
     }
 
     // box_geom holds the UNSCALED content size; on screen the window covers
-    // `size * scale` (as scene::at accounts for). Without this the band sits
-    // in the wrong place at any zoom other than 1.0.
+    // `size * scale`, and lx/ly are layout px — so the CONTENT extents scale
+    // but the band does NOT. The grab width is a screen width, matching what
+    // draw_borders draws: overview is zoomed out, and a band that shrank with
+    // the window would be thinnest exactly where it is the only way to
+    // resize. Keep the two in step.
     let scale = if (*window).scale > 0.0 { (*window).scale } else { 1.0 };
-    let bw = bw_unscaled * scale;
+    let bw = bw_unscaled.max(crate::window::HOVER_BAND_MIN);
 
     let geom = (*window).box_geom;
     let rx = lx - geom.x as f64;
@@ -2835,11 +2838,12 @@ pub unsafe fn get_border_zone(window: *mut crate::window::Window, lx: f64, ly: f
 
     // Corner squares of `corner_len`, measured from the content corners —
     // the same length draw_borders gives the corner handles.
-    let corner_len = crate::window::border_corner_len(
+    let corner_len = (crate::window::border_corner_len(
         bw_unscaled,
         (*(*window).server).wm.layout.border_corner_length,
         0.0,
-    ) * scale;
+    ) * scale)
+        .max(bw);
     let corner_l = rx < corner_len;
     let corner_r = rx >= content_w - corner_len;
     let corner_t = ry < corner_len;
diff --git a/src/server/window.rs b/src/server/window.rs
index 4af2652..cfe6241 100644
--- a/src/server/window.rs
+++ b/src/server/window.rs
@@ -3720,6 +3720,7 @@ impl Window {
             let in_overview = (*self.server).wm.mode
                 == crate::window_manager::WindowManagerMode::Overview;
             let bw = band;
+            let sc = if self.scale > 0.0 { self.scale } else { 1.0 };
             let (cw, ch) = (content.width, content.height);
             // A window thinner than two bands has no interior left for a
             // ring; drawing one would be a solid block over the whole window.
@@ -3727,8 +3728,8 @@ impl Window {
                 && window_takes_handles(self_ptr)
                 && !is_virtual_border
                 && bw > 0
-                && cw >= 2 * bw
-                && ch >= 2 * bw;
+                && (cw as f64 * sc) >= 4.0 * band_f.max(crate::window::HOVER_BAND_MIN)
+                && (ch as f64 * sc) >= 4.0 * band_f.max(crate::window::HOVER_BAND_MIN);
             if !handles_on {
                 for r in [self.border.left, self.border.right, self.border.top, self.border.bottom] {
                     ffi::wlr_scene_node_set_enabled(r as *mut ffi::wlr_scene_node, false);
@@ -3743,17 +3744,28 @@ impl Window {
                 return;
             }
 
+            // The band is a SCREEN width, not a world one. Handles exist only
+            // in overview, which is zoomed OUT, so a band that scaled with the
+            // window would be at its thinnest exactly where it is the only way
+            // to resize — 16px becomes 7 at a typical overview zoom, and the
+            // thin corners 2.5. `apply` scales the boxes it is given, so the
+            // catchers are sized in unscaled units that come back to
+            // `band_screen` on screen. cursor::get_border_zone measures the
+            // same width in layout px; the two must agree.
+            let band_screen = (band_f).max(crate::window::HOVER_BAND_MIN);
+            let bw_u = (band_screen / sc).round().max(1.0) as i32;
+
             // Hit catchers: the inside ring, sides spanning the full height
             // so the corners belong to them. No foam clipping — that exists
             // to split a gap SHARED with a neighbouring window, and an inside
             // ring shares nothing.
-            let b = ffi::wlr_box { x: 0, y: 0, width: bw, height: ch };
+            let b = ffi::wlr_box { x: 0, y: 0, width: bw_u, height: ch };
             apply(self.border.left, b, &transparent, true);
-            let b = ffi::wlr_box { x: cw - bw, y: 0, width: bw, height: ch };
+            let b = ffi::wlr_box { x: cw - bw_u, y: 0, width: bw_u, height: ch };
             apply(self.border.right, b, &transparent, true);
-            let b = ffi::wlr_box { x: bw, y: 0, width: cw - 2 * bw, height: bw };
+            let b = ffi::wlr_box { x: bw_u, y: 0, width: cw - 2 * bw_u, height: bw_u };
             apply(self.border.top, b, &transparent, true);
-            let b = ffi::wlr_box { x: bw, y: ch - bw, width: cw - 2 * bw, height: bw };
+            let b = ffi::wlr_box { x: bw_u, y: ch - bw_u, width: cw - 2 * bw_u, height: bw_u };
             apply(self.border.bottom, b, &transparent, true);
 
             let layout = &(*self.server).wm.layout;
@@ -3772,10 +3784,6 @@ impl Window {
             let a = self.border_reveal[BorderElement::Top.index()].clamp(0.0, 1.0);
             let premul = |c: &[f32; 4]| [c[0] * a, c[1] * a, c[2] * a, c[3] * a];
 
-            // Device px throughout, the same space `apply` put the rects in:
-            // box_geom is the UNSCALED content size and the window covers
-            // `size * scale` on screen.
-            let sc = self.scale;
             let px = |v: i32| (v as f64 * sc) as i32;
             ffi::wlr_scene_frame_set_size(self.border.frame, px(cw), px(ch));
             ffi::wlr_scene_frame_set_corner_radius(self.border.frame, px(r_in));
@@ -3784,9 +3792,9 @@ impl Window {
             // middle of a side without letting them vanish at small sizes.
             ffi::wlr_scene_frame_set_shape(
                 self.border.frame,
-                px(bw) as f32,
-                (px(bw) as f32 * layout.border_taper.clamp(0.0, 1.0)).max(1.0),
-                px(cl) as f32,
+                band_screen as f32,
+                (band_screen as f32 * layout.border_taper.clamp(0.0, 1.0)).max(2.0),
+                (px(cl) as f32).max(band_screen as f32),
                 px(g) as f32,
             );
             ffi::wlr_scene_frame_set_color(self.border.frame, premul(&border_color).as_ptr());