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

commit27082cee6c26411e53d28f12aef988e578fe91db
parent250951587e
authorLucas Galante <lsgalante12@gmail.com>
date2026-10-07 22:23
feat(handles): inline handle discs, and window buttons in the top row

The side discs now take the corner discs' inset, so the three discs along
each edge are inline. Left of the top-right disc, a Floating or Tiled
window gets three button discs: minimize, maximize (the fullscreen
toggle) and a float/tile toggle whose glyph shows the mode a click goes
to. A button is a click, not a grab: the press is recorded and the
release acts only if the pointer is still on the same button. The
buttons drop out when the top row cannot fit all six discs, and the Top
disc leaves the midpoint only when it would crowd them.

The scenefx frame node gains a `buttons` value (0 none, 1 Floating,
2 Tiled) for the shader, which repeats `handle_disc_layout`'s rule.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

 CLAUDE.md                                     |  26 +++-
 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 |  92 ++++++++++--
 scenefx/types/scene/wlr_scene.c               |  10 ++
 src/server/cursor.rs                          |  67 +++++++--
 src/server/window.rs                          | 207 +++++++++++++++++++++-----
 src/server/window_manager.rs                  |  39 +++++
 11 files changed, 382 insertions(+), 68 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index 02804c77..340bc4e0 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -472,9 +472,11 @@ sat outside the edges and the ring that followed hugged them.
 - The handles are drawn by **one scenefx node**, `wlr_scene_frame`
   (`scenefx/render/fx_renderer/shaders/frame.frag`): eight discs of
   diameter `band` (= `border.handle_width`, screen px), each its own zone.
-  The side discs are tangent to their side; a corner disc sits on the
-  corner's 45° diagonal, tangent to the rounded corner arc when that arc is
-  wider than the disc and tucked into the two straight edges otherwise.
+  A corner disc sits on the corner's 45° diagonal, tangent to the rounded
+  corner arc when that arc is wider than the disc and tucked into the two
+  straight edges otherwise, and **the side discs take the same inset**, so
+  the three discs along an edge are inline (since 2026-10-07; before, the
+  side discs hugged their side and sat outboard of a rounded corner's).
   **`window::handle_disc_layout` is the one layout function**: `draw_borders`
   places the eight invisible square catchers (`border.segments`) from it,
   `cursor::get_border_zone` hit-tests the discs from it (a pixel of slack
@@ -529,6 +531,24 @@ sat outside the edges and the ring that followed hugged them.
   still parsed and passed to the node, no longer drawn.
 - The shader's zone numbering MUST match `BorderElement::index()`; it is what
   the hovered-zone uniform selects on.
+- **Three window buttons sit in the top row** (since 2026-10-07), left of
+  the top-right disc: minimize, maximize, float/tile toggle — zones 8-10,
+  `BorderElement::{Minimize, Maximize, ToggleTile}`, inline with the
+  handles and `HANDLE_BUTTON_STEP` diameters apart. Only a Floating or
+  Tiled window takes them (`window::window_takes_buttons`, asked by the hit
+  test and `draw_borders`, which hands the shader `buttons` = 0/1/2 —
+  none/Floating/Tiled, the last two picking the toggle's glyph). They drop
+  out when the top row cannot fit all six discs, and the Top disc leaves
+  the midpoint only when it would crowd them; `handle_disc_layout` returns
+  how many discs are live, and the shader repeats that rule. A button is a
+  click, not a grab: `get_border_zone` returns `BorderZone::Button`, the
+  adjust-mode press records it in `Cursor::button_press` and returns, and
+  the release acts (`WindowManager::press_window_button`) only if the
+  pointer is still on that button. Minimize and maximize focus the window
+  and run the `minimize` / `toggle_fullscreen` actions — maximize is
+  fullscreen, since Tiled already reports xdg maximized — and the toggle
+  is `set-mode floating|tiled`. A fullscreen window takes no handles, so it
+  leaves fullscreen by the key, not a button.
 - `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
diff --git a/scenefx/include/render/fx_renderer/shaders.h b/scenefx/include/render/fx_renderer/shaders.h
index c5fad7f0..93f41822 100644
--- a/scenefx/include/render/fx_renderer/shaders.h
+++ b/scenefx/include/render/fx_renderer/shaders.h
@@ -205,6 +205,7 @@ struct frame_shader {
 	GLint swell_curve;
 	GLint bulge;
 	GLint exclusion;
+	GLint buttons;
 };
 
 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 0eedc629..4d1f68a4 100644
--- a/scenefx/include/scenefx/render/pass.h
+++ b/scenefx/include/scenefx/render/pass.h
@@ -145,6 +145,8 @@ struct fx_render_frame_options {
 	/* Box-local rect the ring must not draw over; w/h <= 0 disables. */
 	float exclusion[4];
 	struct wlr_render_color color;
+	/* Window buttons: 0 none, 1 Floating, 2 Tiled. */
+	float buttons;
 };
 
 struct fx_render_droplet_options {
diff --git a/scenefx/include/scenefx/types/wlr_scene.h b/scenefx/include/scenefx/types/wlr_scene.h
index 1ff9b989..e053ae92 100644
--- a/scenefx/include/scenefx/types/wlr_scene.h
+++ b/scenefx/include/scenefx/types/wlr_scene.h
@@ -248,6 +248,9 @@ struct wlr_scene_frame {
 	/** Node-local rect (x, y, w, h) the ring must not draw over — an
 	 * in-surface client popover. w or h <= 0 disables. */
 	float exclusion[4];
+	/** Window buttons beside the top-right disc: 0 none, 1 for a
+	 * Floating window, 2 for a Tiled one (the toggle's glyph). */
+	float buttons;
 	float color[4];
 };
 
@@ -795,6 +798,7 @@ void wlr_scene_frame_set_hover(struct wlr_scene_frame *frame, float hovered,
 	const float color[static 4]);
 void wlr_scene_frame_set_exclusion(struct wlr_scene_frame *frame,
 	const float rect[static 4]);
+void wlr_scene_frame_set_buttons(struct wlr_scene_frame *frame, float buttons);
 
 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);
diff --git a/scenefx/render/fx_renderer/fx_pass.c b/scenefx/render/fx_renderer/fx_pass.c
index 1488044d..c698496a 100644
--- a/scenefx/render/fx_renderer/fx_pass.c
+++ b/scenefx/render/fx_renderer/fx_pass.c
@@ -1166,6 +1166,7 @@ void fx_render_pass_add_frame(struct fx_gles_render_pass *pass,
 	glUniform1f(renderer->shaders.frame.hovered, options->hovered);
 	glUniform1f(renderer->shaders.frame.swell_curve, options->swell_curve);
 	glUniform1f(renderer->shaders.frame.bulge, options->bulge);
+	glUniform1f(renderer->shaders.frame.buttons, options->buttons);
 	glUniform4f(renderer->shaders.frame.exclusion, options->exclusion[0],
 			options->exclusion[1], options->exclusion[2], options->exclusion[3]);
 	glUniform4f(renderer->shaders.frame.hover_color,
diff --git a/scenefx/render/fx_renderer/shaders.c b/scenefx/render/fx_renderer/shaders.c
index 43ddbc4f..5c076a03 100644
--- a/scenefx/render/fx_renderer/shaders.c
+++ b/scenefx/render/fx_renderer/shaders.c
@@ -407,6 +407,7 @@ bool link_frame_program(struct frame_shader *shader) {
 	shader->swell_curve = glGetUniformLocation(prog, "swell_curve");
 	shader->bulge = glGetUniformLocation(prog, "bulge");
 	shader->exclusion = glGetUniformLocation(prog, "exclusion");
+	shader->buttons = glGetUniformLocation(prog, "buttons");
 
 	return true;
 }
diff --git a/scenefx/render/fx_renderer/shaders/frame.frag b/scenefx/render/fx_renderer/shaders/frame.frag
index 5b49381a..7457ee15 100644
--- a/scenefx/render/fx_renderer/shaders/frame.frag
+++ b/scenefx/render/fx_renderer/shaders/frame.frag
@@ -1,14 +1,17 @@
 // Window resize handles: EIGHT DISCS inside the window's rounded
-// silhouette — one at the midpoint of each side and one on each corner —
-// each disc a zone of its own. `band` is the disc diameter. The side
-// discs are tangent to their side; a corner disc sits on the corner's 45°
-// diagonal, tangent to the rounded corner arc when that arc is wider than
-// the disc and tucked into the two straight edges otherwise.
+// silhouette — one on each side and one on each corner — each disc a zone
+// of its own, plus (`buttons` > 0) three BUTTON discs in the top row, left
+// of the top-right corner disc: minimize, maximize, float/tile toggle.
+// `band` is the disc diameter. Every disc sits the same distance in from
+// the edges it touches, so the three discs along an edge are inline: a
+// corner disc sits on the corner's 45° diagonal, tangent to the rounded
+// corner arc when that arc is wider than the disc and tucked into the two
+// straight edges otherwise, and the side discs take that same inset.
 //
 // The compositor's `window::handle_disc_layout` lays out the same discs
-// from the same inputs (size, corner_radius, band) for the pointer hit
-// test and the catcher rects; the two MUST stay in step, or a handle is
-// drawn where it does not grab, or grabs where it is not drawn.
+// from the same inputs (size, corner_radius, band, buttons) for the
+// pointer hit test and the catcher rects; the two MUST stay in step, or a
+// handle is drawn where it does not grab, or grabs where it is not drawn.
 //
 // Still a shader rather than eight scene rects: a rounded scene rect takes
 // the renderer's global corner shape, a squircle, so a "rect with radius
@@ -42,6 +45,9 @@ uniform vec4 exclusion;
 // Zone under the pointer (see ZONE_* below), or < 0 for none.
 uniform float hovered;
 uniform vec4 hover_color;
+// Window buttons: 0 none, 1 for a Floating window, 2 for a Tiled one (the
+// toggle's glyph shows the mode a click goes to).
+uniform float buttons;
 
 // Defined in corner_alpha.frag, concatenated after this source — the same
 // shared routine bevel.frag uses, so the discs are clipped to the same
@@ -51,8 +57,45 @@ float corner_dist(vec2 size, vec2 position,
 
 // 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.
+// 7 BottomRight, 8 Minimize, 9 Maximize, 10 ToggleTile.
 const float SQRT2 = 1.41421356237;
+// Centre-to-centre spacing of the top row's discs, in diameters
+// (`window::HANDLE_BUTTON_STEP`).
+const float STEP = 1.25;
+
+float box_sdf(vec2 q, vec2 half_size) {
+    vec2 d = abs(q) - half_size;
+    return length(max(d, 0.0)) + min(max(d.x, d.y), 0.0);
+}
+
+// Coverage of a button's glyph at `q` (px from the disc centre, y down)
+// for a disc of radius `r`.
+float glyph(float zone, vec2 q, float r) {
+    float stroke = max(0.11 * r, 1.0);
+    float sd;
+    if (zone < 8.5) {
+        // Minimize: a bar across the lower middle.
+        sd = box_sdf(q - vec2(0.0, 0.18 * r), vec2(0.45 * r, 0.5 * stroke));
+    } else if (zone < 9.5) {
+        // Maximize: a square outline.
+        sd = abs(box_sdf(q, vec2(0.4 * r))) - 0.5 * stroke;
+    } else if (buttons < 1.5) {
+        // Floating -> tile: a 2x2 grid of cells.
+        vec2 cell = vec2(0.2 * r);
+        vec2 g = abs(q) - vec2(0.22 * r);
+        sd = box_sdf(g, cell * 0.85);
+    } else {
+        // Tiled -> float: two overlapping window outlines.
+        float a = abs(box_sdf(q - vec2(0.12 * r, -0.12 * r), vec2(0.28 * r))) - 0.5 * stroke;
+        float b = abs(box_sdf(q + vec2(0.12 * r, -0.12 * r), vec2(0.28 * r))) - 0.5 * stroke;
+        // The back outline is hidden under the front one.
+        if (box_sdf(q - vec2(0.12 * r, -0.12 * r), vec2(0.28 * r)) < 0.0) {
+            b = 1e9;
+        }
+        sd = min(a, b);
+    }
+    return clamp(0.5 - sd, 0.0, 1.0);
+}
 
 void main() {
     // Rect-local position, TOP-DOWN: gl_FragCoord minus the box position is
@@ -74,21 +117,33 @@ void main() {
     float t = corner_radius > r
         ? corner_radius - (corner_radius - r) / SQRT2
         : r;
-    vec2 c[8];
-    c[0] = vec2(0.5 * size.x, r);            // Top
-    c[1] = vec2(0.5 * size.x, size.y - r);   // Bottom
-    c[2] = vec2(r, 0.5 * size.y);            // Left
-    c[3] = vec2(size.x - r, 0.5 * size.y);   // Right
+    float s = STEP * band;
+    // The buttons only when the top row has room for all six discs; the
+    // Top disc then leaves the midpoint only if it would crowd them.
+    bool with_buttons = buttons > 0.5 && size.x >= 2.0 * t + 5.0 * s;
+    float top_x = with_buttons ? min(0.5 * size.x, size.x - t - 4.0 * s) : 0.5 * size.x;
+    vec2 c[11];
+    c[0] = vec2(top_x, t);                   // Top
+    c[1] = vec2(0.5 * size.x, size.y - t);   // Bottom
+    c[2] = vec2(t, 0.5 * size.y);            // Left
+    c[3] = vec2(size.x - t, 0.5 * size.y);   // Right
     c[4] = vec2(t, t);                       // TopLeft
     c[5] = vec2(size.x - t, t);              // TopRight
     c[6] = vec2(t, size.y - t);              // BottomLeft
     c[7] = vec2(size.x - t, size.y - t);     // BottomRight
+    c[8] = vec2(size.x - t - 3.0 * s, t);    // Minimize
+    c[9] = vec2(size.x - t - 2.0 * s, t);    // Maximize
+    c[10] = vec2(size.x - t - s, t);         // ToggleTile
+    int n = with_buttons ? 11 : 8;
 
     // The nearest disc owns the fragment: signed distance to its rim,
     // negative inside.
     float d = 1e9;
     float zone = -1.0;
-    for (int i = 0; i < 8; i++) {
+    for (int i = 0; i < 11; i++) {
+        if (i >= n) {
+            break;
+        }
         float di = length(p - c[i]) - r;
         if (di < d) {
             d = di;
@@ -105,6 +160,13 @@ void main() {
     }
 
     vec4 base = (hovered >= 0.0 && abs(hovered - zone) < 0.5) ? hover_color : v_color;
+    // A button's glyph is the disc darkened, so it reads on any disc color.
+    if (zone > 7.5) {
+        int zi = int(zone + 0.5);
+        vec2 cz = zi == 8 ? c[8] : (zi == 9 ? c[9] : c[10]);
+        float g = glyph(zone, p - cz, r);
+        base = vec4(base.rgb * (1.0 - 0.65 * g), base.a);
+    }
     // Premultiplied, like every other rect this renderer draws.
     gl_FragColor = base * aa;
 }
diff --git a/scenefx/types/scene/wlr_scene.c b/scenefx/types/scene/wlr_scene.c
index f75672f4..e8008c4a 100644
--- a/scenefx/types/scene/wlr_scene.c
+++ b/scenefx/types/scene/wlr_scene.c
@@ -1238,6 +1238,7 @@ struct wlr_scene_frame *wlr_scene_frame_create(struct wlr_scene_tree *parent,
 	scene_frame->hovered = -1.0f;
 	scene_frame->swell_curve = 1.0f;
 	scene_frame->bulge = 0.0f;
+	scene_frame->buttons = 0.0f;
 	memcpy(scene_frame->color, color, sizeof(scene_frame->color));
 	memcpy(scene_frame->hover_color, color, sizeof(scene_frame->hover_color));
 
@@ -1288,6 +1289,14 @@ void wlr_scene_frame_set_exclusion(struct wlr_scene_frame *frame,
 	scene_node_update(&frame->node, NULL);
 }
 
+void wlr_scene_frame_set_buttons(struct wlr_scene_frame *frame, float buttons) {
+	if (frame->buttons == buttons) {
+		return;
+	}
+	frame->buttons = buttons;
+	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;
@@ -3050,6 +3059,7 @@ static void scene_entry_render(struct render_list_entry *entry, const struct ren
 			.hovered = scene_frame->hovered,
 			.swell_curve = scene_frame->swell_curve,
 			.bulge = scene_frame->bulge * data->scale,
+			.buttons = scene_frame->buttons,
 			.exclusion = {
 				scene_frame->exclusion[0] * data->scale,
 				scene_frame->exclusion[1] * data->scale,
diff --git a/src/server/cursor.rs b/src/server/cursor.rs
index e0d9b1bf..f54fe02d 100644
--- a/src/server/cursor.rs
+++ b/src/server/cursor.rs
@@ -161,7 +161,7 @@ pub struct Cursor {
     /// on hover transitions. May dangle after a close — validate against
     /// `wm.windows` before dereferencing.
     pub hovered_border_window: *mut crate::window::Window,
-    /// Which of that window's 8 border zones is highlighted.
+    /// Which of that window's handle discs is highlighted.
     pub hovered_border_element: Option<crate::window::BorderElement>,
     /// The toplevel under the pointer while adjust mode (overview, or Super
     /// held) is on: the window the ring lands on and whose handles are live,
@@ -172,6 +172,11 @@ pub struct Cursor {
     pub adjust_hover: *mut crate::window::Window,
     pub right_click_on_bg: bool,
     pub right_click_on_border: bool,
+    /// A left press on a window button (minimize / maximize / float-tile),
+    /// waiting for its release: a button acts on the release, and only when
+    /// the pointer is still on the same button of the same window. May
+    /// dangle after a close — validated against `wm.windows` at release.
+    pub button_press: Option<(*mut crate::window::Window, crate::window::BorderElement)>,
     /// The grid surface's node mapping — (node_x, node_y, scale) — FROZEN at
     /// the start of an implicit grab that landed on the grid, cleared when
     /// the grab's last button releases. Motion during the grab is mapped
@@ -253,6 +258,7 @@ impl Default for Cursor {
             adjust_hover: std::ptr::null_mut(),
             right_click_on_bg: false,
             right_click_on_border: false,
+            button_press: None,
             grab_grid: None,
         }
     }
@@ -944,6 +950,12 @@ impl Cursor {
                                 self.set_xcursor(b"grab\0".as_ptr() as *const _);
                                 return;
                             }
+                            BorderZone::Button(elem) => {
+                                self.set_border_hover(window, Some(elem));
+                                ffi::wlr_seat_pointer_notify_clear_focus((*self.seat).wlr_seat);
+                                self.set_xcursor(b"pointer\0".as_ptr() as *const _);
+                                return;
+                            }
                             BorderZone::None => {}
                         }
                     }
@@ -1721,6 +1733,14 @@ unsafe extern "C" fn handle_button(listener: *mut ffi::wl_listener, data: *mut s
             } else {
                 BorderZone::None
             };
+            // A window button is a click: nothing is grabbed, the client
+            // never sees the press, and the release decides
+            // (`Cursor::button_press`, answered in the release path).
+            if let BorderZone::Button(elem) = overview_border_zone {
+                cursor.button_press = Some((clicked_win, elem));
+                cursor.pressed.insert((*event).button, None);
+                return;
+            }
             // A press on a SELECTED desktop image grabs the whole selection
             // — windows and images — from the image's side: the grid never
             // sees the press, the compositor moves everything and tells the
@@ -2257,6 +2277,10 @@ unsafe extern "C" fn handle_button(listener: *mut ffi::wl_listener, data: *mut s
                         return;
                     }
                 }
+                // Answered in the adjust-mode block above, which a button
+                // zone always passes through (get_border_zone gives none
+                // outside adjust mode).
+                BorderZone::Button(_) => {}
                 BorderZone::None => {}
             }
         }
@@ -2637,6 +2661,16 @@ unsafe extern "C" fn handle_button(listener: *mut ffi::wl_listener, data: *mut s
                 return;
             }
 
+            if (*event).button == 0x110 {
+                if let Some((win, elem)) = cursor.button_press.take() {
+                    let alive = (*server).wm.windows.iter().any(|&w| w == win) && !(*win).closed;
+                    if alive && get_border_zone(win, lx, ly) == BorderZone::Button(elem) {
+                        (*server).wm.press_window_button(win, elem);
+                    }
+                    return;
+                }
+            }
+
             if (*event).button == 0x111 && (cursor.right_click_on_bg || cursor.right_click_on_border) {
                 cursor.right_click_on_bg = false;
                 cursor.right_click_on_border = false;
@@ -4393,6 +4427,8 @@ pub enum BorderZone {
     None,
     Move,
     Resize(crate::window::Edges),
+    /// A window button disc (`BorderElement::is_button`).
+    Button(crate::window::BorderElement),
 }
 
 pub use crate::window::HOVER_BAND_MIN;
@@ -4458,11 +4494,12 @@ pub unsafe fn grid_node_info(
     None
 }
 
-/// Which resize handle, if any, a layout point falls on.
+/// Which handle disc, if any, a layout point falls on.
 ///
-/// The handles are eight discs INSIDE the content rect — one at the
-/// midpoint of each side, one on each corner — and exist only in adjust
-/// mode (overview, or Super held). Two consequences worth stating, because
+/// The handles are eight discs INSIDE the content rect — one on each side,
+/// one on each corner — plus, for a Floating or Tiled window, the three
+/// window buttons in the top row (`BorderZone::Button`), and exist only in
+/// adjust mode (overview, or Super held). Two consequences worth stating, because
 /// both are deliberate:
 ///
 ///   - Outside adjust mode there are no handles at all, so a window cannot
@@ -4543,14 +4580,22 @@ pub unsafe fn get_border_zone(window: *mut crate::window::Window, lx: f64, ly: f
         geom.height,
     ) as f64;
     let r_in = (r_in * scale) as i32 as f64;
-    let (centres, r) = crate::window::handle_disc_layout(content_w, content_h, r_in, bw);
+    let (centres, r, live) = crate::window::handle_disc_layout(
+        content_w,
+        content_h,
+        r_in,
+        bw,
+        crate::window::window_takes_buttons(window),
+    );
     let reach = (r + 1.0) * (r + 1.0);
-    for (i, &(cx, cy)) in centres.iter().enumerate() {
+    for (i, &(cx, cy)) in centres[..live].iter().enumerate() {
         let (dx, dy) = (rx - cx, ry - cy);
         if dx * dx + dy * dy <= reach {
-            return BorderZone::Resize(edges_for_border_element(
-                crate::window::BorderElement::ALL[i],
-            ));
+            let elem = crate::window::BorderElement::ALL[i];
+            if elem.is_button() {
+                return BorderZone::Button(elem);
+            }
+            return BorderZone::Resize(edges_for_border_element(elem));
         }
     }
     // In the body, between the discs: not ours. This is what leaves the
@@ -4571,6 +4616,8 @@ pub fn edges_for_border_element(element: crate::window::BorderElement) -> crate:
         TopRight => (true, false, false, true),
         BottomLeft => (false, true, true, false),
         BottomRight => (false, true, false, true),
+        // A button resizes nothing.
+        Minimize | Maximize | ToggleTile => (false, false, false, false),
     };
     crate::window::Edges { top, bottom, left, right }
 }
diff --git a/src/server/window.rs b/src/server/window.rs
index 9b046af1..31419725 100644
--- a/src/server/window.rs
+++ b/src/server/window.rs
@@ -149,8 +149,12 @@ pub fn widen_corner_radius(nominal: i32, width: i32, height: i32) -> i32 {
     widened.min(width.min(height) / 2)
 }
 
-/// One of the 8 interactive border zones. Each draws as its own visual
-/// element (corners as two-rect Ls) and highlights independently on hover.
+/// Number of handle discs: the eight resize zones and the three buttons.
+pub const HANDLE_COUNT: usize = 11;
+
+/// One of the interactive handle discs: the eight resize zones, then the
+/// three window buttons beside the top-right disc. Each is its own disc
+/// and highlights independently on hover.
 #[derive(Clone, Copy, Debug, PartialEq, Eq)]
 pub enum BorderElement {
     Top,
@@ -161,11 +165,15 @@ pub enum BorderElement {
     TopRight,
     BottomLeft,
     BottomRight,
+    /// The window buttons (`window_takes_buttons`): a click, not a grab.
+    Minimize,
+    Maximize,
+    ToggleTile,
 }
 
 impl BorderElement {
     /// Every zone, in `index()` order.
-    pub const ALL: [BorderElement; 8] = [
+    pub const ALL: [BorderElement; HANDLE_COUNT] = [
         BorderElement::Top,
         BorderElement::Bottom,
         BorderElement::Left,
@@ -174,8 +182,16 @@ impl BorderElement {
         BorderElement::TopRight,
         BorderElement::BottomLeft,
         BorderElement::BottomRight,
+        BorderElement::Minimize,
+        BorderElement::Maximize,
+        BorderElement::ToggleTile,
     ];
 
+    /// A window button rather than a resize handle.
+    pub fn is_button(self) -> bool {
+        matches!(self, BorderElement::Minimize | BorderElement::Maximize | BorderElement::ToggleTile)
+    }
+
     /// Index into `Window::border_reveal`. Declaration order; kept in one
     /// place so the reveal array and the enum can't drift apart.
     pub fn index(self) -> usize {
@@ -188,6 +204,9 @@ impl BorderElement {
             BorderElement::TopRight => 5,
             BorderElement::BottomLeft => 6,
             BorderElement::BottomRight => 7,
+            BorderElement::Minimize => 8,
+            BorderElement::Maximize => 9,
+            BorderElement::ToggleTile => 10,
         }
     }
 }
@@ -265,31 +284,56 @@ pub fn border_band_width(configured_width: u32) -> f64 {
     (configured_width as f64 * 2.0).max(HOVER_BAND_MIN)
 }
 
-/// Where the eight resize handles sit — one disc per zone, in
+/// Centre-to-centre spacing of the top row's discs when the window buttons
+/// are shown, in disc diameters. The frame shader's `STEP`.
+pub const HANDLE_BUTTON_STEP: f64 = 1.25;
+
+/// Where the handle discs sit — one disc per zone, in
 /// `BorderElement::index()` order — for a window whose content is `w`×`h`
 /// ON SCREEN, with silhouette corner radius `r_in` and handle diameter `d`
-/// (all screen px). Returns the centres and the disc radius. The side discs
-/// are tangent to their side; a corner disc sits on the corner's diagonal,
-/// tangent to the rounded corner arc when that arc is wider than the disc
-/// and tucked into the two straight edges otherwise. The frame shader
-/// (scenefx `frame.frag`) lays out the same discs from the same inputs;
-/// `draw_borders` (the catchers) and `cursor::get_border_zone` (the hit
-/// test) both call this, so what is drawn is what grabs. Keep the shader
-/// and this in step.
-pub fn handle_disc_layout(w: f64, h: f64, r_in: f64, d: f64) -> ([(f64, f64); 8], f64) {
+/// (all screen px). Returns the centres, the disc radius and how many of
+/// the discs are live: 8 (the resize handles), or all [`HANDLE_COUNT`]
+/// when `buttons` asks for the window buttons and the top row has room for
+/// all six of its discs.
+///
+/// Every disc sits the same distance in from the edges it touches, so the
+/// three along an edge are inline: a corner disc sits on the corner's
+/// diagonal, tangent to the rounded corner arc when that arc is wider than
+/// the disc and tucked into the two straight edges otherwise, and the side
+/// discs take that same inset. The buttons run leftward from the
+/// top-right disc — minimize, maximize, float/tile toggle, then the corner
+/// — and the Top disc leaves the midpoint only when it would crowd them.
+///
+/// The frame shader (scenefx `frame.frag`) lays out the same discs from the
+/// same inputs; `draw_borders` (the catchers) and `cursor::get_border_zone`
+/// (the hit test) both call this, so what is drawn is what grabs. Keep the
+/// shader and this in step.
+pub fn handle_disc_layout(
+    w: f64,
+    h: f64,
+    r_in: f64,
+    d: f64,
+    buttons: bool,
+) -> ([(f64, f64); HANDLE_COUNT], f64, usize) {
     let r = 0.5 * d;
     let t = if r_in > r { r_in - (r_in - r) / std::f64::consts::SQRT_2 } else { r };
+    let s = HANDLE_BUTTON_STEP * d;
+    let with_buttons = buttons && w >= 2.0 * t + 5.0 * s;
+    let top_x = if with_buttons { (0.5 * w).min(w - t - 4.0 * s) } else { 0.5 * w };
     let centres = [
-        (0.5 * w, r),     // Top
-        (0.5 * w, h - r), // Bottom
-        (r, 0.5 * h),     // Left
-        (w - r, 0.5 * h), // Right
-        (t, t),           // TopLeft
-        (w - t, t),       // TopRight
-        (t, h - t),       // BottomLeft
-        (w - t, h - t),   // BottomRight
+        (top_x, t),             // Top
+        (0.5 * w, h - t),       // Bottom
+        (t, 0.5 * h),           // Left
+        (w - t, 0.5 * h),       // Right
+        (t, t),                 // TopLeft
+        (w - t, t),             // TopRight
+        (t, h - t),             // BottomLeft
+        (w - t, h - t),         // BottomRight
+        (w - t - 3.0 * s, t),   // Minimize
+        (w - t - 2.0 * s, t),   // Maximize
+        (w - t - s, t),         // ToggleTile
     ];
-    (centres, r)
+    (centres, r, if with_buttons { HANDLE_COUNT } else { 8 })
 }
 
 pub struct BorderRects {
@@ -303,8 +347,8 @@ pub struct BorderRects {
     /// Invisible square catchers, one per handle disc, indexed by
     /// `BorderElement::index()`: they make a scene hit on a disc resolve to
     /// this window even where the client's input region does not cover it.
-    pub segments: [*mut ffi::wlr_scene_rect; 8],
-    /// The resize handles: all eight discs in one shader-drawn node.
+    pub segments: [*mut ffi::wlr_scene_rect; HANDLE_COUNT],
+    /// The handles: every disc, buttons included, 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
@@ -460,7 +504,7 @@ pub struct Window {
     /// zone under the pointer fades in. Deliberately NOT part of
     /// `rendering_requested.border`, which the arrange pass rewrites wholesale
     /// every pass and would otherwise clobber.
-    pub border_reveal: [f32; 8],
+    pub border_reveal: [f32; HANDLE_COUNT],
     /// How far this window is dimmed for lying OVER the adjust target, 0.0
     /// (full opacity) to 1.0 (`border.overlap_opacity`): a Floating window
     /// overlapping the window whose handles are up would hide them, so it
@@ -885,15 +929,15 @@ impl Window {
             ffi::wlr_scene_node_destroy(&mut (*capture_scene).tree as *mut ffi::wlr_scene_tree as *mut ffi::wlr_scene_node);
             return Err("Failed to create window border tree");
         }
-        let mut border_segments = [std::ptr::null_mut(); 8];
+        let mut border_segments = [std::ptr::null_mut(); HANDLE_COUNT];
         for seg in border_segments.iter_mut() {
             *seg = ffi::wlr_scene_rect_create(border_tree, 0, 0, clear_color.as_ptr());
         }
-        // The resize handles: one node draws all eight discs (scenefx
-        // frame.frag). Not eight rounded scene rects, because a scene rect
-        // takes the renderer's global corner shape — a squircle — so a rect
-        // with radius half its size would not be a circle. The eight rects
-        // above are the discs' invisible hit catchers.
+        // The handles: one node draws every disc, the window buttons
+        // included (scenefx frame.frag). Not rounded scene rects, because a
+        // scene rect takes the renderer's global corner shape — a squircle —
+        // so a rect with radius half its size would not be a circle. The
+        // rects above are the discs' invisible hit catchers.
         let border_frame = ffi::wlr_scene_frame_create(border_tree, 0, 0, 0, clear_color.as_ptr());
 
         let decorations_above_tree = ffi::wlr_scene_tree_create(tree);
@@ -926,7 +970,7 @@ impl Window {
             },
             hovered_border_element: None,
             border_hover_drawn: None,
-            border_reveal: [0.0; 8],
+            border_reveal: [0.0; HANDLE_COUNT],
             adjust_dim: 0.0,
             // 1.0, not 0.0: a window only starts its fade in `map()`, and
             // one that never fades (fading disabled, a status segment) must
@@ -3379,7 +3423,7 @@ impl Window {
         if !enabled {
             // The segment tree is not a child of `tree`, so disabling the
             // window does not hide a revealed border with it.
-            self.border_reveal = [0.0; 8];
+            self.border_reveal = [0.0; HANDLE_COUNT];
             ffi::wlr_scene_node_set_enabled(self.border.tree as *mut ffi::wlr_scene_node, false);
         }
 
@@ -3665,7 +3709,7 @@ impl Window {
             // lives outside this window's tree, so it has to be taken down
             // explicitly or a revealed edge would hang over the fullscreen
             // surface.
-            self.border_reveal = [0.0; 8];
+            self.border_reveal = [0.0; HANDLE_COUNT];
             ffi::wlr_scene_node_set_enabled(self.border.tree as *mut ffi::wlr_scene_node, false);
         } else {
             self.box_geom.x = requested.x;
@@ -3701,7 +3745,7 @@ impl Window {
             ffi::wlr_scene_node_set_enabled(self.border.top as *mut ffi::wlr_scene_node, false);
             ffi::wlr_scene_node_set_enabled(self.border.bottom as *mut ffi::wlr_scene_node, false);
             ffi::wlr_scene_node_set_enabled(self.window_background as *mut ffi::wlr_scene_node, false);
-            self.border_reveal = [0.0; 8];
+            self.border_reveal = [0.0; HANDLE_COUNT];
             ffi::wlr_scene_node_set_enabled(self.border.tree as *mut ffi::wlr_scene_node, false);
         }
 
@@ -3867,7 +3911,7 @@ impl Window {
         ffi::wlr_scene_node_set_enabled(self.tree as *mut ffi::wlr_scene_node, enabled);
         ffi::wlr_scene_node_set_enabled(self.popup_tree as *mut ffi::wlr_scene_node, enabled);
         if !enabled {
-            self.border_reveal = [0.0; 8];
+            self.border_reveal = [0.0; HANDLE_COUNT];
             ffi::wlr_scene_node_set_enabled(self.border.tree as *mut ffi::wlr_scene_node, false);
         }
 
@@ -4672,6 +4716,32 @@ pub unsafe fn window_takes_handles(window: *mut Window) -> bool {
         && !(*window).rendering_requested.hidden
 }
 
+/// Does this window get the minimize / maximize / float-tile buttons beside
+/// its top-right handle? Only a window with handles, and only a Floating or
+/// Tiled one: those are the modes the toggle flips between, and an Overlay
+/// dock has no business being minimized or tiled from its chrome. Asked by
+/// `draw_borders` and `cursor::get_border_zone` alike, like
+/// [`window_takes_handles`].
+pub unsafe fn window_takes_buttons(window: *mut Window) -> bool {
+    window_takes_handles(window)
+        && matches!(
+            (*window).tiling_mode,
+            crate::tiling::TilingMode::Floating | crate::tiling::TilingMode::Tiled
+        )
+}
+
+/// The frame shader's `buttons` value for `window`: 0 none, 1 Floating,
+/// 2 Tiled (the toggle's glyph shows the mode a click goes to).
+unsafe fn frame_buttons_value(window: *mut Window) -> f32 {
+    if !window_takes_buttons(window) {
+        0.0
+    } else if (*window).tiling_mode == crate::tiling::TilingMode::Tiled {
+        2.0
+    } else {
+        1.0
+    }
+}
+
 impl Window {
     pub unsafe fn draw_borders(&mut self) {
         // Taken before `requested` borrows self: `window_takes_handles` is
@@ -4867,13 +4937,19 @@ impl Window {
             // by the same function the hit test uses. `apply` takes unscaled
             // boxes, so the screen-px layout is divided back out (a px of
             // rounding on an invisible catcher is nothing).
-            let (centres, disc_r) = handle_disc_layout(
+            let buttons = frame_buttons_value(self_ptr);
+            let (centres, disc_r, live) = handle_disc_layout(
                 cw as f64 * sc,
                 ch as f64 * sc,
                 px(r_in) as f64,
                 band_screen,
+                buttons > 0.0,
             );
             for (i, &(cx, cy)) in centres.iter().enumerate() {
+                if i >= live {
+                    ffi::wlr_scene_node_set_enabled(self.border.segments[i] as *mut ffi::wlr_scene_node, false);
+                    continue;
+                }
                 let b = ffi::wlr_box {
                     x: ((cx - disc_r) / sc).floor() as i32,
                     y: ((cy - disc_r) / sc).floor() as i32,
@@ -4922,6 +4998,7 @@ impl Window {
                 None => [0.0; 4],
             };
             ffi::wlr_scene_frame_set_exclusion(self.border.frame, ex.as_ptr());
+            ffi::wlr_scene_frame_set_buttons(self.border.frame, buttons);
             // The ring exists only for the SEAT-focused window — `handles_on`
             // above says so, and so does step_border_fade's reveal — so it
             // paints in the focused color, taken from the layout rather than
@@ -6203,22 +6280,23 @@ mod handle_disc_tests {
 
     #[test]
     fn discs_follow_border_element_order_and_stay_inside() {
-        let (c, r) = handle_disc_layout(400.0, 300.0, 0.0, 32.0);
+        let (c, r, n) = handle_disc_layout(400.0, 300.0, 0.0, 32.0, false);
         assert_eq!(r, 16.0);
+        assert_eq!(n, 8);
         assert_eq!(c[BorderElement::Top.index()], (200.0, 16.0));
         assert_eq!(c[BorderElement::Bottom.index()], (200.0, 284.0));
         assert_eq!(c[BorderElement::Left.index()], (16.0, 150.0));
         assert_eq!(c[BorderElement::Right.index()], (384.0, 150.0));
         assert_eq!(c[BorderElement::TopLeft.index()], (16.0, 16.0));
         assert_eq!(c[BorderElement::BottomRight.index()], (384.0, 284.0));
-        for &(x, y) in &c {
+        for &(x, y) in &c[..n] {
             assert!(x - r >= 0.0 && x + r <= 400.0 && y - r >= 0.0 && y + r <= 300.0);
         }
     }
 
     #[test]
     fn corner_disc_is_tangent_to_a_wider_corner_arc() {
-        let (c, r) = handle_disc_layout(400.0, 300.0, 40.0, 32.0);
+        let (c, r, _) = handle_disc_layout(400.0, 300.0, 40.0, 32.0, false);
         let (tx, ty) = c[BorderElement::TopLeft.index()];
         assert_eq!(tx, ty);
         // Distance from the arc centre (40, 40) plus the disc radius is the
@@ -6226,6 +6304,55 @@ mod handle_disc_tests {
         let d = ((tx - 40.0).powi(2) + (ty - 40.0).powi(2)).sqrt();
         assert!((d + r - 40.0).abs() < 1e-9);
     }
+
+    #[test]
+    fn discs_sharing_an_edge_are_inline() {
+        use BorderElement::*;
+        // A corner arc wider than the disc pulls the corners in; the side
+        // discs must come in with them.
+        let (c, _, n) = handle_disc_layout(800.0, 600.0, 40.0, 32.0, true);
+        assert_eq!(n, HANDLE_COUNT);
+        let y = |e: BorderElement| c[e.index()].1;
+        let x = |e: BorderElement| c[e.index()].0;
+        for e in [Top, TopRight, Minimize, Maximize, ToggleTile] {
+            assert_eq!(y(e), y(TopLeft));
+        }
+        assert_eq!(y(Bottom), y(BottomLeft));
+        assert_eq!(y(BottomRight), y(BottomLeft));
+        assert_eq!(x(Left), x(TopLeft));
+        assert_eq!(x(BottomLeft), x(TopLeft));
+        assert_eq!(x(Right), x(TopRight));
+        assert_eq!(x(BottomRight), x(TopRight));
+    }
+
+    #[test]
+    fn buttons_run_left_from_the_top_right_disc_without_overlap() {
+        use BorderElement::*;
+        let (c, r, n) = handle_disc_layout(800.0, 600.0, 0.0, 32.0, true);
+        assert_eq!(n, HANDLE_COUNT);
+        let x = |e: BorderElement| c[e.index()].0;
+        let row = [TopLeft, Top, Minimize, Maximize, ToggleTile, TopRight];
+        for pair in row.windows(2) {
+            assert!(x(pair[1]) - x(pair[0]) >= 2.0 * r, "{:?} crowds {:?}", pair[0], pair[1]);
+        }
+        // A wide window keeps the Top disc on its midpoint.
+        assert_eq!(x(Top), 400.0);
+    }
+
+    #[test]
+    fn top_disc_steps_aside_and_buttons_drop_when_the_row_is_full() {
+        use BorderElement::*;
+        // 32 px discs, 40 px step: the six-disc row needs 2*16 + 5*40 = 232.
+        let (c, r, n) = handle_disc_layout(240.0, 300.0, 0.0, 32.0, true);
+        assert_eq!(n, HANDLE_COUNT);
+        let top = c[Top.index()].0;
+        assert!(top < 120.0);
+        assert!(c[Minimize.index()].0 - top >= 2.0 * r);
+        assert!(top - c[TopLeft.index()].0 >= 2.0 * r);
+        let (c, _, n) = handle_disc_layout(200.0, 300.0, 0.0, 32.0, true);
+        assert_eq!(n, 8);
+        assert_eq!(c[Top.index()].0, 100.0);
+    }
 }
 
 #[cfg(test)]
diff --git a/src/server/window_manager.rs b/src/server/window_manager.rs
index 04142ee8..8db0639c 100644
--- a/src/server/window_manager.rs
+++ b/src/server/window_manager.rs
@@ -5151,6 +5151,45 @@ impl WindowManager {
         !cmds.is_empty()
     }
 
+    /// A click on one of a window's buttons — the discs left of its
+    /// top-right handle (`window::window_takes_buttons`). Minimize and
+    /// maximize run the keyboard's own actions, which act on the focused
+    /// window, so the window is focused first: a click is a choice of
+    /// window. Maximize is the fullscreen toggle — a Tiled window already
+    /// reports xdg maximized here, so the step up from it is fullscreen —
+    /// and the toggle flips Floating and Tiled exactly as `set-mode` does.
+    pub unsafe fn press_window_button(&mut self, window: *mut Window, elem: crate::window::BorderElement) {
+        use crate::policy::api::{Command, Compositor, WindowId};
+        use crate::window::BorderElement;
+        if !crate::window::window_takes_buttons(window) {
+            return;
+        }
+        match elem {
+            BorderElement::Minimize | BorderElement::Maximize => {
+                if let Some(seat) = self.first_seat() {
+                    (*seat).focus(crate::seat::Focus::Window(window));
+                }
+                let action = if elem == BorderElement::Minimize {
+                    crate::config::Action::Minimize
+                } else {
+                    crate::config::Action::Fullscreen
+                };
+                self.execute_action(&action, None);
+            }
+            BorderElement::ToggleTile => {
+                let mode = if (*window).tiling_mode == crate::tiling::TilingMode::Tiled {
+                    crate::tiling::TilingMode::Floating
+                } else {
+                    crate::tiling::TilingMode::Tiled
+                };
+                let id = WindowId((*window).ref_key);
+                self.apply(&Command::SetWindowMode { id, mode, locked: true });
+                self.apply(&Command::Relayout);
+            }
+            _ => {}
+        }
+    }
+
     pub unsafe fn execute_action(&mut self, action: &crate::config::Action, command: Option<&str>) {
         use crate::config::Action;
         self.stop_panning_animation();