Create initial window rules set up

At least tag rules seem to be working (but they're not frame perfect).

I might need to investigate more for float/no_float.

Rules are ANDed and only apply during window's first manage sequence,
so changing an appid/title doesn't affect anything.
This commit is contained in:
Ben Buhse 2026-02-17 16:26:18 -06:00
commit 6b8350e7b6
No known key found for this signature in database
GPG key ID: 7916ACFCD38FD0B4
6 changed files with 410 additions and 25 deletions

View file

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

View file

@ -9,6 +9,9 @@ context: *Context,
river_window_v1: *river.WindowV1, river_window_v1: *river.WindowV1,
river_node_v1: *river.NodeV1, river_node_v1: *river.NodeV1,
app_id: ?[]const u8 = null,
title: ?[]const u8 = null,
rect: utils.Rect = .{}, rect: utils.Rect = .{},
fullscreen: bool = false, fullscreen: bool = false,
@ -80,8 +83,11 @@ pub fn create(context: *Context, river_window_v1: *river.WindowV1, output: ?*Out
} }
pub fn destroy(window: *Window) void { 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_node_v1.destroy();
window.river_window_v1.destroy();
utils.gpa.destroy(window); utils.gpa.destroy(window);
} }
@ -139,7 +145,18 @@ fn windowListener(river_window_v1: *river.WindowV1, event: river.WindowV1.Event,
window.pending_manage.dimensions = .{ .width = @intCast(ev.width), .height = @intCast(ev.height) }; window.pending_manage.dimensions = .{ .width = @intCast(ev.width), .height = @intCast(ev.height) };
}, },
.dimensions_hint => { .dimensions_hint => {
// TODO: Maybe could use this for floating windows // TODO: Use this for clamping windows on resize
},
.app_id => |ev| {
if (window.app_id) |app_id| utils.gpa.free(app_id);
window.app_id = utils.gpa.dupe(u8, std.mem.span(ev.app_id.?)) catch @panic("Out of memory");
},
.title => |ev| {
if (window.title) |title| utils.gpa.free(title);
window.title = utils.gpa.dupe(u8, std.mem.span(ev.title.?)) catch @panic("Out of memory");
},
.parent => {
// TODO: float this window directly over its parent
}, },
else => |ev| { else => |ev| {
log.debug("unhandled event: {s}", .{@tagName(ev)}); log.debug("unhandled event: {s}", .{@tagName(ev)});
@ -160,6 +177,13 @@ pub fn manage(window: *Window) void {
river_window_v1.setCapabilities(.{ .fullscreen = true, .maximize = true }); river_window_v1.setCapabilities(.{ .fullscreen = true, .maximize = true });
river_window_v1.setTiled(.{ .top = true, .bottom = true, .left = true, .right = 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
else
window.pending_manage.floating = false;
} }
// Updating state since the last manage event // Updating state since the last manage event
@ -280,6 +304,37 @@ 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); 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 std = @import("std");
const assert = std.debug.assert; const assert = std.debug.assert;
const DoublyLinkedList = std.DoublyLinkedList; const DoublyLinkedList = std.DoublyLinkedList;
@ -288,9 +343,11 @@ const wayland = @import("wayland");
const wl = wayland.client.wl; const wl = wayland.client.wl;
const river = wayland.client.river; const river = wayland.client.river;
const globber = @import("globber.zig");
const utils = @import("utils.zig"); const utils = @import("utils.zig");
const Context = @import("Context.zig"); const Context = @import("Context.zig");
const Output = @import("Output.zig"); const Output = @import("Output.zig");
const Seat = @import("Seat.zig"); const Seat = @import("Seat.zig");
const WindowRule = @import("Config.zig").WindowRule;
const log = std.log.scoped(.Window); const log = std.log.scoped(.Window);

View file

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