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 ## Bar
The bar is an optional widget that shows the time on your screen. Right now, that's it. The bar is an optional widget that shows the focused window title on the left,
It is only created when a `bar` block is present in the config. All settings have the date/time in the center, and layout info (primary count/ratio and visible
defaults, with the color based on the Catppuccin Mocha theme. An empty block can be used window count) on the right. It is only created when a `bar` block is present in
to enable the widget with all defaults: 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 ```kdl
bar { bar {
@ -140,10 +141,12 @@ bar {
| Setting | Type | Default | Description | | Setting | Type | Default | Description |
|--------------------|--------|--------------------|-----------------------------------| |--------------------|--------|--------------------|-----------------------------------|
| `fonts` | string | `monospace:size=14` | Comma-separated FontConfig fonts | | `fonts` | string | `monospace:size=14` | Comma-separated FontConfig fonts |
| `text_color` | color | `0xcdd6f4` | Text color | | `text_color` | color | `0xcdd6f4` | Text color |
| `background_color` | color | `0x1e1e2e` | Background color | | `background_color` | color | `0x1e1e2e` | Background color |
| `position` | enum | `top` | Bar position (`top` or `bottom`) | | `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 ### Margins

View file

@ -2,15 +2,14 @@
These are in rough order of my priority, though no promises I do them in this order. 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) - [ ] Add build-time options for including the wallpaper (and maybe bar)
- [ ] Check pointer position and only warp if not on focused window already - [ ] Check pointer position and only warp if not on focused window already
- [ ] Change focus direction when closing window - [ ] Change focus direction when closing window
- [ ] Use set_xcursor_theme request - [ ] Use set_xcursor_theme request
- [ ] Support configuring bar item positions (left/center/right) - [ ] Support configuring bar item positions (left/center/right)
- [ ] Add focused window title to bar
- [ ] Support overriding config location - [ ] Support overriding config location
- [ ] Add support for center-primary layout - [ ] Add support for center-primary layout
- [ ] Add bar padding to config
- [ ] Support 12-hour clock format (maybe take any time format string?) - [ ] Support 12-hour clock format (maybe take any time format string?)
- [ ] Support per-output bar visibility - [ ] Support per-output bar visibility
- [ ] Support more window rule options (e.g. ssd/csd) - [ ] 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] 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] 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] 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 # BAR
The bar is an optional widget that shows the time. It is only created when a The bar is an optional widget that shows the focused window title on the left,
*bar* block is present: 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 { bar {
@ -135,9 +137,17 @@ bar {
*position* *top*|*bottom* *position* *top*|*bottom*
Bar position. (Default: *top*) 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* The bar also supports *margins* and *anchors* child blocks; see *TAG OVERLAY*
for their format. for their format.
An empty block can be used to enable the widget with all defaults.
# TAG OVERLAY # TAG OVERLAY
The tag overlay is an optional widget that briefly shows tag state when 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 /// Whether the bar is at the top or bottom of the screen
position: Position = .top, position: Position = .top,
/// Directional margins top, right, bottom, left, in pixels /// 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 { 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, .node = node,
}, },
.configured = true, .configured = true,
.pending_manage = .{ .output_geometry = true },
}; };
} }
pub fn deinit(bar: *Bar) void { pub fn deinit(bar: *Bar) void {
bar.configured = false; bar.configured = false;
bar.timezone.deinit(); bar.timezone.deinit();
bar.fcft_fonts.destroy();
if (bar.surfaces) |surfaces| { if (bar.surfaces) |surfaces| {
surfaces.node.destroy(); surfaces.node.destroy();
surfaces.river_shell_surface.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 { pub fn manage(bar: *Bar) !void {
if (!bar.configured) return; if (!bar.configured) return;
defer bar.pending_manage = .{}; defer bar.pending_manage = .{};
@ -131,10 +148,9 @@ pub fn manage(bar: *Bar) !void {
bar.font_scale = scale; bar.font_scale = scale;
} }
const vertical_padding = 5;
const logical_font_height = @divFloor(bar.fcft_fonts.height, @as(i32, bar.font_scale)); 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 height: u31 = @intCast(logical_font_height + 2 * options.vertical_padding);
const width: u31 = output.geometry.width; 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) { if (bar.geometry.width != width or bar.geometry.height != height) {
bar.geometry.width = width; bar.geometry.width = width;
@ -206,9 +222,11 @@ pub fn draw(bar: *Bar) !void {
); );
// Set-up text color // Set-up text color
const text_color = options.text_color; const text_color = pixman.Image.createSolidFill(&options.text_color) orelse return error.FailedToCreatePixmanImage;
const color = pixman.Image.createSolidFill(&text_color) orelse return error.FailedToCreatePixmanImage; defer _ = text_color.unref();
defer _ = 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, // Get the current time in seconds since the epoch,
// then load the local timezone, // then load the local timezone,
@ -220,24 +238,78 @@ pub fn draw(bar: *Bar) !void {
const dt = now_local.time(); const dt = now_local.time();
// Convert time to a string // Convert time to a string
var buf: [255:0]u8 = undefined; var time_buf: [255:0]u8 = undefined;
var fbs = io.fixedBufferStream(&buf); var time_writer = Io.Writer.fixed(&time_buf);
try dt.strftime(fbs.writer(), "%H:%M"); try dt.strftime(&time_writer, "%H:%M");
// Convert date string to Unicode codepoints // Convert date string to Unicode codepoints
const codepoints = try utils.utf8ToCodepoints(fbs.getWritten()); const time_codepoints = try utils.utf8ToCodepoints(time_writer.buffered());
defer utils.gpa.free(codepoints); defer utils.gpa.free(time_codepoints);
const text_width = try bar.textWidth(codepoints); // Get the width of the date string so we can truncate title
var x: i32 = @divFloor(buffer.width - text_width, 2); const center_width = try bar.textWidth(time_codepoints);
const y: i32 = @divFloor(buffer.height - bar.fcft_fonts.height, 2); // X changes
var center_x: i32 = @divFloor(buffer.width - center_width, 2);
// Actually render the unicode codepoints // Write title of focused window to the left side of the bar
try bar.renderChars(codepoints, buffer, &x, y, color); 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 surfaces = bar.surfaces orelse return error.NoSurfaces;
const wl_surface = surfaces.wl_surface; const wl_surface = surfaces.wl_surface;
// sync_next_commit ensures frame-perfect application
surfaces.river_shell_surface.syncNextCommit(); surfaces.river_shell_surface.syncNextCommit();
wl_surface.setBufferScale(scale); wl_surface.setBufferScale(scale);
wl_surface.attach(buffer.wl_buffer, 0, 0); wl_surface.attach(buffer.wl_buffer, 0, 0);
@ -245,7 +317,6 @@ pub fn draw(bar: *Bar) !void {
wl_surface.commit(); wl_surface.commit();
} }
// TODO: This should be moved to utils once fonts are in config
/// Computes the pixel width of a text string. /// Computes the pixel width of a text string.
fn textWidth(bar: *Bar, text: []const u32) !i32 { fn textWidth(bar: *Bar, text: []const u32) !i32 {
var width: i32 = 0; var width: i32 = 0;
@ -262,6 +333,25 @@ fn textWidth(bar: *Bar, text: []const u32) !i32 {
return width; 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 // Borrowed and modified from https://git.sr.ht/~novakane/zig-fcft-example
fn renderChars( fn renderChars(
bar: *Bar, 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 }); log.debug("Scaling font DPI: base={d} scale={d}", .{ base_dpi, scale });
const scaled = try arena_alloc.dupeZ( const scaled = try arena_alloc.dupeZ(
u8, 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); try list.append(arena_alloc, scaled);
} else { } else {
@ -382,9 +472,10 @@ fn getFcftFonts(fonts: []const u8, scale: u31) !*fcft.Font {
const std = @import("std"); const std = @import("std");
const assert = std.debug.assert; const assert = std.debug.assert;
const io = std.io; const fmt = std.fmt;
const mem = std.mem; const mem = std.mem;
const process = std.process; const process = std.process;
const Io = std.Io;
const wayland = @import("wayland"); const wayland = @import("wayland");
const wl = wayland.client.wl; 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; const has_bar = new_config.bar_config != null;
if (had_bar or has_bar) { if (had_bar or has_bar) {
var out_it = context.wm.outputs.iterator(.forward); var out_it = context.wm.outputs.iterator(.forward);
while (out_it.next()) |output| { 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| { if (new_config.bar_config) |bar_config| {
output.bar = Bar.init(context, output, bar_config.toBarOptions()) catch |e| { if (output.bar) |*bar| {
log.err("Failed to create bar: {}", .{e}); // Existing bar; reconfigure in-place to keep surfaces
continue; bar.reconfigure(bar_config.toBarOptions()) catch |e| {
}; log.err("Failed to reconfigure bar: {}", .{e});
output.bar.?.pending_manage.output_geometry = true; 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;
}
} }
} }
} }

View file

@ -523,10 +523,16 @@ pub fn manage(output: *Output) void {
Config.min_primary_ratio, Config.min_primary_ratio,
Config.max_primary_ratio, Config.max_primary_ratio,
); );
if (output.bar) |*bar| {
bar.pending_render.draw = true;
}
} }
if (output.pending_manage.primary_count) |primary_count| { if (output.pending_manage.primary_count) |primary_count| {
// Don't allow less than 1 primary // Don't allow less than 1 primary
output.primary_count = @max(1, primary_count); 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| { if (output.pending_manage.single_window_ratio) |single_window_ratio| {
output.single_window_ratio = std.math.clamp( 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| { if (output.bar) |*bar| {
@ -794,6 +804,15 @@ pub fn occupiedTags(output: *Output) u32 {
return occupied_tags; 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 std = @import("std");
const assert = std.debug.assert; const assert = std.debug.assert;
const mem = std.mem; const mem = std.mem;

View file

@ -137,9 +137,15 @@ pub fn manage(seat: *Seat) void {
switch (pending_window) { switch (pending_window) {
.window => |window| { .window => |window| {
if (seat.focused_window) |focused| { if (seat.focused_window) |focused| {
// Tell the previously focused Window that it's no longer focused
if (focused != window) { if (focused != window) {
// Tell the previously focused Window that it's no longer focused
focused.pending_render.focused = false; 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; 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") utils.gpa.dupe(u8, std.mem.span(t)) catch @panic("Out of memory")
else else
null; 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| { .parent => |ev| {
// Nothing to do if ev.parent is null // Nothing to do if ev.parent is null

View file

@ -9,6 +9,8 @@ const NodeName = enum {
text_color, text_color,
background_color, background_color,
position, position,
vertical_padding,
horizontal_padding,
margins, margins,
}; };
const MarginsNodeName = enum { top, right, bottom, left }; const MarginsNodeName = enum { top, right, bottom, left };
@ -18,11 +20,23 @@ const MarginsNodeName = enum { top, right, bottom, left };
fonts: ?[]const u8 = null, fonts: ?[]const u8 = null,
text_color: pixman.Color = utils.parseRgbaPixmanComptime("0xcdd6f4"), text_color: pixman.Color = utils.parseRgbaPixmanComptime("0xcdd6f4"),
background_color: pixman.Color = utils.parseRgbaPixmanComptime("0x1e1e2e"), background_color: pixman.Color = utils.parseRgbaPixmanComptime("0x1e1e2e"),
/// Whether the bar is at the top or bottom of the screen
position: Bar.Position = .top, position: Bar.Position = .top,
margin_top: i32 = 0,
margin_right: i32 = 0, /// Margin above the top of the bar and another element (a window or the top of the output)
margin_bottom: i32 = 0, margin_top: u8 = 0,
margin_left: i32 = 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 { pub fn toBarOptions(config: BarConfig) Bar.Options {
return .{ return .{
@ -36,6 +50,8 @@ pub fn toBarOptions(config: BarConfig) Bar.Options {
.bottom = config.margin_bottom, .bottom = config.margin_bottom,
.left = config.margin_left, .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); logWarnInvalidNodeArg(name, val_str);
} }
}, },
.margins => next_child_block = .margins, .margins => next_child_block = .margins,
inline .background_color, inline .background_color,
.text_color, .text_color,
@ -86,6 +103,16 @@ pub fn load(config: *Config, parser: *kdl.Parser, hostname: ?[]const u8) !void {
}; };
logDebugSettingNode(name, val_str); 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 { } else {
helpers.logWarnInvalidNode(node.name); helpers.logWarnInvalidNode(node.name);
@ -117,7 +144,7 @@ fn loadMarginsBlock(config: *Config, parser: *kdl.Parser, hostname: ?[]const u8)
continue; continue;
} }
const val_str = utils.stripQuotes(node.arg(parser, 0) orelse ""); 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); logWarnInvalidNodeArg(name, val_str);
continue; continue;
}; };