From 2c642d6cfc6aa7311689bbe3ab7612f9cba48f34 Mon Sep 17 00:00:00 2001 From: Ben Buhse Date: Sun, 15 Feb 2026 20:30:59 -0600 Subject: [PATCH] Create initial version of TagOverlay It's an almost one-to-one clone of Leon Plickat's river-tag-overlay. Right now, it's not wired up, so it doesn't do anything yet. --- src/Bar.zig | 14 +-- src/Buffer.zig | 44 +++++++- src/Output.zig | 2 +- src/TagOverlay.zig | 257 +++++++++++++++++++++++++++++++++++++++++++++ src/utils.zig | 84 ++++++++++++++- 5 files changed, 386 insertions(+), 15 deletions(-) create mode 100644 src/TagOverlay.zig diff --git a/src/Bar.zig b/src/Bar.zig index 9cba3d1..2b22302 100644 --- a/src/Bar.zig +++ b/src/Bar.zig @@ -98,6 +98,7 @@ pub fn layerSurfaceListener( event: zwlr.LayerSurfaceV1.Event, bar: *Bar, ) void { + assert(bar.surfaces.?.layer_surface == layer_surface); switch (event) { .configure => |ev| { layer_surface.ackConfigure(ev.serial); @@ -109,18 +110,14 @@ pub fn layerSurfaceListener( bar.height == height and bar.output.scale == bar.font_scale) { - if (bar.surfaces) |surfaces| { - surfaces.wl_surface.commit(); - } else { - log.warn("Bar is marked as configured but is missing its surfaces.", .{}); - } + bar.surfaces.?.wl_surface.commit(); return; } log.debug("Configuring bar surface with width {} and height {}", .{ width, height }); bar.width = width; bar.height = height; - // Excluse zone == the bar's height + // Exclusive zone == the bar's height layer_surface.setExclusiveZone(bar.height); // Full surface should be opaque @@ -128,9 +125,8 @@ pub fn layerSurfaceListener( 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(); + opaque_region.add(0, 0, bar.width, bar.height); bar.surfaces.?.wl_surface.setOpaqueRegion(opaque_region); bar.configured = true; @@ -145,7 +141,6 @@ pub fn layerSurfaceListener( } } -// TODO: Configure number of visible tags /// Renders the bar and its components pub fn render(bar: *Bar) !void { const context = bar.context; @@ -368,6 +363,7 @@ fn getFcftFonts(fonts: []const u8, scale: u31) !*fcft.Font { } const std = @import("std"); +const assert = std.debug.assert; const io = std.io; const mem = std.mem; const process = std.process; diff --git a/src/Buffer.zig b/src/Buffer.zig index f588ee8..2f701d3 100644 --- a/src/Buffer.zig +++ b/src/Buffer.zig @@ -84,15 +84,55 @@ pub fn deinit(buffer: *Buffer) void { // We have to do this later because of the way init() works pub fn setListener(buffer: *Buffer) void { - buffer.wl_buffer.setListener(*Buffer, buffer_listener, buffer); + buffer.wl_buffer.setListener(*Buffer, bufferListener, buffer); } -fn buffer_listener(_: *wl.Buffer, event: wl.Buffer.Event, buffer: *Buffer) void { +fn bufferListener(_: *wl.Buffer, event: wl.Buffer.Event, buffer: *Buffer) void { switch (event) { .release => buffer.busy = false, } } +pub fn borderedRectangle( + buffer: Buffer, + x: u31, + y: u31, + width: u31, + height: u31, + border_width: u31, + scale: u31, + background_color: *const pixman.Color, + border_color: *const pixman.Color, +) void { + const render_x: i16 = @intCast(x * scale); + const render_y: i16 = @intCast(y * scale); + const render_width: u16 = @intCast(width * scale); + const render_height: u16 = @intCast(height * scale); + const render_border_width: u16 = @intCast(border_width * scale); + + // Background fill + _ = pixman.Image.fillRectangles(.src, buffer.image, background_color, 1, &[1]pixman.Rectangle16{.{ .x = render_x, .y = render_y, .width = render_width, .height = render_height }}); + + // Border: top, bottom, left, right + _ = pixman.Image.fillRectangles(.src, buffer.image, border_color, 4, &[4]pixman.Rectangle16{ + .{ + .x = render_x, + .y = render_y, + .width = render_width, + .height = render_border_width, + }, + .{ + .x = render_x, + .y = render_y + @as(i16, @intCast(render_height - + render_border_width)), + .width = render_width, + .height = render_border_width, + }, + .{ .x = render_x, .y = render_y + @as(i16, @intCast(render_border_width)), .width = render_border_width, .height = render_height - 2 * render_border_width }, + .{ .x = render_x + @as(i16, @intCast(render_width - render_border_width)), .y = render_y + @as(i16, @intCast(render_border_width)), .width = render_border_width, .height = render_height - 2 * render_border_width }, + }); +} + const std = @import("std"); const builtin = @import("builtin"); const mem = std.mem; diff --git a/src/Output.zig b/src/Output.zig index 27206b1..fb3d9f9 100644 --- a/src/Output.zig +++ b/src/Output.zig @@ -444,7 +444,7 @@ pub fn renderWallpaper(output: *Output) !void { const image_stride = image.getStride(); const image_format = image.getFormat(); - const buffer: *Buffer = try context.buffer_pool.nextBuffer(context.wl_shm, width * scale, height * scale); + const buffer = try context.buffer_pool.nextBuffer(context.wl_shm, width * scale, height * scale); const pix = pixman.Image.createBitsNoClear(image_format, image_width, image_height, image_data, image_stride) orelse { log.err("Failed to copy the wallpaper image for rendering", .{}); diff --git a/src/TagOverlay.zig b/src/TagOverlay.zig new file mode 100644 index 0000000..a3ea5bf --- /dev/null +++ b/src/TagOverlay.zig @@ -0,0 +1,257 @@ +// SPDX-FileCopyrightText: 2026 Ben Buhse +// +// SPDX-License-Identifier: GPL-3.0-only + +//! TagOverlay is a Zig clone of Leon Plickat's river-tag-overlay but built for beansprout +//! Find river-tag-overlay at: https://git.sr.ht/~leon_plickat/river-tag-overlay/ + +const TagOverlay = @This(); + +context: *Context, + +options: Options, + +output: *Output, + +// Overlay geometry +width: u31 = 0, +height: u31 = 0, +scale: u31 = 1, + +rows: u31, + +surfaces: ?struct { + wl_surface: *wl.Surface, + layer_surface: *zwlr.LayerSurfaceV1, +} = null, + +configured: bool = false, + +pub const Options = struct { + /// Width of the widget border in pixels + border_width: u8, + /// Number of displayed tags + /// Put in config as 1-32 but we subtract 1 to store it in a u5 + tag_amount: u5, + /// Amount of tags per row + /// Put in config as 1-32 but we subtract 1 to store it in a u5 + tags_per_row: u5, + /// Size of tag squares in pixels + square_size: u8, + /// Padding around the tag occupied indicator in pixels + square_inner_padding: u8, + /// Padding around tag squares in pixels + square_padding: u8, + /// Border width of the tag squares in pixels + square_border_width: u8, + /// Widget background color + background_color: pixman.Color, + /// Widget border color + border_color: pixman.Color, + /// Background color of active tag squares + square_active_background_color: pixman.Color, + /// Border color of active tag squares + square_active_border_color: pixman.Color, + /// Occupied indicator color of active tag squares + square_active_occupied_color: pixman.Color, + /// Background color of inactive tag squares + square_inactive_background_color: pixman.Color, + /// Border color of inactive tag squares + square_inactive_border_color: pixman.Color, + /// Occupied indicator color of inactive tag squares + square_inactive_occupied_color: pixman.Color, + // XXX: We do not support urgent windows right now + // /// Background color of urgent tag squares + // square_urgent_background_color: pixman.Color, + // /// Border color of urgent tag squares + // square_urgent_border_color: pixman.Color, + // /// Occupied indicator color of urgent tag squares + // square_urgent_occupied_color: pixman.Color, + /// Directional anchors top, right bottom, left; true for on, false for off + anchors: zwlr.LayerSurfaceV1.Anchor, + /// Directional margins top, right, bottom, left, in pixels + margins: struct { top: i32 = 0, right: i32 = 0, bottom: i32 = 0, left: i32 = 0 } = .{}, + /// Duration of widget display in milliseconds + timeout: u32, +}; + +pub fn init(context: *Context, output: *Output, options: Options) !TagOverlay { + const rows = try math.divCeil(u31, options.tag_amount, options.tags_per_row); + + return .{ + .context = context, + .options = options, + .rows = rows, + .output = output, + }; +} + +pub fn initSurface(tag_overlay: *TagOverlay) !void { + if (tag_overlay.surfaces) |_| { + // This tag overlay already has a layer surface, we can exit early + return; + } + + const context = tag_overlay.context; + const options = tag_overlay.options; + + const wl_surface = try context.wl_compositor.createSurface(); + errdefer wl_surface.destroy(); + + const layer_surface = try context + .zwlr_layer_shell_v1 + .getLayerSurface(wl_surface, tag_overlay.output.wl_output, .overlay, "beansprout-tag-overlay"); + errdefer layer_surface.destroy(); + layer_surface.setExclusiveZone(-1); + + // 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); + + const surface_width = (@min(options.tag_amount, options.tags_per_row) * (options.square_size + options.square_padding)) + options.square_padding + (2 * options.border_width); + const surface_height = (options.rows * (options.square_size + options.square_padding)) + options.square_padding + (2 * options.border_width); + layer_surface.setSize(surface_width, surface_height); + + layer_surface.setAnchor(options.anchors); + layer_surface.setMargin(options.margins); + + tag_overlay.surfaces = .{ .wl_surface = wl_surface, .layer_surface = layer_surface }; + context.buffer_pool.surface_count += 1; + + // layer_surface.setListener(*Bar, layerSurfaceListener, bar); + wl_surface.commit(); +} + +pub fn deinit(tag_overlay: *TagOverlay) void { + tag_overlay.configured = false; + if (tag_overlay.surfaces) |surfaces| { + surfaces.wl_surface.destroy(); + surfaces.layer_surface.destroy(); + tag_overlay.context.buffer_pool.surface_count -= 1; + } +} + +pub fn layerSurfaceListener( + layer_surface: *zwlr.LayerSurfaceV1, + event: zwlr.LayerSurfaceV1.Event, + tag_overlay: *TagOverlay, +) void { + assert(tag_overlay.surfaces.?.layer_surface == layer_surface); + switch (event) { + .configure => |ev| { + layer_surface.ackConfigure(ev.serial); + const width: u31 = @intCast(ev.width); + const height: u31 = @intCast(ev.height); + + if (tag_overlay.configured and + tag_overlay.width == width and + tag_overlay.height == height and + tag_overlay.output.scale == tag_overlay.scale) + { + tag_overlay.surfaces.?.wl_surface.commit(); + return; + } + + log.debug("Configuring tag_overlay surface with width {} and height {}", .{ width, height }); + tag_overlay.width = width; + tag_overlay.height = height; + + // Full surface should be opaque + const opaque_region: *wl.Region = tag_overlay.context.wl_compositor.createRegion() catch |e| { + log.err("Failed to create opaque region for tag_overlay: {}", .{e}); + return; + }; + defer opaque_region.destroy(); + opaque_region.add(0, 0, tag_overlay.width, tag_overlay.height); + tag_overlay.surfaces.?.wl_surface.setOpaqueRegion(opaque_region); + + tag_overlay.configured = true; + + tag_overlay.render() catch |err| { + log.err("tag_overlay render failed: {}", .{err}); + }; + }, + .closed => { + tag_overlay.deinit(); + }, + } +} + +pub fn render(tag_overlay: *TagOverlay) !void { + const context = tag_overlay.context; + const options = tag_overlay.options; + + // Scaled width/height + const scale = tag_overlay.output.scale; + const render_width = tag_overlay.width * scale; + const render_height = tag_overlay.height * scale; + + // Don't have anything to render + if (render_width == 0 or render_height == 0) { + return; + } + const buffer = try context.buffer_pool.nextBuffer(context.wl_shm, render_width, render_height); + + buffer.borderedRectangle(0, 0, tag_overlay.width, tag_overlay.height, options.border_width, scale, options.background_color, options.border_color); + + const focused_tags = tag_overlay.output.tags; + const occupied_tags = tag_overlay.output.occupiedTags(); + + for (0..tag_overlay.rows) |row| { + for (0..options.tags_per_row) |tag| { + const current_tag = tag + row * options.tags_per_row; + if (current_tag >= options.tag_amount) break; + + const bg_color, const border_color, const occupied_color = + // Check whether this tag is focused or not to decide colors + if (focused_tags & (@as(u32, 1) << @intCast(current_tag)) != 0) + .{ &options.square_active_background_color, &options.square_active_border_color, &options.square_active_occupied_color } + else + .{ &options.square_inactive_background_color, &options.square_inactive_border_color, &options.square_inactive_occupied_color }; + + const x = options.border_width + ((tag + 1) * options.square_padding) + (tag * options.square_size); + const y = options.border_width + ((row + 1) * options.square_padding) + (row * options.square_size); + + buffer.borderedRectangle(x, y, options.square_size, options.square_size, options.square_border_width, scale, bg_color, border_color); + + if (occupied_tags & (@as(u32, 1) << @intCast(current_tag)) != 0) { + buffer.borderedRectangle( + x + options.square_inner_padding, + y + options.square_inner_padding, + options.square_size - 2 * options.square_inner_padding, + options.square_size - 2 * options.square_inner_padding, + options.square_border_width, + scale, + occupied_color, + border_color, + ); + } + } + } + + // Finally, attach the buffer to the surface + const surfaces = tag_overlay.surfaces orelse return error.NoSurfaces; + const wl_surface = surfaces.wl_surface; + wl_surface.setBufferScale(scale); + wl_surface.attach(buffer.wl_buffer, 0, 0); + wl_surface.damageBuffer(0, 0, render_width, render_height); + wl_surface.commit(); +} + +const std = @import("std"); +const assert = std.debug.assert; +const math = std.math; + +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(.TagOverlay); diff --git a/src/utils.zig b/src/utils.zig index 94cadba..b0837ce 100644 --- a/src/utils.zig +++ b/src/utils.zig @@ -26,7 +26,7 @@ pub fn parseRgba(s: []const u8) !RiverColor { } const bytes: [4]u8 = @as([4]u8, @bitCast(color)); - return parseRgbaHelper(bytes); + return bytesToRiverColor(bytes); } /// Parse a color in the format 0xRRGGBB or 0xRRGGBBAA and convert it to @@ -43,10 +43,49 @@ pub fn parseRgbaComptime(comptime s: []const u8) RiverColor { } const bytes = @as([4]u8, @bitCast(color)); - return parseRgbaHelper(bytes); + return bytesToRiverColor(bytes); } -fn parseRgbaHelper(bytes: [4]u8) RiverColor { +/// Parse a color in the format 0xRRGGBB or 0xRRGGBBAA and convert it to +/// 16-bit color values. +pub fn parseRgbaPixman(s: []const u8) !pixman.Color { + if (s.len != 8 and s.len != 10) return error.InvalidRgba; + if (s[0] != '0' or s[1] != 'x') return error.InvalidRgba; + + var color = try fmt.parseUnsigned(u32, s[2..], 16); + if (s.len == 8) { + color <<= 8; + color |= 0xff; + } + + return bytesToPixmanColor(@bitCast(color)); +} + +/// Parse a color in the format 0xRRGGBB or 0xRRGGBBAA and convert it to +/// 16-bit color values at comptime. +pub fn parseRgbaPixmanComptime(comptime s: []const u8) pixman.Color { + if (s.len != 8 and s.len != 10) @compileError("Invalid RGBA"); + if (s[0] != '0' or s[1] != 'x') @compileError("Invalid RGBA"); + + comptime var color = try fmt.parseUnsigned(u32, s[2..], 16); + if (s.len == 8) { + color <<= 8; + color |= 0xff; + } + + return bytesToPixmanColor(@bitCast(color)); +} + +fn bytesToPixmanColor(bytes: [4]u8) pixman.Color { + return .{ + .red = @as(u16, bytes[3]) * 0x101, + .green = @as(u16, bytes[2]) * 0x101, + .blue = @as(u16, bytes[1]) * 0x101, + .alpha = @as(u16, bytes[0]) * 0x101, + }; +} + +fn bytesToRiverColor(bytes: [4]u8) RiverColor { const r: u32 = bytes[3]; const g: u32 = bytes[2]; const b: u32 = bytes[1]; @@ -137,6 +176,7 @@ const mem = std.mem; const wayland = @import("wayland"); const river = wayland.client.river; +const pixman = @import("pixman"); const utils = @import("utils.zig"); @@ -191,6 +231,44 @@ test "parseRgbaComptime with alpha" { try testing.expectEqual(@as(u32, 0xff << 24), color.alpha); } +test "parseRgbaPixman 0xRRGGBB" { + const color = try parseRgbaPixman("0x89b4fa"); + try testing.expectEqual(@as(u16, 0x8989), color.red); + try testing.expectEqual(@as(u16, 0xb4b4), color.green); + try testing.expectEqual(@as(u16, 0xfafa), color.blue); + try testing.expectEqual(@as(u16, 0xffff), color.alpha); +} + +test "parseRgbaPixman 0xRRGGBBAA" { + const color = try parseRgbaPixman("0x1e1e2e80"); + try testing.expectEqual(@as(u16, 0x1e1e), color.red); + try testing.expectEqual(@as(u16, 0x1e1e), color.green); + try testing.expectEqual(@as(u16, 0x2e2e), color.blue); + try testing.expectEqual(@as(u16, 0x8080), color.alpha); +} + +test "parseRgbaPixman invalid" { + try testing.expectError(error.InvalidRgba, parseRgbaPixman("0x123")); + try testing.expectError(error.InvalidRgba, parseRgbaPixman("xx123456")); + try testing.expectError(error.InvalidCharacter, parseRgbaPixman("0xGGGGGG")); +} + +test "parseRgbaPixmanComptime" { + const color = parseRgbaPixmanComptime("0x89b4fa"); + try testing.expectEqual(@as(u16, 0x8989), color.red); + try testing.expectEqual(@as(u16, 0xb4b4), color.green); + try testing.expectEqual(@as(u16, 0xfafa), color.blue); + try testing.expectEqual(@as(u16, 0xffff), color.alpha); +} + +test "parseRgbaPixmanComptime with alpha" { + const color = parseRgbaPixmanComptime("0x1e1e2e80"); + try testing.expectEqual(@as(u16, 0x1e1e), color.red); + try testing.expectEqual(@as(u16, 0x1e1e), color.green); + try testing.expectEqual(@as(u16, 0x2e2e), color.blue); + try testing.expectEqual(@as(u16, 0x8080), color.alpha); +} + test "stripQuotes removes surrounding quotes" { try testing.expectEqualStrings("hello", stripQuotes("\"hello\"")); }