diff --git a/README.md b/README.md index 74823bb..5135753 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,7 @@ Needed at both build-time and runtime: | wayland-client | `dev-libs/wayland` | `libwayland-dev` | | pixman | `x11-libs/pixman` | `libpixman-1-dev` | | xkbcommon | `x11-libs/libxkbcommon` | `libxkbcommon-dev` | +| fcft | `media-libs/fcft` | `libfcft-dev` | Only needed at build-time: diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index a02b79d..df5a7b7 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -44,7 +44,7 @@ wallpaper_image_path "~/Pictures/wallpaper.png" |------------------------------|--------|---------|-----------------------------------------------------| | `attach_mode` | enum | `top` | Where new windows go in the stack (`top` or `bottom`) | | `primary_count` | u8 | `1` | Number of windows in the primary stack (0+) | -| `primary_ratio` | float | `0.55` | Proportion of output width for the primary stack (0.10–0.90) | +| `primary_ratio` | float | `0.55` | Proportion of output width for the primary stack (0.10-0.90) | | `focus_follows_pointer` | bool | `#true` | Focus follows the pointer between windows | | `pointer_warp_on_focus_change` | bool | `#true` | Warp pointer to center of newly-focused windows | | `wallpaper_image_path` | string | none | Path to wallpaper image | @@ -71,6 +71,62 @@ borders { Colors are specified in `0xRRGGBB` or `0xRRGGBBAA` hex format. +## Tag Overlay + +The tag overlay is an optional widget that briefly shows your tag state when switching tags. +It is only created when a `tag_overlay` 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 +tag_overlay { +} +``` + +### Tag Overlay Settings + +| Setting | Type | Default | Description | +|-------------------------------------|-------|--------------|-------------------------------------------| +| `border_width` | u8 | `2` | Widget border width in pixels | +| `tag_amount` | u8 | `9` | Number of displayed tags (1-32) | +| `tags_per_row` | u8 | `32` | Tags per row (1-32) | +| `square_size` | u8 | `40` | Size of tag squares in pixels | +| `square_inner_padding` | u8 | `10` | Padding around occupied indicator | +| `square_padding` | u8 | `15` | Padding around tag squares | +| `square_border_width` | u8 | `1` | Border width of tag squares | +| `timeout` | u32 | `500` | Display duration in milliseconds | +| `background_color` | color | `0x1e1e2e` | Widget background color | +| `border_color` | color | `0x6c7086` | Widget border color | +| `square_active_background_color` | color | `0x89b4fa` | Active tag square background | +| `square_active_border_color` | color | `0x6c7086` | Active tag square border | +| `square_active_occupied_color` | color | `0xcdd6f4` | Active tag occupied indicator | +| `square_inactive_background_color` | color | `0x585b70` | Inactive tag square background | +| `square_inactive_border_color` | color | `0x6c7086` | Inactive tag square border | +| `square_inactive_occupied_color` | color | `0xcdd6f4` | Inactive tag occupied indicator | + +### Anchors + +The `anchors` child block controls which edge(s) of the screen the overlay +attaches to. Each direction is a boolean (`#true` / `#false`). Default: none, i.e. centered on output. + +| Setting | Type | Default | +|----------|------|----------| +| `top` | bool | `#false` | +| `right` | bool | `#false` | +| `bottom` | bool | `#false` | +| `left` | bool | `#false` | + +### Margins + +The `margins` child block sets pixel offsets from the anchored edge(s). + +| Setting | Type | Default | +|----------|------|---------| +| `top` | i32 | `0` | +| `right` | i32 | `0` | +| `bottom` | i32 | `0` | +| `left` | i32 | `0` | + ## Keybinds Keyboard bindings are placed inside a `keybinds` block. Each binding has the diff --git a/docs/TODO.md b/docs/TODO.md index 6872db3..c1b536e 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -2,8 +2,7 @@ These are in rough order of my priority, though no promises I do them in this order. -- [ ] Implement a river-tag-overlay clone -- [ ] Add options to the bar and river-tag-overlay +- [ ] Add options to the bar - [ ] Make a Rect struct to combine x, y, width, and height - [ ] Support window rules (float/tags/SSD by app-id/title) - [ ] Support overriding config location @@ -16,6 +15,7 @@ These are in rough order of my priority, though no promises I do them in this or - [ ] Save window positions between restarts - [ ] Support multiple seats - [ ] Support clipping floating windows on edge of/between outputs +- [ ] Use per-output timerfds for tag overlay instead of a single shared one - [x] Support changeable primary ratio - [x] Support changeable primary count - [x] Support multiple outputs @@ -29,3 +29,5 @@ These are in rough order of my priority, though no promises I do them in this or - [x] Implement primary count/ratio per tagmask - [x] Add primary_count and primary_ratio to Config - [x] Implement an optional clock bar +- [x] Implement a river-tag-overlay clone +- [x] Add options to the tag overlay diff --git a/examples/config.kdl b/examples/config.kdl index e558233..192cb28 100644 --- a/examples/config.kdl +++ b/examples/config.kdl @@ -18,6 +18,19 @@ borders { color_focused "0x89b4fa" color_unfocused "0x1e1e2e" } +// Tag overlay widget — shown briefly when switching tags +// Remove this block to disable the overlay entirely +tag_overlay { + tag_amount 10 + background_color "0x1e1e2e" + border_color "0x6c7086" + square_active_background_color "0x89b4fa" + square_active_border_color "0x6c7086" + square_active_occupied_color "0xcdd6f4" + square_inactive_background_color "0x585b70" + square_inactive_border_color "0x6c7086" + square_inactive_occupied_color "0xcdd6f4" +} keybinds { // Swap a window spawn Mod4 T foot diff --git a/src/Bar.zig b/src/Bar.zig index 3055092..2b22302 100644 --- a/src/Bar.zig +++ b/src/Bar.zig @@ -21,23 +21,23 @@ output: *Output, width: u31 = 0, height: u31 = 0, -wl_surface: ?*wl.Surface = null, -layer_surface: ?*zwlr.LayerSurfaceV1 = null, +surfaces: ?struct { + wl_surface: *wl.Surface, + layer_surface: *zwlr.LayerSurfaceV1, +} = null, configured: bool = false, pub fn init(context: *Context, output: *Output) !Bar { - // Get the local environment - // Needed for the timezone - // XXX: It might be better to store this in Context? - var env = try process.getEnvMap(utils.gpa); - defer env.deinit(); + const timezone = try zeit.local(utils.gpa, &context.env); + errdefer timezone.deinit(); - const timezone = try zeit.local(utils.gpa, &env); + const fonts = try getFcftFonts("monospace:size=14", 1); + errdefer fonts.destroy(); return .{ .context = context, - .fonts = try getFcftFonts("monospace:size=14", 1), + .fonts = fonts, .timezone = timezone, .output = output, }; @@ -45,34 +45,37 @@ pub fn init(context: *Context, output: *Output) !Bar { // 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 + if (bar.surfaces) |_| { + // This bar already has a surface, we can exit early return; } const context = bar.context; - // TODO: Add padding to config - const vertical_padding = 5; - const bar_height: u31 = @intCast(bar.fonts.height + 2 * vertical_padding); - const wl_surface = try context.wl_compositor.createSurface(); errdefer wl_surface.destroy(); + const layer_surface = try context + .zwlr_layer_shell_v1 + .getLayerSurface(wl_surface, bar.output.wl_output, .top, "beansprout-bar"); + errdefer layer_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 = try context.wl_compositor.createRegion(); defer empty_region.destroy(); wl_surface.setInputRegion(empty_region); - const layer_surface = try context - .zwlr_layer_shell_v1 - .getLayerSurface(wl_surface, bar.output.wl_output, .top, "beansprout-bar"); + // TODO: Add padding to config + const vertical_padding = 5; + const bar_height: u31 = @intCast(bar.fonts.height + 2 * vertical_padding); 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; + bar.surfaces = .{ + .wl_surface = wl_surface, + .layer_surface = layer_surface, + }; context.buffer_pool.surface_count += 1; layer_surface.setListener(*Bar, layerSurfaceListener, bar); @@ -82,11 +85,10 @@ pub fn initSurface(bar: *Bar) !void { pub fn deinit(bar: *Bar) void { bar.configured = false; bar.timezone.deinit(); - if (bar.wl_surface) |wl_surface| { - wl_surface.destroy(); - } - if (bar.layer_surface) |layer_surface| { - layer_surface.destroy(); + bar.fonts.destroy(); + if (bar.surfaces) |surfaces| { + surfaces.wl_surface.destroy(); + surfaces.layer_surface.destroy(); bar.context.buffer_pool.surface_count -= 1; } } @@ -96,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); @@ -107,18 +110,14 @@ pub fn layerSurfaceListener( bar.height == height and bar.output.scale == bar.font_scale) { - 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", .{}); - } + bar.surfaces.?.wl_surface.commit(); return; } - log.debug("configuring bar surface with width {} and height {}", .{ width, height }); + 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 @@ -126,10 +125,9 @@ 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(); - bar.wl_surface.?.setOpaqueRegion(opaque_region); + opaque_region.add(0, 0, bar.width, bar.height); + bar.surfaces.?.wl_surface.setOpaqueRegion(opaque_region); bar.configured = true; @@ -143,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; @@ -223,7 +220,8 @@ pub fn render(bar: *Bar) !void { try bar.renderChars(codepoints, buffer, &x, y, color); // Finally, attach the buffer to the surface - const wl_surface = bar.wl_surface orelse return; + const surfaces = bar.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); @@ -365,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..4d685f2 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.pixman_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.pixman_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/Config.zig b/src/Config.zig index be6746b..c25b139 100644 --- a/src/Config.zig +++ b/src/Config.zig @@ -31,6 +31,9 @@ pointer_warp_on_focus_change: bool = true, /// Path to the wallpaper image wallpaper_image_path: ?[]const u8 = null, +/// Tag overlay configuration. If null, no overlay is created. +tag_overlay: ?TagOverlayConfig = null, + /// Tag bind entries parsed from config (tag_bind nodes in keybinds block) tag_binds: std.ArrayList(Keybind) = .{}, keybinds: std.ArrayList(Keybind) = .{}, @@ -85,6 +88,66 @@ pub const AttachMode = enum { bottom, }; +pub const TagOverlayConfig = struct { + border_width: u8 = 2, + tag_amount: u8 = 9, + tags_per_row: u8 = 32, + square_size: u8 = 40, + square_inner_padding: u8 = 10, + square_padding: u8 = 15, + square_border_width: u8 = 1, + background_color: pixman.Color = utils.parseRgbaPixmanComptime("0x1e1e2e"), + border_color: pixman.Color = utils.parseRgbaPixmanComptime("0x6c7086"), + square_active_background_color: pixman.Color = utils.parseRgbaPixmanComptime("0x89b4fa"), + square_active_border_color: pixman.Color = utils.parseRgbaPixmanComptime("0x6c7086"), + square_active_occupied_color: pixman.Color = utils.parseRgbaPixmanComptime("0xcdd6f4"), + square_inactive_background_color: pixman.Color = utils.parseRgbaPixmanComptime("0x585b70"), + square_inactive_border_color: pixman.Color = utils.parseRgbaPixmanComptime("0x6c7086"), + square_inactive_occupied_color: pixman.Color = utils.parseRgbaPixmanComptime("0xcdd6f4"), + timeout: u32 = 500, + anchor_top: bool = false, + anchor_right: bool = false, + anchor_bottom: bool = false, + anchor_left: bool = false, + margin_top: i32 = 0, + margin_right: i32 = 0, + margin_bottom: i32 = 0, + margin_left: i32 = 0, + + pub fn toTagOverlayOptions(self: TagOverlayConfig) TagOverlay.Options { + return .{ + .border_width = self.border_width, + .tag_amount = @intCast(std.math.clamp(@as(u32, self.tag_amount), 1, 32)), + .tags_per_row = @intCast(std.math.clamp(@as(u32, self.tags_per_row), 1, 32)), + .square_size = self.square_size, + .square_inner_padding = self.square_inner_padding, + .square_padding = self.square_padding, + .square_border_width = self.square_border_width, + .background_color = self.background_color, + .border_color = self.border_color, + .square_active_background_color = self.square_active_background_color, + .square_active_border_color = self.square_active_border_color, + .square_active_occupied_color = self.square_active_occupied_color, + .square_inactive_background_color = self.square_inactive_background_color, + .square_inactive_border_color = self.square_inactive_border_color, + .square_inactive_occupied_color = self.square_inactive_occupied_color, + .anchors = .{ + .top = self.anchor_top, + .right = self.anchor_right, + .bottom = self.anchor_bottom, + .left = self.anchor_left, + }, + .margins = .{ + .top = self.margin_top, + .right = self.margin_right, + .bottom = self.margin_bottom, + .left = self.margin_left, + }, + .timeout = self.timeout, + }; + } +}; + const NodeName = enum { attach_mode, primary_count, @@ -97,6 +160,7 @@ const NodeName = enum { keybinds, pointer_binds, input, + tag_overlay, }; const BorderNodeName = enum { @@ -105,6 +169,30 @@ const BorderNodeName = enum { color_unfocused, }; +const TagOverlayNodeName = enum { + border_width, + tag_amount, + tags_per_row, + square_size, + square_inner_padding, + square_padding, + square_border_width, + background_color, + border_color, + square_active_background_color, + square_active_border_color, + square_active_occupied_color, + square_inactive_background_color, + square_inactive_border_color, + square_inactive_occupied_color, + timeout, + anchors, + margins, +}; + +const TagOverlayAnchorsNodeName = enum { top, right, bottom, left }; +const TagOverlayMarginsNodeName = enum { top, right, bottom, left }; + const PointerBindNodeName = enum { move_window, resize_window, @@ -315,6 +403,9 @@ fn load(config: *Config, reader: *Io.Reader) !void { null; next_child_block = .input; }, + .tag_overlay => { + next_child_block = .tag_overlay; + }, } } else { logWarnInvalidNode(node.name); @@ -330,6 +421,7 @@ fn load(config: *Config, reader: *Io.Reader) !void { try config.loadInputChildBlock(&parser, pending_input_name, hostname); pending_input_name = null; // ownership transferred }, + .tag_overlay => try config.loadTagOverlayChildBlock(&parser, hostname), else => { // Nothing else should ever be marked as a next_child_block unreachable; @@ -398,16 +490,162 @@ fn loadBordersChildBlock(config: *Config, parser: *kdl.Parser, hostname: ?[]cons } } +fn loadTagOverlayChildBlock(config: *Config, parser: *kdl.Parser, hostname: ?[]const u8) !void { + config.tag_overlay = .{}; // Presence of block = enabled; initialize with defaults + + const TagOverlayChild = enum { anchors, margins }; + var next_child_block: ?TagOverlayChild = null; + + while (try parser.next()) |event| { + switch (event) { + .node => |node| { + if (next_child_block) |child| { + log.warn("Expected child block for tag_overlay.{s}, got node instead. Ignoring", .{@tagName(child)}); + next_child_block = null; + } + const node_name = std.meta.stringToEnum(TagOverlayNodeName, node.name); + if (node_name) |name| { + if (!hostMatches(node, parser, hostname)) { + logDebugHostMismatch(name); + continue; + } + const val_str = utils.stripQuotes(node.arg(parser, 0) orelse ""); + switch (name) { + .anchors => next_child_block = .anchors, + .margins => next_child_block = .margins, + // These are all u8s, so we can inline the branch + inline .border_width, + .tag_amount, + .tags_per_row, + .square_size, + .square_inner_padding, + .square_padding, + .square_border_width, + => |tag| { + const val = fmt.parseInt(u8, val_str, 10) catch { + logWarnInvalidNodeArg(name, val_str); + continue; + }; + @field(config.tag_overlay.?, @tagName(tag)) = val; + logDebugSettingNode(name, val_str); + }, + .timeout => { + config.tag_overlay.?.timeout = fmt.parseInt(u32, val_str, 10) catch { + logWarnInvalidNodeArg(name, val_str); + continue; + }; + logDebugSettingNode(name, val_str); + }, + inline .background_color, + .border_color, + .square_active_background_color, + .square_active_border_color, + .square_active_occupied_color, + .square_inactive_background_color, + .square_inactive_border_color, + .square_inactive_occupied_color, + => |tag| { + @field(config.tag_overlay.?, @tagName(tag)) = utils.parseRgbaPixman(val_str) catch { + logWarnInvalidNodeArg(name, val_str); + continue; + }; + logDebugSettingNode(name, val_str); + }, + } + } else { + logWarnInvalidNode(node.name); + } + }, + .child_block_begin => { + if (next_child_block) |child| { + switch (child) { + .anchors => try config.loadTagOverlayAnchorsBlock(parser, hostname), + .margins => try config.loadTagOverlayMarginsBlock(parser, hostname), + } + next_child_block = null; + } else { + try config.skipChildBlock(parser); + } + }, + .child_block_end => return, + } + } +} + +fn loadTagOverlayAnchorsBlock(config: *Config, parser: *kdl.Parser, hostname: ?[]const u8) !void { + while (try parser.next()) |event| { + switch (event) { + .node => |node| { + const node_name = std.meta.stringToEnum(TagOverlayAnchorsNodeName, node.name); + if (node_name) |name| { + if (!hostMatches(node, parser, hostname)) { + logDebugHostMismatch(name); + continue; + } + const val_str = utils.stripQuotes(node.arg(parser, 0) orelse ""); + if (boolFromKdlStr(val_str)) |val| { + switch (name) { + .top => config.tag_overlay.?.anchor_top = val, + .right => config.tag_overlay.?.anchor_right = val, + .bottom => config.tag_overlay.?.anchor_bottom = val, + .left => config.tag_overlay.?.anchor_left = val, + } + logDebugSettingNode(name, val_str); + } else { + logWarnInvalidNodeArg(name, val_str); + } + } else { + logWarnInvalidNode(node.name); + } + }, + .child_block_begin => try config.skipChildBlock(parser), + .child_block_end => return, + } + } +} + +fn loadTagOverlayMarginsBlock(config: *Config, parser: *kdl.Parser, hostname: ?[]const u8) !void { + while (try parser.next()) |event| { + switch (event) { + .node => |node| { + const node_name = std.meta.stringToEnum(TagOverlayMarginsNodeName, node.name); + if (node_name) |name| { + if (!hostMatches(node, parser, hostname)) { + logDebugHostMismatch(name); + continue; + } + const val_str = utils.stripQuotes(node.arg(parser, 0) orelse ""); + const val = fmt.parseInt(i32, val_str, 10) catch { + logWarnInvalidNodeArg(name, val_str); + continue; + }; + switch (name) { + .top => config.tag_overlay.?.margin_top = val, + .right => config.tag_overlay.?.margin_right = val, + .bottom => config.tag_overlay.?.margin_bottom = val, + .left => config.tag_overlay.?.margin_left = val, + } + logDebugSettingNode(name, val_str); + } else { + logWarnInvalidNode(node.name); + } + }, + .child_block_begin => try config.skipChildBlock(parser), + .child_block_end => return, + } + } +} + fn loadKeybindsChildBlock(config: *Config, parser: *kdl.Parser, hostname: ?[]const u8) !void { while (try parser.next()) |event| { switch (event) { .node => |node| { - if (!hostMatches(node, parser, hostname)) { - logDebugHostMismatch(node.name); - continue; - } - // tag_bind is a special case node name + // tag_bind is a special case node name not in KeybindNodeName if (mem.eql(u8, node.name, "tag_bind")) { + if (!hostMatches(node, parser, hostname)) { + log.debug("Skipping \"keybind.tag_bind\" (host mismatch)", .{}); + continue; + } const mod_str = utils.stripQuotes(node.arg(parser, 0) orelse { log.warn("tag_bind: missing modifier argument. Ignoring", .{}); continue; @@ -443,13 +681,16 @@ fn loadKeybindsChildBlock(config: *Config, parser: *kdl.Parser, hostname: ?[]con .keysym = null, // Tag binds don't need a keysym (automatically 1-9) }); - // TODO: Make a logger for keybind settings log.debug("tag_bind: {s} {s}", .{ mod_str, cmd_str }); continue; } - // Handle the rest of the possiblities like all the other types of block + // Handle the rest of the possibilities like all the other types of block const node_name = std.meta.stringToEnum(KeybindNodeName, node.name); if (node_name) |name| { + if (!hostMatches(node, parser, hostname)) { + logDebugHostMismatch(name); + continue; + } // All nodes should have at least a command, modifiers, and a keysym // Some may have more arguments handled in the switch const mod_str = utils.stripQuotes(node.arg(parser, 0) orelse { @@ -787,6 +1028,9 @@ fn logWarnInvalidNodeArg(node_name: anytype, node_value: []const u8) void { KeybindNodeName => log.warn("Invalid \"keybind.{s}\" ({s}). Ignoring", .{ @tagName(node_name), node_value }), PointerBindNodeName => log.warn("Invalid \"pointer_binds.{s}\" ({s}). Ignoring", .{ @tagName(node_name), node_value }), InputConfigNodeName => log.warn("Invalid \"input.{s}\" ({s}). Ignoring", .{ @tagName(node_name), node_value }), + TagOverlayNodeName => log.warn("Invalid \"tag_overlay.{s}\" ({s}). Using default value", .{ @tagName(node_name), node_value }), + TagOverlayAnchorsNodeName => log.warn("Invalid \"tag_overlay.anchors.{s}\" ({s}). Using default value", .{ @tagName(node_name), node_value }), + TagOverlayMarginsNodeName => log.warn("Invalid \"tag_overlay.margins.{s}\" ({s}). Using default value", .{ @tagName(node_name), node_value }), else => @compileError("This function does not (yet) support type \"" ++ @typeName(node_name_type) ++ "\""), } } @@ -798,6 +1042,7 @@ fn logWarnMissingNodeArg(node_name: anytype, comptime arg: []const u8) void { KeybindNodeName => log.warn("\"keybind.{s}\" missing " ++ arg ++ " argument. Ignoring", .{@tagName(node_name)}), PointerBindNodeName => log.warn("\"pointer_binds.{s}\" missing " ++ arg ++ " argument. Ignoring", .{@tagName(node_name)}), InputConfigNodeName => log.warn("\"input.{s}\" missing " ++ arg ++ " argument. Ignoring", .{@tagName(node_name)}), + TagOverlayNodeName => log.warn("\"tag_overlay.{s}\" missing " ++ arg ++ " argument. Ignoring", .{@tagName(node_name)}), else => @compileError("This function does not (yet) support type \"" ++ @typeName(node_name_type) ++ "\""), } } @@ -821,7 +1066,10 @@ fn logDebugHostMismatch(node_name: anytype) void { BorderNodeName => log.debug("Skipping \"border.{s}\" (host mismatch)", .{@tagName(node_name)}), PointerBindNodeName => log.debug("Skipping \"pointer_binds.{s}\" (host mismatch)", .{@tagName(node_name)}), InputConfigNodeName => log.debug("Skipping \"input.{s}\" (host mismatch)", .{@tagName(node_name)}), - []const u8 => log.debug("Skipping \"keybind.{s}\" (host mismatch)", .{node_name}), + TagOverlayNodeName => log.debug("Skipping \"tag_overlay.{s}\" (host mismatch)", .{@tagName(node_name)}), + TagOverlayAnchorsNodeName => log.debug("Skipping \"tag_overlay.anchors.{s}\" (host mismatch)", .{@tagName(node_name)}), + TagOverlayMarginsNodeName => log.debug("Skipping \"tag_overlay.margins.{s}\" (host mismatch)", .{@tagName(node_name)}), + KeybindNodeName => log.debug("Skipping \"keybind.{s}\" (host mismatch)", .{@tagName(node_name)}), else => @compileError("This function does not (yet) support type \"" ++ @typeName(node_name_type) ++ "\""), } } @@ -831,6 +1079,9 @@ fn logDebugSettingNode(node_name: anytype, node_value: []const u8) void { switch (node_name_type) { NodeName => log.debug("Setting {s} to {s}", .{ @tagName(node_name), node_value }), BorderNodeName => log.debug("Setting border.{s} to {s}", .{ @tagName(node_name), node_value }), + TagOverlayNodeName => log.debug("Setting tag_overlay.{s} to {s}", .{ @tagName(node_name), node_value }), + TagOverlayAnchorsNodeName => log.debug("Setting tag_overlay.anchors.{s} to {s}", .{ @tagName(node_name), node_value }), + TagOverlayMarginsNodeName => log.debug("Setting tag_overlay.margins.{s} to {s}", .{ @tagName(node_name), node_value }), else => @compileError("This function does not (yet) support type \"" ++ @typeName(@TypeOf(node_name)) ++ "\""), } } @@ -879,10 +1130,12 @@ const ThreeFingerDragState = river.LibinputDeviceV1.ThreeFingerDragState; const kdl = @import("kdl"); const known_folders = @import("known_folders"); +const pixman = @import("pixman"); const xkbcommon = @import("xkbcommon"); const utils = @import("utils.zig"); const RiverColor = utils.RiverColor; +const TagOverlay = @import("TagOverlay.zig"); const XkbBindings = @import("XkbBindings.zig"); const log = std.log.scoped(.Config); diff --git a/src/Context.zig b/src/Context.zig index 2c8eeda..03dbefd 100644 --- a/src/Context.zig +++ b/src/Context.zig @@ -7,6 +7,8 @@ const Context = @This(); initialized: bool, +env: process.EnvMap, + // Wayland globals wl_compositor: *wl.Compositor, wl_display: *wl.Display, @@ -32,6 +34,10 @@ wallpaper_image: ?*WallpaperImage, // WM Configuration config: *Config, +/// Shared timerfd for hiding tag overlays after their timeout expires. +/// This stays null if no tag overlays exist. +tag_overlay_timer_fd: ?posix.fd_t, + /// State consumed in manage() phase, reset at end of manage(). pending_manage: PendingManage = .{}, @@ -60,10 +66,29 @@ pub const Options = struct { pub fn create(options: Options) !*Context { const context = try utils.gpa.create(Context); - errdefer context.destroy(); + errdefer utils.gpa.destroy(context); + + const im = try InputManager.create(context, options.river_input_manager_v1, options.river_libinput_config_v1); + errdefer im.destroy(); + const wm = try WindowManager.create(context, options.river_window_manager_v1); + errdefer wm.destroy(); + const xkb_bindings = try XkbBindings.create(context, options.river_xkb_bindings_v1); + errdefer xkb_bindings.destroy(); + + const env = try process.getEnvMap(utils.gpa); + errdefer env.deinit(); + + const tag_overlay_timer_fd: ?posix.fd_t = if (options.config.tag_overlay) |_| + posix.timerfd_create(.MONOTONIC, .{ .CLOEXEC = true }) catch |e| blk: { + log.err("Failed to create tag overlay timer: {}", .{e}); + break :blk null; + } + else + null; context.* = .{ .initialized = false, + .env = env, .wl_compositor = options.wl_compositor, .wl_display = options.wl_display, .wl_registry = options.wl_registry, @@ -72,22 +97,26 @@ pub fn create(options: Options) !*Context { .river_layer_shell_v1 = options.river_layer_shell_v1, .zwlr_layer_shell_v1 = options.zwlr_layer_shell_v1, .wallpaper_image = loadWallpaperImage(options.config), - .im = try InputManager.create(context, options.river_input_manager_v1, options.river_libinput_config_v1), - .wm = try WindowManager.create(context, options.river_window_manager_v1), - .xkb_bindings = try XkbBindings.create(context, options.river_xkb_bindings_v1), + .im = im, + .wm = wm, + .xkb_bindings = xkb_bindings, .config = options.config, + .tag_overlay_timer_fd = tag_overlay_timer_fd, }; return context; } pub fn destroy(context: *Context) void { - context.xkb_bindings.destroy(); + context.env.deinit(); + context.im.destroy(); context.wm.destroy(); + context.xkb_bindings.destroy(); if (context.wallpaper_image) |wallpaper_image| { wallpaper_image.destroy(); } + if (context.tag_overlay_timer_fd) |fd| posix.close(fd); context.buffer_pool.deinit(); utils.gpa.destroy(context); @@ -120,6 +149,42 @@ pub fn manage(context: *Context) void { libinput_device.should_manage = true; } + // Handle tag overlay config changes + const had_overlay = context.config.tag_overlay != null; + const has_overlay = new_config.tag_overlay != null; + + if (!had_overlay and has_overlay) { + // Create timerfd for newly enabled tag overlay + context.tag_overlay_timer_fd = posix.timerfd_create(.MONOTONIC, .{ .CLOEXEC = true }) catch |e| blk: { + log.err("Failed to create tag overlay timer: {}", .{e}); + break :blk null; + }; + } else if (had_overlay and !has_overlay) { + // Close timerfd for disabled tag overlay + if (context.tag_overlay_timer_fd) |fd| posix.close(fd); + context.tag_overlay_timer_fd = null; + } + + // Recreate or destroy tag overlays on all outputs + if (had_overlay or has_overlay) { + var out_it = context.wm.outputs.iterator(.forward); + while (out_it.next()) |output| { + // Destroy existing overlay + if (output.tag_overlay) |*tag_overlay| { + tag_overlay.deinit(); + output.tag_overlay = null; + } + // Create new overlay if configured + // Create new overlay struct if configured (surfaces created on-demand) + if (new_config.tag_overlay) |tag_overlay_config| { + output.tag_overlay = TagOverlay.init(context, output, tag_overlay_config.toTagOverlayOptions()) catch |e| { + log.err("Failed to create tag overlay: {}", .{e}); + continue; + }; + } + } + } + if (wallpaper_changed) { if (context.wallpaper_image) |img| img.destroy(); context.wallpaper_image = loadWallpaperImage(new_config); @@ -128,7 +193,7 @@ pub fn manage(context: *Context) void { while (out_it.next()) |output| { if (context.wallpaper_image == null) { output.deinitWallpaperLayerSurface(); - } else if (output.wl_surface != null) { + } else if (output.surfaces != null) { output.renderWallpaper() catch |err| { log.err("Wallpaper re-render failed: {}", .{err}); }; @@ -165,6 +230,8 @@ fn pathsEqual(a: ?[]const u8, b: ?[]const u8) bool { const std = @import("std"); const mem = std.mem; +const posix = std.posix; +const process = std.process; const wayland = @import("wayland"); const river = wayland.client.river; @@ -175,6 +242,8 @@ const utils = @import("utils.zig"); const BufferPool = @import("BufferPool.zig"); const Config = @import("Config.zig"); const InputManager = @import("InputManager.zig"); +const Output = @import("Output.zig"); +const TagOverlay = @import("TagOverlay.zig"); const WallpaperImage = @import("WallpaperImage.zig"); const WindowManager = @import("WindowManager.zig"); const XkbBindings = @import("XkbBindings.zig"); diff --git a/src/LibinputDevice.zig b/src/LibinputDevice.zig index 0f693a0..d95a628 100644 --- a/src/LibinputDevice.zig +++ b/src/LibinputDevice.zig @@ -17,66 +17,57 @@ input_device: ?*InputDevice = null, /// reloaded. should_manage: bool = true, -send_events_support: SendEventsModes = .{}, -send_events_current: ?SendEventsModes = null, - -/// The number of fingers supported for tap-to-click/drag. -/// If finger_count is 0, tap-to-click and drag are unsupported. -tap_support: u31 = 0, -tap_current: ?TapState = null, - -tap_button_map_current: ?TapButtonMap = null, - -drag_current: ?DragState = null, - -drag_lock_current: ?DragLockState = null, - -/// The number of fingers supported for three/four finger drag. -/// If finger_count is less than 3, three finger drag is unsupported. -three_finger_drag_support: u31 = 0, -three_finger_drag_current: ?ThreeFingerDragState = null, - -/// A calibration matrix is supported if the supported argument is non-zero. -calibration_matrix_support: bool = false, -calibration_matrix_current: ?[]f32 = null, - -accel_profiles_support: ?AccelProfiles = null, -accel_profile_current: ?AccelProfile = null, - -accel_speed_current: ?f64 = null, - -natural_scroll_support: bool = false, -natural_scroll_current: ?NaturalScrollState = null, - -left_handed_support: bool = false, -left_handed_current: ?LeftHandedState = null, - -click_method_support: ?ClickMethods = null, -click_method_current: ?ClickMethod = null, - -clickfinger_button_map_current: ?ClickfingerButtonMap = null, - -middle_emulation_support: bool = false, -middle_emulation_current: ?MiddleEmulationState = null, - -scroll_method_support: ?ScrollMethods = null, -scroll_method_current: ?ScrollMethod = null, -/// Supported if scroll_methods.on_button_down is supported. -scroll_button_current: ?u32 = null, -/// Supported if scroll_methods.on_button_down is supported. -scroll_button_lock_current: ?ScrollButtonLockState = null, - -dwt_support: bool = false, -dwt_current: ?DwtState = null, - -dwtp_support: bool = false, -dwtp_current: ?DwtpState = null, - -rotation_support: bool = false, -rotation_current: ?u32 = null, +capabilities: Capabilities = .{}, +state: CurrentState = .{}, link: wl.list.Link, +pub const Capabilities = struct { + send_events: SendEventsModes = .{}, + /// The number of fingers supported for tap-to-click/drag. + /// If finger_count is 0, tap-to-click and drag are unsupported. + tap: u31 = 0, + /// The number of fingers supported for three/four finger drag. + /// If finger_count is less than 3, three finger drag is unsupported. + three_finger_drag: u31 = 0, + /// A calibration matrix is supported if the supported argument is non-zero. + calibration_matrix: bool = false, + accel_profiles: ?AccelProfiles = null, + natural_scroll: bool = false, + left_handed: bool = false, + click_methods: ?ClickMethods = null, + middle_emulation: bool = false, + scroll_methods: ?ScrollMethods = null, + dwt: bool = false, + dwtp: bool = false, + rotation: bool = false, +}; + +pub const CurrentState = struct { + send_events: ?SendEventsModes = null, + tap: ?TapState = null, + tap_button_map: ?TapButtonMap = null, + drag: ?DragState = null, + drag_lock: ?DragLockState = null, + three_finger_drag: ?ThreeFingerDragState = null, + calibration_matrix: ?[]f32 = null, + accel_profile: ?AccelProfile = null, + accel_speed: ?f64 = null, + natural_scroll: ?NaturalScrollState = null, + left_handed: ?LeftHandedState = null, + click_method: ?ClickMethod = null, + clickfinger_button_map: ?ClickfingerButtonMap = null, + middle_emulation: ?MiddleEmulationState = null, + scroll_method: ?ScrollMethod = null, + /// Supported if scroll_methods.on_button_down is supported. + scroll_button: ?u32 = null, + /// Supported if scroll_methods.on_button_down is supported. + scroll_button_lock: ?ScrollButtonLockState = null, + dwt: ?DwtState = null, + dwtp: ?DwtpState = null, + rotation: ?u32 = null, +}; + pub fn create(context: *Context, river_libinput_device_v1: *river.LibinputDeviceV1) !*LibinputDevice { const libinput_device = try utils.gpa.create(LibinputDevice); errdefer utils.gpa.destroy(libinput_device); @@ -123,39 +114,39 @@ fn riverLibinputDeviceV1Listener(river_libinput_device_v1: *river.LibinputDevice } } }, - .send_events_support => |ev| libinput_device.send_events_support = ev.modes, - .send_events_current => |ev| libinput_device.send_events_current = ev.mode, - .tap_support => |ev| libinput_device.tap_support = @intCast(ev.finger_count), - .tap_current => |ev| libinput_device.tap_current = ev.state, - .tap_button_map_current => |ev| libinput_device.tap_button_map_current = ev.button_map, - .drag_current => |ev| libinput_device.drag_current = ev.state, - .drag_lock_current => |ev| libinput_device.drag_lock_current = ev.state, - .three_finger_drag_support => |ev| libinput_device.three_finger_drag_support = @intCast(ev.finger_count), - .three_finger_drag_current => |ev| libinput_device.three_finger_drag_current = ev.state, - .calibration_matrix_support => |ev| libinput_device.calibration_matrix_support = ev.supported != 0, - .calibration_matrix_current => |ev| libinput_device.calibration_matrix_current = ev.matrix.slice(f32), - .accel_profiles_support => |ev| libinput_device.accel_profiles_support = ev.profiles, - .accel_profile_current => |ev| libinput_device.accel_profile_current = ev.profile, - .accel_speed_current => |ev| libinput_device.accel_speed_current = ev.speed.slice(f64)[0], - .natural_scroll_support => |ev| libinput_device.natural_scroll_support = ev.supported != 0, - .natural_scroll_current => |ev| libinput_device.natural_scroll_current = ev.state, - .left_handed_support => |ev| libinput_device.left_handed_support = ev.supported != 0, - .left_handed_current => |ev| libinput_device.left_handed_current = ev.state, - .click_method_support => |ev| libinput_device.click_method_support = ev.methods, - .click_method_current => |ev| libinput_device.click_method_current = ev.method, - .clickfinger_button_map_current => |ev| libinput_device.clickfinger_button_map_current = ev.button_map, - .middle_emulation_support => |ev| libinput_device.middle_emulation_support = ev.supported != 0, - .middle_emulation_current => |ev| libinput_device.middle_emulation_current = ev.state, - .scroll_method_support => |ev| libinput_device.scroll_method_support = ev.methods, - .scroll_method_current => |ev| libinput_device.scroll_method_current = ev.method, - .scroll_button_current => |ev| libinput_device.scroll_button_current = ev.button, - .scroll_button_lock_current => |ev| libinput_device.scroll_button_lock_current = ev.state, - .dwt_support => |ev| libinput_device.dwt_support = ev.supported != 0, - .dwt_current => |ev| libinput_device.dwt_current = ev.state, - .dwtp_support => |ev| libinput_device.dwtp_support = ev.supported != 0, - .dwtp_current => |ev| libinput_device.dwtp_current = ev.state, - .rotation_support => |ev| libinput_device.rotation_support = ev.supported != 0, - .rotation_current => |ev| libinput_device.rotation_current = ev.angle, + .send_events_support => |ev| libinput_device.capabilities.send_events = ev.modes, + .send_events_current => |ev| libinput_device.state.send_events = ev.mode, + .tap_support => |ev| libinput_device.capabilities.tap = @intCast(ev.finger_count), + .tap_current => |ev| libinput_device.state.tap = ev.state, + .tap_button_map_current => |ev| libinput_device.state.tap_button_map = ev.button_map, + .drag_current => |ev| libinput_device.state.drag = ev.state, + .drag_lock_current => |ev| libinput_device.state.drag_lock = ev.state, + .three_finger_drag_support => |ev| libinput_device.capabilities.three_finger_drag = @intCast(ev.finger_count), + .three_finger_drag_current => |ev| libinput_device.state.three_finger_drag = ev.state, + .calibration_matrix_support => |ev| libinput_device.capabilities.calibration_matrix = ev.supported != 0, + .calibration_matrix_current => |ev| libinput_device.state.calibration_matrix = ev.matrix.slice(f32), + .accel_profiles_support => |ev| libinput_device.capabilities.accel_profiles = ev.profiles, + .accel_profile_current => |ev| libinput_device.state.accel_profile = ev.profile, + .accel_speed_current => |ev| libinput_device.state.accel_speed = ev.speed.slice(f64)[0], + .natural_scroll_support => |ev| libinput_device.capabilities.natural_scroll = ev.supported != 0, + .natural_scroll_current => |ev| libinput_device.state.natural_scroll = ev.state, + .left_handed_support => |ev| libinput_device.capabilities.left_handed = ev.supported != 0, + .left_handed_current => |ev| libinput_device.state.left_handed = ev.state, + .click_method_support => |ev| libinput_device.capabilities.click_methods = ev.methods, + .click_method_current => |ev| libinput_device.state.click_method = ev.method, + .clickfinger_button_map_current => |ev| libinput_device.state.clickfinger_button_map = ev.button_map, + .middle_emulation_support => |ev| libinput_device.capabilities.middle_emulation = ev.supported != 0, + .middle_emulation_current => |ev| libinput_device.state.middle_emulation = ev.state, + .scroll_method_support => |ev| libinput_device.capabilities.scroll_methods = ev.methods, + .scroll_method_current => |ev| libinput_device.state.scroll_method = ev.method, + .scroll_button_current => |ev| libinput_device.state.scroll_button = ev.button, + .scroll_button_lock_current => |ev| libinput_device.state.scroll_button_lock = ev.state, + .dwt_support => |ev| libinput_device.capabilities.dwt = ev.supported != 0, + .dwt_current => |ev| libinput_device.state.dwt = ev.state, + .dwtp_support => |ev| libinput_device.capabilities.dwtp = ev.supported != 0, + .dwtp_current => |ev| libinput_device.state.dwtp = ev.state, + .rotation_support => |ev| libinput_device.capabilities.rotation = ev.supported != 0, + .rotation_current => |ev| libinput_device.state.rotation = ev.angle, else => |ev| { // We don't keep track of any default states right now log.debug("unhandled event: {s}", .{@tagName(ev)}); @@ -187,29 +178,29 @@ pub fn applyInputConfigs(libinput_device: *LibinputDevice) void { log.debug("Applying input config to {s}", .{device_name}); - if (@as(u32, @bitCast(libinput_device.send_events_support)) != 0) { + if (@as(u32, @bitCast(libinput_device.capabilities.send_events)) != 0) { if (input_config.send_events) |val| { const mode: SendEventsModes = @bitCast(@as(u32, @intCast(@intFromEnum(val)))); const mode_bits: u32 = @bitCast(mode); - const support_bits: u32 = @bitCast(libinput_device.send_events_support); + const support_bits: u32 = @bitCast(libinput_device.capabilities.send_events); if (mode_bits == 0 or mode_bits & support_bits == mode_bits) { applyResult(dev.setSendEvents(mode)); } } } - if (libinput_device.tap_support > 0) { + if (libinput_device.capabilities.tap > 0) { if (input_config.tap) |val| applyResult(dev.setTap(val)); if (input_config.tap_button_map) |val| applyResult(dev.setTapButtonMap(val)); if (input_config.drag) |val| applyResult(dev.setDrag(val)); if (input_config.drag_lock) |val| applyResult(dev.setDragLock(val)); } - if (libinput_device.three_finger_drag_support >= 3) { + if (libinput_device.capabilities.three_finger_drag >= 3) { if (input_config.three_finger_drag) |val| applyResult(dev.setThreeFingerDrag(val)); } - if (libinput_device.accel_profiles_support) |support| { + if (libinput_device.capabilities.accel_profiles) |support| { if (input_config.accel_profile) |val| { if (isSupported(AccelProfile, AccelProfiles, val, support)) { applyResult(dev.setAccelProfile(val)); @@ -226,15 +217,15 @@ pub fn applyInputConfigs(libinput_device: *LibinputDevice) void { } } - if (libinput_device.natural_scroll_support) { + if (libinput_device.capabilities.natural_scroll) { if (input_config.natural_scroll) |val| applyResult(dev.setNaturalScroll(val)); } - if (libinput_device.left_handed_support) { + if (libinput_device.capabilities.left_handed) { if (input_config.left_handed) |val| applyResult(dev.setLeftHanded(val)); } - if (libinput_device.click_method_support) |support| { + if (libinput_device.capabilities.click_methods) |support| { if (input_config.click_method) |val| { if (isSupported(ClickMethod, ClickMethods, val, support)) { applyResult(dev.setClickMethod(val)); @@ -243,11 +234,11 @@ pub fn applyInputConfigs(libinput_device: *LibinputDevice) void { if (input_config.clickfinger_button_map) |val| applyResult(dev.setClickfingerButtonMap(val)); } - if (libinput_device.middle_emulation_support) { + if (libinput_device.capabilities.middle_emulation) { if (input_config.middle_emulation) |val| applyResult(dev.setMiddleEmulation(val)); } - if (libinput_device.scroll_method_support) |support| { + if (libinput_device.capabilities.scroll_methods) |support| { if (input_config.scroll_method) |val| { if (isSupported(ScrollMethod, ScrollMethods, val, support)) { applyResult(dev.setScrollMethod(val)); @@ -259,15 +250,15 @@ pub fn applyInputConfigs(libinput_device: *LibinputDevice) void { } } - if (libinput_device.dwt_support) { + if (libinput_device.capabilities.dwt) { if (input_config.dwt) |val| applyResult(dev.setDwt(val)); } - if (libinput_device.dwtp_support) { + if (libinput_device.capabilities.dwtp) { if (input_config.dwtp) |val| applyResult(dev.setDwtp(val)); } - if (libinput_device.rotation_support) { + if (libinput_device.capabilities.rotation) { if (input_config.rotation) |val| applyResult(dev.setRotation(val)); } } diff --git a/src/Output.zig b/src/Output.zig index 02df08a..62928bd 100644 --- a/src/Output.zig +++ b/src/Output.zig @@ -32,12 +32,15 @@ usable_height: u31 = 0, wallpaper_render_scale: u31 = 0, wallpaper_render_width: u31 = 0, wallpaper_render_height: u31 = 0, -wl_surface: ?*wl.Surface = null, -layer_surface: ?*zwlr.LayerSurfaceV1 = null, + +surfaces: ?struct { + wl_surface: *wl.Surface, + layer_surface: *zwlr.LayerSurfaceV1, +} = null, // TODO: Make Bar a user option, can disable if they want -// This Output's bar bar: ?Bar, +tag_overlay: ?TagOverlay, /// Proportion of output width taken by the primary stack primary_ratio: f32, @@ -89,16 +92,26 @@ pub fn create(context: *Context, river_output_v1: *river.OutputV1) !*Output { var output = try utils.gpa.create(Output); errdefer utils.gpa.destroy(output); - const bar = Bar.init(context, output) catch |e| blk: { + var bar = Bar.init(context, output) catch |e| blk: { log.err("Failed to create a bar: {}", .{e}); break :blk null; }; + errdefer if (bar) |*b| b.deinit(); + + var tag_overlay = if (context.config.tag_overlay) |tag_overlay_config| blk: { + break :blk TagOverlay.init(context, output, tag_overlay_config.toTagOverlayOptions()) catch |e| { + log.err("Failed to create a tag overlay: {}", .{e}); + break :blk null; + }; + } else null; + errdefer if (tag_overlay) |*to| to.deinit(); output.* = .{ .context = context, .river_output_v1 = river_output_v1, .river_layer_shell_output_v1 = try context.river_layer_shell_v1.getOutput(river_output_v1), .bar = bar, + .tag_overlay = tag_overlay, .primary_count = context.config.primary_count, .primary_ratio = context.config.primary_ratio, .windows = undefined, // we will initialize this shortly @@ -119,6 +132,10 @@ pub fn destroy(output: *Output) void { window.link.remove(); window.destroy(); } + + if (output.bar) |*bar| bar.deinit(); + if (output.tag_overlay) |*tag_overlay| tag_overlay.deinit(); + output.tag_layout_overrides.deinit(utils.gpa); output.deinitWallpaperLayerSurface(); output.river_output_v1.destroy(); @@ -227,6 +244,8 @@ fn riverOutputListener(river_output_v1: *river.OutputV1, event: river.OutputV1.E return; }; } + // Tag overlay surfaces are created on-demand when tags change, + // so we don't init them here. }, .dimensions => |ev| { // Protocol guarantees that width and height are strictly greater than zero @@ -319,8 +338,8 @@ pub fn initWallpaperLayerSurface(output: *Output) !void { return; } - if (output.layer_surface) |_| { - // This output already has a layer surface, we can exit early + if (output.surfaces) |_| { + // This output already has a surface, we can exit early return; } @@ -329,6 +348,9 @@ pub fn initWallpaperLayerSurface(output: *Output) !void { const wl_surface = try context.wl_compositor.createSurface(); errdefer wl_surface.destroy(); + const layer_surface = try context.zwlr_layer_shell_v1.getLayerSurface(wl_surface, output.wl_output, .background, "beansprout-wallpaper"); + errdefer layer_surface.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(); @@ -340,12 +362,13 @@ pub fn initWallpaperLayerSurface(output: *Output) !void { defer opaque_region.destroy(); wl_surface.setOpaqueRegion(opaque_region); - const layer_surface = 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 }); - output.wl_surface = wl_surface; - output.layer_surface = layer_surface; + output.surfaces = .{ + .wl_surface = wl_surface, + .layer_surface = layer_surface, + }; context.buffer_pool.surface_count += 1; layer_surface.setListener(*Output, wallpaperLayerSurfaceListener, output); @@ -353,16 +376,13 @@ pub fn initWallpaperLayerSurface(output: *Output) !void { } pub fn deinitWallpaperLayerSurface(output: *Output) void { - if (output.layer_surface) |layer_surface| { - layer_surface.destroy(); - } - if (output.wl_surface) |wl_surface| { - wl_surface.destroy(); + if (output.surfaces) |surfaces| { + surfaces.wl_surface.destroy(); + surfaces.layer_surface.destroy(); output.context.buffer_pool.surface_count -= 1; } - output.layer_surface = null; - output.wl_surface = null; + output.surfaces = null; output.configured = false; } @@ -379,10 +399,10 @@ fn wallpaperLayerSurfaceListener(layer_surface: *zwlr.LayerSurfaceV1, event: zwl output.wallpaper_render_height == height and output.scale == output.wallpaper_render_scale) { - if (output.wl_surface) |wl_surface| { - wl_surface.commit(); + if (output.surfaces) |surfaces| { + surfaces.wl_surface.commit(); } else { - log.warn("Output is marked as configured but is missing a layer_surface for the wallpaper", .{}); + log.warn("Output is marked as configured but is missing its surfaces.", .{}); } return; } @@ -440,7 +460,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", .{}); @@ -485,7 +505,8 @@ pub fn renderWallpaper(output: *Output) !void { log.info("render: {}x{} (scaled from {}x{})", .{ width * scale, height * scale, image_width, image_height }); // Attach the buffer to the surface - const wl_surface = output.wl_surface.?; + const surfaces = output.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, width * scale, height * scale); @@ -548,6 +569,33 @@ pub fn manage(output: *Output) void { } output.tags = new_tags; + + // Show tag overlay and arm the hide timer + if (output.tag_overlay) |*tag_overlay| { + if (tag_overlay.surfaces) |_| { + // The overlay is arleady visible, but we still need to re-render + tag_overlay.render() catch |err| { + log.err("tag_overlay render failed: {}", .{err}); + }; + } else { + // Create surface; the configure handler renders for us + tag_overlay.initSurface() catch |err| { + log.err("tag_overlay initSurface failed: {}", .{err}); + }; + } + if (output.context.tag_overlay_timer_fd) |fd| { + const timeout_ms: isize = tag_overlay.options.timeout; + posix.timerfd_settime(fd, .{}, &.{ + .it_interval = .{ .sec = 0, .nsec = 0 }, + .it_value = .{ + .sec = @divFloor(timeout_ms, 1000), + .nsec = @mod(timeout_ms, 1000) * std.time.ns_per_ms, + }, + }, null) catch |err| { + log.err("Failed to arm tag overlay timer: {}", .{err}); + }; + } + } } // Calculate layout before managing windows @@ -696,6 +744,7 @@ pub fn occupiedTags(output: *Output) u32 { const std = @import("std"); const assert = std.debug.assert; const mem = std.mem; +const posix = std.posix; const DoublyLinkedList = std.DoublyLinkedList; const wayland = @import("wayland"); @@ -708,6 +757,7 @@ const utils = @import("utils.zig"); const Bar = @import("Bar.zig"); const Buffer = @import("Buffer.zig"); const Context = @import("Context.zig"); +const TagOverlay = @import("TagOverlay.zig"); const Window = @import("Window.zig"); const log = std.log.scoped(.Output); diff --git a/src/TagOverlay.zig b/src/TagOverlay.zig new file mode 100644 index 0000000..3538bde --- /dev/null +++ b/src/TagOverlay.zig @@ -0,0 +1,262 @@ +// 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 (1-32) + tag_amount: u6, + /// Amount of tags per row (1-32) + tags_per_row: u6, + /// 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: u31 = @as(u31, @min(options.tag_amount, options.tags_per_row)) * (@as(u31, options.square_size) + options.square_padding) + options.square_padding + 2 * @as(u31, options.border_width); + const surface_height: u31 = @as(u31, tag_overlay.rows) * (@as(u31, options.square_size) + options.square_padding) + options.square_padding + 2 * @as(u31, options.border_width); + layer_surface.setSize(surface_width, surface_height); + + layer_surface.setAnchor(options.anchors); + layer_surface.setMargin(options.margins.top, options.margins.right, options.margins.bottom, options.margins.left); + + tag_overlay.surfaces = .{ .wl_surface = wl_surface, .layer_surface = layer_surface }; + context.buffer_pool.surface_count += 1; + + layer_surface.setListener(*TagOverlay, layerSurfaceListener, tag_overlay); + wl_surface.commit(); +} + +/// Destroy surfaces only (used to hide the overlay). The TagOverlay struct stays valid +/// and can be re-shown by calling initSurface() again. +pub fn deinitSurfaces(tag_overlay: *TagOverlay) void { + tag_overlay.configured = false; + if (tag_overlay.surfaces) |surfaces| { + surfaces.layer_surface.destroy(); + surfaces.wl_surface.destroy(); + tag_overlay.context.buffer_pool.surface_count -= 1; + tag_overlay.surfaces = null; + } +} + +pub fn deinit(tag_overlay: *TagOverlay) void { + tag_overlay.deinitSurfaces(); +} + +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 + @as(u31, @intCast((tag + 1) * options.square_padding)) + @as(u31, @intCast(tag * options.square_size)); + const y = options.border_width + @as(u31, @intCast((row + 1) * options.square_padding)) + @as(u31, @intCast(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/main.zig b/src/main.zig index 0caa199..d2e9521 100644 --- a/src/main.zig +++ b/src/main.zig @@ -115,12 +115,13 @@ fn run(wl_display: *wl.Display, context: *Context) !void { posix.sigprocmask(posix.SIG.BLOCK, &mask, null); - const sig_fd = try posix.signalfd(-1, &mask, 0); + const sig_fd = try posix.signalfd(-1, &mask, @as(u32, @bitCast(posix.O{ .CLOEXEC = true }))); const poll_wayland = 0; const poll_sig = 1; + const poll_tag_overlay_timer = 2; - var pollfds: [2]posix.pollfd = undefined; + var pollfds: [3]posix.pollfd = undefined; pollfds[poll_wayland] = .{ .fd = wl_display.getFd(), @@ -132,6 +133,12 @@ fn run(wl_display: *wl.Display, context: *Context) !void { .events = posix.POLL.IN, .revents = 0, }; + pollfds[poll_tag_overlay_timer] = .{ + // poll ignores negative fds + .fd = context.tag_overlay_timer_fd orelse -1, + .events = posix.POLL.IN, + .revents = 0, + }; while (true) { const errno = wl_display.flush(); @@ -165,22 +172,46 @@ fn run(wl_display: *wl.Display, context: *Context) !void { // Handle fds that became ready if (pollfds[poll_wayland].revents & posix.POLL.HUP != 0) { + @branchHint(.cold); log.info("Disconnected by compositor", .{}); break; } if (pollfds[poll_wayland].revents & posix.POLL.IN != 0) { if (wl_display.dispatch() != .SUCCESS) { + @branchHint(.cold); fatal("Wayland display dispatch failed", .{}); } + // Re-sync in case a config reload created or destroyed the timerfd + pollfds[poll_tag_overlay_timer].fd = context.tag_overlay_timer_fd orelse -1; } if (pollfds[poll_sig].revents & posix.POLL.HUP != 0) { + @branchHint(.cold); fatal("Signal fd hung up", .{}); } if (pollfds[poll_sig].revents & posix.POLL.IN != 0) { + @branchHint(.cold); log.info("Exiting beansprout", .{}); break; } + + if (pollfds[poll_tag_overlay_timer].revents & posix.POLL.HUP != 0) { + @branchHint(.cold); + fatal("Tag overlay timer fd hung up", .{}); + } + if (pollfds[poll_tag_overlay_timer].revents & posix.POLL.IN != 0) { + // Read to consume the timer event + var buf: [8]u8 = undefined; + _ = posix.read(context.tag_overlay_timer_fd.?, &buf) catch {}; + + // Hide all tag overlays by destroying their surfaces + var it = context.wm.outputs.iterator(.forward); + while (it.next()) |output| { + if (output.tag_overlay) |*tag_overlay| { + tag_overlay.deinitSurfaces(); + } + } + } } } diff --git a/src/utils.zig b/src/utils.zig index 94cadba..fa6a171 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,50 @@ 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 { + @setEvalBranchQuota(2000); + 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 +177,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 +232,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\"")); }