terminal: PageList split operation

This commit is contained in:
Mitchell Hashimoto
2026-01-18 06:48:49 -08:00
parent f9699eceb0
commit 93a070c6de

View File

@@ -2753,6 +2753,98 @@ pub fn compact(self: *PageList, node: *List.Node) Allocator.Error!?*List.Node {
new_page.assertIntegrity();
return new_node;
}
pub const SplitError = error{
// Allocator OOM
OutOfMemory,
// Page can't be split further because it is already a single row.
OutOfSpace,
};
/// Split the given node in the PageList at the given pin.
///
/// The row at the pin and after will be moved into a new page with
/// the same capacity as the original page. Alternatively, you can "split
/// above" by splitting the row following the desired split row.
///
/// Since the split happens below the pin, the pin remains valid.
pub fn split(
self: *PageList,
p: Pin,
) SplitError!void {
if (build_options.slow_runtime_safety) assert(self.pinIsValid(p));
// Ran into a bug that I can only explain via aliasing. If a tracked
// pin is passed in, its possible Zig will alias the memory and then
// when we modify it later it updates our p here. Coyping the node
// fixes this.
const original_node = p.node;
const page: *Page = &original_node.data;
// A page that is already 1 row can't be split. In the future we can
// theoretically maybe split by soft-wrapping multiple pages but that
// seems crazy and the rest of our PageList can't handle heterogeneously
// sized pages today.
if (page.size.rows <= 1) return error.OutOfSpace;
// Splitting at row 0 is a no-op since there's nothing before the split point.
if (p.y == 0) return;
// At this point we're doing actual modification so make sure
// on the return that we're good.
defer self.assertIntegrity();
// Create a new node with the same capacity of managed memory.
const target = try self.createPage(page.capacity);
errdefer self.destroyNode(target);
// Determine how many rows we're copying
const y_start = p.y;
const y_end = page.size.rows;
target.data.size.rows = y_end - y_start;
assert(target.data.size.rows <= target.data.capacity.rows);
// Copy our old data. This should NOT fail because we have the
// capacity of the old page which already fits the data we requested.
target.data.cloneFrom(page, y_start, y_end) catch |err| {
log.err(
"error cloning rows for split err={}",
.{err},
);
// Rather than crash, we return an OutOfSpace to show that
// we couldn't split and let our callers gracefully handle it.
// Realistically though... this should not happen.
return error.OutOfSpace;
};
// From this point forward there is no going back. We have no
// error handling. It is possible but we haven't written it.
errdefer comptime unreachable;
// Move any tracked pins from the copied rows
for (self.tracked_pins.keys()) |tracked| {
if (&tracked.node.data != page or
tracked.y < p.y) continue;
tracked.node = target;
tracked.y -= p.y;
// p.x remains the same since we're copying the row as-is
}
// Clear our rows
for (page.rows.ptr(page.memory)[y_start..y_end]) |*row| {
page.clearCells(
row,
0,
page.size.cols,
);
}
page.size.rows -= y_end - y_start;
self.pages.insertAfter(original_node, target);
}
/// This represents the state necessary to render a scrollbar for this
/// PageList. It has the total size, the offset, and the size of the viewport.
pub const Scrollbar = struct {
@@ -12013,3 +12105,596 @@ test "PageList compact insufficient savings returns null" {
}
}
}
test "PageList split at middle row" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 10, 10, 0);
defer s.deinit();
const page = &s.pages.first.?.data;
// Write content to rows: row 0 gets codepoint 0, row 1 gets 1, etc.
for (0..page.size.rows) |y| {
const rac = page.getRowAndCell(0, y);
rac.cell.* = .{
.content_tag = .codepoint,
.content = .{ .codepoint = @intCast(y) },
};
}
// Split at row 5 (middle)
const split_pin: Pin = .{ .node = s.pages.first.?, .y = 5, .x = 0 };
try s.split(split_pin);
// Verify two pages exist
try testing.expect(s.pages.first != null);
try testing.expect(s.pages.first.?.next != null);
const first_page = &s.pages.first.?.data;
const second_page = &s.pages.first.?.next.?.data;
// First page should have rows 0-4 (5 rows)
try testing.expectEqual(@as(usize, 5), first_page.size.rows);
// Second page should have rows 5-9 (5 rows)
try testing.expectEqual(@as(usize, 5), second_page.size.rows);
// Verify content in first page is preserved (rows 0-4 have codepoints 0-4)
for (0..5) |y| {
const rac = first_page.getRowAndCell(0, y);
try testing.expectEqual(@as(u21, @intCast(y)), rac.cell.content.codepoint);
}
// Verify content in second page (original rows 5-9, now at y=0-4)
for (0..5) |y| {
const rac = second_page.getRowAndCell(0, y);
try testing.expectEqual(@as(u21, @intCast(y + 5)), rac.cell.content.codepoint);
}
}
test "PageList split at row 0 is no-op" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 10, 10, 0);
defer s.deinit();
const page = &s.pages.first.?.data;
// Write content to all rows
for (0..page.size.rows) |y| {
const rac = page.getRowAndCell(0, y);
rac.cell.* = .{
.content_tag = .codepoint,
.content = .{ .codepoint = @intCast(y) },
};
}
// Split at row 0 should be a no-op
const split_pin: Pin = .{ .node = s.pages.first.?, .y = 0, .x = 0 };
try s.split(split_pin);
// Verify only one page exists (no split occurred)
try testing.expect(s.pages.first != null);
try testing.expect(s.pages.first.?.next == null);
// Verify all content is still in the original page
try testing.expectEqual(@as(usize, 10), page.size.rows);
for (0..10) |y| {
const rac = page.getRowAndCell(0, y);
try testing.expectEqual(@as(u21, @intCast(y)), rac.cell.content.codepoint);
}
}
test "PageList split at last row" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 10, 10, 0);
defer s.deinit();
const page = &s.pages.first.?.data;
// Write content to all rows
for (0..page.size.rows) |y| {
const rac = page.getRowAndCell(0, y);
rac.cell.* = .{
.content_tag = .codepoint,
.content = .{ .codepoint = @intCast(y) },
};
}
// Split at last row (row 9)
const split_pin: Pin = .{ .node = s.pages.first.?, .y = 9, .x = 0 };
try s.split(split_pin);
// Verify two pages exist
try testing.expect(s.pages.first != null);
try testing.expect(s.pages.first.?.next != null);
const first_page = &s.pages.first.?.data;
const second_page = &s.pages.first.?.next.?.data;
// First page should have 9 rows
try testing.expectEqual(@as(usize, 9), first_page.size.rows);
// Second page should have 1 row
try testing.expectEqual(@as(usize, 1), second_page.size.rows);
// Verify content in second page (original row 9, now at y=0)
const rac = second_page.getRowAndCell(0, 0);
try testing.expectEqual(@as(u21, 9), rac.cell.content.codepoint);
}
test "PageList split single row page returns OutOfSpace" {
const testing = std.testing;
const alloc = testing.allocator;
// Initialize with 1 row
var s = try init(alloc, 10, 1, 0);
defer s.deinit();
const split_pin: Pin = .{ .node = s.pages.first.?, .y = 0, .x = 0 };
const result = s.split(split_pin);
try testing.expectError(error.OutOfSpace, result);
}
test "PageList split moves tracked pins" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 10, 10, 0);
defer s.deinit();
// Track a pin at row 7
const tracked = try s.trackPin(.{ .node = s.pages.first.?, .y = 7, .x = 3 });
defer s.untrackPin(tracked);
// Split at row 5
const split_pin: Pin = .{ .node = s.pages.first.?, .y = 5, .x = 0 };
try s.split(split_pin);
// The tracked pin should now be in the second page
try testing.expect(tracked.node == s.pages.first.?.next.?);
// y should be adjusted: was 7, split at 5, so new y = 7 - 5 = 2
try testing.expectEqual(@as(usize, 2), tracked.y);
// x should remain unchanged
try testing.expectEqual(@as(usize, 3), tracked.x);
}
test "PageList split tracked pin before split point unchanged" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 10, 10, 0);
defer s.deinit();
const original_node = s.pages.first.?;
// Track a pin at row 2 (before the split point)
const tracked = try s.trackPin(.{ .node = original_node, .y = 2, .x = 5 });
defer s.untrackPin(tracked);
// Split at row 5
const split_pin: Pin = .{ .node = original_node, .y = 5, .x = 0 };
try s.split(split_pin);
// The tracked pin should remain in the original page
try testing.expect(tracked.node == s.pages.first.?);
// y and x should be unchanged
try testing.expectEqual(@as(usize, 2), tracked.y);
try testing.expectEqual(@as(usize, 5), tracked.x);
}
test "PageList split tracked pin at split point moves to new page" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 10, 10, 0);
defer s.deinit();
const original_node = s.pages.first.?;
// Track a pin at the exact split point (row 5)
const tracked = try s.trackPin(.{ .node = original_node, .y = 5, .x = 4 });
defer s.untrackPin(tracked);
// Split at row 5
const split_pin: Pin = .{ .node = original_node, .y = 5, .x = 0 };
try s.split(split_pin);
// The tracked pin should be in the new page
try testing.expect(tracked.node == s.pages.first.?.next.?);
// y should be 0 since it was at the split point: 5 - 5 = 0
try testing.expectEqual(@as(usize, 0), tracked.y);
// x should remain unchanged
try testing.expectEqual(@as(usize, 4), tracked.x);
}
test "PageList split multiple tracked pins across regions" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 10, 10, 0);
defer s.deinit();
const original_node = s.pages.first.?;
// Track multiple pins in different regions
const pin_before = try s.trackPin(.{ .node = original_node, .y = 1, .x = 0 });
defer s.untrackPin(pin_before);
const pin_at_split = try s.trackPin(.{ .node = original_node, .y = 5, .x = 2 });
defer s.untrackPin(pin_at_split);
const pin_after1 = try s.trackPin(.{ .node = original_node, .y = 7, .x = 3 });
defer s.untrackPin(pin_after1);
const pin_after2 = try s.trackPin(.{ .node = original_node, .y = 9, .x = 8 });
defer s.untrackPin(pin_after2);
// Split at row 5
const split_pin: Pin = .{ .node = original_node, .y = 5, .x = 0 };
try s.split(split_pin);
const first_page = s.pages.first.?;
const second_page = first_page.next.?;
// Pin before split point stays in original page
try testing.expect(pin_before.node == first_page);
try testing.expectEqual(@as(usize, 1), pin_before.y);
try testing.expectEqual(@as(usize, 0), pin_before.x);
// Pin at split point moves to new page with y=0
try testing.expect(pin_at_split.node == second_page);
try testing.expectEqual(@as(usize, 0), pin_at_split.y);
try testing.expectEqual(@as(usize, 2), pin_at_split.x);
// Pins after split point move to new page with adjusted y
try testing.expect(pin_after1.node == second_page);
try testing.expectEqual(@as(usize, 2), pin_after1.y); // 7 - 5 = 2
try testing.expectEqual(@as(usize, 3), pin_after1.x);
try testing.expect(pin_after2.node == second_page);
try testing.expectEqual(@as(usize, 4), pin_after2.y); // 9 - 5 = 4
try testing.expectEqual(@as(usize, 8), pin_after2.x);
}
test "PageList split tracked viewport_pin in split region moves correctly" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 10, 10, 0);
defer s.deinit();
const original_node = s.pages.first.?;
// Set viewport_pin to row 7 (after split point)
s.viewport_pin.node = original_node;
s.viewport_pin.y = 7;
s.viewport_pin.x = 6;
// Split at row 5
const split_pin: Pin = .{ .node = original_node, .y = 5, .x = 0 };
try s.split(split_pin);
// viewport_pin should be in the new page
try testing.expect(s.viewport_pin.node == s.pages.first.?.next.?);
// y should be adjusted: 7 - 5 = 2
try testing.expectEqual(@as(usize, 2), s.viewport_pin.y);
// x should remain unchanged
try testing.expectEqual(@as(usize, 6), s.viewport_pin.x);
}
test "PageList split middle page preserves linked list order" {
const testing = std.testing;
const alloc = testing.allocator;
// Create a single page with 12 rows
var s = try init(alloc, 10, 12, 0);
defer s.deinit();
// Split at row 4 to create: page1 (rows 0-3), page2 (rows 4-11)
const first_node = s.pages.first.?;
const split_pin1: Pin = .{ .node = first_node, .y = 4, .x = 0 };
try s.split(split_pin1);
// Now we have 2 pages
const page1 = s.pages.first.?;
const page2 = s.pages.first.?.next.?;
try testing.expectEqual(@as(usize, 4), page1.data.size.rows);
try testing.expectEqual(@as(usize, 8), page2.data.size.rows);
// Split page2 at row 4 to create: page1 -> page2 (rows 0-3) -> page3 (rows 4-7)
const split_pin2: Pin = .{ .node = page2, .y = 4, .x = 0 };
try s.split(split_pin2);
// Now we have 3 pages
const first = s.pages.first.?;
const middle = first.next.?;
const last = middle.next.?;
// Verify linked list order: first -> middle -> last
try testing.expectEqual(page1, first);
try testing.expectEqual(page2, middle);
try testing.expectEqual(s.pages.last.?, last);
// Verify prev pointers
try testing.expect(first.prev == null);
try testing.expectEqual(first, middle.prev.?);
try testing.expectEqual(middle, last.prev.?);
// Verify next pointers
try testing.expectEqual(middle, first.next.?);
try testing.expectEqual(last, middle.next.?);
try testing.expect(last.next == null);
// Verify row counts
try testing.expectEqual(@as(usize, 4), first.data.size.rows);
try testing.expectEqual(@as(usize, 4), middle.data.size.rows);
try testing.expectEqual(@as(usize, 4), last.data.size.rows);
}
test "PageList split last page makes new page the last" {
const testing = std.testing;
const alloc = testing.allocator;
// Create a single page with 10 rows
var s = try init(alloc, 10, 10, 0);
defer s.deinit();
// Split to create 2 pages first
const first_node = s.pages.first.?;
const split_pin1: Pin = .{ .node = first_node, .y = 5, .x = 0 };
try s.split(split_pin1);
// Now split the last page
const last_before_split = s.pages.last.?;
try testing.expectEqual(@as(usize, 5), last_before_split.data.size.rows);
const split_pin2: Pin = .{ .node = last_before_split, .y = 2, .x = 0 };
try s.split(split_pin2);
// The new page should be the new last
const new_last = s.pages.last.?;
try testing.expect(new_last != last_before_split);
try testing.expectEqual(last_before_split, new_last.prev.?);
try testing.expect(new_last.next == null);
// Verify row counts: original last has 2 rows, new last has 3 rows
try testing.expectEqual(@as(usize, 2), last_before_split.data.size.rows);
try testing.expectEqual(@as(usize, 3), new_last.data.size.rows);
}
test "PageList split first page keeps original as first" {
const testing = std.testing;
const alloc = testing.allocator;
// Create 2 pages by splitting
var s = try init(alloc, 10, 10, 0);
defer s.deinit();
const original_first = s.pages.first.?;
const split_pin1: Pin = .{ .node = original_first, .y = 5, .x = 0 };
try s.split(split_pin1);
// Get second page (created by first split)
const second_page = s.pages.first.?.next.?;
// Now split the first page again
const split_pin2: Pin = .{ .node = s.pages.first.?, .y = 2, .x = 0 };
try s.split(split_pin2);
// Original first should still be first
try testing.expectEqual(original_first, s.pages.first.?);
try testing.expect(s.pages.first.?.prev == null);
// New page should be inserted between first and second
const inserted = s.pages.first.?.next.?;
try testing.expect(inserted != second_page);
try testing.expectEqual(second_page, inserted.next.?);
// Verify row counts: first has 2, inserted has 3, second has 5
try testing.expectEqual(@as(usize, 2), s.pages.first.?.data.size.rows);
try testing.expectEqual(@as(usize, 3), inserted.data.size.rows);
try testing.expectEqual(@as(usize, 5), second_page.data.size.rows);
}
test "PageList split preserves wrap flags" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 10, 10, 0);
defer s.deinit();
const page = &s.pages.first.?.data;
// Set wrap flags on rows that will be in the second page after split
// Row 5: wrap = true (this is the start of a wrapped line)
// Row 6: wrap_continuation = true (this continues the wrap)
// Row 7: wrap = true, wrap_continuation = true (wrapped and continues)
{
const rac5 = page.getRowAndCell(0, 5);
rac5.row.wrap = true;
const rac6 = page.getRowAndCell(0, 6);
rac6.row.wrap_continuation = true;
const rac7 = page.getRowAndCell(0, 7);
rac7.row.wrap = true;
rac7.row.wrap_continuation = true;
}
// Split at row 5
const split_pin: Pin = .{ .node = s.pages.first.?, .y = 5, .x = 0 };
try s.split(split_pin);
const second_page = &s.pages.first.?.next.?.data;
// Verify wrap flags are preserved in new page
// Original row 5 is now row 0 in second page
{
const rac0 = second_page.getRowAndCell(0, 0);
try testing.expect(rac0.row.wrap);
try testing.expect(!rac0.row.wrap_continuation);
}
// Original row 6 is now row 1 in second page
{
const rac1 = second_page.getRowAndCell(0, 1);
try testing.expect(!rac1.row.wrap);
try testing.expect(rac1.row.wrap_continuation);
}
// Original row 7 is now row 2 in second page
{
const rac2 = second_page.getRowAndCell(0, 2);
try testing.expect(rac2.row.wrap);
try testing.expect(rac2.row.wrap_continuation);
}
}
test "PageList split preserves styled cells" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 10, 10, 0);
defer s.deinit();
const page = &s.pages.first.?.data;
// Create a style and apply it to cells in rows 5-7 (which will be in the second page)
const style: stylepkg.Style = .{ .flags = .{ .bold = true } };
const style_id = try page.styles.add(page.memory, style);
for (5..8) |y| {
const rac = page.getRowAndCell(0, y);
rac.cell.* = .{
.content_tag = .codepoint,
.content = .{ .codepoint = 'S' },
.style_id = style_id,
};
rac.row.styled = true;
page.styles.use(page.memory, style_id);
}
// Release the extra ref from add
page.styles.release(page.memory, style_id);
// Split at row 5
const split_pin: Pin = .{ .node = s.pages.first.?, .y = 5, .x = 0 };
try s.split(split_pin);
const first_page = &s.pages.first.?.data;
const second_page = &s.pages.first.?.next.?.data;
// First page should have no styles (all styled rows moved to second page)
try testing.expectEqual(@as(usize, 0), first_page.styles.count());
// Second page should have exactly 1 style (the bold style, used by 3 cells)
try testing.expectEqual(@as(usize, 1), second_page.styles.count());
// Verify styled cells are preserved in new page
for (0..3) |y| {
const rac = second_page.getRowAndCell(0, y);
try testing.expectEqual(@as(u21, 'S'), rac.cell.content.codepoint);
try testing.expect(rac.cell.style_id != 0);
const got_style = second_page.styles.get(second_page.memory, rac.cell.style_id);
try testing.expect(got_style.flags.bold);
try testing.expect(rac.row.styled);
}
}
test "PageList split preserves grapheme clusters" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 10, 10, 0);
defer s.deinit();
const page = &s.pages.first.?.data;
// Add a grapheme cluster to row 6 (will be row 1 in second page after split at 5)
{
const rac = page.getRowAndCell(0, 6);
rac.cell.* = .{
.content_tag = .codepoint,
.content = .{ .codepoint = 0x1F468 }, // Man emoji
};
try page.setGraphemes(rac.row, rac.cell, &.{
0x200D, // ZWJ
0x1F469, // Woman emoji
});
}
// Split at row 5
const split_pin: Pin = .{ .node = s.pages.first.?, .y = 5, .x = 0 };
try s.split(split_pin);
const first_page = &s.pages.first.?.data;
const second_page = &s.pages.first.?.next.?.data;
// First page should have no graphemes (the grapheme row moved to second page)
try testing.expectEqual(@as(usize, 0), first_page.graphemeCount());
// Second page should have exactly 1 grapheme
try testing.expectEqual(@as(usize, 1), second_page.graphemeCount());
// Verify grapheme is preserved in new page (original row 6 is now row 1)
{
const rac = second_page.getRowAndCell(0, 1);
try testing.expectEqual(@as(u21, 0x1F468), rac.cell.content.codepoint);
try testing.expect(rac.row.grapheme);
const cps = second_page.lookupGrapheme(rac.cell).?;
try testing.expectEqual(@as(usize, 2), cps.len);
try testing.expectEqual(@as(u21, 0x200D), cps[0]);
try testing.expectEqual(@as(u21, 0x1F469), cps[1]);
}
}
test "PageList split preserves hyperlinks" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 10, 10, 0);
defer s.deinit();
const page = &s.pages.first.?.data;
// Add a hyperlink to row 7 (will be row 2 in second page after split at 5)
const hyperlink_id = try page.insertHyperlink(.{
.id = .{ .implicit = 0 },
.uri = "https://example.com",
});
{
const rac = page.getRowAndCell(0, 7);
rac.cell.* = .{
.content_tag = .codepoint,
.content = .{ .codepoint = 'L' },
};
try page.setHyperlink(rac.row, rac.cell, hyperlink_id);
}
// Split at row 5
const split_pin: Pin = .{ .node = s.pages.first.?, .y = 5, .x = 0 };
try s.split(split_pin);
const first_page = &s.pages.first.?.data;
const second_page = &s.pages.first.?.next.?.data;
// First page should have no hyperlinks (the hyperlink row moved to second page)
try testing.expectEqual(@as(usize, 0), first_page.hyperlink_set.count());
// Second page should have exactly 1 hyperlink
try testing.expectEqual(@as(usize, 1), second_page.hyperlink_set.count());
// Verify hyperlink is preserved in new page (original row 7 is now row 2)
{
const rac = second_page.getRowAndCell(0, 2);
try testing.expectEqual(@as(u21, 'L'), rac.cell.content.codepoint);
try testing.expect(rac.cell.hyperlink);
const link_id = second_page.lookupHyperlink(rac.cell).?;
const link = second_page.hyperlink_set.get(second_page.memory, link_id);
try testing.expectEqualStrings("https://example.com", link.uri.slice(second_page.memory));
}
}