diff options
| author | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-07-16 23:28:05 +0300 |
|---|---|---|
| committer | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-07-16 23:29:28 +0300 |
| commit | 4a3302d19a2baa3a33e910e0088c24e780905b90 (patch) | |
| tree | dcaf76fae1c82102d933c9225b17ca7ac5d3a847 /vendor/libghostty-vt/src/terminal/Screen.zig | |
| parent | 550e9f8cc085f428429f5cf2da0bb2dba897e8b8 (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.zig | 1111 |
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 = ¤t.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 = ¤t.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 |
