a73x

6a5f175f

Refine selection span semantics

a73x   2026-04-09 17:47


diff --git a/src/main.zig b/src/main.zig
index 9514ac6..0badb6e 100644
--- a/src/main.zig
+++ b/src/main.zig
@@ -213,16 +213,22 @@ fn runTerminal(alloc: std.mem.Allocator) !void {
        // Flush any pending wayland requests
        _ = conn.display.flush();

        const wayland_read_prepared = conn.display.prepareRead();
        const repeat_timeout_ms = remainingRepeatTimeoutMs(keyboard.nextRepeatDeadlineNs());
        _ = std.posix.poll(&pollfds, computePollTimeoutMs(repeat_timeout_ms, render_pending)) catch {};

        // Wayland events: prepare_read / read_events / dispatch_pending
        if (pollfds[0].revents & std.posix.POLL.IN != 0) {
            if (conn.display.prepareRead()) {
                _ = conn.display.readEvents();
            }
        }
        _ = conn.display.dispatchPending();
        pollfds[0].revents = 0;
        pollfds[1].revents = 0;
        _ = std.posix.poll(
            &pollfds,
            computePollTimeoutMs(repeat_timeout_ms, render_pending, wayland_read_prepared),
        ) catch {};

        // Wayland events: prepare_read before poll so pending queued events
        // force an immediate dispatch instead of sleeping until new socket IO.
        completeWaylandRead(
            conn.display,
            wayland_read_prepared,
            pollfds[0].revents & std.posix.POLL.IN != 0,
        );

        // PTY output
        if (pollfds[1].revents & std.posix.POLL.IN != 0) {
@@ -532,11 +538,23 @@ fn remainingRepeatTimeoutMs(deadline_ns: ?i128) ?i32 {
    return @intCast(@divTrunc(remaining_ns + std.time.ns_per_ms - 1, std.time.ns_per_ms));
}

fn computePollTimeoutMs(next_repeat_in_ms: ?i32, render_pending: bool) i32 {
fn computePollTimeoutMs(next_repeat_in_ms: ?i32, render_pending: bool, wayland_read_prepared: bool) i32 {
    if (!wayland_read_prepared) return 0;
    if (render_pending) return 0;
    return next_repeat_in_ms orelse -1;
}

fn completeWaylandRead(display: anytype, prepared: bool, readable: bool) void {
    if (prepared) {
        if (readable) {
            _ = display.readEvents();
        } else {
            display.cancelRead();
        }
    }
    _ = display.dispatchPending();
}

fn shouldRenderFrame(terminal_dirty: bool, window_dirty: bool, forced: bool) bool {
    return terminal_dirty or window_dirty or forced;
}
@@ -557,13 +575,11 @@ const SelectionSpan = struct {
    }

    fn isEmpty(self: SelectionSpan) bool {
        const span = self.normalized();
        return span.start.row == span.end.row and span.start.col == span.end.col;
        _ = self;
        return false;
    }

    fn containsCell(self: SelectionSpan, col: u32, row: u32) bool {
        if (self.isEmpty()) return false;

        const span = self.normalized();
        if (row < span.start.row or row > span.end.row) return false;
        if (span.start.row == span.end.row) {
@@ -575,35 +591,26 @@ const SelectionSpan = struct {
    }
};

fn selectionIntersectsVisibleGrid(span: SelectionSpan, cols: u16, rows: u16) bool {
    if (cols == 0 or rows == 0) return false;
fn clampSelectionSpan(span: SelectionSpan, cols: u16, rows: u16) ?SelectionSpan {
    if (cols == 0 or rows == 0) return null;

    const normalized = span.normalized();
    const max_col = @as(u32, cols) - 1;
    const max_row = @as(u32, rows) - 1;
    if (normalized.start.row > max_row) return null;
    if (normalized.start.row == normalized.end.row and normalized.start.col > max_col) return null;

    if (normalized.start.row > max_row) return false;
    if (normalized.start.row == normalized.end.row and normalized.start.col > max_col) return false;
    return true;
}

fn clampSelectionSpan(span: SelectionSpan, cols: u16, rows: u16) ?SelectionSpan {
    if (!selectionIntersectsVisibleGrid(span, cols, rows)) return null;

    const max_col = @as(u32, cols) - 1;
    const max_row = @as(u32, rows) - 1;
    const clamped = SelectionSpan{
        .start = .{
            .col = @min(span.start.col, max_col),
            .row = @min(span.start.row, max_row),
            .col = @min(normalized.start.col, max_col),
            .row = @min(normalized.start.row, max_row),
        },
        .end = .{
            .col = @min(span.end.col, max_col),
            .row = @min(span.end.row, max_row),
            .col = @min(normalized.end.col, max_col),
            .row = @min(normalized.end.row, max_row),
        },
    };

    if (clamped.isEmpty()) return null;
    return clamped.normalized();
}

@@ -633,14 +640,28 @@ test "SelectionSpan.containsCell includes the normalized endpoints" {
    try std.testing.expect(!span.containsCell(4, 2));
}

test "SelectionSpan.containsCell treats a degenerate span as empty" {
test "SelectionSpan.containsCell includes a same-cell span" {
    const span = SelectionSpan{
        .start = .{ .col = 5, .row = 5 },
        .end = .{ .col = 5, .row = 5 },
    };

    try std.testing.expect(span.isEmpty());
    try std.testing.expect(!span.containsCell(5, 5));
    try std.testing.expect(span.containsCell(5, 5));
    try std.testing.expect(!span.containsCell(4, 5));
    try std.testing.expect(!span.containsCell(5, 4));
}

test "clampSelectionSpan preserves a same-cell visible span" {
    const clamped = clampSelectionSpan(.{
        .start = .{ .col = 5, .row = 5 },
        .end = .{ .col = 5, .row = 5 },
    }, 80, 24).?;

    try std.testing.expectEqual(@as(u32, 5), clamped.start.col);
    try std.testing.expectEqual(@as(u32, 5), clamped.start.row);
    try std.testing.expectEqual(@as(u32, 5), clamped.end.col);
    try std.testing.expectEqual(@as(u32, 5), clamped.end.row);
    try std.testing.expect(clamped.containsCell(5, 5));
}

test "clampSelectionSpan clears offscreen spans and trims resized spans" {
@@ -658,11 +679,19 @@ test "clampSelectionSpan clears offscreen spans and trims resized spans" {
    try std.testing.expectEqual(@as(u32, 22), clamped.start.row);
    try std.testing.expectEqual(@as(u32, 79), clamped.end.col);
    try std.testing.expectEqual(@as(u32, 23), clamped.end.row);
}

    try std.testing.expect(clampSelectionSpan(.{
        .start = .{ .col = 81, .row = 5 },
        .end = .{ .col = 90, .row = 5 },
    }, 80, 24) == null);
test "clampSelectionSpan preserves a larger span that collapses to one visible cell" {
    const clamped = clampSelectionSpan(.{
        .start = .{ .col = 0, .row = 0 },
        .end = .{ .col = 120, .row = 80 },
    }, 1, 1).?;

    try std.testing.expectEqual(@as(u32, 0), clamped.start.col);
    try std.testing.expectEqual(@as(u32, 0), clamped.start.row);
    try std.testing.expectEqual(@as(u32, 0), clamped.end.col);
    try std.testing.expectEqual(@as(u32, 0), clamped.end.row);
    try std.testing.expect(clamped.containsCell(0, 0));
}

const ComparisonVariant = struct {
@@ -1147,9 +1176,101 @@ fn mapKeysymToInputKey(keysym: u32) ?vt.InputKey {
}

test "event loop waits indefinitely when idle and wakes for imminent repeat" {
    try std.testing.expectEqual(@as(i32, -1), computePollTimeoutMs(null, false));
    try std.testing.expectEqual(@as(i32, 0), computePollTimeoutMs(5, true));
    try std.testing.expectEqual(@as(i32, 17), computePollTimeoutMs(17, false));
    try std.testing.expectEqual(@as(i32, -1), computePollTimeoutMs(null, false, true));
    try std.testing.expectEqual(@as(i32, 0), computePollTimeoutMs(5, true, true));
    try std.testing.expectEqual(@as(i32, 17), computePollTimeoutMs(17, false, true));
}

test "event loop does not sleep while Wayland already has pending events" {
    try std.testing.expectEqual(@as(i32, 0), computePollTimeoutMs(null, false, false));
    try std.testing.expectEqual(@as(i32, 0), computePollTimeoutMs(23, false, false));
}

test "completeWaylandRead cancels prepared read when poll found no socket data" {
    const FakeDisplay = struct {
        read_calls: usize = 0,
        cancel_calls: usize = 0,
        dispatch_calls: usize = 0,

        fn readEvents(self: *@This()) usize {
            self.read_calls += 1;
            return 0;
        }

        fn cancelRead(self: *@This()) void {
            self.cancel_calls += 1;
        }

        fn dispatchPending(self: *@This()) usize {
            self.dispatch_calls += 1;
            return 0;
        }
    };

    var display = FakeDisplay{};
    completeWaylandRead(&display, true, false);

    try std.testing.expectEqual(@as(usize, 0), display.read_calls);
    try std.testing.expectEqual(@as(usize, 1), display.cancel_calls);
    try std.testing.expectEqual(@as(usize, 1), display.dispatch_calls);
}

test "completeWaylandRead dispatches readable socket events without canceling" {
    const FakeDisplay = struct {
        read_calls: usize = 0,
        cancel_calls: usize = 0,
        dispatch_calls: usize = 0,

        fn readEvents(self: *@This()) usize {
            self.read_calls += 1;
            return 0;
        }

        fn cancelRead(self: *@This()) void {
            self.cancel_calls += 1;
        }

        fn dispatchPending(self: *@This()) usize {
            self.dispatch_calls += 1;
            return 0;
        }
    };

    var display = FakeDisplay{};
    completeWaylandRead(&display, true, true);

    try std.testing.expectEqual(@as(usize, 1), display.read_calls);
    try std.testing.expectEqual(@as(usize, 0), display.cancel_calls);
    try std.testing.expectEqual(@as(usize, 1), display.dispatch_calls);
}

test "completeWaylandRead still dispatches pending events when prepareRead could not start" {
    const FakeDisplay = struct {
        read_calls: usize = 0,
        cancel_calls: usize = 0,
        dispatch_calls: usize = 0,

        fn readEvents(self: *@This()) usize {
            self.read_calls += 1;
            return 0;
        }

        fn cancelRead(self: *@This()) void {
            self.cancel_calls += 1;
        }

        fn dispatchPending(self: *@This()) usize {
            self.dispatch_calls += 1;
            return 0;
        }
    };

    var display = FakeDisplay{};
    completeWaylandRead(&display, false, false);

    try std.testing.expectEqual(@as(usize, 0), display.read_calls);
    try std.testing.expectEqual(@as(usize, 0), display.cancel_calls);
    try std.testing.expectEqual(@as(usize, 1), display.dispatch_calls);
}

test "event loop redraws only when terminal or window state changed" {