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