Merge pull request 'Implement river-tag-overlay clone' (#13) from tag-overlay into main

Reviewed-on: https://codeberg.org/bwbuhse/beansprout/pulls/13
This commit is contained in:
Ben Buhse 2026-02-16 18:36:35 +01:00
commit c1c9eb24f7
13 changed files with 1031 additions and 185 deletions

View file

@ -27,6 +27,7 @@ Needed at both build-time and runtime:
| wayland-client | `dev-libs/wayland` | `libwayland-dev` |
| pixman | `x11-libs/pixman` | `libpixman-1-dev` |
| xkbcommon | `x11-libs/libxkbcommon` | `libxkbcommon-dev` |
| fcft | `media-libs/fcft` | `libfcft-dev` |
Only needed at build-time:

View file

@ -44,7 +44,7 @@ wallpaper_image_path "~/Pictures/wallpaper.png"
|------------------------------|--------|---------|-----------------------------------------------------|
| `attach_mode` | enum | `top` | Where new windows go in the stack (`top` or `bottom`) |
| `primary_count` | u8 | `1` | Number of windows in the primary stack (0+) |
| `primary_ratio` | float | `0.55` | Proportion of output width for the primary stack (0.100.90) |
| `primary_ratio` | float | `0.55` | Proportion of output width for the primary stack (0.10-0.90) |
| `focus_follows_pointer` | bool | `#true` | Focus follows the pointer between windows |
| `pointer_warp_on_focus_change` | bool | `#true` | Warp pointer to center of newly-focused windows |
| `wallpaper_image_path` | string | none | Path to wallpaper image |
@ -71,6 +71,62 @@ borders {
Colors are specified in `0xRRGGBB` or `0xRRGGBBAA` hex format.
## Tag Overlay
The tag overlay is an optional widget that briefly shows your tag state when switching tags.
It is only created when a `tag_overlay` block is present in the config. All settings have
defaults, with the color based on the Catppuccin Mocha theme. An empty block can be used
to enable the widget with all defaults:
```kdl
tag_overlay {
}
```
### Tag Overlay Settings
| Setting | Type | Default | Description |
|-------------------------------------|-------|--------------|-------------------------------------------|
| `border_width` | u8 | `2` | Widget border width in pixels |
| `tag_amount` | u8 | `9` | Number of displayed tags (1-32) |
| `tags_per_row` | u8 | `32` | Tags per row (1-32) |
| `square_size` | u8 | `40` | Size of tag squares in pixels |
| `square_inner_padding` | u8 | `10` | Padding around occupied indicator |
| `square_padding` | u8 | `15` | Padding around tag squares |
| `square_border_width` | u8 | `1` | Border width of tag squares |
| `timeout` | u32 | `500` | Display duration in milliseconds |
| `background_color` | color | `0x1e1e2e` | Widget background color |
| `border_color` | color | `0x6c7086` | Widget border color |
| `square_active_background_color` | color | `0x89b4fa` | Active tag square background |
| `square_active_border_color` | color | `0x6c7086` | Active tag square border |
| `square_active_occupied_color` | color | `0xcdd6f4` | Active tag occupied indicator |
| `square_inactive_background_color` | color | `0x585b70` | Inactive tag square background |
| `square_inactive_border_color` | color | `0x6c7086` | Inactive tag square border |
| `square_inactive_occupied_color` | color | `0xcdd6f4` | Inactive tag occupied indicator |
### Anchors
The `anchors` child block controls which edge(s) of the screen the overlay
attaches to. Each direction is a boolean (`#true` / `#false`). Default: none, i.e. centered on output.
| Setting | Type | Default |
|----------|------|----------|
| `top` | bool | `#false` |
| `right` | bool | `#false` |
| `bottom` | bool | `#false` |
| `left` | bool | `#false` |
### Margins
The `margins` child block sets pixel offsets from the anchored edge(s).
| Setting | Type | Default |
|----------|------|---------|
| `top` | i32 | `0` |
| `right` | i32 | `0` |
| `bottom` | i32 | `0` |
| `left` | i32 | `0` |
## Keybinds
Keyboard bindings are placed inside a `keybinds` block. Each binding has the

View file

@ -2,8 +2,7 @@
These are in rough order of my priority, though no promises I do them in this order.
- [ ] Implement a river-tag-overlay clone
- [ ] Add options to the bar and river-tag-overlay
- [ ] Add options to the bar
- [ ] Make a Rect struct to combine x, y, width, and height
- [ ] Support window rules (float/tags/SSD by app-id/title)
- [ ] Support overriding config location
@ -16,6 +15,7 @@ These are in rough order of my priority, though no promises I do them in this or
- [ ] Save window positions between restarts
- [ ] Support multiple seats
- [ ] Support clipping floating windows on edge of/between outputs
- [ ] Use per-output timerfds for tag overlay instead of a single shared one
- [x] Support changeable primary ratio
- [x] Support changeable primary count
- [x] Support multiple outputs
@ -29,3 +29,5 @@ These are in rough order of my priority, though no promises I do them in this or
- [x] Implement primary count/ratio per tagmask
- [x] Add primary_count and primary_ratio to Config
- [x] Implement an optional clock bar
- [x] Implement a river-tag-overlay clone
- [x] Add options to the tag overlay

View file

@ -18,6 +18,19 @@ borders {
color_focused "0x89b4fa"
color_unfocused "0x1e1e2e"
}
// Tag overlay widget — shown briefly when switching tags
// Remove this block to disable the overlay entirely
tag_overlay {
tag_amount 10
background_color "0x1e1e2e"
border_color "0x6c7086"
square_active_background_color "0x89b4fa"
square_active_border_color "0x6c7086"
square_active_occupied_color "0xcdd6f4"
square_inactive_background_color "0x585b70"
square_inactive_border_color "0x6c7086"
square_inactive_occupied_color "0xcdd6f4"
}
keybinds {
// Swap a window
spawn Mod4 T foot

View file

@ -21,23 +21,23 @@ output: *Output,
width: u31 = 0,
height: u31 = 0,
wl_surface: ?*wl.Surface = null,
layer_surface: ?*zwlr.LayerSurfaceV1 = null,
surfaces: ?struct {
wl_surface: *wl.Surface,
layer_surface: *zwlr.LayerSurfaceV1,
} = null,
configured: bool = false,
pub fn init(context: *Context, output: *Output) !Bar {
// Get the local environment
// Needed for the timezone
// XXX: It might be better to store this in Context?
var env = try process.getEnvMap(utils.gpa);
defer env.deinit();
const timezone = try zeit.local(utils.gpa, &context.env);
errdefer timezone.deinit();
const timezone = try zeit.local(utils.gpa, &env);
const fonts = try getFcftFonts("monospace:size=14", 1);
errdefer fonts.destroy();
return .{
.context = context,
.fonts = try getFcftFonts("monospace:size=14", 1),
.fonts = fonts,
.timezone = timezone,
.output = output,
};
@ -45,34 +45,37 @@ pub fn init(context: *Context, output: *Output) !Bar {
// TODO: Add config options for whether it's top or bottom
pub fn initSurface(bar: *Bar) !void {
if (bar.layer_surface) |_| {
// This bar already has a layer surface, we can exit early
if (bar.surfaces) |_| {
// This bar already has a surface, we can exit early
return;
}
const context = bar.context;
// TODO: Add padding to config
const vertical_padding = 5;
const bar_height: u31 = @intCast(bar.fonts.height + 2 * vertical_padding);
const wl_surface = try context.wl_compositor.createSurface();
errdefer wl_surface.destroy();
const layer_surface = try context
.zwlr_layer_shell_v1
.getLayerSurface(wl_surface, bar.output.wl_output, .top, "beansprout-bar");
errdefer layer_surface.destroy();
// TODO: Allow clicking on tags to switch between them?
// We don't want our surface to have any input region (default is infinite)
const empty_region = try context.wl_compositor.createRegion();
defer empty_region.destroy();
wl_surface.setInputRegion(empty_region);
const layer_surface = try context
.zwlr_layer_shell_v1
.getLayerSurface(wl_surface, bar.output.wl_output, .top, "beansprout-bar");
// TODO: Add padding to config
const vertical_padding = 5;
const bar_height: u31 = @intCast(bar.fonts.height + 2 * vertical_padding);
layer_surface.setSize(0, bar_height);
layer_surface.setAnchor(.{ .top = true, .right = true, .left = true });
bar.wl_surface = wl_surface;
bar.layer_surface = layer_surface;
bar.surfaces = .{
.wl_surface = wl_surface,
.layer_surface = layer_surface,
};
context.buffer_pool.surface_count += 1;
layer_surface.setListener(*Bar, layerSurfaceListener, bar);
@ -82,11 +85,10 @@ pub fn initSurface(bar: *Bar) !void {
pub fn deinit(bar: *Bar) void {
bar.configured = false;
bar.timezone.deinit();
if (bar.wl_surface) |wl_surface| {
wl_surface.destroy();
}
if (bar.layer_surface) |layer_surface| {
layer_surface.destroy();
bar.fonts.destroy();
if (bar.surfaces) |surfaces| {
surfaces.wl_surface.destroy();
surfaces.layer_surface.destroy();
bar.context.buffer_pool.surface_count -= 1;
}
}
@ -96,6 +98,7 @@ pub fn layerSurfaceListener(
event: zwlr.LayerSurfaceV1.Event,
bar: *Bar,
) void {
assert(bar.surfaces.?.layer_surface == layer_surface);
switch (event) {
.configure => |ev| {
layer_surface.ackConfigure(ev.serial);
@ -107,18 +110,14 @@ pub fn layerSurfaceListener(
bar.height == height and
bar.output.scale == bar.font_scale)
{
if (bar.wl_surface) |wl_surface| {
wl_surface.commit();
} else {
log.warn("Bar is marked as configured but is missing a layer_surface for the wallpaper", .{});
}
bar.surfaces.?.wl_surface.commit();
return;
}
log.debug("configuring bar surface with width {} and height {}", .{ width, height });
log.debug("Configuring bar surface with width {} and height {}", .{ width, height });
bar.width = width;
bar.height = height;
// Excluse zone == the bar's height
// Exclusive zone == the bar's height
layer_surface.setExclusiveZone(bar.height);
// Full surface should be opaque
@ -126,10 +125,9 @@ pub fn layerSurfaceListener(
log.err("Failed to create opaque region for bar: {}", .{e});
return;
};
// TODO: Need to change the x/y if we support anchoring to the bottom
opaque_region.add(0, 0, bar.width, bar.height);
defer opaque_region.destroy();
bar.wl_surface.?.setOpaqueRegion(opaque_region);
opaque_region.add(0, 0, bar.width, bar.height);
bar.surfaces.?.wl_surface.setOpaqueRegion(opaque_region);
bar.configured = true;
@ -143,7 +141,6 @@ pub fn layerSurfaceListener(
}
}
// TODO: Configure number of visible tags
/// Renders the bar and its components
pub fn render(bar: *Bar) !void {
const context = bar.context;
@ -223,7 +220,8 @@ pub fn render(bar: *Bar) !void {
try bar.renderChars(codepoints, buffer, &x, y, color);
// Finally, attach the buffer to the surface
const wl_surface = bar.wl_surface orelse return;
const surfaces = bar.surfaces orelse return error.NoSurfaces;
const wl_surface = surfaces.wl_surface;
wl_surface.setBufferScale(scale);
wl_surface.attach(buffer.wl_buffer, 0, 0);
wl_surface.damageBuffer(0, 0, render_width, render_height);
@ -365,6 +363,7 @@ fn getFcftFonts(fonts: []const u8, scale: u31) !*fcft.Font {
}
const std = @import("std");
const assert = std.debug.assert;
const io = std.io;
const mem = std.mem;
const process = std.process;

View file

@ -84,15 +84,55 @@ pub fn deinit(buffer: *Buffer) void {
// We have to do this later because of the way init() works
pub fn setListener(buffer: *Buffer) void {
buffer.wl_buffer.setListener(*Buffer, buffer_listener, buffer);
buffer.wl_buffer.setListener(*Buffer, bufferListener, buffer);
}
fn buffer_listener(_: *wl.Buffer, event: wl.Buffer.Event, buffer: *Buffer) void {
fn bufferListener(_: *wl.Buffer, event: wl.Buffer.Event, buffer: *Buffer) void {
switch (event) {
.release => buffer.busy = false,
}
}
pub fn borderedRectangle(
buffer: Buffer,
x: u31,
y: u31,
width: u31,
height: u31,
border_width: u31,
scale: u31,
background_color: *const pixman.Color,
border_color: *const pixman.Color,
) void {
const render_x: i16 = @intCast(x * scale);
const render_y: i16 = @intCast(y * scale);
const render_width: u16 = @intCast(width * scale);
const render_height: u16 = @intCast(height * scale);
const render_border_width: u16 = @intCast(border_width * scale);
// Background fill
_ = pixman.Image.fillRectangles(.src, buffer.pixman_image, background_color, 1, &[1]pixman.Rectangle16{.{ .x = render_x, .y = render_y, .width = render_width, .height = render_height }});
// Border: top, bottom, left, right
_ = pixman.Image.fillRectangles(.src, buffer.pixman_image, border_color, 4, &[4]pixman.Rectangle16{
.{
.x = render_x,
.y = render_y,
.width = render_width,
.height = render_border_width,
},
.{
.x = render_x,
.y = render_y + @as(i16, @intCast(render_height -
render_border_width)),
.width = render_width,
.height = render_border_width,
},
.{ .x = render_x, .y = render_y + @as(i16, @intCast(render_border_width)), .width = render_border_width, .height = render_height - 2 * render_border_width },
.{ .x = render_x + @as(i16, @intCast(render_width - render_border_width)), .y = render_y + @as(i16, @intCast(render_border_width)), .width = render_border_width, .height = render_height - 2 * render_border_width },
});
}
const std = @import("std");
const builtin = @import("builtin");
const mem = std.mem;

View file

@ -31,6 +31,9 @@ pointer_warp_on_focus_change: bool = true,
/// Path to the wallpaper image
wallpaper_image_path: ?[]const u8 = null,
/// Tag overlay configuration. If null, no overlay is created.
tag_overlay: ?TagOverlayConfig = null,
/// Tag bind entries parsed from config (tag_bind nodes in keybinds block)
tag_binds: std.ArrayList(Keybind) = .{},
keybinds: std.ArrayList(Keybind) = .{},
@ -85,6 +88,66 @@ pub const AttachMode = enum {
bottom,
};
pub const TagOverlayConfig = struct {
border_width: u8 = 2,
tag_amount: u8 = 9,
tags_per_row: u8 = 32,
square_size: u8 = 40,
square_inner_padding: u8 = 10,
square_padding: u8 = 15,
square_border_width: u8 = 1,
background_color: pixman.Color = utils.parseRgbaPixmanComptime("0x1e1e2e"),
border_color: pixman.Color = utils.parseRgbaPixmanComptime("0x6c7086"),
square_active_background_color: pixman.Color = utils.parseRgbaPixmanComptime("0x89b4fa"),
square_active_border_color: pixman.Color = utils.parseRgbaPixmanComptime("0x6c7086"),
square_active_occupied_color: pixman.Color = utils.parseRgbaPixmanComptime("0xcdd6f4"),
square_inactive_background_color: pixman.Color = utils.parseRgbaPixmanComptime("0x585b70"),
square_inactive_border_color: pixman.Color = utils.parseRgbaPixmanComptime("0x6c7086"),
square_inactive_occupied_color: pixman.Color = utils.parseRgbaPixmanComptime("0xcdd6f4"),
timeout: u32 = 500,
anchor_top: bool = false,
anchor_right: bool = false,
anchor_bottom: bool = false,
anchor_left: bool = false,
margin_top: i32 = 0,
margin_right: i32 = 0,
margin_bottom: i32 = 0,
margin_left: i32 = 0,
pub fn toTagOverlayOptions(self: TagOverlayConfig) TagOverlay.Options {
return .{
.border_width = self.border_width,
.tag_amount = @intCast(std.math.clamp(@as(u32, self.tag_amount), 1, 32)),
.tags_per_row = @intCast(std.math.clamp(@as(u32, self.tags_per_row), 1, 32)),
.square_size = self.square_size,
.square_inner_padding = self.square_inner_padding,
.square_padding = self.square_padding,
.square_border_width = self.square_border_width,
.background_color = self.background_color,
.border_color = self.border_color,
.square_active_background_color = self.square_active_background_color,
.square_active_border_color = self.square_active_border_color,
.square_active_occupied_color = self.square_active_occupied_color,
.square_inactive_background_color = self.square_inactive_background_color,
.square_inactive_border_color = self.square_inactive_border_color,
.square_inactive_occupied_color = self.square_inactive_occupied_color,
.anchors = .{
.top = self.anchor_top,
.right = self.anchor_right,
.bottom = self.anchor_bottom,
.left = self.anchor_left,
},
.margins = .{
.top = self.margin_top,
.right = self.margin_right,
.bottom = self.margin_bottom,
.left = self.margin_left,
},
.timeout = self.timeout,
};
}
};
const NodeName = enum {
attach_mode,
primary_count,
@ -97,6 +160,7 @@ const NodeName = enum {
keybinds,
pointer_binds,
input,
tag_overlay,
};
const BorderNodeName = enum {
@ -105,6 +169,30 @@ const BorderNodeName = enum {
color_unfocused,
};
const TagOverlayNodeName = enum {
border_width,
tag_amount,
tags_per_row,
square_size,
square_inner_padding,
square_padding,
square_border_width,
background_color,
border_color,
square_active_background_color,
square_active_border_color,
square_active_occupied_color,
square_inactive_background_color,
square_inactive_border_color,
square_inactive_occupied_color,
timeout,
anchors,
margins,
};
const TagOverlayAnchorsNodeName = enum { top, right, bottom, left };
const TagOverlayMarginsNodeName = enum { top, right, bottom, left };
const PointerBindNodeName = enum {
move_window,
resize_window,
@ -315,6 +403,9 @@ fn load(config: *Config, reader: *Io.Reader) !void {
null;
next_child_block = .input;
},
.tag_overlay => {
next_child_block = .tag_overlay;
},
}
} else {
logWarnInvalidNode(node.name);
@ -330,6 +421,7 @@ fn load(config: *Config, reader: *Io.Reader) !void {
try config.loadInputChildBlock(&parser, pending_input_name, hostname);
pending_input_name = null; // ownership transferred
},
.tag_overlay => try config.loadTagOverlayChildBlock(&parser, hostname),
else => {
// Nothing else should ever be marked as a next_child_block
unreachable;
@ -398,16 +490,162 @@ fn loadBordersChildBlock(config: *Config, parser: *kdl.Parser, hostname: ?[]cons
}
}
fn loadTagOverlayChildBlock(config: *Config, parser: *kdl.Parser, hostname: ?[]const u8) !void {
config.tag_overlay = .{}; // Presence of block = enabled; initialize with defaults
const TagOverlayChild = enum { anchors, margins };
var next_child_block: ?TagOverlayChild = null;
while (try parser.next()) |event| {
switch (event) {
.node => |node| {
if (next_child_block) |child| {
log.warn("Expected child block for tag_overlay.{s}, got node instead. Ignoring", .{@tagName(child)});
next_child_block = null;
}
const node_name = std.meta.stringToEnum(TagOverlayNodeName, node.name);
if (node_name) |name| {
if (!hostMatches(node, parser, hostname)) {
logDebugHostMismatch(name);
continue;
}
const val_str = utils.stripQuotes(node.arg(parser, 0) orelse "");
switch (name) {
.anchors => next_child_block = .anchors,
.margins => next_child_block = .margins,
// These are all u8s, so we can inline the branch
inline .border_width,
.tag_amount,
.tags_per_row,
.square_size,
.square_inner_padding,
.square_padding,
.square_border_width,
=> |tag| {
const val = fmt.parseInt(u8, val_str, 10) catch {
logWarnInvalidNodeArg(name, val_str);
continue;
};
@field(config.tag_overlay.?, @tagName(tag)) = val;
logDebugSettingNode(name, val_str);
},
.timeout => {
config.tag_overlay.?.timeout = fmt.parseInt(u32, val_str, 10) catch {
logWarnInvalidNodeArg(name, val_str);
continue;
};
logDebugSettingNode(name, val_str);
},
inline .background_color,
.border_color,
.square_active_background_color,
.square_active_border_color,
.square_active_occupied_color,
.square_inactive_background_color,
.square_inactive_border_color,
.square_inactive_occupied_color,
=> |tag| {
@field(config.tag_overlay.?, @tagName(tag)) = utils.parseRgbaPixman(val_str) catch {
logWarnInvalidNodeArg(name, val_str);
continue;
};
logDebugSettingNode(name, val_str);
},
}
} else {
logWarnInvalidNode(node.name);
}
},
.child_block_begin => {
if (next_child_block) |child| {
switch (child) {
.anchors => try config.loadTagOverlayAnchorsBlock(parser, hostname),
.margins => try config.loadTagOverlayMarginsBlock(parser, hostname),
}
next_child_block = null;
} else {
try config.skipChildBlock(parser);
}
},
.child_block_end => return,
}
}
}
fn loadTagOverlayAnchorsBlock(config: *Config, parser: *kdl.Parser, hostname: ?[]const u8) !void {
while (try parser.next()) |event| {
switch (event) {
.node => |node| {
const node_name = std.meta.stringToEnum(TagOverlayAnchorsNodeName, node.name);
if (node_name) |name| {
if (!hostMatches(node, parser, hostname)) {
logDebugHostMismatch(name);
continue;
}
const val_str = utils.stripQuotes(node.arg(parser, 0) orelse "");
if (boolFromKdlStr(val_str)) |val| {
switch (name) {
.top => config.tag_overlay.?.anchor_top = val,
.right => config.tag_overlay.?.anchor_right = val,
.bottom => config.tag_overlay.?.anchor_bottom = val,
.left => config.tag_overlay.?.anchor_left = val,
}
logDebugSettingNode(name, val_str);
} else {
logWarnInvalidNodeArg(name, val_str);
}
} else {
logWarnInvalidNode(node.name);
}
},
.child_block_begin => try config.skipChildBlock(parser),
.child_block_end => return,
}
}
}
fn loadTagOverlayMarginsBlock(config: *Config, parser: *kdl.Parser, hostname: ?[]const u8) !void {
while (try parser.next()) |event| {
switch (event) {
.node => |node| {
const node_name = std.meta.stringToEnum(TagOverlayMarginsNodeName, node.name);
if (node_name) |name| {
if (!hostMatches(node, parser, hostname)) {
logDebugHostMismatch(name);
continue;
}
const val_str = utils.stripQuotes(node.arg(parser, 0) orelse "");
const val = fmt.parseInt(i32, val_str, 10) catch {
logWarnInvalidNodeArg(name, val_str);
continue;
};
switch (name) {
.top => config.tag_overlay.?.margin_top = val,
.right => config.tag_overlay.?.margin_right = val,
.bottom => config.tag_overlay.?.margin_bottom = val,
.left => config.tag_overlay.?.margin_left = val,
}
logDebugSettingNode(name, val_str);
} else {
logWarnInvalidNode(node.name);
}
},
.child_block_begin => try config.skipChildBlock(parser),
.child_block_end => return,
}
}
}
fn loadKeybindsChildBlock(config: *Config, parser: *kdl.Parser, hostname: ?[]const u8) !void {
while (try parser.next()) |event| {
switch (event) {
.node => |node| {
// tag_bind is a special case node name not in KeybindNodeName
if (mem.eql(u8, node.name, "tag_bind")) {
if (!hostMatches(node, parser, hostname)) {
logDebugHostMismatch(node.name);
log.debug("Skipping \"keybind.tag_bind\" (host mismatch)", .{});
continue;
}
// tag_bind is a special case node name
if (mem.eql(u8, node.name, "tag_bind")) {
const mod_str = utils.stripQuotes(node.arg(parser, 0) orelse {
log.warn("tag_bind: missing modifier argument. Ignoring", .{});
continue;
@ -443,13 +681,16 @@ fn loadKeybindsChildBlock(config: *Config, parser: *kdl.Parser, hostname: ?[]con
.keysym = null, // Tag binds don't need a keysym (automatically 1-9)
});
// TODO: Make a logger for keybind settings
log.debug("tag_bind: {s} {s}", .{ mod_str, cmd_str });
continue;
}
// Handle the rest of the possiblities like all the other types of block
// Handle the rest of the possibilities like all the other types of block
const node_name = std.meta.stringToEnum(KeybindNodeName, node.name);
if (node_name) |name| {
if (!hostMatches(node, parser, hostname)) {
logDebugHostMismatch(name);
continue;
}
// All nodes should have at least a command, modifiers, and a keysym
// Some may have more arguments handled in the switch
const mod_str = utils.stripQuotes(node.arg(parser, 0) orelse {
@ -787,6 +1028,9 @@ fn logWarnInvalidNodeArg(node_name: anytype, node_value: []const u8) void {
KeybindNodeName => log.warn("Invalid \"keybind.{s}\" ({s}). Ignoring", .{ @tagName(node_name), node_value }),
PointerBindNodeName => log.warn("Invalid \"pointer_binds.{s}\" ({s}). Ignoring", .{ @tagName(node_name), node_value }),
InputConfigNodeName => log.warn("Invalid \"input.{s}\" ({s}). Ignoring", .{ @tagName(node_name), node_value }),
TagOverlayNodeName => log.warn("Invalid \"tag_overlay.{s}\" ({s}). Using default value", .{ @tagName(node_name), node_value }),
TagOverlayAnchorsNodeName => log.warn("Invalid \"tag_overlay.anchors.{s}\" ({s}). Using default value", .{ @tagName(node_name), node_value }),
TagOverlayMarginsNodeName => log.warn("Invalid \"tag_overlay.margins.{s}\" ({s}). Using default value", .{ @tagName(node_name), node_value }),
else => @compileError("This function does not (yet) support type \"" ++ @typeName(node_name_type) ++ "\""),
}
}
@ -798,6 +1042,7 @@ fn logWarnMissingNodeArg(node_name: anytype, comptime arg: []const u8) void {
KeybindNodeName => log.warn("\"keybind.{s}\" missing " ++ arg ++ " argument. Ignoring", .{@tagName(node_name)}),
PointerBindNodeName => log.warn("\"pointer_binds.{s}\" missing " ++ arg ++ " argument. Ignoring", .{@tagName(node_name)}),
InputConfigNodeName => log.warn("\"input.{s}\" missing " ++ arg ++ " argument. Ignoring", .{@tagName(node_name)}),
TagOverlayNodeName => log.warn("\"tag_overlay.{s}\" missing " ++ arg ++ " argument. Ignoring", .{@tagName(node_name)}),
else => @compileError("This function does not (yet) support type \"" ++ @typeName(node_name_type) ++ "\""),
}
}
@ -821,7 +1066,10 @@ fn logDebugHostMismatch(node_name: anytype) void {
BorderNodeName => log.debug("Skipping \"border.{s}\" (host mismatch)", .{@tagName(node_name)}),
PointerBindNodeName => log.debug("Skipping \"pointer_binds.{s}\" (host mismatch)", .{@tagName(node_name)}),
InputConfigNodeName => log.debug("Skipping \"input.{s}\" (host mismatch)", .{@tagName(node_name)}),
[]const u8 => log.debug("Skipping \"keybind.{s}\" (host mismatch)", .{node_name}),
TagOverlayNodeName => log.debug("Skipping \"tag_overlay.{s}\" (host mismatch)", .{@tagName(node_name)}),
TagOverlayAnchorsNodeName => log.debug("Skipping \"tag_overlay.anchors.{s}\" (host mismatch)", .{@tagName(node_name)}),
TagOverlayMarginsNodeName => log.debug("Skipping \"tag_overlay.margins.{s}\" (host mismatch)", .{@tagName(node_name)}),
KeybindNodeName => log.debug("Skipping \"keybind.{s}\" (host mismatch)", .{@tagName(node_name)}),
else => @compileError("This function does not (yet) support type \"" ++ @typeName(node_name_type) ++ "\""),
}
}
@ -831,6 +1079,9 @@ fn logDebugSettingNode(node_name: anytype, node_value: []const u8) void {
switch (node_name_type) {
NodeName => log.debug("Setting {s} to {s}", .{ @tagName(node_name), node_value }),
BorderNodeName => log.debug("Setting border.{s} to {s}", .{ @tagName(node_name), node_value }),
TagOverlayNodeName => log.debug("Setting tag_overlay.{s} to {s}", .{ @tagName(node_name), node_value }),
TagOverlayAnchorsNodeName => log.debug("Setting tag_overlay.anchors.{s} to {s}", .{ @tagName(node_name), node_value }),
TagOverlayMarginsNodeName => log.debug("Setting tag_overlay.margins.{s} to {s}", .{ @tagName(node_name), node_value }),
else => @compileError("This function does not (yet) support type \"" ++ @typeName(@TypeOf(node_name)) ++ "\""),
}
}
@ -879,10 +1130,12 @@ const ThreeFingerDragState = river.LibinputDeviceV1.ThreeFingerDragState;
const kdl = @import("kdl");
const known_folders = @import("known_folders");
const pixman = @import("pixman");
const xkbcommon = @import("xkbcommon");
const utils = @import("utils.zig");
const RiverColor = utils.RiverColor;
const TagOverlay = @import("TagOverlay.zig");
const XkbBindings = @import("XkbBindings.zig");
const log = std.log.scoped(.Config);

View file

@ -7,6 +7,8 @@ const Context = @This();
initialized: bool,
env: process.EnvMap,
// Wayland globals
wl_compositor: *wl.Compositor,
wl_display: *wl.Display,
@ -32,6 +34,10 @@ wallpaper_image: ?*WallpaperImage,
// WM Configuration
config: *Config,
/// Shared timerfd for hiding tag overlays after their timeout expires.
/// This stays null if no tag overlays exist.
tag_overlay_timer_fd: ?posix.fd_t,
/// State consumed in manage() phase, reset at end of manage().
pending_manage: PendingManage = .{},
@ -60,10 +66,29 @@ pub const Options = struct {
pub fn create(options: Options) !*Context {
const context = try utils.gpa.create(Context);
errdefer context.destroy();
errdefer utils.gpa.destroy(context);
const im = try InputManager.create(context, options.river_input_manager_v1, options.river_libinput_config_v1);
errdefer im.destroy();
const wm = try WindowManager.create(context, options.river_window_manager_v1);
errdefer wm.destroy();
const xkb_bindings = try XkbBindings.create(context, options.river_xkb_bindings_v1);
errdefer xkb_bindings.destroy();
const env = try process.getEnvMap(utils.gpa);
errdefer env.deinit();
const tag_overlay_timer_fd: ?posix.fd_t = if (options.config.tag_overlay) |_|
posix.timerfd_create(.MONOTONIC, .{ .CLOEXEC = true }) catch |e| blk: {
log.err("Failed to create tag overlay timer: {}", .{e});
break :blk null;
}
else
null;
context.* = .{
.initialized = false,
.env = env,
.wl_compositor = options.wl_compositor,
.wl_display = options.wl_display,
.wl_registry = options.wl_registry,
@ -72,22 +97,26 @@ pub fn create(options: Options) !*Context {
.river_layer_shell_v1 = options.river_layer_shell_v1,
.zwlr_layer_shell_v1 = options.zwlr_layer_shell_v1,
.wallpaper_image = loadWallpaperImage(options.config),
.im = try InputManager.create(context, options.river_input_manager_v1, options.river_libinput_config_v1),
.wm = try WindowManager.create(context, options.river_window_manager_v1),
.xkb_bindings = try XkbBindings.create(context, options.river_xkb_bindings_v1),
.im = im,
.wm = wm,
.xkb_bindings = xkb_bindings,
.config = options.config,
.tag_overlay_timer_fd = tag_overlay_timer_fd,
};
return context;
}
pub fn destroy(context: *Context) void {
context.xkb_bindings.destroy();
context.env.deinit();
context.im.destroy();
context.wm.destroy();
context.xkb_bindings.destroy();
if (context.wallpaper_image) |wallpaper_image| {
wallpaper_image.destroy();
}
if (context.tag_overlay_timer_fd) |fd| posix.close(fd);
context.buffer_pool.deinit();
utils.gpa.destroy(context);
@ -120,6 +149,42 @@ pub fn manage(context: *Context) void {
libinput_device.should_manage = true;
}
// Handle tag overlay config changes
const had_overlay = context.config.tag_overlay != null;
const has_overlay = new_config.tag_overlay != null;
if (!had_overlay and has_overlay) {
// Create timerfd for newly enabled tag overlay
context.tag_overlay_timer_fd = posix.timerfd_create(.MONOTONIC, .{ .CLOEXEC = true }) catch |e| blk: {
log.err("Failed to create tag overlay timer: {}", .{e});
break :blk null;
};
} else if (had_overlay and !has_overlay) {
// Close timerfd for disabled tag overlay
if (context.tag_overlay_timer_fd) |fd| posix.close(fd);
context.tag_overlay_timer_fd = null;
}
// Recreate or destroy tag overlays on all outputs
if (had_overlay or has_overlay) {
var out_it = context.wm.outputs.iterator(.forward);
while (out_it.next()) |output| {
// Destroy existing overlay
if (output.tag_overlay) |*tag_overlay| {
tag_overlay.deinit();
output.tag_overlay = null;
}
// Create new overlay if configured
// Create new overlay struct if configured (surfaces created on-demand)
if (new_config.tag_overlay) |tag_overlay_config| {
output.tag_overlay = TagOverlay.init(context, output, tag_overlay_config.toTagOverlayOptions()) catch |e| {
log.err("Failed to create tag overlay: {}", .{e});
continue;
};
}
}
}
if (wallpaper_changed) {
if (context.wallpaper_image) |img| img.destroy();
context.wallpaper_image = loadWallpaperImage(new_config);
@ -128,7 +193,7 @@ pub fn manage(context: *Context) void {
while (out_it.next()) |output| {
if (context.wallpaper_image == null) {
output.deinitWallpaperLayerSurface();
} else if (output.wl_surface != null) {
} else if (output.surfaces != null) {
output.renderWallpaper() catch |err| {
log.err("Wallpaper re-render failed: {}", .{err});
};
@ -165,6 +230,8 @@ fn pathsEqual(a: ?[]const u8, b: ?[]const u8) bool {
const std = @import("std");
const mem = std.mem;
const posix = std.posix;
const process = std.process;
const wayland = @import("wayland");
const river = wayland.client.river;
@ -175,6 +242,8 @@ const utils = @import("utils.zig");
const BufferPool = @import("BufferPool.zig");
const Config = @import("Config.zig");
const InputManager = @import("InputManager.zig");
const Output = @import("Output.zig");
const TagOverlay = @import("TagOverlay.zig");
const WallpaperImage = @import("WallpaperImage.zig");
const WindowManager = @import("WindowManager.zig");
const XkbBindings = @import("XkbBindings.zig");

View file

@ -17,66 +17,57 @@ input_device: ?*InputDevice = null,
/// reloaded.
should_manage: bool = true,
send_events_support: SendEventsModes = .{},
send_events_current: ?SendEventsModes = null,
/// The number of fingers supported for tap-to-click/drag.
/// If finger_count is 0, tap-to-click and drag are unsupported.
tap_support: u31 = 0,
tap_current: ?TapState = null,
tap_button_map_current: ?TapButtonMap = null,
drag_current: ?DragState = null,
drag_lock_current: ?DragLockState = null,
/// The number of fingers supported for three/four finger drag.
/// If finger_count is less than 3, three finger drag is unsupported.
three_finger_drag_support: u31 = 0,
three_finger_drag_current: ?ThreeFingerDragState = null,
/// A calibration matrix is supported if the supported argument is non-zero.
calibration_matrix_support: bool = false,
calibration_matrix_current: ?[]f32 = null,
accel_profiles_support: ?AccelProfiles = null,
accel_profile_current: ?AccelProfile = null,
accel_speed_current: ?f64 = null,
natural_scroll_support: bool = false,
natural_scroll_current: ?NaturalScrollState = null,
left_handed_support: bool = false,
left_handed_current: ?LeftHandedState = null,
click_method_support: ?ClickMethods = null,
click_method_current: ?ClickMethod = null,
clickfinger_button_map_current: ?ClickfingerButtonMap = null,
middle_emulation_support: bool = false,
middle_emulation_current: ?MiddleEmulationState = null,
scroll_method_support: ?ScrollMethods = null,
scroll_method_current: ?ScrollMethod = null,
/// Supported if scroll_methods.on_button_down is supported.
scroll_button_current: ?u32 = null,
/// Supported if scroll_methods.on_button_down is supported.
scroll_button_lock_current: ?ScrollButtonLockState = null,
dwt_support: bool = false,
dwt_current: ?DwtState = null,
dwtp_support: bool = false,
dwtp_current: ?DwtpState = null,
rotation_support: bool = false,
rotation_current: ?u32 = null,
capabilities: Capabilities = .{},
state: CurrentState = .{},
link: wl.list.Link,
pub const Capabilities = struct {
send_events: SendEventsModes = .{},
/// The number of fingers supported for tap-to-click/drag.
/// If finger_count is 0, tap-to-click and drag are unsupported.
tap: u31 = 0,
/// The number of fingers supported for three/four finger drag.
/// If finger_count is less than 3, three finger drag is unsupported.
three_finger_drag: u31 = 0,
/// A calibration matrix is supported if the supported argument is non-zero.
calibration_matrix: bool = false,
accel_profiles: ?AccelProfiles = null,
natural_scroll: bool = false,
left_handed: bool = false,
click_methods: ?ClickMethods = null,
middle_emulation: bool = false,
scroll_methods: ?ScrollMethods = null,
dwt: bool = false,
dwtp: bool = false,
rotation: bool = false,
};
pub const CurrentState = struct {
send_events: ?SendEventsModes = null,
tap: ?TapState = null,
tap_button_map: ?TapButtonMap = null,
drag: ?DragState = null,
drag_lock: ?DragLockState = null,
three_finger_drag: ?ThreeFingerDragState = null,
calibration_matrix: ?[]f32 = null,
accel_profile: ?AccelProfile = null,
accel_speed: ?f64 = null,
natural_scroll: ?NaturalScrollState = null,
left_handed: ?LeftHandedState = null,
click_method: ?ClickMethod = null,
clickfinger_button_map: ?ClickfingerButtonMap = null,
middle_emulation: ?MiddleEmulationState = null,
scroll_method: ?ScrollMethod = null,
/// Supported if scroll_methods.on_button_down is supported.
scroll_button: ?u32 = null,
/// Supported if scroll_methods.on_button_down is supported.
scroll_button_lock: ?ScrollButtonLockState = null,
dwt: ?DwtState = null,
dwtp: ?DwtpState = null,
rotation: ?u32 = null,
};
pub fn create(context: *Context, river_libinput_device_v1: *river.LibinputDeviceV1) !*LibinputDevice {
const libinput_device = try utils.gpa.create(LibinputDevice);
errdefer utils.gpa.destroy(libinput_device);
@ -123,39 +114,39 @@ fn riverLibinputDeviceV1Listener(river_libinput_device_v1: *river.LibinputDevice
}
}
},
.send_events_support => |ev| libinput_device.send_events_support = ev.modes,
.send_events_current => |ev| libinput_device.send_events_current = ev.mode,
.tap_support => |ev| libinput_device.tap_support = @intCast(ev.finger_count),
.tap_current => |ev| libinput_device.tap_current = ev.state,
.tap_button_map_current => |ev| libinput_device.tap_button_map_current = ev.button_map,
.drag_current => |ev| libinput_device.drag_current = ev.state,
.drag_lock_current => |ev| libinput_device.drag_lock_current = ev.state,
.three_finger_drag_support => |ev| libinput_device.three_finger_drag_support = @intCast(ev.finger_count),
.three_finger_drag_current => |ev| libinput_device.three_finger_drag_current = ev.state,
.calibration_matrix_support => |ev| libinput_device.calibration_matrix_support = ev.supported != 0,
.calibration_matrix_current => |ev| libinput_device.calibration_matrix_current = ev.matrix.slice(f32),
.accel_profiles_support => |ev| libinput_device.accel_profiles_support = ev.profiles,
.accel_profile_current => |ev| libinput_device.accel_profile_current = ev.profile,
.accel_speed_current => |ev| libinput_device.accel_speed_current = ev.speed.slice(f64)[0],
.natural_scroll_support => |ev| libinput_device.natural_scroll_support = ev.supported != 0,
.natural_scroll_current => |ev| libinput_device.natural_scroll_current = ev.state,
.left_handed_support => |ev| libinput_device.left_handed_support = ev.supported != 0,
.left_handed_current => |ev| libinput_device.left_handed_current = ev.state,
.click_method_support => |ev| libinput_device.click_method_support = ev.methods,
.click_method_current => |ev| libinput_device.click_method_current = ev.method,
.clickfinger_button_map_current => |ev| libinput_device.clickfinger_button_map_current = ev.button_map,
.middle_emulation_support => |ev| libinput_device.middle_emulation_support = ev.supported != 0,
.middle_emulation_current => |ev| libinput_device.middle_emulation_current = ev.state,
.scroll_method_support => |ev| libinput_device.scroll_method_support = ev.methods,
.scroll_method_current => |ev| libinput_device.scroll_method_current = ev.method,
.scroll_button_current => |ev| libinput_device.scroll_button_current = ev.button,
.scroll_button_lock_current => |ev| libinput_device.scroll_button_lock_current = ev.state,
.dwt_support => |ev| libinput_device.dwt_support = ev.supported != 0,
.dwt_current => |ev| libinput_device.dwt_current = ev.state,
.dwtp_support => |ev| libinput_device.dwtp_support = ev.supported != 0,
.dwtp_current => |ev| libinput_device.dwtp_current = ev.state,
.rotation_support => |ev| libinput_device.rotation_support = ev.supported != 0,
.rotation_current => |ev| libinput_device.rotation_current = ev.angle,
.send_events_support => |ev| libinput_device.capabilities.send_events = ev.modes,
.send_events_current => |ev| libinput_device.state.send_events = ev.mode,
.tap_support => |ev| libinput_device.capabilities.tap = @intCast(ev.finger_count),
.tap_current => |ev| libinput_device.state.tap = ev.state,
.tap_button_map_current => |ev| libinput_device.state.tap_button_map = ev.button_map,
.drag_current => |ev| libinput_device.state.drag = ev.state,
.drag_lock_current => |ev| libinput_device.state.drag_lock = ev.state,
.three_finger_drag_support => |ev| libinput_device.capabilities.three_finger_drag = @intCast(ev.finger_count),
.three_finger_drag_current => |ev| libinput_device.state.three_finger_drag = ev.state,
.calibration_matrix_support => |ev| libinput_device.capabilities.calibration_matrix = ev.supported != 0,
.calibration_matrix_current => |ev| libinput_device.state.calibration_matrix = ev.matrix.slice(f32),
.accel_profiles_support => |ev| libinput_device.capabilities.accel_profiles = ev.profiles,
.accel_profile_current => |ev| libinput_device.state.accel_profile = ev.profile,
.accel_speed_current => |ev| libinput_device.state.accel_speed = ev.speed.slice(f64)[0],
.natural_scroll_support => |ev| libinput_device.capabilities.natural_scroll = ev.supported != 0,
.natural_scroll_current => |ev| libinput_device.state.natural_scroll = ev.state,
.left_handed_support => |ev| libinput_device.capabilities.left_handed = ev.supported != 0,
.left_handed_current => |ev| libinput_device.state.left_handed = ev.state,
.click_method_support => |ev| libinput_device.capabilities.click_methods = ev.methods,
.click_method_current => |ev| libinput_device.state.click_method = ev.method,
.clickfinger_button_map_current => |ev| libinput_device.state.clickfinger_button_map = ev.button_map,
.middle_emulation_support => |ev| libinput_device.capabilities.middle_emulation = ev.supported != 0,
.middle_emulation_current => |ev| libinput_device.state.middle_emulation = ev.state,
.scroll_method_support => |ev| libinput_device.capabilities.scroll_methods = ev.methods,
.scroll_method_current => |ev| libinput_device.state.scroll_method = ev.method,
.scroll_button_current => |ev| libinput_device.state.scroll_button = ev.button,
.scroll_button_lock_current => |ev| libinput_device.state.scroll_button_lock = ev.state,
.dwt_support => |ev| libinput_device.capabilities.dwt = ev.supported != 0,
.dwt_current => |ev| libinput_device.state.dwt = ev.state,
.dwtp_support => |ev| libinput_device.capabilities.dwtp = ev.supported != 0,
.dwtp_current => |ev| libinput_device.state.dwtp = ev.state,
.rotation_support => |ev| libinput_device.capabilities.rotation = ev.supported != 0,
.rotation_current => |ev| libinput_device.state.rotation = ev.angle,
else => |ev| {
// We don't keep track of any default states right now
log.debug("unhandled event: {s}", .{@tagName(ev)});
@ -187,29 +178,29 @@ pub fn applyInputConfigs(libinput_device: *LibinputDevice) void {
log.debug("Applying input config to {s}", .{device_name});
if (@as(u32, @bitCast(libinput_device.send_events_support)) != 0) {
if (@as(u32, @bitCast(libinput_device.capabilities.send_events)) != 0) {
if (input_config.send_events) |val| {
const mode: SendEventsModes = @bitCast(@as(u32, @intCast(@intFromEnum(val))));
const mode_bits: u32 = @bitCast(mode);
const support_bits: u32 = @bitCast(libinput_device.send_events_support);
const support_bits: u32 = @bitCast(libinput_device.capabilities.send_events);
if (mode_bits == 0 or mode_bits & support_bits == mode_bits) {
applyResult(dev.setSendEvents(mode));
}
}
}
if (libinput_device.tap_support > 0) {
if (libinput_device.capabilities.tap > 0) {
if (input_config.tap) |val| applyResult(dev.setTap(val));
if (input_config.tap_button_map) |val| applyResult(dev.setTapButtonMap(val));
if (input_config.drag) |val| applyResult(dev.setDrag(val));
if (input_config.drag_lock) |val| applyResult(dev.setDragLock(val));
}
if (libinput_device.three_finger_drag_support >= 3) {
if (libinput_device.capabilities.three_finger_drag >= 3) {
if (input_config.three_finger_drag) |val| applyResult(dev.setThreeFingerDrag(val));
}
if (libinput_device.accel_profiles_support) |support| {
if (libinput_device.capabilities.accel_profiles) |support| {
if (input_config.accel_profile) |val| {
if (isSupported(AccelProfile, AccelProfiles, val, support)) {
applyResult(dev.setAccelProfile(val));
@ -226,15 +217,15 @@ pub fn applyInputConfigs(libinput_device: *LibinputDevice) void {
}
}
if (libinput_device.natural_scroll_support) {
if (libinput_device.capabilities.natural_scroll) {
if (input_config.natural_scroll) |val| applyResult(dev.setNaturalScroll(val));
}
if (libinput_device.left_handed_support) {
if (libinput_device.capabilities.left_handed) {
if (input_config.left_handed) |val| applyResult(dev.setLeftHanded(val));
}
if (libinput_device.click_method_support) |support| {
if (libinput_device.capabilities.click_methods) |support| {
if (input_config.click_method) |val| {
if (isSupported(ClickMethod, ClickMethods, val, support)) {
applyResult(dev.setClickMethod(val));
@ -243,11 +234,11 @@ pub fn applyInputConfigs(libinput_device: *LibinputDevice) void {
if (input_config.clickfinger_button_map) |val| applyResult(dev.setClickfingerButtonMap(val));
}
if (libinput_device.middle_emulation_support) {
if (libinput_device.capabilities.middle_emulation) {
if (input_config.middle_emulation) |val| applyResult(dev.setMiddleEmulation(val));
}
if (libinput_device.scroll_method_support) |support| {
if (libinput_device.capabilities.scroll_methods) |support| {
if (input_config.scroll_method) |val| {
if (isSupported(ScrollMethod, ScrollMethods, val, support)) {
applyResult(dev.setScrollMethod(val));
@ -259,15 +250,15 @@ pub fn applyInputConfigs(libinput_device: *LibinputDevice) void {
}
}
if (libinput_device.dwt_support) {
if (libinput_device.capabilities.dwt) {
if (input_config.dwt) |val| applyResult(dev.setDwt(val));
}
if (libinput_device.dwtp_support) {
if (libinput_device.capabilities.dwtp) {
if (input_config.dwtp) |val| applyResult(dev.setDwtp(val));
}
if (libinput_device.rotation_support) {
if (libinput_device.capabilities.rotation) {
if (input_config.rotation) |val| applyResult(dev.setRotation(val));
}
}

View file

@ -32,12 +32,15 @@ usable_height: u31 = 0,
wallpaper_render_scale: u31 = 0,
wallpaper_render_width: u31 = 0,
wallpaper_render_height: u31 = 0,
wl_surface: ?*wl.Surface = null,
layer_surface: ?*zwlr.LayerSurfaceV1 = null,
surfaces: ?struct {
wl_surface: *wl.Surface,
layer_surface: *zwlr.LayerSurfaceV1,
} = null,
// TODO: Make Bar a user option, can disable if they want
// This Output's bar
bar: ?Bar,
tag_overlay: ?TagOverlay,
/// Proportion of output width taken by the primary stack
primary_ratio: f32,
@ -89,16 +92,26 @@ pub fn create(context: *Context, river_output_v1: *river.OutputV1) !*Output {
var output = try utils.gpa.create(Output);
errdefer utils.gpa.destroy(output);
const bar = Bar.init(context, output) catch |e| blk: {
var bar = Bar.init(context, output) catch |e| blk: {
log.err("Failed to create a bar: {}", .{e});
break :blk null;
};
errdefer if (bar) |*b| b.deinit();
var tag_overlay = if (context.config.tag_overlay) |tag_overlay_config| blk: {
break :blk TagOverlay.init(context, output, tag_overlay_config.toTagOverlayOptions()) catch |e| {
log.err("Failed to create a tag overlay: {}", .{e});
break :blk null;
};
} else null;
errdefer if (tag_overlay) |*to| to.deinit();
output.* = .{
.context = context,
.river_output_v1 = river_output_v1,
.river_layer_shell_output_v1 = try context.river_layer_shell_v1.getOutput(river_output_v1),
.bar = bar,
.tag_overlay = tag_overlay,
.primary_count = context.config.primary_count,
.primary_ratio = context.config.primary_ratio,
.windows = undefined, // we will initialize this shortly
@ -119,6 +132,10 @@ pub fn destroy(output: *Output) void {
window.link.remove();
window.destroy();
}
if (output.bar) |*bar| bar.deinit();
if (output.tag_overlay) |*tag_overlay| tag_overlay.deinit();
output.tag_layout_overrides.deinit(utils.gpa);
output.deinitWallpaperLayerSurface();
output.river_output_v1.destroy();
@ -227,6 +244,8 @@ fn riverOutputListener(river_output_v1: *river.OutputV1, event: river.OutputV1.E
return;
};
}
// Tag overlay surfaces are created on-demand when tags change,
// so we don't init them here.
},
.dimensions => |ev| {
// Protocol guarantees that width and height are strictly greater than zero
@ -319,8 +338,8 @@ pub fn initWallpaperLayerSurface(output: *Output) !void {
return;
}
if (output.layer_surface) |_| {
// This output already has a layer surface, we can exit early
if (output.surfaces) |_| {
// This output already has a surface, we can exit early
return;
}
@ -329,6 +348,9 @@ pub fn initWallpaperLayerSurface(output: *Output) !void {
const wl_surface = try context.wl_compositor.createSurface();
errdefer wl_surface.destroy();
const layer_surface = try context.zwlr_layer_shell_v1.getLayerSurface(wl_surface, output.wl_output, .background, "beansprout-wallpaper");
errdefer layer_surface.destroy();
// We don't want our surface to have any input region (default is infinite)
const empty_region = try context.wl_compositor.createRegion();
defer empty_region.destroy();
@ -340,12 +362,13 @@ pub fn initWallpaperLayerSurface(output: *Output) !void {
defer opaque_region.destroy();
wl_surface.setOpaqueRegion(opaque_region);
const layer_surface = try context.zwlr_layer_shell_v1.getLayerSurface(wl_surface, output.wl_output, .background, "beansprout-wallpaper");
layer_surface.setExclusiveZone(-1);
layer_surface.setAnchor(.{ .top = true, .right = true, .bottom = true, .left = true });
output.wl_surface = wl_surface;
output.layer_surface = layer_surface;
output.surfaces = .{
.wl_surface = wl_surface,
.layer_surface = layer_surface,
};
context.buffer_pool.surface_count += 1;
layer_surface.setListener(*Output, wallpaperLayerSurfaceListener, output);
@ -353,16 +376,13 @@ pub fn initWallpaperLayerSurface(output: *Output) !void {
}
pub fn deinitWallpaperLayerSurface(output: *Output) void {
if (output.layer_surface) |layer_surface| {
layer_surface.destroy();
}
if (output.wl_surface) |wl_surface| {
wl_surface.destroy();
if (output.surfaces) |surfaces| {
surfaces.wl_surface.destroy();
surfaces.layer_surface.destroy();
output.context.buffer_pool.surface_count -= 1;
}
output.layer_surface = null;
output.wl_surface = null;
output.surfaces = null;
output.configured = false;
}
@ -379,10 +399,10 @@ fn wallpaperLayerSurfaceListener(layer_surface: *zwlr.LayerSurfaceV1, event: zwl
output.wallpaper_render_height == height and
output.scale == output.wallpaper_render_scale)
{
if (output.wl_surface) |wl_surface| {
wl_surface.commit();
if (output.surfaces) |surfaces| {
surfaces.wl_surface.commit();
} else {
log.warn("Output is marked as configured but is missing a layer_surface for the wallpaper", .{});
log.warn("Output is marked as configured but is missing its surfaces.", .{});
}
return;
}
@ -440,7 +460,7 @@ pub fn renderWallpaper(output: *Output) !void {
const image_stride = image.getStride();
const image_format = image.getFormat();
const buffer: *Buffer = try context.buffer_pool.nextBuffer(context.wl_shm, width * scale, height * scale);
const buffer = try context.buffer_pool.nextBuffer(context.wl_shm, width * scale, height * scale);
const pix = pixman.Image.createBitsNoClear(image_format, image_width, image_height, image_data, image_stride) orelse {
log.err("Failed to copy the wallpaper image for rendering", .{});
@ -485,7 +505,8 @@ pub fn renderWallpaper(output: *Output) !void {
log.info("render: {}x{} (scaled from {}x{})", .{ width * scale, height * scale, image_width, image_height });
// Attach the buffer to the surface
const wl_surface = output.wl_surface.?;
const surfaces = output.surfaces orelse return error.NoSurfaces;
const wl_surface = surfaces.wl_surface;
wl_surface.setBufferScale(scale);
wl_surface.attach(buffer.wl_buffer, 0, 0);
wl_surface.damageBuffer(0, 0, width * scale, height * scale);
@ -548,6 +569,33 @@ pub fn manage(output: *Output) void {
}
output.tags = new_tags;
// Show tag overlay and arm the hide timer
if (output.tag_overlay) |*tag_overlay| {
if (tag_overlay.surfaces) |_| {
// The overlay is arleady visible, but we still need to re-render
tag_overlay.render() catch |err| {
log.err("tag_overlay render failed: {}", .{err});
};
} else {
// Create surface; the configure handler renders for us
tag_overlay.initSurface() catch |err| {
log.err("tag_overlay initSurface failed: {}", .{err});
};
}
if (output.context.tag_overlay_timer_fd) |fd| {
const timeout_ms: isize = tag_overlay.options.timeout;
posix.timerfd_settime(fd, .{}, &.{
.it_interval = .{ .sec = 0, .nsec = 0 },
.it_value = .{
.sec = @divFloor(timeout_ms, 1000),
.nsec = @mod(timeout_ms, 1000) * std.time.ns_per_ms,
},
}, null) catch |err| {
log.err("Failed to arm tag overlay timer: {}", .{err});
};
}
}
}
// Calculate layout before managing windows
@ -696,6 +744,7 @@ pub fn occupiedTags(output: *Output) u32 {
const std = @import("std");
const assert = std.debug.assert;
const mem = std.mem;
const posix = std.posix;
const DoublyLinkedList = std.DoublyLinkedList;
const wayland = @import("wayland");
@ -708,6 +757,7 @@ const utils = @import("utils.zig");
const Bar = @import("Bar.zig");
const Buffer = @import("Buffer.zig");
const Context = @import("Context.zig");
const TagOverlay = @import("TagOverlay.zig");
const Window = @import("Window.zig");
const log = std.log.scoped(.Output);

262
src/TagOverlay.zig Normal file
View file

@ -0,0 +1,262 @@
// SPDX-FileCopyrightText: 2026 Ben Buhse <me@benbuhse.email>
//
// SPDX-License-Identifier: GPL-3.0-only
//! TagOverlay is a Zig clone of Leon Plickat's river-tag-overlay but built for beansprout
//! Find river-tag-overlay at: https://git.sr.ht/~leon_plickat/river-tag-overlay/
const TagOverlay = @This();
context: *Context,
options: Options,
output: *Output,
// Overlay geometry
width: u31 = 0,
height: u31 = 0,
scale: u31 = 1,
rows: u31,
surfaces: ?struct {
wl_surface: *wl.Surface,
layer_surface: *zwlr.LayerSurfaceV1,
} = null,
configured: bool = false,
pub const Options = struct {
/// Width of the widget border in pixels
border_width: u8,
/// Number of displayed tags (1-32)
tag_amount: u6,
/// Amount of tags per row (1-32)
tags_per_row: u6,
/// Size of tag squares in pixels
square_size: u8,
/// Padding around the tag occupied indicator in pixels
square_inner_padding: u8,
/// Padding around tag squares in pixels
square_padding: u8,
/// Border width of the tag squares in pixels
square_border_width: u8,
/// Widget background color
background_color: pixman.Color,
/// Widget border color
border_color: pixman.Color,
/// Background color of active tag squares
square_active_background_color: pixman.Color,
/// Border color of active tag squares
square_active_border_color: pixman.Color,
/// Occupied indicator color of active tag squares
square_active_occupied_color: pixman.Color,
/// Background color of inactive tag squares
square_inactive_background_color: pixman.Color,
/// Border color of inactive tag squares
square_inactive_border_color: pixman.Color,
/// Occupied indicator color of inactive tag squares
square_inactive_occupied_color: pixman.Color,
// XXX: We do not support urgent windows right now
// /// Background color of urgent tag squares
// square_urgent_background_color: pixman.Color,
// /// Border color of urgent tag squares
// square_urgent_border_color: pixman.Color,
// /// Occupied indicator color of urgent tag squares
// square_urgent_occupied_color: pixman.Color,
/// Directional anchors top, right bottom, left; true for on, false for off
anchors: zwlr.LayerSurfaceV1.Anchor,
/// Directional margins top, right, bottom, left, in pixels
margins: struct { top: i32 = 0, right: i32 = 0, bottom: i32 = 0, left: i32 = 0 } = .{},
/// Duration of widget display in milliseconds
timeout: u32,
};
pub fn init(context: *Context, output: *Output, options: Options) !TagOverlay {
const rows = try math.divCeil(u31, options.tag_amount, options.tags_per_row);
return .{
.context = context,
.options = options,
.rows = rows,
.output = output,
};
}
pub fn initSurface(tag_overlay: *TagOverlay) !void {
if (tag_overlay.surfaces) |_| {
// This tag overlay already has a layer surface, we can exit early
return;
}
const context = tag_overlay.context;
const options = tag_overlay.options;
const wl_surface = try context.wl_compositor.createSurface();
errdefer wl_surface.destroy();
const layer_surface = try context
.zwlr_layer_shell_v1
.getLayerSurface(wl_surface, tag_overlay.output.wl_output, .overlay, "beansprout-tag-overlay");
errdefer layer_surface.destroy();
layer_surface.setExclusiveZone(-1);
// We don't want our surface to have any input region (default is infinite)
const empty_region = try context.wl_compositor.createRegion();
defer empty_region.destroy();
wl_surface.setInputRegion(empty_region);
const surface_width: u31 = @as(u31, @min(options.tag_amount, options.tags_per_row)) * (@as(u31, options.square_size) + options.square_padding) + options.square_padding + 2 * @as(u31, options.border_width);
const surface_height: u31 = @as(u31, tag_overlay.rows) * (@as(u31, options.square_size) + options.square_padding) + options.square_padding + 2 * @as(u31, options.border_width);
layer_surface.setSize(surface_width, surface_height);
layer_surface.setAnchor(options.anchors);
layer_surface.setMargin(options.margins.top, options.margins.right, options.margins.bottom, options.margins.left);
tag_overlay.surfaces = .{ .wl_surface = wl_surface, .layer_surface = layer_surface };
context.buffer_pool.surface_count += 1;
layer_surface.setListener(*TagOverlay, layerSurfaceListener, tag_overlay);
wl_surface.commit();
}
/// Destroy surfaces only (used to hide the overlay). The TagOverlay struct stays valid
/// and can be re-shown by calling initSurface() again.
pub fn deinitSurfaces(tag_overlay: *TagOverlay) void {
tag_overlay.configured = false;
if (tag_overlay.surfaces) |surfaces| {
surfaces.layer_surface.destroy();
surfaces.wl_surface.destroy();
tag_overlay.context.buffer_pool.surface_count -= 1;
tag_overlay.surfaces = null;
}
}
pub fn deinit(tag_overlay: *TagOverlay) void {
tag_overlay.deinitSurfaces();
}
pub fn layerSurfaceListener(
layer_surface: *zwlr.LayerSurfaceV1,
event: zwlr.LayerSurfaceV1.Event,
tag_overlay: *TagOverlay,
) void {
assert(tag_overlay.surfaces.?.layer_surface == layer_surface);
switch (event) {
.configure => |ev| {
layer_surface.ackConfigure(ev.serial);
const width: u31 = @intCast(ev.width);
const height: u31 = @intCast(ev.height);
if (tag_overlay.configured and
tag_overlay.width == width and
tag_overlay.height == height and
tag_overlay.output.scale == tag_overlay.scale)
{
tag_overlay.surfaces.?.wl_surface.commit();
return;
}
log.debug("Configuring tag_overlay surface with width {} and height {}", .{ width, height });
tag_overlay.width = width;
tag_overlay.height = height;
// Full surface should be opaque
const opaque_region: *wl.Region = tag_overlay.context.wl_compositor.createRegion() catch |e| {
log.err("Failed to create opaque region for tag_overlay: {}", .{e});
return;
};
defer opaque_region.destroy();
opaque_region.add(0, 0, tag_overlay.width, tag_overlay.height);
tag_overlay.surfaces.?.wl_surface.setOpaqueRegion(opaque_region);
tag_overlay.configured = true;
tag_overlay.render() catch |err| {
log.err("tag_overlay render failed: {}", .{err});
};
},
.closed => {
tag_overlay.deinit();
},
}
}
pub fn render(tag_overlay: *TagOverlay) !void {
const context = tag_overlay.context;
const options = tag_overlay.options;
// Scaled width/height
const scale = tag_overlay.output.scale;
const render_width = tag_overlay.width * scale;
const render_height = tag_overlay.height * scale;
// Don't have anything to render
if (render_width == 0 or render_height == 0) {
return;
}
const buffer = try context.buffer_pool.nextBuffer(context.wl_shm, render_width, render_height);
buffer.borderedRectangle(0, 0, tag_overlay.width, tag_overlay.height, options.border_width, scale, &options.background_color, &options.border_color);
const focused_tags = tag_overlay.output.tags;
const occupied_tags = tag_overlay.output.occupiedTags();
for (0..tag_overlay.rows) |row| {
for (0..options.tags_per_row) |tag| {
const current_tag = tag + row * options.tags_per_row;
if (current_tag >= options.tag_amount) break;
const bg_color, const border_color, const occupied_color =
// Check whether this tag is focused or not to decide colors
if (focused_tags & (@as(u32, 1) << @intCast(current_tag)) != 0)
.{ &options.square_active_background_color, &options.square_active_border_color, &options.square_active_occupied_color }
else
.{ &options.square_inactive_background_color, &options.square_inactive_border_color, &options.square_inactive_occupied_color };
const x = options.border_width + @as(u31, @intCast((tag + 1) * options.square_padding)) + @as(u31, @intCast(tag * options.square_size));
const y = options.border_width + @as(u31, @intCast((row + 1) * options.square_padding)) + @as(u31, @intCast(row * options.square_size));
buffer.borderedRectangle(x, y, options.square_size, options.square_size, options.square_border_width, scale, bg_color, border_color);
if (occupied_tags & (@as(u32, 1) << @intCast(current_tag)) != 0) {
buffer.borderedRectangle(
x + options.square_inner_padding,
y + options.square_inner_padding,
options.square_size - 2 * options.square_inner_padding,
options.square_size - 2 * options.square_inner_padding,
options.square_border_width,
scale,
occupied_color,
border_color,
);
}
}
}
// Finally, attach the buffer to the surface
const surfaces = tag_overlay.surfaces orelse return error.NoSurfaces;
const wl_surface = surfaces.wl_surface;
wl_surface.setBufferScale(scale);
wl_surface.attach(buffer.wl_buffer, 0, 0);
wl_surface.damageBuffer(0, 0, render_width, render_height);
wl_surface.commit();
}
const std = @import("std");
const assert = std.debug.assert;
const math = std.math;
const wayland = @import("wayland");
const wl = wayland.client.wl;
const zwlr = wayland.client.zwlr;
const fcft = @import("fcft");
const pixman = @import("pixman");
const utils = @import("utils.zig");
const Buffer = @import("Buffer.zig");
const Context = @import("Context.zig");
const Output = @import("Output.zig");
const log = std.log.scoped(.TagOverlay);

View file

@ -115,12 +115,13 @@ fn run(wl_display: *wl.Display, context: *Context) !void {
posix.sigprocmask(posix.SIG.BLOCK, &mask, null);
const sig_fd = try posix.signalfd(-1, &mask, 0);
const sig_fd = try posix.signalfd(-1, &mask, @as(u32, @bitCast(posix.O{ .CLOEXEC = true })));
const poll_wayland = 0;
const poll_sig = 1;
const poll_tag_overlay_timer = 2;
var pollfds: [2]posix.pollfd = undefined;
var pollfds: [3]posix.pollfd = undefined;
pollfds[poll_wayland] = .{
.fd = wl_display.getFd(),
@ -132,6 +133,12 @@ fn run(wl_display: *wl.Display, context: *Context) !void {
.events = posix.POLL.IN,
.revents = 0,
};
pollfds[poll_tag_overlay_timer] = .{
// poll ignores negative fds
.fd = context.tag_overlay_timer_fd orelse -1,
.events = posix.POLL.IN,
.revents = 0,
};
while (true) {
const errno = wl_display.flush();
@ -165,22 +172,46 @@ fn run(wl_display: *wl.Display, context: *Context) !void {
// Handle fds that became ready
if (pollfds[poll_wayland].revents & posix.POLL.HUP != 0) {
@branchHint(.cold);
log.info("Disconnected by compositor", .{});
break;
}
if (pollfds[poll_wayland].revents & posix.POLL.IN != 0) {
if (wl_display.dispatch() != .SUCCESS) {
@branchHint(.cold);
fatal("Wayland display dispatch failed", .{});
}
// Re-sync in case a config reload created or destroyed the timerfd
pollfds[poll_tag_overlay_timer].fd = context.tag_overlay_timer_fd orelse -1;
}
if (pollfds[poll_sig].revents & posix.POLL.HUP != 0) {
@branchHint(.cold);
fatal("Signal fd hung up", .{});
}
if (pollfds[poll_sig].revents & posix.POLL.IN != 0) {
@branchHint(.cold);
log.info("Exiting beansprout", .{});
break;
}
if (pollfds[poll_tag_overlay_timer].revents & posix.POLL.HUP != 0) {
@branchHint(.cold);
fatal("Tag overlay timer fd hung up", .{});
}
if (pollfds[poll_tag_overlay_timer].revents & posix.POLL.IN != 0) {
// Read to consume the timer event
var buf: [8]u8 = undefined;
_ = posix.read(context.tag_overlay_timer_fd.?, &buf) catch {};
// Hide all tag overlays by destroying their surfaces
var it = context.wm.outputs.iterator(.forward);
while (it.next()) |output| {
if (output.tag_overlay) |*tag_overlay| {
tag_overlay.deinitSurfaces();
}
}
}
}
}

View file

@ -26,7 +26,7 @@ pub fn parseRgba(s: []const u8) !RiverColor {
}
const bytes: [4]u8 = @as([4]u8, @bitCast(color));
return parseRgbaHelper(bytes);
return bytesToRiverColor(bytes);
}
/// Parse a color in the format 0xRRGGBB or 0xRRGGBBAA and convert it to
@ -43,10 +43,50 @@ pub fn parseRgbaComptime(comptime s: []const u8) RiverColor {
}
const bytes = @as([4]u8, @bitCast(color));
return parseRgbaHelper(bytes);
return bytesToRiverColor(bytes);
}
fn parseRgbaHelper(bytes: [4]u8) RiverColor {
/// Parse a color in the format 0xRRGGBB or 0xRRGGBBAA and convert it to
/// 16-bit color values.
pub fn parseRgbaPixman(s: []const u8) !pixman.Color {
if (s.len != 8 and s.len != 10) return error.InvalidRgba;
if (s[0] != '0' or s[1] != 'x') return error.InvalidRgba;
var color = try fmt.parseUnsigned(u32, s[2..], 16);
if (s.len == 8) {
color <<= 8;
color |= 0xff;
}
return bytesToPixmanColor(@bitCast(color));
}
/// Parse a color in the format 0xRRGGBB or 0xRRGGBBAA and convert it to
/// 16-bit color values at comptime.
pub fn parseRgbaPixmanComptime(comptime s: []const u8) pixman.Color {
@setEvalBranchQuota(2000);
if (s.len != 8 and s.len != 10) @compileError("Invalid RGBA");
if (s[0] != '0' or s[1] != 'x') @compileError("Invalid RGBA");
comptime var color = try fmt.parseUnsigned(u32, s[2..], 16);
if (s.len == 8) {
color <<= 8;
color |= 0xff;
}
return bytesToPixmanColor(@bitCast(color));
}
fn bytesToPixmanColor(bytes: [4]u8) pixman.Color {
return .{
.red = @as(u16, bytes[3]) * 0x101,
.green = @as(u16, bytes[2]) * 0x101,
.blue = @as(u16, bytes[1]) * 0x101,
.alpha = @as(u16, bytes[0]) * 0x101,
};
}
fn bytesToRiverColor(bytes: [4]u8) RiverColor {
const r: u32 = bytes[3];
const g: u32 = bytes[2];
const b: u32 = bytes[1];
@ -137,6 +177,7 @@ const mem = std.mem;
const wayland = @import("wayland");
const river = wayland.client.river;
const pixman = @import("pixman");
const utils = @import("utils.zig");
@ -191,6 +232,44 @@ test "parseRgbaComptime with alpha" {
try testing.expectEqual(@as(u32, 0xff << 24), color.alpha);
}
test "parseRgbaPixman 0xRRGGBB" {
const color = try parseRgbaPixman("0x89b4fa");
try testing.expectEqual(@as(u16, 0x8989), color.red);
try testing.expectEqual(@as(u16, 0xb4b4), color.green);
try testing.expectEqual(@as(u16, 0xfafa), color.blue);
try testing.expectEqual(@as(u16, 0xffff), color.alpha);
}
test "parseRgbaPixman 0xRRGGBBAA" {
const color = try parseRgbaPixman("0x1e1e2e80");
try testing.expectEqual(@as(u16, 0x1e1e), color.red);
try testing.expectEqual(@as(u16, 0x1e1e), color.green);
try testing.expectEqual(@as(u16, 0x2e2e), color.blue);
try testing.expectEqual(@as(u16, 0x8080), color.alpha);
}
test "parseRgbaPixman invalid" {
try testing.expectError(error.InvalidRgba, parseRgbaPixman("0x123"));
try testing.expectError(error.InvalidRgba, parseRgbaPixman("xx123456"));
try testing.expectError(error.InvalidCharacter, parseRgbaPixman("0xGGGGGG"));
}
test "parseRgbaPixmanComptime" {
const color = parseRgbaPixmanComptime("0x89b4fa");
try testing.expectEqual(@as(u16, 0x8989), color.red);
try testing.expectEqual(@as(u16, 0xb4b4), color.green);
try testing.expectEqual(@as(u16, 0xfafa), color.blue);
try testing.expectEqual(@as(u16, 0xffff), color.alpha);
}
test "parseRgbaPixmanComptime with alpha" {
const color = parseRgbaPixmanComptime("0x1e1e2e80");
try testing.expectEqual(@as(u16, 0x1e1e), color.red);
try testing.expectEqual(@as(u16, 0x1e1e), color.green);
try testing.expectEqual(@as(u16, 0x2e2e), color.blue);
try testing.expectEqual(@as(u16, 0x8080), color.alpha);
}
test "stripQuotes removes surrounding quotes" {
try testing.expectEqualStrings("hello", stripQuotes("\"hello\""));
}