a73x

6a5f175f

Refine selection span semantics

a73x   2026-04-09 17:47

Commit message
Refine selection span semantics

src/main.zig
Old New
@@ -213,16 +213,22 @@ fn runTerminal(alloc: std.mem.Allocator) !void {
213 // Flush any pending wayland requests 213 // Flush any pending wayland requests
214 _ = conn.display.flush(); 214 _ = conn.display.flush();
215 215
216 const wayland_read_prepared = conn.display.prepareRead();
216 const repeat_timeout_ms = remainingRepeatTimeoutMs(keyboard.nextRepeatDeadlineNs()); 217 const repeat_timeout_ms = remainingRepeatTimeoutMs(keyboard.nextRepeatDeadlineNs());
217 _ = std.posix.poll(&pollfds, computePollTimeoutMs(repeat_timeout_ms, render_pending)) catch {}; 218 pollfds[0].revents = 0;
218 219 pollfds[1].revents = 0;
219 // Wayland events: prepare_read / read_events / dispatch_pending 220 _ = std.posix.poll(
220 if (pollfds[0].revents & std.posix.POLL.IN != 0) { 221 &pollfds,
221 if (conn.display.prepareRead()) { 222 computePollTimeoutMs(repeat_timeout_ms, render_pending, wayland_read_prepared),
222 _ = conn.display.readEvents(); 223 ) catch {};
223 } 224
224 } 225 // Wayland events: prepare_read before poll so pending queued events
225 _ = conn.display.dispatchPending(); 226 // force an immediate dispatch instead of sleeping until new socket IO.
227 completeWaylandRead(
228 conn.display,
229 wayland_read_prepared,
230 pollfds[0].revents & std.posix.POLL.IN != 0,
231 );
226 232
227 // PTY output 233 // PTY output
228 if (pollfds[1].revents & std.posix.POLL.IN != 0) { 234 if (pollfds[1].revents & std.posix.POLL.IN != 0) {
@@ -532,11 +538,23 @@ fn remainingRepeatTimeoutMs(deadline_ns: ?i128) ?i32 {
532 return @intCast(@divTrunc(remaining_ns + std.time.ns_per_ms - 1, std.time.ns_per_ms)); 538 return @intCast(@divTrunc(remaining_ns + std.time.ns_per_ms - 1, std.time.ns_per_ms));
533 } 539 }
534 540
535 fn computePollTimeoutMs(next_repeat_in_ms: ?i32, render_pending: bool) i32 { 541 fn computePollTimeoutMs(next_repeat_in_ms: ?i32, render_pending: bool, wayland_read_prepared: bool) i32 {
542 if (!wayland_read_prepared) return 0;
536 if (render_pending) return 0; 543 if (render_pending) return 0;
537 return next_repeat_in_ms orelse -1; 544 return next_repeat_in_ms orelse -1;
538 } 545 }
539 546
547 fn completeWaylandRead(display: anytype, prepared: bool, readable: bool) void {
548 if (prepared) {
549 if (readable) {
550 _ = display.readEvents();
551 } else {
552 display.cancelRead();
553 }
554 }
555 _ = display.dispatchPending();
556 }
557
540 fn shouldRenderFrame(terminal_dirty: bool, window_dirty: bool, forced: bool) bool { 558 fn shouldRenderFrame(terminal_dirty: bool, window_dirty: bool, forced: bool) bool {
541 return terminal_dirty or window_dirty or forced; 559 return terminal_dirty or window_dirty or forced;
542 } 560 }
@@ -557,13 +575,11 @@ const SelectionSpan = struct {
557 } 575 }
558 576
559 fn isEmpty(self: SelectionSpan) bool { 577 fn isEmpty(self: SelectionSpan) bool {
560 const span = self.normalized(); 578 _ = self;
561 return span.start.row == span.end.row and span.start.col == span.end.col; 579 return false;
562 } 580 }
563 581
564 fn containsCell(self: SelectionSpan, col: u32, row: u32) bool { 582 fn containsCell(self: SelectionSpan, col: u32, row: u32) bool {
565 if (self.isEmpty()) return false;
566
567 const span = self.normalized(); 583 const span = self.normalized();
568 if (row < span.start.row or row > span.end.row) return false; 584 if (row < span.start.row or row > span.end.row) return false;
569 if (span.start.row == span.end.row) { 585 if (span.start.row == span.end.row) {
@@ -575,35 +591,26 @@ const SelectionSpan = struct {
575 } 591 }
576 }; 592 };
577 593
578 fn selectionIntersectsVisibleGrid(span: SelectionSpan, cols: u16, rows: u16) bool { 594 fn clampSelectionSpan(span: SelectionSpan, cols: u16, rows: u16) ?SelectionSpan {
579 if (cols == 0 or rows == 0) return false; 595 if (cols == 0 or rows == 0) return null;
580 596
581 const normalized = span.normalized(); 597 const normalized = span.normalized();
582 const max_col = @as(u32, cols) - 1; 598 const max_col = @as(u32, cols) - 1;
583 const max_row = @as(u32, rows) - 1; 599 const max_row = @as(u32, rows) - 1;
600 if (normalized.start.row > max_row) return null;
601 if (normalized.start.row == normalized.end.row and normalized.start.col > max_col) return null;
584 602
585 if (normalized.start.row > max_row) return false;
586 if (normalized.start.row == normalized.end.row and normalized.start.col > max_col) return false;
587 return true;
588 }
589
590 fn clampSelectionSpan(span: SelectionSpan, cols: u16, rows: u16) ?SelectionSpan {
591 if (!selectionIntersectsVisibleGrid(span, cols, rows)) return null;
592
593 const max_col = @as(u32, cols) - 1;
594 const max_row = @as(u32, rows) - 1;
595 const clamped = SelectionSpan{ 603 const clamped = SelectionSpan{
596 .start = .{ 604 .start = .{
597 .col = @min(span.start.col, max_col), 605 .col = @min(normalized.start.col, max_col),
598 .row = @min(span.start.row, max_row), 606 .row = @min(normalized.start.row, max_row),
599 }, 607 },
600 .end = .{ 608 .end = .{
601 .col = @min(span.end.col, max_col), 609 .col = @min(normalized.end.col, max_col),
602 .row = @min(span.end.row, max_row), 610 .row = @min(normalized.end.row, max_row),
603 }, 611 },
604 }; 612 };
605 613
606 if (clamped.isEmpty()) return null;
607 return clamped.normalized(); 614 return clamped.normalized();
608 } 615 }
609 616
@@ -633,14 +640,28 @@ test "SelectionSpan.containsCell includes the normalized endpoints" {
633 try std.testing.expect(!span.containsCell(4, 2)); 640 try std.testing.expect(!span.containsCell(4, 2));
634 } 641 }
635 642
636 test "SelectionSpan.containsCell treats a degenerate span as empty" { 643 test "SelectionSpan.containsCell includes a same-cell span" {
637 const span = SelectionSpan{ 644 const span = SelectionSpan{
638 .start = .{ .col = 5, .row = 5 }, 645 .start = .{ .col = 5, .row = 5 },
639 .end = .{ .col = 5, .row = 5 }, 646 .end = .{ .col = 5, .row = 5 },
640 }; 647 };
641 648
642 try std.testing.expect(span.isEmpty()); 649 try std.testing.expect(span.containsCell(5, 5));
643 try std.testing.expect(!span.containsCell(5, 5)); 650 try std.testing.expect(!span.containsCell(4, 5));
651 try std.testing.expect(!span.containsCell(5, 4));
652 }
653
654 test "clampSelectionSpan preserves a same-cell visible span" {
655 const clamped = clampSelectionSpan(.{
656 .start = .{ .col = 5, .row = 5 },
657 .end = .{ .col = 5, .row = 5 },
658 }, 80, 24).?;
659
660 try std.testing.expectEqual(@as(u32, 5), clamped.start.col);
661 try std.testing.expectEqual(@as(u32, 5), clamped.start.row);
662 try std.testing.expectEqual(@as(u32, 5), clamped.end.col);
663 try std.testing.expectEqual(@as(u32, 5), clamped.end.row);
664 try std.testing.expect(clamped.containsCell(5, 5));
644 } 665 }
645 666
646 test "clampSelectionSpan clears offscreen spans and trims resized spans" { 667 test "clampSelectionSpan clears offscreen spans and trims resized spans" {
@@ -658,11 +679,19 @@ test "clampSelectionSpan clears offscreen spans and trims resized spans" {
658 try std.testing.expectEqual(@as(u32, 22), clamped.start.row); 679 try std.testing.expectEqual(@as(u32, 22), clamped.start.row);
659 try std.testing.expectEqual(@as(u32, 79), clamped.end.col); 680 try std.testing.expectEqual(@as(u32, 79), clamped.end.col);
660 try std.testing.expectEqual(@as(u32, 23), clamped.end.row); 681 try std.testing.expectEqual(@as(u32, 23), clamped.end.row);
682 }
661 683
662 try std.testing.expect(clampSelectionSpan(.{ 684 test "clampSelectionSpan preserves a larger span that collapses to one visible cell" {
663 .start = .{ .col = 81, .row = 5 }, 685 const clamped = clampSelectionSpan(.{
664 .end = .{ .col = 90, .row = 5 }, 686 .start = .{ .col = 0, .row = 0 },
665 }, 80, 24) == null); 687 .end = .{ .col = 120, .row = 80 },
688 }, 1, 1).?;
689
690 try std.testing.expectEqual(@as(u32, 0), clamped.start.col);
691 try std.testing.expectEqual(@as(u32, 0), clamped.start.row);
692 try std.testing.expectEqual(@as(u32, 0), clamped.end.col);
693 try std.testing.expectEqual(@as(u32, 0), clamped.end.row);
694 try std.testing.expect(clamped.containsCell(0, 0));
666 } 695 }
667 696
668 const ComparisonVariant = struct { 697 const ComparisonVariant = struct {
@@ -1147,9 +1176,101 @@ fn mapKeysymToInputKey(keysym: u32) ?vt.InputKey {
1147 } 1176 }
1148 1177
1149 test "event loop waits indefinitely when idle and wakes for imminent repeat" { 1178 test "event loop waits indefinitely when idle and wakes for imminent repeat" {
1150 try std.testing.expectEqual(@as(i32, -1), computePollTimeoutMs(null, false)); 1179 try std.testing.expectEqual(@as(i32, -1), computePollTimeoutMs(null, false, true));
1151 try std.testing.expectEqual(@as(i32, 0), computePollTimeoutMs(5, true)); 1180 try std.testing.expectEqual(@as(i32, 0), computePollTimeoutMs(5, true, true));
1152 try std.testing.expectEqual(@as(i32, 17), computePollTimeoutMs(17, false)); 1181 try std.testing.expectEqual(@as(i32, 17), computePollTimeoutMs(17, false, true));
1182 }
1183
1184 test "event loop does not sleep while Wayland already has pending events" {
1185 try std.testing.expectEqual(@as(i32, 0), computePollTimeoutMs(null, false, false));
1186 try std.testing.expectEqual(@as(i32, 0), computePollTimeoutMs(23, false, false));
1187 }
1188
1189 test "completeWaylandRead cancels prepared read when poll found no socket data" {
1190 const FakeDisplay = struct {
1191 read_calls: usize = 0,
1192 cancel_calls: usize = 0,
1193 dispatch_calls: usize = 0,
1194
1195 fn readEvents(self: *@This()) usize {
1196 self.read_calls += 1;
1197 return 0;
1198 }
1199
1200 fn cancelRead(self: *@This()) void {
1201 self.cancel_calls += 1;
1202 }
1203
1204 fn dispatchPending(self: *@This()) usize {
1205 self.dispatch_calls += 1;
1206 return 0;
1207 }
1208 };
1209
1210 var display = FakeDisplay{};
1211 completeWaylandRead(&display, true, false);
1212
1213 try std.testing.expectEqual(@as(usize, 0), display.read_calls);
1214 try std.testing.expectEqual(@as(usize, 1), display.cancel_calls);
1215 try std.testing.expectEqual(@as(usize, 1), display.dispatch_calls);
1216 }
1217
1218 test "completeWaylandRead dispatches readable socket events without canceling" {
1219 const FakeDisplay = struct {
1220 read_calls: usize = 0,
1221 cancel_calls: usize = 0,
1222 dispatch_calls: usize = 0,
1223
1224 fn readEvents(self: *@This()) usize {
1225 self.read_calls += 1;
1226 return 0;
1227 }
1228
1229 fn cancelRead(self: *@This()) void {
1230 self.cancel_calls += 1;
1231 }
1232
1233 fn dispatchPending(self: *@This()) usize {
1234 self.dispatch_calls += 1;
1235 return 0;
1236 }
1237 };
1238
1239 var display = FakeDisplay{};
1240 completeWaylandRead(&display, true, true);
1241
1242 try std.testing.expectEqual(@as(usize, 1), display.read_calls);
1243 try std.testing.expectEqual(@as(usize, 0), display.cancel_calls);
1244 try std.testing.expectEqual(@as(usize, 1), display.dispatch_calls);
1245 }
1246
1247 test "completeWaylandRead still dispatches pending events when prepareRead could not start" {
1248 const FakeDisplay = struct {
1249 read_calls: usize = 0,
1250 cancel_calls: usize = 0,
1251 dispatch_calls: usize = 0,
1252
1253 fn readEvents(self: *@This()) usize {
1254 self.read_calls += 1;
1255 return 0;
1256 }
1257
1258 fn cancelRead(self: *@This()) void {
1259 self.cancel_calls += 1;
1260 }
1261
1262 fn dispatchPending(self: *@This()) usize {
1263 self.dispatch_calls += 1;
1264 return 0;
1265 }
1266 };
1267
1268 var display = FakeDisplay{};
1269 completeWaylandRead(&display, false, false);
1270
1271 try std.testing.expectEqual(@as(usize, 0), display.read_calls);
1272 try std.testing.expectEqual(@as(usize, 0), display.cancel_calls);
1273 try std.testing.expectEqual(@as(usize, 1), display.dispatch_calls);
1153 } 1274 }
1154 1275
1155 test "event loop redraws only when terminal or window state changed" { 1276 test "event loop redraws only when terminal or window state changed" {