From ba5a0b9829d12dc4e7f73fdbe50d2f773767df12 Mon Sep 17 00:00:00 2001
From: stormshadow <190884359+st0rm-shad0w@users.noreply.github.com>
Date: Thu, 14 May 2026 06:23:49 +0530
Subject: [PATCH] feat: implement Openbox-style bottom window handle and grips
Add full handle/grip assembly to the bottom of SSD window frames,
following the Openbox themerc specification for geometry and theming.
Theme parsing:
- Parse window.handle.width (handle bar height, default 6)
- Parse window.grip.width (corner grip width, default 20)
- Parse window.[active|inactive].handle.bg with Solid/Gradient support
- Parse window.[active|inactive].grip.bg (inherits from handle if unset)
- Pre-render 1px-wide fill buffers and cairo patterns for handle/grip
Scene graph (new ssd-handle.c):
- Handle assembly replaces bottom border when active, with its own
left/right/top borders and three-segment bottom border
- Grips at left/right corners for diagonal resize (sw/se-resize)
- Center handle for vertical resize (s-resize)
- Vertical separator lines between grips and handle using border color
- Per Openbox spec, handle_width is content-only height with borders
drawn around it (total assembly height = 2*border_width + handle_width)
Interactive visual states (grips only):
- Hover: 20% black overlay on grip content area
- Pressed: 40% black overlay with 1px inset shadow (dark top/left,
light bottom/right) for a pushed-in 3D effect
- Dragging: 20% overlay with inset shadow maintained
- Global hover tracking (server.hovered_handle_ssd/element) ensures
proper cleanup when cursor moves across views or to desktop
Decoration toggle cycle (ToggleDecorations action):
- New LAB_SSD_MODE_BORDER_HANDLE between BORDER and FULL
- keepBorder=true: full -> border+handle -> border -> none -> full
- keepBorder=false: full -> none -> full (unchanged)
Node types and input:
- New LAB_NODE_HANDLE, LAB_NODE_GRIP_LEFT, LAB_NODE_GRIP_RIGHT
- Integrated into LAB_NODE_BORDER/BORDER_BOTTOM containment so
existing Border context mousebinds (Resize) work automatically
- Handle/grip descriptors resolved directly in get_cursor_context()
bypassing ssd_get_resizing_type() for precise cursor shapes
Visibility rules:
- Hidden when maximized, shaded, or handle_width is 0
- Hidden in LAB_SSD_MODE_BORDER and LAB_SSD_MODE_NONE states
- Bottom border in ssd-border.c disabled when handle is active
Documentation:
- labwc-theme.5.scd: document all handle/grip theme properties
- labwc-actions.5.scd: update ToggleDecorations to 4-state cycle
- docs/themerc: add handle/grip default values
---
docs/labwc-actions.5.scd | 14 +-
docs/labwc-theme.5.scd | 41 ++-
docs/themerc | 13 +
include/common/node-type.h | 4 +
include/config/types.h | 1 +
include/labwc.h | 4 +
include/ssd-internal.h | 65 ++++
include/ssd.h | 2 +
include/theme.h | 20 ++
include/view.h | 1 +
src/common/node-type.c | 28 +-
src/desktop.c | 13 +
src/input/cursor.c | 54 +++-
src/ssd/meson.build | 1 +
src/ssd/ssd-border.c | 21 ++
src/ssd/ssd-handle.c | 607 +++++++++++++++++++++++++++++++++++++
src/ssd/ssd.c | 87 +++++-
src/theme.c | 157 ++++++++++
src/view.c | 16 +
19 files changed, 1132 insertions(+), 17 deletions(-)
create mode 100644 src/ssd/ssd-handle.c
diff --git a/docs/labwc-actions.5.scd b/docs/labwc-actions.5.scd
index 47ee1801..97bb0bef 100644
--- a/docs/labwc-actions.5.scd
+++ b/docs/labwc-actions.5.scd
@@ -217,13 +217,14 @@ Actions are used in menus and keyboard/mouse bindings.
**
Toggle decorations of focused window.
- This is a 3-state action which can be executed multiple times:
- - Only the titlebar will be hidden, borders and resize area are kept
+ This is a 4-state action which can be executed multiple times:
+ - Titlebar is hidden; borders and handle/grip remain visible
+ - Handle/grip is also hidden; only borders remain
- Remaining decorations will be disabled
- Decorations will be shown normally
- By disabling the theme configuration 'keepBorder' the first step
- will be removed and the action only toggles between on and off.
+ By disabling the theme configuration 'keepBorder' the cycle reduces
+ to toggling between full decorations and no decorations.
**
Toggle fullscreen state of focused window.
@@ -526,9 +527,10 @@ Actions that execute other actions. Used in keyboard/mouse bindings.
Whether the client is tiled (snapped) to the indicated
region. The indicated region may be a glob.
- *decoration* [full|border|none]
+ *decoration* [full|border-handle|border|none]
Whether the client has full server-side decorations,
- borders only, or no server-side decorations.
+ borders with handle/grip, borders only, or no
+ server-side decorations.
*monitor* [current|left|right|]
Whether the client is on a monitor relative to the to
diff --git a/docs/labwc-theme.5.scd b/docs/labwc-theme.5.scd
index 5f99cae7..d89044f8 100644
--- a/docs/labwc-theme.5.scd
+++ b/docs/labwc-theme.5.scd
@@ -113,6 +113,33 @@ window.*.title.bg.colorTo.splitTo: #557485
Line width (integer) of border drawn around window frames.
Default is 1.
+*window.handle.width*
+ Height (integer) of the bottom handle bar in pixels. Despite the name,
+ this controls the vertical size of the handle (Openbox naming
+ convention). When set to 0, the handle/grip assembly is disabled and
+ the standard bottom border is used instead. Default is 6.
+
+*window.grip.width*
+ Width (integer) of each corner grip in the bottom handle bar, in
+ pixels. Default is 20.
+
+*window.active.handle.bg* / *window.inactive.handle.bg*
+ Texture for the handle background. Supports Solid, Gradient Vertical,
+ and Gradient SplitVertical. Default is *Solid* with color #a0a0a0
+ (active) / #c0c0c0 (inactive).
+
+*window.active.handle.bg.color* (and .colorTo, .color.splitTo, .colorTo.splitTo)
+ Colors for the handle background gradient stops. See the texture
+ documentation above for details.
+
+*window.active.grip.bg* / *window.inactive.grip.bg*
+ Texture for the left and right grip backgrounds. If not set, the grip
+ inherits from the handle background.
+
+*window.active.grip.bg.color* (and .colorTo, .color.splitTo, .colorTo.splitTo)
+ Colors for the grip background gradient stops. If not set, the grip
+ inherits from the handle background colors.
+
*window.titlebar.padding.width*
Horizontal titlebar padding size, in pixels, between border and first
button on the left/right.
@@ -561,7 +588,19 @@ following icons should be added:
# DEFINITIONS
-The handle is the window edge decoration at the bottom of the window.
+The handle is the window edge decoration at the bottom of the window. It
+consists of a center handle bar (for vertical resizing) and two grips at the
+left and right corners (for diagonal resizing). The handle/grip assembly
+replaces the standard bottom border when enabled (window.handle.width > 0)
+and the current decoration state includes the handle. Vertical separator
+lines of border.width thickness are drawn between the grips and the center
+handle using the window border color.
+
+When hovering over a grip element, it dims by 20%. When clicked (pressed
+but not yet moved), it dims by 40% with an inset shadow effect giving a
+"pushed in" appearance. During drag-resize, the dimming reduces to 20%
+while maintaining the inset shadow. The center handle does not receive
+these visual effects.
# SEE ALSO
diff --git a/docs/themerc b/docs/themerc
index 83ea726b..58bf0a88 100644
--- a/docs/themerc
+++ b/docs/themerc
@@ -58,6 +58,19 @@ window.inactive.shadow.size: 40
window.active.shadow.color: #00000060
window.inactive.shadow.color: #00000040
+# window bottom handle and grips
+window.handle.width: 6
+window.grip.width: 20
+window.active.handle.bg: Solid
+window.active.handle.bg.color: #a0a0a0
+window.inactive.handle.bg: Solid
+window.inactive.handle.bg.color: #c0c0c0
+# Grips inherit from handle if not set explicitly
+# window.active.grip.bg: Solid
+# window.active.grip.bg.color: #a0a0a0
+# window.inactive.grip.bg: Solid
+# window.inactive.grip.bg.color: #c0c0c0
+
# Note that "menu", "iconify", "max", "close" buttons colors can be defined
# individually by inserting the type after the button node, for example:
#
diff --git a/include/common/node-type.h b/include/common/node-type.h
index 52fff3b0..a2a2519a 100644
--- a/include/common/node-type.h
+++ b/include/common/node-type.h
@@ -43,6 +43,10 @@ enum lab_node_type {
LAB_NODE_BORDER_LEFT,
LAB_NODE_BORDER,
+ LAB_NODE_HANDLE,
+ LAB_NODE_GRIP_LEFT,
+ LAB_NODE_GRIP_RIGHT,
+
LAB_NODE_CLIENT,
LAB_NODE_FRAME,
LAB_NODE_ROOT,
diff --git a/include/config/types.h b/include/config/types.h
index 3b1fa5e8..827f78bc 100644
--- a/include/config/types.h
+++ b/include/config/types.h
@@ -38,6 +38,7 @@ enum lab_rotation {
enum lab_ssd_mode {
LAB_SSD_MODE_NONE = 0,
LAB_SSD_MODE_BORDER,
+ LAB_SSD_MODE_BORDER_HANDLE,
LAB_SSD_MODE_FULL,
LAB_SSD_MODE_INVALID,
};
diff --git a/include/labwc.h b/include/labwc.h
index a6d69317..92c64adc 100644
--- a/include/labwc.h
+++ b/include/labwc.h
@@ -229,6 +229,10 @@ struct server {
struct ssd_button *hovered_button;
+ /* Track which handle/grip element is hovered (for cross-view cleanup) */
+ struct ssd *hovered_handle_ssd;
+ int hovered_handle_element; /* -1 = none */
+
/* Tree for all non-layer xdg/xwayland-shell surfaces */
struct wlr_scene_tree *workspace_tree;
diff --git a/include/ssd-internal.h b/include/ssd-internal.h
index 600b9076..9a026273 100644
--- a/include/ssd-internal.h
+++ b/include/ssd-internal.h
@@ -12,6 +12,35 @@ struct ssd_state_title_width {
bool truncated;
};
+/* Interaction state for handle and grip elements */
+enum ssd_handle_state {
+ SSD_HANDLE_STATE_NORMAL = 0,
+ SSD_HANDLE_STATE_HOVER,
+ SSD_HANDLE_STATE_PRESSED, /* clicked but not yet moved */
+ SSD_HANDLE_STATE_DRAGGING, /* clicked and moved */
+};
+
+/* Indices into ssd_handle_scene.element_states[] */
+#define SSD_HANDLE_ELEMENT_GRIP_LEFT 0
+#define SSD_HANDLE_ELEMENT_CENTER 1
+#define SSD_HANDLE_ELEMENT_GRIP_RIGHT 2
+#define SSD_HANDLE_ELEMENT_COUNT 3
+
+/* Indices into ssd_handle_subtree.rects[] for border/separator rects */
+enum handle_rect_idx {
+ HRECT_BORDER_LEFT,
+ HRECT_BORDER_RIGHT,
+ HRECT_BORDER_BOTTOM_LEFT,
+ HRECT_BORDER_BOTTOM_CENTER,
+ HRECT_BORDER_BOTTOM_RIGHT,
+ HRECT_BORDER_TOP_LEFT,
+ HRECT_BORDER_TOP_CENTER,
+ HRECT_BORDER_TOP_RIGHT,
+ HRECT_SEPARATOR_LEFT,
+ HRECT_SEPARATOR_RIGHT,
+ HRECT_COUNT,
+};
+
/*
* The scene-graph of SSD looks like below. The parentheses indicate the
* type of each node (enum lab_node_type, stored in the node_descriptor
@@ -50,6 +79,14 @@ struct ssd_state_title_width {
* +--extents
* +--top
* +--...
+ * +--handle (optional, when handle_width > 0)
+ * +--inactive
+ * | +--textures[3] (grip_left, handle, grip_right wlr_scene_buffers)
+ * | +--rects[HRECT_COUNT] (borders and separators, all border_color)
+ * | +--overlay[3] (per-element hover/pressed dimming)
+ * | +--inset shadow rects (per-element top, left, bottom, right)
+ * +--active
+ * +--...
*/
struct ssd {
struct view *view;
@@ -127,6 +164,27 @@ struct ssd {
} subtrees[2]; /* indexed by enum ssd_active_state */
} shadow;
+ /* Bottom handle/grip assembly (optional, created when handle_width > 0) */
+ struct ssd_handle_scene {
+ struct wlr_scene_tree *tree;
+ struct ssd_handle_subtree {
+ struct wlr_scene_tree *tree;
+ /* Per-element texture buffers (1px wide, stretched) */
+ struct wlr_scene_buffer *textures[SSD_HANDLE_ELEMENT_COUNT];
+ /* Border and separator rects (all border_color) */
+ struct wlr_scene_rect *rects[HRECT_COUNT];
+ /* Per-element overlays for hover/pressed dimming */
+ struct wlr_scene_rect *overlay[SSD_HANDLE_ELEMENT_COUNT];
+ /* Inset shadow rects (per-element, for pressed/dragging) */
+ struct wlr_scene_rect *inset_top[SSD_HANDLE_ELEMENT_COUNT];
+ struct wlr_scene_rect *inset_left[SSD_HANDLE_ELEMENT_COUNT];
+ struct wlr_scene_rect *inset_bottom[SSD_HANDLE_ELEMENT_COUNT];
+ struct wlr_scene_rect *inset_right[SSD_HANDLE_ELEMENT_COUNT];
+ } subtrees[2]; /* indexed by enum ssd_active_state */
+ /* Interaction state per element */
+ enum ssd_handle_state element_states[SSD_HANDLE_ELEMENT_COUNT];
+ } handle;
+
/*
* Space between the extremities of the view's wlr_surface
* and the max extents of the server-side decorations.
@@ -186,4 +244,11 @@ void ssd_shadow_create(struct ssd *ssd);
void ssd_shadow_update(struct ssd *ssd);
void ssd_shadow_destroy(struct ssd *ssd);
+void ssd_handle_create(struct ssd *ssd);
+void ssd_handle_update(struct ssd *ssd);
+void ssd_handle_destroy(struct ssd *ssd);
+void ssd_handle_set_element_state(struct ssd *ssd, int element,
+ enum ssd_handle_state state);
+void ssd_handle_clear_all_states(struct ssd *ssd);
+
#endif /* LABWC_SSD_INTERNAL_H */
diff --git a/include/ssd.h b/include/ssd.h
index 9c617474..650a4917 100644
--- a/include/ssd.h
+++ b/include/ssd.h
@@ -50,8 +50,10 @@ void ssd_set_titlebar(struct ssd *ssd, bool enabled);
void ssd_enable_keybind_inhibit_indicator(struct ssd *ssd, bool enable);
void ssd_enable_shade(struct ssd *ssd, bool enable);
+void ssd_set_handle(struct ssd *ssd, bool enabled);
void ssd_update_hovered_button(struct wlr_scene_node *node);
+void ssd_update_hovered_handle(struct wlr_scene_node *node);
void ssd_button_free(struct ssd_button *button);
diff --git a/include/theme.h b/include/theme.h
index 2fb499a9..6f771715 100644
--- a/include/theme.h
+++ b/include/theme.h
@@ -61,6 +61,16 @@ struct theme_background {
struct theme {
int border_width;
+ /*
+ * Height of the bottom handle bar (Openbox calls this
+ * "window.handle.width" but it is the vertical size).
+ * Set to 0 to disable the handle/grip assembly.
+ */
+ int handle_width;
+
+ /* Width of each corner grip in the handle bar */
+ int grip_width;
+
/*
* the space between title bar border and
* buttons on the left/right/top
@@ -130,6 +140,16 @@ struct theme {
struct lab_data_buffer *shadow_corner_top;
struct lab_data_buffer *shadow_corner_bottom;
struct lab_data_buffer *shadow_edge;
+
+ /* handle and grip backgrounds (Openbox-compatible) */
+ struct theme_background handle_bg;
+ struct theme_background grip_bg;
+
+ /* Pre-rendered handle/grip fill buffers and patterns */
+ cairo_pattern_t *handle_pattern;
+ cairo_pattern_t *grip_pattern;
+ struct lab_data_buffer *handle_fill;
+ struct lab_data_buffer *grip_fill;
} window[2];
/* Derived from font sizes */
diff --git a/include/view.h b/include/view.h
index a179338e..e34c1af7 100644
--- a/include/view.h
+++ b/include/view.h
@@ -562,6 +562,7 @@ bool view_is_tiled_and_notify_tiled(struct view *view);
bool view_is_floating(struct view *view);
void view_move_to_workspace(struct view *view, struct workspace *workspace);
bool view_titlebar_visible(struct view *view);
+bool view_handle_visible(struct view *view);
void view_set_ssd_mode(struct view *view, enum lab_ssd_mode mode);
void view_set_decorations(struct view *view, enum lab_ssd_mode mode, bool force_ssd);
void view_toggle_fullscreen(struct view *view);
diff --git a/src/common/node-type.c b/src/common/node-type.c
index 67958dd4..d619083f 100644
--- a/src/common/node-type.c
+++ b/src/common/node-type.c
@@ -42,6 +42,12 @@ node_type_parse(const char *context)
return LAB_NODE_BORDER_BOTTOM;
} else if (!strcasecmp(context, "Left")) {
return LAB_NODE_BORDER_LEFT;
+ } else if (!strcasecmp(context, "Handle")) {
+ return LAB_NODE_HANDLE;
+ } else if (!strcasecmp(context, "BLGrip")) {
+ return LAB_NODE_GRIP_LEFT;
+ } else if (!strcasecmp(context, "BRGrip")) {
+ return LAB_NODE_GRIP_RIGHT;
} else if (!strcasecmp(context, "Frame")) {
return LAB_NODE_FRAME;
} else if (!strcasecmp(context, "Client")) {
@@ -80,9 +86,16 @@ node_type_contains(enum lab_node_type whole, enum lab_node_type part)
return part >= LAB_NODE_BUTTON_FIRST
&& part <= LAB_NODE_CLIENT;
}
+ if (whole == LAB_NODE_HANDLE) {
+ return part == LAB_NODE_GRIP_LEFT
+ || part == LAB_NODE_GRIP_RIGHT;
+ }
if (whole == LAB_NODE_BORDER) {
- return part >= LAB_NODE_CORNER_TOP_LEFT
- && part <= LAB_NODE_BORDER_LEFT;
+ return (part >= LAB_NODE_CORNER_TOP_LEFT
+ && part <= LAB_NODE_BORDER_LEFT)
+ || part == LAB_NODE_HANDLE
+ || part == LAB_NODE_GRIP_LEFT
+ || part == LAB_NODE_GRIP_RIGHT;
}
if (whole == LAB_NODE_BORDER_TOP) {
return part == LAB_NODE_CORNER_TOP_LEFT
@@ -94,7 +107,10 @@ node_type_contains(enum lab_node_type whole, enum lab_node_type part)
}
if (whole == LAB_NODE_BORDER_BOTTOM) {
return part == LAB_NODE_CORNER_BOTTOM_RIGHT
- || part == LAB_NODE_CORNER_BOTTOM_LEFT;
+ || part == LAB_NODE_CORNER_BOTTOM_LEFT
+ || part == LAB_NODE_HANDLE
+ || part == LAB_NODE_GRIP_LEFT
+ || part == LAB_NODE_GRIP_RIGHT;
}
if (whole == LAB_NODE_BORDER_LEFT) {
return part == LAB_NODE_CORNER_TOP_LEFT
@@ -123,6 +139,12 @@ node_type_to_edges(enum lab_node_type type)
return LAB_EDGES_BOTTOM_RIGHT;
case LAB_NODE_CORNER_BOTTOM_LEFT:
return LAB_EDGES_BOTTOM_LEFT;
+ case LAB_NODE_HANDLE:
+ return LAB_EDGE_BOTTOM;
+ case LAB_NODE_GRIP_LEFT:
+ return LAB_EDGES_BOTTOM_LEFT;
+ case LAB_NODE_GRIP_RIGHT:
+ return LAB_EDGES_BOTTOM_RIGHT;
default:
return LAB_EDGE_NONE;
}
diff --git a/src/desktop.c b/src/desktop.c
index 57ef9e3c..4f103db9 100644
--- a/src/desktop.c
+++ b/src/desktop.c
@@ -390,6 +390,19 @@ get_cursor_context(void)
ret.node = node;
ret.type = LAB_NODE_CYCLE_OSD_ITEM;
return ret;
+ case LAB_NODE_HANDLE:
+ case LAB_NODE_GRIP_LEFT:
+ case LAB_NODE_GRIP_RIGHT:
+ /*
+ * Handle/grip nodes have precise types
+ * assigned via node_descriptor; use them
+ * directly without ssd_get_resizing_type().
+ */
+ ret.node = node;
+ ret.view = desc->view;
+ assert(ret.view);
+ ret.type = desc->type;
+ return ret;
case LAB_NODE_BUTTON_FIRST...LAB_NODE_BUTTON_LAST:
case LAB_NODE_SSD_ROOT:
case LAB_NODE_TITLE:
diff --git a/src/input/cursor.c b/src/input/cursor.c
index e8ac7d96..16bc2921 100644
--- a/src/input/cursor.c
+++ b/src/input/cursor.c
@@ -35,6 +35,7 @@
#include "resistance.h"
#include "resize-outlines.h"
#include "ssd.h"
+#include "ssd-internal.h"
#include "view.h"
#include "xwayland.h"
@@ -315,6 +316,24 @@ process_cursor_resize(uint32_t time)
static struct view *last_resize_view = NULL;
assert(server.grabbed_view);
+
+ /*
+ * Transition handle/grip from PRESSED to DRAGGING on
+ * first resize motion.
+ */
+ struct ssd *ssd = server.grabbed_view->ssd;
+ if (ssd && ssd->handle.tree) {
+ for (int i = 0; i < SSD_HANDLE_ELEMENT_COUNT; i++) {
+ if (i == SSD_HANDLE_ELEMENT_CENTER) {
+ continue;
+ }
+ if (ssd->handle.element_states[i]
+ == SSD_HANDLE_STATE_PRESSED) {
+ ssd_handle_set_element_state(ssd, i,
+ SSD_HANDLE_STATE_DRAGGING);
+ }
+ }
+ }
if (server.grabbed_view == last_resize_view) {
int32_t refresh = 0;
if (output_is_usable(last_resize_view->output)) {
@@ -544,6 +563,7 @@ cursor_update_common(const struct cursor_context *ctx,
struct wlr_seat *wlr_seat = seat->wlr_seat;
ssd_update_hovered_button(ctx->node);
+ ssd_update_hovered_handle(ctx->node);
if (server.input_mode != LAB_INPUT_STATE_PASSTHROUGH) {
/*
@@ -1150,6 +1170,28 @@ cursor_process_button_press(struct seat *seat, uint32_t button, uint32_t time_ms
interactive_set_grab_context(&ctx);
}
+ /*
+ * Set pressed visual state on grip elements (not center handle).
+ * This runs before action processing so the visual
+ * feedback appears immediately on click.
+ */
+ if (ctx.view && ctx.view->ssd && ctx.view->ssd->handle.tree) {
+ switch (ctx.type) {
+ case LAB_NODE_GRIP_LEFT:
+ ssd_handle_set_element_state(ctx.view->ssd,
+ SSD_HANDLE_ELEMENT_GRIP_LEFT,
+ SSD_HANDLE_STATE_PRESSED);
+ break;
+ case LAB_NODE_GRIP_RIGHT:
+ ssd_handle_set_element_state(ctx.view->ssd,
+ SSD_HANDLE_ELEMENT_GRIP_RIGHT,
+ SSD_HANDLE_STATE_PRESSED);
+ break;
+ default:
+ break;
+ }
+ }
+
if (server.input_mode == LAB_INPUT_STATE_MENU) {
/*
* If menu was already opened on press, set a very small value
@@ -1272,11 +1314,21 @@ cursor_finish_button_release(struct seat *seat, uint32_t button)
if (resize_outlines_enabled(server.grabbed_view)) {
resize_outlines_finish(server.grabbed_view);
}
+ /*
+ * Clear any handle/grip pressed/dragging state
+ * when finishing interactive resize.
+ */
+ ssd_handle_clear_all_states(
+ server.grabbed_view ? server.grabbed_view->ssd : NULL);
/* Exit interactive move/resize mode */
interactive_finish(server.grabbed_view);
return true;
} else if (server.grabbed_view) {
- /* Button was released without starting move/resize */
+ /*
+ * Button was released without starting move/resize.
+ * Clear any handle/grip pressed state.
+ */
+ ssd_handle_clear_all_states(server.grabbed_view->ssd);
interactive_cancel(server.grabbed_view);
}
diff --git a/src/ssd/meson.build b/src/ssd/meson.build
index a316409b..7c5685e8 100644
--- a/src/ssd/meson.build
+++ b/src/ssd/meson.build
@@ -5,5 +5,6 @@ labwc_sources += files(
'ssd-titlebar.c',
'ssd-border.c',
'ssd-extents.c',
+ 'ssd-handle.c',
'ssd-shadow.c',
)
diff --git a/src/ssd/ssd-border.c b/src/ssd/ssd-border.c
index c2c99f8d..670e7b78 100644
--- a/src/ssd/ssd-border.c
+++ b/src/ssd/ssd-border.c
@@ -59,6 +59,18 @@ ssd_border_create(struct ssd *ssd)
wlr_scene_node_set_enabled(&ssd->border.tree->node, false);
}
+ /*
+ * When the handle assembly is active, hide the bottom border
+ * because the handle provides its own bottom border visuals.
+ */
+ if (ssd->handle.tree && ssd->handle.tree->node.enabled) {
+ FOR_EACH_ACTIVE_STATE(active) {
+ wlr_scene_node_set_enabled(
+ &ssd->border.subtrees[active].bottom->node,
+ false);
+ }
+ }
+
if (view->current.width > 0 && view->current.height > 0) {
/*
* The SSD is recreated by a Reconfigure request
@@ -152,6 +164,15 @@ ssd_border_update(struct ssd *ssd)
top_width, theme->border_width);
wlr_scene_node_set_position(&subtree->top->node,
top_x, -(ssd->titlebar.height + theme->border_width));
+
+ /*
+ * Hide the bottom border when the handle assembly
+ * is active (handle draws its own borders).
+ */
+ bool handle_active = ssd->handle.tree
+ && ssd->handle.tree->node.enabled;
+ wlr_scene_node_set_enabled(
+ &subtree->bottom->node, !handle_active);
}
}
diff --git a/src/ssd/ssd-handle.c b/src/ssd/ssd-handle.c
new file mode 100644
index 00000000..f1156ff9
--- /dev/null
+++ b/src/ssd/ssd-handle.c
@@ -0,0 +1,607 @@
+// SPDX-License-Identifier: GPL-2.0-only
+/*
+ * SSD handle and grip assembly for the bottom of the window frame.
+ *
+ * Implements Openbox-compatible handle/grip decorations with interactive
+ * visual states (hover, pressed, dragging).
+ */
+
+#include
+#include
+#include
+#include "buffer.h"
+#include "common/macros.h"
+#include "common/scene-helpers.h"
+#include "config/rcxml.h"
+#include "labwc.h"
+#include "node.h"
+#include "ssd.h"
+#include "ssd-internal.h"
+#include "theme.h"
+#include "view.h"
+
+/* Inset shadow line width for pressed/dragging state */
+#define INSET_LINE_WIDTH 1
+
+/* Overlay colors (premultiplied RGBA) */
+static const float overlay_hover[4] = { 0.0f, 0.0f, 0.0f, 0.20f };
+static const float overlay_pressed[4] = { 0.0f, 0.0f, 0.0f, 0.40f };
+static const float overlay_none[4] = { 0.0f, 0.0f, 0.0f, 0.0f };
+
+/* Inset shadow colors (premultiplied) */
+static const float inset_dark[4] = { 0.0f, 0.0f, 0.0f, 0.30f };
+static const float inset_light[4] = { 0.10f, 0.10f, 0.10f, 0.10f };
+
+/* Map element index -> node type for hit detection */
+static const enum lab_node_type elem_node_types[SSD_HANDLE_ELEMENT_COUNT] = {
+ [SSD_HANDLE_ELEMENT_GRIP_LEFT] = LAB_NODE_GRIP_LEFT,
+ [SSD_HANDLE_ELEMENT_CENTER] = LAB_NODE_HANDLE,
+ [SSD_HANDLE_ELEMENT_GRIP_RIGHT] = LAB_NODE_GRIP_RIGHT,
+};
+
+/* Spec for a border/separator rect: geometry + node descriptor type */
+struct rect_spec {
+ int x, y, w, h;
+ enum lab_node_type desc_type;
+};
+
+/* Per-element geometry (shared by textures, overlays, insets) */
+struct elem_geometry {
+ int x, y, w, h;
+};
+
+/*
+ * Compute the layout metrics for the handle/grip assembly.
+ * All coordinates are relative to the handle tree origin, which is
+ * positioned at (-border_width, effective_height) relative to the
+ * SSD root.
+ */
+struct handle_layout {
+ int bw; /* border_width */
+ int hw; /* handle_width (height of handle content) */
+ int full_width; /* total width including side borders */
+ int grip_w; /* width of each grip */
+ int handle_w; /* width of center handle (between grips) */
+ int content_h; /* height of textured content (= hw, per Openbox spec) */
+ int total_h; /* total assembly height: 2*bw + hw */
+ /* X positions (relative to handle tree) */
+ int grip_left_x;
+ int handle_x;
+ int grip_right_x;
+ /* Y position of content (below top border line) */
+ int content_y;
+};
+
+static struct handle_layout
+compute_layout(struct theme *theme, int view_width)
+{
+ struct handle_layout l;
+ l.bw = theme->border_width;
+ l.hw = theme->handle_width;
+ l.full_width = view_width + 2 * l.bw;
+ l.grip_w = theme->grip_width;
+
+ /* Ensure grips don't overflow the available width */
+ if (2 * l.grip_w + 4 * l.bw > l.full_width) {
+ l.grip_w = MAX((l.full_width - 4 * l.bw) / 2, 0);
+ }
+
+ /*
+ * Center handle width: total minus outer borders (2*bw),
+ * grips (2*grip_w), and inner separators (2*bw).
+ */
+ l.handle_w = MAX(l.full_width - 4 * l.bw - 2 * l.grip_w, 0);
+
+ /* X coordinates accounting for separators between grips and handle */
+ l.grip_left_x = l.bw;
+ l.handle_x = l.bw + l.grip_w + l.bw;
+ l.grip_right_x = l.bw + l.grip_w + l.bw + l.handle_w + l.bw;
+
+ /* Content Y starts after top border line */
+ l.content_y = l.bw;
+
+ /*
+ * Per Openbox spec, handle_width is the content-only height.
+ * Borders are drawn around it, not subtracted from it.
+ */
+ l.content_h = l.hw;
+ l.total_h = 2 * l.bw + l.hw;
+
+ return l;
+}
+
+static void
+compute_rect_specs(const struct handle_layout *l,
+ struct rect_spec specs[HRECT_COUNT])
+{
+ specs[HRECT_BORDER_LEFT] = (struct rect_spec){
+ 0, 0, l->bw, l->total_h, LAB_NODE_GRIP_LEFT };
+ specs[HRECT_BORDER_RIGHT] = (struct rect_spec){
+ l->full_width - l->bw, 0, l->bw, l->total_h,
+ LAB_NODE_GRIP_RIGHT };
+ specs[HRECT_BORDER_BOTTOM_LEFT] = (struct rect_spec){
+ 0, l->bw + l->hw, l->bw + l->grip_w + l->bw, l->bw,
+ LAB_NODE_GRIP_LEFT };
+ specs[HRECT_BORDER_BOTTOM_CENTER] = (struct rect_spec){
+ l->handle_x, l->bw + l->hw, l->handle_w, l->bw,
+ LAB_NODE_HANDLE };
+ specs[HRECT_BORDER_BOTTOM_RIGHT] = (struct rect_spec){
+ l->grip_right_x - l->bw, l->bw + l->hw,
+ l->bw + l->grip_w + l->bw, l->bw, LAB_NODE_GRIP_RIGHT };
+ specs[HRECT_BORDER_TOP_LEFT] = (struct rect_spec){
+ l->bw, 0, l->grip_w + l->bw, l->bw,
+ LAB_NODE_GRIP_LEFT };
+ specs[HRECT_BORDER_TOP_CENTER] = (struct rect_spec){
+ l->handle_x, 0, l->handle_w, l->bw,
+ LAB_NODE_HANDLE };
+ specs[HRECT_BORDER_TOP_RIGHT] = (struct rect_spec){
+ l->grip_right_x - l->bw, 0, l->grip_w + l->bw, l->bw,
+ LAB_NODE_GRIP_RIGHT };
+ specs[HRECT_SEPARATOR_LEFT] = (struct rect_spec){
+ l->bw + l->grip_w, l->content_y, l->bw, l->content_h,
+ LAB_NODE_GRIP_LEFT };
+ specs[HRECT_SEPARATOR_RIGHT] = (struct rect_spec){
+ l->grip_right_x - l->bw, l->content_y, l->bw, l->content_h,
+ LAB_NODE_GRIP_RIGHT };
+}
+
+static void
+compute_elem_geometry(const struct handle_layout *l,
+ struct elem_geometry elems[SSD_HANDLE_ELEMENT_COUNT])
+{
+ elems[SSD_HANDLE_ELEMENT_GRIP_LEFT] = (struct elem_geometry){
+ l->grip_left_x, l->content_y, l->grip_w, l->content_h };
+ elems[SSD_HANDLE_ELEMENT_CENTER] = (struct elem_geometry){
+ l->handle_x, l->content_y, l->handle_w, l->content_h };
+ elems[SSD_HANDLE_ELEMENT_GRIP_RIGHT] = (struct elem_geometry){
+ l->grip_right_x, l->content_y, l->grip_w, l->content_h };
+}
+
+static struct wlr_scene_buffer *
+create_handle_buffer(struct wlr_scene_tree *parent,
+ struct wlr_buffer *buf, int x, int y, int w, int h)
+{
+ struct wlr_scene_buffer *sbuf =
+ lab_wlr_scene_buffer_create(parent, buf);
+ wlr_scene_node_set_position(&sbuf->node, x, y);
+ wlr_scene_buffer_set_dest_size(sbuf, w, h);
+ if (wlr_renderer_is_pixman(server.renderer)) {
+ wlr_scene_buffer_set_filter_mode(
+ sbuf, WLR_SCALE_FILTER_NEAREST);
+ }
+ return sbuf;
+}
+
+static void
+create_inset_rects(struct wlr_scene_tree *parent,
+ struct ssd_handle_subtree *subtree, int idx,
+ int x, int y, int w, int h)
+{
+ /* Top inset (dark) */
+ subtree->inset_top[idx] = lab_wlr_scene_rect_create(
+ parent, w, INSET_LINE_WIDTH, inset_dark);
+ wlr_scene_node_set_position(
+ &subtree->inset_top[idx]->node, x, y);
+ wlr_scene_node_set_enabled(
+ &subtree->inset_top[idx]->node, false);
+
+ /* Left inset (dark) */
+ subtree->inset_left[idx] = lab_wlr_scene_rect_create(
+ parent, INSET_LINE_WIDTH, h, inset_dark);
+ wlr_scene_node_set_position(
+ &subtree->inset_left[idx]->node, x, y);
+ wlr_scene_node_set_enabled(
+ &subtree->inset_left[idx]->node, false);
+
+ /* Bottom inset (light highlight) */
+ subtree->inset_bottom[idx] = lab_wlr_scene_rect_create(
+ parent, w, INSET_LINE_WIDTH, inset_light);
+ wlr_scene_node_set_position(
+ &subtree->inset_bottom[idx]->node, x, y + h - INSET_LINE_WIDTH);
+ wlr_scene_node_set_enabled(
+ &subtree->inset_bottom[idx]->node, false);
+
+ /* Right inset (light highlight) */
+ subtree->inset_right[idx] = lab_wlr_scene_rect_create(
+ parent, INSET_LINE_WIDTH, h, inset_light);
+ wlr_scene_node_set_position(
+ &subtree->inset_right[idx]->node,
+ x + w - INSET_LINE_WIDTH, y);
+ wlr_scene_node_set_enabled(
+ &subtree->inset_right[idx]->node, false);
+}
+
+void
+ssd_handle_create(struct ssd *ssd)
+{
+ assert(ssd);
+ struct view *view = ssd->view;
+ struct theme *theme = rc.theme;
+
+ if (theme->handle_width <= 0) {
+ ssd->handle.tree = NULL;
+ return;
+ }
+
+ int width = view->current.width;
+ struct handle_layout l = compute_layout(theme, width);
+
+ ssd->handle.tree = lab_wlr_scene_tree_create(ssd->tree);
+ wlr_scene_node_set_position(&ssd->handle.tree->node,
+ -theme->border_width,
+ view_effective_height(view, /* use_pending */ false));
+
+ /* Initialize element states */
+ for (int i = 0; i < SSD_HANDLE_ELEMENT_COUNT; i++) {
+ ssd->handle.element_states[i] = SSD_HANDLE_STATE_NORMAL;
+ }
+
+ enum ssd_active_state active;
+ FOR_EACH_ACTIVE_STATE(active) {
+ struct ssd_handle_subtree *subtree =
+ &ssd->handle.subtrees[active];
+ subtree->tree = lab_wlr_scene_tree_create(ssd->handle.tree);
+ struct wlr_scene_tree *parent = subtree->tree;
+ wlr_scene_node_set_enabled(&parent->node, active);
+ float *border_color = theme->window[active].border_color;
+
+ /* Border and separator rects */
+ struct rect_spec rspecs[HRECT_COUNT];
+ compute_rect_specs(&l, rspecs);
+ for (int i = 0; i < HRECT_COUNT; i++) {
+ subtree->rects[i] = lab_wlr_scene_rect_create(
+ parent, rspecs[i].w, rspecs[i].h,
+ border_color);
+ wlr_scene_node_set_position(
+ &subtree->rects[i]->node,
+ rspecs[i].x, rspecs[i].y);
+ node_descriptor_create(&subtree->rects[i]->node,
+ rspecs[i].desc_type, view, NULL);
+ }
+
+ /* Per-element geometry (shared by textures, overlays, insets) */
+ struct elem_geometry elems[SSD_HANDLE_ELEMENT_COUNT];
+ compute_elem_geometry(&l, elems);
+ struct wlr_buffer *bufs[SSD_HANDLE_ELEMENT_COUNT] = {
+ &theme->window[active].grip_fill->base,
+ &theme->window[active].handle_fill->base,
+ &theme->window[active].grip_fill->base,
+ };
+
+ for (int i = 0; i < SSD_HANDLE_ELEMENT_COUNT; i++) {
+ /* Texture buffer (1px wide, stretched to fill) */
+ subtree->textures[i] = create_handle_buffer(
+ parent, bufs[i], elems[i].x, elems[i].y,
+ elems[i].w, elems[i].h);
+ node_descriptor_create(
+ &subtree->textures[i]->node,
+ elem_node_types[i], view, NULL);
+
+ /* Overlay rect for hover/pressed dimming */
+ subtree->overlay[i] = lab_wlr_scene_rect_create(
+ parent, elems[i].w, elems[i].h,
+ overlay_none);
+ wlr_scene_node_set_position(
+ &subtree->overlay[i]->node,
+ elems[i].x, elems[i].y);
+ wlr_scene_node_set_enabled(
+ &subtree->overlay[i]->node, false);
+ node_descriptor_create(
+ &subtree->overlay[i]->node,
+ elem_node_types[i], view, NULL);
+
+ /* Inset shadow rects for pressed/dragging state */
+ create_inset_rects(parent, subtree, i,
+ elems[i].x, elems[i].y,
+ elems[i].w, elems[i].h);
+ }
+ }
+
+ if (view->maximized == VIEW_AXIS_BOTH) {
+ wlr_scene_node_set_enabled(
+ &ssd->handle.tree->node, false);
+ }
+}
+
+void
+ssd_handle_update(struct ssd *ssd)
+{
+ assert(ssd);
+
+ if (!ssd->handle.tree) {
+ return;
+ }
+
+ struct view *view = ssd->view;
+ struct theme *theme = rc.theme;
+
+ bool should_show = view_handle_visible(view)
+ && view->maximized != VIEW_AXIS_BOTH;
+
+ if (!should_show) {
+ if (ssd->handle.tree->node.enabled) {
+ wlr_scene_node_set_enabled(
+ &ssd->handle.tree->node, false);
+ }
+ return;
+ } else if (!ssd->handle.tree->node.enabled) {
+ wlr_scene_node_set_enabled(
+ &ssd->handle.tree->node, true);
+ }
+
+ int width = view->current.width;
+ int height = view_effective_height(view, /* use_pending */ false);
+ struct handle_layout l = compute_layout(theme, width);
+
+ /* Update position: handle sits below the client area */
+ wlr_scene_node_set_position(&ssd->handle.tree->node,
+ -theme->border_width, height);
+
+ enum ssd_active_state active;
+ FOR_EACH_ACTIVE_STATE(active) {
+ struct ssd_handle_subtree *subtree =
+ &ssd->handle.subtrees[active];
+
+ float *border_color = theme->window[active].border_color;
+
+ /* Update border and separator rects */
+ struct rect_spec rspecs[HRECT_COUNT];
+ compute_rect_specs(&l, rspecs);
+ for (int i = 0; i < HRECT_COUNT; i++) {
+ wlr_scene_rect_set_size(subtree->rects[i],
+ rspecs[i].w, rspecs[i].h);
+ wlr_scene_node_set_position(
+ &subtree->rects[i]->node,
+ rspecs[i].x, rspecs[i].y);
+ wlr_scene_rect_set_color(subtree->rects[i],
+ border_color);
+ }
+
+ /* Update textures, overlays, and insets per element */
+ struct elem_geometry elems[SSD_HANDLE_ELEMENT_COUNT];
+ compute_elem_geometry(&l, elems);
+
+ for (int i = 0; i < SSD_HANDLE_ELEMENT_COUNT; i++) {
+ int ex = elems[i].x;
+ int ey = elems[i].y;
+ int ew = elems[i].w;
+ int eh = elems[i].h;
+
+ wlr_scene_node_set_position(
+ &subtree->textures[i]->node, ex, ey);
+ wlr_scene_buffer_set_dest_size(
+ subtree->textures[i], ew, eh);
+
+ wlr_scene_rect_set_size(
+ subtree->overlay[i], ew, eh);
+ wlr_scene_node_set_position(
+ &subtree->overlay[i]->node, ex, ey);
+
+ wlr_scene_rect_set_size(
+ subtree->inset_top[i],
+ ew, INSET_LINE_WIDTH);
+ wlr_scene_node_set_position(
+ &subtree->inset_top[i]->node, ex, ey);
+
+ wlr_scene_rect_set_size(
+ subtree->inset_left[i],
+ INSET_LINE_WIDTH, eh);
+ wlr_scene_node_set_position(
+ &subtree->inset_left[i]->node, ex, ey);
+
+ wlr_scene_rect_set_size(
+ subtree->inset_bottom[i],
+ ew, INSET_LINE_WIDTH);
+ wlr_scene_node_set_position(
+ &subtree->inset_bottom[i]->node,
+ ex, ey + eh - INSET_LINE_WIDTH);
+
+ wlr_scene_rect_set_size(
+ subtree->inset_right[i],
+ INSET_LINE_WIDTH, eh);
+ wlr_scene_node_set_position(
+ &subtree->inset_right[i]->node,
+ ex + ew - INSET_LINE_WIDTH, ey);
+ }
+ }
+}
+
+void
+ssd_handle_destroy(struct ssd *ssd)
+{
+ assert(ssd);
+
+ if (!ssd->handle.tree) {
+ return;
+ }
+
+ wlr_scene_node_destroy(&ssd->handle.tree->node);
+ ssd->handle = (struct ssd_handle_scene){0};
+}
+
+/*
+ * Update the visual state of a single handle/grip element.
+ * This toggles the overlay and inset shadow rects on both active
+ * and inactive subtrees (only the enabled one is visible).
+ */
+void
+ssd_handle_set_element_state(struct ssd *ssd, int element,
+ enum ssd_handle_state state)
+{
+ if (!ssd || !ssd->handle.tree || element < 0
+ || element >= SSD_HANDLE_ELEMENT_COUNT) {
+ return;
+ }
+
+ if (ssd->handle.element_states[element] == state) {
+ return;
+ }
+ ssd->handle.element_states[element] = state;
+
+ enum ssd_active_state active;
+ FOR_EACH_ACTIVE_STATE(active) {
+ struct ssd_handle_subtree *subtree =
+ &ssd->handle.subtrees[active];
+ struct wlr_scene_rect *overlay = subtree->overlay[element];
+
+ bool show_overlay = false;
+ bool show_insets = false;
+
+ switch (state) {
+ case SSD_HANDLE_STATE_NORMAL:
+ break;
+ case SSD_HANDLE_STATE_HOVER:
+ wlr_scene_rect_set_color(overlay, overlay_hover);
+ show_overlay = true;
+ break;
+ case SSD_HANDLE_STATE_PRESSED:
+ wlr_scene_rect_set_color(overlay, overlay_pressed);
+ show_overlay = true;
+ show_insets = true;
+ break;
+ case SSD_HANDLE_STATE_DRAGGING:
+ wlr_scene_rect_set_color(overlay, overlay_hover);
+ show_overlay = true;
+ show_insets = true;
+ break;
+ }
+
+ wlr_scene_node_set_enabled(&overlay->node, show_overlay);
+ wlr_scene_node_set_enabled(
+ &subtree->inset_top[element]->node, show_insets);
+ wlr_scene_node_set_enabled(
+ &subtree->inset_left[element]->node, show_insets);
+ wlr_scene_node_set_enabled(
+ &subtree->inset_bottom[element]->node, show_insets);
+ wlr_scene_node_set_enabled(
+ &subtree->inset_right[element]->node, show_insets);
+ }
+}
+
+void
+ssd_handle_clear_all_states(struct ssd *ssd)
+{
+ if (!ssd || !ssd->handle.tree) {
+ return;
+ }
+ for (int i = 0; i < SSD_HANDLE_ELEMENT_COUNT; i++) {
+ ssd_handle_set_element_state(ssd, i,
+ SSD_HANDLE_STATE_NORMAL);
+ }
+}
+
+/*
+ * Determine which handle element (if any) a scene node belongs to,
+ * and resolve the owning view/SSD.
+ *
+ * Returns the element index (SSD_HANDLE_ELEMENT_*) or -1 if the
+ * node is not part of any handle. If found, *out_ssd is set to
+ * the owning SSD.
+ */
+static int
+handle_element_from_node(struct wlr_scene_node *node, struct ssd **out_ssd)
+{
+ *out_ssd = NULL;
+ if (!node) {
+ return -1;
+ }
+
+ /*
+ * Walk up the node tree to find a node_descriptor with
+ * a handle/grip type.
+ */
+ struct wlr_scene_node *n = node;
+ int element = -1;
+ while (n) {
+ if (n->data) {
+ struct node_descriptor *desc = n->data;
+ switch (desc->type) {
+ case LAB_NODE_GRIP_LEFT:
+ element = SSD_HANDLE_ELEMENT_GRIP_LEFT;
+ break;
+ case LAB_NODE_HANDLE:
+ /*
+ * Center handle is identified for correct cursor
+ * shape but visual hover/pressed effects are
+ * intentionally limited to corner grips only.
+ */
+ element = SSD_HANDLE_ELEMENT_CENTER;
+ break;
+ case LAB_NODE_GRIP_RIGHT:
+ element = SSD_HANDLE_ELEMENT_GRIP_RIGHT;
+ break;
+ default:
+ break;
+ }
+ if (element >= 0) {
+ struct view *view = desc->view;
+ if (view && view->ssd
+ && view->ssd->handle.tree) {
+ *out_ssd = view->ssd;
+ }
+ return element;
+ }
+ }
+ n = n->parent ? &n->parent->node : NULL;
+ }
+ return -1;
+}
+
+/*
+ * Update hover state based on the node under the cursor.
+ * Uses global server.hovered_handle_ssd / hovered_handle_element
+ * to track state across views (mirrors ssd_update_hovered_button
+ * pattern).
+ *
+ * Called from cursor_update_common() in cursor.c.
+ */
+void
+ssd_update_hovered_handle(struct wlr_scene_node *node)
+{
+ struct ssd *new_ssd = NULL;
+ int new_element = -1;
+ int raw_element;
+
+ raw_element = handle_element_from_node(node, &new_ssd);
+
+ if (new_ssd && raw_element >= 0) {
+ /*
+ * Visual hover/pressed effects apply to corner grips
+ * only. The center handle still changes the cursor
+ * shape via node_type_to_edges() but receives no
+ * dimming overlay.
+ */
+ if (raw_element != SSD_HANDLE_ELEMENT_CENTER) {
+ new_element = raw_element;
+ }
+ }
+
+ struct ssd *old_ssd = server.hovered_handle_ssd;
+ int old_element = server.hovered_handle_element;
+
+ /* Same element on same SSD -- nothing to do */
+ if (old_ssd == new_ssd && old_element == new_element) {
+ return;
+ }
+
+ /* Clear hover on old element */
+ if (old_ssd && old_ssd->handle.tree && old_element >= 0) {
+ if (old_ssd->handle.element_states[old_element]
+ == SSD_HANDLE_STATE_HOVER) {
+ ssd_handle_set_element_state(old_ssd,
+ old_element, SSD_HANDLE_STATE_NORMAL);
+ }
+ }
+
+ /* Set hover on new element */
+ if (new_ssd && new_element >= 0) {
+ if (new_ssd->handle.element_states[new_element]
+ == SSD_HANDLE_STATE_NORMAL) {
+ ssd_handle_set_element_state(new_ssd,
+ new_element, SSD_HANDLE_STATE_HOVER);
+ }
+ }
+
+ server.hovered_handle_ssd = new_ssd;
+ server.hovered_handle_element = new_element;
+}
diff --git a/src/ssd/ssd.c b/src/ssd/ssd.c
index e2ab6375..13d9e3c1 100644
--- a/src/ssd/ssd.c
+++ b/src/ssd/ssd.c
@@ -59,6 +59,18 @@ ssd_thickness(struct view *view)
if (!view_titlebar_visible(view)) {
thickness.top -= theme->titlebar_height;
}
+
+ /*
+ * When the handle is visible, the bottom thickness is the
+ * full handle assembly: top separator (bw) + content (hw)
+ * + bottom border (bw). Per Openbox spec, handle_width is
+ * the content-only height with borders drawn around it.
+ */
+ if (view_handle_visible(view)) {
+ thickness.bottom = 2 * theme->border_width
+ + theme->handle_width;
+ }
+
return thickness;
}
@@ -122,14 +134,34 @@ ssd_get_resizing_type(const struct ssd *ssd, struct wlr_cursor *cursor)
return LAB_NODE_CORNER_TOP_LEFT;
} else if (top && right) {
return LAB_NODE_CORNER_TOP_RIGHT;
- } else if (bottom && left) {
- return LAB_NODE_CORNER_BOTTOM_LEFT;
- } else if (bottom && right) {
- return LAB_NODE_CORNER_BOTTOM_RIGHT;
+ } else if (bottom) {
+ /*
+ * When the handle is visible, the grip columns define
+ * the effective corner zones for the bottom edge.
+ * Expand the corner width so that the extents below
+ * the grips produce diagonal resize types matching the
+ * grip layout above them.
+ */
+ if (view_handle_visible(view)) {
+ struct theme *theme = rc.theme;
+ int grip_col = theme->grip_width + theme->border_width;
+ int wide = MAX(corner_width, grip_col);
+ if (cursor->x < view_box.x + wide) {
+ return LAB_NODE_CORNER_BOTTOM_LEFT;
+ } else if (cursor->x > view_box.x
+ + view_box.width - wide) {
+ return LAB_NODE_CORNER_BOTTOM_RIGHT;
+ }
+ } else {
+ if (left) {
+ return LAB_NODE_CORNER_BOTTOM_LEFT;
+ } else if (right) {
+ return LAB_NODE_CORNER_BOTTOM_RIGHT;
+ }
+ }
+ return LAB_NODE_BORDER_BOTTOM;
} else if (top) {
return LAB_NODE_BORDER_TOP;
- } else if (bottom) {
- return LAB_NODE_BORDER_BOTTOM;
} else if (left) {
return LAB_NODE_BORDER_LEFT;
} else if (right) {
@@ -167,10 +199,14 @@ ssd_create(struct view *view, bool active)
*/
ssd_titlebar_create(ssd);
ssd_border_create(ssd);
+ ssd_handle_create(ssd);
if (!view_titlebar_visible(view)) {
/* Ensure we keep the old state on Reconfigure or when exiting fullscreen */
ssd_set_titlebar(ssd, false);
}
+ if (!view_handle_visible(view)) {
+ ssd_set_handle(ssd, false);
+ }
ssd->margin = ssd_thickness(view);
ssd_set_active(ssd, active);
ssd_enable_keybind_inhibit_indicator(ssd, view->inhibits_keybinds);
@@ -234,6 +270,7 @@ ssd_update_geometry(struct ssd *ssd)
* maximizedDecoration=none
*/
ssd_set_titlebar(ssd, view_titlebar_visible(view));
+ ssd_set_handle(ssd, view_handle_visible(view));
if (update_extents) {
ssd_extents_update(ssd);
@@ -242,6 +279,7 @@ ssd_update_geometry(struct ssd *ssd)
if (update_area || state_changed) {
ssd_titlebar_update(ssd);
ssd_border_update(ssd);
+ ssd_handle_update(ssd);
ssd_shadow_update(ssd);
}
@@ -264,6 +302,22 @@ ssd_set_titlebar(struct ssd *ssd, bool enabled)
ssd->margin = ssd_thickness(ssd->view);
}
+void
+ssd_set_handle(struct ssd *ssd, bool enabled)
+{
+ if (!ssd || !ssd->handle.tree) {
+ return;
+ }
+ if (ssd->handle.tree->node.enabled == enabled) {
+ return;
+ }
+ wlr_scene_node_set_enabled(&ssd->handle.tree->node, enabled);
+ ssd_border_update(ssd);
+ ssd_extents_update(ssd);
+ ssd_shadow_update(ssd);
+ ssd->margin = ssd_thickness(ssd->view);
+}
+
void
ssd_destroy(struct ssd *ssd)
{
@@ -277,10 +331,15 @@ ssd_destroy(struct ssd *ssd)
server.hovered_button->node) == view) {
server.hovered_button = NULL;
}
+ if (server.hovered_handle_ssd == ssd) {
+ server.hovered_handle_ssd = NULL;
+ server.hovered_handle_element = -1;
+ }
/* Destroy subcomponents */
ssd_titlebar_destroy(ssd);
ssd_border_destroy(ssd);
+ ssd_handle_destroy(ssd);
ssd_extents_destroy(ssd);
ssd_shadow_destroy(ssd);
wlr_scene_node_destroy(&ssd->tree->node);
@@ -298,6 +357,8 @@ ssd_mode_parse(const char *mode)
return LAB_SSD_MODE_NONE;
} else if (!strcasecmp(mode, "border")) {
return LAB_SSD_MODE_BORDER;
+ } else if (!strcasecmp(mode, "border-handle")) {
+ return LAB_SSD_MODE_BORDER_HANDLE;
} else if (!strcasecmp(mode, "full")) {
return LAB_SSD_MODE_FULL;
} else {
@@ -324,6 +385,11 @@ ssd_set_active(struct ssd *ssd, bool active)
&ssd->shadow.subtrees[active_state].tree->node,
active == active_state);
}
+ if (ssd->handle.tree) {
+ wlr_scene_node_set_enabled(
+ &ssd->handle.subtrees[active_state].tree->node,
+ active == active_state);
+ }
}
}
@@ -335,6 +401,7 @@ ssd_enable_shade(struct ssd *ssd, bool enable)
}
ssd_titlebar_update(ssd);
ssd_border_update(ssd);
+ ssd_handle_update(ssd);
wlr_scene_node_set_enabled(&ssd->extents.tree->node, !enable);
ssd_shadow_update(ssd);
}
@@ -385,5 +452,13 @@ ssd_debug_get_node_name(const struct ssd *ssd, struct wlr_scene_node *node)
if (node == &ssd->extents.tree->node) {
return "extents";
}
+ if (ssd->handle.tree) {
+ if (node == &ssd->handle.subtrees[SSD_ACTIVE].tree->node) {
+ return "handle.active";
+ }
+ if (node == &ssd->handle.subtrees[SSD_INACTIVE].tree->node) {
+ return "handle.inactive";
+ }
+ }
return NULL;
}
diff --git a/src/theme.c b/src/theme.c
index 93ac1c5e..7f0e40bd 100644
--- a/src/theme.c
+++ b/src/theme.c
@@ -532,6 +532,8 @@ static void
theme_builtin(struct theme *theme)
{
theme->border_width = 1;
+ theme->handle_width = 6;
+ theme->grip_width = 20;
theme->window_titlebar_padding_height = 0;
theme->window_titlebar_padding_width = 0;
@@ -551,6 +553,30 @@ theme_builtin(struct theme *theme)
theme->window[SSD_ACTIVE].title_bg.color_to_split_to[0] = FLT_MIN;
theme->window[SSD_INACTIVE].title_bg.color_to_split_to[0] = FLT_MIN;
+ /* handle background defaults */
+ theme->window[SSD_ACTIVE].handle_bg.gradient = LAB_GRADIENT_NONE;
+ theme->window[SSD_INACTIVE].handle_bg.gradient = LAB_GRADIENT_NONE;
+ parse_hexstr("#a0a0a0", theme->window[SSD_ACTIVE].handle_bg.color);
+ parse_hexstr("#c0c0c0", theme->window[SSD_INACTIVE].handle_bg.color);
+ theme->window[SSD_ACTIVE].handle_bg.color_split_to[0] = FLT_MIN;
+ theme->window[SSD_INACTIVE].handle_bg.color_split_to[0] = FLT_MIN;
+ theme->window[SSD_ACTIVE].handle_bg.color_to[0] = FLT_MIN;
+ theme->window[SSD_INACTIVE].handle_bg.color_to[0] = FLT_MIN;
+ theme->window[SSD_ACTIVE].handle_bg.color_to_split_to[0] = FLT_MIN;
+ theme->window[SSD_INACTIVE].handle_bg.color_to_split_to[0] = FLT_MIN;
+
+ /* grip background defaults (FLT_MIN sentinel = inherit from handle) */
+ theme->window[SSD_ACTIVE].grip_bg.gradient = LAB_GRADIENT_NONE;
+ theme->window[SSD_INACTIVE].grip_bg.gradient = LAB_GRADIENT_NONE;
+ theme->window[SSD_ACTIVE].grip_bg.color[0] = FLT_MIN;
+ theme->window[SSD_INACTIVE].grip_bg.color[0] = FLT_MIN;
+ theme->window[SSD_ACTIVE].grip_bg.color_split_to[0] = FLT_MIN;
+ theme->window[SSD_INACTIVE].grip_bg.color_split_to[0] = FLT_MIN;
+ theme->window[SSD_ACTIVE].grip_bg.color_to[0] = FLT_MIN;
+ theme->window[SSD_INACTIVE].grip_bg.color_to[0] = FLT_MIN;
+ theme->window[SSD_ACTIVE].grip_bg.color_to_split_to[0] = FLT_MIN;
+ theme->window[SSD_INACTIVE].grip_bg.color_to_split_to[0] = FLT_MIN;
+
parse_hexstr("#000000", theme->window[SSD_ACTIVE].label_text_color);
parse_hexstr("#000000", theme->window[SSD_INACTIVE].label_text_color);
theme->window_label_text_justify = parse_justification("Center");
@@ -757,6 +783,82 @@ entry(struct theme *theme, const char *key, const char *value)
parse_color(value, theme->window[SSD_INACTIVE].title_bg.color_to_split_to);
}
+ /* handle width (Openbox "window.handle.width" is actually the height) */
+ if (match_glob(key, "window.handle.width")) {
+ theme->handle_width = get_int_if_positive(
+ value, "window.handle.width");
+ }
+
+ /* grip width */
+ if (match_glob(key, "window.grip.width")) {
+ theme->grip_width = get_int_if_positive(
+ value, "window.grip.width");
+ }
+
+ /* handle background */
+ if (match_glob(key, "window.active.handle.bg")) {
+ theme->window[SSD_ACTIVE].handle_bg.gradient = parse_gradient(value);
+ }
+ if (match_glob(key, "window.inactive.handle.bg")) {
+ theme->window[SSD_INACTIVE].handle_bg.gradient = parse_gradient(value);
+ }
+ if (match_glob(key, "window.active.handle.bg.color")) {
+ parse_color(value, theme->window[SSD_ACTIVE].handle_bg.color);
+ }
+ if (match_glob(key, "window.inactive.handle.bg.color")) {
+ parse_color(value, theme->window[SSD_INACTIVE].handle_bg.color);
+ }
+ if (match_glob(key, "window.active.handle.bg.color.splitTo")) {
+ parse_color(value, theme->window[SSD_ACTIVE].handle_bg.color_split_to);
+ }
+ if (match_glob(key, "window.inactive.handle.bg.color.splitTo")) {
+ parse_color(value, theme->window[SSD_INACTIVE].handle_bg.color_split_to);
+ }
+ if (match_glob(key, "window.active.handle.bg.colorTo")) {
+ parse_color(value, theme->window[SSD_ACTIVE].handle_bg.color_to);
+ }
+ if (match_glob(key, "window.inactive.handle.bg.colorTo")) {
+ parse_color(value, theme->window[SSD_INACTIVE].handle_bg.color_to);
+ }
+ if (match_glob(key, "window.active.handle.bg.colorTo.splitTo")) {
+ parse_color(value, theme->window[SSD_ACTIVE].handle_bg.color_to_split_to);
+ }
+ if (match_glob(key, "window.inactive.handle.bg.colorTo.splitTo")) {
+ parse_color(value, theme->window[SSD_INACTIVE].handle_bg.color_to_split_to);
+ }
+
+ /* grip background */
+ if (match_glob(key, "window.active.grip.bg")) {
+ theme->window[SSD_ACTIVE].grip_bg.gradient = parse_gradient(value);
+ }
+ if (match_glob(key, "window.inactive.grip.bg")) {
+ theme->window[SSD_INACTIVE].grip_bg.gradient = parse_gradient(value);
+ }
+ if (match_glob(key, "window.active.grip.bg.color")) {
+ parse_color(value, theme->window[SSD_ACTIVE].grip_bg.color);
+ }
+ if (match_glob(key, "window.inactive.grip.bg.color")) {
+ parse_color(value, theme->window[SSD_INACTIVE].grip_bg.color);
+ }
+ if (match_glob(key, "window.active.grip.bg.color.splitTo")) {
+ parse_color(value, theme->window[SSD_ACTIVE].grip_bg.color_split_to);
+ }
+ if (match_glob(key, "window.inactive.grip.bg.color.splitTo")) {
+ parse_color(value, theme->window[SSD_INACTIVE].grip_bg.color_split_to);
+ }
+ if (match_glob(key, "window.active.grip.bg.colorTo")) {
+ parse_color(value, theme->window[SSD_ACTIVE].grip_bg.color_to);
+ }
+ if (match_glob(key, "window.inactive.grip.bg.colorTo")) {
+ parse_color(value, theme->window[SSD_INACTIVE].grip_bg.color_to);
+ }
+ if (match_glob(key, "window.active.grip.bg.colorTo.splitTo")) {
+ parse_color(value, theme->window[SSD_ACTIVE].grip_bg.color_to_split_to);
+ }
+ if (match_glob(key, "window.inactive.grip.bg.colorTo.splitTo")) {
+ parse_color(value, theme->window[SSD_INACTIVE].grip_bg.color_to_split_to);
+ }
+
if (match_glob(key, "window.active.label.text.color")) {
parse_color(value, theme->window[SSD_ACTIVE].label_text_color);
}
@@ -1410,6 +1512,28 @@ create_backgrounds(struct theme *theme)
theme->window[active].titlebar_pattern,
theme->titlebar_height);
}
+
+ /* Create handle and grip fill buffers if handle is enabled */
+ if (theme->handle_width > 0) {
+ FOR_EACH_ACTIVE_STATE(active) {
+ theme->window[active].handle_pattern =
+ create_titlebar_pattern(
+ &theme->window[active].handle_bg,
+ theme->handle_width);
+ theme->window[active].handle_fill =
+ create_titlebar_fill(
+ theme->window[active].handle_pattern,
+ theme->handle_width);
+ theme->window[active].grip_pattern =
+ create_titlebar_pattern(
+ &theme->window[active].grip_bg,
+ theme->handle_width);
+ theme->window[active].grip_fill =
+ create_titlebar_fill(
+ theme->window[active].grip_pattern,
+ theme->handle_width);
+ }
+ }
}
static void
@@ -1686,6 +1810,35 @@ post_processing(struct theme *theme)
fill_background_colors(&theme->window[SSD_INACTIVE].title_bg);
fill_background_colors(&theme->window[SSD_ACTIVE].title_bg);
+ /* Clamp handle_width to a sensible maximum */
+ if (theme->handle_width > 100) {
+ wlr_log(WLR_ERROR,
+ "window.handle.width clamped from %d to 100",
+ theme->handle_width);
+ theme->handle_width = 100;
+ }
+
+ /* Clamp grip_width minimum to 1 */
+ if (theme->grip_width < 1) {
+ theme->grip_width = 1;
+ }
+
+ /* Fill handle gradient color defaults */
+ fill_background_colors(&theme->window[SSD_INACTIVE].handle_bg);
+ fill_background_colors(&theme->window[SSD_ACTIVE].handle_bg);
+
+ /* Inherit grip from handle if grip color was not explicitly set */
+ enum ssd_active_state active;
+ FOR_EACH_ACTIVE_STATE(active) {
+ struct theme_background *grip = &theme->window[active].grip_bg;
+ struct theme_background *handle = &theme->window[active].handle_bg;
+ if (grip->color[0] == FLT_MIN) {
+ memcpy(grip, handle, sizeof(*grip));
+ } else {
+ fill_background_colors(grip);
+ }
+ }
+
theme->menu_item_height = font_height(&rc.font_menuitem)
+ 2 * theme->menu_items_padding_y;
@@ -1877,5 +2030,9 @@ theme_finish(struct theme *theme)
zdrop(&theme->window[active].shadow_corner_top);
zdrop(&theme->window[active].shadow_corner_bottom);
zdrop(&theme->window[active].shadow_edge);
+ zfree_pattern(theme->window[active].handle_pattern);
+ zfree_pattern(theme->window[active].grip_pattern);
+ zdrop(&theme->window[active].handle_fill);
+ zdrop(&theme->window[active].grip_fill);
}
}
diff --git a/src/view.c b/src/view.c
index bcbd366e..092e3606 100644
--- a/src/view.c
+++ b/src/view.c
@@ -1531,6 +1531,8 @@ view_toggle_decorations(struct view *view)
assert(view);
if (rc.ssd_keep_border && view->ssd_mode == LAB_SSD_MODE_FULL) {
+ view_set_ssd_mode(view, LAB_SSD_MODE_BORDER_HANDLE);
+ } else if (view->ssd_mode == LAB_SSD_MODE_BORDER_HANDLE) {
view_set_ssd_mode(view, LAB_SSD_MODE_BORDER);
} else if (view->ssd_mode != LAB_SSD_MODE_NONE) {
view_set_ssd_mode(view, LAB_SSD_MODE_NONE);
@@ -1621,6 +1623,19 @@ view_titlebar_visible(struct view *view)
return view->ssd_mode == LAB_SSD_MODE_FULL;
}
+bool
+view_handle_visible(struct view *view)
+{
+ if (rc.theme->handle_width <= 0) {
+ return false;
+ }
+ if (view->shaded) {
+ return false;
+ }
+ return view->ssd_mode == LAB_SSD_MODE_FULL
+ || view->ssd_mode == LAB_SSD_MODE_BORDER_HANDLE;
+}
+
void
view_set_ssd_mode(struct view *view, enum lab_ssd_mode mode)
{
@@ -1640,6 +1655,7 @@ view_set_ssd_mode(struct view *view, enum lab_ssd_mode mode)
if (mode) {
decorate(view);
ssd_set_titlebar(view->ssd, view_titlebar_visible(view));
+ ssd_set_handle(view->ssd, view_handle_visible(view));
} else {
undecorate(view);
}