const std = @import("std"); const testing = std.testing; const act = @import("action"); const input = @import("input"); const font = @import("font.zig"); 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))); } // ─── splitting the tag set across screens ──────────────────────────────────── test "a lone screen owns every tag" { try testing.expectEqual(act.all_tags, act.tagsForOutput(0, 1)); // No screens at all is not a case callers should have to think about. try testing.expectEqual(act.all_tags, act.tagsForOutput(0, 0)); } test "two screens split the tags into contiguous halves" { // Nine tags do not halve evenly; the left screen takes the extra one. try testing.expectEqual(@as(u32, 0b000011111), act.tagsForOutput(0, 2)); try testing.expectEqual(@as(u32, 0b111100000), act.tagsForOutput(1, 2)); } test "three screens split the tags into thirds" { try testing.expectEqual(@as(u32, 0b000000111), act.tagsForOutput(0, 3)); try testing.expectEqual(@as(u32, 0b000111000), act.tagsForOutput(1, 3)); try testing.expectEqual(@as(u32, 0b111000000), act.tagsForOutput(2, 3)); } test "every tag lands on exactly one screen" { // The whole point of the split: no tag is shared and none goes missing, // whatever the screen count. for (1..act.tag_count + 1) |count| { var seen: u32 = 0; for (0..count) |i| { const mask = act.tagsForOutput(i, count); try testing.expect(mask != 0); try testing.expectEqual(@as(u32, 0), seen & mask); seen |= mask; } try testing.expectEqual(act.all_tags, seen); } } test "ranges are contiguous and in screen order" { for (1..act.tag_count + 1) |count| { var next: u32 = 0; for (0..count) |i| { const mask = act.tagsForOutput(i, count); // Contiguous: a run of ones, starting where the last range ended. const lowest = @ctz(mask); const past_highest = 32 - @clz(mask); try testing.expectEqual(next, lowest); try testing.expectEqual(past_highest - lowest, @popCount(mask)); next = past_highest; } } } test "more screens than tags leaves no screen without one" { // Twelve monitors and nine tags: the ones past the end share the last tag // rather than being handed an empty mask, which could show nothing at all. const count = act.tag_count + 3; for (0..count) |i| { try testing.expect(act.tagsForOutput(i, count) != 0); } try testing.expectEqual(act.tagsForOutput(act.tag_count - 1, count), act.tagsForOutput(count - 1, count)); } test "a screen index past the end owns everything rather than nothing" { try testing.expectEqual(act.all_tags, act.tagsForOutput(2, 2)); } test "the lowest tag is what a stranded view falls back to" { try testing.expectEqual(@as(u32, 0b100000), act.lowestTag(0b111100000)); try testing.expectEqual(@as(u32, 1), act.lowestTag(act.all_tags)); try testing.expectEqual(@as(u32, 0), act.lowestTag(0)); // Junk above the tag range is not a tag to fall back to. try testing.expectEqual(@as(u32, 0), act.lowestTag(~act.all_tags)); } // ─── 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.?); } // ─── tab titles ────────────────────────────────────────────────────────────── /// Stands in for a `Face`, so the eliding rules can be checked without a font /// on the machine running the tests. One cell wide per character, in the 26.6 /// units `font.fit` counts in. const Mono = struct { pub fn advance(_: Mono, _: u21) i32 { return font.fixed(1); } pub fn ellipsisAdvance(self: Mono) i32 { return self.advance(font.ellipsis); } }; /// A proportional face: 'i' is half a cell, the ellipsis two. Real faces are /// like this, and it is where fixed-cell arithmetic goes wrong. const Prop = struct { pub fn advance(_: Prop, cp: u21) i32 { return switch (cp) { 'i' => @divExact(font.fixed(1), 2), font.ellipsis => font.fixed(2), else => font.fixed(1), }; } pub fn ellipsisAdvance(self: Prop) i32 { return self.advance(font.ellipsis); } }; test "a title that fits is drawn whole" { const f = font.fit("foot", font.fixed(10), Mono{}); try testing.expectEqual(@as(usize, 4), f.end); try testing.expectEqual(font.fixed(4), f.width); try testing.expect(!f.elided); // Exactly full is still whole: the ellipsis costs room, so only spend it // when something is actually cut. const exact = font.fit("foot", font.fixed(4), Mono{}); try testing.expectEqual(@as(usize, 4), exact.end); try testing.expect(!exact.elided); } test "a title too long for the tab is cut short for the ellipsis" { const f = font.fit("firefox", font.fixed(4), Mono{}); try testing.expectEqualStrings("fir", "firefox"[0..f.end]); try testing.expectEqual(font.fixed(4), f.width); try testing.expect(f.elided); // Room for the ellipsis alone leaves nothing but the ellipsis, and no room // at all draws nothing rather than indexing past the end. const one = font.fit("firefox", font.fixed(1), Mono{}); try testing.expectEqual(@as(usize, 0), one.end); try testing.expect(one.elided); const none = font.fit("firefox", 0, Mono{}); try testing.expectEqual(@as(usize, 0), none.end); try testing.expect(!none.elided); } test "eliding gives up as many characters as the ellipsis costs" { // "wiki" alone would fit in three cells; the two-cell ellipsis means all // but the first character has to go, which counting cells would miss. const f = font.fit("wikiw", font.fixed(3), Prop{}); try testing.expect(f.elided); try testing.expectEqualStrings("w", "wikiw"[0..f.end]); try testing.expect(f.width <= font.fixed(3)); // Narrow characters earn their place: three of them survive where two wide // ones would not. const narrow = font.fit("wiiwwww", font.fixed(4), Prop{}); try testing.expect(narrow.elided); try testing.expectEqualStrings("wii", "wiiwwww"[0..narrow.end]); try testing.expect(narrow.width <= font.fixed(4)); } test "titles are measured in characters, not bytes" { // Four codepoints, six bytes: a tab four cells wide holds them all. const f = font.fit("ünïx", font.fixed(4), Mono{}); try testing.expectEqual("ünïx".len, f.end); try testing.expect(!f.elided); // And a cut lands on a codepoint boundary, never mid-sequence. const cut = font.fit("ünïx", font.fixed(3), Mono{}); try testing.expect(cut.elided); try testing.expectEqualStrings("ün", "ünïx"[0..cut.end]); } test "malformed utf-8 in a title yields one glyph per bad byte" { var it: font.Iterator = .{ .bytes = "a\xffb\xc3" }; try testing.expectEqual(@as(?u21, 'a'), it.next()); try testing.expectEqual(@as(?u21, 0xfffd), it.next()); try testing.expectEqual(@as(?u21, 'b'), it.next()); // A truncated sequence at the end must terminate the walk, not loop. try testing.expectEqual(@as(?u21, 0xfffd), it.next()); try testing.expectEqual(@as(?u21, null), it.next()); } test "control characters in a title draw as spaces" { var it: font.Iterator = .{ .bytes = "a\nb\tc\x7f" }; try testing.expectEqual(@as(?u21, 'a'), it.next()); try testing.expectEqual(@as(?u21, ' '), it.next()); try testing.expectEqual(@as(?u21, 'b'), it.next()); try testing.expectEqual(@as(?u21, ' '), it.next()); try testing.expectEqual(@as(?u21, 'c'), it.next()); try testing.expectEqual(@as(?u21, ' '), it.next()); try testing.expectEqual(@as(?u21, null), it.next()); } test "fixed point widths round up to whole pixels" { try testing.expectEqual(@as(i32, 0), font.pixels(0)); try testing.expectEqual(@as(i32, 1), font.pixels(1)); try testing.expectEqual(@as(i32, 1), font.pixels(font.fixed(1))); try testing.expectEqual(@as(i32, 2), font.pixels(font.fixed(1) + 1)); }