a73x

2897c867

Add pure ScaleTracker for wl_output scale tracking

a73x   2026-04-09 10:04

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

diff --git a/build.zig b/build.zig
index e74c775..42d5c65 100644
--- a/build.zig
+++ b/build.zig
@@ -9,6 +9,12 @@ pub fn build(b: *std.Build) void {
        .optimize = optimize,
    });

    const scale_tracker_mod = b.createModule(.{
        .root_source_file = b.path("src/scale_tracker.zig"),
        .target = target,
        .optimize = optimize,
    });

    // Lazy-fetch the ghostty dependency. On the first invocation this
    // materializes the package; subsequent builds use the local cache.
    const ghostty_dep = b.lazyDependency("ghostty", .{});
@@ -37,6 +43,7 @@ pub fn build(b: *std.Build) void {
        .link_libc = true,
    });
    wayland_mod.addImport("wayland", wayland_generated_mod);
    wayland_mod.addImport("scale_tracker", scale_tracker_mod);
    wayland_mod.linkSystemLibrary("wayland-client", .{});
    wayland_mod.linkSystemLibrary("xkbcommon", .{});
    _ = wayland_dep; // referenced via Scanner
@@ -101,6 +108,17 @@ pub fn build(b: *std.Build) void {
    });
    test_step.dependOn(&b.addRunArtifact(pty_tests).step);

    // Test scale_tracker.zig
    const scale_tracker_test_mod = b.createModule(.{
        .root_source_file = b.path("src/scale_tracker.zig"),
        .target = target,
        .optimize = optimize,
    });
    const scale_tracker_tests = b.addTest(.{
        .root_module = scale_tracker_test_mod,
    });
    test_step.dependOn(&b.addRunArtifact(scale_tracker_tests).step);

    // Test main.zig (and transitively vt.zig via its import)
    const main_test_mod = b.createModule(.{
        .root_source_file = b.path("src/main.zig"),
diff --git a/src/scale_tracker.zig b/src/scale_tracker.zig
new file mode 100644
index 0000000..87c2e45
--- /dev/null
+++ b/src/scale_tracker.zig
@@ -0,0 +1,137 @@
const std = @import("std");

pub const OutputId = u32;

pub const ScaleTracker = struct {
    alloc: std.mem.Allocator,
    scales: std.AutoHashMapUnmanaged(OutputId, i32),
    entered: std.AutoHashMapUnmanaged(OutputId, void),

    pub fn init(alloc: std.mem.Allocator) ScaleTracker {
        return .{
            .alloc = alloc,
            .scales = .empty,
            .entered = .empty,
        };
    }

    pub fn deinit(self: *ScaleTracker) void {
        self.scales.deinit(self.alloc);
        self.entered.deinit(self.alloc);
    }

    pub fn addOutput(self: *ScaleTracker, id: OutputId) !void {
        try self.scales.put(self.alloc, id, 1);
    }

    pub fn setOutputScale(self: *ScaleTracker, id: OutputId, scale: i32) void {
        if (self.scales.getPtr(id)) |slot| slot.* = scale;
    }

    pub fn removeOutput(self: *ScaleTracker, id: OutputId) void {
        _ = self.scales.remove(id);
        _ = self.entered.remove(id);
    }

    pub fn enterOutput(self: *ScaleTracker, id: OutputId) !void {
        try self.entered.put(self.alloc, id, {});
    }

    pub fn leaveOutput(self: *ScaleTracker, id: OutputId) void {
        _ = self.entered.remove(id);
    }

    pub fn bufferScale(self: *const ScaleTracker) i32 {
        var max_scale: i32 = 1;
        var it = self.entered.iterator();
        while (it.next()) |entry| {
            const id = entry.key_ptr.*;
            if (self.scales.get(id)) |s| {
                if (s > max_scale) max_scale = s;
            }
        }
        return max_scale;
    }
};

test "new tracker reports default scale of 1" {
    var t = ScaleTracker.init(std.testing.allocator);
    defer t.deinit();
    try std.testing.expectEqual(@as(i32, 1), t.bufferScale());
}

test "entered output scale is reflected in bufferScale" {
    var t = ScaleTracker.init(std.testing.allocator);
    defer t.deinit();

    try t.addOutput(1);
    t.setOutputScale(1, 2);
    try t.enterOutput(1);
    try std.testing.expectEqual(@as(i32, 2), t.bufferScale());
}

test "not-yet-entered output does not change bufferScale" {
    var t = ScaleTracker.init(std.testing.allocator);
    defer t.deinit();

    try t.addOutput(7);
    t.setOutputScale(7, 3);
    try std.testing.expectEqual(@as(i32, 1), t.bufferScale());
}

test "bufferScale is max across entered outputs" {
    var t = ScaleTracker.init(std.testing.allocator);
    defer t.deinit();

    try t.addOutput(1);
    try t.addOutput(2);
    t.setOutputScale(1, 1);
    t.setOutputScale(2, 2);

    try t.enterOutput(1);
    try t.enterOutput(2);
    try std.testing.expectEqual(@as(i32, 2), t.bufferScale());
}

test "leaving an output drops its contribution" {
    var t = ScaleTracker.init(std.testing.allocator);
    defer t.deinit();

    try t.addOutput(1);
    try t.addOutput(2);
    t.setOutputScale(1, 2);
    t.setOutputScale(2, 3);
    try t.enterOutput(1);
    try t.enterOutput(2);
    try std.testing.expectEqual(@as(i32, 3), t.bufferScale());

    t.leaveOutput(2);
    try std.testing.expectEqual(@as(i32, 2), t.bufferScale());
}

test "removing an unknown output is a no-op" {
    var t = ScaleTracker.init(std.testing.allocator);
    defer t.deinit();
    t.removeOutput(999);
    try std.testing.expectEqual(@as(i32, 1), t.bufferScale());
}

test "removeOutput also removes it from entered set" {
    var t = ScaleTracker.init(std.testing.allocator);
    defer t.deinit();

    try t.addOutput(5);
    t.setOutputScale(5, 4);
    try t.enterOutput(5);
    try std.testing.expectEqual(@as(i32, 4), t.bufferScale());

    t.removeOutput(5);
    try std.testing.expectEqual(@as(i32, 1), t.bufferScale());
}

test "setOutputScale on unknown id is a no-op" {
    var t = ScaleTracker.init(std.testing.allocator);
    defer t.deinit();
    t.setOutputScale(999, 5);
    try std.testing.expectEqual(@as(i32, 1), t.bufferScale());
}