mirror of
https://github.com/ghostty-org/ghostty.git
synced 2026-04-17 21:12:39 +00:00
terminal: page split and compact operations, more robust OutOfSpace handling (#10383)
Fixes #10357 This updates `manualStyleUpdate` in `Screen` to perform a page split if we get an `OutOfSpace` error. A page split will choose the half that has less used capacity and split to that side, hoping we'll now have space for adding our style. Previously, we'd just raise this error up and not do any splitting. Callers of `manualStyleUpdate` include `setAttribute` amongst many others. So a significant part of libghostty is now resilient to OutOfSpace. This also updates `restoreCursor` to no longer ever fail. If style updates fail for restore cursor, we revert back to a default (unstyled) cursor. This is important because terminal programs that send a restore cursor sequence have no way to understand the restoration failed, and its more important that we restored as much as possible (position and so on) then restore everything in it. ## Internals This adds two new PageList operations: * `split` - Splits a page at a given row, moving rows at and after the split point to a new page. Used when a page runs out of capacity and we need to split it to continue operations. * `compact` - Compacts a page to use the minimum required memory. Computes exact capacity needed for page contents and creates a smaller page if meaningful savings are possible. Clones the data into it, updating all metadata like tracked pins. **This isn't used yet! But it is well tested.** And a supporting Page operation: * `Page.exactRowCapacity` - Computes the exact capacity required to store a range of rows from a page by counting unique styles, hyperlinks, grapheme bytes, and string bytes. This takes into account load factors and so on. ## Weaknesses * `manualStyleUpdate` only splits once. For maximum robustness we should probably recursively split to try to make space until we're down to a 1 row page. I didn't want to do this without compaction implemented though cause pathological cases could cause crazy memory blow-up. * `split` duplicates the capacity, effectively doubling memory when it happens (wasted capacity). I think `compact` calling should be done somewhere, which is why I implemented it, but didn't want to integrate too much in one PR so we can get some real world testing... * `compact` can't go smaller than `std_size` due to the way PageList works. We may want to consider making a MUCH smaller `std_size` and leaning in to more non standard pages. ## AI Disclosure I used Amp a lot to help in every aspect of this work. I reviewed every line written including tests and did significant manual modification too.
This commit is contained in:
@@ -2685,6 +2685,166 @@ pub fn scrollClear(self: *PageList) !void {
|
||||
for (0..non_empty) |_| _ = try self.grow();
|
||||
}
|
||||
|
||||
/// Compact a page to use the minimum required memory for the contents
|
||||
/// it stores. Returns the new node pointer if compaction occurred, or null
|
||||
/// if the page was already compact or compaction would not provide meaningful
|
||||
/// savings.
|
||||
///
|
||||
/// The current design of PageList at the time of writing this doesn't
|
||||
/// allow for smaller than `std_size` nodes so if the current node's backing
|
||||
/// page is standard size or smaller, no compaction will occur. In the
|
||||
/// future we should fix this up.
|
||||
///
|
||||
/// If this returns OOM, the PageList is left unchanged and no dangling
|
||||
/// memory references exist. It is safe to ignore the error and continue using
|
||||
/// the uncompacted page.
|
||||
pub fn compact(self: *PageList, node: *List.Node) Allocator.Error!?*List.Node {
|
||||
defer self.assertIntegrity();
|
||||
const page: *Page = &node.data;
|
||||
|
||||
// We should never have empty rows in our pagelist anyways...
|
||||
assert(page.size.rows > 0);
|
||||
|
||||
// We never compact standard size or smaller pages because changing
|
||||
// the capacity to something smaller won't save memory.
|
||||
if (page.memory.len <= std_size) return null;
|
||||
|
||||
// Compute the minimum capacity required for this page's content
|
||||
const req_cap = page.exactRowCapacity(0, page.size.rows);
|
||||
const new_size = Page.layout(req_cap).total_size;
|
||||
const old_size = page.memory.len;
|
||||
if (new_size >= old_size) return null;
|
||||
|
||||
// Create the new smaller page
|
||||
const new_node = try self.createPage(req_cap);
|
||||
errdefer self.destroyNode(new_node);
|
||||
const new_page: *Page = &new_node.data;
|
||||
new_page.size = page.size;
|
||||
new_page.dirty = page.dirty;
|
||||
new_page.cloneFrom(
|
||||
page,
|
||||
0,
|
||||
page.size.rows,
|
||||
) catch |err| {
|
||||
// cloneFrom should not fail when compacting since req_cap is
|
||||
// computed to exactly fit the source content and our expectation
|
||||
// of exactRowCapacity ensures it can fit all the requested
|
||||
// data.
|
||||
log.err("compact clone failed err={}", .{err});
|
||||
|
||||
// In this case, let's gracefully degrade by pretending we
|
||||
// didn't need to compact.
|
||||
self.destroyNode(new_node);
|
||||
return null;
|
||||
};
|
||||
|
||||
// Fix up all tracked pins to point to the new page
|
||||
const pin_keys = self.tracked_pins.keys();
|
||||
for (pin_keys) |p| {
|
||||
if (p.node != node) continue;
|
||||
p.node = new_node;
|
||||
}
|
||||
|
||||
// Insert the new page and destroy the old one
|
||||
self.pages.insertBefore(node, new_node);
|
||||
self.pages.remove(node);
|
||||
self.destroyNode(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. Copying 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 {
|
||||
@@ -11811,3 +11971,730 @@ test "PageList resize (no reflow) more cols remaps pins in backfill path" {
|
||||
try testing.expectEqual(.codepoint, cell.content_tag);
|
||||
try testing.expectEqual(marker, cell.content.codepoint);
|
||||
}
|
||||
|
||||
test "PageList compact std_size page returns null" {
|
||||
const testing = std.testing;
|
||||
const alloc = testing.allocator;
|
||||
|
||||
var s = try init(alloc, 80, 24, 0);
|
||||
defer s.deinit();
|
||||
|
||||
// A freshly created page should be at std_size
|
||||
const node = s.pages.first.?;
|
||||
try testing.expect(node.data.memory.len <= std_size);
|
||||
|
||||
// compact should return null since there's nothing to compact
|
||||
const result = try s.compact(node);
|
||||
try testing.expectEqual(null, result);
|
||||
|
||||
// Page should still be the same
|
||||
try testing.expectEqual(node, s.pages.first.?);
|
||||
}
|
||||
|
||||
test "PageList compact oversized page" {
|
||||
const testing = std.testing;
|
||||
const alloc = testing.allocator;
|
||||
|
||||
var s = try init(alloc, 80, 24, null);
|
||||
defer s.deinit();
|
||||
|
||||
// Grow until we have multiple pages
|
||||
const page1_node = s.pages.first.?;
|
||||
page1_node.data.pauseIntegrityChecks(true);
|
||||
for (0..page1_node.data.capacity.rows - page1_node.data.size.rows) |_| {
|
||||
_ = try s.grow();
|
||||
}
|
||||
page1_node.data.pauseIntegrityChecks(false);
|
||||
_ = try s.grow();
|
||||
try testing.expect(s.pages.first != s.pages.last);
|
||||
|
||||
var node = s.pages.first.?;
|
||||
|
||||
// Write content to verify it's preserved
|
||||
{
|
||||
const page = &node.data;
|
||||
for (0..page.size.rows) |y| {
|
||||
for (0..s.cols) |x| {
|
||||
const rac = page.getRowAndCell(x, y);
|
||||
rac.cell.* = .{
|
||||
.content_tag = .codepoint,
|
||||
.content = .{ .codepoint = @intCast(x + y * s.cols) },
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create a tracked pin on this page
|
||||
const tracked = try s.trackPin(.{ .node = node, .x = 5, .y = 10 });
|
||||
defer s.untrackPin(tracked);
|
||||
|
||||
// Make the page oversized
|
||||
while (node.data.memory.len <= std_size) {
|
||||
node = try s.increaseCapacity(node, .grapheme_bytes);
|
||||
}
|
||||
try testing.expect(node.data.memory.len > std_size);
|
||||
const oversized_len = node.data.memory.len;
|
||||
const original_size = node.data.size;
|
||||
const second_node = node.next.?;
|
||||
|
||||
// Set dirty flag after increaseCapacity
|
||||
node.data.dirty = true;
|
||||
|
||||
// Compact the page
|
||||
const new_node = try s.compact(node);
|
||||
try testing.expect(new_node != null);
|
||||
|
||||
// Verify memory is smaller
|
||||
try testing.expect(new_node.?.data.memory.len < oversized_len);
|
||||
|
||||
// Verify size preserved
|
||||
try testing.expectEqual(original_size.rows, new_node.?.data.size.rows);
|
||||
try testing.expectEqual(original_size.cols, new_node.?.data.size.cols);
|
||||
|
||||
// Verify dirty flag preserved
|
||||
try testing.expect(new_node.?.data.dirty);
|
||||
|
||||
// Verify linked list integrity
|
||||
try testing.expectEqual(new_node.?, s.pages.first.?);
|
||||
try testing.expectEqual(null, new_node.?.prev);
|
||||
try testing.expectEqual(second_node, new_node.?.next);
|
||||
try testing.expectEqual(new_node.?, second_node.prev);
|
||||
|
||||
// Verify pin updated correctly
|
||||
try testing.expectEqual(new_node.?, tracked.node);
|
||||
try testing.expectEqual(@as(size.CellCountInt, 5), tracked.x);
|
||||
try testing.expectEqual(@as(size.CellCountInt, 10), tracked.y);
|
||||
|
||||
// Verify content preserved
|
||||
const page = &new_node.?.data;
|
||||
for (0..page.size.rows) |y| {
|
||||
for (0..s.cols) |x| {
|
||||
const rac = page.getRowAndCell(x, y);
|
||||
try testing.expectEqual(
|
||||
@as(u21, @intCast(x + y * s.cols)),
|
||||
rac.cell.content.codepoint,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
test "PageList compact insufficient savings returns null" {
|
||||
const testing = std.testing;
|
||||
const alloc = testing.allocator;
|
||||
|
||||
var s = try init(alloc, 80, 24, 0);
|
||||
defer s.deinit();
|
||||
|
||||
var node = s.pages.first.?;
|
||||
|
||||
// Make the page slightly oversized (just one increase)
|
||||
// This might not provide enough savings to justify compaction
|
||||
node = try s.increaseCapacity(node, .grapheme_bytes);
|
||||
|
||||
// If the page is still at or below std_size, compact returns null
|
||||
if (node.data.memory.len <= std_size) {
|
||||
const result = try s.compact(node);
|
||||
try testing.expectEqual(null, result);
|
||||
} else {
|
||||
// If it did grow beyond std_size, verify that compaction
|
||||
// works or returns null based on savings calculation
|
||||
const result = try s.compact(node);
|
||||
// Either it compacted or determined insufficient savings
|
||||
if (result) |new_node| {
|
||||
try testing.expect(new_node.data.memory.len < node.data.memory.len);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1789,9 +1789,25 @@ fn resizeInternal(
|
||||
|
||||
/// Set a style attribute for the current cursor.
|
||||
///
|
||||
/// This can cause a page split if the current page cannot fit this style.
|
||||
/// This is the only scenario an error return is possible.
|
||||
pub fn setAttribute(self: *Screen, attr: sgr.Attribute) !void {
|
||||
/// If the style can't be set due to any internal errors (memory-related),
|
||||
/// then this will revert back to the existing style and return an error.
|
||||
pub fn setAttribute(
|
||||
self: *Screen,
|
||||
attr: sgr.Attribute,
|
||||
) PageList.IncreaseCapacityError!void {
|
||||
// If we fail to set our style for any reason, we should revert
|
||||
// back to the old style. If we fail to do that, we revert back to
|
||||
// the default style.
|
||||
const old_style = self.cursor.style;
|
||||
errdefer {
|
||||
self.cursor.style = old_style;
|
||||
self.manualStyleUpdate() catch |err| {
|
||||
log.warn("setAttribute error restoring old style after failure err={}", .{err});
|
||||
self.cursor.style = .{};
|
||||
self.manualStyleUpdate() catch unreachable;
|
||||
};
|
||||
}
|
||||
|
||||
switch (attr) {
|
||||
.unset => {
|
||||
self.cursor.style = .{};
|
||||
@@ -1935,14 +1951,18 @@ pub fn setAttribute(self: *Screen, attr: sgr.Attribute) !void {
|
||||
|
||||
/// Call this whenever you manually change the cursor style.
|
||||
///
|
||||
/// Note that this can return any PageList capacity error, because it
|
||||
/// is possible for the internal pagelist to not accommodate the new style
|
||||
/// at all. This WILL attempt to resize our internal pages to fit the style
|
||||
/// but it is possible that it cannot be done, in which case upstream callers
|
||||
/// need to split the page or do something else.
|
||||
/// This function can NOT fail if the cursor style is changing to the
|
||||
/// default style.
|
||||
///
|
||||
/// NOTE(mitchellh): I think in the future we'll do page splitting
|
||||
/// automatically here and remove this failure scenario.
|
||||
/// If this returns an error, the style change did not take effect and
|
||||
/// the cursor style is reverted back to the default. The only scenario
|
||||
/// this returns an error is if there is a physical memory allocation failure
|
||||
/// or if there is no possible way to increase style capacity to store
|
||||
/// the style.
|
||||
///
|
||||
/// This function WILL split pages as necessary to accommodate the new style.
|
||||
/// So if OutOfSpace is returned, it means that even after splitting the page
|
||||
/// there was still no room for the new style.
|
||||
pub fn manualStyleUpdate(self: *Screen) PageList.IncreaseCapacityError!void {
|
||||
defer self.assertIntegrity();
|
||||
var page: *Page = &self.cursor.page_pin.node.data;
|
||||
@@ -1979,13 +1999,22 @@ pub fn manualStyleUpdate(self: *Screen) PageList.IncreaseCapacityError!void {
|
||||
) catch |err| id: {
|
||||
// Our style map is full or needs to be rehashed, so we need to
|
||||
// increase style capacity (or rehash).
|
||||
const node = try self.increaseCapacity(
|
||||
const node = self.increaseCapacity(
|
||||
self.cursor.page_pin.node,
|
||||
switch (err) {
|
||||
error.OutOfMemory => .styles,
|
||||
error.NeedsRehash => null,
|
||||
},
|
||||
);
|
||||
) catch |increase_err| switch (increase_err) {
|
||||
error.OutOfMemory => return error.OutOfMemory,
|
||||
error.OutOfSpace => space: {
|
||||
// Out of space, we need to split the page. Split wherever
|
||||
// is using less capacity and hope that works. If it doesn't
|
||||
// work, we tried.
|
||||
try self.splitForCapacity(self.cursor.page_pin.*);
|
||||
break :space self.cursor.page_pin.node;
|
||||
},
|
||||
};
|
||||
|
||||
page = &node.data;
|
||||
break :id page.styles.add(
|
||||
@@ -2015,6 +2044,62 @@ pub fn manualStyleUpdate(self: *Screen) PageList.IncreaseCapacityError!void {
|
||||
self.cursor.style_id = id;
|
||||
}
|
||||
|
||||
/// Split at the given pin so that the pinned row moves to the page
|
||||
/// with less used capacity after the split.
|
||||
///
|
||||
/// The primary use case for this is to handle IncreaseCapacityError
|
||||
/// OutOfSpace conditions where we need to split the page in order
|
||||
/// to make room for more managed memory.
|
||||
///
|
||||
/// If the caller cares about where the pin moves to, they should
|
||||
/// setup a tracked pin before calling this and then check that.
|
||||
/// In many calling cases, the input pin is tracked (e.g. the cursor
|
||||
/// pin).
|
||||
///
|
||||
/// If this returns OOM then its a system OOM. If this returns OutOfSpace
|
||||
/// then it means the page can't be split further.
|
||||
fn splitForCapacity(
|
||||
self: *Screen,
|
||||
pin: Pin,
|
||||
) PageList.SplitError!void {
|
||||
// Get our capacities. We include our target row because its
|
||||
// capacity will be preserved.
|
||||
const bytes_above = Page.layout(pin.node.data.exactRowCapacity(
|
||||
0,
|
||||
pin.y + 1,
|
||||
)).total_size;
|
||||
const bytes_below = Page.layout(pin.node.data.exactRowCapacity(
|
||||
pin.y,
|
||||
pin.node.data.size.rows,
|
||||
)).total_size;
|
||||
|
||||
// We need to track the old cursor pin because if our split
|
||||
// moves the cursor pin we need to update our accounting.
|
||||
const old_cursor = self.cursor.page_pin.*;
|
||||
|
||||
// If our bytes above are less than bytes below, we move the pin
|
||||
// to split down one since splitting includes the pinned row in
|
||||
// the new node.
|
||||
try self.pages.split(if (bytes_above < bytes_below)
|
||||
pin.down(1) orelse pin
|
||||
else
|
||||
pin);
|
||||
|
||||
// Cursor didn't change nodes, we're done.
|
||||
if (self.cursor.page_pin.node == old_cursor.node) return;
|
||||
|
||||
// Cursor changed, we need to restore the old pin then use
|
||||
// cursorChangePin to move to the new pin. The old node is guaranteed
|
||||
// to still exist, just not the row.
|
||||
//
|
||||
// Note that page_row and all that will be invalid, it points to the
|
||||
// new node, but at the time of writing this we don't need any of that
|
||||
// to be right in cursorChangePin.
|
||||
const new_cursor = self.cursor.page_pin.*;
|
||||
self.cursor.page_pin.* = old_cursor;
|
||||
self.cursorChangePin(new_cursor);
|
||||
}
|
||||
|
||||
/// Append a grapheme to the given cell within the current cursor row.
|
||||
pub fn appendGrapheme(
|
||||
self: *Screen,
|
||||
@@ -9247,3 +9332,124 @@ test "Screen: cursorDown to page with insufficient capacity" {
|
||||
try testing.expect(false);
|
||||
}
|
||||
}
|
||||
|
||||
test "Screen setAttribute increases capacity when style map is full" {
|
||||
// Tests that setAttribute succeeds when the style map is full by
|
||||
// increasing page capacity. When capacity is at max and increaseCapacity
|
||||
// returns OutOfSpace, manualStyleUpdate will split the page instead.
|
||||
const testing = std.testing;
|
||||
const alloc = testing.allocator;
|
||||
|
||||
// Use a small screen with multiple rows
|
||||
var s = try init(alloc, .{ .cols = 10, .rows = 5, .max_scrollback = 10 });
|
||||
defer s.deinit();
|
||||
|
||||
// Write content to multiple rows
|
||||
try s.testWriteString("line1\nline2\nline3\nline4\nline5");
|
||||
|
||||
// Get the page and fill its style map to capacity
|
||||
const page = &s.cursor.page_pin.node.data;
|
||||
const original_styles_capacity = page.capacity.styles;
|
||||
|
||||
// Fill the style map to capacity using the StyleSet's layout capacity
|
||||
// which accounts for the load factor
|
||||
{
|
||||
page.pauseIntegrityChecks(true);
|
||||
defer page.pauseIntegrityChecks(false);
|
||||
defer page.assertIntegrity();
|
||||
|
||||
const max_items = page.styles.layout.cap;
|
||||
var n: usize = 1;
|
||||
while (n < max_items) : (n += 1) {
|
||||
_ = page.styles.add(
|
||||
page.memory,
|
||||
.{ .bg_color = .{ .rgb = @bitCast(@as(u24, @intCast(n))) } },
|
||||
) catch break;
|
||||
}
|
||||
}
|
||||
|
||||
// Now try to set a new unique attribute that would require a new style slot
|
||||
// This should succeed by increasing capacity (or splitting if at max capacity)
|
||||
try s.setAttribute(.bold);
|
||||
|
||||
// The style should have been applied (bold flag set)
|
||||
try testing.expect(s.cursor.style.flags.bold);
|
||||
|
||||
// The cursor should have a valid non-default style_id
|
||||
try testing.expect(s.cursor.style_id != style.default_id);
|
||||
|
||||
// Either the capacity increased or the page was split/changed
|
||||
const current_page = &s.cursor.page_pin.node.data;
|
||||
const capacity_increased = current_page.capacity.styles > original_styles_capacity;
|
||||
const page_changed = current_page != page;
|
||||
try testing.expect(capacity_increased or page_changed);
|
||||
}
|
||||
|
||||
test "Screen setAttribute splits page on OutOfSpace at max styles" {
|
||||
const testing = std.testing;
|
||||
const alloc = testing.allocator;
|
||||
|
||||
var s = try init(alloc, .{
|
||||
.cols = 10,
|
||||
.rows = 10,
|
||||
.max_scrollback = 0,
|
||||
});
|
||||
defer s.deinit();
|
||||
|
||||
// Write content to multiple rows so we have something to split
|
||||
try s.testWriteString("line1\nline2\nline3\nline4\nline5");
|
||||
|
||||
// Remember the original node
|
||||
const original_node = s.cursor.page_pin.node;
|
||||
|
||||
// Increase the page's style capacity to max by repeatedly calling increaseCapacity
|
||||
// Use Screen.increaseCapacity to properly maintain cursor state
|
||||
const max_styles = std.math.maxInt(size.CellCountInt);
|
||||
while (s.cursor.page_pin.node.data.capacity.styles < max_styles) {
|
||||
_ = s.increaseCapacity(
|
||||
s.cursor.page_pin.node,
|
||||
.styles,
|
||||
) catch break;
|
||||
}
|
||||
|
||||
// Get the page reference after increaseCapacity - cursor may have moved
|
||||
var page = &s.cursor.page_pin.node.data;
|
||||
try testing.expectEqual(max_styles, page.capacity.styles);
|
||||
|
||||
// Fill the style map to capacity using the StyleSet's layout capacity
|
||||
// which accounts for the load factor
|
||||
{
|
||||
page.pauseIntegrityChecks(true);
|
||||
defer page.pauseIntegrityChecks(false);
|
||||
defer page.assertIntegrity();
|
||||
|
||||
const max_items = page.styles.layout.cap;
|
||||
var n: usize = 1;
|
||||
while (n < max_items) : (n += 1) {
|
||||
_ = page.styles.add(
|
||||
page.memory,
|
||||
.{ .bg_color = .{ .rgb = @bitCast(@as(u24, @intCast(n))) } },
|
||||
) catch break;
|
||||
}
|
||||
}
|
||||
|
||||
// Track the node before setAttribute
|
||||
const node_before_set = s.cursor.page_pin.node;
|
||||
|
||||
// Now try to set a new unique attribute that would require a new style slot
|
||||
// At max capacity, increaseCapacity will return OutOfSpace, triggering page split
|
||||
try s.setAttribute(.bold);
|
||||
|
||||
// The style should have been applied (bold flag set)
|
||||
try testing.expect(s.cursor.style.flags.bold);
|
||||
|
||||
// The cursor should have a valid non-default style_id
|
||||
try testing.expect(s.cursor.style_id != style.default_id);
|
||||
|
||||
// The page should have been split
|
||||
const page_was_split = s.cursor.page_pin.node != node_before_set or
|
||||
node_before_set.next != null or
|
||||
node_before_set.prev != null or
|
||||
s.cursor.page_pin.node != original_node;
|
||||
try testing.expect(page_was_split);
|
||||
}
|
||||
|
||||
@@ -996,7 +996,7 @@ pub fn saveCursor(self: *Terminal) void {
|
||||
///
|
||||
/// The primary and alternate screen have distinct save state.
|
||||
/// If no save was done before values are reset to their initial values.
|
||||
pub fn restoreCursor(self: *Terminal) !void {
|
||||
pub fn restoreCursor(self: *Terminal) void {
|
||||
const saved: Screen.SavedCursor = self.screens.active.saved_cursor orelse .{
|
||||
.x = 0,
|
||||
.y = 0,
|
||||
@@ -1008,10 +1008,17 @@ pub fn restoreCursor(self: *Terminal) !void {
|
||||
};
|
||||
|
||||
// Set the style first because it can fail
|
||||
const old_style = self.screens.active.cursor.style;
|
||||
self.screens.active.cursor.style = saved.style;
|
||||
errdefer self.screens.active.cursor.style = old_style;
|
||||
try self.screens.active.manualStyleUpdate();
|
||||
self.screens.active.manualStyleUpdate() catch |err| {
|
||||
// Regardless of the error here, we revert back to an unstyled
|
||||
// cursor. It is more important that the restore succeeds in
|
||||
// other attributes because terminals have no way to communicate
|
||||
// failure back.
|
||||
log.warn("restoreCursor error updating style err={}", .{err});
|
||||
const screen: *Screen = self.screens.active;
|
||||
screen.cursor.style = .{};
|
||||
self.screens.active.manualStyleUpdate() catch unreachable;
|
||||
};
|
||||
|
||||
self.screens.active.charset = saved.charset;
|
||||
self.modes.set(.origin, saved.origin);
|
||||
@@ -2747,12 +2754,7 @@ pub fn switchScreenMode(
|
||||
}
|
||||
} else {
|
||||
assert(self.screens.active_key == .primary);
|
||||
self.restoreCursor() catch |err| {
|
||||
log.warn(
|
||||
"restore cursor on switch screen failed to={} err={}",
|
||||
.{ to, err },
|
||||
);
|
||||
};
|
||||
self.restoreCursor();
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -4807,7 +4809,7 @@ test "Terminal: horizontal tab back with cursor before left margin" {
|
||||
t.saveCursor();
|
||||
t.modes.set(.enable_left_and_right_margin, true);
|
||||
t.setLeftAndRightMargin(5, 0);
|
||||
try t.restoreCursor();
|
||||
t.restoreCursor();
|
||||
try t.horizontalTabBack();
|
||||
try t.print('X');
|
||||
|
||||
@@ -9873,7 +9875,7 @@ test "Terminal: saveCursor" {
|
||||
t.screens.active.charset.gr = .G0;
|
||||
try t.setAttribute(.{ .unset = {} });
|
||||
t.modes.set(.origin, false);
|
||||
try t.restoreCursor();
|
||||
t.restoreCursor();
|
||||
try testing.expect(t.screens.active.cursor.style.flags.bold);
|
||||
try testing.expect(t.screens.active.charset.gr == .G3);
|
||||
try testing.expect(t.modes.get(.origin));
|
||||
@@ -9889,7 +9891,7 @@ test "Terminal: saveCursor position" {
|
||||
t.saveCursor();
|
||||
t.setCursorPos(1, 1);
|
||||
try t.print('B');
|
||||
try t.restoreCursor();
|
||||
t.restoreCursor();
|
||||
try t.print('X');
|
||||
|
||||
{
|
||||
@@ -9909,7 +9911,7 @@ test "Terminal: saveCursor pending wrap state" {
|
||||
t.saveCursor();
|
||||
t.setCursorPos(1, 1);
|
||||
try t.print('B');
|
||||
try t.restoreCursor();
|
||||
t.restoreCursor();
|
||||
try t.print('X');
|
||||
|
||||
{
|
||||
@@ -9929,7 +9931,7 @@ test "Terminal: saveCursor origin mode" {
|
||||
t.modes.set(.enable_left_and_right_margin, true);
|
||||
t.setLeftAndRightMargin(3, 5);
|
||||
t.setTopAndBottomMargin(2, 4);
|
||||
try t.restoreCursor();
|
||||
t.restoreCursor();
|
||||
try t.print('X');
|
||||
|
||||
{
|
||||
@@ -9947,7 +9949,7 @@ test "Terminal: saveCursor resize" {
|
||||
t.setCursorPos(1, 10);
|
||||
t.saveCursor();
|
||||
try t.resize(alloc, 5, 5);
|
||||
try t.restoreCursor();
|
||||
t.restoreCursor();
|
||||
try t.print('X');
|
||||
|
||||
{
|
||||
@@ -9968,7 +9970,7 @@ test "Terminal: saveCursor protected pen" {
|
||||
t.saveCursor();
|
||||
t.setProtectedMode(.off);
|
||||
try testing.expect(!t.screens.active.cursor.protected);
|
||||
try t.restoreCursor();
|
||||
t.restoreCursor();
|
||||
try testing.expect(t.screens.active.cursor.protected);
|
||||
}
|
||||
|
||||
@@ -9981,10 +9983,67 @@ test "Terminal: saveCursor doesn't modify hyperlink state" {
|
||||
const id = t.screens.active.cursor.hyperlink_id;
|
||||
t.saveCursor();
|
||||
try testing.expectEqual(id, t.screens.active.cursor.hyperlink_id);
|
||||
try t.restoreCursor();
|
||||
t.restoreCursor();
|
||||
try testing.expectEqual(id, t.screens.active.cursor.hyperlink_id);
|
||||
}
|
||||
|
||||
test "Terminal: restoreCursor uses default style on OutOfSpace" {
|
||||
// Tests that restoreCursor falls back to default style when
|
||||
// manualStyleUpdate fails with OutOfSpace (can't split a 1-row page
|
||||
// and styles are at max capacity).
|
||||
const alloc = testing.allocator;
|
||||
|
||||
// Use a single row so the page can't be split
|
||||
var t = try init(alloc, .{ .cols = 10, .rows = 1 });
|
||||
defer t.deinit(alloc);
|
||||
|
||||
// Set a style and save the cursor
|
||||
try t.setAttribute(.{ .bold = {} });
|
||||
t.saveCursor();
|
||||
|
||||
// Clear the style
|
||||
try t.setAttribute(.{ .unset = {} });
|
||||
try testing.expect(!t.screens.active.cursor.style.flags.bold);
|
||||
|
||||
// Fill the style map to max capacity
|
||||
const max_styles = std.math.maxInt(size.CellCountInt);
|
||||
while (t.screens.active.cursor.page_pin.node.data.capacity.styles < max_styles) {
|
||||
_ = t.screens.active.increaseCapacity(
|
||||
t.screens.active.cursor.page_pin.node,
|
||||
.styles,
|
||||
) catch break;
|
||||
}
|
||||
|
||||
const page = &t.screens.active.cursor.page_pin.node.data;
|
||||
try testing.expectEqual(max_styles, page.capacity.styles);
|
||||
|
||||
// Fill all style slots using the StyleSet's layout capacity which accounts
|
||||
// for the load factor. The capacity in the layout is the actual max number
|
||||
// of items that can be stored.
|
||||
{
|
||||
page.pauseIntegrityChecks(true);
|
||||
defer page.pauseIntegrityChecks(false);
|
||||
defer page.assertIntegrity();
|
||||
|
||||
const max_items = page.styles.layout.cap;
|
||||
var n: usize = 1;
|
||||
while (n < max_items) : (n += 1) {
|
||||
_ = page.styles.add(
|
||||
page.memory,
|
||||
.{ .bg_color = .{ .rgb = @bitCast(@as(u24, @intCast(n))) } },
|
||||
) catch break;
|
||||
}
|
||||
}
|
||||
|
||||
// Restore cursor - should fall back to default style since page
|
||||
// can't be split (1 row) and styles are at max capacity
|
||||
t.restoreCursor();
|
||||
|
||||
// The style should be reset to default because OutOfSpace occurred
|
||||
try testing.expect(!t.screens.active.cursor.style.flags.bold);
|
||||
try testing.expectEqual(style.default_id, t.screens.active.cursor.style_id);
|
||||
}
|
||||
|
||||
test "Terminal: setProtectedMode" {
|
||||
const alloc = testing.allocator;
|
||||
var t = try init(alloc, .{ .cols = 3, .rows = 3 });
|
||||
@@ -11376,7 +11435,7 @@ test "Terminal: resize with reflow and saved cursor" {
|
||||
|
||||
t.saveCursor();
|
||||
try t.resize(alloc, 5, 3);
|
||||
try t.restoreCursor();
|
||||
t.restoreCursor();
|
||||
|
||||
{
|
||||
const str = try t.plainString(testing.allocator);
|
||||
@@ -11417,7 +11476,7 @@ test "Terminal: resize with reflow and saved cursor pending wrap" {
|
||||
|
||||
t.saveCursor();
|
||||
try t.resize(alloc, 5, 3);
|
||||
try t.restoreCursor();
|
||||
t.restoreCursor();
|
||||
|
||||
{
|
||||
const str = try t.plainString(testing.allocator);
|
||||
|
||||
@@ -63,6 +63,14 @@ pub fn BitmapAllocator(comptime chunk_size: comptime_int) type {
|
||||
};
|
||||
}
|
||||
|
||||
/// Returns the number of bytes required to allocate n elements of
|
||||
/// type T. This accounts for the chunk size alignment used by the
|
||||
/// bitmap allocator.
|
||||
pub fn bytesRequired(comptime T: type, n: usize) usize {
|
||||
const byte_count = @sizeOf(T) * n;
|
||||
return alignForward(usize, byte_count, chunk_size);
|
||||
}
|
||||
|
||||
/// Allocate n elements of type T. This will return error.OutOfMemory
|
||||
/// if there isn't enough space in the backing buffer.
|
||||
///
|
||||
@@ -955,3 +963,45 @@ test "BitmapAllocator alloc and free two 1.5 bitmaps offset 0.75" {
|
||||
bm.bitmap.ptr(buf)[0..4],
|
||||
);
|
||||
}
|
||||
|
||||
test "BitmapAllocator bytesRequired" {
|
||||
const testing = std.testing;
|
||||
|
||||
// Chunk size of 16 bytes (like grapheme_chunk in page.zig)
|
||||
{
|
||||
const Alloc = BitmapAllocator(16);
|
||||
|
||||
// Single byte rounds up to chunk size
|
||||
try testing.expectEqual(16, Alloc.bytesRequired(u8, 1));
|
||||
try testing.expectEqual(16, Alloc.bytesRequired(u8, 16));
|
||||
try testing.expectEqual(32, Alloc.bytesRequired(u8, 17));
|
||||
|
||||
// u21 (4 bytes each)
|
||||
try testing.expectEqual(16, Alloc.bytesRequired(u21, 1)); // 4 bytes -> 16
|
||||
try testing.expectEqual(16, Alloc.bytesRequired(u21, 4)); // 16 bytes -> 16
|
||||
try testing.expectEqual(32, Alloc.bytesRequired(u21, 5)); // 20 bytes -> 32
|
||||
try testing.expectEqual(32, Alloc.bytesRequired(u21, 6)); // 24 bytes -> 32
|
||||
}
|
||||
|
||||
// Chunk size of 4 bytes
|
||||
{
|
||||
const Alloc = BitmapAllocator(4);
|
||||
|
||||
try testing.expectEqual(4, Alloc.bytesRequired(u8, 1));
|
||||
try testing.expectEqual(4, Alloc.bytesRequired(u8, 4));
|
||||
try testing.expectEqual(8, Alloc.bytesRequired(u8, 5));
|
||||
|
||||
// u32 (4 bytes each) - exactly one chunk per element
|
||||
try testing.expectEqual(4, Alloc.bytesRequired(u32, 1));
|
||||
try testing.expectEqual(8, Alloc.bytesRequired(u32, 2));
|
||||
}
|
||||
|
||||
// Chunk size of 32 bytes (like string_chunk in page.zig)
|
||||
{
|
||||
const Alloc = BitmapAllocator(32);
|
||||
|
||||
try testing.expectEqual(32, Alloc.bytesRequired(u8, 1));
|
||||
try testing.expectEqual(32, Alloc.bytesRequired(u8, 32));
|
||||
try testing.expectEqual(64, Alloc.bytesRequired(u8, 33));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -633,6 +633,114 @@ pub const Page = struct {
|
||||
HyperlinkError ||
|
||||
GraphemeError;
|
||||
|
||||
/// Compute the exact capacity required to store a range of rows from
|
||||
/// this page.
|
||||
///
|
||||
/// The returned capacity will have the same number of columns as this
|
||||
/// page and the number of rows equal to the range given. The returned
|
||||
/// capacity is by definition strictly less than or equal to this
|
||||
/// page's capacity, so the layout is guaranteed to succeed.
|
||||
///
|
||||
/// Preconditions:
|
||||
/// - Range must be at least 1 row
|
||||
/// - Start and end must be valid for this page
|
||||
pub fn exactRowCapacity(
|
||||
self: *const Page,
|
||||
y_start: usize,
|
||||
y_end: usize,
|
||||
) Capacity {
|
||||
assert(y_start < y_end);
|
||||
assert(y_end <= self.size.rows);
|
||||
|
||||
// Track unique IDs using a bitset. Both style IDs and hyperlink IDs
|
||||
// are CellCountInt (u16), so we reuse this set for both to save
|
||||
// stack memory (~8KB instead of ~16KB).
|
||||
const CellCountSet = std.StaticBitSet(std.math.maxInt(size.CellCountInt) + 1);
|
||||
comptime assert(size.StyleCountInt == size.CellCountInt);
|
||||
comptime assert(size.HyperlinkCountInt == size.CellCountInt);
|
||||
|
||||
// Accumulators
|
||||
var id_set: CellCountSet = .initEmpty();
|
||||
var grapheme_bytes: usize = 0;
|
||||
var string_bytes: usize = 0;
|
||||
|
||||
// First pass: count styles and grapheme bytes
|
||||
const rows = self.rows.ptr(self.memory)[y_start..y_end];
|
||||
for (rows) |*row| {
|
||||
const cells = row.cells.ptr(self.memory)[0..self.size.cols];
|
||||
for (cells) |*cell| {
|
||||
if (cell.style_id != stylepkg.default_id) {
|
||||
id_set.set(cell.style_id);
|
||||
}
|
||||
|
||||
if (cell.hasGrapheme()) {
|
||||
if (self.lookupGrapheme(cell)) |cps| {
|
||||
grapheme_bytes += GraphemeAlloc.bytesRequired(u21, cps.len);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const styles_cap = StyleSet.capacityForCount(id_set.count());
|
||||
|
||||
// Second pass: count hyperlinks and string bytes
|
||||
// We count both unique hyperlinks (for hyperlink_set) and total
|
||||
// hyperlink cells (for hyperlink_map capacity).
|
||||
id_set = .initEmpty();
|
||||
var hyperlink_cells: usize = 0;
|
||||
for (rows) |*row| {
|
||||
const cells = row.cells.ptr(self.memory)[0..self.size.cols];
|
||||
for (cells) |*cell| {
|
||||
if (cell.hyperlink) {
|
||||
hyperlink_cells += 1;
|
||||
if (self.lookupHyperlink(cell)) |id| {
|
||||
// Only count each unique hyperlink once for set sizing
|
||||
if (!id_set.isSet(id)) {
|
||||
id_set.set(id);
|
||||
|
||||
// Get the hyperlink entry to compute string bytes
|
||||
const entry = self.hyperlink_set.get(self.memory, id);
|
||||
string_bytes += StringAlloc.bytesRequired(u8, entry.uri.len);
|
||||
|
||||
switch (entry.id) {
|
||||
.implicit => {},
|
||||
.explicit => |slice| {
|
||||
string_bytes += StringAlloc.bytesRequired(u8, slice.len);
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The hyperlink_map capacity in layout() is computed as:
|
||||
// hyperlink_count * hyperlink_cell_multiplier (rounded to power of 2)
|
||||
// We need enough hyperlink_bytes so that when layout() computes
|
||||
// the map capacity, it can accommodate all hyperlink cells. This
|
||||
// is unit tested.
|
||||
const hyperlink_cap = cap: {
|
||||
const hyperlink_count = id_set.count();
|
||||
const hyperlink_set_cap = hyperlink.Set.capacityForCount(hyperlink_count);
|
||||
const hyperlink_map_min = std.math.divCeil(
|
||||
usize,
|
||||
hyperlink_cells,
|
||||
hyperlink_cell_multiplier,
|
||||
) catch 0;
|
||||
break :cap @max(hyperlink_set_cap, hyperlink_map_min);
|
||||
};
|
||||
|
||||
// All the intCasts below are safe because we should have a
|
||||
// capacity strictly less than or equal to this page's capacity.
|
||||
return .{
|
||||
.cols = self.size.cols,
|
||||
.rows = @intCast(y_end - y_start),
|
||||
.styles = @intCast(styles_cap),
|
||||
.grapheme_bytes = @intCast(grapheme_bytes),
|
||||
.hyperlink_bytes = @intCast(hyperlink_cap * @sizeOf(hyperlink.Set.Item)),
|
||||
.string_bytes = @intCast(string_bytes),
|
||||
};
|
||||
}
|
||||
|
||||
/// Clone the contents of another page into this page. The capacities
|
||||
/// can be different, but the size of the other page must fit into
|
||||
/// this page.
|
||||
@@ -1569,10 +1677,13 @@ pub const Page = struct {
|
||||
const grapheme_alloc_start = alignForward(usize, styles_end, GraphemeAlloc.base_align.toByteUnits());
|
||||
const grapheme_alloc_end = grapheme_alloc_start + grapheme_alloc_layout.total_size;
|
||||
|
||||
const grapheme_count = std.math.ceilPowerOfTwo(
|
||||
usize,
|
||||
@divFloor(cap.grapheme_bytes, grapheme_chunk),
|
||||
) catch unreachable;
|
||||
const grapheme_count: usize = count: {
|
||||
if (cap.grapheme_bytes == 0) break :count 0;
|
||||
// Use divCeil to match GraphemeAlloc.layout() which uses alignForward,
|
||||
// ensuring grapheme_map has capacity when grapheme_alloc has chunks.
|
||||
const base = std.math.divCeil(usize, cap.grapheme_bytes, grapheme_chunk) catch unreachable;
|
||||
break :count std.math.ceilPowerOfTwo(usize, base) catch unreachable;
|
||||
};
|
||||
const grapheme_map_layout = GraphemeMap.layout(@intCast(grapheme_count));
|
||||
const grapheme_map_start = alignForward(usize, grapheme_alloc_end, GraphemeMap.base_align.toByteUnits());
|
||||
const grapheme_map_end = grapheme_map_start + grapheme_map_layout.total_size;
|
||||
@@ -3217,3 +3328,512 @@ test "Page verifyIntegrity zero cols" {
|
||||
page.verifyIntegrity(testing.allocator),
|
||||
);
|
||||
}
|
||||
|
||||
test "Page exactRowCapacity empty rows" {
|
||||
var page = try Page.init(.{
|
||||
.cols = 10,
|
||||
.rows = 10,
|
||||
.styles = 8,
|
||||
.hyperlink_bytes = 32 * @sizeOf(hyperlink.Set.Item),
|
||||
.string_bytes = 512,
|
||||
});
|
||||
defer page.deinit();
|
||||
|
||||
// Empty page: all capacity fields should be 0 (except cols/rows)
|
||||
const cap = page.exactRowCapacity(0, 5);
|
||||
try testing.expectEqual(10, cap.cols);
|
||||
try testing.expectEqual(5, cap.rows);
|
||||
try testing.expectEqual(0, cap.styles);
|
||||
try testing.expectEqual(0, cap.grapheme_bytes);
|
||||
try testing.expectEqual(0, cap.hyperlink_bytes);
|
||||
try testing.expectEqual(0, cap.string_bytes);
|
||||
}
|
||||
|
||||
test "Page exactRowCapacity styles" {
|
||||
var page = try Page.init(.{
|
||||
.cols = 10,
|
||||
.rows = 10,
|
||||
.styles = 8,
|
||||
});
|
||||
defer page.deinit();
|
||||
|
||||
// No styles: capacity should be 0
|
||||
{
|
||||
const cap = page.exactRowCapacity(0, 5);
|
||||
try testing.expectEqual(0, cap.styles);
|
||||
}
|
||||
|
||||
// Add one style to a cell
|
||||
const style1_id = try page.styles.add(page.memory, .{ .flags = .{ .bold = true } });
|
||||
{
|
||||
const rac = page.getRowAndCell(0, 0);
|
||||
rac.row.styled = true;
|
||||
rac.cell.style_id = style1_id;
|
||||
}
|
||||
|
||||
// One unique style - capacity accounts for load factor
|
||||
const cap_one_style = page.exactRowCapacity(0, 5);
|
||||
{
|
||||
try testing.expectEqual(StyleSet.capacityForCount(1), cap_one_style.styles);
|
||||
}
|
||||
|
||||
// Add same style to another cell (duplicate) - capacity unchanged
|
||||
{
|
||||
const rac = page.getRowAndCell(1, 0);
|
||||
rac.cell.style_id = style1_id;
|
||||
}
|
||||
{
|
||||
const cap = page.exactRowCapacity(0, 5);
|
||||
try testing.expectEqual(cap_one_style.styles, cap.styles);
|
||||
}
|
||||
|
||||
// Add a different style
|
||||
const style2_id = try page.styles.add(page.memory, .{ .flags = .{ .italic = true } });
|
||||
{
|
||||
const rac = page.getRowAndCell(2, 0);
|
||||
rac.cell.style_id = style2_id;
|
||||
}
|
||||
|
||||
// Two unique styles - capacity accounts for load factor
|
||||
const cap_two_styles = page.exactRowCapacity(0, 5);
|
||||
{
|
||||
try testing.expectEqual(StyleSet.capacityForCount(2), cap_two_styles.styles);
|
||||
try testing.expect(cap_two_styles.styles > cap_one_style.styles);
|
||||
}
|
||||
|
||||
// Style outside the row range should not be counted
|
||||
{
|
||||
const rac = page.getRowAndCell(0, 7);
|
||||
rac.row.styled = true;
|
||||
rac.cell.style_id = try page.styles.add(page.memory, .{ .flags = .{ .underline = .single } });
|
||||
}
|
||||
{
|
||||
const cap = page.exactRowCapacity(0, 5);
|
||||
try testing.expectEqual(cap_two_styles.styles, cap.styles);
|
||||
}
|
||||
|
||||
// Full range includes the new style
|
||||
{
|
||||
const cap = page.exactRowCapacity(0, 10);
|
||||
try testing.expectEqual(StyleSet.capacityForCount(3), cap.styles);
|
||||
}
|
||||
|
||||
// Verify clone works with exact capacity and produces same result
|
||||
{
|
||||
const cap = page.exactRowCapacity(0, 5);
|
||||
var cloned = try Page.init(cap);
|
||||
defer cloned.deinit();
|
||||
for (0..5) |y| {
|
||||
const src_row = &page.rows.ptr(page.memory)[y];
|
||||
const dst_row = &cloned.rows.ptr(cloned.memory)[y];
|
||||
try cloned.cloneRowFrom(&page, dst_row, src_row);
|
||||
}
|
||||
const cloned_cap = cloned.exactRowCapacity(0, 5);
|
||||
try testing.expectEqual(cap, cloned_cap);
|
||||
}
|
||||
}
|
||||
|
||||
test "Page exactRowCapacity single style clone" {
|
||||
// Regression test: verify a single style can be cloned with exact capacity.
|
||||
// This tests that capacityForCount properly accounts for ID 0 being reserved.
|
||||
var page = try Page.init(.{
|
||||
.cols = 10,
|
||||
.rows = 2,
|
||||
.styles = 8,
|
||||
});
|
||||
defer page.deinit();
|
||||
|
||||
// Add exactly one style to row 0
|
||||
const style_id = try page.styles.add(page.memory, .{ .flags = .{ .bold = true } });
|
||||
{
|
||||
const rac = page.getRowAndCell(0, 0);
|
||||
rac.row.styled = true;
|
||||
rac.cell.style_id = style_id;
|
||||
}
|
||||
|
||||
// exactRowCapacity for just row 0 should give capacity for 1 style
|
||||
const cap = page.exactRowCapacity(0, 1);
|
||||
try testing.expectEqual(StyleSet.capacityForCount(1), cap.styles);
|
||||
|
||||
// Create a new page with exact capacity and clone
|
||||
var cloned = try Page.init(cap);
|
||||
defer cloned.deinit();
|
||||
|
||||
const src_row = &page.rows.ptr(page.memory)[0];
|
||||
const dst_row = &cloned.rows.ptr(cloned.memory)[0];
|
||||
|
||||
// This must not fail with StyleSetOutOfMemory
|
||||
try cloned.cloneRowFrom(&page, dst_row, src_row);
|
||||
|
||||
// Verify the style was cloned correctly
|
||||
const cloned_cell = &cloned.rows.ptr(cloned.memory)[0].cells.ptr(cloned.memory)[0];
|
||||
try testing.expect(cloned_cell.style_id != stylepkg.default_id);
|
||||
}
|
||||
|
||||
test "Page exactRowCapacity styles max single row" {
|
||||
var page = try Page.init(.{
|
||||
.cols = std.math.maxInt(size.CellCountInt),
|
||||
.rows = 1,
|
||||
.styles = std.math.maxInt(size.StyleCountInt),
|
||||
});
|
||||
defer page.deinit();
|
||||
|
||||
// Style our first row
|
||||
const row = &page.rows.ptr(page.memory)[0];
|
||||
row.styled = true;
|
||||
|
||||
// Fill cells with styles until we get OOM, but limit to a reasonable count
|
||||
// to avoid overflow when computing capacityForCount near maxInt
|
||||
const cells = row.cells.ptr(page.memory)[0..page.size.cols];
|
||||
var count: usize = 0;
|
||||
const max_count: usize = 1000; // Limit to avoid overflow in capacity calculation
|
||||
for (cells, 0..) |*cell, i| {
|
||||
if (count >= max_count) break;
|
||||
const style_id = page.styles.add(page.memory, .{
|
||||
.fg_color = .{ .rgb = .{
|
||||
.r = @intCast(i & 0xFF),
|
||||
.g = @intCast((i >> 8) & 0xFF),
|
||||
.b = 0,
|
||||
} },
|
||||
}) catch break;
|
||||
cell.style_id = style_id;
|
||||
count += 1;
|
||||
}
|
||||
|
||||
// Verify we added a meaningful number of styles
|
||||
try testing.expect(count > 0);
|
||||
|
||||
// Capacity should be at least count (adjusted for load factor)
|
||||
const cap = page.exactRowCapacity(0, 1);
|
||||
try testing.expectEqual(StyleSet.capacityForCount(count), cap.styles);
|
||||
}
|
||||
|
||||
test "Page exactRowCapacity grapheme_bytes" {
|
||||
var page = try Page.init(.{
|
||||
.cols = 10,
|
||||
.rows = 10,
|
||||
.styles = 8,
|
||||
});
|
||||
defer page.deinit();
|
||||
|
||||
// No graphemes: capacity should be 0
|
||||
{
|
||||
const cap = page.exactRowCapacity(0, 5);
|
||||
try testing.expectEqual(0, cap.grapheme_bytes);
|
||||
}
|
||||
|
||||
// Add one grapheme (1 codepoint) to a cell - rounds up to grapheme_chunk
|
||||
{
|
||||
const rac = page.getRowAndCell(0, 0);
|
||||
rac.cell.* = .init('a');
|
||||
try page.appendGrapheme(rac.row, rac.cell, 0x0301); // combining acute accent
|
||||
}
|
||||
{
|
||||
const cap = page.exactRowCapacity(0, 5);
|
||||
// 1 codepoint = 4 bytes, rounds up to grapheme_chunk (16)
|
||||
try testing.expectEqual(grapheme_chunk, cap.grapheme_bytes);
|
||||
}
|
||||
|
||||
// Add another grapheme to a different cell - should sum
|
||||
{
|
||||
const rac = page.getRowAndCell(1, 0);
|
||||
rac.cell.* = .init('e');
|
||||
try page.appendGrapheme(rac.row, rac.cell, 0x0300); // combining grave accent
|
||||
}
|
||||
{
|
||||
const cap = page.exactRowCapacity(0, 5);
|
||||
// 2 graphemes, each 1 codepoint = 2 * grapheme_chunk
|
||||
try testing.expectEqual(grapheme_chunk * 2, cap.grapheme_bytes);
|
||||
}
|
||||
|
||||
// Add a larger grapheme (multiple codepoints) that fits in one chunk
|
||||
{
|
||||
const rac = page.getRowAndCell(2, 0);
|
||||
rac.cell.* = .init('o');
|
||||
try page.appendGrapheme(rac.row, rac.cell, 0x0301);
|
||||
try page.appendGrapheme(rac.row, rac.cell, 0x0302);
|
||||
try page.appendGrapheme(rac.row, rac.cell, 0x0303);
|
||||
}
|
||||
{
|
||||
const cap = page.exactRowCapacity(0, 5);
|
||||
// First two cells: 2 * grapheme_chunk
|
||||
// Third cell: 3 codepoints = 12 bytes, rounds up to grapheme_chunk
|
||||
try testing.expectEqual(grapheme_chunk * 3, cap.grapheme_bytes);
|
||||
}
|
||||
|
||||
// Grapheme outside the row range should not be counted
|
||||
{
|
||||
const rac = page.getRowAndCell(0, 7);
|
||||
rac.cell.* = .init('x');
|
||||
try page.appendGrapheme(rac.row, rac.cell, 0x0304);
|
||||
}
|
||||
{
|
||||
const cap = page.exactRowCapacity(0, 5);
|
||||
try testing.expectEqual(grapheme_chunk * 3, cap.grapheme_bytes);
|
||||
}
|
||||
|
||||
// Full range includes the new grapheme
|
||||
{
|
||||
const cap = page.exactRowCapacity(0, 10);
|
||||
try testing.expectEqual(grapheme_chunk * 4, cap.grapheme_bytes);
|
||||
}
|
||||
|
||||
// Verify clone works with exact capacity and produces same result
|
||||
{
|
||||
const cap = page.exactRowCapacity(0, 5);
|
||||
var cloned = try Page.init(cap);
|
||||
defer cloned.deinit();
|
||||
for (0..5) |y| {
|
||||
const src_row = &page.rows.ptr(page.memory)[y];
|
||||
const dst_row = &cloned.rows.ptr(cloned.memory)[y];
|
||||
try cloned.cloneRowFrom(&page, dst_row, src_row);
|
||||
}
|
||||
const cloned_cap = cloned.exactRowCapacity(0, 5);
|
||||
try testing.expectEqual(cap, cloned_cap);
|
||||
}
|
||||
}
|
||||
|
||||
test "Page exactRowCapacity grapheme_bytes larger than chunk" {
|
||||
var page = try Page.init(.{
|
||||
.cols = 10,
|
||||
.rows = 10,
|
||||
.styles = 8,
|
||||
});
|
||||
defer page.deinit();
|
||||
|
||||
// Add a grapheme larger than one chunk (grapheme_chunk_len = 4 codepoints)
|
||||
const rac = page.getRowAndCell(0, 0);
|
||||
rac.cell.* = .init('a');
|
||||
|
||||
// Add 6 codepoints - requires 2 chunks (6 * 4 = 24 bytes, rounds up to 32)
|
||||
for (0..6) |i| {
|
||||
try page.appendGrapheme(rac.row, rac.cell, @intCast(0x0300 + i));
|
||||
}
|
||||
|
||||
const cap = page.exactRowCapacity(0, 1);
|
||||
// 6 codepoints = 24 bytes, alignForward(24, 16) = 32
|
||||
try testing.expectEqual(32, cap.grapheme_bytes);
|
||||
|
||||
// Verify clone works with exact capacity and produces same result
|
||||
var cloned = try Page.init(cap);
|
||||
defer cloned.deinit();
|
||||
const src_row = &page.rows.ptr(page.memory)[0];
|
||||
const dst_row = &cloned.rows.ptr(cloned.memory)[0];
|
||||
try cloned.cloneRowFrom(&page, dst_row, src_row);
|
||||
const cloned_cap = cloned.exactRowCapacity(0, 1);
|
||||
try testing.expectEqual(cap, cloned_cap);
|
||||
}
|
||||
|
||||
test "Page exactRowCapacity hyperlinks" {
|
||||
var page = try Page.init(.{
|
||||
.cols = 10,
|
||||
.rows = 10,
|
||||
.styles = 8,
|
||||
.hyperlink_bytes = 32 * @sizeOf(hyperlink.Set.Item),
|
||||
.string_bytes = 512,
|
||||
});
|
||||
defer page.deinit();
|
||||
|
||||
// No hyperlinks: capacity should be 0
|
||||
{
|
||||
const cap = page.exactRowCapacity(0, 5);
|
||||
try testing.expectEqual(0, cap.hyperlink_bytes);
|
||||
try testing.expectEqual(0, cap.string_bytes);
|
||||
}
|
||||
|
||||
// Add one hyperlink with implicit ID
|
||||
const uri1 = "https://example.com";
|
||||
const id1 = blk: {
|
||||
const rac = page.getRowAndCell(0, 0);
|
||||
|
||||
// Create and add hyperlink entry
|
||||
const id = try page.insertHyperlink(.{
|
||||
.id = .{ .implicit = 1 },
|
||||
.uri = uri1,
|
||||
});
|
||||
try page.setHyperlink(rac.row, rac.cell, id);
|
||||
break :blk id;
|
||||
};
|
||||
// 1 hyperlink - capacity accounts for load factor
|
||||
const cap_one_link = page.exactRowCapacity(0, 5);
|
||||
{
|
||||
try testing.expectEqual(hyperlink.Set.capacityForCount(1) * @sizeOf(hyperlink.Set.Item), cap_one_link.hyperlink_bytes);
|
||||
// URI "https://example.com" = 19 bytes, rounds up to string_chunk (32)
|
||||
try testing.expectEqual(string_chunk, cap_one_link.string_bytes);
|
||||
}
|
||||
|
||||
// Add same hyperlink to another cell (duplicate ID) - capacity unchanged
|
||||
{
|
||||
const rac = page.getRowAndCell(1, 0);
|
||||
|
||||
// Use the same hyperlink ID for another cell
|
||||
page.hyperlink_set.use(page.memory, id1);
|
||||
try page.setHyperlink(rac.row, rac.cell, id1);
|
||||
}
|
||||
{
|
||||
const cap = page.exactRowCapacity(0, 5);
|
||||
try testing.expectEqual(cap_one_link.hyperlink_bytes, cap.hyperlink_bytes);
|
||||
try testing.expectEqual(cap_one_link.string_bytes, cap.string_bytes);
|
||||
}
|
||||
|
||||
// Add a different hyperlink with explicit ID
|
||||
const uri2 = "https://other.example.org/path";
|
||||
const explicit_id = "my-link-id";
|
||||
{
|
||||
const rac = page.getRowAndCell(2, 0);
|
||||
|
||||
const id = try page.insertHyperlink(.{
|
||||
.id = .{ .explicit = explicit_id },
|
||||
.uri = uri2,
|
||||
});
|
||||
try page.setHyperlink(rac.row, rac.cell, id);
|
||||
}
|
||||
// 2 hyperlinks - capacity accounts for load factor
|
||||
const cap_two_links = page.exactRowCapacity(0, 5);
|
||||
{
|
||||
try testing.expectEqual(hyperlink.Set.capacityForCount(2) * @sizeOf(hyperlink.Set.Item), cap_two_links.hyperlink_bytes);
|
||||
// First URI: 19 bytes -> 32, Second URI: 30 bytes -> 32, Explicit ID: 10 bytes -> 32
|
||||
try testing.expectEqual(string_chunk * 3, cap_two_links.string_bytes);
|
||||
}
|
||||
|
||||
// Hyperlink outside the row range should not be counted
|
||||
{
|
||||
const rac = page.getRowAndCell(0, 7); // row 7 is outside range [0, 5)
|
||||
|
||||
const id = try page.insertHyperlink(.{
|
||||
.id = .{ .implicit = 99 },
|
||||
.uri = "https://outside.example.com",
|
||||
});
|
||||
try page.setHyperlink(rac.row, rac.cell, id);
|
||||
}
|
||||
{
|
||||
const cap = page.exactRowCapacity(0, 5);
|
||||
try testing.expectEqual(cap_two_links.hyperlink_bytes, cap.hyperlink_bytes);
|
||||
try testing.expectEqual(cap_two_links.string_bytes, cap.string_bytes);
|
||||
}
|
||||
|
||||
// Full range includes the new hyperlink
|
||||
{
|
||||
const cap = page.exactRowCapacity(0, 10);
|
||||
try testing.expectEqual(hyperlink.Set.capacityForCount(3) * @sizeOf(hyperlink.Set.Item), cap.hyperlink_bytes);
|
||||
// Third URI: 27 bytes -> 32
|
||||
try testing.expectEqual(string_chunk * 4, cap.string_bytes);
|
||||
}
|
||||
|
||||
// Verify clone works with exact capacity and produces same result
|
||||
{
|
||||
const cap = page.exactRowCapacity(0, 5);
|
||||
var cloned = try Page.init(cap);
|
||||
defer cloned.deinit();
|
||||
for (0..5) |y| {
|
||||
const src_row = &page.rows.ptr(page.memory)[y];
|
||||
const dst_row = &cloned.rows.ptr(cloned.memory)[y];
|
||||
try cloned.cloneRowFrom(&page, dst_row, src_row);
|
||||
}
|
||||
const cloned_cap = cloned.exactRowCapacity(0, 5);
|
||||
try testing.expectEqual(cap, cloned_cap);
|
||||
}
|
||||
}
|
||||
|
||||
test "Page exactRowCapacity single hyperlink clone" {
|
||||
// Regression test: verify a single hyperlink can be cloned with exact capacity.
|
||||
// This tests that capacityForCount properly accounts for ID 0 being reserved.
|
||||
var page = try Page.init(.{
|
||||
.cols = 10,
|
||||
.rows = 2,
|
||||
.styles = 8,
|
||||
.hyperlink_bytes = 32 * @sizeOf(hyperlink.Set.Item),
|
||||
.string_bytes = 512,
|
||||
});
|
||||
defer page.deinit();
|
||||
|
||||
// Add exactly one hyperlink to row 0
|
||||
const uri = "https://example.com";
|
||||
const id = blk: {
|
||||
const rac = page.getRowAndCell(0, 0);
|
||||
const link_id = try page.insertHyperlink(.{
|
||||
.id = .{ .implicit = 1 },
|
||||
.uri = uri,
|
||||
});
|
||||
try page.setHyperlink(rac.row, rac.cell, link_id);
|
||||
break :blk link_id;
|
||||
};
|
||||
_ = id;
|
||||
|
||||
// exactRowCapacity for just row 0 should give capacity for 1 hyperlink
|
||||
const cap = page.exactRowCapacity(0, 1);
|
||||
try testing.expectEqual(hyperlink.Set.capacityForCount(1) * @sizeOf(hyperlink.Set.Item), cap.hyperlink_bytes);
|
||||
|
||||
// Create a new page with exact capacity and clone
|
||||
var cloned = try Page.init(cap);
|
||||
defer cloned.deinit();
|
||||
|
||||
const src_row = &page.rows.ptr(page.memory)[0];
|
||||
const dst_row = &cloned.rows.ptr(cloned.memory)[0];
|
||||
|
||||
// This must not fail with HyperlinkSetOutOfMemory
|
||||
try cloned.cloneRowFrom(&page, dst_row, src_row);
|
||||
|
||||
// Verify the hyperlink was cloned correctly
|
||||
const cloned_cell = &cloned.rows.ptr(cloned.memory)[0].cells.ptr(cloned.memory)[0];
|
||||
try testing.expect(cloned_cell.hyperlink);
|
||||
}
|
||||
|
||||
test "Page exactRowCapacity hyperlink map capacity for many cells" {
|
||||
// A single hyperlink spanning many cells requires hyperlink_map capacity
|
||||
// based on cell count, not unique hyperlink count.
|
||||
const cols = 50;
|
||||
var page = try Page.init(.{
|
||||
.cols = cols,
|
||||
.rows = 2,
|
||||
.styles = 8,
|
||||
.hyperlink_bytes = 32 * @sizeOf(hyperlink.Set.Item),
|
||||
.string_bytes = 512,
|
||||
});
|
||||
defer page.deinit();
|
||||
|
||||
// Add one hyperlink spanning all 50 columns in row 0
|
||||
const uri = "https://example.com";
|
||||
const id = blk: {
|
||||
const rac = page.getRowAndCell(0, 0);
|
||||
const link_id = try page.insertHyperlink(.{
|
||||
.id = .{ .implicit = 1 },
|
||||
.uri = uri,
|
||||
});
|
||||
try page.setHyperlink(rac.row, rac.cell, link_id);
|
||||
break :blk link_id;
|
||||
};
|
||||
|
||||
// Apply same hyperlink to remaining cells in row 0
|
||||
for (1..cols) |x| {
|
||||
const rac = page.getRowAndCell(@intCast(x), 0);
|
||||
page.hyperlink_set.use(page.memory, id);
|
||||
try page.setHyperlink(rac.row, rac.cell, id);
|
||||
}
|
||||
|
||||
// exactRowCapacity must account for 50 hyperlink cells, not just 1 unique hyperlink
|
||||
const cap = page.exactRowCapacity(0, 1);
|
||||
|
||||
// The hyperlink_bytes must be large enough that layout() computes sufficient
|
||||
// hyperlink_map capacity. With hyperlink_cell_multiplier=16, we need at least
|
||||
// ceil(50/16) = 4 hyperlink entries worth of bytes for the map.
|
||||
const min_for_map = std.math.divCeil(usize, cols, hyperlink_cell_multiplier) catch 0;
|
||||
const min_hyperlink_bytes = min_for_map * @sizeOf(hyperlink.Set.Item);
|
||||
try testing.expect(cap.hyperlink_bytes >= min_hyperlink_bytes);
|
||||
|
||||
// Create a new page with exact capacity and clone - must not fail
|
||||
var cloned = try Page.init(cap);
|
||||
defer cloned.deinit();
|
||||
|
||||
const src_row = &page.rows.ptr(page.memory)[0];
|
||||
const dst_row = &cloned.rows.ptr(cloned.memory)[0];
|
||||
|
||||
// This must not fail with HyperlinkMapOutOfMemory
|
||||
try cloned.cloneRowFrom(&page, dst_row, src_row);
|
||||
|
||||
// Verify all hyperlinks were cloned correctly
|
||||
for (0..cols) |x| {
|
||||
const cloned_cell = &cloned.rows.ptr(cloned.memory)[0].cells.ptr(cloned.memory)[x];
|
||||
try testing.expect(cloned_cell.hyperlink);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,6 +64,20 @@ pub fn RefCountedSet(
|
||||
@alignOf(Id),
|
||||
));
|
||||
|
||||
/// This is the max load until the set returns OutOfMemory and
|
||||
/// requires more capacity.
|
||||
///
|
||||
/// Experimentally, this load factor works quite well.
|
||||
pub const load_factor = 0.8125;
|
||||
|
||||
/// Returns the minimum capacity needed to store `n` items,
|
||||
/// accounting for the load factor and the reserved ID 0.
|
||||
pub fn capacityForCount(n: usize) usize {
|
||||
if (n == 0) return 0;
|
||||
// +1 because ID 0 is reserved, so we need at least n+1 slots.
|
||||
return @intFromFloat(@ceil(@as(f64, @floatFromInt(n + 1)) / load_factor));
|
||||
}
|
||||
|
||||
/// Set item
|
||||
pub const Item = struct {
|
||||
/// The value this item represents.
|
||||
@@ -154,9 +168,6 @@ pub fn RefCountedSet(
|
||||
/// The returned layout `cap` property will be 1 more than the number
|
||||
/// of items that the set can actually store, since ID 0 is reserved.
|
||||
pub fn init(cap: usize) Layout {
|
||||
// Experimentally, this load factor works quite well.
|
||||
const load_factor = 0.8125;
|
||||
|
||||
assert(cap <= @as(usize, @intCast(std.math.maxInt(Id))) + 1);
|
||||
|
||||
// Zero-cap set is valid, return special case
|
||||
|
||||
@@ -125,7 +125,7 @@ pub const Handler = struct {
|
||||
}
|
||||
},
|
||||
.save_cursor => self.terminal.saveCursor(),
|
||||
.restore_cursor => try self.terminal.restoreCursor(),
|
||||
.restore_cursor => self.terminal.restoreCursor(),
|
||||
.invoke_charset => self.terminal.invokeCharset(value.bank, value.charset, value.locking),
|
||||
.configure_charset => self.terminal.configureCharset(value.slot, value.charset),
|
||||
.set_attribute => switch (value) {
|
||||
@@ -240,7 +240,7 @@ pub const Handler = struct {
|
||||
.save_cursor => if (enabled) {
|
||||
self.terminal.saveCursor();
|
||||
} else {
|
||||
try self.terminal.restoreCursor();
|
||||
self.terminal.restoreCursor();
|
||||
},
|
||||
|
||||
.enable_mode_3 => {},
|
||||
|
||||
@@ -721,7 +721,7 @@ pub const StreamHandler = struct {
|
||||
if (enabled) {
|
||||
self.terminal.saveCursor();
|
||||
} else {
|
||||
try self.terminal.restoreCursor();
|
||||
self.terminal.restoreCursor();
|
||||
}
|
||||
},
|
||||
|
||||
@@ -933,7 +933,7 @@ pub const StreamHandler = struct {
|
||||
}
|
||||
|
||||
pub inline fn restoreCursor(self: *StreamHandler) !void {
|
||||
try self.terminal.restoreCursor();
|
||||
self.terminal.restoreCursor();
|
||||
}
|
||||
|
||||
pub fn enquiry(self: *StreamHandler) !void {
|
||||
|
||||
Reference in New Issue
Block a user