diff --git a/src/Config.zig b/src/Config.zig new file mode 100644 index 0000000..8625feb --- /dev/null +++ b/src/Config.zig @@ -0,0 +1,13 @@ +// SPDX-FileCopyrightText: 2026 Ben Buhse +// +// SPDX-License-Identifier: GPL-3.0-or-later + +const Config = @This(); + +border_width: u3, +border_color_focused: RiverColor, +border_color_unfocused: RiverColor, + +const std = @import("std"); + +const RiverColor = @import("utils.zig").RiverColor; diff --git a/src/Seat.zig b/src/Seat.zig index b22faaa..e5de30e 100644 --- a/src/Seat.zig +++ b/src/Seat.zig @@ -8,7 +8,7 @@ context: *Context, seat_v1: *river.SeatV1, -hovered: ?*Window, +focused: ?*Window, link: wl.list.Link, @@ -16,7 +16,7 @@ pub fn init(seat: *Seat, context: *Context, river_seat_v1: *river.SeatV1) void { seat.* = .{ .context = context, .seat_v1 = river_seat_v1, - .hovered = null, + .focused = null, .link = undefined, // Handled by the wl.list }; @@ -36,7 +36,12 @@ fn seatListener(river_seat_v1: *river.SeatV1, event: river.SeatV1.Event, seat: * .pointer_enter => |ev| { const window_v1 = ev.window orelse return; const window: *Window = @ptrCast(@alignCast(window_v1.getUserData())); - seat.hovered = window; + seat.focused = window; + }, + .window_interaction => |ev| { + const window_v1 = ev.window orelse return; + const window: *Window = @ptrCast(@alignCast(window_v1.getUserData())); + seat.focused = window; }, else => |ev| { log.debug("unhandled event: {s}", .{@tagName(ev)}); @@ -45,9 +50,8 @@ fn seatListener(river_seat_v1: *river.SeatV1, event: river.SeatV1.Event, seat: * } pub fn manage(seat: *Seat) void { - if (seat.hovered) |window| { + if (seat.focused) |window| { seat.seat_v1.focusWindow(window.window_v1); - seat.hovered = null; } } diff --git a/src/Window.zig b/src/Window.zig index 467e15c..5909f67 100644 --- a/src/Window.zig +++ b/src/Window.zig @@ -1,4 +1,4 @@ -// SPDX-FileCopyrightText: 2025-2026 Ben Buhse +// SPDX-FileCopyrightText: 2025 Ben Buhse // // SPDX-License-Identifier: GPL-3.0-or-later @@ -19,6 +19,8 @@ new_height: u31 = 0, new_x: i32 = 0, new_y: i32 = 0, +is_maximized: bool = false, + link: wl.list.Link, pub fn init(window: *Window, context: *Context, river_window_v1: *river.WindowV1) void { @@ -42,8 +44,8 @@ fn windowListener(river_window_v1: *river.WindowV1, event: river.WindowV1.Event, { var it = window.context.wm.seats.iterator(.forward); while (it.next()) |seat| { - if (seat.hovered == window) { - seat.hovered = null; + if (seat.focused == window) { + seat.focused = null; } } } @@ -66,14 +68,15 @@ fn windowListener(river_window_v1: *river.WindowV1, event: river.WindowV1.Event, } pub fn manage(window: *Window) void { + // Use server-side decoration (no client-drawn title bars) + // TODO: Probably shouldn't send this for every manage(?) + window.window_v1.useSsd(); + // Layout has been pre-computed by WindowManager.calculatePrimaryStackLayout() // Apply the computed dimensions if (window.new_width > 0 and window.new_height > 0) { window.window_v1.proposeDimensions(window.new_width, window.new_height); - // Use server-side decoration (no client-drawn title bars) - window.window_v1.useSsd(); - // Inform the window about its tiled/fullscreen state if (window.context.wm.window_count == 1) { // Single window is fullscreen @@ -81,8 +84,8 @@ pub fn manage(window: *Window) void { window.window_v1.informFullscreen(); } else { // Tiled windows - set tiled edges - window.window_v1.informNotFullscreen(); window.window_v1.setTiled(.{ .top = true, .bottom = true, .left = true, .right = true }); + window.window_v1.informNotFullscreen(); } } } @@ -90,6 +93,28 @@ pub fn manage(window: *Window) void { pub fn render(window: *Window) void { // Set the window position using the pre-computed layout window.node_v1.setPosition(window.new_x, window.new_y); + + // Set borders + if (!window.is_maximized) { + const border_width = window.context.config.border_width; + if (window.isFocused()) { + const border_color_focused = window.context.config.border_color_focused; + window.window_v1.setBorders(.{ .top = true, .bottom = true, .left = true, .right = true }, border_width, border_color_focused.red, border_color_focused.green, border_color_focused.blue, border_color_focused.alpha); + } else { + const border_color_unfocused = window.context.config.border_color_unfocused; + window.window_v1.setBorders(.{ .top = true, .bottom = true, .left = true, .right = true }, border_width, border_color_unfocused.red, border_color_unfocused.green, border_color_unfocused.blue, border_color_unfocused.alpha); + } + } +} + +fn isFocused(window: *Window) bool { + var it = window.context.wm.seats.iterator(.forward); + while (it.next()) |seat| { + if (seat.focused == window) { + return true; + } + } + return false; } const std = @import("std"); diff --git a/src/WindowManager.zig b/src/WindowManager.zig index ba67e54..eb37fb5 100644 --- a/src/WindowManager.zig +++ b/src/WindowManager.zig @@ -1,4 +1,4 @@ -// SPDX-FileCopyrightText: 2025-2026 Ben Buhse +// SPDX-FileCopyrightText: 2025 Ben Buhse // // SPDX-License-Identifier: GPL-3.0-or-later @@ -16,6 +16,30 @@ windows: wl.list.Head(Window, .link), window_count: u8 = 0, +/// Get the next window in the list, wrapping to first if at end +pub fn getNextWindow(wm: *WindowManager, current: *Window) ?*Window { + var it = wm.windows.iterator(.forward); + while (it.next()) |window| { + if (window == current) { + return it.next() orelse wm.windows.first(); + } + } + return wm.windows.first(); +} + +/// Get the previous window in the list, wrapping to last if at beginning +pub fn getPrevWindow(wm: *WindowManager, current: *Window) ?*Window { + var prev: ?*Window = null; + var it = wm.windows.iterator(.forward); + while (it.next()) |window| { + if (window == current) { + return prev orelse wm.windows.last(); + } + prev = window; + } + return wm.windows.last(); +} + pub fn init(wm: *WindowManager, context: *Context, window_manager_v1: *river.WindowManagerV1) void { assert(wm == &context.wm); wm.* = .{ @@ -36,7 +60,7 @@ pub fn init(wm: *WindowManager, context: *Context, window_manager_v1: *river.Win /// Calculate primary/stack layout positions for all windows. /// - Single window: fullscreen /// - Multiple windows: stack (45% left, vertically tiled), primary (55% right) -pub fn calculatePrimaryStackLayout(wm: *WindowManager) void { +fn calculatePrimaryStackLayout(wm: *WindowManager) void { const count = wm.window_count; if (count == 0) return; @@ -52,16 +76,20 @@ pub fn calculatePrimaryStackLayout(wm: *WindowManager) void { while (it.next()) |window| { if (count == 1) { // Single window: fullscreen + // TODO: Disable borders when maximized window.new_x = output_x; window.new_y = output_y; window.new_width = output_width; window.new_height = output_height; + window.is_maximized = true; } else { // Multiple windows: primary/stack layout + // TODO: Support multiple primaries const primary_width: u31 = @intFromFloat(@as(f32, @floatFromInt(output_width)) * PRIMARY_RATIO); const stack_width: u31 = output_width - primary_width; const stack_count = count - 1; const stack_height: u31 = @divFloor(output_height, stack_count); + window.is_maximized = false; if (index == 0) { // Primary window (first window) - right side @@ -70,7 +98,7 @@ pub fn calculatePrimaryStackLayout(wm: *WindowManager) void { window.new_width = primary_width; window.new_height = output_height; } else { - // Stack window - left side + // Stack window(s) - left side const stack_index = index - 1; window.new_x = output_x; window.new_y = output_y + @as(i32, stack_index) * @as(i32, stack_height); @@ -82,6 +110,14 @@ pub fn calculatePrimaryStackLayout(wm: *WindowManager) void { window.new_height = stack_height; } } + + // Make space for borders; this is the same for all windows + // (unless we disable borders when maximized?) + const border_width = wm.context.config.border_width; + window.new_height -= 2 * border_width; + window.new_width -= 2 * border_width; + window.new_x += border_width; + window.new_y += border_width; } index += 1; } @@ -97,9 +133,17 @@ fn windowManagerV1Listener(window_manager_v1: *river.WindowManagerV1, event: riv }, .manage_start => { if (!context.initialized) { + // This code (should) only be run once while initializing the WM, so let's + // mark it as cold. + @branchHint(.cold); context.initialized = true; - context.xkb_bindings.addBinding(xkbcommon.Keysym.t, .{ .mod1 = true }); + context.xkb_bindings.addBinding(xkbcommon.Keysym.t, .{ .mod4 = true }, .{ .spawn = &.{"foot"} }); + context.xkb_bindings.addBinding(xkbcommon.Keysym.j, .{ .mod4 = true }, .focus_next); + context.xkb_bindings.addBinding(xkbcommon.Keysym.k, .{ .mod4 = true }, .focus_prev); + context.xkb_bindings.addBinding(xkbcommon.Keysym.q, .{ .mod4 = true, .shift = true }, XkbBindings.Command.close_window); + context.xkb_bindings.addBinding(xkbcommon.Keysym.e, .{ .mod4 = true, .shift = true }, XkbBindings.Command.exit); } + { var it = wm.seats.iterator(.forward); while (it.next()) |seat| { @@ -182,5 +226,6 @@ const Context = @import("main.zig").Context; const Output = @import("Output.zig"); const Seat = @import("Seat.zig"); const Window = @import("Window.zig"); +const XkbBindings = @import("XkbBindings.zig"); const log = std.log.scoped(.WindowManager); diff --git a/src/XkbBindings.zig b/src/XkbBindings.zig index 661cef2..0f0b771 100644 --- a/src/XkbBindings.zig +++ b/src/XkbBindings.zig @@ -4,13 +4,25 @@ const XkbBindings = @This(); +pub const Command = union(enum) { + spawn: []const []const u8, + focus_next, + focus_prev, + close_window, + exit, +}; + const XkbBinding = struct { xkb_binding_v1: *river.XkbBindingV1, + command: Command, + context: *Context, link: wl.list.Link, - fn init(xkb_binding: *XkbBinding, xkb_binding_v1: *river.XkbBindingV1) void { + fn init(xkb_binding: *XkbBinding, xkb_binding_v1: *river.XkbBindingV1, command: Command, context: *Context) void { xkb_binding.* = .{ .xkb_binding_v1 = xkb_binding_v1, + .command = command, + .context = context, .link = undefined, // Handled by the wl.list }; @@ -21,10 +33,7 @@ const XkbBinding = struct { assert(xkb_binding.xkb_binding_v1 == xkb_binding_v1); switch (event) { .pressed => { - var child = std.process.Child.init(&.{"foot"}, std.heap.c_allocator); - _ = child.spawn() catch |err| { - log.err("Failed to spawn foot: {}", .{err}); - }; + xkb_binding.executeCommand(); }, .released => {}, else => |ev| { @@ -32,6 +41,48 @@ const XkbBinding = struct { }, } } + + fn executeCommand(xkb_binding: *XkbBinding) void { + const context = xkb_binding.context; + switch (xkb_binding.command) { + .spawn => |cmd| { + var child = std.process.Child.init(cmd, std.heap.c_allocator); + _ = child.spawn() catch |err| { + log.err("Failed to spawn foot: {}", .{err}); + }; + }, + .focus_next => { + const seat = context.wm.seats.first() orelse return; + if (seat.focused) |current| { + seat.focused = context.wm.getNextWindow(current); + } else { + // No window focused, focus the first one + seat.focused = context.wm.windows.first(); + } + context.wm.window_manager_v1.manageDirty(); + }, + .focus_prev => { + const seat = context.wm.seats.first() orelse return; + if (seat.focused) |current| { + seat.focused = context.wm.getPrevWindow(current); + } else { + // No window focused, focus the last one + seat.focused = context.wm.windows.last(); + } + context.wm.window_manager_v1.manageDirty(); + }, + .close_window => { + const seat = context.wm.seats.first() orelse return; + if (seat.focused) |window| { + window.window_v1.close(); + } + }, + .exit => { + // TODO: Disabled while I'm working with river within river :P + // _ = std.process.Child.init(&.{"killall river"}, std.heap.c_allocator); + }, + } + } }; context: *Context, @@ -58,7 +109,7 @@ pub fn getSeat(xkb_bindings: *XkbBindings) *river.SeatV1 { return seat.seat_v1; } -pub fn addBinding(xkb_bindings: *XkbBindings, keysym: u32, modifiers: river.SeatV1.Modifiers) void { +pub fn addBinding(xkb_bindings: *XkbBindings, keysym: u32, modifiers: river.SeatV1.Modifiers, command: Command) void { const seat_v1 = xkb_bindings.getSeat(); const xkb_binding_v1 = xkb_bindings.xkb_bindings_v1.getXkbBinding(seat_v1, keysym, modifiers) catch |err| { log.err("Failed to get xkb binding: {}", .{err}); @@ -66,7 +117,7 @@ pub fn addBinding(xkb_bindings: *XkbBindings, keysym: u32, modifiers: river.Seat }; const xkb_binding = xkb_bindings.context.allocator.create(XkbBinding) catch @panic("out-of-memory"); - xkb_binding.init(xkb_binding_v1); + xkb_binding.init(xkb_binding_v1, command, xkb_bindings.context); xkb_bindings.bindings.append(xkb_binding); xkb_binding_v1.enable(); diff --git a/src/main.zig b/src/main.zig index ca863ab..c789dd4 100644 --- a/src/main.zig +++ b/src/main.zig @@ -17,6 +17,8 @@ pub const Context = struct { wm: WindowManager, xkb_bindings: XkbBindings, + config: Config, + fn registryListener(registry: *wl.Registry, event: wl.Registry.Event, context: *Context) void { switch (event) { .global => |ev| { @@ -77,6 +79,8 @@ pub fn main() !void { .registry = registry, .wm = undefined, .xkb_bindings = undefined, + // Hardcoded config for now + .config = .{ .border_width = 2, .border_color_focused = utils.parseRgbaComptime("0x89b4fa"), .border_color_unfocused = utils.parseRgbaComptime("0x1e1e2e") }, }; registry.setListener(*Context, Context.registryListener, &context); @@ -97,6 +101,7 @@ pub fn main() !void { log.info("Exiting beansprout", .{}); } +// TODO: Actually use this... fn interfaceNotAdvertised(comptime WaylandGlobal: type) noreturn { log.err("{s} not advertised. Exiting", .{WaylandGlobal.interface.name}); std.posix.exit(1); @@ -114,6 +119,8 @@ const wayland = @import("wayland"); const river = wayland.client.river; const wl = wayland.client.wl; +const utils = @import("utils.zig"); +const Config = @import("Config.zig"); const WindowManager = @import("WindowManager.zig"); const XkbBindings = @import("XkbBindings.zig"); diff --git a/src/utils.zig b/src/utils.zig new file mode 100644 index 0000000..8b47218 --- /dev/null +++ b/src/utils.zig @@ -0,0 +1,67 @@ +// SPDX-FileCopyrightText: 2026 Ben Buhse +// +// SPDX-License-Identifier: GPL-3.0-or-later + +pub const RiverColor = struct { + red: u32, + green: u32, + blue: u32, + alpha: u32, +}; + +/// Parse a color in the format 0xRRGGBB or 0xRRGGBBAA and convert it to +/// 32-bit color values (used by Window.set_borders in rwm). +pub fn parseRgba(s: []const u8) !RiverColor { + if (s.len != 8 and s.len != 10) return error.InvalidRgba; + if (s[0] != '0' or s[1] != 'x') return error.InvalidRgba; + + // If the color is 0xRRGGBB, add FF for the alpha channel + var color = try fmt.parseUnsigned(u32, s[2..], 16); + if (s.len == 8) { + color <<= 8; + color |= 0xff; + } + + const bytes: [4]u8 = @as([4]u8, @bitCast(color)); + return parseRgbaHelper(bytes); +} + +/// Parse a color in the format 0xRRGGBB or 0xRRGGBBAA and convert it to +/// 32-bit color values (used by Window.set_borders in rwm) at comptime. +pub fn parseRgbaComptime(comptime s: []const u8) RiverColor { + if (s.len != 8 and s.len != 10) @compileError("Invalid RGBA"); + if (s[0] != '0' or s[1] != 'x') @compileError("Invalid RGBA"); + + // If the color is 0xRRGGBB, add FF for the alpha channel + comptime var color = try fmt.parseUnsigned(u32, s[2..], 16); + if (s.len == 8) { + color <<= 8; + color |= 0xff; + } + + const bytes = @as([4]u8, @bitCast(color)); + return parseRgbaHelper(bytes); +} + +fn parseRgbaHelper(bytes: [4]u8) RiverColor { + const r: u32 = bytes[3]; + const g: u32 = bytes[2]; + const b: u32 = bytes[1]; + const a: u32 = bytes[0]; + + // To convert from an 8-bit color to 32-bit color, we need to do + // color * 2^32 / 2^8 + // which is equivalent to + // color * 2^24 + // or, in other words, + // color << 24 + return .{ + .red = r << 24, + .green = g << 24, + .blue = b << 24, + .alpha = a << 24, + }; +} + +const std = @import("std"); +const fmt = std.fmt;