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.
This commit is contained in:
Ben Buhse 2026-02-27 10:42:08 -06:00
commit efd0222899
No known key found for this signature in database
GPG key ID: 7916ACFCD38FD0B4
9 changed files with 231 additions and 51 deletions

View file

@ -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 {
@ -144,6 +145,8 @@ bar {
| `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

View file

@ -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

View file

@ -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

View file

@ -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, &center_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;

View file

@ -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 (new_config.bar_config) |bar_config| {
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;
}
// Create new bar if configured
if (new_config.bar_config) |bar_config| {
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;
};
output.bar.?.pending_manage.output_geometry = true;
}
} else {
// New config doesn't have a bar; destroy existing one
if (output.bar) |*bar| {
bar.deinit();
output.bar = null;
}
}
}
}

View file

@ -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;

View file

@ -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;

View file

@ -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

View file

@ -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;
};