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 e9ef37c..531002e 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -2,7 +2,8 @@ These are in rough order of my priority, though no promises I do them in this order. -- [ ] Support window rules (float/tags/SSD by app-id/title) +- [ ] Move orphan handling out of .output and .seat events; into manage() +- [ ] Add focused window title to bar - [ ] Support overriding config location - [ ] Support configuring primary vs secondary stack side - [ ] Support switch handling (e.g. lid close) @@ -31,3 +32,5 @@ These are in rough order of my priority, though no promises I do them in this or - [x] Add options to the tag overlay - [x] Add options to the bar - [x] Make a Rect struct to combine x, y, width, and height +- [x] Support window rules (float/tags by app-id/title) +- [x] Fix resizing size when you pop a window out, basically, it start with its current size but then when you try resize it goes to 75% 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 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/Output.zig b/src/Output.zig index ec65420..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.calculatePrimaryStackLayout(); + // Calculate layout before managing windows, but only if output dimensions are initialized + if (output.usable_geometry.width > 0 and output.usable_geometry.height > 0) { + output.calculateLayout(); + } + var it = output.windows.iterator(.forward); while (it.next()) |window| { window.manage(); @@ -611,12 +614,17 @@ pub fn render(output: *Output) void { /// Calculate primary/stack layout positions for all windows. /// - Single window: maximized /// - Multiple windows: stack (45% left, vertically tiled), primary (55% right) -fn calculatePrimaryStackLayout(output: *Output) void { +fn calculateLayout(output: *Output) void { + // Shouldn't be called if height/width are not positive + assert(output.geometry.width > 0 and output.geometry.height > 0); // Get a list of active windows var active_list: DoublyLinkedList = .{}; var active_count: u31 = 0; var it = output.windows.iterator(.forward); while (it.next()) |window| { + // Initialize new windows before checking tags/float so that + // window rules are reflected in the first frame's layout. + window.initialize(); if (output.tags & window.tags != 0x0000) { // Floating windows should be shown but not included in this layout generation const will_float = window.pending_manage.floating orelse window.floating; @@ -711,8 +719,14 @@ fn calculatePrimaryStackLayout(output: *Output) void { } // Make space for borders - 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/Window.zig b/src/Window.zig index ed55093..a21004e 100644 --- a/src/Window.zig +++ b/src/Window.zig @@ -9,6 +9,10 @@ context: *Context, river_window_v1: *river.WindowV1, river_node_v1: *river.NodeV1, +app_id: ?[]const u8 = null, +title: ?[]const u8 = null, +parent: ?*river.WindowV1 = null, + rect: utils.Rect = .{}, fullscreen: bool = false, @@ -19,6 +23,7 @@ output: ?*Output, floating: bool = false, floating_rect: utils.Rect = .{}, +dimensions_hint: DimensionsHint = .{}, initialized: bool = false, @@ -61,6 +66,29 @@ pub const PendingRender = struct { show: ?bool = null, }; +pub const DimensionsHint = struct { + min_width: u31 = 0, + min_height: u31 = 0, + max_width: u31 = 0, + max_height: u31 = 0, + + fn clampWidth(hint: DimensionsHint, width: u31) u31 { + return math.clamp( + width, + if (hint.min_width != 0) hint.min_width else 1, + if (hint.max_width != 0) hint.max_width else math.maxInt(u31), + ); + } + + fn clampHeight(hint: DimensionsHint, height: u31) u31 { + return math.clamp( + height, + if (hint.min_height != 0) hint.min_height else 1, + if (hint.max_height != 0) hint.max_height else math.maxInt(u31), + ); + } +}; + pub fn create(context: *Context, river_window_v1: *river.WindowV1, output: ?*Output) !*Window { var window = try utils.gpa.create(Window); errdefer utils.gpa.destroy(window); @@ -80,8 +108,11 @@ pub fn create(context: *Context, river_window_v1: *river.WindowV1, output: ?*Out } pub fn destroy(window: *Window) void { - window.river_window_v1.destroy(); + if (window.app_id) |app_id| utils.gpa.free(app_id); + if (window.title) |title| utils.gpa.free(title); + window.river_node_v1.destroy(); + window.river_window_v1.destroy(); utils.gpa.destroy(window); } @@ -111,22 +142,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; } } } @@ -138,8 +170,39 @@ fn windowListener(river_window_v1: *river.WindowV1, event: river.WindowV1.Event, assert(ev.width > 0 and ev.height > 0); window.pending_manage.dimensions = .{ .width = @intCast(ev.width), .height = @intCast(ev.height) }; }, - .dimensions_hint => { - // TODO: Maybe could use this for floating windows + .dimensions_hint => |ev| { + window.dimensions_hint = .{ + .min_width = @intCast(ev.min_width), + .min_height = @intCast(ev.min_height), + .max_width = @intCast(ev.max_width), + .max_height = @intCast(ev.max_height), + }; + }, + .app_id => |ev| { + if (window.app_id) |app_id| utils.gpa.free(app_id); + window.app_id = if (ev.app_id) |aid| + utils.gpa.dupe(u8, std.mem.span(aid)) catch @panic("Out of memory") + else + null; + }, + .title => |ev| { + if (window.title) |title| utils.gpa.free(title); + window.title = if (ev.title) |t| + utils.gpa.dupe(u8, std.mem.span(t)) catch @panic("Out of memory") + else + null; + }, + .parent => |ev| { + const parent = ev.parent orelse return; + window.parent = parent; + + // Make window float on top of its parent + window.pending_manage.floating = true; + const parent_window: *Window = @ptrCast(@alignCast(parent.getUserData() orelse return)); + window.pending_render.position = .{ + .x = parent_window.rect.x + @divTrunc(parent_window.rect.width, 2), + .y = parent_window.rect.y + @divTrunc(parent_window.rect.height, 2), + }; }, else => |ev| { log.debug("unhandled event: {s}", .{@tagName(ev)}); @@ -147,25 +210,38 @@ fn windowListener(river_window_v1: *river.WindowV1, event: river.WindowV1.Event, } } -pub fn manage(window: *Window) void { - const river_window_v1 = window.river_window_v1; - if (!window.initialized) { - // Only happens once per Window +/// Apply one-time initialization for newly created windows. +/// Called before calculatePrimaryStackLayout() so that tag and float +/// rules are reflected in the first frame's layout. +pub fn initialize(window: *Window) void { + if (window.initialized) { @branchHint(.unlikely); - 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 }); + return; } + window.initialized = true; + const river_window_v1 = window.river_window_v1; + + // TODO: We might want to think about paying attention to the decoration_hint event + // If we do, this would need to move, I think? + river_window_v1.useSsd(); + + river_window_v1.setCapabilities(.{ .fullscreen = true, .maximize = true }); + river_window_v1.setTiled(.{ .top = true, .bottom = true, .left = true, .right = true }); + + const res = window.applyRules(); + if (res.tags) |tags| window.tags = tags; + if (res.float) |should_float| + window.pending_manage.floating = should_float; +} + +pub fn manage(window: *Window) void { // Updating state since the last manage event defer window.pending_manage = .{}; const pending_manage = window.pending_manage; + const river_window_v1 = window.river_window_v1; // Floating status + var became_floating = false; if (pending_manage.floating) |floating| blk: { // This needs to be before proposing the new dimensions since we want to save the current ones! // Skip the rest of the block if floating matches what is already set @@ -173,14 +249,16 @@ pub fn manage(window: *Window) void { window.floating = floating; if (floating) { + became_floating = true; // Let the window know it isn't tiled river_window_v1.setTiled(.{}); if (window.floating_rect.width == 0) { - // Never floated before; use 75% of usable area, centered on output + // This window has never floated before, let's give it floating dimensions. + // Use 75% of the output's usable size, clamped to the window's dimension hints. if (window.output) |output| { - window.floating_rect.width = @divFloor(output.usable_geometry.width * 3, 4); - window.floating_rect.height = @divFloor(output.usable_geometry.height * 3, 4); + window.floating_rect.width = window.dimensions_hint.clampWidth(@divFloor(output.usable_geometry.width * 3, 4)); + window.floating_rect.height = window.dimensions_hint.clampHeight(@divFloor(output.usable_geometry.height * 3, 4)); window.floating_rect.x = output.usable_geometry.x + @divFloor(output.usable_geometry.width, 2) - @divFloor(window.floating_rect.width, 2); window.floating_rect.y = output.usable_geometry.y + @divFloor(output.usable_geometry.height, 2) - @divFloor(window.floating_rect.height, 2); } else { @@ -202,11 +280,14 @@ 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; - 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: { @@ -280,17 +361,51 @@ fn applyBorders(window: *Window, color: utils.RiverColor) void { window.river_window_v1.setBorders(all_sides, border_width, color.red, color.green, color.blue, color.alpha); } +// Iterate over all window rules and apply any that match. +// Later rules in the list overwrite earlier ones. +fn applyRules(window: *Window) struct { + float: ?bool = null, + tags: ?u32 = null, +} { + var float: ?bool = null; + var tags: ?u32 = null; + for (window.context.config.window_rules.items) |rule| { + const app_id_matches = if (rule.app_id_glob) |glob| + if (window.app_id) |app_id| globber.match(app_id, glob) else false + else + true; + const title_matches = if (rule.title_glob) |glob| + if (window.title) |title| globber.match(title, glob) else false + else + true; + if (app_id_matches and title_matches) { + switch (rule.action) { + .float => |should_float| float = should_float, + .tags => |tagmask| tags = tagmask, + } + } + } + + return .{ + .float = float, + .tags = tags, + }; +} + const std = @import("std"); const assert = std.debug.assert; +const math = std.math; const DoublyLinkedList = std.DoublyLinkedList; const wayland = @import("wayland"); const wl = wayland.client.wl; const river = wayland.client.river; +const globber = @import("globber.zig"); const utils = @import("utils.zig"); const Context = @import("Context.zig"); const Output = @import("Output.zig"); const Seat = @import("Seat.zig"); +const WindowRule = @import("Config.zig").WindowRule; const log = std.log.scoped(.Window); diff --git a/src/WindowManager.zig b/src/WindowManager.zig index 1211aa6..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); @@ -212,12 +221,32 @@ fn windowManagerV1Listener(window_manager_v1: *river.WindowManagerV1, event: riv // If there was already an output, but no seats, set the first output as focused if (wm.outputs.first()) |output| { seat.pending_manage.output = .{ .output = output }; + + // Adopt any orphan windows that arrived before we had a seat + var it = wm.orphan_windows.iterator(.forward); + while (it.next()) |window| { + window.pending_manage.pending_output = .{ .output = output }; + } + if (wm.orphan_windows.first()) |first| { + seat.pending_manage.window = .{ .window = first }; + seat.pending_manage.should_warp_pointer = true; + } + output.windows.appendList(&wm.orphan_windows); } }, .window => |ev| { // TODO: Support multiple seats - const seat = wm.seats.first() orelse @panic("Failed to get seat"); - const focused_output = seat.focused_output; + const seat = wm.seats.first(); + const focused_output = if (seat) |s| + s.focused_output orelse if (s.pending_manage.output) |pending_output| + switch (pending_output) { + .output => |output| output, + .clear_focus => null, + } + else + wm.outputs.first() + else + wm.outputs.first(); const window_list = if (focused_output) |output| &output.windows else @@ -228,8 +257,10 @@ fn windowManagerV1Listener(window_manager_v1: *river.WindowManagerV1, event: riv .top => window_list.prepend(window), .bottom => window_list.append(window), } - seat.pending_manage.window = .{ .window = window }; - seat.pending_manage.should_warp_pointer = true; + if (seat) |s| { + s.pending_manage.window = .{ .window = window }; + s.pending_manage.should_warp_pointer = true; + } }, else => |ev| { log.debug("unhandled event: {s}", .{@tagName(ev)}); 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"); }