From 40088b4ab626fdb7f7518ba407b6286090c0fee1 Mon Sep 17 00:00:00 2001 From: Ben Buhse Date: Fri, 13 Feb 2026 10:07:48 -0600 Subject: [PATCH] Add initial bar support Right now it just renders a black bar at the top of the screen, nothing useful is in it and it has no configuration. --- build.zig | 13 +++ build.zig.zon | 8 ++ src/Bar.zig | 265 +++++++++++++++++++++++++++++++++++++++++++++++++ src/Output.zig | 73 +++++++++----- src/main.zig | 12 +++ 5 files changed, 345 insertions(+), 26 deletions(-) create mode 100644 src/Bar.zig diff --git a/build.zig b/build.zig index d2d0363..03c2d0d 100644 --- a/build.zig +++ b/build.zig @@ -16,10 +16,12 @@ pub fn build(b: *std.Build) void { const scanner = Scanner.create(b, .{}); const wayland = b.createModule(.{ .root_source_file = scanner.result }); // Rest of the deps + const fcft = b.dependency("fcft", .{}).module("fcft"); const kdl = b.dependency("kdl", .{}).module("kdl"); const known_folders = b.dependency("known_folders", .{}).module("known-folders"); const pixman = b.dependency("pixman", .{}).module("pixman"); const xkbcommon = b.dependency("xkbcommon", .{}).module("xkbcommon"); + const zeit = b.dependency("zeit", .{}).module("zeit"); const zigimg = b.dependency("zigimg", .{}).module("zigimg"); scanner.addCustomProtocol(b.path("protocol/river-input-management-v1.xml")); @@ -57,14 +59,17 @@ pub fn build(b: *std.Build) void { beansprout.root_module.addOptions("build_options", options); beansprout.root_module.addImport("wayland", wayland); + beansprout.root_module.addImport("fcft", fcft); beansprout.root_module.addImport("kdl", kdl); beansprout.root_module.addImport("known_folders", known_folders); beansprout.root_module.addImport("pixman", pixman); beansprout.root_module.addImport("xkbcommon", xkbcommon); beansprout.root_module.addImport("zigimg", zigimg); + beansprout.root_module.addImport("zeit", zeit); beansprout.linkLibC(); beansprout.linkSystemLibrary("wayland-client"); + beansprout.linkSystemLibrary("fcft"); beansprout.linkSystemLibrary("pixman-1"); beansprout.linkSystemLibrary("xkbcommon"); @@ -117,6 +122,14 @@ const manifest: 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"); diff --git a/build.zig.zon b/build.zig.zon index 7d8ddc2..bf08063 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -35,6 +35,14 @@ .url = "https://github.com/zigimg/zigimg/archive/fb74dfb7c6d83f2bd01a229826669451525a4ba8.tar.gz", .hash = "zigimg-0.1.0-8_eo2kSGFwADIkeZYTgfnLOV-khh6ZRoGmK6F2-s_QbY", }, + .fcft = .{ + .url = "https://git.sr.ht/~novakane/zig-fcft/archive/4bf5be61c869d08d5bcb0306049c63a9cb0795a7.tar.gz", + .hash = "fcft-3.0.0-zcx6CxQfAADhnwm8SjyCkQF-VFHGiVarigc2de3ciInC", + }, + .zeit = .{ + .url = "https://github.com/rockorager/zeit/archive/7ac64d72dbfb1a4ad549102e7d4e232a687d32d8.tar.gz", + .hash = "zeit-0.6.0-5I6bk36tAgATpSl9wjFmRPMqYN2Mn0JQHgIcRNcqDpJA", + }, }, .paths = .{ diff --git a/src/Bar.zig b/src/Bar.zig new file mode 100644 index 0000000..87cbcbb --- /dev/null +++ b/src/Bar.zig @@ -0,0 +1,265 @@ +// SPDX-FileCopyrightText: 2026 Ben Buhse +// +// SPDX-License-Identifier: GPL-3.0-only + +const Bar = @This(); + +context: *Context, + +// TODO: Get this in Config then save in Context +font: *fcft.Font, + +/// The output that this Bar is on +output: *Output, + +// Bar geometry +width: u31 = 0, +height: u31 = 0, + +wl_surface: ?*wl.Surface = null, +layer_surface: ?*zwlr.LayerSurfaceV1 = null, + +configured: bool = false, + +pub fn init(context: *Context, output: *Output) !Bar { + return .{ + .context = context, + .font = try getFcftFonts("monospace:size=14"), + .output = output, + }; +} + +// TODO: Add config options for whether it's top or bottom +pub fn initSurface(bar: *Bar) !void { + if (bar.layer_surface) |_| { + // This bar already has a layer surface, we can exit early + return; + } + + const context = bar.context; + + // TODO: Add padding to config + const padding = 5; + const bar_height: u31 = @intCast(bar.font.height + 2 * padding); + + const wl_surface: *wl.Surface = try context.wl_compositor.createSurface(); + errdefer wl_surface.destroy(); + + // TODO: Allow clicking on tags to switch between them? + // We don't want our surface to have any input region (default is infinite) + const empty_region: *wl.Region = try context.wl_compositor.createRegion(); + defer empty_region.destroy(); + wl_surface.setInputRegion(empty_region); + + const layer_surface: *zwlr.LayerSurfaceV1 = try context + .zwlr_layer_shell_v1 + .getLayerSurface(wl_surface, bar.output.wl_output, .top, "beansprout-bar"); + layer_surface.setSize(0, bar_height); + layer_surface.setAnchor(.{ .top = true, .right = true, .left = true }); + + bar.wl_surface = wl_surface; + bar.layer_surface = layer_surface; + context.buffer_pool.surface_count += 1; + + layer_surface.setListener(*Bar, layerSurfaceListener, bar); + wl_surface.commit(); +} + +pub fn deinit(bar: *Bar) void { + bar.configured = false; + if (bar.wl_surface) |wl_surface| { + wl_surface.destroy(); + } + if (bar.layer_surface) |layer_surface| { + layer_surface.destroy(); + bar.context.buffer_pool.surface_count -= 1; + } +} + +pub fn layerSurfaceListener( + layer_surface: *zwlr.LayerSurfaceV1, + event: zwlr.LayerSurfaceV1.Event, + bar: *Bar, +) void { + switch (event) { + .configure => |ev| { + layer_surface.ackConfigure(ev.serial); + const width: u31 = @intCast(ev.width); + const height: u31 = @intCast(ev.height); + + if (bar.configured and bar.width == width and bar.height == height) { + if (bar.wl_surface) |wl_surface| { + wl_surface.commit(); + } else { + log.warn("Bar is marked as configured but is missing a layer_surface for the wallpaper", .{}); + } + return; + } + + log.debug("configuring bar surface with width {} and height {}", .{ width, height }); + bar.width = width; + bar.height = height; + // Excluse zone == the bar's height + layer_surface.setExclusiveZone(bar.height); + + // Full surface should be opaque + const opaque_region: *wl.Region = bar.context.wl_compositor.createRegion() catch |e| { + log.err("Failed to create opaque region for bar: {}", .{e}); + return; + }; + // TODO: Need to change the x/y if we support anchoring to the bottom + opaque_region.add(0, 0, bar.width, bar.height); + defer opaque_region.destroy(); + bar.wl_surface.?.setOpaqueRegion(opaque_region); + + bar.configured = true; + + log.debug("bwbuhse before render", .{}); + bar.render() catch |err| { + log.err("Bar render failed: {}", .{err}); + }; + }, + .closed => { + bar.deinit(); + }, + } +} + +fn render(bar: *Bar) !void { + const buffer = try bar.context.buffer_pool.nextBuffer(bar.context.wl_shm, bar.width, bar.height); + + // Fill with a solid color (e.g., dark background) + const bg_color = pixman.Color{ .red = 0x1e1e, .green = 0x1e1e, .blue = 0x2e2e, .alpha = 0xffff }; + const fill = pixman.Image.createSolidFill(&bg_color) orelse return; + defer _ = fill.unref(); + pixman.Image.composite32(.src, fill, null, buffer.pixman_image, 0, 0, 0, 0, 0, 0, bar.width, bar.height); + + const wl_surface = bar.wl_surface orelse return; + wl_surface.attach(buffer.wl_buffer, 0, 0); + wl_surface.damageBuffer(0, 0, bar.width, bar.height); + wl_surface.commit(); + log.debug("bwbuhse end of render", .{}); +} + +// Borrowed and modified from https://git.sr.ht/~novakane/zig-fcft-example +fn renderChars(output: *Output, text: []const u32, buffer: *Buffer, 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 text_width: i32 = 0; + + var i: usize = 0; + while (i < text.len) : (i += 1) { + glyphs[i] = try output.ctx.fonts.rasterizeCharUtf32(text[i], .default); + + kerns[i] = 0; + if (i > 0) { + var x_kern: c_long = 0; + if (output.ctx.fonts.kerning(text[i - 1], text[i], &x_kern, null)) { + kerns[i] = x_kern; + } + } + + text_width += @intCast(kerns[i] + glyphs[i].advance.x); + } + + var x: i32 = @divFloor(buffer.width - text_width, 2); + const y: i32 = @divFloor(buffer.height - output.ctx.fonts.height, 2); + output.render_glyphs(buffer, &x, y, color, text.len, glyphs.ptr, kerns); +} + +// Borrowed https://git.sr.ht/~novakane/zig-fcft-example +fn renderGlyphs( + output: *Output, + 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 + output.ctx.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 + output.ctx.fonts.ascent - @as(i32, @intCast(glyphs[i].y)), + glyphs[i].width, + glyphs[i].height, + ); + }, + } + + x.* += glyphs[i].advance.x; + } +} + +// Borrowed from https://git.sr.ht/~novakane/zig-fcft-example +fn getFcftFonts(fonts: []const u8) !*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| { + 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 mem = std.mem; + +const wayland = @import("wayland"); +const wl = wayland.client.wl; +const zwlr = wayland.client.zwlr; +const fcft = @import("fcft"); +const pixman = @import("pixman"); + +const utils = @import("utils.zig"); +const Buffer = @import("Buffer.zig"); +const Context = @import("Context.zig"); +const Output = @import("Output.zig"); + +const log = std.log.scoped(.Bar); diff --git a/src/Output.zig b/src/Output.zig index 8f4204c..6be863b 100644 --- a/src/Output.zig +++ b/src/Output.zig @@ -11,6 +11,9 @@ river_output_v1: *river.OutputV1, // We have to wait for the rwm.wl_output event to get this wl_output: ?*wl.Output = null, +// Friendly name of this output +name: ?[]const u8 = null, + // Output geometry scale: u31 = 1, width: u31 = 0, @@ -19,11 +22,15 @@ x: i32 = 0, y: i32 = 0, // Information for this Output's wallpaper -render_width: u31 = 0, -render_height: u31 = 0, +wallpaper_render_width: u31 = 0, +wallpaper_render_height: u31 = 0, wl_surface: ?*wl.Surface = null, layer_surface: ?*zwlr.LayerSurfaceV1 = null, +// TODO: Make Bar a user option, can disable if they want +// This Output's bar +bar: ?Bar, + /// Proportion of output width taken by the primary stack primary_ratio: f32, @@ -41,9 +48,6 @@ tags: u32 = 0x0001, /// State consumed in manage() phase, reset at end of manage(). pending_manage: PendingManage = .{}, -// Friendly name of this output -name: ?[]const u8 = null, - /// Used for wallpaper rendering management configured: bool = false, @@ -72,9 +76,15 @@ pub fn create(context: *Context, river_output_v1: *river.OutputV1) !*Output { var output = try utils.gpa.create(Output); errdefer output.destroy(); + const bar = Bar.init(context, output) catch |e| blk: { + log.err("Failed to create a bar: {}", .{e}); + break :blk null; + }; + output.* = .{ .context = context, .river_output_v1 = river_output_v1, + .bar = bar, .primary_count = context.config.primary_count, .primary_ratio = context.config.primary_ratio, .windows = undefined, // we will initialize this shortly @@ -188,14 +198,20 @@ fn riverOutputListener(river_output_v1: *river.OutputV1, event: river.OutputV1.E output.wl_output = output.context.wl_outputs.get(ev.name).?; output.wl_output.?.setListener(*Output, wlOutputListener, output); - // The wl_output's initial events (mode, scale, name, done) were likely - // already delivered during the initial roundtrip before we set our - // listener, so the .done event that triggers wallpaper init was lost. - // Explicitly init the wallpaper surface here. + // The wl_output's initial events come during the initial roundtrip + // before we set our listener, so the .done event that triggers + // wallpaper init was lost. Explicitly init the surfaces here. output.initWallpaperLayerSurface() catch |err| { const output_name = output.name orelse "some output"; log.err("failed to add a surface to {s}: {}", .{ output_name, err }); }; + if (output.bar) |*bar| { + bar.initSurface() catch |err| { + const output_name = output.name orelse "some output"; + log.err("failed to init bar for {s}: {}", .{ output_name, err }); + return; + }; + } }, .dimensions => |ev| { // Protocol guarantees that width and height are strictly greater than zero @@ -229,6 +245,13 @@ fn wlOutputListener(_: *wl.Output, event: wl.Output.Event, output: *Output) void log.err("failed to add a surface to {s}: {}", .{ output_name, err }); return; }; + if (output.bar) |*bar| { + bar.initSurface() catch |err| { + const output_name = output.name orelse "some output"; + log.err("failed to init bar for {s}: {}", .{ output_name, err }); + return; + }; + } }, .scale => |ev| { if (ev.factor < 0) { @@ -251,7 +274,7 @@ pub fn initWallpaperLayerSurface(output: *Output) !void { return; } - if (output.wl_surface) |_| { + if (output.layer_surface) |_| { // This output already has a layer surface, we can exit early return; } @@ -259,6 +282,7 @@ pub fn initWallpaperLayerSurface(output: *Output) !void { const context = output.context; const wl_surface: *wl.Surface = try context.wl_compositor.createSurface(); + errdefer wl_surface.destroy(); // We don't want our surface to have any input region (default is infinite) const empty_region: *wl.Region = try context.wl_compositor.createRegion(); @@ -267,10 +291,11 @@ pub fn initWallpaperLayerSurface(output: *Output) !void { // Full surface should be opaque const opaque_region: *wl.Region = try context.wl_compositor.createRegion(); + opaque_region.add(0, 0, output.width, output.height); defer opaque_region.destroy(); wl_surface.setOpaqueRegion(opaque_region); - const layer_surface: *zwlr.LayerSurfaceV1 = try context.zwlr_layer_shell_v1.getLayerSurface(wl_surface, output.wl_output, .background, "beansprout"); + const layer_surface: *zwlr.LayerSurfaceV1 = try context.zwlr_layer_shell_v1.getLayerSurface(wl_surface, output.wl_output, .background, "beansprout-wallpaper"); layer_surface.setExclusiveZone(-1); layer_surface.setAnchor(.{ .top = true, .right = true, .bottom = true, .left = true }); @@ -301,15 +326,10 @@ fn wallpaperLayerSurfaceListener(layer_surface: *zwlr.LayerSurfaceV1, event: zwl .configure => |ev| { layer_surface.ackConfigure(ev.serial); - if (ev.width < 0 or ev.height < 0) { - // I'm not actually sure if this is possible, but just to be safe - log.warn("Received zwlr_layer_surface_v1.configure event with a negative width or height ({d}x{d})", .{ ev.width, ev.height }); - return; - } const width: u31 = @intCast(ev.width); const height: u31 = @intCast(ev.height); - if (output.configured and output.render_width == width and output.render_height == height) { + if (output.configured and output.wallpaper_render_width == width and output.wallpaper_render_height == height) { if (output.wl_surface) |wl_surface| { wl_surface.commit(); } else { @@ -319,8 +339,8 @@ fn wallpaperLayerSurfaceListener(layer_surface: *zwlr.LayerSurfaceV1, event: zwl } log.debug("configuring wallpaper surface with width {} and height {}", .{ width, height }); - output.render_width = width; - output.render_height = height; + output.wallpaper_render_width = width; + output.wallpaper_render_height = height; output.configured = true; output.renderWallpaper() catch |err| { @@ -334,7 +354,7 @@ fn wallpaperLayerSurfaceListener(layer_surface: *zwlr.LayerSurfaceV1, event: zwl } /// Calculates image_dimension / (output_dimension * scale) -fn calculate_scale(image_dimension: c_int, output_dimension: u31, scale: u31) f64 { +fn calculateScale(image_dimension: c_int, output_dimension: u31, scale: u31) f64 { const numerator: f64 = @floatFromInt(image_dimension); const denominator: f64 = @floatFromInt(output_dimension * scale); @@ -342,7 +362,7 @@ fn calculate_scale(image_dimension: c_int, output_dimension: u31, scale: u31) f6 } /// Calculates (image_dimension / dimension_scale - output_dimension) / 2 / dimension_scale; -fn calculate_transform(image_dimension: c_int, output_dimension: u31, dimension_scale: f64) f64 { +fn calculateTransform(image_dimension: c_int, output_dimension: u31, dimension_scale: f64) f64 { const numerator1: f64 = @floatFromInt(image_dimension); const denominator1: f64 = dimension_scale; const subtruend: f64 = @floatFromInt(output_dimension); @@ -354,8 +374,8 @@ fn calculate_transform(image_dimension: c_int, output_dimension: u31, dimension_ /// Render the wallpaper image onto the layer surface pub fn renderWallpaper(output: *Output) !void { const context = output.context; - const width = output.render_width; - const height = output.render_height; + const width = output.wallpaper_render_width; + const height = output.wallpaper_render_height; const scale = output.scale; // Don't have anything to render @@ -381,7 +401,7 @@ pub fn renderWallpaper(output: *Output) !void { // Calculate image scale var sx: f64 = @as(f64, @floatFromInt(image_width)) / @as(f64, @floatFromInt(width * scale)); - var sy: f64 = calculate_scale(image_height, height, scale); + var sy: f64 = calculateScale(image_height, height, scale); const s = if (sx > sy) sy else sx; sx = s; @@ -389,8 +409,8 @@ pub fn renderWallpaper(output: *Output) !void { // Calculate translation offsets to center the image on the output. // If the scaled image is larger than the output, the offset crops equally from both sides. - const tx: f64 = calculate_transform(image_width, width, sx); - const ty: f64 = calculate_transform(image_height, height, sy); + const tx: f64 = calculateTransform(image_width, width, sx); + const ty: f64 = calculateTransform(image_height, height, sy); // Build a combined source-to-destination transform matrix. // Pixman transforms map destination pixels back to source pixels, so: @@ -613,6 +633,7 @@ const zwlr = wayland.client.zwlr; const pixman = @import("pixman"); const utils = @import("utils.zig"); +const Bar = @import("Bar.zig"); const Buffer = @import("Buffer.zig"); const Context = @import("Context.zig"); const Window = @import("Window.zig"); diff --git a/src/main.zig b/src/main.zig index 82a3ef5..6474085 100644 --- a/src/main.zig +++ b/src/main.zig @@ -36,6 +36,7 @@ const usage: []const u8 = \\ ; +// TODO: I'd like to clean this function up a bit and move some bits into helpers pub fn main() !void { const result = flags.parser([*:0]const u8, &.{ .{ .name = "h", .kind = .boolean }, @@ -78,6 +79,16 @@ pub fn main() !void { } } + // Initialize fcft + const fcft_log_level: fcft.LogClass = switch (runtime_log_level) { + .err => .err, + .warn => .warning, + .info => .info, + .debug => .debug, + }; + _ = fcft.init(.auto, false, fcft_log_level); + defer fcft.fini(); + const wayland_display_var = try utils.gpa.dupeZ(u8, process.getEnvVarOwned(utils.gpa, "WAYLAND_DISPLAY") catch { fatal("Error getting WAYLAND_DISPLAY environment variable. Exiting", .{}); }); @@ -246,6 +257,7 @@ const wayland = @import("wayland"); const river = wayland.client.river; const wl = wayland.client.wl; const zwlr = wayland.client.zwlr; +const fcft = @import("fcft"); const flags = @import("flags.zig"); const utils = @import("utils.zig");