terminal/PageList: rework reflow logic to fix issues

Reflow logic now lives inside of ReflowCursor. This fixes multiple
issues with the previous implementation, and is more straightforward
in how it works. The old impl resulted in fragmentation of pages,
leading to unbounded memory growth when resizing repeatedly.

Also improves the preserved_cursor logic in `resizeCols`.
This commit is contained in:
Qwerasd
2024-07-08 22:35:15 -04:00
parent 10dbca9464
commit 8589f2c0fb
2 changed files with 454 additions and 474 deletions

View File

@ -640,46 +640,59 @@ fn resizeCols(
const preserved_cursor: ?struct {
tracked_pin: *Pin,
remaining_rows: usize,
wrapped_rows: usize,
} = if (cursor) |c| cursor: {
const p = self.pin(.{ .active = .{
.x = c.x,
.y = c.y,
} }) orelse break :cursor null;
const active_pin = self.pin(.{ .active = .{} });
// We count how many wraps the cursor had before it to begin with
// so that we can offset any additional wraps to avoid pushing the
// original row contents in to the scrollback.
const wrapped = wrapped: {
var wrapped: usize = 0;
var row_it = p.rowIterator(.left_up, active_pin);
while (row_it.next()) |next| {
const row = next.rowAndCell().row;
if (row.wrap_continuation) wrapped += 1;
}
break :wrapped wrapped;
};
break :cursor .{
.tracked_pin = try self.trackPin(p),
.remaining_rows = self.rows - c.y - 1,
.wrapped_rows = wrapped,
};
} else null;
defer if (preserved_cursor) |c| self.untrackPin(c.tracked_pin);
// Go page by page and shrink the columns on a per-page basis.
var it = self.pageIterator(.right_down, .{ .screen = .{} }, null);
while (it.next()) |chunk| {
// Fast-path: none of our rows are wrapped. In this case we can
// treat this like a no-reflow resize. This only applies if we
// are growing columns.
if (cols > self.cols) no_reflow: {
const page = &chunk.page.data;
const rows = page.rows.ptr(page.memory)[0..page.size.rows];
const first = self.pages.first.?;
var it = self.rowIterator(.right_down, .{ .screen = .{} }, null);
// If our first row is a wrap continuation, then we have to
// reflow since we're continuing a wrapped line.
if (rows[0].wrap_continuation) break :no_reflow;
const dst_node = try self.createPage(try first.data.capacity.adjust(.{ .cols = cols }));
dst_node.data.size.rows = 1;
// If any row is soft-wrapped then we have to reflow
for (rows) |row| {
if (row.wrap) break :no_reflow;
}
// Set our new page as the only page. This orphans the existing pages
// in the list, but that's fine since we're gonna delete them anyway.
self.pages.first = dst_node;
self.pages.last = dst_node;
try self.resizeWithoutReflowGrowCols(cols, chunk);
continue;
var dst_cursor = ReflowCursor.init(dst_node);
// Reflow all our rows.
while (it.next()) |row| {
try dst_cursor.reflowRow(self, row);
// Once we're done reflowing a page, destroy it.
if (row.y == row.page.data.size.rows - 1) {
self.destroyPage(row.page);
}
// Note: we can do a fast-path here if all of our rows in this
// page already fit within the new capacity. In that case we can
// do a non-reflow resize.
try self.reflowPage(cols, chunk.page);
}
// If our total rows is less than our active rows, we need to grow.
@ -701,6 +714,8 @@ fn resizeCols(
c.tracked_pin.*,
) orelse break :cursor;
const active_pin = self.pin(.{ .active = .{} });
// We need to determine how many rows we wrapped from the original
// and subtract that from the remaining rows we expect because if
// we wrap down we don't want to push our original row contents into
@ -708,23 +723,25 @@ fn resizeCols(
const wrapped = wrapped: {
var wrapped: usize = 0;
var row_it = c.tracked_pin.rowIterator(.left_up, null);
_ = row_it.next(); // skip ourselves
var row_it = c.tracked_pin.rowIterator(.left_up, active_pin);
while (row_it.next()) |next| {
const row = next.rowAndCell().row;
if (!row.wrap) break;
wrapped += 1;
if (row.wrap_continuation) wrapped += 1;
}
break :wrapped wrapped;
};
// If we wrapped more than we expect, do nothing.
if (wrapped >= c.remaining_rows) break :cursor;
const desired = c.remaining_rows - wrapped;
const current = self.rows - (active_pt.active.y + 1);
if (current >= desired) break :cursor;
for (0..desired - current) |_| _ = try self.grow();
const current = self.rows - active_pt.active.y - 1;
var req_rows = c.remaining_rows;
req_rows -|= wrapped -| c.wrapped_rows;
req_rows -|= current;
while (req_rows > 0) {
_ = try self.grow();
req_rows -= 1;
}
}
// Update our cols
@ -739,22 +756,360 @@ const ReflowCursor = struct {
x: size.CellCountInt,
y: size.CellCountInt,
pending_wrap: bool,
node: *List.Node,
page: *pagepkg.Page,
page_row: *pagepkg.Row,
page_cell: *pagepkg.Cell,
new_rows: usize,
fn init(page: *pagepkg.Page) ReflowCursor {
fn init(node: *List.Node) ReflowCursor {
const page = &node.data;
const rows = page.rows.ptr(page.memory);
return .{
.x = 0,
.y = 0,
.pending_wrap = false,
.node = node,
.page = page,
.page_row = &rows[0],
.page_cell = &rows[0].cells.ptr(page.memory)[0],
.new_rows = 0,
};
}
/// Reflow the provided row in to this cursor.
fn reflowRow(
self: *ReflowCursor,
list: *PageList,
row: Pin,
) !void {
const src_page = &row.page.data;
const src_row = row.rowAndCell().row;
const src_y = row.y;
// Inherit increased styles or grapheme bytes from
// the src page we're reflowing from for new pages.
const cap = try src_page.capacity.adjust(.{ .cols = self.page.size.cols });
const cells = src_row.cells.ptr(src_page.memory)[0..src_page.size.cols];
var cols_len = src_page.size.cols;
// If the row is wrapped, all empty cells are meaningful.
if (!src_row.wrap) {
while (cols_len > 0) {
if (!cells[cols_len - 1].isEmpty()) break;
cols_len -= 1;
}
// If the row has a semantic prompt then the blank row is meaningful
// so we just consider pretend the first cell of the row isn't empty.
if (cols_len == 0 and src_row.semantic_prompt != .unknown) cols_len = 1;
}
// Handle tracked pin adjustments.
{
var it = list.tracked_pins.keyIterator();
while (it.next()) |p_ptr| {
const p = p_ptr.*;
if (&p.page.data != src_page or
p.y != src_y) continue;
// If this pin is in the blanks on the right and past the end
// of the dst col width then we move it to the end of the dst
// col width instead.
if (p.x >= cols_len) {
p.x = @min(p.x, cap.cols - 1 - self.x);
}
// We increase our col len to at least include this pin.
// This ensures that blank rows with pins are processed,
// so that the pins can be properly remapped.
cols_len = @max(cols_len, p.x + 1);
}
}
// Defer processing of blank rows so that blank rows
// at the end of the page list are never written.
if (cols_len == 0) {
// If this blank row was a wrap continuation somehow
// then we won't need to write it since it should be
// a part of the previously written row.
if (!src_row.wrap_continuation) {
self.new_rows += 1;
}
return;
}
// Our row isn't blank, write any new rows we deferred.
while (self.new_rows > 0) {
self.new_rows -= 1;
try self.cursorScrollOrNewPage(list, cap);
}
self.copyRowMetadata(src_row);
var x: usize = 0;
while (x < cols_len) {
if (self.pending_wrap) {
self.page_row.wrap = true;
try self.cursorScrollOrNewPage(list, cap);
self.copyRowMetadata(src_row);
self.page_row.wrap_continuation = true;
}
// Move any tracked pins from the source.
{
var it = list.tracked_pins.keyIterator();
while (it.next()) |p_ptr| {
const p = p_ptr.*;
if (&p.page.data != src_page or
p.y != src_y or
p.x != x) continue;
p.page = self.node;
p.x = self.x;
p.y = self.y;
}
}
const cell = &cells[x];
x += 1;
// std.log.warn("\nsrc_y={} src_x={} dst_y={} dst_x={} dst_cols={} cp={} wide={}", .{
// src_y,
// x,
// self.y,
// self.x,
// self.page.size.cols,
// cell.content.codepoint,
// cell.wide,
// });
// Copy cell contents.
switch (cell.content_tag) {
.codepoint,
.codepoint_grapheme,
=> switch (cell.wide) {
.narrow => self.page_cell.* = cell.*,
.wide => if (self.page.size.cols > 1) {
if (self.x == self.page.size.cols - 1) {
// If there's a wide character in the last column of
// the reflowed page then we need to insert a spacer
// head and wrap before handling it.
self.page_cell.* = .{
.content_tag = .codepoint,
.content = .{ .codepoint = 0 },
.wide = .spacer_head,
};
// Decrement the source position so that when we
// loop we'll process this source cell again.
x -= 1;
} else {
self.page_cell.* = cell.*;
}
} else {
// Edge case, when resizing to 1 column, wide
// characters are just destroyed and replaced
// with empty narrow cells.
self.page_cell.content.codepoint = 0;
self.page_cell.wide = .narrow;
self.cursorForward();
// Skip spacer tail so it doesn't cause a wrap.
x += 1;
continue;
},
.spacer_tail => if (self.page.size.cols > 1) {
self.page_cell.* = cell.*;
} else {
// Edge case, when resizing to 1 column, wide
// characters are just destroyed and replaced
// with empty narrow cells, so we should just
// discard any spacer tails.
continue;
},
.spacer_head => {
// Spacer heads should be ignored. If we need a
// spacer head in our reflowed page, it is added
// when processing the wide cell it belongs to.
continue;
},
},
.bg_color_palette,
.bg_color_rgb,
=> {
// These are guaranteed to have no style or grapheme
// data associated with them so we can fast path them.
self.page_cell.* = cell.*;
self.cursorForward();
continue;
},
}
// Prevent integrity checks from tripping
// while copying graphemes and hyperlinks.
if (comptime std.debug.runtime_safety) {
self.page_cell.style_id = stylepkg.default_id;
}
// Copy grapheme data.
if (cell.content_tag == .codepoint_grapheme) {
// The tag is asserted to be .codepoint in setGraphemes.
self.page_cell.content_tag = .codepoint;
// Copy the graphemes
const cps = src_page.lookupGrapheme(cell).?;
// If our page can't support an additional cell with
// graphemes then we create a new page for this row.
if (self.page.graphemeCount() >= self.page.graphemeCapacity() - 1) {
try self.moveLastRowToNewPage(list, cap);
} else {
// Attempt to allocate the space that would be required for
// these graphemes, and if it's not available, create a new
// page for this row.
if (self.page.grapheme_alloc.alloc(
u21,
self.page.memory,
cps.len,
)) |slice| {
self.page.grapheme_alloc.free(self.page.memory, slice);
} else |_| {
try self.moveLastRowToNewPage(list, cap);
}
}
self.page_row.grapheme = true;
// This shouldn't fail since we made sure we have space above.
try self.page.setGraphemes(self.page_row, self.page_cell, cps);
}
// Copy hyperlink data.
if (cell.hyperlink) {
const src_id = src_page.lookupHyperlink(cell).?;
const src_link = src_page.hyperlink_set.get(src_page.memory, src_id);
// If our page can't support an additional cell with
// a hyperlink ID then we create a new page for this row.
if (self.page.hyperlinkCount() >= self.page.hyperlinkCapacity() - 1) {
try self.moveLastRowToNewPage(list, cap);
}
const dst_id = self.page.hyperlink_set.addWithIdContext(
self.page.memory,
try src_link.dupe(src_page, self.page),
src_id,
.{ .page = self.page },
) catch id: {
// We have no space for this link,
// so make a new page for this row.
try self.moveLastRowToNewPage(list, cap);
break :id try self.page.hyperlink_set.addContext(
self.page.memory,
try src_link.dupe(src_page, self.page),
.{ .page = self.page },
);
} orelse src_id;
self.page_row.hyperlink = true;
// We expect this to succeed due to the
// hyperlinkCapacity check we did before.
try self.page.setHyperlink(
self.page_row,
self.page_cell,
dst_id,
);
}
// Copy style data.
if (cell.hasStyling()) {
const style = src_page.styles.get(
src_page.memory,
cell.style_id,
).*;
const id = self.page.styles.addWithId(
self.page.memory,
style,
cell.style_id,
) catch id: {
// We have no space for this style,
// so make a new page for this row.
try self.moveLastRowToNewPage(list, cap);
break :id try self.page.styles.add(
self.page.memory,
style,
);
} orelse cell.style_id;
self.page_row.styled = true;
self.page_cell.style_id = id;
}
self.cursorForward();
}
// If the source row isn't wrapped then we should scroll afterwards.
if (!src_row.wrap) {
self.new_rows += 1;
}
}
/// Create a new page in the provided list with the provided
/// capacity then clone the row currently being worked on to
/// it and delete it from the old page. Places cursor in the
/// same position it was in in the old row in the new one.
///
/// Asserts that the cursor is on the final row of the page.
///
/// Expects that the provided capacity is sufficient to copy
/// the row.
///
/// If this is the only row in the page, the page is removed
/// from the list after cloning the row.
fn moveLastRowToNewPage(
self: *ReflowCursor,
list: *PageList,
cap: Capacity,
) !void {
assert(self.y == self.page.size.rows - 1);
assert(!self.pending_wrap);
const old_node = self.node;
const old_page = self.page;
const old_row = self.page_row;
const old_x = self.x;
try self.cursorNewPage(list, cap);
// Restore the x position of the cursor.
self.cursorAbsolute(old_x, 0);
// We expect to have enough capacity to clone the row.
try self.page.cloneRowFrom(old_page, self.page_row, old_row);
// Clear the row from the old page and truncate it.
old_page.clearCells(old_row, 0, self.page.size.cols);
old_page.size.rows -= 1;
// If that was the last row in that page
// then we should remove it from the list.
if (old_page.size.rows == 0) {
list.pages.remove(old_node);
list.destroyPage(old_node);
}
}
/// True if this cursor is at the bottom of the page by capacity,
/// i.e. we can't scroll anymore.
fn bottom(self: *const ReflowCursor) bool {
@ -776,6 +1131,10 @@ const ReflowCursor = struct {
self.cursorAbsolute(self.x, self.y + 1);
}
/// Create a new row and move the cursor down.
///
/// Asserts that the cursor is on the bottom row of the
/// page and that there is capacity to add a new one.
fn cursorScroll(self: *ReflowCursor) void {
// Scrolling requires that we're on the bottom of our page.
// We also assert that we have capacity because reflow always
@ -796,6 +1155,40 @@ const ReflowCursor = struct {
self.y += 1;
}
/// Create a new page in the provided list with the provided
/// capacity and one row and move the cursor in to it at 0,0
fn cursorNewPage(
self: *ReflowCursor,
list: *PageList,
cap: Capacity,
) !void {
// Remember our new row count so we can restore it
// after reinitializing our cursor on the new page.
const new_rows = self.new_rows;
const node = try list.createPage(cap);
node.data.size.rows = 1;
list.pages.insertAfter(self.node, node);
self.* = ReflowCursor.init(node);
self.new_rows = new_rows;
}
/// Performs `cursorScroll` or `cursorNewPage` as necessary
/// depending on if the cursor is currently at the bottom.
fn cursorScrollOrNewPage(
self: *ReflowCursor,
list: *PageList,
cap: Capacity,
) !void {
if (self.bottom()) {
try self.cursorNewPage(list, cap);
} else {
self.cursorScroll();
}
}
fn cursorAbsolute(
self: *ReflowCursor,
x: size.CellCountInt,
@ -840,446 +1233,6 @@ const ReflowCursor = struct {
}
};
/// Reflow the given page into the new capacity. The new capacity can have
/// any number of columns and rows. This will create as many pages as
/// necessary to fit the reflowed text and will remove the old page.
///
/// Note a couple edge cases:
///
/// 1. All initial rows that are wrap continuations are ignored. If you
/// want to reflow these lines you must reflow the page with the
/// initially wrapped line.
///
/// 2. If the last row is wrapped then we will traverse forward to reflow
/// all the continuation rows. This follows from #1.
///
/// Despite the edge cases above, this will only ever remove the initial
/// node, so that this can be called within a pageIterator. This is a weird
/// detail that will surely cause bugs one day so we should look into fixing
/// it. :)
///
/// Conceptually, this is a simple process: we're effectively traversing
/// the old page and rewriting into the new page as if it were a text editor.
/// But, due to the edge cases, cursor tracking, and attempts at efficiency,
/// the code can be convoluted so this is going to be a heavily commented
/// function.
fn reflowPage(
self: *PageList,
cols: size.CellCountInt,
initial_node: *List.Node,
) !void {
// The cursor tracks where we are in the source page.
var src_node = initial_node;
var src_cursor = ReflowCursor.init(&src_node.data);
// Skip initially reflowed lines
if (src_cursor.page_row.wrap_continuation) {
while (src_cursor.page_row.wrap_continuation) {
// If this entire page was continuations then we can remove it.
if (src_cursor.y == src_cursor.page.size.rows - 1) {
// If this is the last page, then we need to insert an empty
// page so that erasePage works. This is a rare scenario that
// can happen in no-scrollback pages where EVERY line is
// a continuation.
if (initial_node.prev == null and initial_node.next == null) {
const cap = try std_capacity.adjust(.{ .cols = cols });
const node = try self.createPage(cap);
self.pages.insertAfter(initial_node, node);
}
self.erasePage(initial_node);
return;
}
src_cursor.cursorDown();
}
}
// This is set to true when we're in the middle of completing a wrap
// from the initial page. If this is true, the moment we see a non-wrapped
// row we are done.
var src_completing_wrap = false;
// This is used to count blank lines so that we don't copy those.
var blank_lines: usize = 0;
// This is set to true when we're wrapping a line that requires a new
// writer page.
var dst_wrap = false;
// Our new capacity when growing columns may also shrink rows. So we
// need to do a loop in order to potentially make multiple pages.
dst_loop: while (true) {
// Our cap is based on the source page cap so we can inherit
// potentially increased styles/graphemes/etc.
const cap = try src_cursor.page.capacity.adjust(.{ .cols = cols });
// Create our new page and our cursor restarts at 0,0 in the new page.
// The new page always starts with a size of 1 because we know we have
// at least one row to copy from the src.
const dst_node = try self.createPage(cap);
defer dst_node.data.assertIntegrity();
dst_node.data.size.rows = 1;
var dst_cursor = ReflowCursor.init(&dst_node.data);
dst_cursor.copyRowMetadata(src_cursor.page_row);
// Set our wrap state
if (dst_wrap) {
dst_cursor.page_row.wrap_continuation = true;
dst_wrap = false;
}
// Our new page goes before our src node. This will append it to any
// previous pages we've created.
self.pages.insertBefore(initial_node, dst_node);
src_loop: while (true) {
// Continue traversing the source until we're out of space in our
// destination or we've copied all our intended rows.
const started_completing_wrap = src_completing_wrap;
for (src_cursor.y..src_cursor.page.size.rows) |src_y| {
// If we started completing a wrap and our flag is no longer true
// then we completed it and we can exit the loop.
if (started_completing_wrap and !src_completing_wrap) break;
// We are previously wrapped if we're not on the first row and
// the previous row was wrapped OR if we're on the first row
// but we're not on our initial node it means the last row of
// our previous page was wrapped.
const prev_wrap =
(src_y > 0 and src_cursor.page_row.wrap) or
(src_y == 0 and src_node != initial_node);
src_cursor.cursorAbsolute(0, @intCast(src_y));
// Trim trailing empty cells if the row is not wrapped. If the
// row is wrapped then we don't trim trailing empty cells because
// the empty cells can be meaningful.
const trailing_empty = src_cursor.countTrailingEmptyCells();
const cols_len = cols_len: {
var cols_len = src_cursor.page.size.cols - trailing_empty;
if (cols_len > 0) {
// We want to update any tracked pins that are in our
// trailing empty cells to the last col. We don't
// want to wrap blanks.
var it = self.tracked_pins.keyIterator();
while (it.next()) |p_ptr| {
const p = p_ptr.*;
if (&p.page.data != src_cursor.page or
p.y != src_cursor.y or
p.x < cols_len) continue;
if (p.x >= cap.cols) p.x = cap.cols - 1;
}
break :cols_len cols_len;
}
// If a tracked pin is in this row then we need to keep it
// even if it is empty, because it is somehow meaningful
// (usually the screen cursor), but we do trim the cells
// down to the desired size.
//
// The reason we do this logic is because if you do a scroll
// clear (i.e. move all active into scrollback and reset
// the screen), the cursor is on the top line again with
// an empty active. If you resize to a smaller col size we
// don't want to "pull down" all the scrollback again. The
// user expects we just shrink the active area.
var it = self.tracked_pins.keyIterator();
while (it.next()) |p_ptr| {
const p = p_ptr.*;
if (&p.page.data != src_cursor.page or
p.y != src_cursor.y) continue;
// If our tracked pin is outside our resized cols, we
// trim it to the last col, we don't want to wrap blanks.
if (p.x >= cap.cols) p.x = cap.cols - 1;
// We increase our col len to at least include this pin
cols_len = @max(cols_len, p.x + 1);
}
if (cols_len == 0) {
// If the row is empty, we don't copy it. We count it as a
// blank line and continue to the next row.
blank_lines += 1;
continue;
}
break :cols_len cols_len;
};
// We have data, if we have blank lines we need to create them first.
if (blank_lines > 0) {
// This is a dumb edge caes where if we start with blank
// lines, we're off by one because our cursor is at 0
// on the first blank line but if its in the middle we
// haven't scrolled yet. Don't worry, this is covered by
// unit tests so if we find a better way we can remove this.
const len = blank_lines - @intFromBool(blank_lines >= src_y);
for (0..len) |i| {
// If we're at the bottom we can't fit anymore into this page,
// so we need to reloop and create a new page.
if (dst_cursor.bottom()) {
blank_lines -= i;
continue :dst_loop;
}
// TODO: cursor in here
dst_cursor.cursorScroll();
}
}
if (src_y > 0) {
// We're done with this row, if this row isn't wrapped, we can
// move our destination cursor to the next row.
//
// The blank_lines == 0 condition is because if we were prefixed
// with blank lines, we handled the scroll already above.
if (!prev_wrap) {
if (dst_cursor.bottom()) continue :dst_loop;
dst_cursor.cursorScroll();
}
dst_cursor.copyRowMetadata(src_cursor.page_row);
}
// Reset our blank line count since handled it all above.
blank_lines = 0;
for (src_cursor.x..cols_len) |src_x| {
assert(src_cursor.x == src_x);
// std.log.warn("src_y={} src_x={} dst_y={} dst_x={} dst_cols={} cp={} wide={}", .{
// src_cursor.y,
// src_cursor.x,
// dst_cursor.y,
// dst_cursor.x,
// dst_cursor.page.size.cols,
// src_cursor.page_cell.content.codepoint,
// src_cursor.page_cell.wide,
// });
if (cap.cols > 1) switch (src_cursor.page_cell.wide) {
.narrow => {},
.wide => if (!dst_cursor.pending_wrap and
dst_cursor.x == cap.cols - 1)
{
self.reflowUpdateCursor(&src_cursor, &dst_cursor, dst_node);
dst_cursor.page_cell.* = .{
.content_tag = .codepoint,
.content = .{ .codepoint = 0 },
.wide = .spacer_head,
};
dst_cursor.cursorForward();
assert(dst_cursor.pending_wrap);
},
.spacer_head => if (dst_cursor.pending_wrap or
dst_cursor.x != cap.cols - 1)
{
assert(src_cursor.x == src_cursor.page.size.cols - 1);
self.reflowUpdateCursor(&src_cursor, &dst_cursor, dst_node);
continue;
},
else => {},
};
if (dst_cursor.pending_wrap) {
dst_cursor.page_row.wrap = true;
if (dst_cursor.bottom()) {
dst_wrap = true;
continue :dst_loop;
}
dst_cursor.cursorScroll();
dst_cursor.page_row.wrap_continuation = true;
dst_cursor.copyRowMetadata(src_cursor.page_row);
}
// A rare edge case. If we're resizing down to 1 column
// and the source is a non-narrow character, we reset the
// cell to a narrow blank and we skip to the next cell.
if (cap.cols == 1 and src_cursor.page_cell.wide != .narrow) {
switch (src_cursor.page_cell.wide) {
.narrow => unreachable,
// Wide char, we delete it, reset it to narrow,
// and skip forward.
.wide => {
dst_cursor.page_cell.content.codepoint = 0;
dst_cursor.page_cell.wide = .narrow;
src_cursor.cursorForward();
continue;
},
// Skip spacer tails since we should've already
// handled them in the previous cell.
.spacer_tail => {},
// TODO: test?
.spacer_head => {},
}
} else {
switch (src_cursor.page_cell.content_tag) {
// These are guaranteed to have no styling data and no
// graphemes, a fast path.
.bg_color_palette,
.bg_color_rgb,
=> {
assert(!src_cursor.page_cell.hasStyling());
assert(!src_cursor.page_cell.hasGrapheme());
dst_cursor.page_cell.* = src_cursor.page_cell.*;
},
.codepoint => {
dst_cursor.page_cell.* = src_cursor.page_cell.*;
},
.codepoint_grapheme => {
// We copy the cell like normal but we have to reset the
// tag because this is used for fast-path detection in
// appendGrapheme.
dst_cursor.page_cell.* = src_cursor.page_cell.*;
dst_cursor.page_cell.content_tag = .codepoint;
// Unset the style ID so our integrity checks don't fire.
// We handle style fixups after this switch block.
if (comptime std.debug.runtime_safety) {
dst_cursor.page_cell.style_id = stylepkg.default_id;
}
// Copy the graphemes
const src_cps = src_cursor.page.lookupGrapheme(src_cursor.page_cell).?;
for (src_cps) |cp| {
try dst_cursor.page.appendGrapheme(
dst_cursor.page_row,
dst_cursor.page_cell,
cp,
);
}
},
}
// If the source cell has a hyperlink we need to copy it
if (src_cursor.page_cell.hyperlink) {
const src_page = src_cursor.page;
const dst_page = dst_cursor.page;
// Pause integrity checks because setHyperlink
// calls them but we're not ready yet.
dst_page.pauseIntegrityChecks(true);
defer dst_page.pauseIntegrityChecks(false);
const id = src_page.lookupHyperlink(src_cursor.page_cell).?;
const src_link = src_page.hyperlink_set.get(src_page.memory, id);
const dst_id = try dst_page.hyperlink_set.addContext(
dst_page.memory,
try src_link.dupe(src_page, dst_page),
.{ .page = dst_page },
);
try dst_page.setHyperlink(
dst_cursor.page_row,
dst_cursor.page_cell,
dst_id,
);
}
// If the source cell has a style, we need to copy it.
if (src_cursor.page_cell.style_id != stylepkg.default_id) {
const src_style = src_cursor.page.styles.get(
src_cursor.page.memory,
src_cursor.page_cell.style_id,
).*;
if (try dst_cursor.page.styles.addWithId(
dst_cursor.page.memory,
src_style,
src_cursor.page_cell.style_id,
)) |id| {
dst_cursor.page_cell.style_id = id;
}
dst_cursor.page_row.styled = true;
}
}
// If our original cursor was on this page, this x/y then
// we need to update to the new location.
self.reflowUpdateCursor(&src_cursor, &dst_cursor, dst_node);
// Move both our cursors forward
src_cursor.cursorForward();
dst_cursor.cursorForward();
} else cursor: {
// We made it through all our source columns. As a final edge
// case, if our cursor is in one of the blanks, we update it
// to the edge of this page.
// If we are in wrap completion mode and this row is not wrapped
// then we are done and we can gracefully exit our y loop.
if (src_completing_wrap and !src_cursor.page_row.wrap) {
assert(started_completing_wrap);
src_completing_wrap = false;
}
// If we have no trailing empty cells, it can't be in the blanks.
if (trailing_empty == 0) break :cursor;
// Update all our tracked pins
var it = self.tracked_pins.keyIterator();
while (it.next()) |p_ptr| {
const p = p_ptr.*;
if (&p.page.data != src_cursor.page or
p.y != src_cursor.y or
p.x < cols_len) continue;
p.page = dst_node;
p.y = dst_cursor.y;
}
}
}
// If we're still in a wrapped line at the end of our page,
// we traverse forward and continue reflowing until we complete
// this entire line.
if (src_cursor.page_row.wrap) wrap: {
src_completing_wrap = true;
src_node = src_node.next orelse break :wrap;
src_cursor = ReflowCursor.init(&src_node.data);
continue :src_loop;
}
// We are not on a wrapped line, we're truly done.
self.pages.remove(initial_node);
self.destroyPage(initial_node);
return;
}
}
}
/// This updates the cursor offset if the cursor is exactly on the cell
/// we're currently reflowing. This can then be fixed up later to an exact
/// x/y (see resizeCols).
fn reflowUpdateCursor(
self: *const PageList,
src_cursor: *const ReflowCursor,
dst_cursor: *const ReflowCursor,
dst_node: *List.Node,
) void {
// Update all our tracked pins
var it = self.tracked_pins.keyIterator();
while (it.next()) |p_ptr| {
const p = p_ptr.*;
if (&p.page.data != src_cursor.page or
p.y != src_cursor.y or
p.x != src_cursor.x) continue;
p.page = dst_node;
p.x = dst_cursor.x;
p.y = dst_cursor.y;
}
}
fn resizeWithoutReflow(self: *PageList, opts: Resize) !void {
// We only set the new min_max_size if we're not reflowing. If we are
// reflowing, then resize handles this for us.

View File

@ -1003,6 +1003,33 @@ pub const Page = struct {
return self.hyperlink_map.map(self.memory).capacity();
}
/// Set the graphemes for the given cell. This asserts that the cell
/// has no graphemes set, and only contains a single codepoint.
pub fn setGraphemes(self: *Page, row: *Row, cell: *Cell, cps: []u21) Allocator.Error!void {
defer self.assertIntegrity();
assert(cell.hasText());
assert(cell.content_tag == .codepoint);
const cell_offset = getOffset(Cell, self.memory, cell);
var map = self.grapheme_map.map(self.memory);
const slice = try self.grapheme_alloc.alloc(u21, self.memory, cps.len);
errdefer self.grapheme_alloc.free(self.memory, slice);
@memcpy(slice, cps);
try map.putNoClobber(cell_offset, .{
.offset = getOffset(u21, self.memory, @ptrCast(slice.ptr)),
.len = slice.len,
});
errdefer map.remove(cell_offset);
cell.content_tag = .codepoint_grapheme;
row.grapheme = true;
return;
}
/// Append a codepoint to the given cell as a grapheme.
pub fn appendGrapheme(self: *Page, row: *Row, cell: *Cell, cp: u21) Allocator.Error!void {
defer self.assertIntegrity();