425 lines
18 KiB
Zig
425 lines
18 KiB
Zig
const std = @import("std");
|
|
const testing = std.testing;
|
|
|
|
const act = @import("action");
|
|
const input = @import("input");
|
|
const layout = @import("layout.zig");
|
|
const Box = layout.Box;
|
|
|
|
comptime {
|
|
_ = act;
|
|
_ = input;
|
|
_ = @import("color.zig");
|
|
}
|
|
|
|
const area: Box = .{ .x = 0, .y = 0, .width = 1000, .height = 600 };
|
|
|
|
fn params(nmaster: u32, mfact: f32) layout.Params {
|
|
return .{ .area = area, .nmaster = nmaster, .mfact = mfact };
|
|
}
|
|
|
|
// ─── master/stack tiling ─────────────────────────────────────────────────────
|
|
|
|
test "single window fills the whole area" {
|
|
var cells: [1]Box = undefined;
|
|
_ = layout.arrange(.master, params(1, 0.55), &cells);
|
|
try testing.expectEqual(area, cells[0]);
|
|
}
|
|
|
|
test "two windows split at mfact" {
|
|
var cells: [2]Box = undefined;
|
|
_ = layout.arrange(.master, params(1, 0.55), &cells);
|
|
|
|
try testing.expectEqual(@as(i32, 550), cells[0].width);
|
|
try testing.expectEqual(@as(i32, 600), cells[0].height);
|
|
|
|
try testing.expectEqual(@as(i32, 550), cells[1].x);
|
|
try testing.expectEqual(@as(i32, 450), cells[1].width);
|
|
try testing.expectEqual(@as(i32, 600), cells[1].height);
|
|
}
|
|
|
|
test "stack column divides height with no gap or overlap" {
|
|
// Three windows, one master: the stack column holds two.
|
|
var cells: [3]Box = undefined;
|
|
_ = layout.arrange(.master, params(1, 0.5), &cells);
|
|
|
|
try testing.expectEqual(@as(i32, 600), cells[0].height);
|
|
try testing.expectEqual(cells[1].y + cells[1].height, cells[2].y);
|
|
try testing.expectEqual(area.height, cells[1].height + cells[2].height);
|
|
}
|
|
|
|
test "odd heights are absorbed rather than leaving a gap at the bottom" {
|
|
// 600 / 7 does not divide evenly; the last window must still end exactly
|
|
// at the bottom edge.
|
|
var cells: [7]Box = undefined;
|
|
_ = layout.arrange(.master, params(0, 0.55), &cells);
|
|
|
|
var y: i32 = area.y;
|
|
for (cells) |cell| {
|
|
try testing.expectEqual(y, cell.y);
|
|
y += cell.height;
|
|
}
|
|
try testing.expectEqual(area.y + area.height, y);
|
|
}
|
|
|
|
test "nmaster zero puts every window in the stack column" {
|
|
var cells: [3]Box = undefined;
|
|
_ = layout.arrange(.master, params(0, 0.55), &cells);
|
|
for (cells) |cell| {
|
|
try testing.expectEqual(@as(i32, 0), cell.x);
|
|
try testing.expectEqual(area.width, cell.width);
|
|
}
|
|
}
|
|
|
|
test "windows all fit in master when count does not exceed nmaster" {
|
|
var cells: [2]Box = undefined;
|
|
_ = layout.arrange(.master, params(3, 0.55), &cells);
|
|
// No stack column, so master spans the full width.
|
|
for (cells) |cell| {
|
|
try testing.expectEqual(area.width, cell.width);
|
|
}
|
|
try testing.expectEqual(area.height, cells[0].height + cells[1].height);
|
|
}
|
|
|
|
test "master column also divides height when nmaster exceeds one" {
|
|
var cells: [4]Box = undefined;
|
|
_ = layout.arrange(.master, params(2, 0.5), &cells);
|
|
|
|
try testing.expectEqual(@as(i32, 500), cells[0].width);
|
|
try testing.expectEqual(@as(i32, 500), cells[1].width);
|
|
try testing.expectEqual(area.height, cells[0].height + cells[1].height);
|
|
try testing.expectEqual(area.height, cells[2].height + cells[3].height);
|
|
}
|
|
|
|
// ─── stacking layouts ────────────────────────────────────────────────────────
|
|
|
|
test "monocle gives every window the full area and reports stacked" {
|
|
var cells: [3]Box = undefined;
|
|
const result = layout.arrange(.monocle, params(1, 0.55), &cells);
|
|
|
|
try testing.expect(result.stacked);
|
|
try testing.expect(result.tabbar == null);
|
|
for (cells) |cell| try testing.expectEqual(area, cell);
|
|
}
|
|
|
|
test "master does not report stacked" {
|
|
var cells: [2]Box = undefined;
|
|
var p = params(1, 0.55);
|
|
p.tabbar_height = 22;
|
|
|
|
try testing.expect(!layout.arrange(.master, p, &cells).stacked);
|
|
}
|
|
|
|
test "tabbed reserves the bar strip above the windows" {
|
|
var cells: [3]Box = undefined;
|
|
var p = params(1, 0.55);
|
|
p.tabbar_height = 22;
|
|
const result = layout.arrange(.tabbed, p, &cells);
|
|
|
|
try testing.expect(result.stacked);
|
|
const bar = result.tabbar.?;
|
|
try testing.expectEqual(@as(i32, 22), bar.height);
|
|
try testing.expectEqual(area.y, bar.y);
|
|
|
|
// Windows start below the bar and the two together cover the area exactly.
|
|
for (cells) |cell| {
|
|
try testing.expectEqual(area.y + 22, cell.y);
|
|
try testing.expectEqual(area.height - 22, cell.height);
|
|
}
|
|
}
|
|
|
|
test "tabbed drops the bar rather than crushing the windows" {
|
|
var cells: [2]Box = undefined;
|
|
var p: layout.Params = .{
|
|
.area = .{ .x = 0, .y = 0, .width = 400, .height = 30 },
|
|
.nmaster = 1,
|
|
.mfact = 0.55,
|
|
.tabbar_height = 22,
|
|
};
|
|
const result = layout.arrange(.tabbed, p, &cells);
|
|
try testing.expect(result.tabbar == null);
|
|
try testing.expectEqual(@as(i32, 30), cells[0].height);
|
|
p.tabbar_height = 0;
|
|
}
|
|
|
|
test "no windows is not a crash" {
|
|
var cells: [0]Box = undefined;
|
|
const result = layout.arrange(.master, params(1, 0.55), &cells);
|
|
try testing.expect(result.tabbar == null);
|
|
}
|
|
|
|
// ─── gaps ────────────────────────────────────────────────────────────────────
|
|
|
|
test "outer gap insets the whole area" {
|
|
var cells: [1]Box = undefined;
|
|
var p = params(1, 0.55);
|
|
p.outer_gap = 10;
|
|
_ = layout.arrange(.master, p, &cells);
|
|
|
|
try testing.expectEqual(@as(i32, 10), cells[0].x);
|
|
try testing.expectEqual(@as(i32, 10), cells[0].y);
|
|
try testing.expectEqual(@as(i32, 980), cells[0].width);
|
|
try testing.expectEqual(@as(i32, 580), cells[0].height);
|
|
}
|
|
|
|
test "inner gap separates adjacent windows" {
|
|
var cells: [2]Box = undefined;
|
|
var p = params(1, 0.5);
|
|
p.gap = 10;
|
|
_ = layout.arrange(.master, p, &cells);
|
|
|
|
// Each cell shrinks by half the gap per side, leaving a full gap between.
|
|
const right_of_master = cells[0].x + cells[0].width;
|
|
try testing.expect(cells[1].x - right_of_master == 10);
|
|
}
|
|
|
|
// ─── tab rectangles ──────────────────────────────────────────────────────────
|
|
|
|
test "tab rects tile the bar exactly with no rounding gap" {
|
|
const bar: Box = .{ .x = 5, .y = 0, .width = 101, .height = 22 };
|
|
var rects: [4]Box = undefined;
|
|
layout.tabRects(bar, 4, &rects);
|
|
|
|
try testing.expectEqual(bar.x, rects[0].x);
|
|
var total: i32 = 0;
|
|
for (rects, 0..) |rect, i| {
|
|
total += rect.width;
|
|
if (i > 0) {
|
|
try testing.expectEqual(rects[i - 1].x + rects[i - 1].width, rect.x);
|
|
}
|
|
}
|
|
try testing.expectEqual(bar.width, total);
|
|
try testing.expectEqual(bar.x + bar.width, rects[3].x + rects[3].width);
|
|
}
|
|
|
|
test "single tab spans the bar" {
|
|
const bar: Box = .{ .x = 0, .y = 0, .width = 300, .height = 22 };
|
|
var rects: [1]Box = undefined;
|
|
layout.tabRects(bar, 1, &rects);
|
|
try testing.expectEqual(@as(i32, 300), rects[0].width);
|
|
}
|
|
|
|
// ─── Box helpers ─────────────────────────────────────────────────────────────
|
|
|
|
test "inset never produces negative dimensions" {
|
|
const tiny: Box = .{ .x = 0, .y = 0, .width = 4, .height = 4 };
|
|
const r = tiny.inset(10);
|
|
try testing.expectEqual(@as(i32, 0), r.width);
|
|
try testing.expectEqual(@as(i32, 0), r.height);
|
|
}
|
|
|
|
test "contains is half open on the far edges" {
|
|
const b: Box = .{ .x = 10, .y = 10, .width = 100, .height = 50 };
|
|
try testing.expect(b.contains(10, 10));
|
|
try testing.expect(b.contains(109, 59));
|
|
try testing.expect(!b.contains(110, 30));
|
|
try testing.expect(!b.contains(9, 30));
|
|
}
|
|
|
|
// ─── per-tag settings slots ──────────────────────────────────────────────────
|
|
|
|
test "each single tag gets its own settings slot" {
|
|
for (0..act.tag_count) |i| {
|
|
const mask = @as(u32, 1) << @intCast(i);
|
|
try testing.expectEqual(i + 1, act.tagSlot(mask));
|
|
}
|
|
}
|
|
|
|
test "views of more than one tag share the shared slot" {
|
|
try testing.expectEqual(@as(usize, 0), act.tagSlot(0b11));
|
|
try testing.expectEqual(@as(usize, 0), act.tagSlot(0b101));
|
|
try testing.expectEqual(@as(usize, 0), act.tagSlot(act.all_tags));
|
|
}
|
|
|
|
test "an empty view falls back to the shared slot" {
|
|
try testing.expectEqual(@as(usize, 0), act.tagSlot(0));
|
|
}
|
|
|
|
test "bits above the tag range do not affect the slot" {
|
|
// A single valid tag stays on its own slot even with junk in the high bits,
|
|
// so a mask that survived a sloppy IPC caller cannot index past the array.
|
|
const junk: u32 = ~act.all_tags;
|
|
try testing.expectEqual(@as(usize, 1), act.tagSlot(0b1 | junk));
|
|
try testing.expectEqual(act.tag_count, act.tagSlot(@as(u32, 1) << (act.tag_count - 1)));
|
|
}
|
|
|
|
// ─── command parsing ─────────────────────────────────────────────────────────
|
|
|
|
fn parseOk(argv: []const []const u8) act.Action {
|
|
return act.parse(argv) catch unreachable;
|
|
}
|
|
|
|
test "tag arguments accept indices, masks and all" {
|
|
try testing.expectEqual(@as(u32, 1), parseOk(&.{ "view", "1" }).view);
|
|
try testing.expectEqual(@as(u32, 4), parseOk(&.{ "view", "3" }).view);
|
|
try testing.expectEqual(@as(u32, 4), parseOk(&.{ "view", "0x4" }).view);
|
|
try testing.expectEqual(@as(u32, 4), parseOk(&.{ "view", "mask:4" }).view);
|
|
try testing.expectEqual(act.all_tags, parseOk(&.{ "view", "all" }).view);
|
|
}
|
|
|
|
test "tag indices outside the range are rejected" {
|
|
try testing.expectError(error.InvalidArgument, act.parse(&.{ "view", "0" }));
|
|
try testing.expectError(error.InvalidArgument, act.parse(&.{ "view", "10" }));
|
|
try testing.expectError(error.InvalidArgument, act.parse(&.{ "view", "nope" }));
|
|
try testing.expectError(error.MissingArgument, act.parse(&.{"view"}));
|
|
}
|
|
|
|
test "masks are clamped to the valid tag range" {
|
|
try testing.expectEqual(act.all_tags, parseOk(&.{ "view", "0xffffffff" }).view);
|
|
}
|
|
|
|
test "deltas distinguish relative from absolute" {
|
|
switch (parseOk(&.{ "mfact", "+0.05" }).mfact) {
|
|
.relative => |v| try testing.expectApproxEqAbs(@as(f32, 0.05), v, 1e-6),
|
|
.absolute => return error.TestUnexpectedResult,
|
|
}
|
|
switch (parseOk(&.{ "mfact", "0.5" }).mfact) {
|
|
.absolute => |v| try testing.expectApproxEqAbs(@as(f32, 0.5), v, 1e-6),
|
|
.relative => return error.TestUnexpectedResult,
|
|
}
|
|
switch (parseOk(&.{ "nmaster", "-1" }).nmaster) {
|
|
.relative => |v| try testing.expectEqual(@as(i32, -1), v),
|
|
.absolute => return error.TestUnexpectedResult,
|
|
}
|
|
}
|
|
|
|
test "delta application respects relative and absolute" {
|
|
const rel = act.Delta(i32){ .relative = -1 };
|
|
const abs = act.Delta(i32){ .absolute = 3 };
|
|
try testing.expectEqual(@as(i32, 4), rel.apply(5));
|
|
try testing.expectEqual(@as(i32, 3), abs.apply(5));
|
|
}
|
|
|
|
test "unknown commands are rejected rather than guessed at" {
|
|
try testing.expectError(error.UnknownCommand, act.parse(&.{"nonsense"}));
|
|
try testing.expectError(error.UnknownCommand, act.parse(&.{}));
|
|
}
|
|
|
|
test "spawn keeps its whole argv" {
|
|
const a = parseOk(&.{ "spawn", "foot", "-e", "htop" });
|
|
try testing.expectEqual(@as(usize, 3), a.spawn.len);
|
|
try testing.expectEqualStrings("htop", a.spawn[2]);
|
|
try testing.expectError(error.MissingArgument, act.parse(&.{"spawn"}));
|
|
}
|
|
|
|
test "directions parse both spellings" {
|
|
try testing.expectEqual(act.Direction.next, parseOk(&.{ "focus", "next" }).focus);
|
|
try testing.expectEqual(act.Direction.prev, parseOk(&.{ "focus", "prev" }).focus);
|
|
try testing.expectEqual(act.Direction.prev, parseOk(&.{ "focus", "previous" }).focus);
|
|
try testing.expectError(error.InvalidArgument, act.parse(&.{ "focus", "sideways" }));
|
|
}
|
|
|
|
test "window commands keep the identifier verbatim" {
|
|
try testing.expectEqualStrings("w-17", parseOk(&.{ "focus-window", "w-17" }).focus_window);
|
|
try testing.expectEqualStrings("w-17", parseOk(&.{ "close-window", "w-17" }).close_window);
|
|
// An identifier is opaque, so a direction-looking one is still an id.
|
|
try testing.expectEqualStrings("next", parseOk(&.{ "focus-window", "next" }).focus_window);
|
|
try testing.expectError(error.MissingArgument, act.parse(&.{"focus-window"}));
|
|
try testing.expectError(error.InvalidArgument, act.parse(&.{ "focus-window", "" }));
|
|
}
|
|
|
|
test "layouts parse by name" {
|
|
try testing.expectEqual(act.Layout.monocle, parseOk(&.{ "layout", "monocle" }).set_layout);
|
|
try testing.expectEqual(act.Layout.tabbed, parseOk(&.{ "layout", "tabbed" }).set_layout);
|
|
try testing.expectError(error.InvalidArgument, act.parse(&.{ "layout", "spiral" }));
|
|
}
|
|
|
|
test "only navigation-style actions repeat on key hold" {
|
|
try testing.expect(parseOk(&.{ "focus", "next" }).repeats());
|
|
try testing.expect(parseOk(&.{ "mfact", "+0.05" }).repeats());
|
|
try testing.expect(!parseOk(&.{"zoom"}).repeats());
|
|
try testing.expect(!parseOk(&.{"close"}).repeats());
|
|
try testing.expect(!parseOk(&.{ "view", "1" }).repeats());
|
|
}
|
|
|
|
// ─── input device rules ──────────────────────────────────────────────────────
|
|
|
|
test "device name globs match the way a config author expects" {
|
|
const touchpad = "ELAN0501:00 04F3:3060 Touchpad";
|
|
|
|
try testing.expect(input.matches("*Touchpad*", touchpad));
|
|
try testing.expect(input.matches("*Touchpad", touchpad));
|
|
try testing.expect(input.matches("ELAN*", touchpad));
|
|
try testing.expect(input.matches("*", touchpad));
|
|
try testing.expect(input.matches(touchpad, touchpad));
|
|
|
|
try testing.expect(!input.matches("*Trackpoint*", touchpad));
|
|
try testing.expect(!input.matches("Touchpad", touchpad));
|
|
// A literal pattern must match the whole name, not merely a prefix.
|
|
try testing.expect(!input.matches("ELAN0501", touchpad));
|
|
}
|
|
|
|
test "globs handle empty runs and repeated stars" {
|
|
try testing.expect(input.matches("", ""));
|
|
try testing.expect(input.matches("*", ""));
|
|
try testing.expect(input.matches("***", ""));
|
|
try testing.expect(!input.matches("a", ""));
|
|
|
|
// The backtracking case: each star has to be willing to give ground.
|
|
try testing.expect(input.matches("*a*b*c*", "xxaxxbxxcxx"));
|
|
try testing.expect(!input.matches("*a*b*c*", "xxaxxcxxbxx"));
|
|
// Only the last 'a' lets the rest of the pattern through.
|
|
try testing.expect(input.matches("*aab", "aaab"));
|
|
}
|
|
|
|
test "rules match on name and type independently" {
|
|
const rule: input.Rule = .{ .name = "*Touchpad*", .type = .pointer, .tap = true };
|
|
|
|
try testing.expect(rule.matchesDevice("Foo Touchpad", .pointer));
|
|
// Right name, wrong kind of device.
|
|
try testing.expect(!rule.matchesDevice("Foo Touchpad", .touch));
|
|
try testing.expect(!rule.matchesDevice("Foo Keyboard", .pointer));
|
|
|
|
// A rule with neither selector applies to everything.
|
|
const catch_all: input.Rule = .{ .natural_scroll = true };
|
|
try testing.expect(catch_all.matchesDevice("anything", .tablet));
|
|
try testing.expect(catch_all.matchesDevice("", .keyboard));
|
|
}
|
|
|
|
test "later rules override earlier ones field by field" {
|
|
const broad: input.Rule = .{ .name = "*", .tap = true, .natural_scroll = true };
|
|
const narrow: input.Rule = .{ .name = "*Touchpad*", .tap = false, .click_method = .clickfinger };
|
|
|
|
const merged = (input.Rule{}).merge(broad).merge(narrow);
|
|
|
|
// Stated twice: the later rule wins.
|
|
try testing.expectEqual(@as(?bool, false), merged.tap);
|
|
// Stated only by the broad rule: survives.
|
|
try testing.expectEqual(@as(?bool, true), merged.natural_scroll);
|
|
// Stated only by the narrow rule: applied.
|
|
try testing.expectEqual(@as(?input.ClickMethod, .clickfinger), merged.click_method);
|
|
// Stated by neither: still null, so the device keeps libinput's default.
|
|
try testing.expectEqual(@as(?bool, null), merged.middle_emulation);
|
|
}
|
|
|
|
test "merging leaves the selectors alone" {
|
|
// Otherwise a merged rule would claim to be about whichever device matched
|
|
// last, which is not a thing anything should be able to read back out.
|
|
const merged = (input.Rule{ .name = "a", .type = .pointer })
|
|
.merge(.{ .name = "b", .type = .keyboard, .tap = true });
|
|
|
|
try testing.expectEqualStrings("a", merged.name.?);
|
|
try testing.expectEqual(@as(?input.Type, .pointer), merged.type);
|
|
try testing.expectEqual(@as(?bool, true), merged.tap);
|
|
}
|
|
|
|
test "an unset keymap is recognised as the default" {
|
|
try testing.expect((input.Keymap{}).isDefault());
|
|
try testing.expect(!(input.Keymap{ .layout = "us" }).isDefault());
|
|
try testing.expect(!(input.Keymap{ .options = "caps:escape" }).isDefault());
|
|
}
|
|
|
|
test "map_to_output merges like any other setting" {
|
|
// It is a string rather than a scalar, so worth pinning that the generic
|
|
// merge handles it and that a rule silent about it does not clear it.
|
|
const merged = (input.Rule{})
|
|
.merge(.{ .type = .touch, .map_to_output = "eDP-1" })
|
|
.merge(.{ .name = "*", .tap = true });
|
|
|
|
try testing.expectEqualStrings("eDP-1", merged.map_to_output.?);
|
|
|
|
// And that a later rule naming a different output does win.
|
|
const moved = merged.merge(.{ .map_to_output = "DP-2" });
|
|
try testing.expectEqualStrings("DP-2", moved.map_to_output.?);
|
|
}
|