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

commitc59820299e71a00cf0815e55888c86831670bc3d
parent27d9d2dccd
authorLucas Galante <[email protected]>
date2026-08-29 13:50
feat(handles): swell_curve — a corner that actually swells

Two complaints, one cause. The corners showed no swell however long they
got, and the edges gained their thickness too fast.

The eased ramp is nearly flat exactly where a corner piece sits. A corner
spanning an eighth of a side only reaches t=0.13, and smoothstep(0.13) is
0.04 — under a pixel of the whole range. Lengthening the corner barely helps
while the curve is that flat at the start; it just makes a longer constant
run.

So the profile takes an exponent, `swell_curve`, applied after the
smoothstep. Below 1 it pulls the gain earlier: the ring rises quickly off
the corner, so the corner visibly swells, and then creeps the rest of the
way, so the edge takes far longer to reach full size. Above 1 is the
reverse. smoothstep still runs first, so both ends stay crease-free.

Default 0.45. Measured on an 839px-wide window with corner_length raised to
120 (corner pieces spanning ~14% of a side): 11px at the very corner, 15,
17, 19 across the corner piece, then 22, 24, 28, 30, and 31 at the middle —
a visible swell in the corner and a long slow approach after it.

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

 CLAUDE.md                                     |  9 ++++++++-
 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 | 18 +++++++++++++-----
 scenefx/types/scene/wlr_scene.c               |  8 ++++++--
 src/server/config.rs                          | 18 ++++++++++++++++++
 src/server/window.rs                          |  1 +
 10 files changed, 54 insertions(+), 9 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index 4f6dd0a..41e5d2c 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -340,7 +340,14 @@ 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.
-- Three knobs shape it, all under `border` in config.kdl. `handle_width` is
+- `swell_curve` shapes the profile: below 1 the ring gains its thickness
+  early — a corner that visibly swells, then a long creep to the peak at the
+  midpoint — and above 1 does the reverse. It matters more than it sounds,
+  because a plain eased ramp is nearly FLAT across a corner piece: a corner
+  spanning an eighth of a side only reaches t=0.13, where smoothstep is 0.04,
+  under a pixel of the whole range. That is why the corners read as a constant
+  thin run without it, however long `corner_length` makes them.
+- Four 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
diff --git a/scenefx/include/render/fx_renderer/shaders.h b/scenefx/include/render/fx_renderer/shaders.h
index 011267c..cecbed7 100644
--- a/scenefx/include/render/fx_renderer/shaders.h
+++ b/scenefx/include/render/fx_renderer/shaders.h
@@ -198,6 +198,7 @@ struct frame_shader {
 	GLint gap;
 	GLint hovered;
 	GLint hover_color;
+	GLint swell_curve;
 };
 
 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 224b95c..3133efc 100644
--- a/scenefx/include/scenefx/render/pass.h
+++ b/scenefx/include/scenefx/render/pass.h
@@ -126,6 +126,8 @@ struct fx_render_frame_options {
 	/* Zone under the pointer (compositor BorderElement index), < 0 none. */
 	float hovered;
 	float hover_color[4];
+	/* Shape of the swell; < 1 gains thickness early, > 1 late. */
+	float swell_curve;
 	struct wlr_render_color color;
 };
 
diff --git a/scenefx/include/scenefx/types/wlr_scene.h b/scenefx/include/scenefx/types/wlr_scene.h
index 979c800..99f9493 100644
--- a/scenefx/include/scenefx/types/wlr_scene.h
+++ b/scenefx/include/scenefx/types/wlr_scene.h
@@ -209,6 +209,8 @@ struct wlr_scene_frame {
 	/** Zone under the pointer (BorderElement index), or < 0 for none. */
 	float hovered;
 	float hover_color[4];
+	/** Shape of the swell; < 1 gains thickness early, > 1 late. */
+	float swell_curve;
 	float color[4];
 };
 
@@ -685,7 +687,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 band_min, float corner_len, float gap, float swell_curve);
 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 6677f5e..8d946ef 100644
--- a/scenefx/render/fx_renderer/fx_pass.c
+++ b/scenefx/render/fx_renderer/fx_pass.c
@@ -1152,6 +1152,7 @@ void fx_render_pass_add_frame(struct fx_gles_render_pass *pass,
 	glUniform1f(renderer->shaders.frame.corner_len, options->corner_len);
 	glUniform1f(renderer->shaders.frame.gap, options->gap);
 	glUniform1f(renderer->shaders.frame.hovered, options->hovered);
+	glUniform1f(renderer->shaders.frame.swell_curve, options->swell_curve);
 	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 8fdd097..df70a1f 100644
--- a/scenefx/render/fx_renderer/shaders.c
+++ b/scenefx/render/fx_renderer/shaders.c
@@ -401,6 +401,7 @@ bool link_frame_program(struct frame_shader *shader) {
 	shader->gap = glGetUniformLocation(prog, "gap");
 	shader->hovered = glGetUniformLocation(prog, "hovered");
 	shader->hover_color = glGetUniformLocation(prog, "hover_color");
+	shader->swell_curve = glGetUniformLocation(prog, "swell_curve");
 
 	return true;
 }
diff --git a/scenefx/render/fx_renderer/shaders/frame.frag b/scenefx/render/fx_renderer/shaders/frame.frag
index 6ed967e..4a5fffa 100644
--- a/scenefx/render/fx_renderer/shaders/frame.frag
+++ b/scenefx/render/fx_renderer/shaders/frame.frag
@@ -35,6 +35,10 @@ uniform float band_min;
 // separates it from the neighbouring edge bar.
 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.
+uniform float swell_curve;
 // Zone under the pointer (see ZONE_* below), or < 0 for none.
 uniform float hovered;
 uniform vec4 hover_color;
@@ -92,13 +96,17 @@ void main() {
     float u = min(along, len - along);
 
     // Thickness: one curve over the whole half-side, corner pieces included.
-    // Spreading it across the full run rather than only the bar is what makes
-    // the swell gradual; smoothstep flattens the curve at both ends, so it
-    // leaves no crease where the two halves meet at the midpoint and none
-    // where it starts at the corner.
+    // smoothstep first, so the curve is flat where the two halves meet at the
+    // midpoint and where it starts at the corner — no crease at either. Then
+    // `swell_curve` reshapes it: below 1 pulls the gain earlier, which is what
+    // makes the corner swell visible at all. Plain smoothstep is nearly flat
+    // across a corner piece (a corner spanning an eighth of the side reaches
+    // t=0.13, where smoothstep is 0.04 — under a pixel of the whole range),
+    // which is why the corners read as a constant thin run without this.
     float half_side = max(0.5 * len, 1e-3);
     float t = clamp(u / half_side, 0.0, 1.0);
-    float thickness = mix(band_min, band, smoothstep(0.0, 1.0, t));
+    float s = pow(smoothstep(0.0, 1.0, t), max(swell_curve, 0.01));
+    float thickness = mix(band_min, band, s);
 
     // 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.
diff --git a/scenefx/types/scene/wlr_scene.c b/scenefx/types/scene/wlr_scene.c
index b22faa2..eec1060 100644
--- a/scenefx/types/scene/wlr_scene.c
+++ b/scenefx/types/scene/wlr_scene.c
@@ -1153,6 +1153,7 @@ struct wlr_scene_frame *wlr_scene_frame_create(struct wlr_scene_tree *parent,
 	scene_frame->corner_len = 0.0f;
 	scene_frame->gap = 0.0f;
 	scene_frame->hovered = -1.0f;
+	scene_frame->swell_curve = 1.0f;
 	memcpy(scene_frame->color, color, sizeof(scene_frame->color));
 	memcpy(scene_frame->hover_color, color, sizeof(scene_frame->hover_color));
 
@@ -1179,11 +1180,13 @@ 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 band_min, float corner_len, float gap, float swell_curve) {
 	if (frame->band == band && frame->band_min == band_min
-			&& frame->corner_len == corner_len && frame->gap == gap) {
+			&& frame->corner_len == corner_len && frame->gap == gap
+			&& frame->swell_curve == swell_curve) {
 		return;
 	}
+	frame->swell_curve = swell_curve;
 	frame->band = band;
 	frame->band_min = band_min;
 	frame->corner_len = corner_len;
@@ -2606,6 +2609,7 @@ static void scene_entry_render(struct render_list_entry *entry, const struct ren
 			.corner_len = scene_frame->corner_len,
 			.gap = scene_frame->gap,
 			.hovered = scene_frame->hovered,
+			.swell_curve = scene_frame->swell_curve,
 			.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 7f6ac44..6271d61 100644
--- a/src/server/config.rs
+++ b/src/server/config.rs
@@ -38,6 +38,11 @@ pub struct Layout {
     /// 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,
+    /// Shape of the handle ring's swell along a side. Below 1 the ring gains
+    /// its thickness early — a corner that visibly swells, then a long slow
+    /// 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,
     /// Corner zone length measured from the outer corner along each band;
     /// 0 = auto (max(2 * width, 16)).
     pub border_corner_length: i32,
@@ -195,6 +200,7 @@ impl Default for Layout {
             border_segment_gap: 4,
             border_taper: 0.35,
             border_handle_width: 32.0,
+            border_swell_curve: 0.45,
             border_corner_length: 0,
             background_r: 0x1C1C1C1Cu32,
             background_g: 0x20202020u32,
@@ -492,6 +498,8 @@ pub struct SurfaceConfig {
     pub border_taper: f64,
     #[serde(default = "default_border_handle_width")]
     pub border_handle_width: f64,
+    #[serde(default = "default_border_swell_curve")]
+    pub border_swell_curve: f64,
     /// 0 = auto (max(2 * width, 16)).
     #[serde(default)]
     pub border_corner_length: i64,
@@ -589,6 +597,7 @@ impl Default for SurfaceConfig {
             border_segment_gap: default_border_segment_gap(),
             border_taper: default_border_taper(),
             border_handle_width: default_border_handle_width(),
+            border_swell_curve: default_border_swell_curve(),
             border_corner_length: 0,
             cloud_position_default: default_cloud_position_default(),
             shadow_enabled: default_shadow_enabled(),
@@ -692,6 +701,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_segment_gap() -> i64 {
     4
@@ -2038,6 +2048,13 @@ fn parse_kdl_config(content: &str) -> Result<Config, String> {
                                             surface.border_handle_width = val as f64;
                                         }
                                     }
+                                    "swell_curve" => {
+                                        if let Some(val) = entry.value().as_f64() {
+                                            surface.border_swell_curve = val;
+                                        } else if let Some(val) = entry.value().as_i64() {
+                                            surface.border_swell_curve = val as f64;
+                                        }
+                                    }
                                     "corner_length" => {
                                         if let Some(val) = entry.value().as_i64() {
                                             surface.border_corner_length = val;
@@ -2337,6 +2354,7 @@ pub fn parse_config(path: &str, state: &mut crate::window_manager::WindowManager
     // 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_swell_curve = config.surface.border_swell_curve.clamp(0.1, 6.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/window.rs b/src/server/window.rs
index 9515548..a3a7d07 100644
--- a/src/server/window.rs
+++ b/src/server/window.rs
@@ -3805,6 +3805,7 @@ impl Window {
                 (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,
+                layout.border_swell_curve,
             );
             ffi::wlr_scene_frame_set_color(self.border.frame, premul(&border_color).as_ptr());
             let hovered = self