613 lines
25 KiB
Zig
613 lines
25 KiB
Zig
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));
|
|
}
|