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