From 98d15e37732f03329bcdcd08ba7929214eb2efe9 Mon Sep 17 00:00:00 2001 From: Ben Buhse Date: Thu, 26 Feb 2026 17:26:52 -0600 Subject: [PATCH 1/7] build: use type inference for manifest import --- build.zig | 44 +------------------------------------------- 1 file changed, 1 insertion(+), 43 deletions(-) diff --git a/build.zig b/build.zig index 43a0333..2640ad8 100644 --- a/build.zig +++ b/build.zig @@ -118,46 +118,4 @@ pub fn build(b: *std.Build) !void { } const version = manifest.version; -/// Needed until https://github.com/ziglang/zig/issues/22775 -/// is addressed. -const manifest: struct { - name: @Type(.enum_literal), - version: []const u8, - fingerprint: u64, - minimum_zig_version: []const u8, - dependencies: struct { - wayland: struct { - url: []const u8, - hash: []const u8, - }, - xkbcommon: struct { - url: []const u8, - hash: []const u8, - }, - kdl: struct { - url: []const u8, - hash: []const u8, - }, - known_folders: struct { - url: []const u8, - hash: []const u8, - }, - pixman: struct { - url: []const u8, - hash: []const u8, - }, - zigimg: struct { - url: []const u8, - hash: []const u8, - }, - fcft: struct { - url: []const u8, - hash: []const u8, - }, - zeit: struct { - url: []const u8, - hash: []const u8, - }, - }, - paths: []const []const u8, -} = @import("build.zig.zon"); +const manifest = @import("build.zig.zon"); From 062748967ccae3b901ea9305017f35b96634e3a1 Mon Sep 17 00:00:00 2001 From: Ben Buhse Date: Thu, 26 Feb 2026 17:29:22 -0600 Subject: [PATCH 2/7] Move Utf-8 -> codepoint conversion to utils Once we add more text to the bar, it makes sense to move this into a helper function. --- src/Bar.zig | 13 ++---------- src/Window.zig | 2 +- src/utils.zig | 54 ++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 57 insertions(+), 12 deletions(-) diff --git a/src/Bar.zig b/src/Bar.zig index 4935bed..5a4922a 100644 --- a/src/Bar.zig +++ b/src/Bar.zig @@ -224,17 +224,9 @@ pub fn draw(bar: *Bar) !void { var fbs = io.fixedBufferStream(&buf); try dt.strftime(fbs.writer(), "%H:%M"); - // Convert ASCII text string to unicode - // XXX: Not sure if this even needs to be converted to unicode - var codepoint_it = (try unicode.Utf8View.init(fbs.getWritten())).iterator(); - const codepoint_count = try unicode.utf8CountCodepoints(fbs.getWritten()); - // We use u32 for fcft even if zig uses u21 - var codepoints: []u32 = try utils.gpa.alloc(u32, codepoint_count); + // Convert date string to Unicode codepoints + const codepoints = try utils.utf8ToCodepoints(fbs.getWritten()); defer utils.gpa.free(codepoints); - var i: usize = 0; - while (codepoint_it.nextCodepoint()) |cp| : (i += 1) { - codepoints[i] = cp; - } const text_width = try bar.textWidth(codepoints); var x: i32 = @divFloor(buffer.width - text_width, 2); @@ -393,7 +385,6 @@ const assert = std.debug.assert; const io = std.io; const mem = std.mem; const process = std.process; -const unicode = std.unicode; const wayland = @import("wayland"); const wl = wayland.client.wl; diff --git a/src/Window.zig b/src/Window.zig index 2cbe4fa..38cb860 100644 --- a/src/Window.zig +++ b/src/Window.zig @@ -145,7 +145,7 @@ fn windowListener(river_window_v1: *river.WindowV1, event: river.WindowV1.Event, 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 (output.nextWindow(window)) |next_focus| { if (next_focus != window) { seat.pending_manage.window = .{ .window = next_focus }; seat.pending_manage.should_warp_pointer = true; diff --git a/src/utils.zig b/src/utils.zig index 094b494..0243443 100644 --- a/src/utils.zig +++ b/src/utils.zig @@ -193,6 +193,21 @@ pub fn stripQuotes(s: []const u8) []const u8 { return s; } +/// Convert a Utf-8 string into codepoints +/// Caller owns the returned slice and is responsible for freeing it. +pub fn utf8ToCodepoints(utf8: []const u8) ![]u32 { + var codepoint_it = (try unicode.Utf8View.init(utf8)).iterator(); + const codepoint_count = try unicode.utf8CountCodepoints(utf8); + // We use u32 for fcft even if zig uses u21 + const codepoints: []u32 = try gpa.alloc(u32, codepoint_count); + var i: usize = 0; + while (codepoint_it.nextCodepoint()) |cp| : (i += 1) { + codepoints[i] = cp; + } + + return codepoints; +} + /// Report that the given WaylandGlobal wasn't advertised and exit the program pub fn interfaceNotAdvertised(comptime WaylandGlobal: type) noreturn { fatal("{s} not advertised. Exiting", .{WaylandGlobal.interface.name}); @@ -207,6 +222,7 @@ const std = @import("std"); const fatal = std.process.fatal; const fmt = std.fmt; const mem = std.mem; +const unicode = std.unicode; const wayland = @import("wayland"); const river = wayland.client.river; @@ -447,3 +463,41 @@ test "tokenizeShell quotes mid-token" { try testing.expectEqual(1, result.len); try testing.expectEqualStrings("foobar bazqux", result[0]); } + +test "utf8ToCodepoints ASCII" { + const codepoints = try utf8ToCodepoints("hello"); + defer gpa.free(codepoints); + try testing.expectEqual(5, codepoints.len); + try testing.expectEqual('h', codepoints[0]); + try testing.expectEqual('e', codepoints[1]); + try testing.expectEqual('l', codepoints[2]); + try testing.expectEqual('l', codepoints[3]); + try testing.expectEqual('o', codepoints[4]); +} + +test "utf8ToCodepoints multi-byte" { + const codepoints = try utf8ToCodepoints("grüezi"); + defer gpa.free(codepoints); + try testing.expectEqual(6, codepoints.len); + try testing.expectEqual('g', codepoints[0]); + try testing.expectEqual('r', codepoints[1]); + try testing.expectEqual(0x00FC, codepoints[2]); // ü + try testing.expectEqual('e', codepoints[3]); + try testing.expectEqual('z', codepoints[4]); + try testing.expectEqual('i', codepoints[5]); +} + +test "utf8ToCodepoints empty" { + const codepoints = try utf8ToCodepoints(""); + defer gpa.free(codepoints); + try testing.expectEqual(0, codepoints.len); +} + +test "utf8ToCodepoints emoji" { + // 🇨🇦 is two regional indicator symbols: U+1F1E8 U+1F1E6 + const codepoints = try utf8ToCodepoints("🇨🇦"); + defer gpa.free(codepoints); + try testing.expectEqual(2, codepoints.len); + try testing.expectEqual(0x1F1E8, codepoints[0]); + try testing.expectEqual(0x1F1E6, codepoints[1]); +} From efd0222899f635e284c47ed4a98ac6867367eb73 Mon Sep 17 00:00:00 2001 From: Ben Buhse Date: Fri, 27 Feb 2026 10:42:08 -0600 Subject: [PATCH 3/7] Add window title and wm info to Bar This commit adds the focused window title to the left side of the bar and some WM info (primary count/ratio and # of visible/total windows) to the right side. It also adds new vertical_padding and horizontal_padding config options for the bar. --- docs/CONFIGURATION.md | 19 +++--- docs/TODO.md | 5 +- man/beansprout.5.scd | 14 ++++- src/Bar.zig | 133 ++++++++++++++++++++++++++++++++------- src/Context.zig | 34 ++++++---- src/Output.zig | 19 ++++++ src/Seat.zig | 8 ++- src/Window.zig | 13 ++++ src/config/BarConfig.zig | 37 +++++++++-- 9 files changed, 231 insertions(+), 51 deletions(-) diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 8bd9adf..e68daf1 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -126,10 +126,11 @@ do not re-trigger rules. ## Bar -The bar is an optional widget that shows the time on your screen. Right now, that's it. -It is only created when a `bar` block is present in the config. All settings have -defaults, with the color based on the Catppuccin Mocha theme. An empty block can be used -to enable the widget with all defaults: +The bar is an optional widget that shows the focused window title on the left, +the date/time in the center, and layout info (primary count/ratio and visible +window count) on the right. It is only created when a `bar` block is present in +the config. All settings have defaults, with the color based on the Catppuccin +Mocha theme. An empty block can be used to enable the widget with all defaults: ```kdl bar { @@ -140,10 +141,12 @@ bar { | Setting | Type | Default | Description | |--------------------|--------|--------------------|-----------------------------------| -| `fonts` | string | `monospace:size=14` | Comma-separated FontConfig fonts | -| `text_color` | color | `0xcdd6f4` | Text color | -| `background_color` | color | `0x1e1e2e` | Background color | -| `position` | enum | `top` | Bar position (`top` or `bottom`) | +| `fonts` | string | `monospace:size=14` | Comma-separated FontConfig fonts | +| `text_color` | color | `0xcdd6f4` | Text color | +| `background_color` | color | `0x1e1e2e` | Background color | +| `position` | enum | `top` | Bar position (`top` or `bottom`) | +| `vertical_padding` | u8 | `5` | Vertical padding above and below text | +| `horizontal_padding` | u8 | `5` | Horizontal padding between bar edges and text | ### Margins diff --git a/docs/TODO.md b/docs/TODO.md index e47f7a0..0140a53 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -2,15 +2,14 @@ These are in rough order of my priority, though no promises I do them in this order. +- [ ] Add gap support - [ ] Add build-time options for including the wallpaper (and maybe bar) - [ ] Check pointer position and only warp if not on focused window already - [ ] Change focus direction when closing window - [ ] Use set_xcursor_theme request - [ ] Support configuring bar item positions (left/center/right) -- [ ] Add focused window title to bar - [ ] Support overriding config location - [ ] Add support for center-primary layout -- [ ] Add bar padding to config - [ ] Support 12-hour clock format (maybe take any time format string?) - [ ] Support per-output bar visibility - [ ] Support more window rule options (e.g. ssd/csd) @@ -49,3 +48,5 @@ These are in rough order of my priority, though no promises I do them in this or - [x] Move orphan handling out of .output and .seat events; into manage() - [x] Add config for single-window width ratio (mostly because my ultrawide makes a single window massive) - [x] Support configuring primary vs secondary stack side +- [x] Add focused window title to bar +- [x] Add bar padding to config diff --git a/man/beansprout.5.scd b/man/beansprout.5.scd index 6bf9c58..2c73352 100644 --- a/man/beansprout.5.scd +++ b/man/beansprout.5.scd @@ -114,8 +114,10 @@ initialization do not re-trigger rules. # BAR -The bar is an optional widget that shows the time. It is only created when a -*bar* block is present: +The bar is an optional widget that shows the focused window title on the left, +the date/time in the center, and layout info (primary count/ratio and visible +window count) on the right. It is only created when a *bar* block is present +in the config: ``` bar { @@ -135,9 +137,17 @@ bar { *position* *top*|*bottom* Bar position. (Default: *top*) +*vertical_padding* _pixels_ + Vertical padding above and below text. (Default: 5) + +*horizontal_padding* _pixels_ + Horizontal padding between bar edges and text. (Default: 5) + The bar also supports *margins* and *anchors* child blocks; see *TAG OVERLAY* for their format. +An empty block can be used to enable the widget with all defaults. + # TAG OVERLAY The tag overlay is an optional widget that briefly shows tag state when diff --git a/src/Bar.zig b/src/Bar.zig index 5a4922a..98a65e6 100644 --- a/src/Bar.zig +++ b/src/Bar.zig @@ -55,7 +55,12 @@ pub const Options = struct { /// Whether the bar is at the top or bottom of the screen position: Position = .top, /// Directional margins top, right, bottom, left, in pixels - margins: struct { top: i32 = 0, right: i32 = 0, bottom: i32 = 0, left: i32 = 0 } = .{}, + margins: struct { top: u8 = 0, right: u8 = 0, bottom: u8 = 0, left: u8 = 0 } = .{}, + + /// Vertical padding between bar edges and content, in pixels + vertical_padding: u8 = 5, + /// Horizontal padding between bar edges and content, in pixels + horizontal_padding: u8 = 5, }; pub fn init(context: *Context, output: *Output, options: Options) !Bar { @@ -98,12 +103,14 @@ pub fn init(context: *Context, output: *Output, options: Options) !Bar { .node = node, }, .configured = true, + .pending_manage = .{ .output_geometry = true }, }; } pub fn deinit(bar: *Bar) void { bar.configured = false; bar.timezone.deinit(); + bar.fcft_fonts.destroy(); if (bar.surfaces) |surfaces| { surfaces.node.destroy(); surfaces.river_shell_surface.destroy(); @@ -112,6 +119,16 @@ pub fn deinit(bar: *Bar) void { } } +/// Update bar options in-place without destroying/recreating Wayland surfaces +/// This is used when reloading the config +pub fn reconfigure(bar: *Bar, options: Options) !void { + const new_fonts = try getFcftFonts(options.fonts, bar.font_scale); + bar.fcft_fonts.destroy(); + bar.fcft_fonts = new_fonts; + bar.options = options; + bar.pending_manage.output_geometry = true; +} + pub fn manage(bar: *Bar) !void { if (!bar.configured) return; defer bar.pending_manage = .{}; @@ -131,10 +148,9 @@ pub fn manage(bar: *Bar) !void { bar.font_scale = scale; } - const vertical_padding = 5; const logical_font_height = @divFloor(bar.fcft_fonts.height, @as(i32, bar.font_scale)); - const height: u31 = @intCast(logical_font_height + 2 * vertical_padding); - const width: u31 = output.geometry.width; + const height: u31 = @intCast(logical_font_height + 2 * options.vertical_padding); + const width: u31 = output.geometry.width -| @as(u31, @intCast(options.margins.left + options.margins.right)); if (bar.geometry.width != width or bar.geometry.height != height) { bar.geometry.width = width; @@ -206,9 +222,11 @@ pub fn draw(bar: *Bar) !void { ); // Set-up text color - const text_color = options.text_color; - const color = pixman.Image.createSolidFill(&text_color) orelse return error.FailedToCreatePixmanImage; - defer _ = color.unref(); + const text_color = pixman.Image.createSolidFill(&options.text_color) orelse return error.FailedToCreatePixmanImage; + defer _ = text_color.unref(); + + // Y is shared between all components + const y: i32 = @divFloor(buffer.height - bar.fcft_fonts.height, 2); // Get the current time in seconds since the epoch, // then load the local timezone, @@ -220,24 +238,78 @@ pub fn draw(bar: *Bar) !void { const dt = now_local.time(); // Convert time to a string - var buf: [255:0]u8 = undefined; - var fbs = io.fixedBufferStream(&buf); - try dt.strftime(fbs.writer(), "%H:%M"); + var time_buf: [255:0]u8 = undefined; + var time_writer = Io.Writer.fixed(&time_buf); + try dt.strftime(&time_writer, "%H:%M"); // Convert date string to Unicode codepoints - const codepoints = try utils.utf8ToCodepoints(fbs.getWritten()); - defer utils.gpa.free(codepoints); + const time_codepoints = try utils.utf8ToCodepoints(time_writer.buffered()); + defer utils.gpa.free(time_codepoints); - const text_width = try bar.textWidth(codepoints); - var x: i32 = @divFloor(buffer.width - text_width, 2); - const y: i32 = @divFloor(buffer.height - bar.fcft_fonts.height, 2); + // Get the width of the date string so we can truncate title + const center_width = try bar.textWidth(time_codepoints); + // X changes + var center_x: i32 = @divFloor(buffer.width - center_width, 2); - // Actually render the unicode codepoints - try bar.renderChars(codepoints, buffer, &x, y, color); + // Write title of focused window to the left side of the bar + if (context.wm.seats.first()) |seat| { + if (seat.focused_window) |window| { + if (window.title) |title| { + if (title.len > 0) { + const title_codepoints = try utils.utf8ToCodepoints(title); + defer utils.gpa.free(title_codepoints); - // Finally, attach the buffer to the surface + const max_left_width = center_x - 2 * options.horizontal_padding; + const truncated_codepoints = try bar.truncateToWidth(title_codepoints, max_left_width); + + var left_x: i32 = options.horizontal_padding; + + try bar.renderChars( + truncated_codepoints, + buffer, + &left_x, + y, + text_color, + ); + } + } + } + } + + // Put WM info on the right side of the bar + const output = bar.output; + var wm_info_buf: [255:0]u8 = undefined; + var wm_info_writer = Io.Writer.fixed(&wm_info_buf); + const ratio_percent: u32 = @intFromFloat(@round(output.primary_ratio * 100)); + try wm_info_writer.print("P:{d}/{d}% W:{d}({d})", .{ + output.primary_count, + ratio_percent, + output.countVisible(), + output.windows.length(), + }); + const wm_info_codepoints = try utils.utf8ToCodepoints(wm_info_writer.buffered()); + defer utils.gpa.free(wm_info_codepoints); + + const max_right_width = buffer.width - (center_x + center_width) - 2 * options.horizontal_padding; + const right_truncated = try bar.truncateToWidth(wm_info_codepoints, max_right_width); + const right_text_width = try bar.textWidth(right_truncated); + + var right_x: i32 = buffer.width - right_text_width - options.horizontal_padding; + try bar.renderChars( + right_truncated, + buffer, + &right_x, + y, + text_color, + ); + + // Finally, put the time in the center of the bar + try bar.renderChars(time_codepoints, buffer, ¢er_x, y, text_color); + + // Really finally, attach the buffer to the surface const surfaces = bar.surfaces orelse return error.NoSurfaces; const wl_surface = surfaces.wl_surface; + // sync_next_commit ensures frame-perfect application surfaces.river_shell_surface.syncNextCommit(); wl_surface.setBufferScale(scale); wl_surface.attach(buffer.wl_buffer, 0, 0); @@ -245,7 +317,6 @@ pub fn draw(bar: *Bar) !void { wl_surface.commit(); } -// TODO: This should be moved to utils once fonts are in config /// Computes the pixel width of a text string. fn textWidth(bar: *Bar, text: []const u32) !i32 { var width: i32 = 0; @@ -262,6 +333,25 @@ fn textWidth(bar: *Bar, text: []const u32) !i32 { return width; } +/// Return the longest prefix of `text` that fits within `max_width` pixels. +fn truncateToWidth(bar: *Bar, text: []const u32, max_width: i32) ![]const u32 { + var width: i32 = 0; + for (text, 0..) |cp, i| { + const glyph = try bar.fcft_fonts.rasterizeCharUtf32(cp, .default); + if (i > 0) { + var x_kern: c_long = 0; + if (bar.fcft_fonts.kerning(text[i - 1], cp, &x_kern, null)) { + width += @intCast(x_kern); + } + } + if (width + glyph.advance.x > max_width) { + return text[0..i]; + } + width += glyph.advance.x; + } + return text; +} + // Borrowed and modified from https://git.sr.ht/~novakane/zig-fcft-example fn renderChars( bar: *Bar, @@ -365,7 +455,7 @@ fn getFcftFonts(fonts: []const u8, scale: u31) !*fcft.Font { log.debug("Scaling font DPI: base={d} scale={d}", .{ base_dpi, scale }); const scaled = try arena_alloc.dupeZ( u8, - try std.fmt.allocPrint(arena_alloc, "{s}:dpi={}", .{ font, @as(u32, base_dpi) * scale }), + try fmt.allocPrint(arena_alloc, "{s}:dpi={}", .{ font, @as(u32, base_dpi) * scale }), ); try list.append(arena_alloc, scaled); } else { @@ -382,9 +472,10 @@ fn getFcftFonts(fonts: []const u8, scale: u31) !*fcft.Font { const std = @import("std"); const assert = std.debug.assert; -const io = std.io; +const fmt = std.fmt; const mem = std.mem; const process = std.process; +const Io = std.Io; const wayland = @import("wayland"); const wl = wayland.client.wl; diff --git a/src/Context.zig b/src/Context.zig index 78f2e71..3cd35c0 100644 --- a/src/Context.zig +++ b/src/Context.zig @@ -200,23 +200,33 @@ pub fn manage(context: *Context) void { } } - // Recreate or destroy bars on all outputs + // Update, create, or destroy bars on all outputs const has_bar = new_config.bar_config != null; if (had_bar or has_bar) { var out_it = context.wm.outputs.iterator(.forward); while (out_it.next()) |output| { - // Destroy existing bar - if (output.bar) |*bar| { - bar.deinit(); - output.bar = null; - } - // Create new bar if configured if (new_config.bar_config) |bar_config| { - output.bar = Bar.init(context, output, bar_config.toBarOptions()) catch |e| { - log.err("Failed to create bar: {}", .{e}); - continue; - }; - output.bar.?.pending_manage.output_geometry = true; + if (output.bar) |*bar| { + // Existing bar; reconfigure in-place to keep surfaces + bar.reconfigure(bar_config.toBarOptions()) catch |e| { + log.err("Failed to reconfigure bar: {}", .{e}); + bar.deinit(); + output.bar = null; + continue; + }; + } else { + // No bar; we need to initialize a new one + output.bar = Bar.init(context, output, bar_config.toBarOptions()) catch |e| { + log.err("Failed to create bar: {}", .{e}); + continue; + }; + } + } else { + // New config doesn't have a bar; destroy existing one + if (output.bar) |*bar| { + bar.deinit(); + output.bar = null; + } } } } diff --git a/src/Output.zig b/src/Output.zig index 03c934f..3f00687 100644 --- a/src/Output.zig +++ b/src/Output.zig @@ -523,10 +523,16 @@ pub fn manage(output: *Output) void { Config.min_primary_ratio, Config.max_primary_ratio, ); + if (output.bar) |*bar| { + bar.pending_render.draw = true; + } } if (output.pending_manage.primary_count) |primary_count| { // Don't allow less than 1 primary output.primary_count = @max(1, primary_count); + if (output.bar) |*bar| { + bar.pending_render.draw = true; + } } if (output.pending_manage.single_window_ratio) |single_window_ratio| { output.single_window_ratio = std.math.clamp( @@ -580,6 +586,10 @@ pub fn manage(output: *Output) void { }; } } + + if (output.bar) |*bar| { + bar.pending_render.draw = true; + } } if (output.bar) |*bar| { @@ -794,6 +804,15 @@ pub fn occupiedTags(output: *Output) u32 { return occupied_tags; } +pub fn countVisible(output: *Output) usize { + var visible: usize = 0; + var it = output.windows.iterator(.forward); + while (it.next()) |window| { + if (window.tags & output.tags != 0) visible += 1; + } + return visible; +} + const std = @import("std"); const assert = std.debug.assert; const mem = std.mem; diff --git a/src/Seat.zig b/src/Seat.zig index b24e050..012ab3c 100644 --- a/src/Seat.zig +++ b/src/Seat.zig @@ -137,9 +137,15 @@ pub fn manage(seat: *Seat) void { switch (pending_window) { .window => |window| { if (seat.focused_window) |focused| { - // Tell the previously focused Window that it's no longer focused if (focused != window) { + // Tell the previously focused Window that it's no longer focused focused.pending_render.focused = false; + // Update the Bar to have the newly-focused window's title + if (focused.output) |output| { + if (output.bar) |*bar| { + bar.pending_render.draw = true; + } + } } } seat.focused_window = window; diff --git a/src/Window.zig b/src/Window.zig index 38cb860..c8161b9 100644 --- a/src/Window.zig +++ b/src/Window.zig @@ -188,6 +188,19 @@ fn windowListener(river_window_v1: *river.WindowV1, event: river.WindowV1.Event, utils.gpa.dupe(u8, std.mem.span(t)) catch @panic("Out of memory") else null; + + // Need to update the bar if this window is focused + if (window.context.wm.seats.first()) |seat| { + if (seat.focused_window) |focused_window| { + if (focused_window == window) { + if (window.output) |output| { + if (output.bar) |*bar| { + bar.pending_render.draw = true; + } + } + } + } + } }, .parent => |ev| { // Nothing to do if ev.parent is null diff --git a/src/config/BarConfig.zig b/src/config/BarConfig.zig index 5e3b23c..bf20c67 100644 --- a/src/config/BarConfig.zig +++ b/src/config/BarConfig.zig @@ -9,6 +9,8 @@ const NodeName = enum { text_color, background_color, position, + vertical_padding, + horizontal_padding, margins, }; const MarginsNodeName = enum { top, right, bottom, left }; @@ -18,11 +20,23 @@ const MarginsNodeName = enum { top, right, bottom, left }; fonts: ?[]const u8 = null, text_color: pixman.Color = utils.parseRgbaPixmanComptime("0xcdd6f4"), background_color: pixman.Color = utils.parseRgbaPixmanComptime("0x1e1e2e"), + +/// Whether the bar is at the top or bottom of the screen position: Bar.Position = .top, -margin_top: i32 = 0, -margin_right: i32 = 0, -margin_bottom: i32 = 0, -margin_left: i32 = 0, + +/// Margin above the top of the bar and another element (a window or the top of the output) +margin_top: u8 = 0, +/// Margin above the right of the bar and another element (a window or the top of the output) +margin_right: u8 = 0, +/// Margin above bottom top of the bar and another element (a window or the top of the output) +margin_bottom: u8 = 0, +/// Margin above left top of the bar and another element (a window or the top of the output) +margin_left: u8 = 0, + +/// Vertical padding between bar edges and content, in pixels +vertical_padding: u8 = 5, +/// Horizontal padding between bar edges and content, in pixels +horizontal_padding: u8 = 5, pub fn toBarOptions(config: BarConfig) Bar.Options { return .{ @@ -36,6 +50,8 @@ pub fn toBarOptions(config: BarConfig) Bar.Options { .bottom = config.margin_bottom, .left = config.margin_left, }, + .vertical_padding = config.vertical_padding, + .horizontal_padding = config.horizontal_padding, }; } @@ -76,6 +92,7 @@ pub fn load(config: *Config, parser: *kdl.Parser, hostname: ?[]const u8) !void { logWarnInvalidNodeArg(name, val_str); } }, + .margins => next_child_block = .margins, inline .background_color, .text_color, @@ -86,6 +103,16 @@ pub fn load(config: *Config, parser: *kdl.Parser, hostname: ?[]const u8) !void { }; logDebugSettingNode(name, val_str); }, + inline .vertical_padding, + .horizontal_padding, + => |tag| { + const padding_str = utils.stripQuotes(node.arg(parser, 0) orelse ""); + const padding = fmt.parseInt(u8, padding_str, 10) catch { + logWarnInvalidNodeArg(name, padding_str); + continue; + }; + @field(config.bar_config.?, @tagName(tag)) = padding; + }, } } else { helpers.logWarnInvalidNode(node.name); @@ -117,7 +144,7 @@ fn loadMarginsBlock(config: *Config, parser: *kdl.Parser, hostname: ?[]const u8) continue; } const val_str = utils.stripQuotes(node.arg(parser, 0) orelse ""); - const val = fmt.parseInt(i32, val_str, 10) catch { + const val = fmt.parseInt(u8, val_str, 10) catch { logWarnInvalidNodeArg(name, val_str); continue; }; From 0e7d652d24594fff0e36a788c7f47eb8f73f031e Mon Sep 17 00:00:00 2001 From: Ben Buhse Date: Fri, 27 Feb 2026 10:51:42 -0600 Subject: [PATCH 4/7] Add time_format config for custom strftime strings This lets the user change to any time format they want in the bar. As part of this, we also change the bar to re-draw every second (to allow using seconds in the time format string). --- docs/CONFIGURATION.md | 1 + docs/TODO.md | 2 +- examples/config.kdl | 1 + man/beansprout.5.scd | 5 +++++ src/Bar.zig | 8 +++++++- src/config/BarConfig.zig | 30 +++++++++++++++++++++++++++++- src/main.zig | 14 ++++++-------- 7 files changed, 50 insertions(+), 11 deletions(-) diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index e68daf1..f7a24ff 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -147,6 +147,7 @@ bar { | `position` | enum | `top` | Bar position (`top` or `bottom`) | | `vertical_padding` | u8 | `5` | Vertical padding above and below text | | `horizontal_padding` | u8 | `5` | Horizontal padding between bar edges and text | +| `time_format` | string | `%Y-%m-%d %H:%M, %A` | strftime format string for the clock display (an empty string hides the clock) | ### Margins diff --git a/docs/TODO.md b/docs/TODO.md index 0140a53..d5493b8 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -10,7 +10,6 @@ These are in rough order of my priority, though no promises I do them in this or - [ ] Support configuring bar item positions (left/center/right) - [ ] Support overriding config location - [ ] Add support for center-primary layout -- [ ] Support 12-hour clock format (maybe take any time format string?) - [ ] Support per-output bar visibility - [ ] Support more window rule options (e.g. ssd/csd) - [ ] Support solid `background-color` fallback (no wallpaper) @@ -50,3 +49,4 @@ These are in rough order of my priority, though no promises I do them in this or - [x] Support configuring primary vs secondary stack side - [x] Add focused window title to bar - [x] Add bar padding to config +- [x] Support 12-hour clock format (maybe take any time format string?) diff --git a/examples/config.kdl b/examples/config.kdl index 8c43c34..2fa5222 100644 --- a/examples/config.kdl +++ b/examples/config.kdl @@ -28,6 +28,7 @@ borders { // Bar widget; shows the time bar { position top + time_format "%H:%M" } // Tag overlay widget; shown briefly when switching tags // Remove this block to disable the overlay entirely diff --git a/man/beansprout.5.scd b/man/beansprout.5.scd index 2c73352..7ac835c 100644 --- a/man/beansprout.5.scd +++ b/man/beansprout.5.scd @@ -143,6 +143,11 @@ bar { *horizontal_padding* _pixels_ Horizontal padding between bar edges and text. (Default: 5) +*time_format* _format_ + strftime format string for the clock display. Invalid format strings + are ignored and the default is used instead. Set to an empty string + to hide the clock. (Default: "%Y-%m-%d %H:%M, %A") + The bar also supports *margins* and *anchors* child blocks; see *TAG OVERLAY* for their format. diff --git a/src/Bar.zig b/src/Bar.zig index 98a65e6..682edd2 100644 --- a/src/Bar.zig +++ b/src/Bar.zig @@ -7,6 +7,9 @@ const Bar = @This(); /// Standard base DPI at a scale of 1 const base_dpi = 96; +/// Default strftime string to use for the clock +pub const default_time_format = "%Y-%m-%d %H:%M, %A"; + context: *Context, /// The timezone of the computer. @@ -61,6 +64,9 @@ pub const Options = struct { vertical_padding: u8 = 5, /// Horizontal padding between bar edges and content, in pixels horizontal_padding: u8 = 5, + + /// strftime format string for the clock display + time_format: []const u8 = default_time_format, }; pub fn init(context: *Context, output: *Output, options: Options) !Bar { @@ -240,7 +246,7 @@ pub fn draw(bar: *Bar) !void { // Convert time to a string var time_buf: [255:0]u8 = undefined; var time_writer = Io.Writer.fixed(&time_buf); - try dt.strftime(&time_writer, "%H:%M"); + try dt.strftime(&time_writer, options.time_format); // Convert date string to Unicode codepoints const time_codepoints = try utils.utf8ToCodepoints(time_writer.buffered()); diff --git a/src/config/BarConfig.zig b/src/config/BarConfig.zig index bf20c67..34ebf99 100644 --- a/src/config/BarConfig.zig +++ b/src/config/BarConfig.zig @@ -12,6 +12,7 @@ const NodeName = enum { vertical_padding, horizontal_padding, margins, + time_format, }; const MarginsNodeName = enum { top, right, bottom, left }; @@ -38,6 +39,10 @@ vertical_padding: u8 = 5, /// Horizontal padding between bar edges and content, in pixels horizontal_padding: u8 = 5, +/// strftime format string for the clock display. +/// null means use the default. +time_format: ?[]const u8 = null, + pub fn toBarOptions(config: BarConfig) Bar.Options { return .{ .fonts = config.fonts orelse "monospace:size=14", @@ -52,6 +57,7 @@ pub fn toBarOptions(config: BarConfig) Bar.Options { }, .vertical_padding = config.vertical_padding, .horizontal_padding = config.horizontal_padding, + .time_format = config.time_format orelse Bar.default_time_format, }; } @@ -92,7 +98,18 @@ pub fn load(config: *Config, parser: *kdl.Parser, hostname: ?[]const u8) !void { logWarnInvalidNodeArg(name, val_str); } }, - + .time_format => { + if (node.argcount() < 1) { + logWarnMissingNodeArg(name, "format string"); + continue; + } + if (validateTimeFormat(val_str)) { + config.bar_config.?.time_format = utils.gpa.dupe(u8, val_str) catch @panic("Out of memory"); + logDebugSettingNode(name, val_str); + } else { + logWarnInvalidNodeArg(name, val_str); + } + }, .margins => next_child_block = .margins, inline .background_color, .text_color, @@ -192,15 +209,26 @@ inline fn logWarnInvalidNodeArg(node_name: anytype, node_value: []const u8) void } } +fn validateTimeFormat(format: []const u8) bool { + // Try formatting with a dummy time to validate the format string + var buf: [255]u8 = undefined; + var writer = Io.Writer.fixed(&buf); + const dummy_time = zeit.Time{}; + dummy_time.strftime(&writer, format) catch return false; + return true; +} + inline fn logWarnMissingNodeArg(node_name: NodeName, comptime arg: []const u8) void { log.warn("\"bar.{s}\" missing " ++ arg ++ " argument. Ignoring", .{@tagName(node_name)}); } const std = @import("std"); const fmt = std.fmt; +const Io = std.Io; const kdl = @import("kdl"); const pixman = @import("pixman"); +const zeit = @import("zeit"); const utils = @import("../utils.zig"); const Bar = @import("../Bar.zig"); diff --git a/src/main.zig b/src/main.zig index 4191091..ba9eceb 100644 --- a/src/main.zig +++ b/src/main.zig @@ -129,19 +129,17 @@ fn run(wl_display: *wl.Display, context: *Context) !void { fatal("wl_display flush failed: E{s}", .{@tagName(errno)}); } - // Get the number of milliseconds to the top of the next minute - const time = std.time.timestamp(); - if (time < 0) { - log.err("Got a negative time ({d})", .{time}); - return error.InvalidTime; - } - const timeout: i32 = @intCast((@divFloor(time, 60) * 60 + 60 - time) * 1000); + // Get the number of milliseconds to the top of the next second + const time_ns = std.time.nanoTimestamp(); + const ns_per_sec = std.time.ns_per_s; + const remainder_ns = @mod(time_ns, ns_per_sec); + const timeout: i32 = @intCast(@divFloor(ns_per_sec - remainder_ns, std.time.ns_per_ms)); const poll_rc = posix.poll(&pollfds, timeout) catch |err| { fatal("Failed to poll {s}", .{@errorName(err)}); }; if (poll_rc == 0) { - // If poll returns 0, it timed out, meaning we hit the top of the minute + // If poll returns 0, it timed out, meaning we hit the top of the next second // and need to update the clock. var it = context.wm.outputs.iterator(.forward); while (it.next()) |output| { From bce58855abd23e2fac86138205e4bb2ab8dd18f9 Mon Sep 17 00:00:00 2001 From: Ben Buhse Date: Fri, 27 Feb 2026 11:32:22 -0600 Subject: [PATCH 5/7] Fix Focus when switching tags Now, we clear focus if we switch to new tags and no window is visible --- src/Output.zig | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/src/Output.zig b/src/Output.zig index 3f00687..2db197f 100644 --- a/src/Output.zig +++ b/src/Output.zig @@ -560,6 +560,33 @@ pub fn manage(output: *Output) void { output.tags = new_tags; + // If the focused window is no longer visible on the new tags, update focus. + if (output.context.wm.seats.first()) |seat| { + if (seat.focused_output == output) { + // Whether focus has changed, either to a new window or to no focus + const should_update_focus = if (seat.focused_window) |w| + w.tags & new_tags == 0 + else + true; + if (should_update_focus) { + var new_focus: ?*Window = null; + var it = output.windows.iterator(.forward); + while (it.next()) |window| { + if (window.tags & new_tags != 0) { + new_focus = window; + break; + } + } + if (new_focus) |w| { + seat.pending_manage.window = .{ .window = w }; + seat.pending_manage.should_warp_pointer = true; + } else { + seat.pending_manage.window = .clear_focus; + } + } + } + } + // Show tag overlay and arm the hide timer if (output.tag_overlay) |*tag_overlay| { if (tag_overlay.surfaces) |_| { From 040ccc14f3f71a545b03b330a073e763d075e13d Mon Sep 17 00:00:00 2001 From: Ben Buhse Date: Fri, 27 Feb 2026 11:33:50 -0600 Subject: [PATCH 6/7] Implement configurable component locations in bar This allows the user to configure which component (title, wm_info, clock) is rendered to which part of the bar (left, right, center). You can also use `none` to hide the location. --- docs/CONFIGURATION.md | 10 ++-- docs/TODO.md | 3 +- man/beansprout.5.scd | 17 ++++-- src/Bar.zig | 120 ++++++++++++++++++++------------------- src/config/BarConfig.zig | 22 +++++++ 5 files changed, 104 insertions(+), 68 deletions(-) diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index f7a24ff..177da68 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -126,9 +126,8 @@ do not re-trigger rules. ## Bar -The bar is an optional widget that shows the focused window title on the left, -the date/time in the center, and layout info (primary count/ratio and visible -window count) on the right. It is only created when a `bar` block is present in +The bar is an optional widget that displays configurable components in three +slots: left, center, and right. It is only created when a `bar` block is present in the config. All settings have defaults, with the color based on the Catppuccin Mocha theme. An empty block can be used to enable the widget with all defaults: @@ -145,9 +144,12 @@ bar { | `text_color` | color | `0xcdd6f4` | Text color | | `background_color` | color | `0x1e1e2e` | Background color | | `position` | enum | `top` | Bar position (`top` or `bottom`) | +| `left` | enum | `title` | Component in the left slot (`title`, `clock`, `wm_info`, `none`) | +| `center` | enum | `clock` | Component in the center slot (`title`, `clock`, `wm_info`, `none`) | +| `right` | enum | `wm_info` | Component in the right slot (`title`, `clock`, `wm_info`, `none`) | | `vertical_padding` | u8 | `5` | Vertical padding above and below text | | `horizontal_padding` | u8 | `5` | Horizontal padding between bar edges and text | -| `time_format` | string | `%Y-%m-%d %H:%M, %A` | strftime format string for the clock display (an empty string hides the clock) | +| `time_format` | string | `%Y-%m-%d %H:%M, %A` | strftime format string for the clock display | ### Margins diff --git a/docs/TODO.md b/docs/TODO.md index d5493b8..44cb883 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -2,12 +2,12 @@ These are in rough order of my priority, though no promises I do them in this order. +- [ ] Support window tag/order caching between WM restarts (within a river session) - [ ] Add gap support - [ ] Add build-time options for including the wallpaper (and maybe bar) - [ ] Check pointer position and only warp if not on focused window already - [ ] Change focus direction when closing window - [ ] Use set_xcursor_theme request -- [ ] Support configuring bar item positions (left/center/right) - [ ] Support overriding config location - [ ] Add support for center-primary layout - [ ] Support per-output bar visibility @@ -50,3 +50,4 @@ These are in rough order of my priority, though no promises I do them in this or - [x] Add focused window title to bar - [x] Add bar padding to config - [x] Support 12-hour clock format (maybe take any time format string?) +- [x] Support configuring bar item positions (left/center/right) diff --git a/man/beansprout.5.scd b/man/beansprout.5.scd index 7ac835c..020cafe 100644 --- a/man/beansprout.5.scd +++ b/man/beansprout.5.scd @@ -114,9 +114,8 @@ initialization do not re-trigger rules. # BAR -The bar is an optional widget that shows the focused window title on the left, -the date/time in the center, and layout info (primary count/ratio and visible -window count) on the right. It is only created when a *bar* block is present +The bar is an optional widget that displays configurable components in three +slots: left, center, and right. It is only created when a *bar* block is present in the config: ``` @@ -137,6 +136,15 @@ bar { *position* *top*|*bottom* Bar position. (Default: *top*) +*left* *title*|*clock*|*wm_info*|*none* + Component shown in the left slot. (Default: *title*) + +*center* *title*|*clock*|*wm_info*|*none* + Component shown in the center slot. (Default: *clock*) + +*right* *title*|*clock*|*wm_info*|*none* + Component shown in the right slot. (Default: *wm_info*) + *vertical_padding* _pixels_ Vertical padding above and below text. (Default: 5) @@ -145,8 +153,7 @@ bar { *time_format* _format_ strftime format string for the clock display. Invalid format strings - are ignored and the default is used instead. Set to an empty string - to hide the clock. (Default: "%Y-%m-%d %H:%M, %A") + are ignored and the default is used instead. (Default: "%Y-%m-%d %H:%M, %A") The bar also supports *margins* and *anchors* child blocks; see *TAG OVERLAY* for their format. diff --git a/src/Bar.zig b/src/Bar.zig index 682edd2..b19bd3a 100644 --- a/src/Bar.zig +++ b/src/Bar.zig @@ -46,6 +46,7 @@ pub const PendingRender = struct { }; pub const Position = enum { top, bottom }; +pub const Component = enum { title, clock, wm_info, none }; pub const Options = struct { /// Comma separated list of FontConfig formatted font specifications @@ -67,6 +68,13 @@ pub const Options = struct { /// strftime format string for the clock display time_format: []const u8 = default_time_format, + + /// Which component to show on the left side of the bar + left: Component = .title, + /// Which component to show in the center of the bar + center: Component = .clock, + /// Which component to show on the right side of the bar + right: Component = .wm_info, }; pub fn init(context: *Context, output: *Output, options: Options) !Bar { @@ -234,55 +242,26 @@ pub fn draw(bar: *Bar) !void { // Y is shared between all components const y: i32 = @divFloor(buffer.height - bar.fcft_fonts.height, 2); - // Get the current time in seconds since the epoch, - // then load the local timezone, - // then convert `now` to the `local` timezone - const now = try zeit.instant(.{}); - const now_local = now.in(&bar.timezone); + // Pre-compute codepoints for each component type - // Generate date/time info for this instant - const dt = now_local.time(); - - // Convert time to a string + // Clock var time_buf: [255:0]u8 = undefined; var time_writer = Io.Writer.fixed(&time_buf); - try dt.strftime(&time_writer, options.time_format); + const now = try zeit.instant(.{}); + const now_local = now.in(&bar.timezone); + try now_local.time().strftime(&time_writer, options.time_format); + const clock_codepoints = try utils.utf8ToCodepoints(time_writer.buffered()); + defer utils.gpa.free(clock_codepoints); - // Convert date string to Unicode codepoints - const time_codepoints = try utils.utf8ToCodepoints(time_writer.buffered()); - defer utils.gpa.free(time_codepoints); + // Title (empty string if no focused window) + const focused_title: []const u8 = if (context.wm.seats.first()) |seat| + if (seat.focused_window) |window| window.title orelse "" else "" + else + ""; + const title_codepoints = try utils.utf8ToCodepoints(focused_title); + defer utils.gpa.free(title_codepoints); - // Get the width of the date string so we can truncate title - const center_width = try bar.textWidth(time_codepoints); - // X changes - var center_x: i32 = @divFloor(buffer.width - center_width, 2); - - // Write title of focused window to the left side of the bar - if (context.wm.seats.first()) |seat| { - if (seat.focused_window) |window| { - if (window.title) |title| { - if (title.len > 0) { - const title_codepoints = try utils.utf8ToCodepoints(title); - defer utils.gpa.free(title_codepoints); - - const max_left_width = center_x - 2 * options.horizontal_padding; - const truncated_codepoints = try bar.truncateToWidth(title_codepoints, max_left_width); - - var left_x: i32 = options.horizontal_padding; - - try bar.renderChars( - truncated_codepoints, - buffer, - &left_x, - y, - text_color, - ); - } - } - } - } - - // Put WM info on the right side of the bar + // WM info const output = bar.output; var wm_info_buf: [255:0]u8 = undefined; var wm_info_writer = Io.Writer.fixed(&wm_info_buf); @@ -296,23 +275,48 @@ pub fn draw(bar: *Bar) !void { const wm_info_codepoints = try utils.utf8ToCodepoints(wm_info_writer.buffered()); defer utils.gpa.free(wm_info_codepoints); - const max_right_width = buffer.width - (center_x + center_width) - 2 * options.horizontal_padding; - const right_truncated = try bar.truncateToWidth(wm_info_codepoints, max_right_width); - const right_text_width = try bar.textWidth(right_truncated); + // Map a Component to its pre-computed codepoints slice + const componentSlice = struct { + fn f(component: Component, clock: []u32, title: []u32, wm_info: []u32) []u32 { + return switch (component) { + .clock => clock, + .title => title, + .wm_info => wm_info, + .none => &.{}, + }; + } + }.f; - var right_x: i32 = buffer.width - right_text_width - options.horizontal_padding; - try bar.renderChars( - right_truncated, - buffer, - &right_x, - y, - text_color, - ); + // Measure center first — needed to constrain left and right widths + const center_codepoints = componentSlice(options.center, clock_codepoints, title_codepoints, wm_info_codepoints); + const center_width = try bar.textWidth(center_codepoints); + var center_x: i32 = @divFloor(buffer.width - center_width, 2); - // Finally, put the time in the center of the bar - try bar.renderChars(time_codepoints, buffer, ¢er_x, y, text_color); + // Render left slot + const left_codepoints = componentSlice(options.left, clock_codepoints, title_codepoints, wm_info_codepoints); + if (left_codepoints.len > 0) { + const max_width = center_x - 2 * options.horizontal_padding; + const truncated = try bar.truncateToWidth(left_codepoints, max_width); + var x: i32 = options.horizontal_padding; + try bar.renderChars(truncated, buffer, &x, y, text_color); + } - // Really finally, attach the buffer to the surface + // Render right slot + const right_codepoints = componentSlice(options.right, clock_codepoints, title_codepoints, wm_info_codepoints); + if (right_codepoints.len > 0) { + const max_width = buffer.width - (center_x + center_width) - 2 * options.horizontal_padding; + const truncated = try bar.truncateToWidth(right_codepoints, max_width); + const text_width = try bar.textWidth(truncated); + var x: i32 = buffer.width - text_width - options.horizontal_padding; + try bar.renderChars(truncated, buffer, &x, y, text_color); + } + + // Render center slot + if (center_codepoints.len > 0) { + try bar.renderChars(center_codepoints, buffer, ¢er_x, y, text_color); + } + + // Attach the buffer to the surface const surfaces = bar.surfaces orelse return error.NoSurfaces; const wl_surface = surfaces.wl_surface; // sync_next_commit ensures frame-perfect application diff --git a/src/config/BarConfig.zig b/src/config/BarConfig.zig index 34ebf99..ea7179c 100644 --- a/src/config/BarConfig.zig +++ b/src/config/BarConfig.zig @@ -9,6 +9,9 @@ const NodeName = enum { text_color, background_color, position, + left, + center, + right, vertical_padding, horizontal_padding, margins, @@ -25,6 +28,13 @@ background_color: pixman.Color = utils.parseRgbaPixmanComptime("0x1e1e2e"), /// Whether the bar is at the top or bottom of the screen position: Bar.Position = .top, +/// Which component to show on the left side of the bar +left: Bar.Component = .title, +/// Which component to show in the center of the bar +center: Bar.Component = .clock, +/// Which component to show on the right side of the bar +right: Bar.Component = .wm_info, + /// Margin above the top of the bar and another element (a window or the top of the output) margin_top: u8 = 0, /// Margin above the right of the bar and another element (a window or the top of the output) @@ -49,6 +59,9 @@ pub fn toBarOptions(config: BarConfig) Bar.Options { .text_color = config.text_color, .background_color = config.background_color, .position = config.position, + .left = config.left, + .center = config.center, + .right = config.right, .margins = .{ .top = config.margin_top, .right = config.margin_right, @@ -129,6 +142,15 @@ pub fn load(config: *Config, parser: *kdl.Parser, hostname: ?[]const u8) !void { continue; }; @field(config.bar_config.?, @tagName(tag)) = padding; + logDebugSettingNode(name, padding_str); + }, + inline .left, .center, .right => |tag| { + if (std.meta.stringToEnum(Bar.Component, val_str)) |component| { + @field(config.bar_config.?, @tagName(tag)) = component; + logDebugSettingNode(name, val_str); + } else { + logWarnInvalidNodeArg(name, val_str); + } }, } } else { From dfdea33389a33da6a6ef21582b81a201973eeb31 Mon Sep 17 00:00:00 2001 From: Ben Buhse Date: Fri, 27 Feb 2026 12:07:24 -0600 Subject: [PATCH 7/7] Fix bug where windows don't have border on wm restart Before, if you restarted the WM with windows already present, any non-focused window would just not have a border (because borderes are only drawn on focus change, which happens for all new windows, but not exisiting ones). I guess this probably would've happened if users add new windows appending and didn't focus on new window spawns? Anyways, now we just tell new windows they're unfocused to draw the border on first render. --- src/Window.zig | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/Window.zig b/src/Window.zig index c8161b9..7ddf85e 100644 --- a/src/Window.zig +++ b/src/Window.zig @@ -97,6 +97,9 @@ pub fn create(context: *Context, river_window_v1: *river.WindowV1, output: ?*Out .output = output, .tags = if (output) |o| o.tags else 0x0001, .link = undefined, // Handled by the wl.list + // Ensure borders are applied on the first render cycle, even for windows that + // are never explicitly told they are unfocused (e.g. on WM restart). + .pending_render = .{ .focused = false }, }; window.river_window_v1.setListener(*Window, windowListener, window);