6a5f175f
Refine selection span semantics
a73x 2026-04-09 17:47
Commit message
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" { |