From 6b8350e7b6a44661c77b6ebd017ba437c62e7617 Mon Sep 17 00:00:00 2001 From: Ben Buhse Date: Tue, 17 Feb 2026 16:26:18 -0600 Subject: [PATCH 1/9] 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"); } From c4da4ef30acd8f7b9151b1b1e3bf148b1d631270 Mon Sep 17 00:00:00 2001 From: Ben Buhse Date: Tue, 17 Feb 2026 16:51:57 -0600 Subject: [PATCH 2/9] Make window rules frame perfect This moves window initialization earlier in the manage sequence. Previously, it was on the Window's first manage() call, but this is after the layout has already been calculated, which matters both because of tags and whether the window starts floating or not. Now, initialization is handled in a separate function that gets called in Output.calculatePrimaryStackLayout() instead. --- docs/TODO.md | 2 +- src/Output.zig | 3 +++ src/Window.zig | 45 ++++++++++++++++++++++++++------------------- 3 files changed, 30 insertions(+), 20 deletions(-) diff --git a/docs/TODO.md b/docs/TODO.md index e9ef37c..9b87dd5 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -2,7 +2,6 @@ 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) - [ ] Support overriding config location - [ ] Support configuring primary vs secondary stack side - [ ] Support switch handling (e.g. lid close) @@ -31,3 +30,4 @@ 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) diff --git a/src/Output.zig b/src/Output.zig index ec65420..fdfff0a 100644 --- a/src/Output.zig +++ b/src/Output.zig @@ -617,6 +617,9 @@ fn calculatePrimaryStackLayout(output: *Output) void { 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; diff --git a/src/Window.zig b/src/Window.zig index 7ff9be0..5c38bfb 100644 --- a/src/Window.zig +++ b/src/Window.zig @@ -164,31 +164,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); - window.initialized = true; - - // 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 - else - window.pending_manage.floating = false; + 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 + else + window.pending_manage.floating = false; +} + +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 if (pending_manage.floating) |floating| blk: { // This needs to be before proposing the new dimensions since we want to save the current ones! From 433e3772370375405c4258cb2a661845463dc581 Mon Sep 17 00:00:00 2001 From: Ben Buhse Date: Tue, 17 Feb 2026 16:55:35 -0600 Subject: [PATCH 3/9] Rename calculatePrimaryStackLayout It's the only layout, so just rename to calculateLayout() --- src/Output.zig | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Output.zig b/src/Output.zig index fdfff0a..4ce8ab6 100644 --- a/src/Output.zig +++ b/src/Output.zig @@ -577,7 +577,7 @@ pub fn manage(output: *Output) void { } // Calculate layout before managing windows - output.calculatePrimaryStackLayout(); + output.calculateLayout(); var it = output.windows.iterator(.forward); while (it.next()) |window| { window.manage(); @@ -611,7 +611,7 @@ 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 { // Get a list of active windows var active_list: DoublyLinkedList = .{}; var active_count: u31 = 0; From 6f68850a703214425713f8c5ad8bccf4dd675224 Mon Sep 17 00:00:00 2001 From: Ben Buhse Date: Tue, 17 Feb 2026 20:44:21 -0600 Subject: [PATCH 4/9] Fix crash when windows arrive before seat/output I found this when trying to restart beansprout after a separate crash. Basically, the .window handler used to put new windows on seat.focused_output for new windows, but after a restart this is still null even when an output (and windows) already exist. Windows became orphans with output=null, breaking the focused_window.output == focused_output assert --- src/WindowManager.zig | 30 ++++++++++++++++++++++++++---- 1 file changed, 26 insertions(+), 4 deletions(-) diff --git a/src/WindowManager.zig b/src/WindowManager.zig index 1211aa6..13e6bfd 100644 --- a/src/WindowManager.zig +++ b/src/WindowManager.zig @@ -212,12 +212,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 +248,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)}); From 08be768d99d4c025cc803946113b18025746ce42 Mon Sep 17 00:00:00 2001 From: Ben Buhse Date: Wed, 18 Feb 2026 15:30:05 -0600 Subject: [PATCH 5/9] Fix two crashes One was where WM was assuming that a seat existed during first manage, but that's not always true, so we have to check that before running the initialization code. I also split that off into its own function like in Window. The other crash was when trying to calculate the layout with the output's width and/or height equal to zero, it would crash subtracting the border width. I discovered both of these when try to restart beansprout without restarting River. --- src/Output.zig | 19 +++++-- src/WindowManager.zig | 113 +++++++++++++++++++++++------------------- 2 files changed, 76 insertions(+), 56 deletions(-) diff --git a/src/Output.zig b/src/Output.zig index 4ce8ab6..c07e682 100644 --- a/src/Output.zig +++ b/src/Output.zig @@ -576,8 +576,11 @@ pub fn manage(output: *Output) void { } } - // Calculate layout before managing windows - output.calculateLayout(); + // 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(); @@ -612,6 +615,8 @@ pub fn render(output: *Output) void { /// - Single window: maximized /// - Multiple windows: stack (45% left, vertically tiled), primary (55% right) 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; @@ -714,8 +719,14 @@ fn calculateLayout(output: *Output) void { } // Make space for borders - window.pending_manage.dimensions.?.height -= 2 * border_width; - window.pending_manage.dimensions.?.width -= 2 * border_width; + 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; } diff --git a/src/WindowManager.zig b/src/WindowManager.zig index 13e6bfd..cb64fc5 100644 --- a/src/WindowManager.zig +++ b/src/WindowManager.zig @@ -72,7 +72,63 @@ pub fn prevOutput(wm: *WindowManager, current: *Output) ?*Output { return @fieldParentPtr("link", prev_link); } -fn manage_start(wm: *WindowManager) void { +fn initialize(wm: *WindowManager) void { + if (wm.context.initialized) return; + + // 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; + comptime var buffer: [1]u8 = undefined; + inline while (i <= 9) : (i += 1) { + const tags: u32 = 1 << (i - 1); + buffer[0] = i + '0'; + const command: XkbBindings.Command = switch (tag_bind.command) { + .set_output_tags => .{ .set_output_tags = tags }, + .set_window_tags => .{ .set_window_tags = tags }, + .toggle_output_tags => .{ .toggle_output_tags = tags }, + .toggle_window_tags => .{ .toggle_window_tags = tags }, + else => unreachable, + }; + context.xkb_bindings.addBinding(river_seat_v1, @field(xkbcommon.Keysym, &buffer), tag_bind.modifiers, command); + } + } + // Rest of the keybinds + for (context.config.keybinds.keys(), context.config.keybinds.values()) |key, command| { + context.xkb_bindings.addBinding(river_seat_v1, key.keysym, key.modifiers, command); + } + + // Pointer bindings + for (context.config.pointer_binds.items) |pointer_bind| { + const binding = river_seat_v1.getPointerBinding(pointer_bind.button, pointer_bind.modifiers) catch { + log.err("Failed to create pointer binding", .{}); + continue; + }; + + switch (pointer_bind.action) { + .move_window => { + if (seat.move_pointer_binding) |old| old.destroy(); + binding.setListener(*Seat, Seat.movePointerBindingListener, seat); + seat.move_pointer_binding = binding; + }, + .resize_window => { + if (seat.resize_pointer_binding) |old| old.destroy(); + binding.setListener(*Seat, Seat.resizePointerBindingListener, seat); + seat.resize_pointer_binding = binding; + }, + } + binding.enable(); + } +} + +fn manage(wm: *WindowManager) void { const river_window_manager_v1 = wm.river_window_manager_v1; const context = wm.context; @@ -82,54 +138,7 @@ fn manage_start(wm: *WindowManager) void { 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"); - const river_seat_v1 = seat.river_seat_v1; - - // Tag bindings - for (context.config.tag_binds.items) |tag_bind| { - comptime var i: u8 = 1; - comptime var buffer: [1]u8 = undefined; - inline while (i <= 9) : (i += 1) { - const tags: u32 = 1 << (i - 1); - buffer[0] = i + '0'; - const command: XkbBindings.Command = switch (tag_bind.command) { - .set_output_tags => .{ .set_output_tags = tags }, - .set_window_tags => .{ .set_window_tags = tags }, - .toggle_output_tags => .{ .toggle_output_tags = tags }, - .toggle_window_tags => .{ .toggle_window_tags = tags }, - else => unreachable, - }; - context.xkb_bindings.addBinding(river_seat_v1, @field(xkbcommon.Keysym, &buffer), tag_bind.modifiers, command); - } - } - // Rest of the keybinds - for (context.config.keybinds.keys(), context.config.keybinds.values()) |key, command| { - context.xkb_bindings.addBinding(river_seat_v1, key.keysym, key.modifiers, command); - } - - // Pointer bindings - for (context.config.pointer_binds.items) |pointer_bind| { - const binding = river_seat_v1.getPointerBinding(pointer_bind.button, pointer_bind.modifiers) catch { - log.err("Failed to create pointer binding", .{}); - continue; - }; - - switch (pointer_bind.action) { - .move_window => { - if (seat.move_pointer_binding) |old| old.destroy(); - binding.setListener(*Seat, Seat.movePointerBindingListener, seat); - seat.move_pointer_binding = binding; - }, - .resize_window => { - if (seat.resize_pointer_binding) |old| old.destroy(); - binding.setListener(*Seat, Seat.resizePointerBindingListener, seat); - seat.resize_pointer_binding = binding; - }, - } - binding.enable(); - } + wm.initialize(); } { @@ -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); From dc1e38e737d824465cde7c8b5a60e7ebeaf6c6d7 Mon Sep 17 00:00:00 2001 From: Ben Buhse Date: Wed, 18 Feb 2026 15:39:58 -0600 Subject: [PATCH 6/9] Support river_window_v1.dimensions_hint This is currently only used when floating a window for the first time. If the window has preferred dimensions, we will use those isntead of the 75% of the screen size rule we were using before. --- src/Window.zig | 79 +++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 66 insertions(+), 13 deletions(-) diff --git a/src/Window.zig b/src/Window.zig index 5c38bfb..d1b5474 100644 --- a/src/Window.zig +++ b/src/Window.zig @@ -11,6 +11,7 @@ river_node_v1: *river.NodeV1, app_id: ?[]const u8 = null, title: ?[]const u8 = null, +parent: ?*river.WindowV1 = null, rect: utils.Rect = .{}, @@ -22,6 +23,7 @@ output: ?*Output, floating: bool = false, floating_rect: utils.Rect = .{}, +dimensions_hint: DimensionsHint = .{}, initialized: bool = false, @@ -64,6 +66,37 @@ 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 preferredWidth(hint: DimensionsHint) ?u31 { + if (hint.min_width != 0 and hint.max_width != 0) + // Two separate divisions so we don't overflow the u31 + return hint.min_width / 2 + hint.max_width / 2 + else if (hint.min_width != 0) + return hint.min_width + else if (hint.max_width != 0) + return hint.max_width + else + return null; + } + + fn preferredHeight(hint: DimensionsHint) ?u31 { + if (hint.min_height != 0 and hint.max_height != 0) + // Two separate divisions so we don't overflow the u31 + return hint.min_height / 2 + hint.max_height / 2 + else if (hint.min_height != 0) + return hint.min_height + else if (hint.max_height != 0) + return hint.max_height + else + return null; + } +}; + 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); @@ -144,19 +177,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: Use this for clamping windows on resize + .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 = utils.gpa.dupe(u8, std.mem.span(ev.app_id.?)) catch @panic("Out of memory"); + 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 = utils.gpa.dupe(u8, std.mem.span(ev.title.?)) catch @panic("Out of memory"); + window.title = if (ev.title) |t| + utils.gpa.dupe(u8, std.mem.span(t)) catch @panic("Out of memory") + else + null; }, - .parent => { - // TODO: float this window directly over its parent + .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)}); @@ -186,9 +239,7 @@ pub fn initialize(window: *Window) void { 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; + window.pending_manage.floating = should_float; } pub fn manage(window: *Window) void { @@ -208,10 +259,12 @@ pub fn manage(window: *Window) void { 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 + // Go with the mid-point of the preferred width/height if the window has one + // If not, go with 75% of the output's usable size in the same dimension 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 = if (window.dimensions_hint.preferredWidth()) |w| w else @divFloor(output.usable_geometry.width * 3, 4); + window.floating_rect.height = if (window.dimensions_hint.preferredHeight()) |h| h else @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 { @@ -233,7 +286,7 @@ 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; From 3a7975eb1f4c33c4e26441bff90ebe6983c17a6e Mon Sep 17 00:00:00 2001 From: Ben Buhse Date: Wed, 18 Feb 2026 15:46:35 -0600 Subject: [PATCH 7/9] Fix a memory leak during window.close We weren't actually destroying the window or removing its link if the window was closed while it didn't have an output. --- src/Window.zig | 27 ++++++++++++++------------- 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/src/Window.zig b/src/Window.zig index d1b5474..e9f0aac 100644 --- a/src/Window.zig +++ b/src/Window.zig @@ -150,22 +150,23 @@ 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; - var it = window.context.wm.seats.iterator(.forward); - while (it.next()) |seat| { - if (seat.focused_window == window) { - // Find another window to focus and warp pointer there - if (output.prevWindow(window)) |next_focus| { - if (next_focus != window) { - seat.pending_manage.window = .{ .window = next_focus }; - seat.pending_manage.should_warp_pointer = true; + 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) { + // Find another window to focus and warp pointer there + if (output.prevWindow(window)) |next_focus| { + if (next_focus != window) { + seat.pending_manage.window = .{ .window = next_focus }; + seat.pending_manage.should_warp_pointer = true; + } else { + // Only window in list - clear focus + seat.pending_manage.window = .clear_focus; + } } else { - // Only window in list - clear focus seat.pending_manage.window = .clear_focus; } - } else { - seat.pending_manage.window = .clear_focus; } } } From 76f292332b8cc6285c927273113627804ec972f4 Mon Sep 17 00:00:00 2001 From: Ben Buhse Date: Wed, 18 Feb 2026 16:06:09 -0600 Subject: [PATCH 8/9] Fix floating window initial sizes When I added support for propose dimensions, I was foolishly just choosing the min (or max) if only one was set. However, a lot of windows will a set a very small min size with no max, which meant we would have really tiny windows when first floating one. The fix was to try set to 75% but then clamp to the min/max dimensions the window gave. After fixing that, there was still an issue where windows kept their size after being floated. That's because there was a propose_dimensions call happening later in Window.manage(). I'm skipping that now if a window became floating in the same manage event. --- docs/TODO.md | 1 + src/Window.zig | 49 +++++++++++++++++++++++-------------------------- 2 files changed, 24 insertions(+), 26 deletions(-) diff --git a/docs/TODO.md b/docs/TODO.md index 9b87dd5..ed3c048 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -31,3 +31,4 @@ These are in rough order of my priority, though no promises I do them in this or - [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% diff --git a/src/Window.zig b/src/Window.zig index e9f0aac..a21004e 100644 --- a/src/Window.zig +++ b/src/Window.zig @@ -72,28 +72,20 @@ pub const DimensionsHint = struct { max_width: u31 = 0, max_height: u31 = 0, - fn preferredWidth(hint: DimensionsHint) ?u31 { - if (hint.min_width != 0 and hint.max_width != 0) - // Two separate divisions so we don't overflow the u31 - return hint.min_width / 2 + hint.max_width / 2 - else if (hint.min_width != 0) - return hint.min_width - else if (hint.max_width != 0) - return hint.max_width - else - return null; + 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 preferredHeight(hint: DimensionsHint) ?u31 { - if (hint.min_height != 0 and hint.max_height != 0) - // Two separate divisions so we don't overflow the u31 - return hint.min_height / 2 + hint.max_height / 2 - else if (hint.min_height != 0) - return hint.min_height - else if (hint.max_height != 0) - return hint.max_height - else - return null; + 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), + ); } }; @@ -249,6 +241,7 @@ pub fn manage(window: *Window) void { 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 @@ -256,16 +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) { - // This window has never floated before, let's give it floating dimensions - // Go with the mid-point of the preferred width/height if the window has one - // If not, go with 75% of the output's usable size in the same dimension + // 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 = if (window.dimensions_hint.preferredWidth()) |w| w else @divFloor(output.usable_geometry.width * 3, 4); - window.floating_rect.height = if (window.dimensions_hint.preferredHeight()) |h| h else @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 { @@ -291,7 +284,10 @@ pub fn manage(window: *Window) void { if (pending_manage.dimensions) |dimensions| { window.rect.width = dimensions.width; window.rect.height = dimensions.height; - window.river_window_v1.proposeDimensions(dimensions.width, 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: { @@ -398,6 +394,7 @@ fn applyRules(window: *Window) struct { const std = @import("std"); const assert = std.debug.assert; +const math = std.math; const DoublyLinkedList = std.DoublyLinkedList; const wayland = @import("wayland"); From 5ca0b9d1570c33401965b71fabdf398a279597f1 Mon Sep 17 00:00:00 2001 From: Ben Buhse Date: Wed, 18 Feb 2026 16:13:16 -0600 Subject: [PATCH 9/9] Add documentation and example for window rules --- docs/CONFIGURATION.md | 51 +++++++++++++++++++++++++++++++++++++++++++ docs/TODO.md | 2 ++ examples/config.kdl | 16 ++++++++++++-- 3 files changed, 67 insertions(+), 2 deletions(-) diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index a0e3329..1fe859f 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -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. diff --git a/docs/TODO.md b/docs/TODO.md index ed3c048..531002e 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -2,6 +2,8 @@ These are in rough order of my priority, though no promises I do them in this order. +- [ ] 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) diff --git a/examples/config.kdl b/examples/config.kdl index 9be9915..63e09e6 100644 --- a/examples/config.kdl +++ b/examples/config.kdl @@ -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