Wayland compositor (wlroots)
git clone https://git.lucas.co/cce-compositor.git
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