From efd0222899f635e284c47ed4a98ac6867367eb73 Mon Sep 17 00:00:00 2001 From: Ben Buhse Date: Fri, 27 Feb 2026 10:42:08 -0600 Subject: [PATCH] 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; };