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

commit5baf91778e4d16a334ce1179554d486d7c0645f1
parent738e7a5b94
authorLucas Galante <[email protected]>
date2026-08-29 13:18
fix(handles): give the ring its own width, not the border's

The ring's thickness came from `border width` doubled, which capped it at
16 screen px — measured at 15 in the middle of a side and 5 at the corners.
Too fine to see against a zoomed-out desktop, and the same number set the
grab zone, so too fine to hit. Worse, it could not be fixed from config:
raising `width` to thicken the grip would have thickened every window's
visible border with it, and those two want opposite values. A resize grip
wants to be chunky; a border wants to be a line.

So `handle_width` is its own key under `border`, defaulting to 32 screen px
— roughly double what the old derivation allowed. Measured on a window
rendering 839x519: 11px at the corners, 31px at the middle.

Also replaced the small-window guard. It disabled the handles outright below
4x the band, which is a cliff: a window you cannot resize at all is worse
than one with a slimmer grip. The thickness is now capped at a fifth of the
shorter on-screen side instead, so the ring shrinks with a small window
rather than vanishing. Both draw_borders and get_border_zone apply that cap,
or the grab zone would outgrow the ring you can see.

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

 CLAUDE.md            | 19 ++++++++++++++-----
 src/server/config.rs | 18 ++++++++++++++++++
 src/server/cursor.rs |  8 ++++++--
 src/server/window.rs | 15 ++++++++++++---
 4 files changed, 50 insertions(+), 10 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index ad507d1..73a1338 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -337,11 +337,20 @@ where the old band sat outside them.
   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
-  ring, and it is clamped to (0, 1] because past 1 the corners would be
-  thicker than the middle, which is the moulding inside out.
+- Three knobs shape it, all under `border` in config.kdl. `handle_width` is
+  the thickness at the middle of a side in screen px — **its own key, not
+  derived from `width`**, because the ring must be thick enough to see and hit
+  while the desktop is zoomed out, while the window's visible border is a much
+  finer line; deriving one from the other meant you could not thicken the grip
+  without thickening every border. `corner_length` sets how far the thin
+  corner run extends before the swell begins, and `taper` is the corner
+  thickness as a fraction of the middle's — 1.0 is an even ring, clamped to
+  (0, 1] because past 1 the corners would be thicker than the middle, which is
+  the moulding inside out.
+- The thickness is capped at a fifth of the window's shorter on-screen side,
+  so a zoomed-out window is never mostly ring. That cap replaced a hard
+  cutoff which disabled the handles below a size threshold: a window you
+  cannot resize at all is worse than one with a slimmer grip.
 - The shader's zone numbering MUST match `BorderElement::index()`; it is what
   the hovered-zone uniform selects on.
 - `window::window_takes_handles` is the single predicate for which windows get
diff --git a/src/server/config.rs b/src/server/config.rs
index 3998930..7f6ac44 100644
--- a/src/server/config.rs
+++ b/src/server/config.rs
@@ -33,6 +33,11 @@ pub struct Layout {
     /// its thickness at the middle of a side. 1.0 is an even ring; smaller
     /// values swell the middle of each side, like a picture-frame moulding.
     pub border_taper: f32,
+    /// Thickness of the resize-handle ring at the middle of a side, in SCREEN
+    /// px. Deliberately its own knob rather than derived from `width`: the
+    /// ring has to be thick enough to see and hit while the desktop is zoomed
+    /// out, and the window's visible border is a much finer line than that.
+    pub border_handle_width: f32,
     /// Corner zone length measured from the outer corner along each band;
     /// 0 = auto (max(2 * width, 16)).
     pub border_corner_length: i32,
@@ -189,6 +194,7 @@ impl Default for Layout {
             border_corner_radius: 0,
             border_segment_gap: 4,
             border_taper: 0.35,
+            border_handle_width: 32.0,
             border_corner_length: 0,
             background_r: 0x1C1C1C1Cu32,
             background_g: 0x20202020u32,
@@ -484,6 +490,8 @@ pub struct SurfaceConfig {
     pub border_segment_gap: i64,
     #[serde(default = "default_border_taper")]
     pub border_taper: f64,
+    #[serde(default = "default_border_handle_width")]
+    pub border_handle_width: f64,
     /// 0 = auto (max(2 * width, 16)).
     #[serde(default)]
     pub border_corner_length: i64,
@@ -580,6 +588,7 @@ impl Default for SurfaceConfig {
             border_corner_radius: default_border_corner_radius(),
             border_segment_gap: default_border_segment_gap(),
             border_taper: default_border_taper(),
+            border_handle_width: default_border_handle_width(),
             border_corner_length: 0,
             cloud_position_default: default_cloud_position_default(),
             shadow_enabled: default_shadow_enabled(),
@@ -682,6 +691,7 @@ fn default_border_corner_radius() -> i64 {
 }
 
 fn default_border_taper() -> f64 { 0.35 }
+fn default_border_handle_width() -> f64 { 32.0 }
 
 fn default_border_segment_gap() -> i64 {
     4
@@ -2021,6 +2031,13 @@ fn parse_kdl_config(content: &str) -> Result<Config, String> {
                                             surface.border_taper = val as f64;
                                         }
                                     }
+                                    "handle_width" => {
+                                        if let Some(val) = entry.value().as_f64() {
+                                            surface.border_handle_width = val;
+                                        } else if let Some(val) = entry.value().as_i64() {
+                                            surface.border_handle_width = val as f64;
+                                        }
+                                    }
                                     "corner_length" => {
                                         if let Some(val) = entry.value().as_i64() {
                                             surface.border_corner_length = val;
@@ -2319,6 +2336,7 @@ pub fn parse_config(path: &str, state: &mut crate::window_manager::WindowManager
     // Clamped at 1: past that the corners would be THICKER than the middle,
     // which is the moulding inside out.
     state.layout.border_taper = config.surface.border_taper.clamp(0.05, 1.0) as f32;
+    state.layout.border_handle_width = config.surface.border_handle_width.max(4.0) as f32;
     state.layout.border_corner_length = config.surface.border_corner_length.max(0) as i32;
 
     state.layout.desktop_gap_color = config.surface.desktop_gap_color.clone();
diff --git a/src/server/cursor.rs b/src/server/cursor.rs
index aeef783..e7ce388 100644
--- a/src/server/cursor.rs
+++ b/src/server/cursor.rs
@@ -2817,14 +2817,18 @@ pub unsafe fn get_border_zone(window: *mut crate::window::Window, lx: f64, ly: f
     // 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.max(crate::window::HOVER_BAND_MIN);
-
     let geom = (*window).box_geom;
     let rx = lx - geom.x as f64;
     let ry = ly - geom.y as f64;
     let content_w = geom.width as f64 * scale;
     let content_h = geom.height as f64 * scale;
 
+    let bw = ((*(*window).server).wm.layout.border_handle_width as f64)
+        .max(crate::window::HOVER_BAND_MIN)
+        // The same fifth-of-the-short-side cap draw_borders applies, so the
+        // grab zone never outgrows the ring the user can see.
+        .min(content_w.min(content_h).max(1.0) * 0.2);
+
     // Outside the window entirely, or in the body beyond the ring: not ours.
     // The body case is what leaves overview's drag-to-move working.
     if rx < 0.0 || rx >= content_w || ry < 0.0 || ry >= content_h {
diff --git a/src/server/window.rs b/src/server/window.rs
index cfe6241..9515548 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 layout_handle_w = (*self.server).wm.layout.border_handle_width;
             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
@@ -3728,8 +3729,8 @@ impl Window {
                 && window_takes_handles(self_ptr)
                 && !is_virtual_border
                 && bw > 0
-                && (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);
+                && (cw as f64 * sc) >= 12.0
+                && (ch as f64 * sc) >= 12.0;
             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);
@@ -3752,7 +3753,15 @@ impl Window {
             // 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);
+            // Screen thickness, but never more than a fifth of the smaller
+            // on-screen side: a zoomed-out window would otherwise be mostly
+            // ring. Shrinking beats the old hard cutoff, which dropped the
+            // handles altogether below a threshold — a window you cannot
+            // resize at all is worse than one with a slimmer grip.
+            let short_side = (cw.min(ch) as f64 * sc).max(1.0);
+            let band_screen = (layout_handle_w as f64)
+                .max(crate::window::HOVER_BAND_MIN)
+                .min(short_side * 0.2);
             let bw_u = (band_screen / sc).round().max(1.0) as i32;
 
             // Hit catchers: the inside ring, sides spanning the full height