Merge pull request 'Implement window rules' (#14) from window-rules into main

Reviewed-on: https://codeberg.org/bwbuhse/beansprout/pulls/14
This commit is contained in:
Ben Buhse 2026-02-18 23:22:56 +01:00
commit 507b16521d
11 changed files with 674 additions and 120 deletions

View file

@ -71,6 +71,57 @@ borders {
Colors are specified in `0xRRGGBB` or `0xRRGGBBAA` hex format.
## Window Rules
Window rules let you set certain properties on windows when they first appear,
based on their `app_id` and/or `title`. Crucially, you can override them any time after
that, it's only when the window is first created. Rules are placed inside a `window_rules` block:
```kdl
window_rules {
// Float Firefox picture-in-picture windows
float app_id="firefox" title="Picture-in-Picture"
// Float any window with "Preferences" in the title
float title="*Preferences*"
// Keep mpv windows tiled
no_float app_id="mpv"
// Send Slack to tag 3 (1<<2 = 0x0004)
tags 0x0004 app_id="Slack"
}
```
### Rule Types
| Rule | Argument | Description |
|------------|------------------|------------------------------------------|
| `float` | | Make matching windows float |
| `no_float` | | Keep matching windows tiled |
| `tags` | u32 (bitmask) | Assign matching windows to specific tags |
### Matching
Each rule can have an `app_id=` and/or `title=` property. Both support glob patterns:
| Pattern | Matches |
|---------------|--------------------------------|
| `"foo"` | Exact match only |
| `"foo*"` | Starts with "foo" |
| `"*foo"` | Ends with "foo" |
| `"*foo*"` | Contains "foo" |
| `"*"` | Everything |
A rule with no `app_id=` or `title=` property matches all windows. If both are
specified, both must match.
### Evaluation
Rules are evaluated top-to-bottom. Each matching rule applies only the properties it
specifies, so multiple rules can contribute different properties to the same window.
For the same property, later rules override earlier ones.
Rules are applied once during window initialization. Title changes after initialization
do not re-trigger rules.
## Bar
The bar is an optional widget that shows the time on your screen. Right now, that's it.

View file

@ -2,7 +2,8 @@
These are in rough order of my priority, though no promises I do them in this order.
- [ ] Support window rules (float/tags/SSD by app-id/title)
- [ ] Move orphan handling out of .output and .seat events; into manage()
- [ ] Add focused window title to bar
- [ ] Support overriding config location
- [ ] Support configuring primary vs secondary stack side
- [ ] Support switch handling (e.g. lid close)
@ -31,3 +32,5 @@ These are in rough order of my priority, though no promises I do them in this or
- [x] Add options to the tag overlay
- [x] Add options to the bar
- [x] Make a Rect struct to combine x, y, width, and height
- [x] Support window rules (float/tags by app-id/title)
- [x] Fix resizing size when you pop a window out, basically, it start with its current size but then when you try resize it goes to 75%

View file

@ -18,11 +18,11 @@ borders {
color_focused "0x89b4fa"
color_unfocused "0x1e1e2e"
}
// Bar widget - shows the time
// Bar widget; shows the time
bar {
position top
}
// Tag overlay widget - shown briefly when switching tags
// Tag overlay widget; shown briefly when switching tags
// Remove this block to disable the overlay entirely
tag_overlay {
tag_amount 10
@ -35,6 +35,18 @@ tag_overlay {
square_inactive_border_color "0x6c7086"
square_inactive_occupied_color "0xcdd6f4"
}
// Window rules; applied once when a window first appears
// Rules are evaluated top-to-bottom; later rules override earlier ones
window_rules {
// Float Firefox picture-in-picture windows
float app_id="firefox" title="Picture-in-Picture"
// Float any window with "Preferences" in the title
float title="*Preferences*"
// Keep mpv windows tiled
no_float app_id="mpv"
// Send Slack to tag 3 (1<<2 = 0x0004)
tags 0x0004 app_id="Slack"
}
keybinds {
// Swap a window
spawn Mod4 T foot

View file

@ -42,10 +42,13 @@ tag_binds: std.ArrayList(Keybind) = .{},
keybinds: keybind.Map = .{},
pointer_binds: std.ArrayList(PointerBind) = .{},
input_configs: std.ArrayList(InputConfig) = .{},
window_rules: std.ArrayList(WindowRule) = .{},
// Re-exports
pub const Keybind = keybind.Keybind;
pub const PointerBind = pointer_bind.PointerBind;
pub const WindowRule = window_rule.Rule;
pub const WindowRuleAction = window_rule.Action;
pub const AttachMode = enum {
top,
@ -66,6 +69,7 @@ const NodeName = enum {
keybinds,
pointer_binds,
tag_overlay,
window_rules,
};
pub fn create() !*Config {
@ -103,6 +107,11 @@ pub fn create() !*Config {
if (ic.name) |name| utils.gpa.free(name);
}
config.input_configs.clearAndFree(utils.gpa);
for (config.window_rules.items) |rule| {
if (rule.app_id_glob) |app_id_glob| utils.gpa.free(app_id_glob);
if (rule.title_glob) |title_glob| utils.gpa.free(title_glob);
}
config.window_rules.clearAndFree(utils.gpa);
if (config.bar_config) |bc| {
if (bc.fonts) |fonts| utils.gpa.free(fonts);
}
@ -133,6 +142,11 @@ pub fn destroy(config: *Config) void {
if (ic.name) |name| utils.gpa.free(name);
}
config.input_configs.deinit(utils.gpa);
for (config.window_rules.items) |rule| {
if (rule.app_id_glob) |app_id_glob| utils.gpa.free(app_id_glob);
if (rule.title_glob) |title_glob| utils.gpa.free(title_glob);
}
config.window_rules.deinit(utils.gpa);
if (config.bar_config) |bc| {
if (bc.fonts) |fonts| utils.gpa.free(fonts);
}
@ -210,7 +224,7 @@ fn load(config: *Config, reader: *Io.Reader) !void {
if (helpers.boolFromKdlStr(focus_follows_pointer_str)) |focus_follows_pointer| {
config.focus_follows_pointer = focus_follows_pointer;
logDebugSettingNode(name, focus_follows_pointer_str);
} else {
} else |_| {
logWarnInvalidNodeArg(name, focus_follows_pointer_str);
continue;
}
@ -220,7 +234,7 @@ fn load(config: *Config, reader: *Io.Reader) !void {
if (helpers.boolFromKdlStr(pointer_warp_on_focus_change_str)) |pointer_warp_on_focus_change| {
config.pointer_warp_on_focus_change = pointer_warp_on_focus_change;
logDebugSettingNode(name, pointer_warp_on_focus_change_str);
} else {
} else |_| {
logWarnInvalidNodeArg(name, pointer_warp_on_focus_change_str);
continue;
}
@ -238,8 +252,6 @@ fn load(config: *Config, reader: *Io.Reader) !void {
};
logDebugSettingNode(name, path_str);
},
.bar => next_child_block = .bar,
.borders => next_child_block = .borders,
.input => {
pending_input_name = if (node.prop(&parser, "name")) |n|
try utils.gpa.dupe(u8, utils.stripQuotes(n))
@ -247,15 +259,13 @@ fn load(config: *Config, reader: *Io.Reader) !void {
null;
next_child_block = .input;
},
.keybinds => {
next_child_block = .keybinds;
},
.pointer_binds => {
next_child_block = .pointer_binds;
},
.tag_overlay => {
next_child_block = .tag_overlay;
},
inline .bar,
.borders,
.keybinds,
.pointer_binds,
.tag_overlay,
.window_rules,
=> |n| next_child_block = n,
}
} else {
helpers.logWarnInvalidNode(node.name);
@ -273,6 +283,7 @@ fn load(config: *Config, reader: *Io.Reader) !void {
pending_input_name = null; // ownership transferred
},
.tag_overlay => try TagOverlayConfig.load(config, &parser, hostname),
.window_rules => try window_rule.load(config, &parser, hostname),
else => {
// Nothing else should ever be marked as a next_child_block
unreachable;
@ -322,6 +333,7 @@ const border = @import("config/border.zig");
const helpers = @import("config/helpers.zig");
const keybind = @import("config/keybind.zig");
const pointer_bind = @import("config/pointer_bind.zig");
const window_rule = @import("config/window_rule.zig");
const BarConfig = @import("config/BarConfig.zig");
const InputConfig = @import("config/InputConfig.zig");
const TagOverlayConfig = @import("config/TagOverlayConfig.zig");

View file

@ -576,8 +576,11 @@ pub fn manage(output: *Output) void {
}
}
// Calculate layout before managing windows
output.calculatePrimaryStackLayout();
// Calculate layout before managing windows, but only if output dimensions are initialized
if (output.usable_geometry.width > 0 and output.usable_geometry.height > 0) {
output.calculateLayout();
}
var it = output.windows.iterator(.forward);
while (it.next()) |window| {
window.manage();
@ -611,12 +614,17 @@ pub fn render(output: *Output) void {
/// Calculate primary/stack layout positions for all windows.
/// - Single window: maximized
/// - Multiple windows: stack (45% left, vertically tiled), primary (55% right)
fn calculatePrimaryStackLayout(output: *Output) void {
fn calculateLayout(output: *Output) void {
// Shouldn't be called if height/width are not positive
assert(output.geometry.width > 0 and output.geometry.height > 0);
// Get a list of active windows
var active_list: DoublyLinkedList = .{};
var active_count: u31 = 0;
var it = output.windows.iterator(.forward);
while (it.next()) |window| {
// Initialize new windows before checking tags/float so that
// window rules are reflected in the first frame's layout.
window.initialize();
if (output.tags & window.tags != 0x0000) {
// Floating windows should be shown but not included in this layout generation
const will_float = window.pending_manage.floating orelse window.floating;
@ -711,8 +719,14 @@ fn calculatePrimaryStackLayout(output: *Output) void {
}
// Make space for borders
if (window.pending_manage.dimensions.?.height > 2 * border_width and
window.pending_manage.dimensions.?.width > 2 * border_width)
{
window.pending_manage.dimensions.?.height -= 2 * border_width;
window.pending_manage.dimensions.?.width -= 2 * border_width;
} else {
log.warn("Can't add borders to some window; {s}'s dimensions are too small.", .{output.name orelse "some output"});
}
window.pending_render.position.?.x += border_width;
window.pending_render.position.?.y += border_width;
}

View file

@ -9,6 +9,10 @@ context: *Context,
river_window_v1: *river.WindowV1,
river_node_v1: *river.NodeV1,
app_id: ?[]const u8 = null,
title: ?[]const u8 = null,
parent: ?*river.WindowV1 = null,
rect: utils.Rect = .{},
fullscreen: bool = false,
@ -19,6 +23,7 @@ output: ?*Output,
floating: bool = false,
floating_rect: utils.Rect = .{},
dimensions_hint: DimensionsHint = .{},
initialized: bool = false,
@ -61,6 +66,29 @@ pub const PendingRender = struct {
show: ?bool = null,
};
pub const DimensionsHint = struct {
min_width: u31 = 0,
min_height: u31 = 0,
max_width: u31 = 0,
max_height: u31 = 0,
fn clampWidth(hint: DimensionsHint, width: u31) u31 {
return math.clamp(
width,
if (hint.min_width != 0) hint.min_width else 1,
if (hint.max_width != 0) hint.max_width else math.maxInt(u31),
);
}
fn clampHeight(hint: DimensionsHint, height: u31) u31 {
return math.clamp(
height,
if (hint.min_height != 0) hint.min_height else 1,
if (hint.max_height != 0) hint.max_height else math.maxInt(u31),
);
}
};
pub fn create(context: *Context, river_window_v1: *river.WindowV1, output: ?*Output) !*Window {
var window = try utils.gpa.create(Window);
errdefer utils.gpa.destroy(window);
@ -80,8 +108,11 @@ pub fn create(context: *Context, river_window_v1: *river.WindowV1, output: ?*Out
}
pub fn destroy(window: *Window) void {
window.river_window_v1.destroy();
if (window.app_id) |app_id| utils.gpa.free(app_id);
if (window.title) |title| utils.gpa.free(title);
window.river_node_v1.destroy();
window.river_window_v1.destroy();
utils.gpa.destroy(window);
}
@ -111,8 +142,8 @@ fn windowListener(river_window_v1: *river.WindowV1, event: river.WindowV1.Event,
seat.pending_manage.pointer_resize_request = null;
}
}
// If there's no output, we don't really care about focus and can skip this event
const output = if (window.output) |output| output else return;
if (window.output) |output| {
// Get a new window for the wm to focus
var it = window.context.wm.seats.iterator(.forward);
while (it.next()) |seat| {
if (seat.focused_window == window) {
@ -130,6 +161,7 @@ fn windowListener(river_window_v1: *river.WindowV1, event: river.WindowV1.Event,
}
}
}
}
window.link.remove();
window.destroy();
},
@ -138,8 +170,39 @@ fn windowListener(river_window_v1: *river.WindowV1, event: river.WindowV1.Event,
assert(ev.width > 0 and ev.height > 0);
window.pending_manage.dimensions = .{ .width = @intCast(ev.width), .height = @intCast(ev.height) };
},
.dimensions_hint => {
// TODO: Maybe could use this for floating windows
.dimensions_hint => |ev| {
window.dimensions_hint = .{
.min_width = @intCast(ev.min_width),
.min_height = @intCast(ev.min_height),
.max_width = @intCast(ev.max_width),
.max_height = @intCast(ev.max_height),
};
},
.app_id => |ev| {
if (window.app_id) |app_id| utils.gpa.free(app_id);
window.app_id = if (ev.app_id) |aid|
utils.gpa.dupe(u8, std.mem.span(aid)) catch @panic("Out of memory")
else
null;
},
.title => |ev| {
if (window.title) |title| utils.gpa.free(title);
window.title = if (ev.title) |t|
utils.gpa.dupe(u8, std.mem.span(t)) catch @panic("Out of memory")
else
null;
},
.parent => |ev| {
const parent = ev.parent orelse return;
window.parent = parent;
// Make window float on top of its parent
window.pending_manage.floating = true;
const parent_window: *Window = @ptrCast(@alignCast(parent.getUserData() orelse return));
window.pending_render.position = .{
.x = parent_window.rect.x + @divTrunc(parent_window.rect.width, 2),
.y = parent_window.rect.y + @divTrunc(parent_window.rect.height, 2),
};
},
else => |ev| {
log.debug("unhandled event: {s}", .{@tagName(ev)});
@ -147,25 +210,38 @@ fn windowListener(river_window_v1: *river.WindowV1, event: river.WindowV1.Event,
}
}
pub fn manage(window: *Window) void {
const river_window_v1 = window.river_window_v1;
if (!window.initialized) {
// Only happens once per Window
/// Apply one-time initialization for newly created windows.
/// Called before calculatePrimaryStackLayout() so that tag and float
/// rules are reflected in the first frame's layout.
pub fn initialize(window: *Window) void {
if (window.initialized) {
@branchHint(.unlikely);
return;
}
window.initialized = true;
const river_window_v1 = window.river_window_v1;
// TODO: We might want to think about paying attention to the decoration_hint event
// If we do, this would need to move, I think?
river_window_v1.useSsd();
river_window_v1.setCapabilities(.{ .fullscreen = true, .maximize = true });
river_window_v1.setTiled(.{ .top = true, .bottom = true, .left = true, .right = true });
const res = window.applyRules();
if (res.tags) |tags| window.tags = tags;
if (res.float) |should_float|
window.pending_manage.floating = should_float;
}
pub fn manage(window: *Window) void {
// Updating state since the last manage event
defer window.pending_manage = .{};
const pending_manage = window.pending_manage;
const river_window_v1 = window.river_window_v1;
// Floating status
var became_floating = false;
if (pending_manage.floating) |floating| blk: {
// This needs to be before proposing the new dimensions since we want to save the current ones!
// Skip the rest of the block if floating matches what is already set
@ -173,14 +249,16 @@ pub fn manage(window: *Window) void {
window.floating = floating;
if (floating) {
became_floating = true;
// Let the window know it isn't tiled
river_window_v1.setTiled(.{});
if (window.floating_rect.width == 0) {
// Never floated before; use 75% of usable area, centered on output
// This window has never floated before, let's give it floating dimensions.
// Use 75% of the output's usable size, clamped to the window's dimension hints.
if (window.output) |output| {
window.floating_rect.width = @divFloor(output.usable_geometry.width * 3, 4);
window.floating_rect.height = @divFloor(output.usable_geometry.height * 3, 4);
window.floating_rect.width = window.dimensions_hint.clampWidth(@divFloor(output.usable_geometry.width * 3, 4));
window.floating_rect.height = window.dimensions_hint.clampHeight(@divFloor(output.usable_geometry.height * 3, 4));
window.floating_rect.x = output.usable_geometry.x + @divFloor(output.usable_geometry.width, 2) - @divFloor(window.floating_rect.width, 2);
window.floating_rect.y = output.usable_geometry.y + @divFloor(output.usable_geometry.height, 2) - @divFloor(window.floating_rect.height, 2);
} else {
@ -202,12 +280,15 @@ pub fn manage(window: *Window) void {
window.floating_rect.y = window.rect.y;
}
}
// Layout (pre-computed by WindowManager.calculatePrimaryStackLayout())
// Layout (pre-computed by WindowManager.caluclateLayout())
if (pending_manage.dimensions) |dimensions| {
window.rect.width = dimensions.width;
window.rect.height = dimensions.height;
if (!became_floating) {
// We want to skip this if the floating block above already proposed dimensions
window.river_window_v1.proposeDimensions(dimensions.width, dimensions.height);
}
}
// Fullscreen and maximize operations
if (pending_manage.fullscreen) |fullscreen| blk: {
window.fullscreen = fullscreen;
@ -280,17 +361,51 @@ fn applyBorders(window: *Window, color: utils.RiverColor) void {
window.river_window_v1.setBorders(all_sides, border_width, color.red, color.green, color.blue, color.alpha);
}
// Iterate over all window rules and apply any that match.
// Later rules in the list overwrite earlier ones.
fn applyRules(window: *Window) struct {
float: ?bool = null,
tags: ?u32 = null,
} {
var float: ?bool = null;
var tags: ?u32 = null;
for (window.context.config.window_rules.items) |rule| {
const app_id_matches = if (rule.app_id_glob) |glob|
if (window.app_id) |app_id| globber.match(app_id, glob) else false
else
true;
const title_matches = if (rule.title_glob) |glob|
if (window.title) |title| globber.match(title, glob) else false
else
true;
if (app_id_matches and title_matches) {
switch (rule.action) {
.float => |should_float| float = should_float,
.tags => |tagmask| tags = tagmask,
}
}
}
return .{
.float = float,
.tags = tags,
};
}
const std = @import("std");
const assert = std.debug.assert;
const math = std.math;
const DoublyLinkedList = std.DoublyLinkedList;
const wayland = @import("wayland");
const wl = wayland.client.wl;
const river = wayland.client.river;
const globber = @import("globber.zig");
const utils = @import("utils.zig");
const Context = @import("Context.zig");
const Output = @import("Output.zig");
const Seat = @import("Seat.zig");
const WindowRule = @import("Config.zig").WindowRule;
const log = std.log.scoped(.Window);

View file

@ -72,21 +72,17 @@ pub fn prevOutput(wm: *WindowManager, current: *Output) ?*Output {
return @fieldParentPtr("link", prev_link);
}
fn manage_start(wm: *WindowManager) void {
const river_window_manager_v1 = wm.river_window_manager_v1;
const context = wm.context;
fn initialize(wm: *WindowManager) void {
if (wm.context.initialized) return;
// This gets used shortly, so it goes at the beginning
context.manage();
if (!context.initialized) {
// This code runs during initial startup and after config reloads.
@branchHint(.cold);
context.initialized = true;
const seat = wm.seats.first() orelse @panic("Failed to get seat");
// We need a seat to intitialize this stuff, so let's just not do it right now.
// The WM can run fine without it, though, it won't be fully usuable.
const seat = wm.seats.first() orelse return;
const river_seat_v1 = seat.river_seat_v1;
const context = wm.context;
context.initialized = true;
// Tag bindings
for (context.config.tag_binds.items) |tag_bind| {
comptime var i: u8 = 1;
@ -132,6 +128,19 @@ fn manage_start(wm: *WindowManager) void {
}
}
fn manage(wm: *WindowManager) void {
const river_window_manager_v1 = wm.river_window_manager_v1;
const context = wm.context;
// This gets used shortly, so it goes at the beginning
context.manage();
if (!context.initialized) {
// This code runs during initial startup and after config reloads.
@branchHint(.cold);
wm.initialize();
}
{
var it = wm.outputs.iterator(.forward);
while (it.next()) |output| {
@ -147,7 +156,7 @@ fn manage_start(wm: *WindowManager) void {
river_window_manager_v1.manageFinish();
}
fn render_start(wm: *WindowManager) void {
fn render(wm: *WindowManager) void {
const river_window_manager_v1 = wm.river_window_manager_v1;
{
var it = wm.seats.iterator(.forward);
@ -171,8 +180,8 @@ fn windowManagerV1Listener(window_manager_v1: *river.WindowManagerV1, event: riv
.unavailable => {
fatal("Window manager unavailable (some other wm instance is running). Exiting", .{});
},
.manage_start => wm.manage_start(),
.render_start => wm.render_start(),
.manage_start => wm.manage(),
.render_start => wm.render(),
.output => |ev| {
const output = Output.create(context, ev.id) catch @panic("Out of memory");
wm.outputs.append(output);
@ -212,12 +221,32 @@ fn windowManagerV1Listener(window_manager_v1: *river.WindowManagerV1, event: riv
// If there was already an output, but no seats, set the first output as focused
if (wm.outputs.first()) |output| {
seat.pending_manage.output = .{ .output = output };
// Adopt any orphan windows that arrived before we had a seat
var it = wm.orphan_windows.iterator(.forward);
while (it.next()) |window| {
window.pending_manage.pending_output = .{ .output = output };
}
if (wm.orphan_windows.first()) |first| {
seat.pending_manage.window = .{ .window = first };
seat.pending_manage.should_warp_pointer = true;
}
output.windows.appendList(&wm.orphan_windows);
}
},
.window => |ev| {
// TODO: Support multiple seats
const seat = wm.seats.first() orelse @panic("Failed to get seat");
const focused_output = seat.focused_output;
const seat = wm.seats.first();
const focused_output = if (seat) |s|
s.focused_output orelse if (s.pending_manage.output) |pending_output|
switch (pending_output) {
.output => |output| output,
.clear_focus => null,
}
else
wm.outputs.first()
else
wm.outputs.first();
const window_list = if (focused_output) |output|
&output.windows
else
@ -228,8 +257,10 @@ fn windowManagerV1Listener(window_manager_v1: *river.WindowManagerV1, event: riv
.top => window_list.prepend(window),
.bottom => window_list.append(window),
}
seat.pending_manage.window = .{ .window = window };
seat.pending_manage.should_warp_pointer = true;
if (seat) |s| {
s.pending_manage.window = .{ .window = window };
s.pending_manage.should_warp_pointer = true;
}
},
else => |ev| {
log.debug("unhandled event: {s}", .{@tagName(ev)});

View file

@ -7,7 +7,7 @@
/// if arg_str in ["#true", "true"], return true
/// if arg_str in ["#false", "false"], return false
/// else, return null
pub fn boolFromKdlStr(arg_str: []const u8) ?bool {
pub fn boolFromKdlStr(arg_str: []const u8) !bool {
if (mem.eql(u8, arg_str, "#true") or
mem.eql(u8, arg_str, "true"))
{
@ -17,7 +17,7 @@ pub fn boolFromKdlStr(arg_str: []const u8) ?bool {
{
return false;
}
return null;
return error.NotABool;
}
pub fn parseButton(s: []const u8) ?u32 {
@ -96,16 +96,16 @@ const testing = std.testing;
test "boolFromKdlStr" {
// True valid
try testing.expectEqual(@as(?bool, true), boolFromKdlStr("#true"));
try testing.expectEqual(@as(?bool, true), boolFromKdlStr("true"));
try testing.expectEqual(true, try boolFromKdlStr("#true"));
try testing.expectEqual(true, try boolFromKdlStr("true"));
// False valid
try testing.expectEqual(@as(?bool, false), boolFromKdlStr("#false"));
try testing.expectEqual(@as(?bool, false), boolFromKdlStr("false"));
try testing.expectEqual(false, try boolFromKdlStr("#false"));
try testing.expectEqual(false, try boolFromKdlStr("false"));
// Invalid
try testing.expectEqual(@as(?bool, null), boolFromKdlStr("yes"));
try testing.expectEqual(@as(?bool, null), boolFromKdlStr("1"));
try testing.expectEqual(@as(?bool, null), boolFromKdlStr(""));
try testing.expectEqual(@as(?bool, null), boolFromKdlStr("TRUE"));
try testing.expectError(error.NotABool, boolFromKdlStr("yes"));
try testing.expectError(error.NotABool, boolFromKdlStr("1"));
try testing.expectError(error.NotABool, boolFromKdlStr(""));
try testing.expectError(error.NotABool, boolFromKdlStr("TRUE"));
}
test "parseButton named buttons" {

104
src/config/window_rule.zig Normal file
View file

@ -0,0 +1,104 @@
// SPDX-FileCopyrightText: 2026 Ben Buhse <me@benbuhse.email>
//
// SPDX-License-Identifier: GPL-3.0-only
const NodeName = enum {
float,
no_float,
tags,
// TODO: Add more of riverctl's rule options such as ssd/csd
};
pub const Rule = struct {
// if app_id/title are null, they match all values
app_id_glob: ?[]const u8 = null,
title_glob: ?[]const u8 = null,
action: Action,
};
pub const Action = union(enum) {
float: bool,
tags: u32,
};
pub fn load(config: *Config, parser: *kdl.Parser, hostname: ?[]const u8) !void {
while (try parser.next()) |event| {
switch (event) {
.node => |node| {
const node_name = std.meta.stringToEnum(NodeName, node.name);
if (node_name) |name| {
if (!helpers.hostMatches(node, parser, hostname)) {
log.debug("Skipping \"window_rule.{s}\" (host mismatch)", .{@tagName(name)});
continue;
}
const app_id_glob = if (node.prop(parser, "app_id")) |raw_app_id| blk: {
const app_id = utils.stripQuotes(raw_app_id);
globber.validate(app_id) catch {
log.warn("Invalid glob for app_id \"{s}\"", .{app_id});
continue;
};
break :blk try utils.gpa.dupe(u8, app_id);
} else null;
errdefer if (app_id_glob) |app_id| utils.gpa.free(app_id);
const title_glob = if (node.prop(parser, "title")) |raw_title| blk: {
const title = utils.stripQuotes(raw_title);
globber.validate(title) catch {
log.warn("Invalid glob for title \"{s}\"", .{title});
continue;
};
break :blk try utils.gpa.dupe(u8, title);
} else null;
errdefer if (title_glob) |title| utils.gpa.free(title);
const action: Action = sw: switch (name) {
.float => .{ .float = true },
.no_float => .{ .float = false },
.tags => {
const tags_str = utils.stripQuotes(node.arg(parser, 0) orelse "");
const tags = fmt.parseInt(u32, tags_str, 0) catch {
logWarnInvalidNodeArg(name, tags_str);
continue;
};
break :sw .{ .tags = tags };
},
};
try config.window_rules.append(utils.gpa, .{
.app_id_glob = app_id_glob,
.title_glob = title_glob,
.action = action,
});
} else {
helpers.logWarnInvalidNode(node.name);
}
},
.child_block_begin => {
// window_rules should never have a nested child block
try helpers.skipChildBlock(parser);
},
.child_block_end => {
// Done parsing the window_rules block; return
return;
},
}
}
}
inline fn logWarnInvalidNodeArg(node_name: NodeName, node_value: []const u8) void {
log.warn("Invalid \"window_rule.{s}\" ({s}). Ignoring", .{ @tagName(node_name), node_value });
}
const std = @import("std");
const fmt = std.fmt;
const mem = std.mem;
const kdl = @import("kdl");
const globber = @import("../globber.zig");
const utils = @import("../utils.zig");
const Config = @import("../Config.zig");
const XkbBindings = @import("../XkbBindings.zig");
const helpers = @import("helpers.zig");
const log = std.log.scoped(.config_window_rule);

211
src/globber.zig Normal file
View file

@ -0,0 +1,211 @@
// Copyright 2023 Isaac Freund
// SPDX-FileCopyrightText: 2023 Isaac Freund
//
// SPDX-License-Identifier: 0BSD
const std = @import("std");
const mem = std.mem;
/// Validate a glob, returning error.InvalidGlob if it is empty, "**" or has a
/// '*' at any position other than the first and/or last byte.
pub fn validate(glob: []const u8) error{InvalidGlob}!void {
switch (glob.len) {
0 => return error.InvalidGlob,
1 => {},
2 => if (glob[0] == '*' and glob[1] == '*') return error.InvalidGlob,
else => if (mem.indexOfScalar(u8, glob[1 .. glob.len - 1], '*') != null) {
return error.InvalidGlob;
},
}
}
test validate {
const testing = std.testing;
try validate("*");
try validate("a");
try validate("*a");
try validate("a*");
try validate("*a*");
try validate("ab");
try validate("*ab");
try validate("ab*");
try validate("*ab*");
try validate("abc");
try validate("*abc");
try validate("abc*");
try validate("*abc*");
try testing.expectError(error.InvalidGlob, validate(""));
try testing.expectError(error.InvalidGlob, validate("**"));
try testing.expectError(error.InvalidGlob, validate("***"));
try testing.expectError(error.InvalidGlob, validate("a*c"));
try testing.expectError(error.InvalidGlob, validate("ab*c*"));
try testing.expectError(error.InvalidGlob, validate("*ab*c"));
try testing.expectError(error.InvalidGlob, validate("ab*c"));
try testing.expectError(error.InvalidGlob, validate("a*bc*"));
try testing.expectError(error.InvalidGlob, validate("**a"));
try testing.expectError(error.InvalidGlob, validate("abc**"));
}
/// Return true if s is matched by glob.
/// Asserts that the glob is valid, see `validate()`.
pub fn match(s: []const u8, glob: []const u8) bool {
if (std.debug.runtime_safety) {
validate(glob) catch unreachable;
}
if (glob.len == 1) {
return glob[0] == '*' or mem.eql(u8, s, glob);
}
const suffix_match = glob[0] == '*';
const prefix_match = glob[glob.len - 1] == '*';
if (suffix_match and prefix_match) {
return mem.indexOf(u8, s, glob[1 .. glob.len - 1]) != null;
} else if (suffix_match) {
return mem.endsWith(u8, s, glob[1..]);
} else if (prefix_match) {
return mem.startsWith(u8, s, glob[0 .. glob.len - 1]);
} else {
return mem.eql(u8, s, glob);
}
}
test match {
const testing = std.testing;
try testing.expect(match("", "*"));
try testing.expect(match("a", "*"));
try testing.expect(match("a", "*a*"));
try testing.expect(match("a", "a*"));
try testing.expect(match("a", "*a"));
try testing.expect(match("a", "a"));
try testing.expect(!match("a", "b"));
try testing.expect(!match("a", "*b*"));
try testing.expect(!match("a", "b*"));
try testing.expect(!match("a", "*b"));
try testing.expect(match("ab", "*"));
try testing.expect(match("ab", "*a*"));
try testing.expect(match("ab", "*b*"));
try testing.expect(match("ab", "a*"));
try testing.expect(match("ab", "*b"));
try testing.expect(match("ab", "*ab*"));
try testing.expect(match("ab", "ab*"));
try testing.expect(match("ab", "*ab"));
try testing.expect(match("ab", "ab"));
try testing.expect(!match("ab", "b*"));
try testing.expect(!match("ab", "*a"));
try testing.expect(!match("ab", "*c*"));
try testing.expect(!match("ab", "c*"));
try testing.expect(!match("ab", "*c"));
try testing.expect(!match("ab", "ac"));
try testing.expect(!match("ab", "*ac*"));
try testing.expect(!match("ab", "ac*"));
try testing.expect(!match("ab", "*ac"));
try testing.expect(match("abc", "*"));
try testing.expect(match("abc", "*a*"));
try testing.expect(match("abc", "*b*"));
try testing.expect(match("abc", "*c*"));
try testing.expect(match("abc", "a*"));
try testing.expect(match("abc", "*c"));
try testing.expect(match("abc", "*ab*"));
try testing.expect(match("abc", "ab*"));
try testing.expect(match("abc", "*bc*"));
try testing.expect(match("abc", "*bc"));
try testing.expect(match("abc", "*abc*"));
try testing.expect(match("abc", "abc*"));
try testing.expect(match("abc", "*abc"));
try testing.expect(match("abc", "abc"));
try testing.expect(!match("abc", "*a"));
try testing.expect(!match("abc", "*b"));
try testing.expect(!match("abc", "b*"));
try testing.expect(!match("abc", "c*"));
try testing.expect(!match("abc", "*ab"));
try testing.expect(!match("abc", "bc*"));
try testing.expect(!match("abc", "*d*"));
try testing.expect(!match("abc", "d*"));
try testing.expect(!match("abc", "*d"));
}
/// Returns .lt if a is less general than b.
/// Returns .gt if a is more general than b.
/// Returns .eq if a and b are equally general.
/// Both a and b must be valid globs, see `validate()`.
pub fn order(a: []const u8, b: []const u8) std.math.Order {
if (std.debug.runtime_safety) {
validate(a) catch unreachable;
validate(b) catch unreachable;
}
if (mem.eql(u8, a, "*") and mem.eql(u8, b, "*")) {
return .eq;
} else if (mem.eql(u8, a, "*")) {
return .gt;
} else if (mem.eql(u8, b, "*")) {
return .lt;
}
const count_a = @as(u2, @intFromBool(a[0] == '*')) + @intFromBool(a[a.len - 1] == '*');
const count_b = @as(u2, @intFromBool(b[0] == '*')) + @intFromBool(b[b.len - 1] == '*');
if (count_a == 0 and count_b == 0) {
return .eq;
} else if (count_a == count_b) {
// This may look backwards since e.g. "c*" is more general than "cc*"
return std.math.order(b.len, a.len);
} else {
return std.math.order(count_a, count_b);
}
}
test order {
const testing = std.testing;
const Order = std.math.Order;
try testing.expectEqual(Order.eq, order("*", "*"));
try testing.expectEqual(Order.eq, order("*a*", "*b*"));
try testing.expectEqual(Order.eq, order("a*", "*b"));
try testing.expectEqual(Order.eq, order("*a", "*b"));
try testing.expectEqual(Order.eq, order("*a", "b*"));
try testing.expectEqual(Order.eq, order("a*", "b*"));
const descending = [_][]const u8{
"*",
"*a*",
"*b*",
"*a*",
"*ab*",
"*bab*",
"*a",
"b*",
"*b",
"*a",
"a",
"bababab",
"b",
"a",
};
for (descending, 0..) |a, i| {
for (descending[i..]) |b| {
try testing.expect(order(a, b) != .lt);
}
}
var ascending = descending;
mem.reverse([]const u8, &ascending);
for (ascending, 0..) |a, i| {
for (ascending[i..]) |b| {
try testing.expect(order(a, b) != .gt);
}
}
}

View file

@ -357,4 +357,5 @@ const log = std.log.scoped(.main);
test {
_ = @import("utils.zig");
_ = @import("Config.zig");
_ = @import("globber.zig");
}