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

commit96159346bec4ec71cc981702bf23b3a107d97672
parentd43f5245a4
authorLucas Galante <[email protected]>
date2026-08-30 09:59
feat(handles): round corner pads — a bulge, not a thicker band

The corner boss thickened the ring along the band's own direction, so
however it was tuned it stayed a band that got wider — the inner boundary
followed the window's arc. A bulge is a different shape: the boundary has
to bow INWARD at the corner, convex toward the window's centre.

So the corner is now a disc, centred on the corner arc's centre (flush
behind the silhouette, poking inward by its radius), smooth-min-unioned
onto the ring so it flows into the moulding with a fillet on each side
instead of a notch. The band goes back to running thin at the corners —
that contrast is what makes the pad read as a bead sitting on a slender
moulding rather than as more band.

Details that follow from the shape: the gap notches sever only the band,
since a groove across a pad would read as damage; a pad's fragments belong
to its corner's zone for hover; and the hit test gets a matching
corner-disc check, because the pad's tip reaches past the 32px band and
what is drawn must grab. The pad radius is `bulge` in config.kdl (screen
px, default 48, 0 disables), capped at 0.3x the window's shorter on-screen
side alongside the band's own cap.

Verified headless: the pad renders as a rounded lobe with fillets both
sides, and a drag from its inner tip — 40px diagonally inside the corner,
beyond the band — resizes both axes (840x520 -> 810x490).

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

 CLAUDE.md                                     | 16 ++++--
 scenefx/include/render/fx_renderer/shaders.h  |  1 +
 scenefx/include/scenefx/render/pass.h         |  2 +
 scenefx/include/scenefx/types/wlr_scene.h     |  4 +-
 scenefx/render/fx_renderer/fx_pass.c          |  1 +
 scenefx/render/fx_renderer/shaders.c          |  1 +
 scenefx/render/fx_renderer/shaders/frame.frag | 80 ++++++++++++++-------------
 scenefx/types/scene/wlr_scene.c               |  7 ++-
 src/server/config.rs                          | 16 ++++++
 src/server/cursor.rs                          | 19 +++++++
 src/server/window.rs                          |  1 +
 11 files changed, 102 insertions(+), 46 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index e0d09fc..178f47c 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -322,12 +322,16 @@ where the old band sat outside them.
   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
-  every piece is its own swell — `band_min` at its two ends by the gaps,
-  `band` at its middle. An edge bar peaks at the side's midpoint; a corner
-  piece peaks at the corner APEX, wrapping the arc like the corner block of
-  an ornamental frame, so the corners are bosses rather than the one place
-  the moulding runs thin. `corner_length` sets a corner boss's extent and
-  places the gaps. The profile is continuous along
+  two elements make the frame. The BAND runs thin at the corners and swells
+  to `band` at each side's midpoint; a round PAD (`bulge`, screen px, 0
+  disables) sits on each corner — a disc centred on the corner arc's centre,
+  smooth-unioned onto the ring so its inner boundary bows inward with a
+  fillet where it meets the moulding. The band deliberately thins toward the
+  corner so the pad reads as a bead on a slender moulding, not as more band.
+  The gap notches sever only the band; a groove across a pad would read as
+  damage. Both `get_border_zone` and the drawing treat a pad as its corner's
+  zone — its tip reaches past the band, so the hit test carries a matching
+  corner-disc check. 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
diff --git a/scenefx/include/render/fx_renderer/shaders.h b/scenefx/include/render/fx_renderer/shaders.h
index cecbed7..14a15aa 100644
--- a/scenefx/include/render/fx_renderer/shaders.h
+++ b/scenefx/include/render/fx_renderer/shaders.h
@@ -199,6 +199,7 @@ struct frame_shader {
 	GLint hovered;
 	GLint hover_color;
 	GLint swell_curve;
+	GLint bulge;
 };
 
 bool link_frame_program(struct frame_shader *shader);
diff --git a/scenefx/include/scenefx/render/pass.h b/scenefx/include/scenefx/render/pass.h
index 3133efc..864446f 100644
--- a/scenefx/include/scenefx/render/pass.h
+++ b/scenefx/include/scenefx/render/pass.h
@@ -128,6 +128,8 @@ struct fx_render_frame_options {
 	float hover_color[4];
 	/* Shape of the swell; < 1 gains thickness early, > 1 late. */
 	float swell_curve;
+	/* Radius of the round pad on each corner, 0 to disable. */
+	float bulge;
 	struct wlr_render_color color;
 };
 
diff --git a/scenefx/include/scenefx/types/wlr_scene.h b/scenefx/include/scenefx/types/wlr_scene.h
index 99f9493..86a338c 100644
--- a/scenefx/include/scenefx/types/wlr_scene.h
+++ b/scenefx/include/scenefx/types/wlr_scene.h
@@ -211,6 +211,8 @@ struct wlr_scene_frame {
 	float hover_color[4];
 	/** Shape of the swell; < 1 gains thickness early, > 1 late. */
 	float swell_curve;
+	/** Radius of the round pad on each corner, 0 to disable. */
+	float bulge;
 	float color[4];
 };
 
@@ -687,7 +689,7 @@ struct wlr_scene_frame *wlr_scene_frame_create(struct wlr_scene_tree *parent,
 void wlr_scene_frame_set_size(struct wlr_scene_frame *frame, int width, int height);
 void wlr_scene_frame_set_corner_radius(struct wlr_scene_frame *frame, int radius);
 void wlr_scene_frame_set_shape(struct wlr_scene_frame *frame, float band,
-	float band_min, float corner_len, float gap, float swell_curve);
+	float band_min, float corner_len, float gap, float swell_curve, float bulge);
 void wlr_scene_frame_set_color(struct wlr_scene_frame *frame, const float color[static 4]);
 void wlr_scene_frame_set_hover(struct wlr_scene_frame *frame, float hovered,
 	const float color[static 4]);
diff --git a/scenefx/render/fx_renderer/fx_pass.c b/scenefx/render/fx_renderer/fx_pass.c
index 8d946ef..42a90ee 100644
--- a/scenefx/render/fx_renderer/fx_pass.c
+++ b/scenefx/render/fx_renderer/fx_pass.c
@@ -1153,6 +1153,7 @@ void fx_render_pass_add_frame(struct fx_gles_render_pass *pass,
 	glUniform1f(renderer->shaders.frame.gap, options->gap);
 	glUniform1f(renderer->shaders.frame.hovered, options->hovered);
 	glUniform1f(renderer->shaders.frame.swell_curve, options->swell_curve);
+	glUniform1f(renderer->shaders.frame.bulge, options->bulge);
 	glUniform4f(renderer->shaders.frame.hover_color,
 			options->hover_color[0], options->hover_color[1],
 			options->hover_color[2], options->hover_color[3]);
diff --git a/scenefx/render/fx_renderer/shaders.c b/scenefx/render/fx_renderer/shaders.c
index df70a1f..460b9fe 100644
--- a/scenefx/render/fx_renderer/shaders.c
+++ b/scenefx/render/fx_renderer/shaders.c
@@ -402,6 +402,7 @@ bool link_frame_program(struct frame_shader *shader) {
 	shader->hovered = glGetUniformLocation(prog, "hovered");
 	shader->hover_color = glGetUniformLocation(prog, "hover_color");
 	shader->swell_curve = glGetUniformLocation(prog, "swell_curve");
+	shader->bulge = glGetUniformLocation(prog, "bulge");
 
 	return true;
 }
diff --git a/scenefx/render/fx_renderer/shaders/frame.frag b/scenefx/render/fx_renderer/shaders/frame.frag
index 6cccfe7..26a0f1f 100644
--- a/scenefx/render/fx_renderer/shaders/frame.frag
+++ b/scenefx/render/fx_renderer/shaders/frame.frag
@@ -2,14 +2,12 @@
 // swells from the corners toward the middle of each side, like the moulding
 // of a picture frame.
 //
-// The ring is cut into eight pieces — four corner pieces and four edge bars —
-// separated by `gap`, and EVERY piece is its own swell: `band_min` at its two
-// ends by the gaps, `band` at its middle. For an edge bar the middle is the
-// side's midpoint; for a corner piece it is the corner apex, where the piece
-// wraps the arc — so the corners carry a boss of their own, like the corner
-// blocks of an ornamental frame, instead of being the one place the moulding
-// runs thin. `corner_len` sets a corner boss's extent along each side and
-// places the gaps.
+// Two elements make the frame. The BAND runs `band_min` at the corners,
+// easing to `band` at each side's midpoint — the moulding. And a round BULGE
+// sits on each corner: a disc smooth-unioned onto the ring, so the inner
+// boundary bows inward there like a bead. The band deliberately thins toward
+// the corner so the bulge reads as its own rounded pad on a slender moulding,
+// not as more band. `corner_len` places the gaps and the corner zones.
 //
 // 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
@@ -37,9 +35,10 @@ uniform float band_min;
 uniform float corner_len;
 uniform float gap;
 // Shape of the swell along a side. Below 1 the ring gains its thickness
-// early and then creeps toward the peak — a corner that visibly swells and a
-// long slow approach to the middle. Above 1 does the reverse.
+// early and then creeps toward the peak; above 1 does the reverse.
 uniform float swell_curve;
+// Radius of the round pad on each corner, 0 to disable.
+uniform float bulge;
 // Zone under the pointer (see ZONE_* below), or < 0 for none.
 uniform float hovered;
 uniform vec4 hover_color;
@@ -69,8 +68,10 @@ const float ZONE_BR = 7.0;
 
 void main() {
     float dist = ring_dist(vec2(0.0));
-    // Outside the rect, or deeper in than the thickest the ring ever gets.
-    if (dist > 0.0 || dist < -band) {
+    // Outside the rect, or deeper in than anything here reaches: the band at
+    // its thickest, or a corner bulge (its centre already sits corner_radius
+    // in from the silhouette).
+    if (dist > 0.0 || dist < -(max(band, bulge + corner_radius) + 1.0)) {
         discard;
     }
     float depth = -dist; // 0 at the silhouette, growing inward
@@ -96,34 +97,39 @@ void main() {
     // Distance from the nearer end of that side.
     float u = min(along, len - along);
 
-    // Per-piece swell: v runs 0 at a piece's ends to 1 at its peak. A corner
-    // piece peaks at u=0 — the apex, where the two sides' fragments meet on
-    // the diagonal; u is the same distance for both there, so the profile is
-    // continuous across it, and smoothstep's flat top leaves no crease. An
-    // edge bar peaks at the side's midpoint. Both ends of every piece sit at
-    // band_min, so each gap separates two thin tips — a scalloped frame.
-    // `swell_curve` reshapes every swell the same way: below 1 gains early
-    // and creeps to the peak.
+    // The band: thin at the corners, swelling to the side's midpoint, with
+    // swell_curve pulling the gain early (below 1) or late (above 1).
     float half_side = max(0.5 * len, 1e-3);
-    float v;
-    if (u <= corner_len) {
-        v = 1.0 - u / max(corner_len, 1e-3);
-    } else {
-        float run = max(half_side - corner_len - gap, 1e-3);
-        v = clamp((u - corner_len - gap) / run, 0.0, 1.0);
-    }
-    float s = pow(smoothstep(0.0, 1.0, v), max(swell_curve, 0.01));
+    float t = clamp(u / half_side, 0.0, 1.0);
+    float s = pow(smoothstep(0.0, 1.0, t), max(swell_curve, 0.01));
     float thickness = mix(band_min, band, s);
+    float f_ring = thickness - depth;
+
+    // The corner bulge: a disc centred on the corner ARC's centre, so it sits
+    // flush behind the silhouette and pokes inward by its radius. Positive
+    // inside, like f_ring.
+    bool left = dl <= dr;
+    bool top = dt <= db;
+    vec2 arc_c = vec2(left ? corner_radius : size.x - corner_radius,
+                      top ? corner_radius : size.y - corner_radius);
+    float f_disc = bulge - length(p - arc_c);
+
+    // Smooth union of band and bulge: the pad flows into the moulding with a
+    // fillet instead of a notch. Blend radius scales with the pad.
+    float k = max(0.35 * bulge, 1.0);
+    float h = clamp(0.5 + 0.5 * (f_disc - f_ring) / k, 0.0, 1.0);
+    float f = mix(f_ring, f_disc, h) + k * h * (1.0 - h);
 
-    // The cut into zones is separate from the profile: it decides where the
-    // gaps fall and which zone the pointer is over, nothing about thickness.
+    // Zones: anything on the pad is its corner's, then the band cut as
+    // before. The gap only severs the BAND — a groove across the pad would
+    // read as damage, so it skips fragments the disc owns.
     float zone;
-    if (u <= corner_len) {
-        bool left = dl <= dr;
-        bool top = dt <= db;
+    if (u <= corner_len || f_disc > 0.0) {
         zone = top ? (left ? ZONE_TL : ZONE_TR) : (left ? ZONE_BL : ZONE_BR);
+        if (u > corner_len && u <= corner_len + gap && f_disc <= 0.0) {
+            discard;
+        }
     } else if (u <= corner_len + gap) {
-        // The gap between a corner piece and its neighbouring bar.
         discard;
     } else if (vertical) {
         zone = (dl <= dr) ? ZONE_LEFT : ZONE_RIGHT;
@@ -131,13 +137,13 @@ void main() {
         zone = (dt <= db) ? ZONE_TOP : ZONE_BOTTOM;
     }
 
-    if (depth > thickness) {
+    if (f <= 0.0) {
         discard;
     }
 
     // Feather both flanks by a pixel: the silhouette side against the
-    // window's own rounded edge, the inner side against its content.
-    float aa = clamp(depth, 0.0, 1.0) * clamp(thickness - depth, 0.0, 1.0);
+    // window's own rounded edge, the inner side along the unioned boundary.
+    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.
diff --git a/scenefx/types/scene/wlr_scene.c b/scenefx/types/scene/wlr_scene.c
index eec1060..b9b0b66 100644
--- a/scenefx/types/scene/wlr_scene.c
+++ b/scenefx/types/scene/wlr_scene.c
@@ -1154,6 +1154,7 @@ struct wlr_scene_frame *wlr_scene_frame_create(struct wlr_scene_tree *parent,
 	scene_frame->gap = 0.0f;
 	scene_frame->hovered = -1.0f;
 	scene_frame->swell_curve = 1.0f;
+	scene_frame->bulge = 0.0f;
 	memcpy(scene_frame->color, color, sizeof(scene_frame->color));
 	memcpy(scene_frame->hover_color, color, sizeof(scene_frame->hover_color));
 
@@ -1180,13 +1181,14 @@ void wlr_scene_frame_set_corner_radius(struct wlr_scene_frame *frame, int radius
 }
 
 void wlr_scene_frame_set_shape(struct wlr_scene_frame *frame, float band,
-		float band_min, float corner_len, float gap, float swell_curve) {
+		float band_min, float corner_len, float gap, float swell_curve, float bulge) {
 	if (frame->band == band && frame->band_min == band_min
 			&& frame->corner_len == corner_len && frame->gap == gap
-			&& frame->swell_curve == swell_curve) {
+			&& frame->swell_curve == swell_curve && frame->bulge == bulge) {
 		return;
 	}
 	frame->swell_curve = swell_curve;
+	frame->bulge = bulge;
 	frame->band = band;
 	frame->band_min = band_min;
 	frame->corner_len = corner_len;
@@ -2610,6 +2612,7 @@ static void scene_entry_render(struct render_list_entry *entry, const struct ren
 			.gap = scene_frame->gap,
 			.hovered = scene_frame->hovered,
 			.swell_curve = scene_frame->swell_curve,
+			.bulge = scene_frame->bulge,
 			.hover_color = {
 				scene_frame->hover_color[0],
 				scene_frame->hover_color[1],
diff --git a/src/server/config.rs b/src/server/config.rs
index 6271d61..0cecaae 100644
--- a/src/server/config.rs
+++ b/src/server/config.rs
@@ -43,6 +43,9 @@ pub struct Layout {
     /// approach to the middle. Above 1 stays thin near the corner and gains
     /// late. 1.0 is the plain eased ramp.
     pub border_swell_curve: f32,
+    /// Radius of the round pad on each corner of the handle ring, in screen
+    /// px. 0 disables the pads and leaves the plain moulding.
+    pub border_corner_bulge: f32,
     /// Corner zone length measured from the outer corner along each band;
     /// 0 = auto (max(2 * width, 16)).
     pub border_corner_length: i32,
@@ -201,6 +204,7 @@ impl Default for Layout {
             border_taper: 0.35,
             border_handle_width: 32.0,
             border_swell_curve: 0.45,
+            border_corner_bulge: 48.0,
             border_corner_length: 0,
             background_r: 0x1C1C1C1Cu32,
             background_g: 0x20202020u32,
@@ -500,6 +504,8 @@ pub struct SurfaceConfig {
     pub border_handle_width: f64,
     #[serde(default = "default_border_swell_curve")]
     pub border_swell_curve: f64,
+    #[serde(default = "default_border_corner_bulge")]
+    pub border_corner_bulge: f64,
     /// 0 = auto (max(2 * width, 16)).
     #[serde(default)]
     pub border_corner_length: i64,
@@ -598,6 +604,7 @@ impl Default for SurfaceConfig {
             border_taper: default_border_taper(),
             border_handle_width: default_border_handle_width(),
             border_swell_curve: default_border_swell_curve(),
+            border_corner_bulge: default_border_corner_bulge(),
             border_corner_length: 0,
             cloud_position_default: default_cloud_position_default(),
             shadow_enabled: default_shadow_enabled(),
@@ -702,6 +709,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_swell_curve() -> f64 { 0.45 }
+fn default_border_corner_bulge() -> f64 { 48.0 }
 
 fn default_border_segment_gap() -> i64 {
     4
@@ -2055,6 +2063,13 @@ fn parse_kdl_config(content: &str) -> Result<Config, String> {
                                             surface.border_swell_curve = val as f64;
                                         }
                                     }
+                                    "bulge" => {
+                                        if let Some(val) = entry.value().as_f64() {
+                                            surface.border_corner_bulge = val;
+                                        } else if let Some(val) = entry.value().as_i64() {
+                                            surface.border_corner_bulge = val as f64;
+                                        }
+                                    }
                                     "corner_length" => {
                                         if let Some(val) = entry.value().as_i64() {
                                             surface.border_corner_length = val;
@@ -2355,6 +2370,7 @@ pub fn parse_config(path: &str, state: &mut crate::window_manager::WindowManager
     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_swell_curve = config.surface.border_swell_curve.clamp(0.1, 6.0) as f32;
+    state.layout.border_corner_bulge = config.surface.border_corner_bulge.max(0.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 605389e..70d8ccf 100644
--- a/src/server/cursor.rs
+++ b/src/server/cursor.rs
@@ -2863,6 +2863,25 @@ pub unsafe fn get_border_zone(window: *mut crate::window::Window, lx: f64, ly: f
     if rx < 0.0 || rx >= content_w || ry < 0.0 || ry >= content_h {
         return BorderZone::None;
     }
+    // The corner pads reach further inward than the band (their radius plus
+    // the corner arc), and what is drawn must grab: a point within a pad's
+    // radius of its corner resizes on that corner's two edges.
+    let pad = ((*(*window).server).wm.layout.border_corner_bulge as f64)
+        .min(content_w.min(content_h) * 0.3);
+    if pad > 0.0 {
+        let cx = if rx < content_w / 2.0 { 0.0 } else { content_w };
+        let cy = if ry < content_h / 2.0 { 0.0 } else { content_h };
+        let (dx, dy) = (rx - cx, ry - cy);
+        if (dx * dx + dy * dy).sqrt() < pad {
+            return BorderZone::Resize(crate::window::Edges {
+                top: cy == 0.0,
+                bottom: cy > 0.0,
+                left: cx == 0.0,
+                right: cx > 0.0,
+            });
+        }
+    }
+
     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) {
diff --git a/src/server/window.rs b/src/server/window.rs
index adfb6da..be916a0 100644
--- a/src/server/window.rs
+++ b/src/server/window.rs
@@ -3818,6 +3818,7 @@ impl Window {
                 (px(cl) as f32).max(band_screen as f32),
                 px(g) as f32,
                 layout.border_swell_curve,
+                (layout.border_corner_bulge as f64).min(short_side * 0.3) as f32,
             );
             ffi::wlr_scene_frame_set_color(self.border.frame, premul(&border_color).as_ptr());
             let hovered = self