aboutsummaryrefslogtreecommitdiff
path: root/vendor/libghostty-vt/src/terminal/Screen.zig
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-07-16 23:28:05 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-07-16 23:29:28 +0300
commit4a3302d19a2baa3a33e910e0088c24e780905b90 (patch)
treedcaf76fae1c82102d933c9225b17ca7ac5d3a847 /vendor/libghostty-vt/src/terminal/Screen.zig
parent550e9f8cc085f428429f5cf2da0bb2dba897e8b8 (diff)
chore: update vendored libghostty-vt
refs #276
Diffstat (limited to 'vendor/libghostty-vt/src/terminal/Screen.zig')
-rw-r--r--vendor/libghostty-vt/src/terminal/Screen.zig1111
1 files changed, 925 insertions, 186 deletions
diff --git a/vendor/libghostty-vt/src/terminal/Screen.zig b/vendor/libghostty-vt/src/terminal/Screen.zig
index becda78b..6c4db323 100644
--- a/vendor/libghostty-vt/src/terminal/Screen.zig
+++ b/vendor/libghostty-vt/src/terminal/Screen.zig
@@ -113,7 +113,7 @@ pub const SemanticPrompt = struct {
pub const SemanticClick = union(enum) {
none,
- click_events,
+ click_events: osc.semantic_prompt.ClickEvents,
cl: osc.semantic_prompt.Click,
};
};
@@ -530,20 +530,30 @@ pub fn clone(
}
// We move the top pin back in bounds to the top row.
+ const node = pages.pages.first.?;
break :start try pages.trackPin(.{
- .node = pages.pages.first.?,
- .x = if (sel.rectangle) ordered.tl.x else 0,
+ .node = node,
+ .x = if (sel.rectangle)
+ @min(ordered.tl.x, node.cols() - 1)
+ else
+ 0,
});
};
// If we got to this point it means that the selection is not
// fully out of bounds, so we move the bottom right pin back
// in bounds if it isn't already.
- const end_pin = pin_remap.get(ordered.br) orelse try pages.trackPin(.{
- .node = pages.pages.last.?,
- .x = if (sel.rectangle) ordered.br.x else pages.cols - 1,
- .y = pages.pages.last.?.data.size.rows - 1,
- });
+ const end_pin = pin_remap.get(ordered.br) orelse end: {
+ const node = pages.pages.last.?;
+ break :end try pages.trackPin(.{
+ .node = node,
+ .x = if (sel.rectangle)
+ @min(ordered.br.x, node.cols() - 1)
+ else
+ node.cols() - 1,
+ .y = node.rows() - 1,
+ });
+ };
break :sel .{
.bounds = .{ .tracked = .{
@@ -584,7 +594,7 @@ pub fn increaseCapacity(
// capacity below it will be short the ref count on our
// current style and hyperlink, so we need to init those.
const new_node = try self.pages.increaseCapacity(node, adjustment);
- const new_page: *Page = &new_node.data;
+ const new_page: *Page = new_node.page();
// Re-add the style, if the page somehow doesn't have enough
// memory to add it, we emit a warning and gracefully degrade
@@ -651,7 +661,7 @@ pub fn cursorCellEndOfPrev(self: *Screen) *pagepkg.Cell {
assert(self.cursor.y > 0);
var page_pin = self.cursor.page_pin.up(1).?;
- page_pin.x = self.pages.cols - 1;
+ page_pin.x = page_pin.node.cols() - 1;
const page_rac = page_pin.rowAndCell();
return page_rac.cell;
}
@@ -808,7 +818,7 @@ pub fn cursorDownScroll(self: *Screen) !void {
// so our cursor is in the correct place we just have to clear
// the cells.
if (self.pages.rows == 1) {
- const page: *Page = &self.cursor.page_pin.node.data;
+ const page: *Page = self.cursor.page_pin.node.page();
self.clearCells(
page,
self.cursor.page_row,
@@ -845,10 +855,9 @@ pub fn cursorDownScroll(self: *Screen) !void {
// allocate, prune scrollback, whatever.
_ = try self.pages.grow();
- self.cursorChangePin(new_pin: {
- // We do this all in a block here because referencing this pin
- // after cursorChangePin is unsafe, and we want to keep it out
- // of scope.
+ const new_pin = new_pin: {
+ // Calculate this before cursorChangePin because that function may
+ // adjust the underlying page and invalidate references to its pin.
// If our pin page change it means that the page that the pin
// was on was pruned. In this case, grow() moves the pin to
@@ -874,10 +883,27 @@ pub fn cursorDownScroll(self: *Screen) !void {
}
break :new_pin page_pin;
- });
- const page_rac = self.cursor.page_pin.rowAndCell();
- self.cursor.page_row = page_rac.row;
- self.cursor.page_cell = page_rac.cell;
+ };
+
+ if (self.cursor.page_pin.node == new_pin.node) {
+ // Scrolling normally stays within one page. Resolve the page once
+ // while refreshing the cursor pointers. The cursor already holds
+ // live row and cell pointers into this mapping, and grow does not
+ // compress pages, so the node must still be resident here.
+ self.cursorMarkDirty();
+ const page = new_pin.node.pageAssumeResident();
+ const page_rac = page.getRowAndCell(new_pin.x, new_pin.y);
+ self.cursor.page_pin.* = new_pin;
+ self.cursor.page_row = page_rac.row;
+ self.cursor.page_cell = page_rac.cell;
+ } else {
+ // Crossing a page may require migrating the cursor's style and
+ // hyperlink references, so retain the general path here.
+ self.cursorChangePin(new_pin);
+ const page_rac = self.cursor.page_pin.rowAndCell();
+ self.cursor.page_row = page_rac.row;
+ self.cursor.page_cell = page_rac.cell;
+ }
// Our new row is always dirty
self.cursorMarkDirty();
@@ -885,7 +911,7 @@ pub fn cursorDownScroll(self: *Screen) !void {
// Clear the new row so it gets our bg color. We only do this
// if we have a bg color at all.
if (self.cursor.style.bg_color != .none) {
- const page: *Page = &self.cursor.page_pin.node.data;
+ const page: *Page = self.cursor.page_pin.node.page();
self.clearCells(
page,
self.cursor.page_row,
@@ -934,8 +960,8 @@ pub fn cursorScrollAbove(self: *Screen) !void {
// lot cheaper in 99% of cases.
const old_pin = self.cursor.page_pin.*;
- if (try self.pages.grow()) |_| {
- try self.cursorScrollAboveRotate();
+ if (try self.pages.grow()) |new_node| {
+ try self.cursorScrollAboveRotate(new_node);
} else {
// In this case, it means grow() didn't allocate a new page.
@@ -950,11 +976,13 @@ pub fn cursorScrollAbove(self: *Screen) !void {
self.cursor.page_pin.* = self.cursor.page_pin.down(1).?;
const pin = self.cursor.page_pin;
- const page: *Page = &self.cursor.page_pin.node.data;
+ const page: *Page = self.cursor.page_pin.node.page();
// Rotate the rows so that the newly created empty row is at the
// beginning. e.g. [ 0 1 2 3 ] in to [ 3 0 1 2 ].
var rows = page.rows.ptr(page.memory.ptr);
+ // Rotating this suffix changes which logical row its coordinates identify.
+ self.pages.invalidateNodeLayout(pin.node);
fastmem.rotateOnceR(Row, rows[pin.y..page.size.rows]);
// Mark the whole page as dirty.
@@ -988,7 +1016,7 @@ pub fn cursorScrollAbove(self: *Screen) !void {
// 1 |5E00000000| | 4
// +----------+ :
// +-------------+
- try self.cursorScrollAboveRotate();
+ try self.cursorScrollAboveRotate(null);
}
}
@@ -1003,7 +1031,10 @@ pub fn cursorScrollAbove(self: *Screen) !void {
}
}
-fn cursorScrollAboveRotate(self: *Screen) !void {
+fn cursorScrollAboveRotate(
+ self: *Screen,
+ fresh_node: ?*PageList.List.Node,
+) !void {
self.cursorChangePin(self.cursor.page_pin.down(1).?);
// Go through each of the pages following our pin, shift all rows
@@ -1011,11 +1042,17 @@ fn cursorScrollAboveRotate(self: *Screen) !void {
var current = self.pages.pages.last.?;
while (current != self.cursor.page_pin.node) : (current = current.prev.?) {
const prev = current.prev.?;
- const prev_page = &prev.data;
- const cur_page = &current.data;
+ const prev_page = prev.page();
+ const cur_page = current.page();
const prev_rows = prev_page.rows.ptr(prev_page.memory.ptr);
const cur_rows = cur_page.rows.ptr(cur_page.memory.ptr);
+ // A newly allocated tail has no earlier references to invalidate.
+ if (fresh_node == null or current != fresh_node.?) {
+ // Rotating this page moves every cached row coordinate down by one.
+ self.pages.invalidateNodeLayout(current);
+ }
+
// Rotate the pages down: [ 0 1 2 3 ] => [ 3 0 1 2 ]
fastmem.rotateOnceR(Row, cur_rows[0..cur_page.size.rows]);
@@ -1033,8 +1070,10 @@ fn cursorScrollAboveRotate(self: *Screen) !void {
// Our current is our cursor page, we need to rotate down from
// our cursor and clear our row.
assert(current == self.cursor.page_pin.node);
- const cur_page = &current.data;
+ const cur_page = current.page();
const cur_rows = cur_page.rows.ptr(cur_page.memory.ptr);
+ // Rotating the cursor-page suffix changes its cached row coordinates.
+ self.pages.invalidateNodeLayout(current);
fastmem.rotateOnceR(Row, cur_rows[self.cursor.page_pin.y..cur_page.size.rows]);
self.clearCells(
cur_page,
@@ -1055,6 +1094,161 @@ fn cursorScrollAboveRotate(self: *Screen) !void {
self.cursor.page_cell = page_rac.cell;
}
+/// Scroll a full-width scroll region that ends at the cursor row up by
+/// one row. The cursor must be on the bottom row of the region. `limit`
+/// is the number of rows in the region above the cursor (region height
+/// minus one) and must be at least 1.
+///
+/// The top row of the region is discarded (NOT moved into scrollback).
+/// All other rows in the region shift up by one and the cursor row
+/// becomes a blank row, filled with the current background color (like
+/// other scroll operations such as deleteLines).
+///
+/// The cursor stays at the same screen position (the new blank row).
+/// Content outside of the region is unmodified.
+///
+/// This is a very hot path for scroll region usage (e.g. a program
+/// on the alt screen using DECSTBM and scrolling via LF/IND) so this
+/// is optimized for the common case where the full region is within
+/// a single page.
+pub fn cursorScrollRegionUp(self: *Screen, limit: usize) !void {
+ assert(limit >= 1);
+ assert(self.cursor.y >= limit);
+ defer self.assertIntegrity();
+
+ const pin: *Pin = self.cursor.page_pin;
+
+ // If the region crosses a page boundary we take a slower path. This
+ // is rare: it requires the active area to span multiple pages with
+ // the split point inside the scroll region.
+ if (pin.y < limit) return try self.cursorScrollRegionUpSlow(limit);
+
+ // Fast path: the entire region is in a single page. We can clear
+ // the top row and rotate it down to the cursor row, updating any
+ // tracked pins along the way.
+ //
+ // The cursor's cached row and cell pointers refer into this mapping.
+ // PageList cannot compress the cursor page while those pointers are
+ // installed, so the node is known to be resident here.
+ const page: *Page = pin.node.pageAssumeResident();
+ const rows = page.rows.ptr(page.memory.ptr)[pin.y - limit ..][0 .. limit + 1];
+
+ // Clear the erased (top) row.
+ {
+ const row: *Row = &rows[0];
+
+ // Whether our blank cell is a plain zero cell. This is true
+ // unless the cursor has a background color set (see blankCell).
+ const blank_is_zero = self.cursor.style_id == style.default_id or
+ self.cursor.style.bg_color == .none;
+
+ if (!row.managedMemory() and blank_is_zero) {
+ // Hot path: the row has no managed memory (styles,
+ // graphemes, hyperlinks) and our blank is zero so this is
+ // a straight zero fill. This is the overwhelmingly common
+ // case for scroll region usage.
+ const cells = page.getCells(row);
+ @memset(@as([]u64, @ptrCast(cells)), 0);
+ } else {
+ // The generic clear handles managed memory and fills the
+ // row with our blank cell, preserving the background color.
+ self.clearCells(page, row, page.getCells(row));
+ }
+ }
+
+ // Rotate the region rows so the now-blank top row moves to the
+ // bottom (the cursor row) and everything else shifts up by one.
+ // Rotating the region changes which logical row its coordinates identify.
+ self.pages.invalidateNodeLayout(pin.node);
+ fastmem.rotateOnce(Row, rows);
+
+ // Mark the whole page as dirty.
+ //
+ // Technically we only need to mark the rotated rows but this is
+ // a hot function, so we want to minimize work.
+ page.dirty = true;
+
+ // If our viewport is a pin and it's within the rotated region
+ // then we need to shift its cached row offset up. See
+ // PageList.eraseRowBounded for details; this mirrors that logic.
+ if (self.pages.viewport == .pin) viewport: {
+ if (self.pages.viewport_pin_row_offset) |*v| {
+ const p = self.pages.viewport_pin;
+ if (p.node != pin.node or
+ p.y < pin.y - limit or
+ p.y > pin.y or
+ p.y == 0) break :viewport;
+ v.* -= 1;
+ }
+ }
+
+ // Update tracked pins within the region since their rows moved up
+ // by one. The cursor's own pin is skipped because the cursor stays
+ // at the region bottom (the new blank row).
+ const pin_keys = self.pages.tracked_pins.keys();
+ for (pin_keys) |p| {
+ if (p.node != pin.node or
+ p == pin or
+ p.y < pin.y - limit or
+ p.y > pin.y) continue;
+ if (p.y == 0) p.x = 0 else p.y -= 1;
+ }
+
+ // The cursor pin is unchanged, but the Row structure at the pin
+ // position now contains the blank row, so we need to refresh our
+ // cached row/cell pointers. We compute them directly from the row
+ // slice we already have rather than going through the pin since
+ // this is a hot path.
+ const cursor_row: *Row = &rows[limit];
+ self.cursor.page_row = cursor_row;
+ self.cursor.page_cell = &page.getCells(cursor_row)[self.cursor.x];
+}
+
+/// Slow path for cursorScrollRegionUp: the scroll region spans
+/// multiple pages so we use the generic PageList erase machinery.
+fn cursorScrollRegionUpSlow(self: *Screen, limit: usize) !void {
+ // The call to eraseRowBounded below will move our tracked cursor
+ // pin up by one row since it is inside the erased region, but we
+ // don't actually want that: the cursor stays put, on the new blank
+ // row at the region bottom. We keep the old pin and put it back
+ // after, exactly like cursorDownScroll does for the no-scrollback
+ // case.
+ //
+ // This matters beyond performance: when the cursor is on the first
+ // row of a page, eraseRowBounded moves the tracked pin to the
+ // previous page. Moving the cursor back down with cursorDown would
+ // then cross pages via cursorChangePin, which migrates the cursor
+ // style refcount from a page that never held it, corrupting the
+ // style accounting.
+ const old_pin = self.cursor.page_pin.*;
+
+ try self.pages.eraseRowBounded(
+ .{ .active = .{ .y = @intCast(self.cursor.y - limit) } },
+ limit,
+ );
+
+ // We don't use `cursorChangePin` here because we aren't actually
+ // changing the pin, we're keeping it the same. Since the page
+ // never changes, the cursor's style ref stays valid and no style
+ // accounting needs to be updated.
+ self.cursor.page_pin.* = old_pin;
+
+ // We do, however, need to refresh the cached page row and cell,
+ // because the row contents at our pin position changed (it now
+ // contains the blank row).
+ const page_rac = self.cursor.page_pin.rowAndCell();
+ self.cursor.page_row = page_rac.row;
+ self.cursor.page_cell = page_rac.cell;
+
+ // eraseRowBounded clears the new row with zero cells so if our
+ // blank cell isn't zero (bg color is set) we need to fill it.
+ const blank = self.blankCell();
+ if (!blank.isZero()) {
+ const cells: [*]Cell = @ptrCast(self.cursor.page_cell);
+ @memset((cells - self.cursor.x)[0..self.pages.cols], blank);
+ }
+}
+
/// Move the cursor down if we're not at the bottom of the screen. Otherwise
/// scroll. Currently only used for testing.
inline fn cursorDownOrScroll(self: *Screen) !void {
@@ -1107,7 +1301,7 @@ pub fn cursorCopy(self: *Screen, other: Cursor, opts: struct {
// If the other cursor had a hyperlink, add it to ours.
if (opts.hyperlink and other.hyperlink_id != 0) {
// Get the hyperlink from the other cursor's page.
- const other_page = &other.page_pin.node.data;
+ const other_page = other.page_pin.node.page();
const other_link = other_page.hyperlink_set.get(other_page.memory, other.hyperlink_id);
const uri = other_link.uri.slice(other_page.memory);
@@ -1162,7 +1356,7 @@ inline fn cursorChangePin(self: *Screen, new: Pin) void {
// Release the style directly from the old page instead of going through
// manualStyleUpdate, because the cursor position may have already been
// updated but the pin has not, which would fail integrity checks.
- const old_page: *Page = &self.cursor.page_pin.node.data;
+ const old_page: *Page = self.cursor.page_pin.node.page();
old_page.styles.release(old_page.memory, self.cursor.style_id);
self.cursor.style = .{};
self.cursor.style_id = style.default_id;
@@ -1170,7 +1364,7 @@ inline fn cursorChangePin(self: *Screen, new: Pin) void {
// If we have a hyperlink then we need to release it from the old page.
if (self.cursor.hyperlink != null) {
- const old_page: *Page = &self.cursor.page_pin.node.data;
+ const old_page: *Page = self.cursor.page_pin.node.page();
old_page.hyperlink_set.release(old_page.memory, self.cursor.hyperlink_id);
}
@@ -1245,12 +1439,12 @@ pub fn cursorResetWrap(self: *Screen) void {
// If the last cell in the row is a spacer head we need to clear it.
const cells = self.cursor.page_pin.cells(.all);
- const cell = cells[self.cursor.page_pin.node.data.size.cols - 1];
+ const cell = cells[self.cursor.page_pin.node.cols() - 1];
if (cell.wide == .spacer_head) {
self.clearCells(
- &self.cursor.page_pin.node.data,
+ self.cursor.page_pin.node.page(),
page_row,
- cells[self.cursor.page_pin.node.data.size.cols - 1 ..][0..1],
+ cells[self.cursor.page_pin.node.cols() - 1 ..][0..1],
);
}
}
@@ -1347,19 +1541,19 @@ pub fn clearRows(
var it = self.pages.pageIterator(.right_down, tl, bl);
while (it.next()) |chunk| {
+ const page = chunk.node.page();
for (chunk.rows()) |*row| {
const cells_offset = row.cells;
- const cells_multi: [*]Cell = row.cells.ptr(chunk.node.data.memory);
- const cells = cells_multi[0..self.pages.cols];
+ const cells = page.getCells(row);
// Clear all cells
if (protected) {
- self.clearUnprotectedCells(&chunk.node.data, row, cells);
+ self.clearUnprotectedCells(page, row, cells);
// We need to preserve other row attributes since we only
// cleared unprotected cells.
row.cells = cells_offset;
} else {
- self.clearCells(&chunk.node.data, row, cells);
+ self.clearCells(page, row, cells);
row.* = .{ .cells = cells_offset };
}
@@ -1377,6 +1571,8 @@ pub fn clearCells(
row: *Row,
cells: []Cell,
) void {
+ if (cells.len == 0) return;
+
// This whole operation does unsafe things, so we just want to assert
// the end state.
page.pauseIntegrityChecks(true);
@@ -1408,7 +1604,7 @@ pub fn clearCells(
// If we have no left/right scroll region we can be sure
// that we've cleared all the graphemes, so we clear the
// flag, otherwise we ask the page to update the flag.
- if (cells.len == self.pages.cols) {
+ if (cells.len == page.size.cols) {
row.grapheme = false;
} else {
page.updateRowGraphemeFlag(row);
@@ -1424,7 +1620,7 @@ pub fn clearCells(
// If we have no left/right scroll region we can be sure
// that we've cleared all the hyperlinks, so we clear the
// flag, otherwise we ask the page to update the flag.
- if (cells.len == self.pages.cols) {
+ if (cells.len == page.size.cols) {
row.hyperlink = false;
} else {
page.updateRowHyperlinkFlag(row);
@@ -1432,15 +1628,27 @@ pub fn clearCells(
}
if (row.styled) {
- for (cells) |*cell| {
- if (cell.hasStyling())
- page.styles.release(page.memory, cell.style_id);
+ // Styled cells overwhelmingly come in runs sharing the same
+ // style (e.g. a colored status bar or a highlighted region),
+ // so group them and release each run with a single ref-count
+ // update rather than per cell.
+ var i: usize = 0;
+ while (i < cells.len) {
+ const id = cells[i].style_id;
+ if (id == style.default_id) {
+ i += 1;
+ continue;
+ }
+ var j = i + 1;
+ while (j < cells.len and cells[j].style_id == id) j += 1;
+ page.styles.releaseMultiple(page.memory, id, @intCast(j - i));
+ i = j;
}
// If we have no left/right scroll region we can be sure
// that we've cleared all the styles, so we clear the
// flag, otherwise we ask the page to update the flag.
- if (cells.len == self.pages.cols) {
+ if (cells.len == page.size.cols) {
row.styled = false;
} else {
page.updateRowStyledFlag(row);
@@ -1449,7 +1657,7 @@ pub fn clearCells(
if (comptime build_options.kitty_graphics) {
if (row.kitty_virtual_placeholder and
- cells.len == self.pages.cols)
+ cells.len == page.size.cols)
{
for (cells) |c| {
if (c.codepoint() == kitty.graphics.unicode.placeholder) {
@@ -1525,12 +1733,12 @@ pub fn splitCellBoundary(
self: *Screen,
x: size.CellCountInt,
) void {
- const page = &self.cursor.page_pin.node.data;
+ const page = self.cursor.page_pin.node.page();
page.pauseIntegrityChecks(true);
defer page.pauseIntegrityChecks(false);
- const cols = self.cursor.page_pin.node.data.size.cols;
+ const cols = self.cursor.page_pin.node.cols();
// `x` may be up to an INCLUDING `cols`, since that signifies splitting
// the boundary to the right of the final cell in the row.
@@ -1574,12 +1782,12 @@ pub fn splitCellBoundary(
if (self.cursor.page_pin.up(1)) |p_row| {
const p_rac = p_row.rowAndCell();
const p_cells = p_row.cells(.all);
- const p_cell = p_cells[p_row.node.data.size.cols - 1];
+ const p_cell = p_cells[p_row.node.cols() - 1];
if (p_cell.wide == .spacer_head) {
self.clearCells(
- &p_row.node.data,
+ p_row.node.page(),
p_rac.row,
- p_cells[p_row.node.data.size.cols - 1 ..][0..1],
+ p_cells[p_row.node.cols() - 1 ..][0..1],
);
}
}
@@ -1690,7 +1898,7 @@ pub inline fn resize(
if (self.cursor.hyperlink_id != 0) {
// Note we do NOT use endHyperlink because we want to keep
// our allocated self.cursor.hyperlink valid.
- var page = &self.cursor.page_pin.node.data;
+ var page = self.cursor.page_pin.node.page();
page.hyperlink_set.release(page.memory, self.cursor.hyperlink_id);
self.cursor.hyperlink_id = 0;
self.cursor.hyperlink = null;
@@ -1727,7 +1935,7 @@ pub inline fn resize(
// For `.last`, only clear the current line where the cursor is.
// For `.true`, clear all prompt lines starting from the beginning.
.last => {
- const page = &self.cursor.page_pin.node.data;
+ const page = self.cursor.page_pin.node.page();
const row = self.cursor.page_row;
const cells = page.getCells(row);
self.clearCells(page, row, cells);
@@ -1753,7 +1961,7 @@ pub inline fn resize(
// shell is going to expect this space to be available.
var it = start.rowIterator(.right_down, null);
while (it.next()) |pin| {
- const page = &pin.node.data;
+ const page = pin.node.page();
const row = pin.rowAndCell().row;
const cells = page.getCells(row);
self.clearCells(page, row, cells);
@@ -1992,6 +2200,12 @@ pub fn setAttribute(
.unknown => return,
}
+ // If the attribute didn't change our style then we can skip the
+ // style update entirely: our current style ID is already correct.
+ // This is a common case in the wild where programs re-assert the
+ // same style repeatedly (e.g. per span or per line).
+ if (self.cursor.style.eql(old_style)) return;
+
try self.manualStyleUpdate();
}
@@ -2011,7 +2225,7 @@ pub fn setAttribute(
/// 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;
+ var page: *Page = self.cursor.page_pin.node.page();
// std.log.warn("active styles={}", .{page.styles.count()});
@@ -2062,7 +2276,7 @@ pub fn manualStyleUpdate(self: *Screen) PageList.IncreaseCapacityError!void {
},
};
- page = &node.data;
+ page = node.page();
break :id page.styles.add(
page.memory,
self.cursor.style,
@@ -2110,13 +2324,13 @@ fn splitForCapacity(
) 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(
+ const bytes_above = Page.layout(pin.node.page().exactRowCapacity(
0,
pin.y + 1,
)).total_size;
- const bytes_below = Page.layout(pin.node.data.exactRowCapacity(
+ const bytes_below = Page.layout(pin.node.page().exactRowCapacity(
pin.y,
- pin.node.data.size.rows,
+ pin.node.rows(),
)).total_size;
// We need to track the old cursor pin because if our split
@@ -2152,8 +2366,8 @@ pub fn appendGrapheme(
cell: *Cell,
cp: u21,
) PageList.IncreaseCapacityError!void {
- defer self.cursor.page_pin.node.data.assertIntegrity();
- self.cursor.page_pin.node.data.appendGrapheme(
+ defer self.cursor.page_pin.node.page().assertIntegrity();
+ self.cursor.page_pin.node.page().appendGrapheme(
self.cursor.page_row,
cell,
cp,
@@ -2184,7 +2398,7 @@ pub fn appendGrapheme(
.gt => self.cursorCellRight(@intCast(cell_idx - self.cursor.x)),
};
- self.cursor.page_pin.node.data.appendGrapheme(
+ self.cursor.page_pin.node.page().appendGrapheme(
self.cursor.page_row,
reloaded_cell,
cp,
@@ -2214,13 +2428,13 @@ pub fn startHyperlink(
.id = if (id_) |id| .{
.explicit = id,
} else implicit: {
- defer self.cursor.hyperlink_implicit_id += 1;
+ defer self.cursor.hyperlink_implicit_id +%= 1;
break :implicit .{ .implicit = self.cursor.hyperlink_implicit_id };
},
};
errdefer switch (link.id) {
.explicit => {},
- .implicit => self.cursor.hyperlink_implicit_id -= 1,
+ .implicit => self.cursor.hyperlink_implicit_id -%= 1,
};
// Loop until we have enough page memory to add the hyperlink
@@ -2261,9 +2475,6 @@ fn startHyperlinkOnce(
self: *Screen,
source: hyperlink.Hyperlink,
) (Allocator.Error || Page.InsertHyperlinkError)!void {
- // End any prior hyperlink
- self.endHyperlink();
-
// Allocate our new Hyperlink entry in non-page memory. This
// lets us quickly get access to URI, ID.
const link = try self.alloc.create(hyperlink.Hyperlink);
@@ -2271,8 +2482,12 @@ fn startHyperlinkOnce(
link.* = try source.dupe(self.alloc);
errdefer link.deinit(self.alloc);
+ // End any prior hyperlink only after duplicating the new value. The
+ // source slices are allowed to reference our current hyperlink.
+ self.endHyperlink();
+
// Insert the hyperlink into page memory
- var page = &self.cursor.page_pin.node.data;
+ var page = self.cursor.page_pin.node.page();
const id: hyperlink.Id = try page.insertHyperlink(link.*);
// Save it all
@@ -2299,7 +2514,7 @@ pub fn endHyperlink(self: *Screen) void {
// how RefCountedSet works). This causes some memory fragmentation but
// is fine because if it is ever pruned the context deleted callback
// will be called.
- var page: *Page = &self.cursor.page_pin.node.data;
+ var page: *Page = self.cursor.page_pin.node.page();
page.hyperlink_set.release(page.memory, self.cursor.hyperlink_id);
self.cursor.hyperlink.?.deinit(self.alloc);
self.alloc.destroy(self.cursor.hyperlink.?);
@@ -2311,7 +2526,7 @@ pub fn endHyperlink(self: *Screen) void {
pub fn cursorSetHyperlink(self: *Screen) PageList.IncreaseCapacityError!void {
assert(self.cursor.hyperlink_id != 0);
- var page = &self.cursor.page_pin.node.data;
+ var page = self.cursor.page_pin.node.page();
if (page.setHyperlink(
self.cursor.page_row,
self.cursor.page_cell,
@@ -2349,9 +2564,12 @@ pub fn cursorSetHyperlink(self: *Screen) PageList.IncreaseCapacityError!void {
.string_bytes,
);
assert(new_node == self.cursor.page_pin.node);
- page = &new_node.data;
+ page = new_node.page();
}
+ // The hyperlink map is fixed-capacity, so reaching this error
+ // means live entries fill the usable map capacity and the page
+ // must grow.
_ = try self.increaseCapacity(
self.cursor.page_pin.node,
.hyperlink_bytes,
@@ -2425,8 +2643,28 @@ pub fn select(self: *Screen, sel_: ?Selection) Allocator.Error!void {
const tracked_sel = if (sel.tracked()) sel else try sel.track(self);
errdefer if (!sel.tracked()) tracked_sel.deinit(self);
- // Untrack prior selection
- if (self.selection) |*old| old.deinit(self);
+ // Untrack prior selection pins that aren't also owned by the replacement.
+ // A caller may pass our current tracked selection back to us by value, so
+ // releasing both old pins unconditionally would leave the replacement
+ // pointing at freed pool entries.
+ if (self.selection) |old| {
+ const new_bounds = tracked_sel.bounds.tracked;
+ switch (old.bounds) {
+ .untracked => old.deinit(self),
+ .tracked => |old_bounds| {
+ if (old_bounds.start != new_bounds.start and
+ old_bounds.start != new_bounds.end)
+ {
+ self.pages.untrackPin(old_bounds.start);
+ }
+ if (old_bounds.end != new_bounds.start and
+ old_bounds.end != new_bounds.end)
+ {
+ self.pages.untrackPin(old_bounds.end);
+ }
+ },
+ }
+ }
self.selection = tracked_sel;
self.dirty.selection = true;
}
@@ -2551,7 +2789,7 @@ pub fn selectLine(self: *const Screen, opts: SelectLine) ?Selection {
// First, check the current row for semantic boundaries before the clicked position.
if (semantic_prompt_state) |v| {
const row = it_prev.rowAndCell().row;
- const cells = it_prev.node.data.getCells(row);
+ const cells = it_prev.node.page().getCells(row);
// Scan backwards from clicked position to find where our content starts
for (0..opts.pin.x + 1) |i| {
const x_rev = opts.pin.x - i;
@@ -2579,7 +2817,7 @@ pub fn selectLine(self: *const Screen, opts: SelectLine) ?Selection {
if (semantic_prompt_state) |v| {
// We need to check every cell in this row in reverse
// order since we're going up and back.
- const cells = p.node.data.getCells(row);
+ const cells = p.node.page().getCells(row);
for (0..cells.len) |x| {
const x_rev = cells.len - 1 - x;
const cell = cells[x_rev];
@@ -2607,7 +2845,7 @@ pub fn selectLine(self: *const Screen, opts: SelectLine) ?Selection {
if (semantic_prompt_state) |v| {
// We need to check every cell in this row
- const cells = p.node.data.getCells(row);
+ const cells = p.node.page().getCells(row);
// If this is our pin row we can start from our x because
// the start_pin logic already found the real start.
@@ -2620,7 +2858,7 @@ pub fn selectLine(self: *const Screen, opts: SelectLine) ?Selection {
// so we scan forward to find where our content ends.
if (start_offset == 0 and cells[0].semantic_content != v) {
var prev = p.up(1).?;
- prev.x = p.node.data.size.cols - 1;
+ prev.x = prev.node.cols() - 1;
break :end_pin prev;
}
@@ -2636,7 +2874,7 @@ pub fn selectLine(self: *const Screen, opts: SelectLine) ?Selection {
if (!row.wrap) {
var copy = p;
- copy.x = p.node.data.size.cols - 1;
+ copy.x = p.node.cols() - 1;
break :end_pin copy;
}
}
@@ -2826,7 +3064,7 @@ pub fn selectWord(
// If we are going to the next row and it isn't wrapped, we
// return the previous.
- if (p.x == p.node.data.size.cols - 1 and !rac.row.wrap) {
+ if (p.x == p.node.cols() - 1 and !rac.row.wrap) {
break :end p;
}
@@ -2846,7 +3084,7 @@ pub fn selectWord(
// If we are going to the next row and it isn't wrapped, we
// return the previous.
- if (p.x == p.node.data.size.cols - 1 and !rac.row.wrap) {
+ if (p.x == p.node.cols() - 1 and !rac.row.wrap) {
break :start prev;
}
@@ -2898,7 +3136,7 @@ pub fn selectOutput(self: *Screen, pin: Pin) ?Selection {
// the prompt and will trim the trailing whitespace.
const start_pin = self.pages.getTopLeft(.screen);
var end_pin = next.up(1) orelse return null;
- end_pin.x = end_pin.node.data.size.cols - 1;
+ end_pin.x = end_pin.node.cols() - 1;
var cell_it = end_pin.cellIterator(.left_up, start_pin);
while (cell_it.next()) |p| {
const cell = p.rowAndCell().cell;
@@ -3027,7 +3265,7 @@ fn promptClickLine(self: *Screen, click_pin: Pin) PromptClickMove {
);
row_it: while (row_it.next()) |row_pin| {
const rac = row_pin.rowAndCell();
- const cells = row_pin.node.data.getCells(rac.row);
+ const cells = row_pin.node.page().getCells(rac.row);
// Determine if this row is our cursor.
const is_cursor_row = row_pin.node == cursor_pin.node and
@@ -3103,7 +3341,7 @@ fn promptClickLine(self: *Screen, click_pin: Pin) PromptClickMove {
);
row_it: while (row_it.next()) |row_pin| {
const rac = row_pin.rowAndCell();
- const cells = row_pin.node.data.getCells(rac.row);
+ const cells = row_pin.node.page().getCells(rac.row);
// Determine the length of the cells we look at in this row.
const end_len: usize = end_len: {
@@ -3253,7 +3491,7 @@ pub fn testWriteString(self: *Screen, text: []const u8) !void {
break :cell cell;
};
- try self.cursor.page_pin.node.data.appendGrapheme(
+ try self.cursor.page_pin.node.page().appendGrapheme(
self.cursor.page_row,
cell,
c,
@@ -3287,7 +3525,7 @@ pub fn testWriteString(self: *Screen, text: []const u8) !void {
// If we have a ref-counted style, increase.
if (self.cursor.style_id != style.default_id) {
- const page = self.cursor.page_pin.node.data;
+ const page = self.cursor.page_pin.node.page();
page.styles.use(page.memory, self.cursor.style_id);
self.cursor.page_row.styled = true;
}
@@ -3344,7 +3582,7 @@ pub fn testWriteString(self: *Screen, text: []const u8) !void {
// If we have a ref-counted style, increase twice.
if (self.cursor.style_id != style.default_id) {
- const page = self.cursor.page_pin.node.data;
+ const page = self.cursor.page_pin.node.page();
page.styles.use(page.memory, self.cursor.style_id);
page.styles.use(page.memory, self.cursor.style_id);
self.cursor.page_row.styled = true;
@@ -3484,7 +3722,7 @@ test "Screen cursorCopy style deref" {
var s2 = try Screen.init(alloc, .{ .cols = 10, .rows = 10, .max_scrollback = 0 });
defer s2.deinit();
- const page = &s2.cursor.page_pin.node.data;
+ const page = s2.cursor.page_pin.node.page();
// Bold should create our style
try s2.setAttribute(.{ .bold = {} });
@@ -3508,15 +3746,15 @@ test "Screen cursorCopy style deref new page" {
defer s2.deinit();
// We need to get the cursor on a new page.
- const first_page_size = s2.pages.pages.first.?.data.capacity.rows;
+ const first_page_size = s2.pages.pages.first.?.capacity().rows;
// Fill the scrollback with blank lines until
// there are only 5 rows left on the first page.
- s2.pages.pages.first.?.data.pauseIntegrityChecks(true);
+ s2.pages.pages.first.?.page().pauseIntegrityChecks(true);
for (0..first_page_size - 5) |_| {
try s2.testWriteString("\n");
}
- s2.pages.pages.first.?.data.pauseIntegrityChecks(false);
+ s2.pages.pages.first.?.page().pauseIntegrityChecks(false);
try s2.testWriteString("1\n2\n3\n4\n5\n6\n7\n8\n9\n10");
@@ -3542,10 +3780,10 @@ test "Screen cursorCopy style deref new page" {
// +-------------+
// This should be PAGE 1
- const page = &s2.cursor.page_pin.node.data;
+ const page = s2.cursor.page_pin.node.page();
// It should be the last page in the list.
- try testing.expectEqual(&s2.pages.pages.last.?.data, page);
+ try testing.expectEqual(s2.pages.pages.last.?.page(), page);
// It should have a previous page.
try testing.expect(s2.cursor.page_pin.node.prev != null);
@@ -3564,7 +3802,7 @@ test "Screen cursorCopy style deref new page" {
try testing.expect(!s2.cursor.style.flags.bold);
try testing.expectEqual(@as(usize, 0), page.styles.count());
// The page after the page the cursor is now in should be page 1.
- try testing.expectEqual(page, &s2.cursor.page_pin.node.next.?.data);
+ try testing.expectEqual(page, s2.cursor.page_pin.node.next.?.page());
// The cursor should be at 0, 0
try testing.expect(s2.cursor.x == 0);
try testing.expect(s2.cursor.y == 0);
@@ -3580,7 +3818,7 @@ test "Screen cursorCopy style copy" {
var s2 = try Screen.init(alloc, .{ .cols = 10, .rows = 10, .max_scrollback = 0 });
defer s2.deinit();
- const page = &s2.cursor.page_pin.node.data;
+ const page = s2.cursor.page_pin.node.page();
try s2.cursorCopy(s.cursor, .{});
try testing.expect(s2.cursor.style.flags.bold);
try testing.expectEqual(@as(usize, 1), page.styles.count());
@@ -3595,7 +3833,7 @@ test "Screen cursorCopy hyperlink deref" {
var s2 = try Screen.init(alloc, .{ .cols = 10, .rows = 10, .max_scrollback = 0 });
defer s2.deinit();
- const page = &s2.cursor.page_pin.node.data;
+ const page = s2.cursor.page_pin.node.page();
// Create a hyperlink for the cursor.
try s2.startHyperlink("https://example.com/", null);
@@ -3608,6 +3846,54 @@ test "Screen cursorCopy hyperlink deref" {
try testing.expect(s2.cursor.hyperlink_id == 0);
}
+test "Screen write regrows compacted page capacity" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var s = try Screen.init(alloc, .{
+ .cols = 80,
+ .rows = 24,
+ .max_scrollback = 0,
+ });
+ defer s.deinit();
+
+ // Compact the active page so every managed capacity dimension is
+ // zero, then reload the cursor since its cached row/cell pointers
+ // point into the replaced page.
+ {
+ const node = (try s.pages.compact(s.cursor.page_pin.node)).?;
+ try testing.expectEqual(0, node.capacity().styles);
+ try testing.expectEqual(0, node.capacity().grapheme_bytes);
+ try testing.expectEqual(0, node.capacity().string_bytes);
+ try testing.expectEqual(0, node.capacity().hyperlink_bytes);
+ s.cursorReload();
+ }
+
+ // Styled write: exercises the manualStyleUpdate single-retry
+ // path. Prior to increaseCapacity handling zero dimensions, the
+ // retry would fail and the style would be dropped.
+ try s.setAttribute(.{ .bold = {} });
+ try s.testWriteString("A");
+
+ // Grapheme write: exercises the appendGrapheme single-retry path.
+ // We can't use testWriteString here because it appends graphemes
+ // directly on the page without the capacity retry.
+ try s.testWriteString("a");
+ try s.appendGrapheme(s.cursorCellLeft(1), 0x0301);
+
+ // Hyperlink: exercises the startHyperlink retry loop, which used
+ // to loop forever when capacity growth from zero didn't grow.
+ try s.startHyperlink("https://example.com/", null);
+ try s.testWriteString("B");
+ s.endHyperlink();
+
+ // Verify the content landed on the page.
+ const page = s.cursor.page_pin.node.page();
+ try testing.expect(page.styles.count() >= 1);
+ try testing.expect(page.hyperlink_set.count() >= 1);
+ try testing.expect(page.graphemeCount() >= 1);
+}
+
test "Screen cursorCopy hyperlink deref new page" {
const testing = std.testing;
const alloc = testing.allocator;
@@ -3619,15 +3905,15 @@ test "Screen cursorCopy hyperlink deref new page" {
defer s2.deinit();
// We need to get the cursor on a new page.
- const first_page_size = s2.pages.pages.first.?.data.capacity.rows;
+ const first_page_size = s2.pages.pages.first.?.capacity().rows;
// Fill the scrollback with blank lines until
// there are only 5 rows left on the first page.
- s2.pages.pages.first.?.data.pauseIntegrityChecks(true);
+ s2.pages.pages.first.?.page().pauseIntegrityChecks(true);
for (0..first_page_size - 5) |_| {
try s2.testWriteString("\n");
}
- s2.pages.pages.first.?.data.pauseIntegrityChecks(false);
+ s2.pages.pages.first.?.page().pauseIntegrityChecks(false);
try s2.testWriteString("1\n2\n3\n4\n5\n6\n7\n8\n9\n10");
@@ -3653,10 +3939,10 @@ test "Screen cursorCopy hyperlink deref new page" {
// +-------------+
// This should be PAGE 1
- const page = &s2.cursor.page_pin.node.data;
+ const page = s2.cursor.page_pin.node.page();
// It should be the last page in the list.
- try testing.expectEqual(&s2.pages.pages.last.?.data, page);
+ try testing.expectEqual(s2.pages.pages.last.?.page(), page);
// It should have a previous page.
try testing.expect(s2.cursor.page_pin.node.prev != null);
@@ -3675,7 +3961,7 @@ test "Screen cursorCopy hyperlink deref new page" {
try testing.expectEqual(@as(usize, 0), page.hyperlink_set.count());
try testing.expect(s2.cursor.hyperlink_id == 0);
// The page after the page the cursor is now in should be page 1.
- try testing.expectEqual(page, &s2.cursor.page_pin.node.next.?.data);
+ try testing.expectEqual(page, s2.cursor.page_pin.node.next.?.page());
// The cursor should be at 0, 0
try testing.expect(s2.cursor.x == 0);
try testing.expect(s2.cursor.y == 0);
@@ -3690,12 +3976,12 @@ test "Screen cursorCopy hyperlink copy" {
// Create a hyperlink for the cursor.
try s.startHyperlink("https://example.com/", null);
- try testing.expectEqual(@as(usize, 1), s.cursor.page_pin.node.data.hyperlink_set.count());
+ try testing.expectEqual(@as(usize, 1), s.cursor.page_pin.node.page().hyperlink_set.count());
try testing.expect(s.cursor.hyperlink_id != 0);
var s2 = try Screen.init(alloc, .{ .cols = 10, .rows = 10, .max_scrollback = 0 });
defer s2.deinit();
- const page = &s2.cursor.page_pin.node.data;
+ const page = s2.cursor.page_pin.node.page();
try testing.expectEqual(@as(usize, 0), page.hyperlink_set.count());
try testing.expect(s2.cursor.hyperlink_id == 0);
@@ -3715,12 +4001,12 @@ test "Screen cursorCopy hyperlink copy disabled" {
// Create a hyperlink for the cursor.
try s.startHyperlink("https://example.com/", null);
- try testing.expectEqual(@as(usize, 1), s.cursor.page_pin.node.data.hyperlink_set.count());
+ try testing.expectEqual(@as(usize, 1), s.cursor.page_pin.node.page().hyperlink_set.count());
try testing.expect(s.cursor.hyperlink_id != 0);
var s2 = try Screen.init(alloc, .{ .cols = 10, .rows = 10, .max_scrollback = 0 });
defer s2.deinit();
- const page = &s2.cursor.page_pin.node.data;
+ const page = s2.cursor.page_pin.node.page();
try testing.expectEqual(@as(usize, 0), page.hyperlink_set.count());
try testing.expect(s2.cursor.hyperlink_id == 0);
@@ -3737,7 +4023,7 @@ test "Screen style basics" {
var s = try Screen.init(alloc, .{ .cols = 80, .rows = 24, .max_scrollback = 1000 });
defer s.deinit();
- const page = &s.cursor.page_pin.node.data;
+ const page = s.cursor.page_pin.node.page();
try testing.expectEqual(@as(usize, 0), page.styles.count());
// Set a new style
@@ -3759,7 +4045,7 @@ test "Screen style reset to default" {
var s = try Screen.init(alloc, .{ .cols = 80, .rows = 24, .max_scrollback = 1000 });
defer s.deinit();
- const page = &s.cursor.page_pin.node.data;
+ const page = s.cursor.page_pin.node.page();
try testing.expectEqual(@as(usize, 0), page.styles.count());
// Set a new style
@@ -3779,7 +4065,7 @@ test "Screen style reset with unset" {
var s = try Screen.init(alloc, .{ .cols = 80, .rows = 24, .max_scrollback = 1000 });
defer s.deinit();
- const page = &s.cursor.page_pin.node.data;
+ const page = s.cursor.page_pin.node.page();
try testing.expectEqual(@as(usize, 0), page.styles.count());
// Set a new style
@@ -3836,7 +4122,7 @@ test "Screen clearRows active styled line" {
try s.setAttribute(.{ .unset = {} });
// We should have one style
- const page = &s.cursor.page_pin.node.data;
+ const page = s.cursor.page_pin.node.page();
try testing.expectEqual(@as(usize, 1), page.styles.count());
s.clearRows(.{ .active = .{} }, null, false);
@@ -3849,6 +4135,38 @@ test "Screen clearRows active styled line" {
try testing.expectEqualStrings("", str);
}
+test "Screen clearCells empty range" {
+ const testing = std.testing;
+
+ var s = try Screen.init(testing.allocator, .default);
+ defer s.deinit();
+
+ const page = s.cursor.page_pin.node.page();
+ const row = s.cursor.page_row;
+ const cells = page.getCells(row);
+ s.clearCells(page, row, cells[0..0]);
+}
+
+test "Screen clearRows uses stored page width" {
+ const testing = std.testing;
+
+ var s = try Screen.init(testing.allocator, .{
+ .cols = 2,
+ .rows = 1,
+ .max_scrollback = 0,
+ });
+ defer s.deinit();
+
+ try s.testWriteString("AB");
+ const node = s.pages.pages.first.?;
+ s.pages.cols = 4;
+
+ s.clearRows(.{ .screen = .{} }, null, false);
+ const cells = node.page().getCells(&node.page().rows.ptr(node.page().memory)[0]);
+ try testing.expectEqual(@as(usize, 2), cells.len);
+ for (cells) |cell| try testing.expect(cell.isEmpty());
+}
+
test "Screen clearRows protected" {
const testing = std.testing;
const alloc = testing.allocator;
@@ -3974,6 +4292,27 @@ test "Screen eraseRows active partial" {
}
}
+test "Screen: cursorCellEndOfPrev across mixed-width pages" {
+ const testing = std.testing;
+ var s = try init(testing.allocator, .{
+ .cols = 4,
+ .rows = 2,
+ .max_scrollback = 0,
+ });
+ defer s.deinit();
+
+ try s.testWriteString("ABCDE");
+ const first = s.pages.pages.first.?;
+ try s.pages.split(.{ .node = first, .y = 1 });
+ s.cursorReload();
+ const second = first.next.?;
+ first.page().size.cols = 2;
+
+ try testing.expectEqual(second, s.cursor.page_pin.node);
+ const expected = (Pin{ .node = first, .x = 1 }).rowAndCell().cell;
+ try testing.expectEqual(expected, s.cursorCellEndOfPrev());
+}
+
test "Screen: cursorDown across pages preserves style" {
const testing = std.testing;
const alloc = testing.allocator;
@@ -3982,7 +4321,7 @@ test "Screen: cursorDown across pages preserves style" {
defer s.deinit();
// Scroll down enough to go to another page
- const start_page = &s.pages.pages.last.?.data;
+ const start_page = s.pages.pages.last.?.page();
const rem = start_page.capacity.rows;
start_page.pauseIntegrityChecks(true);
for (0..rem) |_| try s.cursorDownOrScroll();
@@ -3992,21 +4331,21 @@ test "Screen: cursorDown across pages preserves style" {
// assertion fails then the bug is in the test: we should be scrolling
// above enough for a new page to show up.
{
- const page = &s.cursor.page_pin.node.data;
+ const page = s.cursor.page_pin.node.page();
try testing.expect(start_page != page);
}
// Scroll back to the previous page
s.cursorUp(1);
{
- const page = &s.cursor.page_pin.node.data;
+ const page = s.cursor.page_pin.node.page();
try testing.expect(start_page == page);
}
// Go back up, set a style
try s.setAttribute(.{ .bold = {} });
{
- const page = &s.cursor.page_pin.node.data;
+ const page = s.cursor.page_pin.node.page();
const styleval = page.styles.get(
page.memory,
s.cursor.style_id,
@@ -4017,7 +4356,7 @@ test "Screen: cursorDown across pages preserves style" {
// Go back down into the next page and we should have that style
s.cursorDown(1);
{
- const page = &s.cursor.page_pin.node.data;
+ const page = s.cursor.page_pin.node.page();
const styleval = page.styles.get(
page.memory,
s.cursor.style_id,
@@ -4034,7 +4373,7 @@ test "Screen: cursorUp across pages preserves style" {
defer s.deinit();
// Scroll down enough to go to another page
- const start_page = &s.pages.pages.last.?.data;
+ const start_page = s.pages.pages.last.?.page();
const rem = start_page.capacity.rows;
start_page.pauseIntegrityChecks(true);
for (0..rem) |_| try s.cursorDownOrScroll();
@@ -4044,14 +4383,14 @@ test "Screen: cursorUp across pages preserves style" {
// assertion fails then the bug is in the test: we should be scrolling
// above enough for a new page to show up.
{
- const page = &s.cursor.page_pin.node.data;
+ const page = s.cursor.page_pin.node.page();
try testing.expect(start_page != page);
}
// Go back up, set a style
try s.setAttribute(.{ .bold = {} });
{
- const page = &s.cursor.page_pin.node.data;
+ const page = s.cursor.page_pin.node.page();
const styleval = page.styles.get(
page.memory,
s.cursor.style_id,
@@ -4062,7 +4401,7 @@ test "Screen: cursorUp across pages preserves style" {
// Go back down into the prev page and we should have that style
s.cursorUp(1);
{
- const page = &s.cursor.page_pin.node.data;
+ const page = s.cursor.page_pin.node.page();
try testing.expect(start_page == page);
const styleval = page.styles.get(
@@ -4081,7 +4420,7 @@ test "Screen: cursorAbsolute across pages preserves style" {
defer s.deinit();
// Scroll down enough to go to another page
- const start_page = &s.pages.pages.last.?.data;
+ const start_page = s.pages.pages.last.?.page();
const rem = start_page.capacity.rows;
start_page.pauseIntegrityChecks(true);
for (0..rem) |_| try s.cursorDownOrScroll();
@@ -4091,14 +4430,14 @@ test "Screen: cursorAbsolute across pages preserves style" {
// assertion fails then the bug is in the test: we should be scrolling
// above enough for a new page to show up.
{
- const page = &s.cursor.page_pin.node.data;
+ const page = s.cursor.page_pin.node.page();
try testing.expect(start_page != page);
}
// Go back up, set a style
try s.setAttribute(.{ .bold = {} });
{
- const page = &s.cursor.page_pin.node.data;
+ const page = s.cursor.page_pin.node.page();
const styleval = page.styles.get(
page.memory,
s.cursor.style_id,
@@ -4109,7 +4448,7 @@ test "Screen: cursorAbsolute across pages preserves style" {
// Go back down into the prev page and we should have that style
s.cursorAbsolute(1, 1);
{
- const page = &s.cursor.page_pin.node.data;
+ const page = s.cursor.page_pin.node.page();
try testing.expect(start_page == page);
const styleval = page.styles.get(
@@ -4136,13 +4475,13 @@ test "Screen: cursorAbsolute to page with insufficient capacity" {
defer s.deinit();
// Scroll down enough to go to another page
- const start_page = &s.pages.pages.last.?.data;
+ const start_page = s.pages.pages.last.?.page();
const rem = start_page.capacity.rows;
start_page.pauseIntegrityChecks(true);
for (0..rem) |_| try s.cursorDownOrScroll();
start_page.pauseIntegrityChecks(false);
- const new_page = &s.cursor.page_pin.node.data;
+ const new_page = s.cursor.page_pin.node.page();
// We need our page to change for this test to make sense. If this
// assertion fails then the bug is in the test: we should be scrolling
@@ -4177,7 +4516,7 @@ test "Screen: cursorAbsolute to page with insufficient capacity" {
// Go back up into the start page and we should still have that style.
s.cursorAbsolute(1, 1);
{
- const cur_page = &s.cursor.page_pin.node.data;
+ const cur_page = s.cursor.page_pin.node.page();
// The page we're on now should NOT equal start_page, since its
// capacity should have been adjusted, which invalidates our ptr.
try testing.expect(start_page != cur_page);
@@ -4191,7 +4530,7 @@ test "Screen: cursorAbsolute to page with insufficient capacity" {
try testing.expect(styleval.flags.bold);
}
- s.cursor.page_pin.node.data.assertIntegrity();
+ s.cursor.page_pin.node.page().assertIntegrity();
new_page.assertIntegrity();
}
@@ -4295,7 +4634,7 @@ test "Screen: scrolling across pages preserves style" {
defer s.deinit();
try s.setAttribute(.{ .bold = {} });
try s.testWriteString("1ABCD\n2EFGH\n3IJKL");
- const start_page = &s.pages.pages.last.?.data;
+ const start_page = s.pages.pages.last.?.page();
// Scroll down enough to go to another page
const rem = start_page.capacity.rows - start_page.size.rows + 1;
@@ -4306,7 +4645,7 @@ test "Screen: scrolling across pages preserves style" {
// We need our page to change for this test o make sense. If this
// assertion fails then the bug is in the test: we should be scrolling
// above enough for a new page to show up.
- const page = &s.pages.pages.last.?.data;
+ const page = s.pages.pages.last.?.page();
try testing.expect(start_page != page);
const styleval = page.styles.get(
@@ -4571,6 +4910,223 @@ test "Screen: scrolling moves selection" {
}
}
+test "Screen: cursorScrollRegionUp simple" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var s = try init(alloc, .{ .cols = 5, .rows = 5, .max_scrollback = 0 });
+ defer s.deinit();
+ try s.testWriteString("1ABCD\n2EFGH\n3IJKL\n4MNOP\n5QRST");
+
+ // Scroll a region ending at row 2 (zero-indexed) up by one. This
+ // emulates a scroll region of rows 0-2 with the cursor at the
+ // region bottom.
+ s.cursorAbsolute(1, 2);
+ try s.cursorScrollRegionUp(2);
+
+ // The cursor stays in place, on the new blank row.
+ try testing.expectEqual(@as(size.CellCountInt, 1), s.cursor.x);
+ try testing.expectEqual(@as(size.CellCountInt, 2), s.cursor.y);
+
+ // Rows in the region scrolled, rows below are unchanged, and
+ // nothing was moved into scrollback.
+ {
+ const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} });
+ defer alloc.free(contents);
+ try testing.expectEqualStrings("2EFGH\n3IJKL\n\n4MNOP\n5QRST", contents);
+ }
+}
+
+test "Screen: cursorScrollRegionUp renews page generation" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var s = try init(alloc, .{ .cols = 5, .rows = 5, .max_scrollback = 0 });
+ defer s.deinit();
+ try s.testWriteString("1ABCD\n2EFGH\n3IJKL\n4MNOP\n5QRST");
+ s.cursorAbsolute(0, 2);
+
+ const node = s.cursor.page_pin.node;
+ const serial = node.serial;
+ try s.cursorScrollRegionUp(2);
+
+ try testing.expect(!s.pages.nodeIsValid(node, serial));
+}
+
+test "Screen: cursorScrollRegionUp moves selection" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var s = try init(alloc, .{ .cols = 5, .rows = 5, .max_scrollback = 0 });
+ defer s.deinit();
+ try s.testWriteString("1ABCD\n2EFGH\n3IJKL\n4MNOP\n5QRST");
+
+ // Select the second row.
+ try s.select(Selection.init(
+ s.pages.pin(.{ .active = .{ .x = 0, .y = 1 } }).?,
+ s.pages.pin(.{ .active = .{ .x = s.pages.cols - 1, .y = 1 } }).?,
+ false,
+ ));
+
+ s.cursorAbsolute(0, 2);
+ try s.cursorScrollRegionUp(2);
+
+ // Our selection should've moved up with its row.
+ {
+ const sel = s.selection.?;
+ try testing.expectEqual(point.Point{ .active = .{
+ .x = 0,
+ .y = 0,
+ } }, s.pages.pointFromPin(.active, sel.start()).?);
+ try testing.expectEqual(point.Point{ .active = .{
+ .x = s.pages.cols - 1,
+ .y = 0,
+ } }, s.pages.pointFromPin(.active, sel.end()).?);
+ }
+
+ {
+ const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} });
+ defer alloc.free(contents);
+ try testing.expectEqualStrings("2EFGH\n3IJKL\n\n4MNOP\n5QRST", contents);
+ }
+}
+
+test "Screen: cursorScrollRegionUp region spans pages" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var s = try init(alloc, .{ .cols = 10, .rows = 5, .max_scrollback = 10 });
+ defer s.deinit();
+
+ // We need to get the cursor to a new page
+ const first_page_size = s.pages.pages.first.?.capacity().rows;
+ s.pages.pages.first.?.page().pauseIntegrityChecks(true);
+ for (0..first_page_size - 3) |_| try s.testWriteString("\n");
+ s.pages.pages.first.?.page().pauseIntegrityChecks(false);
+ try s.testWriteString("1A\n2B\n3C\n4D\n5E");
+
+ // At this point:
+ // +----------+ = PAGE 0
+ // ... : :
+ // +-------------+ ACTIVE
+ // 4305 |1A00000000| | 0
+ // 4306 |2B00000000| | 1
+ // 4307 |3C00000000| | 2
+ // +----------+ :
+ // +----------+ : = PAGE 1
+ // 0 |4D00000000| | 3
+ // :^ : : = PIN 0
+ // 1 |5E00000000| | 4
+ // +----------+ :
+ // +-------------+
+
+ // Move the cursor to the first row of the second page and give it
+ // a non-default style. This is important: it verifies that the
+ // cursor's style ref stays accounted on the correct page even
+ // though eraseRowBounded moves the cursor's tracked pin across
+ // the page boundary.
+ s.cursorAbsolute(0, 3);
+ try s.setAttribute(.{ .bold = {} });
+ try testing.expect(s.cursor.page_pin.node == s.pages.pages.last.?);
+ try testing.expectEqual(@as(usize, 0), s.cursor.page_pin.y);
+
+ // Scroll a region of active rows 1-3 with the cursor at the region
+ // bottom. The region spans the page boundary so this exercises the
+ // slow path.
+ try s.cursorScrollRegionUp(2);
+
+ // The cursor stays in place, on the new blank row.
+ try testing.expectEqual(@as(size.CellCountInt, 0), s.cursor.x);
+ try testing.expectEqual(@as(size.CellCountInt, 3), s.cursor.y);
+
+ {
+ const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} });
+ defer alloc.free(contents);
+ try testing.expectEqualStrings("1A\n3C\n4D\n\n5E", contents);
+ }
+
+ // Our cursor style must remain usable: write a styled cell and
+ // verify the style ref counting is intact on the cursor's page.
+ try s.testWriteString("X");
+ {
+ const page = s.cursor.page_pin.node.page();
+ const styles = page.styles.count();
+ try testing.expectEqual(@as(usize, 1), styles);
+ }
+}
+
+test "Screen: cursorScrollRegionUp region spans pages with background SGR" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var s = try init(alloc, .{ .cols = 10, .rows = 5, .max_scrollback = 10 });
+ defer s.deinit();
+
+ // We need to get the cursor to a new page. See the previous test
+ // for a diagram of the page layout.
+ const first_page_size = s.pages.pages.first.?.capacity().rows;
+ s.pages.pages.first.?.page().pauseIntegrityChecks(true);
+ for (0..first_page_size - 3) |_| try s.testWriteString("\n");
+ s.pages.pages.first.?.page().pauseIntegrityChecks(false);
+ try s.testWriteString("1A\n2B\n3C\n4D\n5E");
+
+ s.cursorAbsolute(0, 3);
+ try s.setAttribute(.{ .direct_color_bg = .{ .r = 0xFF, .g = 0, .b = 0 } });
+ try testing.expect(s.cursor.page_pin.node == s.pages.pages.last.?);
+ try testing.expectEqual(@as(usize, 0), s.cursor.page_pin.y);
+
+ try s.cursorScrollRegionUp(2);
+
+ {
+ const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} });
+ defer alloc.free(contents);
+ try testing.expectEqualStrings("1A\n3C\n4D\n\n5E", contents);
+ }
+
+ // The new blank row must be filled with our background color.
+ for (0..s.pages.cols) |x| {
+ const list_cell = s.pages.getCell(.{ .active = .{
+ .x = @intCast(x),
+ .y = 3,
+ } }).?;
+ try testing.expect(list_cell.cell.content_tag == .bg_color_rgb);
+ try testing.expectEqual(Cell.RGB{
+ .r = 0xFF,
+ .g = 0,
+ .b = 0,
+ }, list_cell.cell.content.color_rgb);
+ }
+}
+
+test "Screen: cursorScrollRegionUp with styled erased row" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var s = try init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 0 });
+ defer s.deinit();
+
+ // Write a styled row at the top so the erased row has managed
+ // memory that must be released.
+ try s.setAttribute(.{ .bold = {} });
+ try s.testWriteString("1ABCD");
+ try s.setAttribute(.{ .unset = {} });
+ try s.testWriteString("\n2EFGH\n3IJKL");
+
+ s.cursorAbsolute(0, 2);
+ try s.cursorScrollRegionUp(2);
+
+ {
+ const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} });
+ defer alloc.free(contents);
+ try testing.expectEqualStrings("2EFGH\n3IJKL", contents);
+ }
+
+ // The style should be gone from the page since the only user
+ // was the erased row.
+ const page = s.cursor.page_pin.node.page();
+ try testing.expectEqual(@as(usize, 0), page.styles.count());
+}
+
test "Screen: scrolling moves viewport" {
const testing = std.testing;
const alloc = testing.allocator;
@@ -4759,7 +5315,10 @@ test "Screen: scroll above same page" {
// +----------+ :
// +-------------+
+ const node = s.cursor.page_pin.node;
+ const serial = node.serial;
try s.cursorScrollAbove();
+ try testing.expect(!s.pages.nodeIsValid(node, serial));
// +----------+ = PAGE 0
// 0 |1ABCD00000|
@@ -4805,10 +5364,10 @@ test "Screen: scroll above same page but cursor on previous page" {
defer s.deinit();
// We need to get the cursor to a new page
- const first_page_size = s.pages.pages.first.?.data.capacity.rows;
- s.pages.pages.first.?.data.pauseIntegrityChecks(true);
+ const first_page_size = s.pages.pages.first.?.capacity().rows;
+ s.pages.pages.first.?.page().pauseIntegrityChecks(true);
for (0..first_page_size - 3) |_| try s.testWriteString("\n");
- s.pages.pages.first.?.data.pauseIntegrityChecks(false);
+ s.pages.pages.first.?.page().pauseIntegrityChecks(false);
try s.setAttribute(.{ .direct_color_bg = .{ .r = 155 } });
try s.testWriteString("1A\n2B\n3C\n4D\n5E");
@@ -4834,7 +5393,13 @@ test "Screen: scroll above same page but cursor on previous page" {
// +----------+ :
// +-------------+
+ const first_node = s.pages.pages.first.?;
+ const second_node = first_node.next.?;
+ const first_serial = first_node.serial;
+ const second_serial = second_node.serial;
try s.cursorScrollAbove();
+ try testing.expect(!s.pages.nodeIsValid(first_node, first_serial));
+ try testing.expect(!s.pages.nodeIsValid(second_node, second_serial));
// +----------+ = PAGE 0
// ... : :
@@ -4886,10 +5451,10 @@ test "Screen: scroll above same page but cursor on previous page last row" {
defer s.deinit();
// We need to get the cursor to a new page
- const first_page_size = s.pages.pages.first.?.data.capacity.rows;
- s.pages.pages.first.?.data.pauseIntegrityChecks(true);
+ const first_page_size = s.pages.pages.first.?.capacity().rows;
+ s.pages.pages.first.?.page().pauseIntegrityChecks(true);
for (0..first_page_size - 2) |_| try s.testWriteString("\n");
- s.pages.pages.first.?.data.pauseIntegrityChecks(false);
+ s.pages.pages.first.?.page().pauseIntegrityChecks(false);
try s.setAttribute(.{ .direct_color_bg = .{ .r = 155 } });
try s.testWriteString("1A\n2B\n3C\n4D\n5E");
@@ -4964,8 +5529,8 @@ test "Screen: scroll above same page but cursor on previous page last row" {
// This catches a case of memory corruption where the cursor
// is moved between pages without accounting for style refs.
try s.setAttribute(.{ .reset_bg = {} });
- s.pages.pages.first.?.data.assertIntegrity();
- s.pages.pages.last.?.data.assertIntegrity();
+ s.pages.pages.first.?.page().assertIntegrity();
+ s.pages.pages.last.?.page().assertIntegrity();
}
test "Screen: scroll above creates new page" {
@@ -4976,10 +5541,10 @@ test "Screen: scroll above creates new page" {
defer s.deinit();
// We need to get the cursor to a new page
- const first_page_size = s.pages.pages.first.?.data.capacity.rows;
- s.pages.pages.first.?.data.pauseIntegrityChecks(true);
+ const first_page_size = s.pages.pages.first.?.capacity().rows;
+ s.pages.pages.first.?.page().pauseIntegrityChecks(true);
for (0..first_page_size - 3) |_| try s.testWriteString("\n");
- s.pages.pages.first.?.data.pauseIntegrityChecks(false);
+ s.pages.pages.first.?.page().pauseIntegrityChecks(false);
try s.setAttribute(.{ .direct_color_bg = .{ .r = 155 } });
try s.testWriteString("1ABCD\n2EFGH\n3IJKL");
@@ -4999,7 +5564,10 @@ test "Screen: scroll above creates new page" {
// 4307 |3IJKL00000| | 2
// +----------+ :
// +-------------+
+ const node = s.pages.pages.first.?;
+ const serial = node.serial;
try s.cursorScrollAbove();
+ try testing.expect(!s.pages.nodeIsValid(node, serial));
// +----------+ = PAGE 0
// ... : :
@@ -5048,10 +5616,10 @@ test "Screen: scroll above with cursor on non-final row" {
defer s.deinit();
// Get the cursor to be 2 rows above a new page
- const first_page_size = s.pages.pages.first.?.data.capacity.rows;
- s.pages.pages.first.?.data.pauseIntegrityChecks(true);
+ const first_page_size = s.pages.pages.first.?.capacity().rows;
+ s.pages.pages.first.?.page().pauseIntegrityChecks(true);
for (0..first_page_size - 3) |_| try s.testWriteString("\n");
- s.pages.pages.first.?.data.pauseIntegrityChecks(false);
+ s.pages.pages.first.?.page().pauseIntegrityChecks(false);
// Write 3 lines of text, forcing the last line into the first
// row of a new page. Move our cursor onto the previous page.
@@ -5124,10 +5692,10 @@ test "Screen: scroll above no scrollback bottom of page" {
var s = try init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 0 });
defer s.deinit();
- const first_page_size = s.pages.pages.first.?.data.capacity.rows;
- s.pages.pages.first.?.data.pauseIntegrityChecks(true);
+ const first_page_size = s.pages.pages.first.?.capacity().rows;
+ s.pages.pages.first.?.page().pauseIntegrityChecks(true);
for (0..first_page_size - 3) |_| try s.testWriteString("\n");
- s.pages.pages.first.?.data.pauseIntegrityChecks(false);
+ s.pages.pages.first.?.page().pauseIntegrityChecks(false);
try s.setAttribute(.{ .direct_color_bg = .{ .r = 155 } });
try s.testWriteString("1ABCD\n2EFGH\n3IJKL");
@@ -5498,6 +6066,51 @@ test "Screen: clone contains subset of selection" {
}
}
+test "Screen: clone clamps clipped selections to mixed-width pages" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var s = try init(alloc, .{ .cols = 4, .rows = 3, .max_scrollback = 0 });
+ defer s.deinit();
+
+ const first = s.pages.pages.first.?;
+ try s.pages.split(.{ .node = first, .y = 2 });
+ try s.pages.split(.{ .node = first, .y = 1 });
+ const middle = first.next.?;
+ const last = middle.next.?;
+ middle.page().size.cols = 2;
+
+ try s.select(Selection.init(
+ .{ .node = first },
+ .{ .node = last, .x = 3 },
+ false,
+ ));
+ var linear = try s.clone(
+ alloc,
+ .{ .screen = .{} },
+ .{ .screen = .{ .y = 1 } },
+ );
+ defer linear.deinit();
+ const linear_end = linear.selection.?.end();
+ _ = linear_end.rowAndCell();
+ try testing.expectEqual(@as(size.CellCountInt, 1), linear_end.x);
+
+ try s.select(Selection.init(
+ .{ .node = first, .x = 3 },
+ .{ .node = last, .x = 3 },
+ true,
+ ));
+ var rectangle = try s.clone(
+ alloc,
+ .{ .screen = .{ .y = 1 } },
+ null,
+ );
+ defer rectangle.deinit();
+ const rectangle_start = rectangle.selection.?.start();
+ _ = rectangle_start.rowAndCell();
+ try testing.expectEqual(@as(size.CellCountInt, 1), rectangle_start.x);
+}
+
test "Screen: clone contains subset of rectangle selection" {
const testing = std.testing;
const alloc = testing.allocator;
@@ -6533,7 +7146,7 @@ test "Screen: resize more cols bounded scrollback keeps viewport valid" {
// That cuts the total row count down and is what stresses the viewport pin.
var it = s.pages.pageIterator(.right_down, .{ .screen = .{} }, null);
while (it.next()) |chunk| {
- const page = &chunk.node.data;
+ const page = chunk.node.page();
for (chunk.start..chunk.end) |y| {
const rac = page.getRowAndCell(0, y);
if (y % 2 == 0) {
@@ -7652,6 +8265,49 @@ test "Screen: select untracked" {
try testing.expectEqual(tracked, s.pages.countTrackedPins());
}
+test "Screen: select replaces existing pins" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var s = try init(alloc, .{ .cols = 10, .rows = 10, .max_scrollback = 0 });
+ defer s.deinit();
+ try s.testWriteString("ABC DEF\n 123\n456");
+
+ const tracked = s.pages.countTrackedPins();
+ try s.select(Selection.init(
+ s.pages.pin(.{ .active = .{ .x = 0, .y = 0 } }).?,
+ s.pages.pin(.{ .active = .{ .x = 3, .y = 0 } }).?,
+ false,
+ ));
+ try testing.expectEqual(tracked + 2, s.pages.countTrackedPins());
+
+ // Replacing the selection must untrack the prior selection's pins
+ // rather than leak them.
+ try s.select(Selection.init(
+ s.pages.pin(.{ .active = .{ .x = 0, .y = 1 } }).?,
+ s.pages.pin(.{ .active = .{ .x = 2, .y = 1 } }).?,
+ false,
+ ));
+ try testing.expectEqual(tracked + 2, s.pages.countTrackedPins());
+}
+
+test "Screen: reselecting tracked selection preserves its pins" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var s = try init(alloc, .{ .cols = 10, .rows = 2, .max_scrollback = 0 });
+ defer s.deinit();
+
+ try s.select(Selection.init(
+ s.pages.pin(.{ .active = .{ .x = 1, .y = 0 } }).?,
+ s.pages.pin(.{ .active = .{ .x = 3, .y = 0 } }).?,
+ false,
+ ));
+
+ try s.select(s.selection.?);
+ try testing.expectEqual(Selection.Order.forward, s.selection.?.order(&s));
+}
+
test "Screen: selectAll" {
const testing = std.testing;
const alloc = testing.allocator;
@@ -8342,6 +8998,37 @@ test "Screen: selectLine semantic boundary first cell of row" {
}
}
+test "Screen: selectLine semantic boundary across mixed-width pages" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var s = try init(alloc, .{ .cols = 4, .rows = 2, .max_scrollback = 0 });
+ defer s.deinit();
+
+ s.cursorSetSemanticContent(.{ .input = .clear_explicit });
+ try s.testWriteString("ABCD");
+ s.cursorSetSemanticContent(.output);
+ try s.testWriteString("E");
+
+ const first = s.pages.pages.first.?;
+ try s.pages.split(.{ .node = first, .y = 1 });
+ const second = first.next.?;
+ first.page().size.cols = 2;
+ s.pages.pauseIntegrityChecks(true);
+ defer s.pages.pauseIntegrityChecks(false);
+
+ try testing.expectEqual(@as(size.CellCountInt, 2), first.cols());
+ try testing.expectEqual(@as(size.CellCountInt, 4), second.cols());
+
+ var sel = s.selectLine(.{ .pin = .{
+ .node = first,
+ .x = 1,
+ } }).?;
+ defer sel.deinit(&s);
+ try testing.expect((Pin{ .node = first, .x = 0 }).eql(sel.start()));
+ try testing.expect((Pin{ .node = first, .x = 1 }).eql(sel.end()));
+}
+
test "Screen: selectLine semantic all same content" {
const testing = std.testing;
const alloc = testing.allocator;
@@ -9141,7 +9828,7 @@ test "Screen: selectionString with zero width joiner" {
const cell = pin.rowAndCell().cell;
try testing.expectEqual(@as(u21, 0x1F468), cell.content.codepoint);
try testing.expectEqual(Cell.Wide.wide, cell.wide);
- const cps = pin.node.data.lookupGrapheme(cell).?;
+ const cps = pin.node.page().lookupGrapheme(cell).?;
try testing.expectEqual(@as(usize, 1), cps.len);
}
@@ -9276,14 +9963,14 @@ test "Screen: selectionString multi-page" {
var s = try init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 2048 });
defer s.deinit();
- const first_page_size = s.pages.pages.first.?.data.capacity.rows;
+ const first_page_size = s.pages.pages.first.?.capacity().rows;
// Lazy way to seek to the first page boundary.
- s.pages.pages.first.?.data.pauseIntegrityChecks(true);
+ s.pages.pages.first.?.page().pauseIntegrityChecks(true);
for (0..first_page_size - 1) |_| {
try s.testWriteString("\n");
}
- s.pages.pages.first.?.data.pauseIntegrityChecks(false);
+ s.pages.pages.first.?.page().pauseIntegrityChecks(false);
try s.testWriteString("123456789\n!@#$%^&*(\n123456789");
@@ -9374,25 +10061,77 @@ test "Screen: hyperlink start/end" {
defer s.deinit();
try testing.expect(s.cursor.hyperlink_id == 0);
{
- const page = &s.cursor.page_pin.node.data;
+ const page = s.cursor.page_pin.node.page();
try testing.expectEqual(0, page.hyperlink_set.count());
}
try s.startHyperlink("http://example.com", null);
try testing.expect(s.cursor.hyperlink_id != 0);
{
- const page = &s.cursor.page_pin.node.data;
+ const page = s.cursor.page_pin.node.page();
try testing.expectEqual(1, page.hyperlink_set.count());
}
s.endHyperlink();
try testing.expect(s.cursor.hyperlink_id == 0);
{
- const page = &s.cursor.page_pin.node.data;
+ const page = s.cursor.page_pin.node.page();
try testing.expectEqual(0, page.hyperlink_set.count());
}
}
+test "Screen: hyperlink accepts its current values" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var s = try init(alloc, .{ .cols = 5, .rows = 5, .max_scrollback = 0 });
+ defer s.deinit();
+
+ try s.startHyperlink("http://example.com", "current");
+ const current = s.cursor.hyperlink.?;
+ try s.startHyperlink(current.uri, current.id.explicit);
+
+ try testing.expectEqualStrings("http://example.com", s.cursor.hyperlink.?.uri);
+ try testing.expectEqualStrings("current", s.cursor.hyperlink.?.id.explicit);
+}
+
+test "Screen: implicit hyperlink ID wraps" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var s = try init(alloc, .{ .cols = 5, .rows = 5, .max_scrollback = 0 });
+ defer s.deinit();
+
+ s.cursor.hyperlink_implicit_id = std.math.maxInt(size.OffsetInt);
+ try s.startHyperlink("http://example.com", null);
+
+ try testing.expectEqual(@as(size.OffsetInt, 0), s.cursor.hyperlink_implicit_id);
+ try testing.expectEqual(
+ std.math.maxInt(size.OffsetInt),
+ s.cursor.hyperlink.?.id.implicit,
+ );
+
+ // A failed allocation must roll the wrapped counter back to its
+ // original value as well.
+ s.endHyperlink();
+ s.cursor.hyperlink_implicit_id = std.math.maxInt(size.OffsetInt);
+ var failing = testing.FailingAllocator.init(alloc, .{});
+ failing.fail_index = failing.alloc_index;
+ {
+ const original_alloc = s.alloc;
+ defer s.alloc = original_alloc;
+ s.alloc = failing.allocator();
+ try testing.expectError(
+ error.OutOfMemory,
+ s.startHyperlink("http://example.com", null),
+ );
+ }
+ try testing.expectEqual(
+ std.math.maxInt(size.OffsetInt),
+ s.cursor.hyperlink_implicit_id,
+ );
+}
+
test "Screen: hyperlink reuse" {
const testing = std.testing;
const alloc = testing.allocator;
@@ -9402,7 +10141,7 @@ test "Screen: hyperlink reuse" {
try testing.expect(s.cursor.hyperlink_id == 0);
{
- const page = &s.cursor.page_pin.node.data;
+ const page = s.cursor.page_pin.node.page();
try testing.expectEqual(0, page.hyperlink_set.count());
}
@@ -9415,14 +10154,14 @@ test "Screen: hyperlink reuse" {
try s.startHyperlink("http://example.com", null);
try testing.expectEqual(id, s.cursor.hyperlink_id);
{
- const page = &s.cursor.page_pin.node.data;
+ const page = s.cursor.page_pin.node.page();
try testing.expectEqual(1, page.hyperlink_set.count());
}
s.endHyperlink();
try testing.expect(s.cursor.hyperlink_id == 0);
{
- const page = &s.cursor.page_pin.node.data;
+ const page = s.cursor.page_pin.node.page();
try testing.expectEqual(0, page.hyperlink_set.count());
}
}
@@ -9442,7 +10181,7 @@ test "Screen: hyperlink cursor state on resize" {
try s.startHyperlink("http://example.com", null);
try testing.expect(s.cursor.hyperlink_id != 0);
{
- const page = &s.cursor.page_pin.node.data;
+ const page = s.cursor.page_pin.node.page();
try testing.expectEqual(1, page.hyperlink_set.count());
}
@@ -9450,14 +10189,14 @@ test "Screen: hyperlink cursor state on resize" {
try s.resize(.{ .cols = 10, .rows = 10 });
try testing.expect(s.cursor.hyperlink_id != 0);
{
- const page = &s.cursor.page_pin.node.data;
+ const page = s.cursor.page_pin.node.page();
try testing.expectEqual(1, page.hyperlink_set.count());
}
s.endHyperlink();
try testing.expect(s.cursor.hyperlink_id == 0);
{
- const page = &s.cursor.page_pin.node.data;
+ const page = s.cursor.page_pin.node.page();
try testing.expectEqual(0, page.hyperlink_set.count());
}
}
@@ -9478,8 +10217,8 @@ test "Screen: cursorSetHyperlink OOM + URI too large for string alloc" {
// Figure out how many cells should can have hyperlinks in this page,
// and write twice that number, to guarantee the capacity needs to be
// increased at some point.
- const base_capacity = s.cursor.page_pin.node.data.hyperlinkCapacity();
- const base_string_bytes = s.cursor.page_pin.node.data.capacity.string_bytes;
+ const base_capacity = s.cursor.page_pin.node.page().hyperlinkCapacity();
+ const base_string_bytes = s.cursor.page_pin.node.capacity().string_bytes;
for (0..base_capacity * 2) |_| {
try s.cursorSetHyperlink();
if (s.cursor.x >= s.pages.cols - 1) {
@@ -9491,9 +10230,9 @@ test "Screen: cursorSetHyperlink OOM + URI too large for string alloc" {
}
// Make sure the capacity really did increase.
- try testing.expect(base_capacity < s.cursor.page_pin.node.data.hyperlinkCapacity());
+ try testing.expect(base_capacity < s.cursor.page_pin.node.page().hyperlinkCapacity());
// And that our string_bytes increased as well.
- try testing.expect(base_string_bytes < s.cursor.page_pin.node.data.capacity.string_bytes);
+ try testing.expect(base_string_bytes < s.cursor.page_pin.node.capacity().string_bytes);
}
test "Screen: increaseCapacity cursor style ref count preserved" {
@@ -9516,7 +10255,7 @@ test "Screen: increaseCapacity cursor style ref count preserved" {
const old_style = s.cursor.style;
{
- const page = &s.pages.pages.last.?.data;
+ const page = s.pages.pages.last.?.page();
// 5 chars + cursor = 6 refs
try testing.expectEqual(
6,
@@ -9545,7 +10284,7 @@ test "Screen: increaseCapacity cursor style ref count preserved" {
// After increaseCapacity, the 5 chars are cloned (5 refs) and
// the cursor's style is re-added (1 ref) = 6 total.
{
- const page = &s.pages.pages.last.?.data;
+ const page = s.pages.pages.last.?.page();
const ref_count = page.styles.refCount(page.memory, s.cursor.style_id);
try testing.expectEqual(6, ref_count);
}
@@ -9569,7 +10308,7 @@ test "Screen: increaseCapacity cursor hyperlink ref count preserved" {
try testing.expect(s.pages.pages.first == s.cursor.page_pin.node);
{
- const page = &s.pages.pages.last.?.data;
+ const page = s.pages.pages.last.?.page();
// Cursor has the hyperlink active = 1 count in hyperlink_set
try testing.expectEqual(1, page.hyperlink_set.count());
try testing.expect(s.cursor.hyperlink_id != 0);
@@ -9589,7 +10328,7 @@ test "Screen: increaseCapacity cursor hyperlink ref count preserved" {
// After increaseCapacity, the hyperlink is re-added to the new page.
{
- const page = &s.pages.pages.last.?.data;
+ const page = s.pages.pages.last.?.page();
try testing.expectEqual(1, page.hyperlink_set.count());
}
}
@@ -9619,7 +10358,7 @@ test "Screen: increaseCapacity cursor with both style and hyperlink preserved" {
const old_style = s.cursor.style;
{
- const page = &s.pages.pages.last.?.data;
+ const page = s.pages.pages.last.?.page();
// 5 chars + cursor = 6 refs for bold style
try testing.expectEqual(
6,
@@ -9649,7 +10388,7 @@ test "Screen: increaseCapacity cursor with both style and hyperlink preserved" {
// After increaseCapacity, both style and hyperlink are re-added to the new page.
{
- const page = &s.pages.pages.last.?.data;
+ const page = s.pages.pages.last.?.page();
const ref_count = page.styles.refCount(page.memory, s.cursor.style_id);
try testing.expectEqual(6, ref_count);
try testing.expectEqual(1, page.hyperlink_set.count());
@@ -9688,11 +10427,11 @@ test "Screen: increaseCapacity non-cursor page returns early" {
// Grow pages until we have multiple pages. The cursor's pin stays on
// the first page since we're just adding rows.
const first_page_node = s.pages.pages.first.?;
- first_page_node.data.pauseIntegrityChecks(true);
- for (0..first_page_node.data.capacity.rows - first_page_node.data.size.rows) |_| {
+ first_page_node.page().pauseIntegrityChecks(true);
+ for (0..first_page_node.capacity().rows - first_page_node.rows()) |_| {
_ = try s.pages.grow();
}
- first_page_node.data.pauseIntegrityChecks(false);
+ first_page_node.page().pauseIntegrityChecks(false);
_ = try s.pages.grow();
// Now we have two pages
@@ -9703,8 +10442,8 @@ test "Screen: increaseCapacity non-cursor page returns early" {
try testing.expect(s.cursor.page_pin.node == s.pages.pages.first.?);
try testing.expect(s.cursor.page_pin.node != second_page);
- const second_page_styles_cap = second_page.data.capacity.styles;
- const cursor_page_styles_cap = s.cursor.page_pin.node.data.capacity.styles;
+ const second_page_styles_cap = second_page.capacity().styles;
+ const cursor_page_styles_cap = s.cursor.page_pin.node.capacity().styles;
// Call increaseCapacity on the second page (NOT the cursor's page)
const new_second_page = try s.increaseCapacity(second_page, .styles);
@@ -9712,13 +10451,13 @@ test "Screen: increaseCapacity non-cursor page returns early" {
// The second page should have increased capacity
try testing.expectEqual(
second_page_styles_cap * 2,
- new_second_page.data.capacity.styles,
+ new_second_page.capacity().styles,
);
// The cursor's page (first page) should be unchanged
try testing.expectEqual(
cursor_page_styles_cap,
- s.cursor.page_pin.node.data.capacity.styles,
+ s.cursor.page_pin.node.capacity().styles,
);
// Cursor state should be completely unchanged since we didn't touch its page
@@ -9739,9 +10478,9 @@ test "Screen: cursorDown to page with insufficient capacity" {
// cursor movement functions). The bug pattern:
//
// 1. cursorDown creates a by-value copy of the pin via page_pin.down(n)
- // 2. cursorChangePin is called, which may trigger adjustCapacity
+ // 2. cursorChangePin is called, which may trigger increaseCapacity
// if the target page's style map is full
- // 3. adjustCapacity frees the old page and creates a new one
+ // 3. increaseCapacity frees the old page and creates a new one
// 4. The local pin copy still points to the freed page
// 5. rowAndCell() on the stale pin accesses freed memory
@@ -9753,18 +10492,18 @@ test "Screen: cursorDown to page with insufficient capacity" {
defer s.deinit();
// Scroll down enough to create a second page
- const start_page = &s.pages.pages.last.?.data;
+ const start_page = s.pages.pages.last.?.page();
const rem = start_page.capacity.rows;
start_page.pauseIntegrityChecks(true);
for (0..rem) |_| try s.cursorDownOrScroll();
start_page.pauseIntegrityChecks(false);
// Cursor should now be on a new page
- const new_page = &s.cursor.page_pin.node.data;
+ const new_page = s.cursor.page_pin.node.page();
try testing.expect(start_page != new_page);
// Fill new_page's style map to capacity. When we move INTO this page
- // with a style set, adjustCapacity will be triggered.
+ // with a style set, increaseCapacity will be triggered.
{
new_page.pauseIntegrityChecks(true);
defer new_page.pauseIntegrityChecks(false);
@@ -9790,10 +10529,10 @@ test "Screen: cursorDown to page with insufficient capacity" {
if (s.cursor.page_pin.down(1)) |next_pin| {
if (next_pin.node != cur_node) {
// Cursor is at 'row', moving down crosses to new_page
- try testing.expect(&next_pin.node.data == new_page);
+ try testing.expect(next_pin.node.page() == new_page);
// This cursorDown triggers the bug: the local page_pin copy
- // becomes stale after adjustCapacity, causing rowAndCell()
+ // becomes stale after increaseCapacity, causing rowAndCell()
// to access freed memory.
s.cursorDown(1);
@@ -9825,7 +10564,7 @@ test "Screen setAttribute increases capacity when style map is full" {
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 page = s.cursor.page_pin.node.page();
const original_styles_capacity = page.capacity.styles;
// Fill the style map to capacity using the StyleSet's layout capacity
@@ -9856,7 +10595,7 @@ test "Screen setAttribute increases capacity when style map is full" {
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 current_page = s.cursor.page_pin.node.page();
const capacity_increased = current_page.capacity.styles > original_styles_capacity;
const page_changed = current_page != page;
try testing.expect(capacity_increased or page_changed);
@@ -9882,7 +10621,7 @@ test "Screen setAttribute splits page on OutOfSpace at max styles" {
// 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) {
+ while (s.cursor.page_pin.node.capacity().styles < max_styles) {
_ = s.increaseCapacity(
s.cursor.page_pin.node,
.styles,
@@ -9890,7 +10629,7 @@ test "Screen setAttribute splits page on OutOfSpace at max styles" {
}
// Get the page reference after increaseCapacity - cursor may have moved
- var page = &s.cursor.page_pin.node.data;
+ var page = s.cursor.page_pin.node.page();
try testing.expectEqual(max_styles, page.capacity.styles);
// Fill the style map to capacity using the StyleSet's layout capacity