beansprout-custom/src/Bar.zig
Ben Buhse a9473204ad
Convert Bar to use river-shell-surface
I want to implement more functionality to the bar, similar to what
machi has in its bar, but it seems a lot easier to just handle the bar
with the rest of the manage/render loop that rwm and beansprout use.

To do that, I had to convert the bar to use river-shell-surface instead
of zwlr-layer-shell.

In that process, I also removed support for zwlr-layer-shell exclusive
zones. It made calculating the usable area for the layout more annoying.
If someone *really* wants, I would consider adding it back, but the only
thing I can think of that requires exclusive area is a bar, and we don't
really support other bars, so I don't think it's needed.

I also switched a couple of places to use saturating subtraction on
unsigned ints.
2026-02-26 16:34:48 -06:00

411 lines
13 KiB
Zig

// SPDX-FileCopyrightText: 2026 Ben Buhse <me@benbuhse.email>
//
// SPDX-License-Identifier: GPL-3.0-only
const Bar = @This();
/// Standard base DPI at a scale of 1
const base_dpi = 96;
context: *Context,
/// The timezone of the computer.
timezone: zeit.timezone.TimeZone,
options: Options,
fcft_fonts: *fcft.Font,
font_scale: u31 = 1,
output: *Output,
// Bar geometry
geometry: Rect = .{},
surfaces: ?struct {
wl_surface: *wl.Surface,
river_shell_surface: *river.ShellSurfaceV1,
node: *river.NodeV1,
} = null,
pending_manage: PendingManage = .{},
pending_render: PendingRender = .{},
configured: bool = false,
pub const PendingManage = struct {
output_geometry: bool = false,
};
pub const PendingRender = struct {
position: ?struct { x: i32, y: i32 } = null,
draw: bool = false,
};
pub const Position = enum { top, bottom };
pub const Options = struct {
/// Comma separated list of FontConfig formatted font specifications
fonts: []const u8 = "monospace:size=14",
/// Color of text on the bar
text_color: pixman.Color = utils.parseRgbaPixmanComptime("0xcdd6f4"),
/// Background color of the bar
background_color: pixman.Color = utils.parseRgbaPixmanComptime("0x1e1e2e"),
/// 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 } = .{},
};
pub fn init(context: *Context, output: *Output, options: Options) !Bar {
const timezone = try zeit.local(utils.gpa, &context.env);
errdefer timezone.deinit();
const scale = output.scale;
const fcft_fonts = try getFcftFonts(options.fonts, scale);
errdefer fcft_fonts.destroy();
const wl_surface = try context.wl_compositor.createSurface();
errdefer wl_surface.destroy();
const river_shell_surface = try context
.wm
.river_window_manager_v1
.getShellSurface(wl_surface);
errdefer river_shell_surface.destroy();
const node = try river_shell_surface.getNode();
errdefer node.destroy();
// We don't want our surface to have any input region (default is infinite)
const empty_region = try context.wl_compositor.createRegion();
defer empty_region.destroy();
wl_surface.setInputRegion(empty_region);
context.buffer_pool.surface_count += 1;
return .{
.context = context,
.options = options,
.fcft_fonts = fcft_fonts,
.font_scale = scale,
.timezone = timezone,
.output = output,
.surfaces = .{
.wl_surface = wl_surface,
.river_shell_surface = river_shell_surface,
.node = node,
},
.configured = true,
};
}
pub fn deinit(bar: *Bar) void {
bar.configured = false;
bar.timezone.deinit();
if (bar.surfaces) |surfaces| {
surfaces.node.destroy();
surfaces.river_shell_surface.destroy();
surfaces.wl_surface.destroy();
bar.context.buffer_pool.surface_count -= 1;
}
}
pub fn manage(bar: *Bar) !void {
if (!bar.configured) return;
defer bar.pending_manage = .{};
// The only manage actions we need to do are when the output changes geometry
if (!bar.pending_manage.output_geometry) return;
const output = bar.output;
const options = bar.options;
// Recreate fonts if the output scale changed, so geometry calculations
// below use the correct font metrics.
const scale = output.scale;
if (scale != bar.font_scale) {
bar.fcft_fonts.destroy();
bar.fcft_fonts = try getFcftFonts(bar.options.fonts, scale);
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;
if (bar.geometry.width != width or bar.geometry.height != height) {
bar.geometry.width = width;
bar.geometry.height = height;
const opaque_region = try bar.context.wl_compositor.createRegion();
defer opaque_region.destroy();
opaque_region.add(0, 0, width, height);
bar.surfaces.?.wl_surface.setOpaqueRegion(opaque_region);
}
const x = output.geometry.x + options.margins.left;
const y = switch (options.position) {
.top => output.geometry.y + options.margins.top,
.bottom => output.geometry.y + output.geometry.height - bar.geometry.height - options.margins.bottom,
};
bar.pending_render.position = .{ .x = x, .y = y };
bar.pending_render.draw = true;
}
pub fn render(bar: *Bar) void {
if (!bar.configured) return;
defer bar.pending_render = .{};
const surfaces = bar.surfaces orelse return;
if (bar.pending_render.position) |position| {
surfaces.node.setPosition(position.x, position.y);
surfaces.node.placeTop();
}
if (bar.pending_render.draw) {
bar.draw() catch |err| {
log.err("Bar draw failed: {}", .{err});
};
}
}
/// Draw the bar and its components (clock, title, etc.)
pub fn draw(bar: *Bar) !void {
const context = bar.context;
const options = bar.options;
const scale = bar.font_scale;
// Scaled width/height
const render_width = bar.geometry.width * scale;
const render_height = bar.geometry.height * scale;
// Don't have anything to render
if (render_width == 0 or render_height == 0 or scale == 0) {
return;
}
const buffer = try context.buffer_pool.nextBuffer(bar.context.wl_shm, render_width, render_height);
// Fill with a solid color (e.g., dark background)
const bg_color = options.background_color;
_ = pixman.Image.fillRectangles(
.src,
buffer.pixman_image,
&bg_color,
1,
&[1]pixman.Rectangle16{.{
.x = 0,
.y = 0,
.width = @intCast(render_width),
.height = @intCast(render_height),
}},
);
// Set-up text color
const text_color = options.text_color;
const color = pixman.Image.createSolidFill(&text_color) orelse return error.FailedToCreatePixmanImage;
defer _ = color.unref();
// 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);
// Generate date/time info for this instant
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");
// 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);
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);
const y: i32 = @divFloor(buffer.height - bar.fcft_fonts.height, 2);
// Actually render the unicode codepoints
try bar.renderChars(codepoints, buffer, &x, y, color);
// Finally, attach the buffer to the surface
const surfaces = bar.surfaces orelse return error.NoSurfaces;
const wl_surface = surfaces.wl_surface;
surfaces.river_shell_surface.syncNextCommit();
wl_surface.setBufferScale(scale);
wl_surface.attach(buffer.wl_buffer, 0, 0);
wl_surface.damageBuffer(0, 0, render_width, render_height);
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;
for (text, 0..) |cp, i| {
const glyph = try bar.fcft_fonts.rasterizeCharUtf32(cp, .default);
width += glyph.advance.x;
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);
}
}
}
return width;
}
// Borrowed and modified from https://git.sr.ht/~novakane/zig-fcft-example
fn renderChars(
bar: *Bar,
text: []const u32,
buffer: *Buffer,
x: *i32,
y: i32,
color: *pixman.Image,
) !void {
const glyphs = try utils.gpa.alloc(*const fcft.Glyph, text.len);
const kerns = try utils.gpa.alloc(c_long, text.len);
defer utils.gpa.free(glyphs);
defer utils.gpa.free(kerns);
var i: usize = 0;
while (i < text.len) : (i += 1) {
glyphs[i] = try bar.fcft_fonts.rasterizeCharUtf32(text[i], .default);
kerns[i] = 0;
if (i > 0) {
var x_kern: c_long = 0;
if (bar.fcft_fonts.kerning(text[i - 1], text[i], &x_kern, null)) {
kerns[i] = x_kern;
}
}
}
bar.renderGlyphs(buffer, x, y, color, text.len, glyphs.ptr, kerns);
}
// Borrowed https://git.sr.ht/~novakane/zig-fcft-example
fn renderGlyphs(
bar: *Bar,
buffer: *Buffer,
x: *i32,
y: i32,
color: *pixman.Image,
len: usize,
glyphs: [*]*const fcft.Glyph,
kerns: ?[]c_long,
) void {
var i: usize = 0;
while (i < len) : (i += 1) {
if (kerns) |kern| x.* += @intCast(kern[i]);
switch (pixman.Image.getFormat(glyphs[i].pix)) {
// Glyph is a pre-rendered image. (e.g. a color emoji)
.a8r8g8b8 => {
pixman.Image.composite32(
.over,
glyphs[i].pix,
null,
buffer.pixman_image,
0,
0,
0,
0,
x.* + @as(i32, @intCast(glyphs[i].x)),
y + bar.fcft_fonts.ascent - @as(i32, @intCast(glyphs[i].y)),
glyphs[i].width,
glyphs[i].height,
);
},
// Glyph is an alpha mask.
else => {
pixman.Image.composite32(
.over,
color,
glyphs[i].pix,
buffer.pixman_image,
0,
0,
0,
0,
x.* + @as(i32, @intCast(glyphs[i].x)),
y + bar.fcft_fonts.ascent - @as(i32, @intCast(glyphs[i].y)),
glyphs[i].width,
glyphs[i].height,
);
},
}
x.* += glyphs[i].advance.x;
}
}
// Borrowed and modified from https://git.sr.ht/~novakane/zig-fcft-example
fn getFcftFonts(fonts: []const u8, scale: u31) !*fcft.Font {
// Create an arena to free just for this function;
// It makes cleaning up the ArrayList easier.
var arena = std.heap.ArenaAllocator.init(utils.gpa);
defer arena.deinit();
const arena_alloc = arena.allocator();
var list = try std.ArrayList([*:0]const u8).initCapacity(arena_alloc, 2);
var it = mem.tokenizeScalar(u8, fonts, ',');
while (it.next()) |font| {
if (scale > 1) {
// If scale >1, we append :dpi so we can scale the 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 list.append(arena_alloc, scaled);
} else {
try list.append(arena_alloc, try arena_alloc.dupeZ(u8, font));
}
}
const fcft_fonts = try fcft.Font.fromName(list.items[0..], null);
errdefer fcft_fonts.destroy();
fcft_fonts.setEmojiPresentation(.default);
return fcft_fonts;
}
const std = @import("std");
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;
const river = wayland.client.river;
const fcft = @import("fcft");
const pixman = @import("pixman");
const zeit = @import("zeit");
const utils = @import("utils.zig");
const Rect = utils.Rect;
const Buffer = @import("Buffer.zig");
const Context = @import("Context.zig");
const Output = @import("Output.zig");
const log = std.log.scoped(.Bar);