From 6b8350e7b6a44661c77b6ebd017ba437c62e7617 Mon Sep 17 00:00:00 2001 From: Ben Buhse Date: Tue, 17 Feb 2026 16:26:18 -0600 Subject: [PATCH] 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. --- src/Config.zig | 38 ++++--- src/Window.zig | 61 ++++++++++- src/config/helpers.zig | 20 ++-- src/config/window_rule.zig | 104 ++++++++++++++++++ src/globber.zig | 211 +++++++++++++++++++++++++++++++++++++ src/main.zig | 1 + 6 files changed, 410 insertions(+), 25 deletions(-) create mode 100644 src/config/window_rule.zig create mode 100644 src/globber.zig diff --git a/src/Config.zig b/src/Config.zig index 29e0669..2f5ac81 100644 --- a/src/Config.zig +++ b/src/Config.zig @@ -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"); diff --git a/src/Window.zig b/src/Window.zig index ed55093..7ff9be0 100644 --- a/src/Window.zig +++ b/src/Window.zig @@ -9,6 +9,9 @@ context: *Context, river_window_v1: *river.WindowV1, river_node_v1: *river.NodeV1, +app_id: ?[]const u8 = null, +title: ?[]const u8 = null, + rect: utils.Rect = .{}, 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 { - 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); } @@ -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) }; }, .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| { 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.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 @@ -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); } +// 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 DoublyLinkedList = std.DoublyLinkedList; @@ -288,9 +343,11 @@ 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); diff --git a/src/config/helpers.zig b/src/config/helpers.zig index bbfeecf..ffc5643 100644 --- a/src/config/helpers.zig +++ b/src/config/helpers.zig @@ -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" { diff --git a/src/config/window_rule.zig b/src/config/window_rule.zig new file mode 100644 index 0000000..dcdddc4 --- /dev/null +++ b/src/config/window_rule.zig @@ -0,0 +1,104 @@ +// SPDX-FileCopyrightText: 2026 Ben Buhse +// +// 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); diff --git a/src/globber.zig b/src/globber.zig new file mode 100644 index 0000000..9b273e6 --- /dev/null +++ b/src/globber.zig @@ -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); + } + } +} diff --git a/src/main.zig b/src/main.zig index 72db61e..62f898b 100644 --- a/src/main.zig +++ b/src/main.zig @@ -357,4 +357,5 @@ const log = std.log.scoped(.main); test { _ = @import("utils.zig"); _ = @import("Config.zig"); + _ = @import("globber.zig"); }