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

commit1feaf773f929e6597bc33d06d47698a481d6ba8c
parent2e09b182ee
authorLucas Galante <[email protected]>
date2026-08-29 13:04
feat(handles): a picture-frame ring that swells toward each side's middle

The resize ring was an even band. Now it thickens from the corners to the
middle of each side, like the moulding of a picture frame, and the thin
corner run is longer.

This needed a shader. A scene rect's only shaping tool is a clipped region
whose corner radius is one scalar, so the fillet it can cut is capped by the
thickness change — tens of pixels against a side hundreds long. That reads
as a bump near the centre, not a swell along the run. So: a new
wlr_scene_frame node and frame.frag, modelled on the bevel node, drawing all
eight zones in one node from the shared corner_dist SDF. The 12 segment
rects it replaced stay allocated but disabled.

Two shaping knobs under `border` in config.kdl. `corner_length` already
existed and now sets how far the thin corner run extends; `taper` is new —
the corner thickness as a fraction of the middle's, clamped to (0, 1]
because past 1 the corners would be thicker than the middle, which is the
moulding inside out.

The grab zone is deliberately NOT tapered: the four catcher rects still
cover the full even band, so a corner drawn thin is still grabbed at full
width. The swell is ornament and should not cost precision.

Two mistakes worth recording, both from assuming rather than reading.
corner_alpha.frag flips y after subtracting `position`, so zone logic that
skips the flip is upside down against the SDF it is paired with. And the
node's geometry is device px, like every rect `apply` positions — passing
box_geom's unscaled size drew the ring far larger than its window at any
zoom below 1.

Measured across a side at overview zoom: 2px at both ends, rising smoothly
to 6px at the middle. Resize still exact — a +50px drag on the inside right
edge gives +50 width.

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

 CLAUDE.md                                        |  29 ++++-
 scenefx/include/render/fx_renderer/fx_renderer.h |   1 +
 scenefx/include/render/fx_renderer/shaders.h     |  20 +++
 scenefx/include/scenefx/render/pass.h            |  25 ++++
 scenefx/include/scenefx/types/wlr_scene.h        |  36 ++++++
 scenefx/render/fx_renderer/fx_pass.c             |  46 +++++++
 scenefx/render/fx_renderer/fx_renderer.c         |   7 +
 scenefx/render/fx_renderer/shaders.c             |  30 +++++
 scenefx/render/fx_renderer/shaders/frame.frag    | 128 ++++++++++++++++++
 scenefx/render/fx_renderer/shaders/meson.build   |   1 +
 scenefx/types/scene/wlr_scene.c                  | 120 +++++++++++++++++
 src/server/config.rs                             |  20 +++
 src/server/window.rs                             | 157 +++++++++--------------
 13 files changed, 521 insertions(+), 99 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index b4338cc..866297a 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -320,12 +320,29 @@ 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.
-- `window::draw_borders` draws the handles from the same band width and
-  corner length the hit test uses, and `window::window_takes_handles` is the
-  single predicate for which windows get them (excluding Popup, Fullscreen,
-  Status, Utility, circular, hidden). 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.
+- The ring is drawn by **one scenefx node**, `wlr_scene_frame`
+  (`scenefx/render/fx_renderer/shaders/frame.frag`), not by rects. Its
+  thickness swells from `band_min` at the corners to `band` at the middle of
+  each side — a picture-frame moulding — and that 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.
+- Two knobs shape it, both under `border` in config.kdl: `corner_length` sets
+  how far the thin corner run extends before the swell begins, and `taper`
+  (new) is the corner thickness as a fraction of the middle's — 1.0 is an even
+  ring, and it is clamped to (0, 1] because past 1 the corners would be
+  thicker than the middle, which is the moulding inside out.
+- 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.
 - Handles are shown for **every** eligible window the whole time overview is
   on, not just the hovered one, via the `all_on` branch in
   `step_border_fade`; hover still reads through as `hover_color`. Because
diff --git a/scenefx/include/render/fx_renderer/fx_renderer.h b/scenefx/include/render/fx_renderer/fx_renderer.h
index c4d492a..1909a97 100644
--- a/scenefx/include/render/fx_renderer/fx_renderer.h
+++ b/scenefx/include/render/fx_renderer/fx_renderer.h
@@ -201,6 +201,7 @@ struct fx_renderer {
 
 		struct box_shadow_shader box_shadow;
 		struct bevel_shader bevel;
+		struct frame_shader frame;
 		struct droplet_shader droplet;
 		struct blur_shader blur1;
 		struct blur_shader blur2;
diff --git a/scenefx/include/render/fx_renderer/shaders.h b/scenefx/include/render/fx_renderer/shaders.h
index 10f28e8..011267c 100644
--- a/scenefx/include/render/fx_renderer/shaders.h
+++ b/scenefx/include/render/fx_renderer/shaders.h
@@ -182,6 +182,26 @@ struct bevel_shader {
 
 bool link_bevel_program(struct bevel_shader *shader);
 
+struct frame_shader {
+	GLuint program;
+	GLint proj;
+	GLint color;
+	GLint pos_attrib;
+	GLint position;
+	GLint size;
+	GLint corner_radius;
+	GLint corner_shape;
+	// Thickness at a side's midpoint and at the corner pieces.
+	GLint band;
+	GLint band_min;
+	GLint corner_len;
+	GLint gap;
+	GLint hovered;
+	GLint hover_color;
+};
+
+bool link_frame_program(struct frame_shader *shader);
+
 struct droplet_shader {
 	GLuint program;
 	GLint proj;
diff --git a/scenefx/include/scenefx/render/pass.h b/scenefx/include/scenefx/render/pass.h
index 8914105..224b95c 100644
--- a/scenefx/include/scenefx/render/pass.h
+++ b/scenefx/include/scenefx/render/pass.h
@@ -107,6 +107,28 @@ struct fx_render_bevel_options {
 	struct wlr_render_color color;
 };
 
+/* A resize-handle frame: a ring inside a rounded rect, cut into four corner
+ * pieces and four edge bars, whose thickness swells from `band_min` at the
+ * corners to `band` at each side's midpoint. */
+struct fx_render_frame_options {
+	struct wlr_box box;
+	/* Clip region, leave NULL to disable clipping */
+	const pixman_region32_t *clip;
+
+	int corner_radius;
+	/* Thickness at a side's midpoint, and along the corner pieces. */
+	float band;
+	float band_min;
+	/* How far a corner piece runs along each of its sides, and the gap
+	 * separating it from the neighbouring bar. */
+	float corner_len;
+	float gap;
+	/* Zone under the pointer (compositor BorderElement index), < 0 none. */
+	float hovered;
+	float hover_color[4];
+	struct wlr_render_color color;
+};
+
 struct fx_render_droplet_options {
 	struct wlr_box box;
 	/* Clip region, leave NULL to disable clipping */
@@ -186,6 +208,9 @@ void fx_render_pass_add_box_shadow(struct fx_gles_render_pass *pass,
 /**
  * Render an edge bevel: a lit chamfer around the inside of a rounded rect.
  */
+void fx_render_pass_add_frame(struct fx_gles_render_pass *pass,
+	const struct fx_render_frame_options *options);
+
 void fx_render_pass_add_bevel(struct fx_gles_render_pass *pass,
 		const struct fx_render_bevel_options *options);
 
diff --git a/scenefx/include/scenefx/types/wlr_scene.h b/scenefx/include/scenefx/types/wlr_scene.h
index 9239a8d..979c800 100644
--- a/scenefx/include/scenefx/types/wlr_scene.h
+++ b/scenefx/include/scenefx/types/wlr_scene.h
@@ -62,6 +62,7 @@ enum wlr_scene_node_type {
 	WLR_SCENE_NODE_BUFFER,
 	WLR_SCENE_NODE_SHADOW,
 	WLR_SCENE_NODE_BEVEL,
+	WLR_SCENE_NODE_FRAME,
 	WLR_SCENE_NODE_DROPLET,
 	WLR_SCENE_NODE_OPTIMIZED_BLUR,
 	WLR_SCENE_NODE_BLUR,
@@ -191,6 +192,26 @@ struct wlr_scene_droplet {
 	float ghost;
 };
 
+/** A resize-handle frame: a ring inside a rounded rect, cut into four corner
+ * pieces and four edge bars, thickening from the corners toward the middle of
+ * each side. */
+struct wlr_scene_frame {
+	struct wlr_scene_node node;
+	int width, height;
+	int corner_radius;
+	/** Thickness at a side's midpoint, and along the corner pieces. */
+	float band;
+	float band_min;
+	/** How far a corner piece runs along each side, and the gap that
+	 * separates it from the neighbouring bar. */
+	float corner_len;
+	float gap;
+	/** Zone under the pointer (BorderElement index), or < 0 for none. */
+	float hovered;
+	float hover_color[4];
+	float color[4];
+};
+
 struct wlr_scene_bevel {
 	struct wlr_scene_node node;
 	int width, height;
@@ -569,6 +590,11 @@ struct wlr_scene_shadow *wlr_scene_shadow_from_node(struct wlr_scene_node *node)
  */
 struct wlr_scene_bevel *wlr_scene_bevel_from_node(struct wlr_scene_node *node);
 
+/**
+ * If this node represents a wlr_scene_frame, that structure is returned.
+ */
+struct wlr_scene_frame *wlr_scene_frame_from_node(struct wlr_scene_node *node);
+
 /**
  * If this node represents a wlr_scene_droplet, that structure is returned.
  */
@@ -654,6 +680,16 @@ struct wlr_scene_bevel *wlr_scene_bevel_create(struct wlr_scene_tree *parent,
 	int width, int height, int corner_radius, float thickness,
 	const float color[static 4]);
 
+struct wlr_scene_frame *wlr_scene_frame_create(struct wlr_scene_tree *parent,
+	int width, int height, int corner_radius, const float color[static 4]);
+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);
+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]);
+
 void wlr_scene_bevel_set_size(struct wlr_scene_bevel *bevel, int width, int height);
 void wlr_scene_bevel_set_corner_radius(struct wlr_scene_bevel *bevel, int radius);
 void wlr_scene_bevel_set_thickness(struct wlr_scene_bevel *bevel, float thickness);
diff --git a/scenefx/render/fx_renderer/fx_pass.c b/scenefx/render/fx_renderer/fx_pass.c
index 0662dbf..6677f5e 100644
--- a/scenefx/render/fx_renderer/fx_pass.c
+++ b/scenefx/render/fx_renderer/fx_pass.c
@@ -1116,6 +1116,52 @@ static struct fx_framebuffer *get_main_buffer_blur(struct fx_gles_render_pass *p
 	return fx_options->current_buffer;
 }
 
+void fx_render_pass_add_frame(struct fx_gles_render_pass *pass,
+		const struct fx_render_frame_options *options) {
+	struct fx_renderer *renderer = pass->buffer->renderer;
+
+	struct wlr_box box = options->box;
+	assert(box.width > 0 && box.height > 0);
+
+	pixman_region32_t clip_region;
+	if (options->clip) {
+		pixman_region32_init(&clip_region);
+		pixman_region32_copy(&clip_region, options->clip);
+	} else {
+		pixman_region32_init_rect(&clip_region, box.x, box.y, box.width, box.height);
+	}
+
+	push_fx_debug(renderer);
+
+	// Premultiplied source-over: the shader emits colour already scaled by
+	// its coverage, the same convention the bevel pass settled on.
+	setup_blending(WLR_RENDER_BLEND_MODE_PREMULTIPLIED);
+	glBlendFuncSeparate(GL_ONE, GL_ONE_MINUS_SRC_ALPHA, GL_ZERO, GL_ONE);
+
+	glUseProgram(renderer->shaders.frame.program);
+
+	const struct wlr_render_color *color = &options->color;
+	set_proj_matrix(renderer->shaders.frame.proj, pass->projection_matrix, &box);
+	glUniform4f(renderer->shaders.frame.color, color->r, color->g, color->b, color->a);
+	glUniform2f(renderer->shaders.frame.size, box.width, box.height);
+	glUniform2f(renderer->shaders.frame.position, box.x, box.y);
+	glUniform1f(renderer->shaders.frame.corner_radius, options->corner_radius);
+	glUniform1f(renderer->shaders.frame.corner_shape, fx_corner_shape());
+	glUniform1f(renderer->shaders.frame.band, options->band);
+	glUniform1f(renderer->shaders.frame.band_min, options->band_min);
+	glUniform1f(renderer->shaders.frame.corner_len, options->corner_len);
+	glUniform1f(renderer->shaders.frame.gap, options->gap);
+	glUniform1f(renderer->shaders.frame.hovered, options->hovered);
+	glUniform4f(renderer->shaders.frame.hover_color,
+			options->hover_color[0], options->hover_color[1],
+			options->hover_color[2], options->hover_color[3]);
+
+	render(&box, &clip_region, renderer->shaders.frame.pos_attrib);
+	pixman_region32_fini(&clip_region);
+
+	pop_fx_debug(renderer);
+}
+
 void fx_render_pass_add_bevel(struct fx_gles_render_pass *pass,
 		const struct fx_render_bevel_options *options) {
 	struct fx_renderer *renderer = pass->buffer->renderer;
diff --git a/scenefx/render/fx_renderer/fx_renderer.c b/scenefx/render/fx_renderer/fx_renderer.c
index fed750a..39507c5 100644
--- a/scenefx/render/fx_renderer/fx_renderer.c
+++ b/scenefx/render/fx_renderer/fx_renderer.c
@@ -96,6 +96,7 @@ static inline void free_shaders(struct fx_renderer *renderer) {
 	glDeleteProgram(renderer->shaders.tex_effects_ext.program);
 	glDeleteProgram(renderer->shaders.box_shadow.program);
 	glDeleteProgram(renderer->shaders.bevel.program);
+	glDeleteProgram(renderer->shaders.frame.program);
 	glDeleteProgram(renderer->shaders.droplet.program);
 	glDeleteProgram(renderer->shaders.blur1.program);
 	glDeleteProgram(renderer->shaders.blur2.program);
@@ -430,6 +431,12 @@ static bool link_shaders(struct fx_renderer *renderer) {
 		goto error;
 	}
 
+	// resize-handle frame shader
+	if (!link_frame_program(&renderer->shaders.frame)) {
+		wlr_log(WLR_ERROR, "Could not link frame shader");
+		goto error;
+	}
+
 	// droplet backdrop-refraction shader
 	if (!link_droplet_program(&renderer->shaders.droplet)) {
 		wlr_log(WLR_ERROR, "Could not link droplet shader");
diff --git a/scenefx/render/fx_renderer/shaders.c b/scenefx/render/fx_renderer/shaders.c
index 6af59fc..8fdd097 100644
--- a/scenefx/render/fx_renderer/shaders.c
+++ b/scenefx/render/fx_renderer/shaders.c
@@ -19,6 +19,7 @@
 #include "tex_frag_src.h"
 #include "box_shadow_frag_src.h"
 #include "bevel_frag_src.h"
+#include "frame_frag_src.h"
 #include "droplet_frag_src.h"
 #include "blur1_frag_src.h"
 #include "blur2_frag_src.h"
@@ -375,6 +376,35 @@ bool link_bevel_program(struct bevel_shader *shader) {
 	return true;
 }
 
+bool link_frame_program(struct frame_shader *shader) {
+	// Paired with the shared corner routine, like bevel: the ring traces the
+	// same superellipse every other corner cut does.
+	GLchar frame_src[16384];
+	snprintf(frame_src, sizeof(frame_src), "%s\n%s", frame_frag_src,
+		corner_alpha_frag_src);
+
+	GLuint prog;
+	shader->program = prog = link_program(frame_src);
+	if (!shader->program) {
+		return false;
+	}
+	shader->proj = glGetUniformLocation(prog, "proj");
+	shader->color = glGetUniformLocation(prog, "color");
+	shader->pos_attrib = glGetAttribLocation(prog, "pos");
+	shader->position = glGetUniformLocation(prog, "position");
+	shader->size = glGetUniformLocation(prog, "size");
+	shader->corner_radius = glGetUniformLocation(prog, "corner_radius");
+	shader->corner_shape = glGetUniformLocation(prog, "corner_shape");
+	shader->band = glGetUniformLocation(prog, "band");
+	shader->band_min = glGetUniformLocation(prog, "band_min");
+	shader->corner_len = glGetUniformLocation(prog, "corner_len");
+	shader->gap = glGetUniformLocation(prog, "gap");
+	shader->hovered = glGetUniformLocation(prog, "hovered");
+	shader->hover_color = glGetUniformLocation(prog, "hover_color");
+
+	return true;
+}
+
 bool link_droplet_program(struct droplet_shader *shader) {
 	GLuint prog;
 	shader->program = prog = link_program(droplet_frag_src);
diff --git a/scenefx/render/fx_renderer/shaders/frame.frag b/scenefx/render/fx_renderer/shaders/frame.frag
new file mode 100644
index 0000000..935b7ed
--- /dev/null
+++ b/scenefx/render/fx_renderer/shaders/frame.frag
@@ -0,0 +1,128 @@
+// Window resize-handle frame: a ring inside a rounded rect whose thickness
+// swells from the corners toward the middle of each side, like the moulding
+// of a picture frame.
+//
+// The ring is cut into eight zones — four corner pieces and four edge bars —
+// separated by `gap`. A corner piece keeps the thin `band_min` thickness for
+// `corner_len` along each of its two sides; an edge bar runs from there to
+// the side's midpoint, easing from `band_min` up to `band`.
+//
+// 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.
+
+#ifdef GL_FRAGMENT_PRECISION_HIGH
+precision highp float;
+#else
+precision mediump float;
+#endif
+
+varying vec4 v_color;
+varying vec2 v_texcoord;
+
+uniform vec2 position;
+uniform vec2 size;
+uniform float corner_radius;
+// Thickness at a side's midpoint, and at the corner pieces.
+uniform float band;
+uniform float band_min;
+// How far a corner piece runs along each of its sides, and the gap that
+// separates it from the neighbouring edge bar.
+uniform float corner_len;
+uniform float gap;
+// 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.
+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;
+
+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) {
+        discard;
+    }
+    float depth = -dist; // 0 at the silhouette, growing inward
+
+    // Rect-local position, in the SAME y-flipped space corner_dist works in
+    // (see corner_alpha.frag: it flips y after subtracting `position`).
+    // Without the flip the zone logic is upside down relative to the SDF.
+    vec2 p = gl_FragCoord.xy - position;
+    p.y = size.y - p.y;
+
+    // Which side owns this fragment: whichever edge it sits nearer. The
+    // comparison is on distance to the edge, so the split runs along the
+    // diagonals and every corner is shared consistently by its two sides.
+    float dl = p.x;
+    float dr = size.x - p.x;
+    float dt = p.y;
+    float db = size.y - p.y;
+    bool vertical = min(dl, dr) < min(dt, db);
+
+    // `along` is the coordinate down the owning side, `len` its full length.
+    float along = vertical ? p.y : p.x;
+    float len = vertical ? size.y : size.x;
+    // Distance from the nearer end of that side.
+    float u = min(along, len - along);
+
+    // Thickness profile and zone.
+    float thickness;
+    float zone;
+    if (u <= corner_len) {
+        // Corner piece: thin, and constant for its whole run.
+        thickness = band_min;
+        bool left = dl <= dr;
+        bool top = dt <= db;
+        zone = top ? (left ? ZONE_TL : ZONE_TR) : (left ? ZONE_BL : ZONE_BR);
+    } else if (u <= corner_len + gap) {
+        // The gap between a corner piece and its neighbouring bar.
+        discard;
+    } else {
+        // Edge bar: eased from band_min at the corner end to band at the
+        // side's midpoint. smoothstep rather than a straight ramp so the
+        // join at either end has no visible crease.
+        float run = max(0.5 * len - corner_len - gap, 1e-3);
+        float v = clamp((u - corner_len - gap) / run, 0.0, 1.0);
+        thickness = mix(band_min, band, smoothstep(0.0, 1.0, v));
+        if (vertical) {
+            zone = (dl <= dr) ? ZONE_LEFT : ZONE_RIGHT;
+        } else {
+            zone = (dt <= db) ? ZONE_TOP : ZONE_BOTTOM;
+        }
+    }
+
+    if (depth > thickness) {
+        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);
+
+    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/scenefx/render/fx_renderer/shaders/meson.build b/scenefx/render/fx_renderer/shaders/meson.build
index 28441b1..934c1b4 100644
--- a/scenefx/render/fx_renderer/shaders/meson.build
+++ b/scenefx/render/fx_renderer/shaders/meson.build
@@ -12,6 +12,7 @@ shaders = [
 	'tex.frag',
 	'box_shadow.frag',
 	'bevel.frag',
+	'frame.frag',
 	'droplet.frag',
 	'blur1.frag',
 	'blur2.frag',
diff --git a/scenefx/types/scene/wlr_scene.c b/scenefx/types/scene/wlr_scene.c
index 7832189..b22faa2 100644
--- a/scenefx/types/scene/wlr_scene.c
+++ b/scenefx/types/scene/wlr_scene.c
@@ -82,6 +82,12 @@ struct wlr_scene_shadow *wlr_scene_shadow_from_node(struct wlr_scene_node *node)
 	return shadow;
 }
 
+struct wlr_scene_frame *wlr_scene_frame_from_node(struct wlr_scene_node *node) {
+	assert(node->type == WLR_SCENE_NODE_FRAME);
+	struct wlr_scene_frame *frame = wl_container_of(node, frame, node);
+	return frame;
+}
+
 struct wlr_scene_bevel *wlr_scene_bevel_from_node(struct wlr_scene_node *node) {
 	assert(node->type == WLR_SCENE_NODE_BEVEL);
 	struct wlr_scene_bevel *bevel = wl_container_of(node, bevel, node);
@@ -293,6 +299,7 @@ static bool _scene_nodes_in_box(struct wlr_scene_node *node, struct wlr_box *box
 	case WLR_SCENE_NODE_BUFFER:
 	case WLR_SCENE_NODE_SHADOW:
 	case WLR_SCENE_NODE_BEVEL:
+	case WLR_SCENE_NODE_FRAME:
 	case WLR_SCENE_NODE_DROPLET:
 	case WLR_SCENE_NODE_OPTIMIZED_BLUR:
 	case WLR_SCENE_NODE_BLUR:;
@@ -406,6 +413,7 @@ static void scene_node_opaque_region(struct wlr_scene_node *node, int x, int y,
 		// TODO: test & handle case of blur sigma = 0 and color[3] = 1?
 		return;
 	} else if (node->type == WLR_SCENE_NODE_BEVEL
+			|| node->type == WLR_SCENE_NODE_FRAME
 			|| node->type == WLR_SCENE_NODE_DROPLET
 			|| node->type == WLR_SCENE_NODE_OPTIMIZED_BLUR
 			|| node->type == WLR_SCENE_NODE_BLUR) {
@@ -1127,6 +1135,81 @@ struct wlr_scene_shadow *wlr_scene_shadow_create(struct wlr_scene_tree *parent,
 	return scene_shadow;
 }
 
+struct wlr_scene_frame *wlr_scene_frame_create(struct wlr_scene_tree *parent,
+		int width, int height, int corner_radius, const float color[static 4]) {
+	struct wlr_scene_frame *scene_frame = calloc(1, sizeof(*scene_frame));
+	if (scene_frame == NULL) {
+		return NULL;
+	}
+	scene_node_init(&scene_frame->node, WLR_SCENE_NODE_FRAME, parent);
+
+	scene_frame->width = width;
+	scene_frame->height = height;
+	scene_frame->corner_radius = corner_radius;
+	// Shape defaults to a plain even ring; the compositor sets the real
+	// profile every frame through wlr_scene_frame_set_shape.
+	scene_frame->band = 0.0f;
+	scene_frame->band_min = 0.0f;
+	scene_frame->corner_len = 0.0f;
+	scene_frame->gap = 0.0f;
+	scene_frame->hovered = -1.0f;
+	memcpy(scene_frame->color, color, sizeof(scene_frame->color));
+	memcpy(scene_frame->hover_color, color, sizeof(scene_frame->hover_color));
+
+	scene_node_update(&scene_frame->node, NULL);
+
+	return scene_frame;
+}
+
+void wlr_scene_frame_set_size(struct wlr_scene_frame *frame, int width, int height) {
+	if (frame->width == width && frame->height == height) {
+		return;
+	}
+	frame->width = width;
+	frame->height = height;
+	scene_node_update(&frame->node, NULL);
+}
+
+void wlr_scene_frame_set_corner_radius(struct wlr_scene_frame *frame, int radius) {
+	if (frame->corner_radius == radius) {
+		return;
+	}
+	frame->corner_radius = radius;
+	scene_node_update(&frame->node, NULL);
+}
+
+void wlr_scene_frame_set_shape(struct wlr_scene_frame *frame, float band,
+		float band_min, float corner_len, float gap) {
+	if (frame->band == band && frame->band_min == band_min
+			&& frame->corner_len == corner_len && frame->gap == gap) {
+		return;
+	}
+	frame->band = band;
+	frame->band_min = band_min;
+	frame->corner_len = corner_len;
+	frame->gap = gap;
+	scene_node_update(&frame->node, NULL);
+}
+
+void wlr_scene_frame_set_color(struct wlr_scene_frame *frame, const float color[static 4]) {
+	if (memcmp(frame->color, color, sizeof(frame->color)) == 0) {
+		return;
+	}
+	memcpy(frame->color, color, sizeof(frame->color));
+	scene_node_update(&frame->node, NULL);
+}
+
+void wlr_scene_frame_set_hover(struct wlr_scene_frame *frame, float hovered,
+		const float color[static 4]) {
+	if (frame->hovered == hovered
+			&& memcmp(frame->hover_color, color, sizeof(frame->hover_color)) == 0) {
+		return;
+	}
+	frame->hovered = hovered;
+	memcpy(frame->hover_color, color, sizeof(frame->hover_color));
+	scene_node_update(&frame->node, NULL);
+}
+
 struct wlr_scene_bevel *wlr_scene_bevel_create(struct wlr_scene_tree *parent,
 		int width, int height, int corner_radius, float thickness,
 		const float color[static 4]) {
@@ -2041,6 +2124,11 @@ void scene_node_get_size(struct wlr_scene_node *node, int *width, int *height) {
 		*width = scene_bevel->width;
 		*height = scene_bevel->height;
 		break;
+	case WLR_SCENE_NODE_FRAME:;
+		struct wlr_scene_frame *scene_frame_sz = wlr_scene_frame_from_node(node);
+		*width = scene_frame_sz->width;
+		*height = scene_frame_sz->height;
+		break;
 	case WLR_SCENE_NODE_DROPLET:;
 		struct wlr_scene_droplet *scene_droplet = wlr_scene_droplet_from_node(node);
 		*width = scene_droplet->width;
@@ -2241,6 +2329,7 @@ static bool scene_node_at_iterator(struct wlr_scene_node *node,
 		}
 	} else if (node->type == WLR_SCENE_NODE_SHADOW
 			|| node->type == WLR_SCENE_NODE_BEVEL
+			|| node->type == WLR_SCENE_NODE_FRAME
 			|| node->type == WLR_SCENE_NODE_DROPLET
 			|| node->type == WLR_SCENE_NODE_OPTIMIZED_BLUR
 			|| node->type == WLR_SCENE_NODE_BLUR) {
@@ -2506,6 +2595,33 @@ static void scene_entry_render(struct render_list_entry *entry, const struct ren
 		};
 		fx_render_pass_add_box_shadow(fx_pass, &shadow_options);
 		break;
+	case WLR_SCENE_NODE_FRAME:;
+		struct wlr_scene_frame *scene_frame = wlr_scene_frame_from_node(node);
+
+		struct fx_render_frame_options frame_options = {
+			.box = dst_box,
+			.corner_radius = scene_frame->corner_radius,
+			.band = scene_frame->band,
+			.band_min = scene_frame->band_min,
+			.corner_len = scene_frame->corner_len,
+			.gap = scene_frame->gap,
+			.hovered = scene_frame->hovered,
+			.hover_color = {
+				scene_frame->hover_color[0],
+				scene_frame->hover_color[1],
+				scene_frame->hover_color[2],
+				scene_frame->hover_color[3],
+			},
+			.color = {
+				.r = scene_frame->color[0],
+				.g = scene_frame->color[1],
+				.b = scene_frame->color[2],
+				.a = scene_frame->color[3],
+			},
+			.clip = &render_region,
+		};
+		fx_render_pass_add_frame(fx_pass, &frame_options);
+		break;
 	case WLR_SCENE_NODE_BEVEL:;
 		struct wlr_scene_bevel *scene_bevel = wlr_scene_bevel_from_node(node);
 
@@ -3032,6 +3148,10 @@ static bool scene_node_invisible(struct wlr_scene_node *node) {
 		struct wlr_scene_bevel *bevel = wlr_scene_bevel_from_node(node);
 
 		return bevel->color[3] == 0.f || bevel->thickness <= 0.f;
+	} else if (node->type == WLR_SCENE_NODE_FRAME) {
+		struct wlr_scene_frame *frame = wlr_scene_frame_from_node(node);
+
+		return frame->color[3] == 0.f || frame->band <= 0.f;
 	}
 
 	return false;
diff --git a/src/server/config.rs b/src/server/config.rs
index f5c76c7..3998930 100644
--- a/src/server/config.rs
+++ b/src/server/config.rs
@@ -29,6 +29,10 @@ pub struct Layout {
     pub border_corner_radius: i32,
     /// Visual gap between the 8 border zone segments.
     pub border_segment_gap: i32,
+    /// How thin the resize-handle ring gets at the corners, as a fraction of
+    /// its thickness at the middle of a side. 1.0 is an even ring; smaller
+    /// values swell the middle of each side, like a picture-frame moulding.
+    pub border_taper: f32,
     /// Corner zone length measured from the outer corner along each band;
     /// 0 = auto (max(2 * width, 16)).
     pub border_corner_length: i32,
@@ -184,6 +188,7 @@ impl Default for Layout {
             border_color_hover: lighten_premultiplied([62.0 / 255.0, 62.0 / 255.0, 62.0 / 255.0, 1.0], HOVER_LIGHTEN),
             border_corner_radius: 0,
             border_segment_gap: 4,
+            border_taper: 0.35,
             border_corner_length: 0,
             background_r: 0x1C1C1C1Cu32,
             background_g: 0x20202020u32,
@@ -477,6 +482,8 @@ pub struct SurfaceConfig {
     pub border_corner_radius: i64,
     #[serde(default = "default_border_segment_gap")]
     pub border_segment_gap: i64,
+    #[serde(default = "default_border_taper")]
+    pub border_taper: f64,
     /// 0 = auto (max(2 * width, 16)).
     #[serde(default)]
     pub border_corner_length: i64,
@@ -572,6 +579,7 @@ impl Default for SurfaceConfig {
             border_color_hover: None,
             border_corner_radius: default_border_corner_radius(),
             border_segment_gap: default_border_segment_gap(),
+            border_taper: default_border_taper(),
             border_corner_length: 0,
             cloud_position_default: default_cloud_position_default(),
             shadow_enabled: default_shadow_enabled(),
@@ -673,6 +681,8 @@ fn default_border_corner_radius() -> i64 {
     0
 }
 
+fn default_border_taper() -> f64 { 0.35 }
+
 fn default_border_segment_gap() -> i64 {
     4
 }
@@ -2004,6 +2014,13 @@ fn parse_kdl_config(content: &str) -> Result<Config, String> {
                                             surface.border_segment_gap = val;
                                         }
                                     }
+                                    "taper" => {
+                                        if let Some(val) = entry.value().as_f64() {
+                                            surface.border_taper = val;
+                                        } else if let Some(val) = entry.value().as_i64() {
+                                            surface.border_taper = val as f64;
+                                        }
+                                    }
                                     "corner_length" => {
                                         if let Some(val) = entry.value().as_i64() {
                                             surface.border_corner_length = val;
@@ -2299,6 +2316,9 @@ pub fn parse_config(path: &str, state: &mut crate::window_manager::WindowManager
         .unwrap_or_else(|| lighten_premultiplied(state.layout.border_color_focused, HOVER_LIGHTEN));
     state.layout.border_corner_radius = config.surface.border_corner_radius as i32;
     state.layout.border_segment_gap = config.surface.border_segment_gap.max(0) as i32;
+    // Clamped at 1: past that the corners would be THICKER than the middle,
+    // which is the moulding inside out.
+    state.layout.border_taper = config.surface.border_taper.clamp(0.05, 1.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 9f4ee08..4af2652 100644
--- a/src/server/window.rs
+++ b/src/server/window.rs
@@ -235,8 +235,11 @@ pub struct BorderRects {
     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.
+    /// 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.
+    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
     /// segments keep their window-local coordinates.
@@ -624,6 +627,13 @@ impl Window {
         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.
+        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);
 
@@ -649,6 +659,7 @@ impl Window {
                 top: border_top,
                 bottom: border_bottom,
                 segments: border_segments,
+                frame: border_frame,
                 tree: border_tree,
             },
             hovered_border_element: None,
@@ -3725,6 +3736,10 @@ impl Window {
                 for &seg in self.border.segments.iter() {
                     ffi::wlr_scene_node_set_enabled(seg as *mut ffi::wlr_scene_node, false);
                 }
+                ffi::wlr_scene_node_set_enabled(
+                    &mut (*self.border.frame).node as *mut ffi::wlr_scene_node,
+                    false,
+                );
                 return;
             }
 
@@ -3744,102 +3759,58 @@ impl Window {
             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 backplate radius the
-            // corner clip uses) rather than that plus a band, as the outside
-            // border's was.
+            // corner clip uses) rather than that plus a band.
             let r_in = bg_radius;
             let cl = border_corner_len(bw as f64, layout.border_corner_length, r_in as f64) as i32;
             let g = layout.border_segment_gap;
 
-            // Borders rest invisible; a zone is drawn only as far as its
-            // reveal factor has faded in. Channels are premultiplied alpha, so
-            // scaling all four by the factor is the correct fade.
-            let color_for = |elem: BorderElement| -> [f32; 4] {
-                let base = if self.hovered_border_element == Some(elem) {
-                    border.hover_color
-                } else {
-                    border_color
-                };
-                let a = self.border_reveal[elem.index()].clamp(0.0, 1.0);
-                [base[0] * a, base[1] * a, base[2] * a, base[3] * a]
-            };
-            let e = &border.edges;
-            use BorderElement::*;
-
-            // Edge bars, spanning between the corner zones and inset by the
-            // segment gap.
-            let (bar_x, bar_w) = (cl + g, cw - 2 * cl - 2 * g);
-            let (bar_y, bar_h) = (cl + g, ch - 2 * cl - 2 * g);
-            #[rustfmt::skip]
-            let bars: [(usize, ffi::wlr_box, BorderElement, bool); 4] = [
-                (SEG_TOP, ffi::wlr_box { x: bar_x, y: 0, width: bar_w, height: bw }, Top, e.top),
-                (SEG_BOTTOM, ffi::wlr_box { x: bar_x, y: ch - bw, width: bar_w, height: bw }, Bottom, e.bottom),
-                (SEG_LEFT, ffi::wlr_box { x: 0, y: bar_y, width: bw, height: bar_h }, Left, e.left),
-                (SEG_RIGHT, ffi::wlr_box { x: cw - bw, y: bar_y, width: bw, height: bar_h }, Right, e.right),
-            ];
-            for (idx, bx, elem, enabled) in bars {
-                // A fully-faded-out zone is disabled outright rather than
-                // drawn transparent, so it costs nothing while at rest.
-                let enabled = enabled && self.border_reveal[elem.index()] > 0.0;
-                apply(self.border.segments[idx], bx, &color_for(elem), enabled);
-            }
-
-            // Corner zones: a cl x cl square per corner with the INNER
-            // quadrant clipped away, leaving an L whose outer arc follows the
-            // window's rounded corner and whose inner arc is that radius less
-            // the band.
-            let rr = |v: f64| -> u16 { (v.max(0.0) as i32).clamp(0, u16::MAX as i32) as u16 };
-            let s = self.scale;
-            let ro = rr(r_in as f64 * s);
-            let ri = rr((r_in - bw).max(0) as f64 * s);
-            let no_radii = ffi::fx_corner_radii { top_left: 0, top_right: 0, bottom_right: 0, bottom_left: 0 };
-
-            // (slot, retired slot, element, enabled, box, corner: 0=TL 1=TR 2=BR 3=BL)
-            #[rustfmt::skip]
-            let corners: [(usize, usize, BorderElement, bool, ffi::wlr_box, usize); 4] = [
-                (SEG_TL_H, SEG_TL_V, TopLeft, e.top && e.left,
-                 ffi::wlr_box { x: 0, y: 0, width: cl, height: cl }, 0),
-                (SEG_TR_H, SEG_TR_V, TopRight, e.top && e.right,
-                 ffi::wlr_box { x: cw - cl, y: 0, width: cl, height: cl }, 1),
-                (SEG_BR_H, SEG_BR_V, BottomRight, e.bottom && e.right,
-                 ffi::wlr_box { x: cw - cl, y: ch - cl, width: cl, height: cl }, 2),
-                (SEG_BL_H, SEG_BL_V, BottomLeft, e.bottom && e.left,
-                 ffi::wlr_box { x: 0, y: ch - cl, width: cl, height: cl }, 3),
-            ];
-            for (slot, retired, elem, enabled, bx, which) in corners {
-                ffi::wlr_scene_node_set_enabled(self.border.segments[retired] as *mut ffi::wlr_scene_node, false);
-                let rect = self.border.segments[slot];
-                let enabled = enabled && self.border_reveal[elem.index()] > 0.0;
-                apply(rect, bx, &color_for(elem), enabled);
-                if !enabled || bx.width <= 0 || bx.height <= 0 {
-                    continue;
-                }
-                // The inner quadrant to cut, rect-local physical px: the
-                // band's thickness in from whichever two sides are outer.
-                let (qx, qy) = match which {
-                    0 => (bw, bw),
-                    1 => (0, bw),
-                    2 => (0, 0),
-                    _ => (bw, 0),
-                };
-                let side = ((cl - bw).max(0) as f64 * s) as i32;
-                let mut outer = no_radii;
-                let mut inner = no_radii;
-                match which {
-                    0 => { outer.top_left = ro; inner.top_left = ri; }
-                    1 => { outer.top_right = ro; inner.top_right = ri; }
-                    2 => { outer.bottom_right = ro; inner.bottom_right = ri; }
-                    _ => { outer.bottom_left = ro; inner.bottom_left = ri; }
-                }
-                ffi::wlr_scene_rect_set_corner_radii(rect, outer);
-                ffi::wlr_scene_rect_set_clipped_region(rect, ffi::clipped_region {
-                    area: ffi::wlr_box {
-                        x: (qx as f64 * s) as i32,
-                        y: (qy as f64 * s) as i32,
-                        width: side,
-                        height: side,
-                    },
-                    corners: inner,
-                });
+            // Handles rest invisible and fade in with the mode; one alpha for
+            // the whole ring now that every zone reveals together in overview
+            // (`step_border_fade`'s all_on branch). The Top slot carries it —
+            // they are all equal while the ring is up, and taking one keeps
+            // the fade a single number.
+            let a = self.border_reveal[BorderElement::Top.index()].clamp(0.0, 1.0);
+            let premul = |c: &[f32; 4]| [c[0] * a, c[1] * a, c[2] * a, c[3] * a];
+
+            // Device px throughout, the same space `apply` put the rects in:
+            // box_geom is the UNSCALED content size and the window covers
+            // `size * scale` on screen.
+            let sc = self.scale;
+            let px = |v: i32| (v as f64 * sc) as i32;
+            ffi::wlr_scene_frame_set_size(self.border.frame, px(cw), px(ch));
+            ffi::wlr_scene_frame_set_corner_radius(self.border.frame, px(r_in));
+            // band_min is the thin corner thickness the swell rises from;
+            // half the band keeps the corner pieces clearly lighter than the
+            // middle of a side without letting them vanish at small sizes.
+            ffi::wlr_scene_frame_set_shape(
+                self.border.frame,
+                px(bw) as f32,
+                (px(bw) as f32 * layout.border_taper.clamp(0.0, 1.0)).max(1.0),
+                px(cl) as f32,
+                px(g) as f32,
+            );
+            ffi::wlr_scene_frame_set_color(self.border.frame, premul(&border_color).as_ptr());
+            let hovered = self
+                .hovered_border_element
+                .map(|e| e.index() as f32)
+                .unwrap_or(-1.0);
+            ffi::wlr_scene_frame_set_hover(
+                self.border.frame,
+                hovered,
+                premul(&border.hover_color).as_ptr(),
+            );
+            ffi::river_scene_node_set_position_if_changed(
+                &mut (*self.border.frame).node as *mut ffi::wlr_scene_node,
+                0,
+                0,
+            );
+            ffi::wlr_scene_node_set_enabled(
+                &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);
             }
         }
     }