Implement single_window_ratio

This is a new config option that allows the user to set the width ratio
when only a single window is tiled and visible. The main idea is that,
on ultrawides, a single window taking the full width could be ugly.
With this new config, you can make the window take a smaller width.

I also renamed consts to snake_case instead of SCREAMING_CASE and fixed
a bug where the default primary_count and primary_ratio weren't updated
on config reload.
This commit is contained in:
Ben Buhse 2026-02-25 13:49:43 -06:00
commit b921751100
No known key found for this signature in database
GPG key ID: 7916ACFCD38FD0B4
6 changed files with 74 additions and 10 deletions

View file

@ -45,6 +45,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) |
| `single_window_ratio` | float | `1.00` | Proportion of output width taken when a single tiled window is visible (0.10-1.00) |
| `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 |

View file

@ -4,6 +4,11 @@ attach_mode top
primary_count 1
// Proportion of output width taken by the primary stack
primary_ratio 0.55
// Proportion of output width taken by a window if it's the only visible tiled window
// This is intended to be useful for ultrawides where a very wide window might not look very nice
// When this is < 1.0 and only one window is being tiled, the window will have width
// output_width * single_window_ratio and be centered on the output
single_window_ratio 0.70
// Whether mousing over a new window should move focus
focus_follows_pointer #true
// Whether the focus should warp to the center of newly-focused windows

View file

@ -4,7 +4,10 @@
const Config = @This();
const CONFIG_FILE = "beansprout/config.kdl";
const config_file = "beansprout/config.kdl";
pub const min_primary_ratio = 0.10;
pub const max_primary_ratio = 0.90;
/// Width of window borders in pixels
border_width: u8 = 2,
@ -19,6 +22,11 @@ primary_count: u8 = 1,
/// Proportion of output width taken by the primary stack
/// This is a global default, but each tagmask can have its own value
primary_ratio: f32 = 0.55,
/// Proportion of output width taken by a window if it's the only visible tiled window
/// This is intended to be useful for ultrawides where a very wide window might not look very nice
/// When this is < 1.0 and only one window is being tiled, the window will have width
/// output_width * single_window_ratio and be centered on the output
single_window_ratio: f32 = 1.0,
/// Where a new window should attach, top or bottom of the stack
attach_mode: AttachMode = .top,
@ -58,6 +66,7 @@ const NodeName = enum {
attach_mode,
primary_count,
primary_ratio,
single_window_ratio,
focus_follows_pointer,
pointer_warp_on_focus_change,
wallpaper_image_path,
@ -80,7 +89,7 @@ pub fn create() !*Config {
defer utils.gpa.free(config_dir);
var path_buf: [std.fs.max_path_bytes]u8 = undefined;
const config_path = std.fmt.bufPrint(&path_buf, "{s}/{s}", .{ config_dir, CONFIG_FILE }) catch return config;
const config_path = std.fmt.bufPrint(&path_buf, "{s}/{s}", .{ config_dir, config_file }) catch return config;
const file = fs.openFileAbsolute(config_path, .{}) catch break :blk;
@ -193,7 +202,24 @@ fn load(config: *Config, reader: *Io.Reader) !void {
logWarnInvalidNodeArg(name, ratio_str);
continue;
};
config.primary_ratio = std.math.clamp(ratio, 0.10, 0.90);
config.primary_ratio = std.math.clamp(ratio, min_primary_ratio, max_primary_ratio);
if (ratio != config.primary_ratio) {
log.warn("primary_ratio outside of valid range ({d} to {d}); clamping it", .{ min_primary_ratio, max_primary_ratio });
}
logDebugSettingNode(name, ratio_str);
},
.single_window_ratio => {
const ratio_str = utils.stripQuotes(node.arg(&parser, 0) orelse "");
const ratio = fmt.parseFloat(f32, ratio_str) catch {
logWarnInvalidNodeArg(name, ratio_str);
continue;
};
// We use 1.00 here because it doesn't make sense for a window to have a ratio > 1,
// i.e. for it to be wider than the output
config.single_window_ratio = std.math.clamp(ratio, min_primary_ratio, 1.00);
if (ratio != config.single_window_ratio) {
log.warn("single_window_ratio outside of valid range ({d} to {d}); clamping it", .{ min_primary_ratio, 1.00 });
}
logDebugSettingNode(name, ratio_str);
},
.attach_mode => {

View file

@ -151,6 +151,14 @@ pub fn manage(context: *Context) void {
context.config = new_config;
context.initialized = false;
// Update output defaults from new config
var out_it_cfg = context.wm.outputs.iterator(.forward);
while (out_it_cfg.next()) |output| {
output.primary_ratio = new_config.primary_ratio;
output.primary_count = new_config.primary_count;
output.single_window_ratio = new_config.single_window_ratio;
}
// Mark all libinput devices as needing config re-application
var dev_it = context.im.libinput_devices.iterator(.forward);
while (dev_it.next()) |libinput_device| {

View file

@ -41,6 +41,9 @@ primary_ratio: f32,
/// Number of windows in the primary stack
primary_count: u8,
/// Proportion of output width taken by a window when it is the only visible tiled window
single_window_ratio: f32,
/// Per-tagmask layout overrides
/// These only get added when the user modifies primary count or ratio
/// Any tagmask NOT in this map keeps using the defaults from Config
@ -74,6 +77,7 @@ pub const PendingManage = struct {
tags: ?u32 = null,
primary_ratio: ?f32 = null,
primary_count: ?u8 = null,
single_window_ratio: ?f32 = null,
};
pub fn create(context: *Context, river_output_v1: *river.OutputV1) !*Output {
@ -88,6 +92,7 @@ pub fn create(context: *Context, river_output_v1: *river.OutputV1) !*Output {
.tag_overlay = null,
.primary_count = context.config.primary_count,
.primary_ratio = context.config.primary_ratio,
.single_window_ratio = context.config.single_window_ratio,
.windows = undefined, // we will initialize this shortly
.link = undefined, // Handled by the wl.list
};
@ -536,12 +541,24 @@ pub fn manage(output: *Output) void {
if (output.pending_manage.primary_ratio) |primary_ratio| {
// Ratios outside of this range could cause crashes (when doing the layout calculation)
output.primary_ratio = std.math.clamp(primary_ratio, 0.10, 0.90);
output.primary_ratio = std.math.clamp(
primary_ratio,
Config.min_primary_ratio,
Config.max_primary_ratio,
);
}
if (output.pending_manage.primary_count) |primary_count| {
// Don't allow less than 1 primary
output.primary_count = @max(1, primary_count);
}
if (output.pending_manage.single_window_ratio) |single_window_ratio| {
output.single_window_ratio = std.math.clamp(
single_window_ratio,
Config.min_primary_ratio,
1.00,
);
}
if (output.pending_manage.tags) |new_tags| {
// Save current layout for the old tagmask
output.tag_layout_overrides.put(utils.gpa, output.tags, .{
@ -627,7 +644,8 @@ pub fn render(output: *Output) void {
fn calculateLayout(output: *Output) void {
// Shouldn't be called if height/width are not positive
assert(output.geometry.width > 0 and output.geometry.height > 0);
// Get a list of active windows
// Get a list of active tiled windows
// i.e. any windows that are on this output with at least one active tag and aren't fullscreen or floating
var active_list: DoublyLinkedList = .{};
var active_count: u31 = 0;
var it = output.windows.iterator(.forward);
@ -661,9 +679,14 @@ fn calculateLayout(output: *Output) void {
// Single window: maximize and return early
if (active_count == 1) {
const window: *Window = @fieldParentPtr("active_list_node", active_list.popFirst().?);
window.pending_render.position = .{ .x = output_x + border_width, .y = output_y + border_width };
const width = @as(u31, @intFromFloat(@as(f32, @floatFromInt(output_width)) * output.single_window_ratio)) -
2 * border_width;
const x = output_x + @divFloor(output_width - width, 2);
window.pending_render.position = .{ .x = x, .y = output_y + border_width };
window.pending_manage.dimensions = .{
.width = output_width - 2 * border_width,
.width = width,
.height = output_height - 2 * border_width,
};
window.pending_manage.maximized = true;
@ -771,6 +794,7 @@ const utils = @import("utils.zig");
const Rect = utils.Rect;
const Bar = @import("Bar.zig");
const Buffer = @import("Buffer.zig");
const Config = @import("Config.zig");
const Context = @import("Context.zig");
const TagOverlay = @import("TagOverlay.zig");
const Window = @import("Window.zig");

View file

@ -4,7 +4,7 @@
const WindowManager = @This();
const MIN_RIVER_SEAT_V1_VERSION: u2 = 3;
const min_river_seat_v1_version: u2 = 3;
context: *Context,
@ -239,9 +239,9 @@ fn windowManagerV1Listener(window_manager_v1: *river.WindowManagerV1, event: riv
.seat => |ev| {
const river_seat_v1 = ev.id;
const river_seat_v1_version = river_seat_v1.getVersion();
if (river_seat_v1_version < MIN_RIVER_SEAT_V1_VERSION) {
if (river_seat_v1_version < min_river_seat_v1_version) {
@branchHint(.cold); // If we're in here, the program is exiting anyways
utils.versionNotSupported(river.SeatV1, river_seat_v1_version, MIN_RIVER_SEAT_V1_VERSION);
utils.versionNotSupported(river.SeatV1, river_seat_v1_version, min_river_seat_v1_version);
}
const seat = Seat.create(context, river_seat_v1) catch @panic("Out of memory");