Complete wallpaper support with new config

Load wallpaper_image_path from config with tilde expansion (environment
variables are not supported)

On the way for this commit, I also had to:
- Fix wallpaper not rendering on startup by triggering init from
the .wl_output handler, since wl_output.done is lost during the
initial roundtrip
- Re-render wallpapers on config reload when the path changes
- Fix crash in deinitWallpaperLayerSurface when wl_surface is null
This commit is contained in:
Ben Buhse 2026-02-07 19:11:10 -06:00
commit 3c16929a6a
No known key found for this signature in database
GPG key ID: 7916ACFCD38FD0B4
6 changed files with 100 additions and 17 deletions

View file

@ -12,8 +12,7 @@ These are in rough order of my priority, though no promises I do them in this or
- [ ] Switch all structs to idiomatic Zig init/deinit pattern (init returns value, caller decides stack/heap) - [ ] Switch all structs to idiomatic Zig init/deinit pattern (init returns value, caller decides stack/heap)
- [ ] Support per-host config using properties - [ ] Support per-host config using properties
- [ ] Support wallpapers - [ ] Support a basic bar
- [ ] Support a bar
- [ ] Support starting programs at WM launch - [ ] Support starting programs at WM launch
- [ ] Support overriding config location - [ ] Support overriding config location
- [ ] Add support for multimedia/brightness keys - [ ] Add support for multimedia/brightness keys
@ -23,4 +22,5 @@ These are in rough order of my priority, though no promises I do them in this or
- [x] Support changeable primary ratio - [x] Support changeable primary ratio
- [x] Support changeable primary count - [x] Support changeable primary count
- [x] Support multiple outputs - [x] Support multiple outputs
- [X] Support floating windows - [x] Support floating windows
- [x] Support wallpapers

View file

@ -4,6 +4,10 @@ attach_mode top
focus_follows_pointer #true focus_follows_pointer #true
// Whether the focus should warp to the center of newly-focused windows // Whether the focus should warp to the center of newly-focused windows
pointer_warp_on_focus_change #true pointer_warp_on_focus_change #true
// Path to image to use as wallpaper
// The same image is displayed on all outputs, but scaled separately
// If this config is missing, then the background is blank black screen
wallpaper_image_path "~/Pictures/the_valley.png"
borders { borders {
width 2 width 2
// 8 or 10 digit hex color // 8 or 10 digit hex color

View file

@ -22,7 +22,7 @@ pointer_warp_on_focus_change: bool = true,
// TODO: Implement a color when this is null // TODO: Implement a color when this is null
/// Path to the wallpaper image /// Path to the wallpaper image
wallpaper_image_path: []const u8 = "", wallpaper_image_path: ?[]const u8 = null,
/// Tag bind entries parsed from config (tag_bind nodes in keybinds block) /// Tag bind entries parsed from config (tag_bind nodes in keybinds block)
tag_binds: std.ArrayList(Keybind) = .{}, tag_binds: std.ArrayList(Keybind) = .{},
@ -55,6 +55,7 @@ const NodeName = enum {
attach_mode, attach_mode,
focus_follows_pointer, focus_follows_pointer,
pointer_warp_on_focus_change, pointer_warp_on_focus_change,
wallpaper_image_path,
borders, borders,
keybinds, keybinds,
pointer_binds, pointer_binds,
@ -105,6 +106,9 @@ pub fn create() !*Config {
config.keybinds.clearAndFree(utils.allocator); config.keybinds.clearAndFree(utils.allocator);
config.tag_binds.clearAndFree(utils.allocator); config.tag_binds.clearAndFree(utils.allocator);
config.pointer_binds.clearAndFree(utils.allocator); config.pointer_binds.clearAndFree(utils.allocator);
if (config.wallpaper_image_path) |path| {
utils.allocator.free(path);
}
config.* = .{}; config.* = .{};
}; };
} }
@ -125,6 +129,9 @@ pub fn destroy(config: *Config) void {
config.keybinds.deinit(utils.allocator); config.keybinds.deinit(utils.allocator);
config.tag_binds.deinit(utils.allocator); config.tag_binds.deinit(utils.allocator);
config.pointer_binds.deinit(utils.allocator); config.pointer_binds.deinit(utils.allocator);
if (config.wallpaper_image_path) |path| {
utils.allocator.free(path);
}
utils.allocator.destroy(config); utils.allocator.destroy(config);
} }
@ -179,6 +186,19 @@ fn load(config: *Config, reader: *Io.Reader) !void {
continue; continue;
} }
}, },
.wallpaper_image_path => {
if (node.argcount() < 1) {
logWarnMissingNodeArg(name, "image path");
continue;
}
const path_str = utils.stripQuotes(node.arg(&parser, 0) orelse unreachable);
config.wallpaper_image_path = expandTilde(path_str) catch {
logWarnInvalidNodeArg(name, path_str);
continue;
};
logDebugSettingNode(name, path_str);
},
.borders => { .borders => {
next_child_block = .borders; next_child_block = .borders;
}, },
@ -547,6 +567,7 @@ fn logWarnInvalidNodeArg(node_name: anytype, node_value: []const u8) void {
fn logWarnMissingNodeArg(node_name: anytype, comptime arg: []const u8) void { fn logWarnMissingNodeArg(node_name: anytype, comptime arg: []const u8) void {
const node_name_type = @TypeOf(node_name); const node_name_type = @TypeOf(node_name);
switch (node_name_type) { switch (node_name_type) {
NodeName => log.warn("\"{s}\" missing " ++ arg ++ " argument. Ignoring", .{@tagName(node_name)}),
KeybindNodeName => log.warn("\"keybind.{s}\" missing " ++ arg ++ " argument. Ignoring", .{@tagName(node_name)}), 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)}), PointerBindNodeName => log.warn("\"pointer_binds.{s}\" missing " ++ arg ++ " argument. Ignoring", .{@tagName(node_name)}),
else => @compileError("This function does not (yet) support type \"" ++ @typeName(node_name_type) ++ "\""), else => @compileError("This function does not (yet) support type \"" ++ @typeName(node_name_type) ++ "\""),
@ -574,6 +595,14 @@ fn logDebugSettingNode(node_name: anytype, node_value: []const u8) void {
} }
} }
fn expandTilde(path: []const u8) ![]const u8 {
if (path.len > 0 and path[0] == '~') {
const home = std.posix.getenv("HOME") orelse return error.HomeNotSet;
return std.fmt.allocPrint(utils.allocator, "{s}{s}", .{ home, path[1..] });
}
return utils.allocator.dupe(u8, path);
}
const std = @import("std"); const std = @import("std");
const fmt = std.fmt; const fmt = std.fmt;
const fs = std.fs; const fs = std.fs;

View file

@ -60,12 +60,6 @@ pub fn create(options: Options) !*Context {
const context = try utils.allocator.create(Context); const context = try utils.allocator.create(Context);
errdefer context.destroy(); errdefer context.destroy();
// FIXME: TODO: Get this from Config
const wallpaper_image = WallpaperImage.create("FIXME") catch |e| blk: {
log.err("Failed to load wallpaper image from path \"{s}\": {s}", .{ "FIXME", @errorName(e) });
break :blk null;
};
context.* = .{ context.* = .{
.initialized = false, .initialized = false,
.wl_compositor = options.wl_compositor, .wl_compositor = options.wl_compositor,
@ -74,7 +68,7 @@ pub fn create(options: Options) !*Context {
.wl_shm = options.wl_shm, .wl_shm = options.wl_shm,
.wl_outputs = options.wl_outputs, .wl_outputs = options.wl_outputs,
.zwlr_layer_shell_v1 = options.zwlr_layer_shell_v1, .zwlr_layer_shell_v1 = options.zwlr_layer_shell_v1,
.wallpaper_image = wallpaper_image, .wallpaper_image = loadWallpaperImage(options.config),
.wm = try WindowManager.create(context, options.river_window_manager_v1), .wm = try WindowManager.create(context, options.river_window_manager_v1),
.xkb_bindings = try XkbBindings.create(context, options.river_xkb_bindings_v1), .xkb_bindings = try XkbBindings.create(context, options.river_xkb_bindings_v1),
.config = options.config, .config = options.config,
@ -105,13 +99,56 @@ pub fn manage(context: *Context) void {
binding.link.remove(); binding.link.remove();
binding.destroy(); binding.destroy();
} }
// Check if wallpaper path changed before destroying old config
const wallpaper_changed = !pathsEqual(
context.config.wallpaper_image_path,
new_config.wallpaper_image_path,
);
context.config.destroy(); context.config.destroy();
context.config = new_config; context.config = new_config;
context.initialized = false; context.initialized = false;
if (wallpaper_changed) {
if (context.wallpaper_image) |img| img.destroy();
context.wallpaper_image = loadWallpaperImage(new_config);
var out_it = context.wm.outputs.iterator(.forward);
while (out_it.next()) |output| {
if (context.wallpaper_image == null) {
output.deinitWallpaperLayerSurface();
} else if (output.wl_surface != null) {
output.renderWallpaper() catch |err| {
log.err("Wallpaper re-render failed: {}", .{err});
};
} else {
output.initWallpaperLayerSurface() catch |err| {
log.err("Failed to init wallpaper surface: {}", .{err});
};
}
}
}
} }
} }
fn loadWallpaperImage(config: *Config) ?*WallpaperImage {
const image_path = config.wallpaper_image_path orelse return null;
if (image_path.len == 0) return null;
return WallpaperImage.create(image_path) catch |e| {
log.err("Failed to load wallpaper image from path \"{s}\": {s}", .{ image_path, @errorName(e) });
return null;
};
}
fn pathsEqual(a: ?[]const u8, b: ?[]const u8) bool {
const a_val = a orelse return b == null;
const b_val = b orelse return false;
return mem.eql(u8, a_val, b_val);
}
const std = @import("std"); const std = @import("std");
const mem = std.mem;
const wayland = @import("wayland"); const wayland = @import("wayland");
const river = wayland.client.river; const river = wayland.client.river;

View file

@ -12,7 +12,7 @@ river_output_v1: *river.OutputV1,
wl_output: ?*wl.Output = null, wl_output: ?*wl.Output = null,
// Output geometry // Output geometry
scale: u31 = 0, scale: u31 = 1,
width: u31 = 0, width: u31 = 0,
height: u31 = 0, height: u31 = 0,
x: i32 = 0, x: i32 = 0,
@ -158,6 +158,15 @@ fn riverOutputListener(river_output_v1: *river.OutputV1, event: river.OutputV1.E
// It's guaranteed for the wl_output global to advertised before this event happens // It's guaranteed for the wl_output global to advertised before this event happens
output.wl_output = output.context.wl_outputs.get(ev.name) orelse unreachable; output.wl_output = output.context.wl_outputs.get(ev.name) orelse unreachable;
output.wl_output.?.setListener(*Output, wlOutputListener, output); 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.
output.initWallpaperLayerSurface() catch |err| {
const output_name = output.name orelse "some output";
log.err("failed to add a surface to {s}: {}", .{ output_name, err });
};
}, },
.dimensions => |ev| { .dimensions => |ev| {
// Protocol guarantees that width and height are strictly greater than zero // Protocol guarantees that width and height are strictly greater than zero
@ -207,14 +216,14 @@ fn wlOutputListener(_: *wl.Output, event: wl.Output.Event, output: *Output) void
} }
} }
fn initWallpaperLayerSurface(output: *Output) !void { pub fn initWallpaperLayerSurface(output: *Output) !void {
if (output.context.wallpaper_image == null) { if (output.context.wallpaper_image == null) {
// No wallpaper image, so we don't need any surfaces // No wallpaper image, so we don't need any surfaces
return; return;
} }
if (output.wl_surface) |_| { if (output.wl_surface) |_| {
log.warn("Skipping adding a second wallpaper surface to {s}", .{output.name orelse "some output"}); log.debug("Skipping adding a second wallpaper surface to {s}", .{output.name orelse "some output"});
return; return;
} }
@ -244,18 +253,18 @@ fn initWallpaperLayerSurface(output: *Output) !void {
wl_surface.commit(); wl_surface.commit();
} }
fn deinitWallpaperLayerSurface(output: *Output) void { pub fn deinitWallpaperLayerSurface(output: *Output) void {
if (output.layer_surface) |layer_surface| { if (output.layer_surface) |layer_surface| {
layer_surface.destroy(); layer_surface.destroy();
} }
if (output.wl_surface) |wl_surface| { if (output.wl_surface) |wl_surface| {
wl_surface.destroy(); wl_surface.destroy();
output.context.buffer_pool.surface_count -= 1;
} }
output.layer_surface = null; output.layer_surface = null;
output.wl_surface = null; output.wl_surface = null;
output.configured = false; output.configured = false;
output.context.buffer_pool.surface_count -= 1;
} }
fn wallpaperLayerSurfaceListener(layer_surface: *zwlr.LayerSurfaceV1, event: zwlr.LayerSurfaceV1.Event, output: *Output) void { fn wallpaperLayerSurfaceListener(layer_surface: *zwlr.LayerSurfaceV1, event: zwlr.LayerSurfaceV1.Event, output: *Output) void {
@ -314,7 +323,7 @@ fn calculate_transform(image_dimension: c_int, output_dimension: u31, dimension_
} }
/// Render the wallpaper image onto the layer surface /// Render the wallpaper image onto the layer surface
fn renderWallpaper(output: *Output) !void { pub fn renderWallpaper(output: *Output) !void {
const context = output.context; const context = output.context;
const width = output.render_width; const width = output.render_width;
const height = output.render_height; const height = output.render_height;

View file

@ -134,6 +134,10 @@ fn registryListener(registry: *wl.Registry, event: wl.Registry.Event, globals: *
} }
} }
pub const std_options = std.Options{
.log_level = .debug,
};
const std = @import("std"); const std = @import("std");
const mem = std.mem; const mem = std.mem;
const fatal = std.process.fatal; const fatal = std.process.fatal;