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

commitd48785e1ebb7868e1be2ae300a021cf1c907aabc
parent89eb539795
authorLucas Galante <[email protected]>
date2026-09-18 00:38
feat(borders): resize handles are eight discs, not a ring

The frame node now draws one disc per zone — the midpoint of each side
and each corner — of diameter `border.handle_width` (screen px), the
corner discs on the diagonal, tangent to the rounded silhouette. The
wave ring's hills, valleys and superellipse corners are gone from the
shader; `taper`, `swell_curve`, `bulge`, `corner_length` and
`segment_gap` still parse and are passed through, unused.

One layout function, `window::handle_disc_layout`, places the discs for
the pointer hit test (`get_border_zone`, now a per-disc circle test with
a pixel of rim slack) and the eight invisible square catchers
(`border.segments`, shrunk from 12 to 8); the shader repeats the same
arithmetic. The four full-band catchers are disabled: between two discs
the pointer belongs to the body, so a press there moves the window and
the app gets hover. Not eight rounded scene rects, because a scene rect
takes the renderer's global squircle corner shape.

Verified in a shadow: eight discs drawn inside the silhouette, the
hovered disc in hover_color with the resize cursor, a side disc drag
widens, a corner disc drag resizes both axes, a press between discs on
an edge moves. Unit tests cover the layout order and the corner tangent.

Co-Authored-By: Claude Fable 5.1 <[email protected]>

 CLAUDE.md                                     | 106 ++++++---------
 scenefx/render/fx_renderer/shaders/frame.frag | 186 ++++++++------------------
 src/server/cursor.rs                          | 111 ++++++++-------
 src/server/window.rs                          | 165 +++++++++++++++--------
 4 files changed, 261 insertions(+), 307 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index 1f38417..5551ae9 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -341,9 +341,10 @@ treats them as opaque.
 
 ### Window move/resize handles
 
-Pointer move and resize exist **only in overview mode**, and their band is
-**inside** the content rect — an inset ring hugging the window's own edges,
-where the old band sat outside them.
+Pointer move and resize exist **only in adjust mode** (overview, or Super
+held), and the resize handles are **eight discs inside** the content rect —
+one at the midpoint of each side, one on each corner — where the old band
+sat outside the edges and the ring that followed hugged them.
 
 - `cursor::get_border_zone` is the hit test: it returns `BorderZone::None`
   outright unless `wm.mode == Overview`, so in normal mode a window cannot be
@@ -353,79 +354,48 @@ where the old band sat outside them.
 - Inside the ring, **all four edges resize**, the top included. Dragging the
   window's body is what moves it in overview, so the top edge no longer has
   to be spent on moving the way the outside band's did.
-- The ring is drawn by **one scenefx node**, `wlr_scene_frame`
-  (`scenefx/render/fx_renderer/shaders/frame.frag`), not by rects. Its
-  inner edge is a WAVE of eight hills and eight valleys: a hill in the
-  middle of each side, a hill on each corner, a valley between every two.
-  The valleys sit `R` in from every corner — a quarter of the window's
-  SHORTER side, on screen — and are `band_min` thick. Between a side's two
-  valleys the band swells to `band` at the midpoint along a raised cosine
-  (shaped by `swell_curve`). Between the two valleys flanking a corner the
-  inner edge is a SUPERELLIPSE arc — a squircle corner of radius
-  R − band_min, tangent to both valleys — whose exponent the shader solves
-  so its deepest point, on the corner's 45° diagonal, is exactly `band` in
-  from the silhouette: the corner hill peaks on the diagonal at the same
-  height as the side hills, and meets the valleys with no crease. The
-  earlier profile (edge bars and corner arms pinching to seams at
-  `corner_length`, with gap notches and a round `bulge` pad on each corner)
-  is gone; those three keys still parse and are passed to the node, and
-  the shader ignores them.
+- The handles are drawn by **one scenefx node**, `wlr_scene_frame`
+  (`scenefx/render/fx_renderer/shaders/frame.frag`): eight discs of
+  diameter `band` (= `border.handle_width`, screen px), each its own zone.
+  The side discs are tangent to their side; a corner disc sits on the
+  corner's 45° diagonal, tangent to the rounded corner arc when that arc is
+  wider than the disc and tucked into the two straight edges otherwise.
+  **`window::handle_disc_layout` is the one layout function**: `draw_borders`
+  places the eight invisible square catchers (`border.segments`) from it,
+  `cursor::get_border_zone` hit-tests the discs from it (a pixel of slack
+  for the rim), and the shader repeats the same arithmetic from the same
+  inputs (size, corner radius, band) — keep the three in step. Between two
+  discs the pointer reaches the app; the old four full-band catchers are
+  disabled for exactly that reason. Not eight rounded scene rects: a scene
+  rect takes the renderer's global corner shape, a squircle, so a rect with
+  radius half its size is not a circle.
   The shader's zone logic works in TOP-DOWN box-local coordinates
-  (`gl_FragCoord` minus the box position, unflipped): the `corner_dist` SDF
-  flips its own copy, and mirroring the zone coordinate the same way once
+  (`gl_FragCoord` minus the box position, unflipped): `corner_dist` flips
+  its own copy, and mirroring the zone coordinate the same way once
   swapped every zone label vertically — the top edge lit the bottom. And a
-  hover swap must repaint even when no reveal value moves: in overview the
-  ring is already fully revealed, so `step_border_fade` compares the hovered
-  zone against the one last drawn (`border_hover_drawn`), or the shader
-  keeps showing the previous zone until an unrelated commit repaints.
-  The valleys are the zone seams: in the shader, a fragment belongs to the
-  side it is nearest (so the split runs along the diagonals) and is a
-  corner zone when it lies within R of that side's end; in
-  `get_border_zone` the corner zones are the R×R squares at the content
-  corners. Both derive R the same way and must stay in step. The profile
-  is continuous along a side, which a rect cannot express: its only shaping
-  tool is a clipped region whose corner radius is a single scalar, capped by
-  the thickness 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. R, the
-  valley position, is a fraction of the on-screen window, 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.
-- `swell_curve` shapes the SIDE hills: the raised cosine's height, 0 at the
-  valleys and 1 at the midpoint, is raised to this power. Below 1 broadens
-  the hill (a flatter top, tighter valleys); above 1 sharpens it. The shader
-  floors it at 0.6 — below 0.5 the valleys turn into cusps. The corner
-  hills' shape is the superellipse's own and does not take it.
-- Three knobs shape it, all under `border` in config.kdl. `handle_width` is
-  the hill height — the thickness at the middle of a side and on each
-  corner's diagonal — 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. `taper` is the valley thickness as a
-  fraction of the hill's — 1.0 is an even ring, clamped to (0, 1] because
-  past 1 the valleys would be thicker than the hills, which is the moulding
-  inside out. `swell_curve` is above. `corner_length`, `segment_gap` and
-  `bulge` belonged to the retired seam-and-pad profile: still parsed, no
-  longer drawn.
-- 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.
+  hover swap must repaint even when no reveal value moves: in adjust mode
+  the discs are already fully revealed, so `step_border_fade` compares the
+  hovered zone against the one last drawn (`border_hover_drawn`), or the
+  shader keeps showing the previous zone until an unrelated commit repaints.
+- **The disc diameter is a SCREEN size, not a world one**, floored at
+  `HOVER_BAND_MIN` and capped at a fifth of the window's shorter on-screen
+  side. Overview is zoomed *out*, so a handle that scaled with the window
+  would be smallest exactly where it is the only way to resize; the cap
+  keeps a zoomed-out window from being mostly handle. `draw_borders` and
+  `cursor::get_border_zone` each derive it the same way and must stay in
+  step.
+- `handle_width` under `border` in config.kdl is the diameter. `taper`,
+  `swell_curve`, `bulge`, `corner_length` and `segment_gap` belonged to the
+  retired ring profiles (an even ring, then a wave of hills and valleys):
+  still parsed and passed to the node, no longer drawn.
 - 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
   handles (excluding Popup, Fullscreen, Status, Utility, circular, hidden),
   used by both the hit test and the drawing. Keep those in step: a handle that
   is drawn but not honoured — or honoured but not drawn — is the failure mode
-  this arrangement exists to prevent. Note the *grab* zone stays the full
-  even band (the four catcher rects) even where the ring is drawn thin: the
-  swell is ornament, and a corner you can see but not grab would be worse.
+  this arrangement exists to prevent. The grab zone IS the disc (plus a pixel
+  of rim), not a band: a press between two discs is a body press and moves.
 - **Holding Super is window-adjust mode at zoom 1**: the same handles and
   body-drag as overview, gated by one predicate,
   `WindowManager::window_adjust_active()` (overview OR `adjust_held`).
diff --git a/scenefx/render/fx_renderer/shaders/frame.frag b/scenefx/render/fx_renderer/shaders/frame.frag
index b1d4372..5b49381 100644
--- a/scenefx/render/fx_renderer/shaders/frame.frag
+++ b/scenefx/render/fx_renderer/shaders/frame.frag
@@ -1,24 +1,18 @@
-// Window resize-handle frame: a ring inside a rounded rect whose INNER
-// edge is a wave of eight hills and eight valleys — a hill in the middle
-// of each side and one on each corner, with a valley between every two.
-// The corner hills are domes whose apex sits on the corner's 45° diagonal,
-// pointing into the window, at the same height as the side hills.
+// Window resize handles: EIGHT DISCS inside the window's rounded
+// silhouette — one at the midpoint of each side and one on each corner —
+// each disc a zone of its own. `band` is the disc diameter. The side
+// discs are tangent to their side; a corner disc sits on the corner's 45°
+// diagonal, tangent to the rounded corner arc when that arc is wider than
+// the disc and tucked into the two straight edges otherwise.
 //
-// The ring is `band_min` thick at the valleys, which sit `R` (a quarter of
-// the shorter side) in from every corner. Between the two valleys of a
-// side, the band swells to `band` at the midpoint with a raised cosine.
-// Between the two valleys that flank a corner, the ring's inner edge is a
-// SUPERELLIPSE arc — a squircle corner of radius R − band_min, tangent to
-// both valleys — whose exponent is solved so that its deepest point, on
-// the diagonal, is exactly `band` in from the silhouette. That arc is the
-// corner hill: it rises from each valley, peaks on the diagonal, and, being
-// tangent at both ends, meets the side hills' flat valleys with no crease.
+// The compositor's `window::handle_disc_layout` lays out the same discs
+// from the same inputs (size, corner_radius, band) for the pointer hit
+// test and the catcher rects; the two MUST stay in step, or a handle is
+// drawn where it does not grab, or grabs where it is not drawn.
 //
-// Why a shader rather than rects: the swell is continuous along a side,
-// and a scene rect can only approximate it with a clipped region whose
-// corner radius is one scalar. That fillet is capped by the thickness
-// change (tens of px) while a side is hundreds long, so it reads as a bump
-// near the centre rather than a swell along the whole run.
+// Still a shader rather than eight scene rects: a rounded scene rect takes
+// the renderer's global corner shape, a squircle, so a "rect with radius
+// half its size" would draw as a squircle, not a circle.
 
 #ifdef GL_FRAGMENT_PRECISION_HIGH
 precision highp float;
@@ -32,152 +26,84 @@ varying vec2 v_texcoord;
 uniform vec2 position;
 uniform vec2 size;
 uniform float corner_radius;
-// Thickness at a hill's apex, and at a valley.
+// Disc diameter.
 uniform float band;
+// Retired by the disc handles and ignored; kept so the node API and its
+// callers need not change.
 uniform float band_min;
-// Retired by the wave profile and ignored: the valleys place the zone
-// seams now, and the corner hill's size and height follow from `band`
-// and the window's shorter side. Kept so the node API and its callers
-// need not change.
 uniform float corner_len;
 uniform float gap;
 uniform float bulge;
-// Shape of each SIDE hill: the raised cosine's [0,1] height to this power.
-// Below 1 broadens the hill (flatter top, tighter valleys); above 1
-// sharpens it. Floored at 0.6 in the shader — below 0.5 the valleys would
-// turn into cusps. The corner hills' shape is the superellipse's own.
 uniform float swell_curve;
-// Node-local rect (x, y, w, h) the ring must not draw over — the client is
-// drawing an in-surface popover there and the menu must read as in FRONT of
-// the chrome. w or h <= 0 disables.
+// Node-local rect (x, y, w, h) the handles must not draw over — the client
+// is drawing an in-surface popover there and the menu must read as in
+// FRONT of the chrome. w or h <= 0 disables.
 uniform vec4 exclusion;
 // Zone under the pointer (see ZONE_* below), or < 0 for none.
 uniform float hovered;
 uniform vec4 hover_color;
 
 // Defined in corner_alpha.frag, concatenated after this source — the same
-// shared routine bevel.frag uses, so the ring traces the same superellipse
-// every other corner cut does.
+// shared routine bevel.frag uses, so the discs are clipped to the same
+// superellipse silhouette every other corner cut traces.
 float corner_dist(vec2 size, vec2 position,
         float radius_tl, float radius_tr, float radius_bl, float radius_br);
 
-float ring_dist(vec2 offset) {
-    return corner_dist(size, position + offset,
-        corner_radius, corner_radius, corner_radius, corner_radius);
-}
-
 // Zone numbering MUST match the compositor's BorderElement::index():
 // 0 Top, 1 Bottom, 2 Left, 3 Right, 4 TopLeft, 5 TopRight, 6 BottomLeft,
 // 7 BottomRight.
-const float ZONE_TOP = 0.0;
-const float ZONE_BOTTOM = 1.0;
-const float ZONE_LEFT = 2.0;
-const float ZONE_RIGHT = 3.0;
-const float ZONE_TL = 4.0;
-const float ZONE_TR = 5.0;
-const float ZONE_BL = 6.0;
-const float ZONE_BR = 7.0;
-
-const float PI = 3.14159265359;
-const float LN2 = 0.69314718056;
-
-// Height, 0..1, of a side's hill at distance `x` along it: a raised cosine
-// peaking at the midpoint and reaching zero — with zero slope — at the two
-// valleys, `R` in from either end. Zero beyond them.
-float side_hill(float x, float len, float R) {
-    float half_span = max(0.5 * len - R, 1e-3);
-    float d = abs(x - 0.5 * len);
-    if (d >= half_span) {
-        return 0.0;
-    }
-    float h = 0.5 * (1.0 + cos(PI * d / half_span));
-    return pow(h, max(swell_curve, 0.6));
-}
+const float SQRT2 = 1.41421356237;
 
 void main() {
-    float dist = ring_dist(vec2(0.0));
     // Rect-local position, TOP-DOWN: gl_FragCoord minus the box position is
     // already y-down box-local here (the pass renders under a FLIPPED_180
     // projection, so fragment row 0 is the top of the buffer — see the same
-    // note in droplet.frag). Everything below that names a side reads y as
-    // "distance from the top", so this must NOT be flipped the way
-    // corner_dist flips its own copy: the SDF is symmetric under that flip
-    // (one radius for all four corners), but the zone labels are not.
+    // note in droplet.frag). corner_dist flips its own copy; this must not
+    // be flipped too, or the zone labels swap vertically.
     vec2 p = gl_FragCoord.xy - position;
 
-    // Distances to the four sides, and which of each pair is nearer.
-    float dl = p.x;
-    float dr = size.x - p.x;
-    float dt = p.y;
-    float db = size.y - p.y;
-    bool left = dl <= dr;
-    bool top = dt <= db;
-    float dv = left ? dl : dr;
-    float dh = top ? dt : db;
-
-    // The valleys sit R in from every corner; nothing reaches deeper than
-    // that, so anything past it is interior.
-    float R = 0.25 * min(size.x, size.y);
-    if (dist > 0.0 || dist < -(R + 2.0)) {
-        discard;
-    }
-    float depth = -dist; // 0 at the silhouette, growing inward
-
-    // A client popover owns this rect; the ring yields to it wholesale. The
-    // rect arrives top-left-origin (y down), the same space as p.
+    // A client popover owns this rect; the handles yield to it wholesale.
+    // The rect arrives top-left-origin (y down), the same space as p.
     if (exclusion.z > 0.0 && exclusion.w > 0.0
             && p.x >= exclusion.x && p.x < exclusion.x + exclusion.z
             && p.y >= exclusion.y && p.y < exclusion.y + exclusion.w) {
         discard;
     }
 
-    float A = max(band - band_min, 0.0);
-    // The inner superellipse's radius, and the exponent that puts its
-    // diagonal point — R - Rp * 2^(-1/n) in from each side — at `band`.
-    // Clamped to a circle at the low end (a window too small for the
-    // solve gets a taller corner rather than a concave one).
-    float Rp = max(R - band_min, 1e-3);
-    float ratio = clamp(1.0 - A / Rp, 0.05, 0.9999); // 2^(-1/n)
-    float n = clamp(-LN2 / log(ratio), 2.0, 12.0);
-
-    // Signed distance to the interior, positive in the ring: in a corner
-    // square, the superellipse (its implicit function scaled by its
-    // gradient, exact where it matters — at the edge); elsewhere, how far
-    // short of the nearer inner edges the fragment falls.
-    float f;
-    if (dv < R && dh < R) {
-        vec2 c = max(vec2(R - dv, R - dh) / Rp, 0.0);
-        vec2 cn = pow(c, vec2(n));
-        float F = cn.x + cn.y - 1.0;
-        vec2 grad = n * pow(c, vec2(n - 1.0)) / Rp;
-        f = F / max(length(grad), 1e-4);
-    } else {
-        float t_h = band_min + A * side_hill(p.x, size.x, R);
-        float t_v = band_min + A * side_hill(p.y, size.y, R);
-        f = max(t_h - dh, t_v - dv);
+    float r = 0.5 * band;
+    float t = corner_radius > r
+        ? corner_radius - (corner_radius - r) / SQRT2
+        : r;
+    vec2 c[8];
+    c[0] = vec2(0.5 * size.x, r);            // Top
+    c[1] = vec2(0.5 * size.x, size.y - r);   // Bottom
+    c[2] = vec2(r, 0.5 * size.y);            // Left
+    c[3] = vec2(size.x - r, 0.5 * size.y);   // Right
+    c[4] = vec2(t, t);                       // TopLeft
+    c[5] = vec2(size.x - t, t);              // TopRight
+    c[6] = vec2(t, size.y - t);              // BottomLeft
+    c[7] = vec2(size.x - t, size.y - t);     // BottomRight
+
+    // The nearest disc owns the fragment: signed distance to its rim,
+    // negative inside.
+    float d = 1e9;
+    float zone = -1.0;
+    for (int i = 0; i < 8; i++) {
+        float di = length(p - c[i]) - r;
+        if (di < d) {
+            d = di;
+            zone = float(i);
+        }
     }
-    if (f <= 0.0) {
+    // Feather the rim by a pixel, and clip to the window's own rounded
+    // silhouette (a disc never crosses it, but the feather may).
+    float sil = corner_dist(size, position,
+        corner_radius, corner_radius, corner_radius, corner_radius);
+    float aa = clamp(0.5 - d, 0.0, 1.0) * clamp(-sil, 0.0, 1.0);
+    if (aa <= 0.0) {
         discard;
     }
 
-    // Zones: the side the fragment is nearer to owns it, so the split runs
-    // along the diagonals; along that side, everything within R of an end
-    // — the corner hill, valley to valley — is the corner zone.
-    bool vertical = dv < dh;
-    float from_end = vertical ? dh : dv;
-    float zone;
-    if (from_end < R) {
-        zone = top ? (left ? ZONE_TL : ZONE_TR) : (left ? ZONE_BL : ZONE_BR);
-    } else if (vertical) {
-        zone = left ? ZONE_LEFT : ZONE_RIGHT;
-    } else {
-        zone = top ? ZONE_TOP : ZONE_BOTTOM;
-    }
-
-    // Feather both flanks by a pixel: the silhouette side against the
-    // window's own rounded edge, the inner side along the wave.
-    float aa = clamp(depth, 0.0, 1.0) * clamp(f, 0.0, 1.0);
-
     vec4 base = (hovered >= 0.0 && abs(hovered - zone) < 0.5) ? hover_color : v_color;
     // Premultiplied, like every other rect this renderer draws.
     gl_FragColor = base * aa;
diff --git a/src/server/cursor.rs b/src/server/cursor.rs
index 32e0eb4..492444e 100644
--- a/src/server/cursor.rs
+++ b/src/server/cursor.rs
@@ -3984,23 +3984,24 @@ pub unsafe fn grid_node_info(
     None
 }
 
-/// Where a layout point falls on a window's interactive band.
+/// Which resize handle, if any, a layout point falls on.
 ///
-/// The band lives INSIDE the content rect — an inset ring hugging the
-/// window's own edges — and exists only in overview mode. Two consequences
-/// worth stating, because both are deliberate:
+/// The handles are eight discs INSIDE the content rect — one at the
+/// midpoint of each side, one on each corner — and exist only in adjust
+/// mode (overview, or Super held). Two consequences worth stating, because
+/// both are deliberate:
 ///
-///   - In normal mode there is no band at all, so a window cannot be moved
-///     or resized with the pointer. The keyboard and IPC paths
+///   - Outside adjust mode there are no handles at all, so a window cannot
+///     be resized with the pointer. The keyboard and IPC paths
 ///     (`move_window_*`, `ccectl move-window`, a client repositioning
 ///     itself) are untouched; this is only about dragging.
-///   - Inside the ring, all four edges RESIZE, the top included. Moving is
-///     what dragging the window's body already does in overview, so the top
-///     edge does not have to be spent on it the way the old outside band
-///     did.
+///   - Every disc RESIZES, the top one included. Moving is what dragging
+///     the window's body does in adjust mode, so no handle has to be spent
+///     on it; between two discs the pointer belongs to the body.
 ///
-/// `window.rs`'s `draw_borders` draws the handles from the same band width
-/// and corner length, so the zones and the visuals cannot drift.
+/// `window::handle_disc_layout` places the discs for the hit test here,
+/// the catchers in `draw_borders`, and (mirrored in the frame shader) the
+/// drawing, so the zones and the visuals cannot drift.
 pub unsafe fn get_border_zone(window: *mut crate::window::Window, lx: f64, ly: f64) -> BorderZone {
     // Overview, or Super held (window-adjust mode): the same ring either way.
     if !(*(*window).server).wm.window_adjust_active() {
@@ -4026,10 +4027,10 @@ 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`, 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.
+    // but the discs do NOT. The disc diameter is a screen size, matching
+    // what draw_borders draws: overview is zoomed out, and a handle that
+    // shrank with the window would be smallest 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 geom = (*window).box_geom;
     let rx = lx - geom.x as f64;
@@ -4040,17 +4041,16 @@ pub unsafe fn get_border_zone(window: *mut crate::window::Window, lx: f64, ly: f
     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.
+        // grab zone never outgrows the disc 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.
+    // Outside the window entirely: not ours.
     if rx < 0.0 || rx >= content_w || ry < 0.0 || ry >= content_h {
         return BorderZone::None;
     }
     // A client popover (set_popover_region) owns its rect outright: the menu
     // reads as in front of the chrome, so nothing under it may grab. Checked
-    // before the pads and the band — it beats both.
+    // before the discs — it beats them.
     if let Some(r) = (*window).popover_region {
         let (ex, ey) = (r.x as f64 * scale, r.y as f64 * scale);
         let (ew, eh) = (r.width as f64 * scale, r.height as f64 * scale);
@@ -4059,40 +4059,47 @@ pub unsafe fn get_border_zone(window: *mut crate::window::Window, lx: f64, ly: f
         }
     }
 
-    let (near_l, near_r) = (rx < bw, rx >= content_w - bw);
-    let (near_t, near_b) = (ry < bw, ry >= content_h - bw);
-    if !(near_l || near_r || near_t || near_b) {
-        return BorderZone::None;
-    }
-
-    // Corner zones are the squares within R of each content corner, R a
-    // quarter of the SHORTER side: the ring's inner edge is a wave with a
-    // hill on every corner and in the middle of every side, the valleys
-    // between them sit R in from each corner, and the valley is where the
-    // shader cuts one zone from the next. The same rule here, so what
-    // lights up is what grabs.
-    let cl_x = content_w.min(content_h) * 0.25;
-    let cl_y = cl_x;
-    let corner_l = rx < cl_x;
-    let corner_r = rx >= content_w - cl_x;
-    let corner_t = ry < cl_y;
-    let corner_b = ry >= content_h - cl_y;
-
-    if (corner_l || corner_r) && (corner_t || corner_b) {
-        return BorderZone::Resize(crate::window::Edges {
-            top: corner_t,
-            bottom: corner_b && !corner_t,
-            left: corner_l,
-            right: corner_r && !corner_l,
-        });
+    // The discs, from the same on-screen size, silhouette radius and
+    // diameter draw_borders hands the frame shader, so what is drawn is
+    // what grabs. A pixel of slack covers the antialiased rim. The corner
+    // discs place against the content radius: the widened root plate
+    // radius the corner clip uses, on screen.
+    let r_in = crate::window::widen_corner_radius(
+        (*window).root_plate_radius_base(),
+        geom.width,
+        geom.height,
+    ) as f64;
+    let r_in = (r_in * scale) as i32 as f64;
+    let (centres, r) = crate::window::handle_disc_layout(content_w, content_h, r_in, bw);
+    let reach = (r + 1.0) * (r + 1.0);
+    for (i, &(cx, cy)) in centres.iter().enumerate() {
+        let (dx, dy) = (rx - cx, ry - cy);
+        if dx * dx + dy * dy <= reach {
+            return BorderZone::Resize(edges_for_border_element(
+                crate::window::BorderElement::ALL[i],
+            ));
+        }
     }
+    // In the body, between the discs: not ours. This is what leaves the
+    // body drag-to-move working.
+    BorderZone::None
+}
 
-    BorderZone::Resize(crate::window::Edges {
-        top: near_t,
-        bottom: near_b && !near_t,
-        left: near_l,
-        right: near_r && !near_l,
-    })
+/// The resize edges a handle disc stands for — the inverse of
+/// `border_element_for_edges`.
+pub fn edges_for_border_element(element: crate::window::BorderElement) -> crate::window::Edges {
+    use crate::window::BorderElement::*;
+    let (top, bottom, left, right) = match element {
+        Top => (true, false, false, false),
+        Bottom => (false, true, false, false),
+        Left => (false, false, true, false),
+        Right => (false, false, false, true),
+        TopLeft => (true, false, true, false),
+        TopRight => (true, false, false, true),
+        BottomLeft => (false, true, true, false),
+        BottomRight => (false, true, false, true),
+    };
+    crate::window::Edges { top, bottom, left, right }
 }
 
 /// Map a resize zone's edges to the border element that should highlight.
diff --git a/src/server/window.rs b/src/server/window.rs
index 4ed2b4b..d0b8711 100644
--- a/src/server/window.rs
+++ b/src/server/window.rs
@@ -238,31 +238,46 @@ pub fn border_band_width(configured_width: u32) -> f64 {
     (configured_width as f64 * 2.0).max(HOVER_BAND_MIN)
 }
 
-// Indices into BorderRects.segments: 4 edge bars + 2 L-arm rects per corner.
-const SEG_TOP: usize = 0;
-const SEG_BOTTOM: usize = 1;
-const SEG_LEFT: usize = 2;
-const SEG_RIGHT: usize = 3;
-const SEG_TL_H: usize = 4;
-const SEG_TL_V: usize = 5;
-const SEG_TR_H: usize = 6;
-const SEG_TR_V: usize = 7;
-const SEG_BL_H: usize = 8;
-const SEG_BL_V: usize = 9;
-const SEG_BR_H: usize = 10;
-const SEG_BR_V: usize = 11;
+/// Where the eight resize handles sit — one disc per zone, in
+/// `BorderElement::index()` order — for a window whose content is `w`×`h`
+/// ON SCREEN, with silhouette corner radius `r_in` and handle diameter `d`
+/// (all screen px). Returns the centres and the disc radius. The side discs
+/// are tangent to their side; a corner disc sits on the corner's diagonal,
+/// tangent to the rounded corner arc when that arc is wider than the disc
+/// and tucked into the two straight edges otherwise. The frame shader
+/// (scenefx `frame.frag`) lays out the same discs from the same inputs;
+/// `draw_borders` (the catchers) and `cursor::get_border_zone` (the hit
+/// test) both call this, so what is drawn is what grabs. Keep the shader
+/// and this in step.
+pub fn handle_disc_layout(w: f64, h: f64, r_in: f64, d: f64) -> ([(f64, f64); 8], f64) {
+    let r = 0.5 * d;
+    let t = if r_in > r { r_in - (r_in - r) / std::f64::consts::SQRT_2 } else { r };
+    let centres = [
+        (0.5 * w, r),     // Top
+        (0.5 * w, h - r), // Bottom
+        (r, 0.5 * h),     // Left
+        (w - r, 0.5 * h), // Right
+        (t, t),           // TopLeft
+        (w - t, t),       // TopRight
+        (t, h - t),       // BottomLeft
+        (w - t, h - t),   // BottomRight
+    ];
+    (centres, r)
+}
 
 pub struct BorderRects {
-    /// Invisible full-band rects kept as scene hit-test catchers, so the
-    /// pointer never falls through the visual gaps between segments.
+    /// The old full-band hit catchers. Retired by the disc handles — the
+    /// pointer between two discs must reach the app, not a catcher — and
+    /// kept disabled.
     pub left: *mut ffi::wlr_scene_rect,
     pub right: *mut ffi::wlr_scene_rect,
     pub top: *mut ffi::wlr_scene_rect,
     pub bottom: *mut ffi::wlr_scene_rect,
-    /// The visible zone segments, indexed by the SEG_* constants. Retired by
-    /// the frame node below and kept disabled; see the creation site.
-    pub segments: [*mut ffi::wlr_scene_rect; 12],
-    /// The resize-handle ring: all eight zones in one shader-drawn node.
+    /// Invisible square catchers, one per handle disc, indexed by
+    /// `BorderElement::index()`: they make a scene hit on a disc resolve to
+    /// this window even where the client's input region does not cover it.
+    pub segments: [*mut ffi::wlr_scene_rect; 8],
+    /// The resize handles: all eight discs in one shader-drawn node.
     pub frame: *mut ffi::wlr_scene_frame,
     /// Parent of `segments`, living in the global border overlay layer rather
     /// than in the window tree. Tracks the window tree's position so the
@@ -695,16 +710,15 @@ impl Window {
             ffi::wlr_scene_node_destroy(&mut (*capture_scene).tree as *mut ffi::wlr_scene_tree as *mut ffi::wlr_scene_node);
             return Err("Failed to create window border tree");
         }
-        let mut border_segments = [std::ptr::null_mut(); 12];
+        let mut border_segments = [std::ptr::null_mut(); 8];
         for seg in border_segments.iter_mut() {
             *seg = ffi::wlr_scene_rect_create(border_tree, 0, 0, clear_color.as_ptr());
         }
-        // The resize-handle ring. One node draws all eight zones, because the
-        // thickness swells continuously along each side and rects cannot
-        // (see scenefx frame.frag). The 12 segment rects above are what it
-        // replaced; they stay allocated but disabled — the status-bar code
-        // still reaches for the array, and freeing them would be a wider
-        // change than this.
+        // The resize handles: one node draws all eight discs (scenefx
+        // frame.frag). Not eight rounded scene rects, because a scene rect
+        // takes the renderer's global corner shape — a squircle — so a rect
+        // with radius half its size would not be a circle. The eight rects
+        // above are the discs' invisible hit catchers.
         let border_frame = ffi::wlr_scene_frame_create(border_tree, 0, 0, 0, clear_color.as_ptr());
 
         let decorations_above_tree = ffi::wlr_scene_tree_create(tree);
@@ -3974,15 +3988,13 @@ impl Window {
         ffi::river_scene_rect_set_corner_radius(self.window_background, (bg_radius as f64 * self.scale) as i32);
         ffi::wlr_scene_node_set_enabled(self.window_background as *mut ffi::wlr_scene_node, !requested.hidden && self.wm_requested.ssd);
 
-        // The border draws as 8 zone segments (4 edge bars + 4 two-rect L
-        // corners) with BORDER_SEGMENT_GAP between them; the hovered zone
-        // draws in hover_color. Underneath, the 4 full-band rects stay
-        // enabled but transparent as scene hit-test catchers, so the pointer
-        // never falls through the gaps (and width 0 keeps the legacy
+        // The handles draw as eight discs in one frame node; the hovered
+        // disc draws in hover_color. Under each disc a transparent square
+        // rect is a scene hit-test catcher (width 0 keeps the legacy
         // invisible 8px virtual resize zones).
         //
-        // Segments live in `border.tree`, parented to the global border
-        // overlay layer rather than to this window's tree, so it has to be
+        // They live in `border.tree`, parented to the global border overlay
+        // layer rather than to this window's tree, so it has to be
         // positioned and enabled in step with the window by hand.
         let is_virtual_border = border.width == 0;
         // Deliberately NOT gated on `wm_requested.ssd`: that flag defaults to
@@ -4126,26 +4138,39 @@ impl Window {
             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
-            // 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_u, height: ch };
-            apply(self.border.left, b, &transparent, handles_live);
-            let b = ffi::wlr_box { x: cw - bw_u, y: 0, width: bw_u, height: ch };
-            apply(self.border.right, b, &transparent, handles_live);
-            let b = ffi::wlr_box { x: bw_u, y: 0, width: cw - 2 * bw_u, height: bw_u };
-            apply(self.border.top, b, &transparent, handles_live);
-            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, handles_live);
+            let px = |v: i32| (v as f64 * sc) as i32;
+
+            // The band catchers are retired: between two discs the pointer
+            // must reach the app, not a catcher.
+            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);
+            }
 
             let layout = &(*self.server).wm.layout;
-            // The ring hugs the window's own silhouette, so its outer arc IS
-            // the window's content radius (the widened root plate radius the
-            // corner clip uses) rather than that plus a band.
+            // The discs sit inside the window's own silhouette, so the
+            // corner discs place against the content radius (the widened
+            // root plate radius the corner clip uses).
             let r_in = bg_radius;
+
+            // Hit catchers: one transparent square under each disc, laid out
+            // by the same function the hit test uses. `apply` takes unscaled
+            // boxes, so the screen-px layout is divided back out (a px of
+            // rounding on an invisible catcher is nothing).
+            let (centres, disc_r) = handle_disc_layout(
+                cw as f64 * sc,
+                ch as f64 * sc,
+                px(r_in) as f64,
+                band_screen,
+            );
+            for (i, &(cx, cy)) in centres.iter().enumerate() {
+                let b = ffi::wlr_box {
+                    x: ((cx - disc_r) / sc).floor() as i32,
+                    y: ((cy - disc_r) / sc).floor() as i32,
+                    width: (2.0 * disc_r / sc).ceil() as i32,
+                    height: (2.0 * disc_r / sc).ceil() as i32,
+                };
+                apply(self.border.segments[i], b, &transparent, handles_live);
+            }
             // corner_len and gap are retired by the wave profile (the
             // valleys place the seams now, a quarter along each side) and
             // ignored by the shader; still passed so the node API holds.
@@ -4160,11 +4185,10 @@ 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];
 
-            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));
-            // band is the hill height, band_min (taper × band) the valley
-            // floor; bulge is the fillet radius that domes each corner hill.
+            // band is the disc diameter; the rest is retired by the discs and
+            // ignored by the shader, still passed so the node API holds.
             ffi::wlr_scene_frame_set_shape(
                 self.border.frame,
                 band_screen as f32,
@@ -4229,10 +4253,6 @@ impl Window {
                 &mut (*self.border.frame).node as *mut ffi::wlr_scene_node,
                 a > 0.0,
             );
-            // The rects the ring replaced.
-            for &seg in self.border.segments.iter() {
-                ffi::wlr_scene_node_set_enabled(seg as *mut ffi::wlr_scene_node, false);
-            }
         }
     }
 
@@ -5488,3 +5508,34 @@ unsafe extern "C" fn handle_window_commit(listener: *mut ffi::wl_listener, _data
         }
     }
 }
+
+#[cfg(test)]
+mod handle_disc_tests {
+    use super::*;
+
+    #[test]
+    fn discs_follow_border_element_order_and_stay_inside() {
+        let (c, r) = handle_disc_layout(400.0, 300.0, 0.0, 32.0);
+        assert_eq!(r, 16.0);
+        assert_eq!(c[BorderElement::Top.index()], (200.0, 16.0));
+        assert_eq!(c[BorderElement::Bottom.index()], (200.0, 284.0));
+        assert_eq!(c[BorderElement::Left.index()], (16.0, 150.0));
+        assert_eq!(c[BorderElement::Right.index()], (384.0, 150.0));
+        assert_eq!(c[BorderElement::TopLeft.index()], (16.0, 16.0));
+        assert_eq!(c[BorderElement::BottomRight.index()], (384.0, 284.0));
+        for &(x, y) in &c {
+            assert!(x - r >= 0.0 && x + r <= 400.0 && y - r >= 0.0 && y + r <= 300.0);
+        }
+    }
+
+    #[test]
+    fn corner_disc_is_tangent_to_a_wider_corner_arc() {
+        let (c, r) = handle_disc_layout(400.0, 300.0, 40.0, 32.0);
+        let (tx, ty) = c[BorderElement::TopLeft.index()];
+        assert_eq!(tx, ty);
+        // Distance from the arc centre (40, 40) plus the disc radius is the
+        // arc radius: tangent from the inside.
+        let d = ((tx - 40.0).powi(2) + (ty - 40.0).powi(2)).sqrt();
+        assert!((d + r - 40.0).abs() < 1e-9);
+    }
+}