ghostty/src/terminal/Screen.zig
Mitchell Hashimoto cd2e2b801a Screen.cursorScrollAboveRotate memory corruption fix (#3129)
Extracted from #3110

Initial fix is relatively basic, and catching it with a test only
required a little bit of extra scrutiny of the cursor state after one of
the tests that we already had.

However, the fix revealed faulty dirty tracking logic throughout the
`cursorScrollAbove` function (and therefore bad results that were being
tested for when they should not have been). I've ended up clarifying
things, fixing the asserted dirty states in all the `cursorScrollAbove`
tests, and then finally implementing another very trivial fix that
catches the mistake.

Fixing the dirty tracking is really just an exercise in correctness
though, since when the scroll happens it inherently invalidates the
viewport, and therefore will trigger a full rebuild in the renderer...
unless, I guess, another operation is performed that cancels things out
and results in the viewport pin being in the same place as the previous
render, but that seems an exceptionally difficult scenario to make
happen on purpose much less accidentally.

This PR is almost entirely changes to comments and tests, there are only
2 lines of real code it changes, the one added to the start of
`cursorScrollAbove` and the one modified at the start of
`cursorScrollAboveRotate`. I believe these changes are entirely safe. (I
wonder if they might have a bad effect on our `vtebench` scrolling
performance though...)
2024-12-26 06:30:27 -08:00

8806 lines
277 KiB
Zig

const Screen = @This();
const std = @import("std");
const build_config = @import("../build_config.zig");
const Allocator = std.mem.Allocator;
const assert = std.debug.assert;
const ansi = @import("ansi.zig");
const charsets = @import("charsets.zig");
const fastmem = @import("../fastmem.zig");
const kitty = @import("kitty.zig");
const sgr = @import("sgr.zig");
const unicode = @import("../unicode/main.zig");
const Selection = @import("Selection.zig");
const PageList = @import("PageList.zig");
const StringMap = @import("StringMap.zig");
const pagepkg = @import("page.zig");
const point = @import("point.zig");
const size = @import("size.zig");
const style = @import("style.zig");
const hyperlink = @import("hyperlink.zig");
const Offset = size.Offset;
const Page = pagepkg.Page;
const Row = pagepkg.Row;
const Cell = pagepkg.Cell;
const Pin = PageList.Pin;
const log = std.log.scoped(.screen);
/// The general purpose allocator to use for all memory allocations.
/// Unfortunately some screen operations do require allocation.
alloc: Allocator,
/// The list of pages in the screen.
pages: PageList,
/// Special-case where we want no scrollback whatsoever. We have to flag
/// this because max_size 0 in PageList gets rounded up to two pages so
/// we can always have an active screen.
no_scrollback: bool = false,
/// The current cursor position
cursor: Cursor,
/// The saved cursor
saved_cursor: ?SavedCursor = null,
/// The selection for this screen (if any). This MUST be a tracked selection
/// otherwise the selection will become invalid. Instead of accessing this
/// directly to set it, use the `select` function which will assert and
/// automatically setup tracking.
selection: ?Selection = null,
/// The charset state
charset: CharsetState = .{},
/// The current or most recent protected mode. Once a protection mode is
/// set, this will never become "off" again until the screen is reset.
/// The current state of whether protection attributes should be set is
/// set on the Cell pen; this is only used to determine the most recent
/// protection mode since some sequences such as ECH depend on this.
protected_mode: ansi.ProtectedMode = .off,
/// The kitty keyboard settings.
kitty_keyboard: kitty.KeyFlagStack = .{},
/// Kitty graphics protocol state.
kitty_images: kitty.graphics.ImageStorage = .{},
/// Dirty flags for the renderer.
dirty: Dirty = .{},
/// See Terminal.Dirty. This behaves the same way.
pub const Dirty = packed struct {
/// Set when the selection is set or unset, regardless of if the
/// selection is changed or not.
selection: bool = false,
/// When an OSC8 hyperlink is hovered, we set the full screen as dirty
/// because links can span multiple lines.
hyperlink_hover: bool = false,
};
/// The cursor position and style.
pub const Cursor = struct {
// The x/y position within the viewport.
x: size.CellCountInt = 0,
y: size.CellCountInt = 0,
/// The visual style of the cursor. This defaults to block because
/// it has to default to something, but users of this struct are
/// encouraged to set their own default.
cursor_style: CursorStyle = .block,
/// The "last column flag (LCF)" as its called. If this is set then the
/// next character print will force a soft-wrap.
pending_wrap: bool = false,
/// The protected mode state of the cursor. If this is true then
/// all new characters printed will have the protected state set.
protected: bool = false,
/// The currently active style. This is the concrete style value
/// that should be kept up to date. The style ID to use for cell writing
/// is below.
style: style.Style = .{},
/// The currently active style ID. The style is page-specific so when
/// we change pages we need to ensure that we update that page with
/// our style when used.
style_id: style.Id = style.default_id,
/// The hyperlink ID that is currently active for the cursor. A value
/// of zero means no hyperlink is active. (Implements OSC8, saying that
/// so code search can find it.).
hyperlink_id: hyperlink.Id = 0,
/// This is the implicit ID to use for hyperlinks that don't specify
/// an ID. We do an overflowing add to this so repeats can technically
/// happen with carefully crafted inputs but for real workloads its
/// highly unlikely -- and the fix is for the TUI program to use explicit
/// IDs.
hyperlink_implicit_id: size.OffsetInt = 0,
/// Heap-allocated hyperlink state so that we can recreate it when
/// the cursor page pin changes. We can't get it from the old screen
/// state because the page may be cleared. This is heap allocated
/// because its most likely null.
hyperlink: ?*hyperlink.Hyperlink = null,
/// The pointers into the page list where the cursor is currently
/// located. This makes it faster to move the cursor.
page_pin: *PageList.Pin,
page_row: *pagepkg.Row,
page_cell: *pagepkg.Cell,
pub fn deinit(self: *Cursor, alloc: Allocator) void {
if (self.hyperlink) |link| {
link.deinit(alloc);
alloc.destroy(link);
}
}
};
/// The visual style of the cursor. Whether or not it blinks
/// is determined by mode 12 (modes.zig). This mode is synchronized
/// with CSI q, the same as xterm.
pub const CursorStyle = enum {
bar, // DECSCUSR 5, 6
block, // DECSCUSR 1, 2
underline, // DECSCUSR 3, 4
/// The cursor styles below aren't known by DESCUSR and are custom
/// implemented in Ghostty. They are reported as some standard style
/// if requested, though.
/// Hollow block cursor. This is a block cursor with the center empty.
/// Reported as DECSCUSR 1 or 2 (block).
block_hollow,
};
/// Saved cursor state.
pub const SavedCursor = struct {
x: size.CellCountInt,
y: size.CellCountInt,
style: style.Style,
protected: bool,
pending_wrap: bool,
origin: bool,
charset: CharsetState,
};
/// State required for all charset operations.
pub const CharsetState = struct {
/// The list of graphical charsets by slot
charsets: CharsetArray = CharsetArray.initFill(charsets.Charset.utf8),
/// GL is the slot to use when using a 7-bit printable char (up to 127)
/// GR used for 8-bit printable chars.
gl: charsets.Slots = .G0,
gr: charsets.Slots = .G2,
/// Single shift where a slot is used for exactly one char.
single_shift: ?charsets.Slots = null,
/// An array to map a charset slot to a lookup table.
const CharsetArray = std.EnumArray(charsets.Slots, charsets.Charset);
};
/// Initialize a new screen.
///
/// max_scrollback is the amount of scrollback to keep in bytes. This
/// will be rounded UP to the nearest page size because our minimum allocation
/// size is that anyways.
///
/// If max scrollback is 0, then no scrollback is kept at all.
pub fn init(
alloc: Allocator,
cols: size.CellCountInt,
rows: size.CellCountInt,
max_scrollback: usize,
) !Screen {
// Initialize our backing pages.
var pages = try PageList.init(alloc, cols, rows, max_scrollback);
errdefer pages.deinit();
// Create our tracked pin for the cursor.
const page_pin = try pages.trackPin(.{ .node = pages.pages.first.? });
errdefer pages.untrackPin(page_pin);
const page_rac = page_pin.rowAndCell();
return .{
.alloc = alloc,
.pages = pages,
.no_scrollback = max_scrollback == 0,
.cursor = .{
.x = 0,
.y = 0,
.page_pin = page_pin,
.page_row = page_rac.row,
.page_cell = page_rac.cell,
},
};
}
pub fn deinit(self: *Screen) void {
self.kitty_images.deinit(self.alloc, self);
self.cursor.deinit(self.alloc);
self.pages.deinit();
}
/// Assert that the screen is in a consistent state. This doesn't check
/// all pages in the page list because that is SO SLOW even just for
/// tests. This only asserts the screen specific data so callers should
/// ensure they're also calling page integrity checks if necessary.
pub fn assertIntegrity(self: *const Screen) void {
if (build_config.slow_runtime_safety) {
assert(self.cursor.x < self.pages.cols);
assert(self.cursor.y < self.pages.rows);
// Our cursor x/y should always match the pin. If this doesn't
// match then it indicates that the tracked pin moved and we didn't
// account for it by either calling cursorReload or manually
// adjusting.
const pt: point.Point = self.pages.pointFromPin(
.active,
self.cursor.page_pin.*,
) orelse unreachable;
assert(self.cursor.x == pt.active.x);
assert(self.cursor.y == pt.active.y);
}
}
/// Reset the screen according to the logic of a DEC RIS sequence.
///
/// - Clears the screen and attempts to reclaim memory.
/// - Moves the cursor to the top-left.
/// - Clears any cursor state: style, hyperlink, etc.
/// - Resets the charset
/// - Clears the selection
/// - Deletes all Kitty graphics
/// - Resets Kitty Keyboard settings
/// - Disables protection mode
///
pub fn reset(self: *Screen) void {
// Reset our pages
self.pages.reset();
// The above reset preserves tracked pins so we can still use
// our cursor pin, which should be at the top-left already.
const cursor_pin: *PageList.Pin = self.cursor.page_pin;
assert(cursor_pin.node == self.pages.pages.first.?);
assert(cursor_pin.x == 0);
assert(cursor_pin.y == 0);
const cursor_rac = cursor_pin.rowAndCell();
self.cursor.deinit(self.alloc);
self.cursor = .{
.page_pin = cursor_pin,
.page_row = cursor_rac.row,
.page_cell = cursor_rac.cell,
};
// Clear kitty graphics
self.kitty_images.delete(
self.alloc,
undefined, // All image deletion doesn't need the terminal
.{ .all = true },
);
// Reset our basic state
self.saved_cursor = null;
self.charset = .{};
self.kitty_keyboard = .{};
self.protected_mode = .off;
self.clearSelection();
}
/// Clone the screen.
///
/// This will copy:
///
/// - Screen dimensions
/// - Screen data (cell state, etc.) for the region
///
/// Anything not mentioned above is NOT copied. Some of this is for
/// very good reason:
///
/// - Kitty images have a LOT of data. This is not efficient to copy.
/// Use a lock and access the image data. The dirty bit is there for
/// a reason.
/// - Cursor location can be expensive to calculate with respect to the
/// specified region. It is faster to grab the cursor from the old
/// screen and then move it to the new screen.
/// - Current hyperlink cursor state has heap allocations. Since clone
/// is only for read-only operations, it is better to not have any
/// hyperlink state. Note that already-written hyperlinks are cloned.
///
/// If not mentioned above, then there isn't a specific reason right now
/// to not copy some data other than we probably didn't need it and it
/// isn't necessary for screen coherency.
///
/// Other notes:
///
/// - The viewport will always be set to the active area of the new
/// screen. This is the bottom "rows" rows.
/// - If the clone region is smaller than a viewport area, blanks will
/// be filled in at the bottom.
///
pub fn clone(
self: *const Screen,
alloc: Allocator,
top: point.Point,
bot: ?point.Point,
) !Screen {
return try self.clonePool(alloc, null, top, bot);
}
/// Same as clone but you can specify a custom memory pool to use for
/// the screen.
pub fn clonePool(
self: *const Screen,
alloc: Allocator,
pool: ?*PageList.MemoryPool,
top: point.Point,
bot: ?point.Point,
) !Screen {
// Create a tracked pin remapper for our selection and cursor. Note
// that we may want to expose this generally in the future but at the
// time of doing this we don't need to.
var pin_remap = PageList.Clone.TrackedPinsRemap.init(alloc);
defer pin_remap.deinit();
var pages = try self.pages.clone(.{
.top = top,
.bot = bot,
.memory = if (pool) |p| .{
.pool = p,
} else .{
.alloc = alloc,
},
.tracked_pins = &pin_remap,
});
errdefer pages.deinit();
// Find our cursor. If the cursor isn't in the cloned area, we move it
// to the top-left arbitrarily because a screen must have SOME cursor.
const cursor: Cursor = cursor: {
if (pin_remap.get(self.cursor.page_pin)) |p| remap: {
const page_rac = p.rowAndCell();
const pt = pages.pointFromPin(.active, p.*) orelse break :remap;
break :cursor .{
.x = @intCast(pt.active.x),
.y = @intCast(pt.active.y),
.page_pin = p,
.page_row = page_rac.row,
.page_cell = page_rac.cell,
};
}
const page_pin = try pages.trackPin(.{ .node = pages.pages.first.? });
const page_rac = page_pin.rowAndCell();
break :cursor .{
.x = 0,
.y = 0,
.page_pin = page_pin,
.page_row = page_rac.row,
.page_cell = page_rac.cell,
};
};
// Preserve our selection if we have one.
const sel: ?Selection = if (self.selection) |sel| sel: {
assert(sel.tracked());
const ordered: struct {
tl: *Pin,
br: *Pin,
} = switch (sel.order(self)) {
.forward, .mirrored_forward => .{
.tl = sel.bounds.tracked.start,
.br = sel.bounds.tracked.end,
},
.reverse, .mirrored_reverse => .{
.tl = sel.bounds.tracked.end,
.br = sel.bounds.tracked.start,
},
};
const start_pin = pin_remap.get(ordered.tl) orelse start: {
// No start means it is outside the cloned area. We change it
// to the top-left.
// If we have no end pin then either
// (1) our whole selection is outside the cloned area or
// (2) our cloned area is within the selection
if (pin_remap.get(ordered.br) == null) {
// If our tl is before the cloned area and br is after
// the cloned area then the whole screen is selected.
// This detection is somewhat more expensive so we try
// to avoid it if possible so its nested in this if.
const clone_top = self.pages.pin(top) orelse break :sel null;
if (!sel.contains(self, clone_top)) break :sel null;
}
break :start try pages.trackPin(.{ .node = pages.pages.first.? });
};
const end_pin = pin_remap.get(ordered.br) orelse end: {
// No end means it is outside the cloned area. We change it
// to the bottom-right.
break :end try pages.trackPin(pages.pin(.{ .active = .{
.x = pages.cols - 1,
.y = pages.rows - 1,
} }) orelse break :sel null);
};
break :sel .{
.bounds = .{ .tracked = .{
.start = start_pin,
.end = end_pin,
} },
.rectangle = sel.rectangle,
};
} else null;
const result: Screen = .{
.alloc = alloc,
.pages = pages,
.no_scrollback = self.no_scrollback,
.cursor = cursor,
.selection = sel,
.dirty = self.dirty,
};
result.assertIntegrity();
return result;
}
/// Adjust the capacity of a page within the pagelist of this screen.
/// This handles some accounting if the page being modified is the
/// cursor page.
pub fn adjustCapacity(
self: *Screen,
node: *PageList.List.Node,
adjustment: PageList.AdjustCapacity,
) PageList.AdjustCapacityError!*PageList.List.Node {
// If the page being modified isn't our cursor page then
// this is a quick operation because we have no additional
// accounting.
if (node != self.cursor.page_pin.node) {
return try self.pages.adjustCapacity(node, adjustment);
}
// We're modifying the cursor page. When we adjust the
// 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.adjustCapacity(node, adjustment);
const new_page: *Page = &new_node.data;
// All additions below have unreachable catches because when
// we adjust cap we should have enough memory to fit the
// existing data.
// Re-add the style
if (self.cursor.style_id != 0) {
self.cursor.style_id = new_page.styles.add(
new_page.memory,
self.cursor.style,
) catch unreachable;
}
// Re-add the hyperlink
if (self.cursor.hyperlink) |link| {
// So we don't attempt to free any memory in the replaced page.
self.cursor.hyperlink_id = 0;
self.cursor.hyperlink = null;
// Re-add
self.startHyperlinkOnce(link.*) catch unreachable;
// Remove our old link
link.deinit(self.alloc);
self.alloc.destroy(link);
}
// Reload the cursor information because the pin changed.
// So our page row/cell and so on are all off.
self.cursorReload();
return new_node;
}
pub fn cursorCellRight(self: *Screen, n: size.CellCountInt) *pagepkg.Cell {
assert(self.cursor.x + n < self.pages.cols);
const cell: [*]pagepkg.Cell = @ptrCast(self.cursor.page_cell);
return @ptrCast(cell + n);
}
pub fn cursorCellLeft(self: *Screen, n: size.CellCountInt) *pagepkg.Cell {
assert(self.cursor.x >= n);
const cell: [*]pagepkg.Cell = @ptrCast(self.cursor.page_cell);
return @ptrCast(cell - n);
}
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;
const page_rac = page_pin.rowAndCell();
return page_rac.cell;
}
/// Move the cursor right. This is a specialized function that is very fast
/// if the caller can guarantee we have space to move right (no wrapping).
pub fn cursorRight(self: *Screen, n: size.CellCountInt) void {
assert(self.cursor.x + n < self.pages.cols);
defer self.assertIntegrity();
const cell: [*]pagepkg.Cell = @ptrCast(self.cursor.page_cell);
self.cursor.page_cell = @ptrCast(cell + n);
self.cursor.page_pin.x += n;
self.cursor.x += n;
}
/// Move the cursor left.
pub fn cursorLeft(self: *Screen, n: size.CellCountInt) void {
assert(self.cursor.x >= n);
defer self.assertIntegrity();
const cell: [*]pagepkg.Cell = @ptrCast(self.cursor.page_cell);
self.cursor.page_cell = @ptrCast(cell - n);
self.cursor.page_pin.x -= n;
self.cursor.x -= n;
}
/// Move the cursor up.
///
/// Precondition: The cursor is not at the top of the screen.
pub fn cursorUp(self: *Screen, n: size.CellCountInt) void {
assert(self.cursor.y >= n);
defer self.assertIntegrity();
self.cursor.y -= n; // Must be set before cursorChangePin
const page_pin = self.cursor.page_pin.up(n).?;
self.cursorChangePin(page_pin);
const page_rac = page_pin.rowAndCell();
self.cursor.page_row = page_rac.row;
self.cursor.page_cell = page_rac.cell;
}
pub fn cursorRowUp(self: *Screen, n: size.CellCountInt) *pagepkg.Row {
assert(self.cursor.y >= n);
defer self.assertIntegrity();
const page_pin = self.cursor.page_pin.up(n).?;
const page_rac = page_pin.rowAndCell();
return page_rac.row;
}
/// Move the cursor down.
///
/// Precondition: The cursor is not at the bottom of the screen.
pub fn cursorDown(self: *Screen, n: size.CellCountInt) void {
assert(self.cursor.y + n < self.pages.rows);
defer self.assertIntegrity();
self.cursor.y += n; // Must be set before cursorChangePin
// We move the offset into our page list to the next row and then
// get the pointers to the row/cell and set all the cursor state up.
const page_pin = self.cursor.page_pin.down(n).?;
self.cursorChangePin(page_pin);
const page_rac = page_pin.rowAndCell();
self.cursor.page_row = page_rac.row;
self.cursor.page_cell = page_rac.cell;
}
/// Move the cursor to some absolute horizontal position.
pub fn cursorHorizontalAbsolute(self: *Screen, x: size.CellCountInt) void {
assert(x < self.pages.cols);
defer self.assertIntegrity();
self.cursor.page_pin.x = x;
const page_rac = self.cursor.page_pin.rowAndCell();
self.cursor.page_cell = page_rac.cell;
self.cursor.x = x;
}
/// Move the cursor to some absolute position.
pub fn cursorAbsolute(self: *Screen, x: size.CellCountInt, y: size.CellCountInt) void {
assert(x < self.pages.cols);
assert(y < self.pages.rows);
defer self.assertIntegrity();
var page_pin = if (y < self.cursor.y)
self.cursor.page_pin.up(self.cursor.y - y).?
else if (y > self.cursor.y)
self.cursor.page_pin.down(y - self.cursor.y).?
else
self.cursor.page_pin.*;
page_pin.x = x;
self.cursor.x = x; // Must be set before cursorChangePin
self.cursor.y = y;
self.cursorChangePin(page_pin);
const page_rac = self.cursor.page_pin.rowAndCell();
self.cursor.page_row = page_rac.row;
self.cursor.page_cell = page_rac.cell;
}
/// Reloads the cursor pointer information into the screen. This is expensive
/// so it should only be done in cases where the pointers are invalidated
/// in such a way that its difficult to recover otherwise.
pub fn cursorReload(self: *Screen) void {
defer self.assertIntegrity();
// Our tracked pin is ALWAYS accurate, so we derive the active
// point from the pin. If this returns null it means our pin
// points outside the active area. In that case, we update the
// pin to be the top-left.
const pt: point.Point = self.pages.pointFromPin(
.active,
self.cursor.page_pin.*,
) orelse reset: {
const pin = self.pages.pin(.{ .active = .{} }).?;
self.cursor.page_pin.* = pin;
break :reset self.pages.pointFromPin(.active, pin).?;
};
self.cursor.x = @intCast(pt.active.x);
self.cursor.y = @intCast(pt.active.y);
const page_rac = self.cursor.page_pin.rowAndCell();
self.cursor.page_row = page_rac.row;
self.cursor.page_cell = page_rac.cell;
// If we have a style, we need to ensure it is in the page because this
// method may also be called after a page change.
if (self.cursor.style_id != style.default_id) {
self.manualStyleUpdate() catch |err| {
// This failure should not happen because manualStyleUpdate
// handles page splitting, overflow, and more. This should only
// happen if we're out of RAM. In this case, we'll just degrade
// gracefully back to the default style.
log.err("failed to update style on cursor reload err={}", .{err});
self.cursor.style = .{};
self.cursor.style_id = 0;
};
}
}
/// Scroll the active area and keep the cursor at the bottom of the screen.
/// This is a very specialized function but it keeps it fast.
pub fn cursorDownScroll(self: *Screen) !void {
assert(self.cursor.y == self.pages.rows - 1);
defer self.assertIntegrity();
// Scrolling dirties the images because it updates their placements pins.
self.kitty_images.dirty = true;
// If we have no scrollback, then we shift all our rows instead.
if (self.no_scrollback) {
// If we have a single-row screen, we have no rows to shift
// 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;
self.clearCells(
page,
self.cursor.page_row,
page.getCells(self.cursor.page_row),
);
var dirty = page.dirtyBitSet();
dirty.set(0);
} else {
// eraseRow will shift everything below it up.
try self.pages.eraseRow(.{ .active = .{} });
// Note we don't need to mark anything dirty in this branch
// because eraseRow will mark all the rotated rows as dirty
// in the entire page.
// We need to move our cursor down one because eraseRows will
// preserve our pin directly and we're erasing one row.
const page_pin = self.cursor.page_pin.down(1).?;
self.cursorChangePin(page_pin);
const page_rac = page_pin.rowAndCell();
self.cursor.page_row = page_rac.row;
self.cursor.page_cell = page_rac.cell;
// The above may clear our cursor so we need to update that
// again. If this fails (highly unlikely) we just reset
// the cursor.
self.manualStyleUpdate() catch |err| {
// This failure should not happen because manualStyleUpdate
// handles page splitting, overflow, and more. This should only
// happen if we're out of RAM. In this case, we'll just degrade
// gracefully back to the default style.
log.err("failed to update style on cursor scroll err={}", .{err});
self.cursor.style = .{};
self.cursor.style_id = 0;
};
}
} else {
const old_pin = self.cursor.page_pin.*;
// Grow our pages by one row. The PageList will handle if we need to
// allocate, prune scrollback, whatever.
_ = try self.pages.grow();
// 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
// the top-left of the new page. This effectively moves it by
// one already, we just need to fix up the x value.
const page_pin = if (old_pin.node == self.cursor.page_pin.node)
self.cursor.page_pin.down(1).?
else reuse: {
var pin = self.cursor.page_pin.*;
pin.x = self.cursor.x;
break :reuse pin;
};
// These assertions help catch some pagelist math errors. Our
// x/y should be unchanged after the grow.
if (build_config.slow_runtime_safety) {
const active = self.pages.pointFromPin(
.active,
page_pin,
).?.active;
assert(active.x == self.cursor.x);
assert(active.y == self.cursor.y);
}
self.cursorChangePin(page_pin);
const page_rac = 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();
// 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 = &page_pin.node.data;
self.clearCells(
page,
self.cursor.page_row,
page.getCells(self.cursor.page_row),
);
}
}
if (self.cursor.style_id != style.default_id) {
// The newly created line needs to be styled according to
// the bg color if it is set.
if (self.cursor.style.bgCell()) |blank_cell| {
const cell_current: [*]pagepkg.Cell = @ptrCast(self.cursor.page_cell);
const cells = cell_current - self.cursor.x;
@memset(cells[0..self.pages.cols], blank_cell);
}
}
}
/// This scrolls the active area at and above the cursor.
/// The lines below the cursor are not scrolled.
pub fn cursorScrollAbove(self: *Screen) !void {
// We unconditionally mark the cursor row as dirty here because
// the cursor always changes page rows inside this function, and
// when that happens it can mean the text in the old row needs to
// be re-shaped because the cursor splits runs to break ligatures.
self.cursor.page_pin.markDirty();
// If the cursor is on the bottom of the screen, its faster to use
// our specialized function for that case.
if (self.cursor.y == self.pages.rows - 1) {
return try self.cursorDownScroll();
}
defer self.assertIntegrity();
// Logic below assumes we always have at least one row that isn't moving
assert(self.cursor.y < self.pages.rows - 1);
// Explanation:
// We don't actually move everything that's at or above the cursor row,
// since this would require us to shift up our ENTIRE scrollback, which
// would be ridiculously expensive. Instead, we insert a new row at the
// end of the pagelist (`grow()`), and move everything BELOW the cursor
// DOWN by one row. This has the same practical result but it's a whole
// lot cheaper in 99% of cases.
const old_pin = self.cursor.page_pin.*;
if (try self.pages.grow()) |_| {
try self.cursorScrollAboveRotate();
} else {
// In this case, it means grow() didn't allocate a new page.
if (self.cursor.page_pin.node == self.pages.pages.last) {
// If we're on the last page we can do a very fast path because
// all the rows we need to move around are within a single page.
// Note: we don't need to call cursorChangePin here because
// the pin page is the same so there is no accounting to do
// for styles or any of that.
assert(old_pin.node == self.cursor.page_pin.node);
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;
// 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);
fastmem.rotateOnceR(Row, rows[pin.y..page.size.rows]);
// Mark all our rotated rows as dirty.
var dirty = page.dirtyBitSet();
dirty.setRangeValue(.{ .start = pin.y, .end = page.size.rows }, true);
// Setup our cursor caches after the rotation so it points to the
// correct data
const page_rac = self.cursor.page_pin.rowAndCell();
self.cursor.page_row = page_rac.row;
self.cursor.page_cell = page_rac.cell;
} else {
// We didn't grow pages but our cursor isn't on the last page.
// In this case we need to do more work because we need to copy
// elements between pages.
//
// An example scenario of this is shown below:
//
// +----------+ = PAGE 0
// ... : :
// +-------------+ ACTIVE
// 4302 |1A00000000| | 0
// 4303 |2B00000000| | 1
// :^ : : = PIN 0
// 4304 |3C00000000| | 2
// +----------+ :
// +----------+ : = PAGE 1
// 0 |4D00000000| | 3
// 1 |5E00000000| | 4
// +----------+ :
// +-------------+
try self.cursorScrollAboveRotate();
}
}
if (self.cursor.style_id != style.default_id) {
// The newly created line needs to be styled according to
// the bg color if it is set.
if (self.cursor.style.bgCell()) |blank_cell| {
const cell_current: [*]pagepkg.Cell = @ptrCast(self.cursor.page_cell);
const cells = cell_current - self.cursor.x;
@memset(cells[0..self.pages.cols], blank_cell);
}
}
}
fn cursorScrollAboveRotate(self: *Screen) !void {
self.cursorChangePin(self.cursor.page_pin.down(1).?);
// Go through each of the pages following our pin, shift all rows
// down by one, and copy the last row of the previous page.
var current = self.pages.pages.last.?;
while (current != self.cursor.page_pin.node) : (current = current.prev.?) {
const prev = current.prev.?;
const prev_page = &prev.data;
const cur_page = &current.data;
const prev_rows = prev_page.rows.ptr(prev_page.memory.ptr);
const cur_rows = cur_page.rows.ptr(cur_page.memory.ptr);
// Rotate the pages down: [ 0 1 2 3 ] => [ 3 0 1 2 ]
fastmem.rotateOnceR(Row, cur_rows[0..cur_page.size.rows]);
// Copy the last row of the previous page to the top of current.
try cur_page.cloneRowFrom(
prev_page,
&cur_rows[0],
&prev_rows[prev_page.size.rows - 1],
);
// All rows we rotated are dirty
var dirty = cur_page.dirtyBitSet();
dirty.setRangeValue(.{ .start = 0, .end = cur_page.size.rows }, true);
}
// Our current is our cursor page, we need to rotate down from
// our cursor and clear our row.
assert(current == self.cursor.page_pin.node);
const cur_page = &current.data;
const cur_rows = cur_page.rows.ptr(cur_page.memory.ptr);
fastmem.rotateOnceR(Row, cur_rows[self.cursor.page_pin.y..cur_page.size.rows]);
self.clearCells(
cur_page,
&cur_rows[0],
cur_page.getCells(&cur_rows[0]),
);
// Set all the rows we rotated and cleared dirty
var dirty = cur_page.dirtyBitSet();
dirty.setRangeValue(
.{ .start = self.cursor.page_pin.y, .end = cur_page.size.rows },
true,
);
// Setup cursor cache data after all the rotations so our
// row is valid.
const page_rac = self.cursor.page_pin.rowAndCell();
self.cursor.page_row = page_rac.row;
self.cursor.page_cell = page_rac.cell;
}
/// Move the cursor down if we're not at the bottom of the screen. Otherwise
/// scroll. Currently only used for testing.
fn cursorDownOrScroll(self: *Screen) !void {
if (self.cursor.y + 1 < self.pages.rows) {
self.cursorDown(1);
} else {
try self.cursorDownScroll();
}
}
/// Copy another cursor. The cursor can be on any screen but the x/y
/// must be within our screen bounds.
pub fn cursorCopy(self: *Screen, other: Cursor, opts: struct {
/// Copy the hyperlink from the other cursor. If not set, this will
/// clear our current hyperlink.
hyperlink: bool = true,
}) !void {
assert(other.x < self.pages.cols);
assert(other.y < self.pages.rows);
// End any currently active hyperlink on our cursor.
self.endHyperlink();
const old = self.cursor;
self.cursor = other;
errdefer self.cursor = old;
// Keep our old style ID so it can be properly cleaned up below.
self.cursor.style_id = old.style_id;
// Hyperlinks will be managed separately below.
self.cursor.hyperlink_id = 0;
self.cursor.hyperlink = null;
// Keep our old page pin and X/Y because:
// 1. The old style will need to be cleaned up from the page it's from.
// 2. The new position navigated to by `cursorAbsolute` needs to be in our
// own screen.
self.cursor.page_pin = old.page_pin;
self.cursor.x = old.x;
self.cursor.y = old.y;
// Call manual style update in order to clean up our old style, if we have
// one, and also to load the style from the other cursor, if it had one.
try self.manualStyleUpdate();
// Move to the correct location to match the other cursor.
self.cursorAbsolute(other.x, other.y);
// 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_link = other_page.hyperlink_set.get(other_page.memory, other.hyperlink_id);
const uri = other_link.uri.offset.ptr(other_page.memory)[0..other_link.uri.len];
const id_ = switch (other_link.id) {
.explicit => |id| id.offset.ptr(other_page.memory)[0..id.len],
.implicit => null,
};
// And it to our cursor.
self.startHyperlink(uri, id_) catch |err| {
// This shouldn't happen because startHyperlink should handle
// resizing. This only happens if we're truly out of RAM. Degrade
// to forgetting the hyperlink.
log.err("failed to update hyperlink on cursor change err={}", .{err});
};
}
}
/// Always use this to write to cursor.page_pin.*.
///
/// This specifically handles the case when the new pin is on a different
/// page than the old AND we have a style set. In that case, we must release
/// our old style and upsert our new style since styles are stored per-page.
fn cursorChangePin(self: *Screen, new: Pin) void {
// Moving the cursor affects text run splitting (ligatures) so
// we must mark the old and new page dirty. We do this as long
// as the pins are not equal
if (!self.cursor.page_pin.eql(new)) {
self.cursor.page_pin.markDirty();
new.markDirty();
}
// If our pin is on the same page, then we can just update the pin.
// We don't need to migrate any state.
if (self.cursor.page_pin.node == new.node) {
self.cursor.page_pin.* = new;
return;
}
// If we have a old style then we need to release it from the old page.
const old_style_: ?style.Style = if (self.cursor.style_id == style.default_id)
null
else
self.cursor.style;
if (old_style_ != null) {
self.cursor.style = .{};
self.manualStyleUpdate() catch unreachable; // Removing a style should never fail
}
// 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;
old_page.hyperlink_set.release(old_page.memory, self.cursor.hyperlink_id);
}
// Update our pin to the new page
self.cursor.page_pin.* = new;
// On the new page, we need to migrate our style
if (old_style_) |old_style| {
self.cursor.style = old_style;
self.manualStyleUpdate() catch |err| {
// This failure should not happen because manualStyleUpdate
// handles page splitting, overflow, and more. This should only
// happen if we're out of RAM. In this case, we'll just degrade
// gracefully back to the default style.
log.err("failed to update style on cursor change err={}", .{err});
self.cursor.style = .{};
self.cursor.style_id = 0;
};
}
// On the new page, we need to migrate our hyperlink
if (self.cursor.hyperlink) |link| {
// So we don't attempt to free any memory in the replaced page.
self.cursor.hyperlink_id = 0;
self.cursor.hyperlink = null;
// Re-add
self.startHyperlink(link.uri, switch (link.id) {
.explicit => |v| v,
.implicit => null,
}) catch |err| {
// This shouldn't happen because startHyperlink should handle
// resizing. This only happens if we're truly out of RAM. Degrade
// to forgetting the hyperlink.
log.err("failed to update hyperlink on cursor change err={}", .{err});
};
// Remove our old link
link.deinit(self.alloc);
self.alloc.destroy(link);
}
}
/// Mark the cursor position as dirty.
/// TODO: test
pub fn cursorMarkDirty(self: *Screen) void {
self.cursor.page_pin.markDirty();
}
/// Reset the cursor row's soft-wrap state and the cursor's pending wrap.
/// Also handles clearing the spacer head on the cursor row and resetting
/// the wrap_continuation flag on the next row if necessary.
///
/// NOTE(qwerasd): This method is not scrolling region aware, and cannot be
/// since it's on Screen not Terminal. This needs to be addressed down the
/// line. Not an extremely urgent issue since it's an edge case of an edge
/// case, but not ideal.
pub fn cursorResetWrap(self: *Screen) void {
// Reset the cursor's pending wrap state
self.cursor.pending_wrap = false;
const page_row = self.cursor.page_row;
if (!page_row.wrap) return;
// This row does not wrap and the next row is not wrapped to
page_row.wrap = false;
if (self.cursor.page_pin.down(1)) |next_row| {
next_row.rowAndCell().row.wrap_continuation = false;
}
// 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];
if (cell.wide == .spacer_head) {
self.clearCells(
&self.cursor.page_pin.node.data,
page_row,
cells[self.cursor.page_pin.node.data.size.cols - 1 ..][0..1],
);
}
}
/// Options for scrolling the viewport of the terminal grid. The reason
/// we have this in addition to PageList.Scroll is because we have additional
/// scroll behaviors that are not part of the PageList.Scroll enum.
pub const Scroll = union(enum) {
/// For all of these, see PageList.Scroll.
active,
top,
pin: Pin,
delta_row: isize,
delta_prompt: isize,
};
/// Scroll the viewport of the terminal grid.
pub fn scroll(self: *Screen, behavior: Scroll) void {
defer self.assertIntegrity();
// No matter what, scrolling marks our image state as dirty since
// it could move placements. If there are no placements or no images
// this is still a very cheap operation.
self.kitty_images.dirty = true;
switch (behavior) {
.active => self.pages.scroll(.{ .active = {} }),
.top => self.pages.scroll(.{ .top = {} }),
.pin => |p| self.pages.scroll(.{ .pin = p }),
.delta_row => |v| self.pages.scroll(.{ .delta_row = v }),
.delta_prompt => |v| self.pages.scroll(.{ .delta_prompt = v }),
}
}
/// See PageList.scrollClear. In addition to that, we reset the cursor
/// to be on top.
pub fn scrollClear(self: *Screen) !void {
defer self.assertIntegrity();
try self.pages.scrollClear();
self.cursorReload();
// No matter what, scrolling marks our image state as dirty since
// it could move placements. If there are no placements or no images
// this is still a very cheap operation.
self.kitty_images.dirty = true;
}
/// Returns true if the viewport is scrolled to the bottom of the screen.
pub fn viewportIsBottom(self: Screen) bool {
return self.pages.viewport == .active;
}
/// Erase the region specified by tl and br, inclusive. This will physically
/// erase the rows meaning the memory will be reclaimed (if the underlying
/// page is empty) and other rows will be shifted up.
pub fn eraseRows(
self: *Screen,
tl: point.Point,
bl: ?point.Point,
) void {
defer self.assertIntegrity();
// Erase the rows
self.pages.eraseRows(tl, bl);
// Just to be safe, reset our cursor since it is possible depending
// on the points that our active area shifted so our pointers are
// invalid.
self.cursorReload();
}
// Clear the region specified by tl and bl, inclusive. Cleared cells are
// colored with the current style background color. This will clear all
// cells in the rows.
//
// If protected is true, the protected flag will be respected and only
// unprotected cells will be cleared. Otherwise, all cells will be cleared.
pub fn clearRows(
self: *Screen,
tl: point.Point,
bl: ?point.Point,
protected: bool,
) void {
defer self.assertIntegrity();
var it = self.pages.pageIterator(.right_down, tl, bl);
while (it.next()) |chunk| {
// Mark everything in this chunk as dirty
var dirty = chunk.node.data.dirtyBitSet();
dirty.setRangeValue(.{ .start = chunk.start, .end = chunk.end }, true);
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];
// Clear all cells
if (protected) {
self.clearUnprotectedCells(&chunk.node.data, 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);
row.* = .{ .cells = cells_offset };
}
}
}
}
/// Clear the cells with the blank cell. This takes care to handle
/// cleaning up graphemes and styles.
pub fn clearCells(
self: *Screen,
page: *Page,
row: *Row,
cells: []Cell,
) void {
// This whole operation does unsafe things, so we just want to assert
// the end state.
page.pauseIntegrityChecks(true);
defer {
page.pauseIntegrityChecks(false);
page.assertIntegrity();
self.assertIntegrity();
}
// If this row has graphemes, then we need go through a slow path
// and delete the cell graphemes.
if (row.grapheme) {
for (cells) |*cell| {
if (cell.hasGrapheme()) page.clearGrapheme(row, cell);
}
}
// If we have hyperlinks, we need to clear those.
if (row.hyperlink) {
for (cells) |*cell| {
if (cell.hyperlink) page.clearHyperlink(row, cell);
}
}
if (row.styled) {
for (cells) |*cell| {
if (cell.style_id == style.default_id) continue;
page.styles.release(page.memory, cell.style_id);
}
// If we have no left/right scroll region we can be sure that
// the row is no longer styled.
if (cells.len == self.pages.cols) row.styled = false;
}
if (row.kitty_virtual_placeholder and
cells.len == self.pages.cols)
{
for (cells) |c| {
if (c.codepoint() == kitty.graphics.unicode.placeholder) {
break;
}
} else row.kitty_virtual_placeholder = false;
}
@memset(cells, self.blankCell());
}
/// Clear cells but only if they are not protected.
pub fn clearUnprotectedCells(
self: *Screen,
page: *Page,
row: *Row,
cells: []Cell,
) void {
var x0: usize = 0;
var x1: usize = 0;
while (x0 < cells.len) clear: {
while (cells[x0].protected) {
x0 += 1;
if (x0 >= cells.len) break :clear;
}
x1 = x0 + 1;
while (x1 < cells.len and !cells[x1].protected) {
x1 += 1;
}
self.clearCells(page, row, cells[x0..x1]);
x0 = x1;
}
page.assertIntegrity();
self.assertIntegrity();
}
/// Clears the prompt lines if the cursor is currently at a prompt. This
/// clears the entire line. This is used for resizing when the shell
/// handles reflow.
///
/// The cleared cells are not colored with the current style background
/// color like other clear functions, because this is a special case used
/// for a specific purpose that does not want that behavior.
pub fn clearPrompt(self: *Screen) void {
var found: ?Pin = null;
// From our cursor, move up and find all prompt lines.
var it = self.cursor.page_pin.rowIterator(
.left_up,
self.pages.pin(.{ .active = .{} }),
);
while (it.next()) |p| {
const row = p.rowAndCell().row;
switch (row.semantic_prompt) {
// We are at a prompt but we're not at the start of the prompt.
// We mark our found value and continue because the prompt
// may be multi-line, unless this is the second time we've
// seen an .input marker, in which case we've run into an
// earlier prompt.
.input => {
if (found != null) break;
found = p;
},
// If we find the prompt then we're done. We are also done
// if we find any prompt continuation, because the shells
// that send this currently (zsh) cannot redraw every line.
.prompt, .prompt_continuation => {
found = p;
break;
},
// If we have command output, then we're most certainly not
// at a prompt. Break out of the loop.
.command => break,
// If we don't know, we keep searching.
.unknown => {},
}
}
// If we found a prompt, we clear it.
if (found) |top| {
var clear_it = top.rowIterator(.right_down, null);
while (clear_it.next()) |p| {
const row = p.rowAndCell().row;
p.node.data.clearCells(row, 0, p.node.data.size.cols);
p.node.data.assertIntegrity();
}
}
}
/// Clean up boundary conditions where a cell will become discontiguous with
/// a neighboring cell because either one of them will be moved and/or cleared.
///
/// For performance reasons this is specialized to operate on the cursor row.
///
/// Handles the boundary between the cell at `x` and the cell at `x - 1`.
///
/// So, for example, when moving a region of cells [a, b] (inclusive), call this
/// function with `x = a` and `x = b + 1`. It is okay if `x` is out of bounds by
/// 1, this will be interpreted correctly.
///
/// DOES NOT MODIFY ROW WRAP STATE! See `cursorResetWrap` for that.
///
/// The following boundary conditions are handled:
///
/// - `x - 1` is a wide character and `x` is a spacer tail:
/// o Both cells will be cleared.
/// o If `x - 1` is the start of the row and was wrapped from a previous row
/// then the previous row is checked for a spacer head, which is cleared if
/// present.
///
/// - `x == 0` and is a wide character:
/// o If the row is a wrap continuation then the previous row will be checked
/// for a spacer head, which is cleared if present.
///
/// - `x == cols` and `x - 1` is a spacer head:
/// o `x - 1` will be cleared.
///
/// NOTE(qwerasd): This method is not scrolling region aware, and cannot be
/// since it's on Screen not Terminal. This needs to be addressed down the
/// line. Not an extremely urgent issue since it's an edge case of an edge
/// case, but not ideal.
pub fn splitCellBoundary(
self: *Screen,
x: size.CellCountInt,
) void {
const page = &self.cursor.page_pin.node.data;
page.pauseIntegrityChecks(true);
defer page.pauseIntegrityChecks(false);
const cols = self.cursor.page_pin.node.data.size.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.
assert(x <= cols);
// [ A B C D E F|]
// ^ Boundary between final cell and row end.
if (x == cols) {
if (!self.cursor.page_row.wrap) return;
const cells = self.cursor.page_pin.cells(.all);
// Spacer head at end of wrapped row.
if (cells[cols - 1].wide == .spacer_head) {
self.clearCells(
page,
self.cursor.page_row,
cells[cols - 1 ..][0..1],
);
}
return;
}
// [|A B C D E F ]
// ^ Boundary between first cell and row start.
//
// OR
//
// [ A|B C D E F ]
// ^ Boundary between first cell and second cell.
//
// First cell may be a wrapped wide cell with a spacer
// head on the previous row that needs to be cleared.
if ((x == 0 or x == 1) and self.cursor.page_row.wrap_continuation) {
const cells = self.cursor.page_pin.cells(.all);
// If the first cell in a row is wide the previous row
// may have a spacer head which needs to be cleared.
if (cells[0].wide == .wide) {
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];
if (p_cell.wide == .spacer_head) {
self.clearCells(
&p_row.node.data,
p_rac.row,
p_cells[p_row.node.data.size.cols - 1 ..][0..1],
);
}
}
}
}
// If x is 0 then we're done.
if (x == 0) return;
// [ ... X|Y ... ]
// ^ Boundary between two cells in the middle of the row.
{
assert(x > 0);
assert(x < cols);
const cells = self.cursor.page_pin.cells(.all);
const left = cells[x - 1];
switch (left.wide) {
// There should not be spacer heads in the middle of the row.
.spacer_head => unreachable,
// We don't need to do anything for narrow cells or spacer tails.
.narrow, .spacer_tail => {},
// A wide char would be split, so must be cleared.
.wide => {
self.clearCells(
page,
self.cursor.page_row,
cells[x - 1 ..][0..2],
);
},
}
}
}
/// Returns the blank cell to use when doing terminal operations that
/// require preserving the bg color.
pub fn blankCell(self: *const Screen) Cell {
if (self.cursor.style_id == style.default_id) return .{};
return self.cursor.style.bgCell() orelse .{};
}
/// Resize the screen. The rows or cols can be bigger or smaller.
///
/// This will reflow soft-wrapped text. If the screen size is getting
/// smaller and the maximum scrollback size is exceeded, data will be
/// lost from the top of the scrollback.
///
/// If this returns an error, the screen is left in a likely garbage state.
/// It is very hard to undo this operation without blowing up our memory
/// usage. The only way to recover is to reset the screen. The only way
/// this really fails is if page allocation is required and fails, which
/// probably means the system is in trouble anyways. I'd like to improve this
/// in the future but it is not a priority particularly because this scenario
/// (resize) is difficult.
pub fn resize(
self: *Screen,
cols: size.CellCountInt,
rows: size.CellCountInt,
) !void {
try self.resizeInternal(cols, rows, true);
}
/// Resize the screen without any reflow. In this mode, columns/rows will
/// be truncated as they are shrunk. If they are grown, the new space is filled
/// with zeros.
pub fn resizeWithoutReflow(
self: *Screen,
cols: size.CellCountInt,
rows: size.CellCountInt,
) !void {
try self.resizeInternal(cols, rows, false);
}
/// Resize the screen.
fn resizeInternal(
self: *Screen,
cols: size.CellCountInt,
rows: size.CellCountInt,
reflow: bool,
) !void {
defer self.assertIntegrity();
// No matter what we mark our image state as dirty
self.kitty_images.dirty = true;
// Release the cursor style while resizing just
// in case the cursor ends up on a different page.
const cursor_style = self.cursor.style;
self.cursor.style = .{};
self.manualStyleUpdate() catch unreachable;
defer {
// Restore the cursor style.
self.cursor.style = cursor_style;
self.manualStyleUpdate() catch |err| {
// This failure should not happen because manualStyleUpdate
// handles page splitting, overflow, and more. This should only
// happen if we're out of RAM. In this case, we'll just degrade
// gracefully back to the default style.
log.err("failed to update style on cursor reload err={}", .{err});
self.cursor.style = .{};
self.cursor.style_id = 0;
};
}
// If we have a hyperlink, release it from the old page
// and then we need to re-add it to the new page. This needs
// to happen because resize below typically reallocates a
// new page so the old hyperlink is invalid.
const hyperlink_ = self.cursor.hyperlink;
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;
page.hyperlink_set.release(page.memory, self.cursor.hyperlink_id);
self.cursor.hyperlink_id = 0;
self.cursor.hyperlink = null;
}
// Perform the resize operation.
try self.pages.resize(.{
.rows = rows,
.cols = cols,
.reflow = reflow,
.cursor = .{ .x = self.cursor.x, .y = self.cursor.y },
});
// If we have no scrollback and we shrunk our rows, we must explicitly
// erase our history. This is because PageList always keeps at least
// a page size of history.
if (self.no_scrollback) {
self.pages.eraseRows(.{ .history = .{} }, null);
}
// If our cursor was updated, we do a full reload so all our cursor
// state is correct.
self.cursorReload();
// Fix up our hyperlink if we had one.
if (hyperlink_) |link| {
self.startHyperlink(link.uri, switch (link.id) {
.explicit => |v| v,
.implicit => null,
}) catch |err| {
// This shouldn't happen because startHyperlink should handle
// resizing. This only happens if we're truly out of RAM. Degrade
// to forgetting the hyperlink.
log.err("failed to update hyperlink on resize err={}", .{err});
};
// Remove our old link
link.deinit(self.alloc);
self.alloc.destroy(link);
}
}
/// Set a style attribute for the current cursor.
///
/// This can cause a page split if the current page cannot fit this style.
/// This is the only scenario an error return is possible.
pub fn setAttribute(self: *Screen, attr: sgr.Attribute) !void {
switch (attr) {
.unset => {
self.cursor.style = .{};
},
.bold => {
self.cursor.style.flags.bold = true;
},
.reset_bold => {
// Bold and faint share the same SGR code for this
self.cursor.style.flags.bold = false;
self.cursor.style.flags.faint = false;
},
.italic => {
self.cursor.style.flags.italic = true;
},
.reset_italic => {
self.cursor.style.flags.italic = false;
},
.faint => {
self.cursor.style.flags.faint = true;
},
.underline => |v| {
self.cursor.style.flags.underline = v;
},
.reset_underline => {
self.cursor.style.flags.underline = .none;
},
.underline_color => |rgb| {
self.cursor.style.underline_color = .{ .rgb = .{
.r = rgb.r,
.g = rgb.g,
.b = rgb.b,
} };
},
.@"256_underline_color" => |idx| {
self.cursor.style.underline_color = .{ .palette = idx };
},
.reset_underline_color => {
self.cursor.style.underline_color = .none;
},
.overline => {
self.cursor.style.flags.overline = true;
},
.reset_overline => {
self.cursor.style.flags.overline = false;
},
.blink => {
self.cursor.style.flags.blink = true;
},
.reset_blink => {
self.cursor.style.flags.blink = false;
},
.inverse => {
self.cursor.style.flags.inverse = true;
},
.reset_inverse => {
self.cursor.style.flags.inverse = false;
},
.invisible => {
self.cursor.style.flags.invisible = true;
},
.reset_invisible => {
self.cursor.style.flags.invisible = false;
},
.strikethrough => {
self.cursor.style.flags.strikethrough = true;
},
.reset_strikethrough => {
self.cursor.style.flags.strikethrough = false;
},
.direct_color_fg => |rgb| {
self.cursor.style.fg_color = .{
.rgb = .{
.r = rgb.r,
.g = rgb.g,
.b = rgb.b,
},
};
},
.direct_color_bg => |rgb| {
self.cursor.style.bg_color = .{
.rgb = .{
.r = rgb.r,
.g = rgb.g,
.b = rgb.b,
},
};
},
.@"8_fg" => |n| {
self.cursor.style.fg_color = .{ .palette = @intFromEnum(n) };
},
.@"8_bg" => |n| {
self.cursor.style.bg_color = .{ .palette = @intFromEnum(n) };
},
.reset_fg => self.cursor.style.fg_color = .none,
.reset_bg => self.cursor.style.bg_color = .none,
.@"8_bright_fg" => |n| {
self.cursor.style.fg_color = .{ .palette = @intFromEnum(n) };
},
.@"8_bright_bg" => |n| {
self.cursor.style.bg_color = .{ .palette = @intFromEnum(n) };
},
.@"256_fg" => |idx| {
self.cursor.style.fg_color = .{ .palette = idx };
},
.@"256_bg" => |idx| {
self.cursor.style.bg_color = .{ .palette = idx };
},
.unknown => return,
}
try self.manualStyleUpdate();
}
/// Call this whenever you manually change the cursor style.
pub fn manualStyleUpdate(self: *Screen) !void {
var page: *Page = &self.cursor.page_pin.node.data;
// std.log.warn("active styles={}", .{page.styles.count()});
// Release our previous style if it was not default.
if (self.cursor.style_id != style.default_id) {
page.styles.release(page.memory, self.cursor.style_id);
}
// If our new style is the default, just reset to that
if (self.cursor.style.default()) {
self.cursor.style_id = style.default_id;
return;
}
// Clear the cursor style ID to prevent weird things from happening
// if the page capacity has to be adjusted which would end up calling
// manualStyleUpdate again.
self.cursor.style_id = style.default_id;
// After setting the style, we need to update our style map.
// Note that we COULD lazily do this in print. We should look into
// if that makes a meaningful difference. Our priority is to keep print
// fast because setting a ton of styles that do nothing is uncommon
// and weird.
const id = page.styles.add(
page.memory,
self.cursor.style,
) catch |err| id: {
// Our style map is full or needs to be rehashed,
// so we allocate a new page, which will rehash,
// and double the style capacity for it if it was
// full.
const node = try self.adjustCapacity(
self.cursor.page_pin.node,
switch (err) {
error.OutOfMemory => .{ .styles = page.capacity.styles * 2 },
error.NeedsRehash => .{},
},
);
page = &node.data;
break :id try page.styles.add(
page.memory,
self.cursor.style,
);
};
self.cursor.style_id = id;
self.assertIntegrity();
}
/// Append a grapheme to the given cell within the current cursor row.
pub fn appendGrapheme(self: *Screen, cell: *Cell, cp: u21) !void {
defer self.cursor.page_pin.node.data.assertIntegrity();
self.cursor.page_pin.node.data.appendGrapheme(
self.cursor.page_row,
cell,
cp,
) catch |err| switch (err) {
error.OutOfMemory => {
// We need to determine the actual cell index of the cell so
// that after we adjust the capacity we can reload the cell.
const cell_idx: usize = cell_idx: {
const cells: [*]Cell = @ptrCast(self.cursor.page_cell);
const zero: [*]Cell = cells - self.cursor.x;
const target: [*]Cell = @ptrCast(cell);
const cell_idx = (@intFromPtr(target) - @intFromPtr(zero)) / @sizeOf(Cell);
break :cell_idx cell_idx;
};
// Adjust our capacity. This will update our cursor page pin and
// force us to reload.
const original_node = self.cursor.page_pin.node;
const new_bytes = original_node.data.capacity.grapheme_bytes * 2;
_ = try self.adjustCapacity(
original_node,
.{ .grapheme_bytes = new_bytes },
);
// The cell pointer is now invalid, so we need to get it from
// the reloaded cursor pointers.
const reloaded_cell: *Cell = switch (std.math.order(cell_idx, self.cursor.x)) {
.eq => self.cursor.page_cell,
.lt => self.cursorCellLeft(@intCast(self.cursor.x - cell_idx)),
.gt => self.cursorCellRight(@intCast(cell_idx - self.cursor.x)),
};
try self.cursor.page_pin.node.data.appendGrapheme(
self.cursor.page_row,
reloaded_cell,
cp,
);
},
};
}
pub const StartHyperlinkError = Allocator.Error || PageList.AdjustCapacityError;
/// Start the hyperlink state. Future cells will be marked as hyperlinks with
/// this state. Note that various terminal operations may clear the hyperlink
/// state, such as switching screens (alt screen).
pub fn startHyperlink(
self: *Screen,
uri: []const u8,
id_: ?[]const u8,
) StartHyperlinkError!void {
// Create our pending entry.
const link: hyperlink.Hyperlink = .{
.uri = uri,
.id = if (id_) |id| .{
.explicit = id,
} else implicit: {
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,
};
// Loop until we have enough page memory to add the hyperlink
while (true) {
if (self.startHyperlinkOnce(link)) {
return;
} else |err| switch (err) {
// An actual self.alloc OOM is a fatal error.
error.OutOfMemory => return error.OutOfMemory,
// strings table is out of memory, adjust it up
error.StringsOutOfMemory => _ = try self.adjustCapacity(
self.cursor.page_pin.node,
.{ .string_bytes = self.cursor.page_pin.node.data.capacity.string_bytes * 2 },
),
// hyperlink set is out of memory, adjust it up
error.SetOutOfMemory => _ = try self.adjustCapacity(
self.cursor.page_pin.node,
.{ .hyperlink_bytes = self.cursor.page_pin.node.data.capacity.hyperlink_bytes * 2 },
),
// hyperlink set is too full, rehash it
error.SetNeedsRehash => _ = try self.adjustCapacity(
self.cursor.page_pin.node,
.{},
),
}
self.assertIntegrity();
}
}
/// This is like startHyperlink but if we have to adjust page capacities
/// this returns error.PageAdjusted. This is useful so that we unwind
/// all the previous state and try again.
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);
errdefer self.alloc.destroy(link);
link.* = try source.dupe(self.alloc);
errdefer link.deinit(self.alloc);
// Insert the hyperlink into page memory
var page = &self.cursor.page_pin.node.data;
const id: hyperlink.Id = try page.insertHyperlink(link.*);
// Save it all
self.cursor.hyperlink = link;
self.cursor.hyperlink_id = id;
}
/// End the hyperlink state so that future cells aren't part of the
/// current hyperlink (if any). This is safe to call multiple times.
pub fn endHyperlink(self: *Screen) void {
// If we have no hyperlink state then do nothing
if (self.cursor.hyperlink_id == 0) {
assert(self.cursor.hyperlink == null);
return;
}
// Release the old hyperlink state. If there are cells using the
// hyperlink this will work because the creation creates a reference
// and all additional cells create a new reference. This release will
// just release our initial reference.
//
// If the ref count reaches zero the set will not delete the item
// immediately; it is kept around in case it is used again (this is
// 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;
page.hyperlink_set.release(page.memory, self.cursor.hyperlink_id);
self.cursor.hyperlink.?.deinit(self.alloc);
self.alloc.destroy(self.cursor.hyperlink.?);
self.cursor.hyperlink_id = 0;
self.cursor.hyperlink = null;
}
/// Set the current hyperlink state on the current cell.
pub fn cursorSetHyperlink(self: *Screen) !void {
assert(self.cursor.hyperlink_id != 0);
var page = &self.cursor.page_pin.node.data;
if (page.setHyperlink(
self.cursor.page_row,
self.cursor.page_cell,
self.cursor.hyperlink_id,
)) {
// Success, increase the refcount for the hyperlink.
page.hyperlink_set.use(page.memory, self.cursor.hyperlink_id);
return;
} else |err| switch (err) {
// hyperlink_map is out of space, realloc the page to be larger
error.HyperlinkMapOutOfMemory => {
_ = try self.adjustCapacity(
self.cursor.page_pin.node,
.{ .hyperlink_bytes = page.capacity.hyperlink_bytes * 2 },
);
// Retry
return try self.cursorSetHyperlink();
},
}
}
/// Set the selection to the given selection. If this is a tracked selection
/// then the screen will take overnship of the selection. If this is untracked
/// then the screen will convert it to tracked internally. This will automatically
/// untrack the prior selection (if any).
///
/// Set the selection to null to clear any previous selection.
///
/// This is always recommended over setting `selection` directly. Beyond
/// managing memory for you, it also performs safety checks that the selection
/// is always tracked.
pub fn select(self: *Screen, sel_: ?Selection) !void {
const sel = sel_ orelse {
self.clearSelection();
return;
};
// If this selection is untracked then we track it.
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);
self.selection = tracked_sel;
self.dirty.selection = true;
}
/// Same as select(null) but can't fail.
pub fn clearSelection(self: *Screen) void {
if (self.selection) |*sel| {
sel.deinit(self);
self.dirty.selection = true;
}
self.selection = null;
}
pub const SelectionString = struct {
/// The selection to convert to a string.
sel: Selection,
/// If true, trim whitespace around the selection.
trim: bool = true,
/// If non-null, a stringmap will be written here. This will use
/// the same allocator as the call to selectionString. The string will
/// be duplicated here and in the return value so both must be freed.
map: ?*StringMap = null,
};
/// Returns the raw text associated with a selection. This will unwrap
/// soft-wrapped edges. The returned slice is owned by the caller and allocated
/// using alloc, not the allocator associated with the screen (unless they match).
pub fn selectionString(self: *Screen, alloc: Allocator, opts: SelectionString) ![:0]const u8 {
// Use an ArrayList so that we can grow the array as we go. We
// build an initial capacity of just our rows in our selection times
// columns. It can be more or less based on graphemes, newlines, etc.
var strbuilder = std.ArrayList(u8).init(alloc);
defer strbuilder.deinit();
// If we're building a stringmap, create our builder for the pins.
const MapBuilder = std.ArrayList(Pin);
var mapbuilder: ?MapBuilder = if (opts.map != null) MapBuilder.init(alloc) else null;
defer if (mapbuilder) |*b| b.deinit();
const sel_ordered = opts.sel.ordered(self, .forward);
const sel_start: Pin = start: {
var start: Pin = sel_ordered.start();
const cell = start.rowAndCell().cell;
if (cell.wide == .spacer_tail) start.x -= 1;
break :start start;
};
const sel_end: Pin = end: {
var end: Pin = sel_ordered.end();
const cell = end.rowAndCell().cell;
switch (cell.wide) {
.narrow, .wide => {},
// We can omit the tail
.spacer_tail => end.x -= 1,
// With the head we want to include the wrapped wide character.
.spacer_head => if (end.down(1)) |p| {
end = p;
end.x = 0;
},
}
break :end end;
};
var page_it = sel_start.pageIterator(.right_down, sel_end);
while (page_it.next()) |chunk| {
const rows = chunk.rows();
for (rows, chunk.start.., 0..) |row, y, row_i| {
const cells_ptr = row.cells.ptr(chunk.node.data.memory);
const start_x = if ((row_i == 0 or sel_ordered.rectangle) and
sel_start.node == chunk.node)
sel_start.x
else
0;
const end_x = if ((row_i == rows.len - 1 or sel_ordered.rectangle) and
sel_end.node == chunk.node)
sel_end.x + 1
else
self.pages.cols;
const cells = cells_ptr[start_x..end_x];
for (cells, start_x..) |*cell, x| {
// Skip wide spacers
switch (cell.wide) {
.narrow, .wide => {},
.spacer_head, .spacer_tail => continue,
}
var buf: [4]u8 = undefined;
{
const raw: u21 = if (cell.hasText()) cell.content.codepoint else 0;
const char = if (raw > 0) raw else ' ';
const encode_len = try std.unicode.utf8Encode(char, &buf);
try strbuilder.appendSlice(buf[0..encode_len]);
if (mapbuilder) |*b| {
for (0..encode_len) |_| try b.append(.{
.node = chunk.node,
.y = @intCast(y),
.x = @intCast(x),
});
}
}
if (cell.hasGrapheme()) {
const cps = chunk.node.data.lookupGrapheme(cell).?;
for (cps) |cp| {
const encode_len = try std.unicode.utf8Encode(cp, &buf);
try strbuilder.appendSlice(buf[0..encode_len]);
if (mapbuilder) |*b| {
for (0..encode_len) |_| try b.append(.{
.node = chunk.node,
.y = @intCast(y),
.x = @intCast(x),
});
}
}
}
}
const is_final_row = chunk.node == sel_end.node and y == sel_end.y;
if (!is_final_row and
(!row.wrap or sel_ordered.rectangle))
{
try strbuilder.append('\n');
if (mapbuilder) |*b| try b.append(.{
.node = chunk.node,
.y = @intCast(y),
.x = chunk.node.data.size.cols - 1,
});
}
}
}
if (comptime std.debug.runtime_safety) {
if (mapbuilder) |b| assert(strbuilder.items.len == b.items.len);
}
// If we have a mapbuilder, we need to setup our string map.
if (mapbuilder) |*b| {
var strclone = try strbuilder.clone();
defer strclone.deinit();
const str = try strclone.toOwnedSliceSentinel(0);
errdefer alloc.free(str);
const map = try b.toOwnedSlice();
errdefer alloc.free(map);
opts.map.?.* = .{ .string = str, .map = map };
}
// Remove any trailing spaces on lines. We could do optimize this by
// doing this in the loop above but this isn't very hot path code and
// this is simple.
if (opts.trim) {
var it = std.mem.tokenizeScalar(u8, strbuilder.items, '\n');
// Reset our items. We retain our capacity. Because we're only
// removing bytes, we know that the trimmed string must be no longer
// than the original string so we copy directly back into our
// allocated memory.
strbuilder.clearRetainingCapacity();
while (it.next()) |line| {
const trimmed = std.mem.trimRight(u8, line, " \t");
const i = strbuilder.items.len;
strbuilder.items.len += trimmed.len;
std.mem.copyForwards(u8, strbuilder.items[i..], trimmed);
try strbuilder.append('\n');
}
// Remove all trailing newlines
for (0..strbuilder.items.len) |_| {
if (strbuilder.items[strbuilder.items.len - 1] != '\n') break;
strbuilder.items.len -= 1;
}
}
// Get our final string
const string = try strbuilder.toOwnedSliceSentinel(0);
errdefer alloc.free(string);
return string;
}
pub const SelectLine = struct {
/// The pin of some part of the line to select.
pin: Pin,
/// These are the codepoints to consider whitespace to trim
/// from the ends of the selection.
whitespace: ?[]const u21 = &.{ 0, ' ', '\t' },
/// If true, line selection will consider semantic prompt
/// state changing a boundary. State changing is ANY state
/// change.
semantic_prompt_boundary: bool = true,
};
/// Select the line under the given point. This will select across soft-wrapped
/// lines and will omit the leading and trailing whitespace. If the point is
/// over whitespace but the line has non-whitespace characters elsewhere, the
/// line will be selected.
pub fn selectLine(self: *const Screen, opts: SelectLine) ?Selection {
_ = self;
// Get the current point semantic prompt state since that determines
// boundary conditions too. This makes it so that line selection can
// only happen within the same prompt state. For example, if you triple
// click output, but the shell uses spaces to soft-wrap to the prompt
// then the selection will stop prior to the prompt. See issue #1329.
const semantic_prompt_state: ?bool = state: {
if (!opts.semantic_prompt_boundary) break :state null;
const rac = opts.pin.rowAndCell();
break :state rac.row.semantic_prompt.promptOrInput();
};
// The real start of the row is the first row in the soft-wrap.
const start_pin: Pin = start_pin: {
var it = opts.pin.rowIterator(.left_up, null);
var it_prev: Pin = it.next().?; // skip self
while (it.next()) |p| {
const row = p.rowAndCell().row;
if (!row.wrap) {
var copy = it_prev;
copy.x = 0;
break :start_pin copy;
}
if (semantic_prompt_state) |v| {
// See semantic_prompt_state comment for why
const current_prompt = row.semantic_prompt.promptOrInput();
if (current_prompt != v) {
var copy = it_prev;
copy.x = 0;
break :start_pin copy;
}
}
it_prev = p;
} else {
var copy = it_prev;
copy.x = 0;
break :start_pin copy;
}
};
// The real end of the row is the final row in the soft-wrap.
const end_pin: Pin = end_pin: {
var it = opts.pin.rowIterator(.right_down, null);
while (it.next()) |p| {
const row = p.rowAndCell().row;
if (semantic_prompt_state) |v| {
// See semantic_prompt_state comment for why
const current_prompt = row.semantic_prompt.promptOrInput();
if (current_prompt != v) {
var prev = p.up(1).?;
prev.x = p.node.data.size.cols - 1;
break :end_pin prev;
}
}
if (!row.wrap) {
var copy = p;
copy.x = p.node.data.size.cols - 1;
break :end_pin copy;
}
}
return null;
};
// Go forward from the start to find the first non-whitespace character.
const start: Pin = start: {
const whitespace = opts.whitespace orelse break :start start_pin;
var it = start_pin.cellIterator(.right_down, end_pin);
while (it.next()) |p| {
const cell = p.rowAndCell().cell;
if (!cell.hasText()) continue;
// Non-empty means we found it.
const this_whitespace = std.mem.indexOfAny(
u21,
whitespace,
&[_]u21{cell.content.codepoint},
) != null;
if (this_whitespace) continue;
break :start p;
}
return null;
};
// Go backward from the end to find the first non-whitespace character.
const end: Pin = end: {
const whitespace = opts.whitespace orelse break :end end_pin;
var it = end_pin.cellIterator(.left_up, start_pin);
while (it.next()) |p| {
const cell = p.rowAndCell().cell;
if (!cell.hasText()) continue;
// Non-empty means we found it.
const this_whitespace = std.mem.indexOfAny(
u21,
whitespace,
&[_]u21{cell.content.codepoint},
) != null;
if (this_whitespace) continue;
break :end p;
}
return null;
};
return Selection.init(start, end, false);
}
/// Return the selection for all contents on the screen. Surrounding
/// whitespace is omitted. If there is no selection, this returns null.
pub fn selectAll(self: *Screen) ?Selection {
const whitespace = &[_]u32{ 0, ' ', '\t' };
const start: Pin = start: {
var it = self.pages.cellIterator(
.right_down,
.{ .screen = .{} },
null,
);
while (it.next()) |p| {
const cell = p.rowAndCell().cell;
if (!cell.hasText()) continue;
// Non-empty means we found it.
const this_whitespace = std.mem.indexOfAny(
u32,
whitespace,
&[_]u32{cell.content.codepoint},
) != null;
if (this_whitespace) continue;
break :start p;
}
return null;
};
const end: Pin = end: {
var it = self.pages.cellIterator(
.left_up,
.{ .screen = .{} },
null,
);
while (it.next()) |p| {
const cell = p.rowAndCell().cell;
if (!cell.hasText()) continue;
// Non-empty means we found it.
const this_whitespace = std.mem.indexOfAny(
u32,
whitespace,
&[_]u32{cell.content.codepoint},
) != null;
if (this_whitespace) continue;
break :end p;
}
return null;
};
return Selection.init(start, end, false);
}
/// Select the nearest word to start point that is between start_pt and
/// end_pt (inclusive). Because it selects "nearest" to start point, start
/// point can be before or after end point.
///
/// TODO: test this
pub fn selectWordBetween(
self: *Screen,
start: Pin,
end: Pin,
) ?Selection {
const dir: PageList.Direction = if (start.before(end)) .right_down else .left_up;
var it = start.cellIterator(dir, end);
while (it.next()) |pin| {
// Boundary conditions
switch (dir) {
.right_down => if (end.before(pin)) return null,
.left_up => if (pin.before(end)) return null,
}
// If we found a word, then return it
if (self.selectWord(pin)) |sel| return sel;
}
return null;
}
/// Select the word under the given point. A word is any consecutive series
/// of characters that are exclusively whitespace or exclusively non-whitespace.
/// A selection can span multiple physical lines if they are soft-wrapped.
///
/// This will return null if a selection is impossible. The only scenario
/// this happens is if the point pt is outside of the written screen space.
pub fn selectWord(self: *Screen, pin: Pin) ?Selection {
_ = self;
// Boundary characters for selection purposes
const boundary = &[_]u32{
0,
' ',
'\t',
'\'',
'"',
'│',
'`',
'|',
':',
',',
'(',
')',
'[',
']',
'{',
'}',
'<',
'>',
'$',
};
// If our cell is empty we can't select a word, because we can't select
// areas where the screen is not yet written.
const start_cell = pin.rowAndCell().cell;
if (!start_cell.hasText()) return null;
// Determine if we are a boundary or not to determine what our boundary is.
const expect_boundary = std.mem.indexOfAny(
u32,
boundary,
&[_]u32{start_cell.content.codepoint},
) != null;
// Go forwards to find our end boundary
const end: Pin = end: {
var it = pin.cellIterator(.right_down, null);
var prev = it.next().?; // Consume one, our start
while (it.next()) |p| {
const rac = p.rowAndCell();
const cell = rac.cell;
// If we reached an empty cell its always a boundary
if (!cell.hasText()) break :end prev;
// If we do not match our expected set, we hit a boundary
const this_boundary = std.mem.indexOfAny(
u32,
boundary,
&[_]u32{cell.content.codepoint},
) != null;
if (this_boundary != expect_boundary) break :end prev;
// 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) {
break :end p;
}
prev = p;
}
break :end prev;
};
// Go backwards to find our start boundary
const start: Pin = start: {
var it = pin.cellIterator(.left_up, null);
var prev = it.next().?; // Consume one, our start
while (it.next()) |p| {
const rac = p.rowAndCell();
const cell = rac.cell;
// 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) {
break :start prev;
}
// If we reached an empty cell its always a boundary
if (!cell.hasText()) break :start prev;
// If we do not match our expected set, we hit a boundary
const this_boundary = std.mem.indexOfAny(
u32,
boundary,
&[_]u32{cell.content.codepoint},
) != null;
if (this_boundary != expect_boundary) break :start prev;
prev = p;
}
break :start prev;
};
return Selection.init(start, end, false);
}
/// Select the command output under the given point. The limits of the output
/// are determined by semantic prompt information provided by shell integration.
/// A selection can span multiple physical lines if they are soft-wrapped.
///
/// This will return null if a selection is impossible. The only scenarios
/// this happens is if:
/// - the point pt is outside of the written screen space.
/// - the point pt is on a prompt / input line.
pub fn selectOutput(self: *Screen, pin: Pin) ?Selection {
_ = self;
switch (pin.rowAndCell().row.semantic_prompt) {
.input, .prompt_continuation, .prompt => {
// Cursor on a prompt line, selection impossible
return null;
},
else => {},
}
// Go forwards to find our end boundary
// We are looking for input start / prompt markers
const end: Pin = boundary: {
var it = pin.rowIterator(.right_down, null);
var it_prev = pin;
while (it.next()) |p| {
const row = p.rowAndCell().row;
switch (row.semantic_prompt) {
.input, .prompt_continuation, .prompt => {
var copy = it_prev;
copy.x = it_prev.node.data.size.cols - 1;
break :boundary copy;
},
else => {},
}
it_prev = p;
}
// Find the last non-blank row
it = it_prev.rowIterator(.left_up, null);
while (it.next()) |p| {
const row = p.rowAndCell().row;
const cells = p.node.data.getCells(row);
if (Cell.hasTextAny(cells)) {
var copy = p;
copy.x = p.node.data.size.cols - 1;
break :boundary copy;
}
}
// In this case it means that all our rows are blank. Let's
// just return no selection, this is a weird case.
return null;
};
// Go backwards to find our start boundary
// We are looking for output start markers
const start: Pin = boundary: {
var it = pin.rowIterator(.left_up, null);
var it_prev = pin;
while (it.next()) |p| {
const row = p.rowAndCell().row;
switch (row.semantic_prompt) {
.command => break :boundary p,
else => {},
}
it_prev = p;
}
break :boundary it_prev;
};
return Selection.init(start, end, false);
}
/// Returns the selection bounds for the prompt at the given point. If the
/// point is not on a prompt line, this returns null. Note that due to
/// the underlying protocol, this will only return the y-coordinates of
/// the prompt. The x-coordinates of the start will always be zero and
/// the x-coordinates of the end will always be the last column.
///
/// Note that this feature requires shell integration. If shell integration
/// is not enabled, this will always return null.
pub fn selectPrompt(self: *Screen, pin: Pin) ?Selection {
_ = self;
// Ensure that the line the point is on is a prompt.
const is_known = switch (pin.rowAndCell().row.semantic_prompt) {
.prompt, .prompt_continuation, .input => true,
.command => return null,
// We allow unknown to continue because not all shells output any
// semantic prompt information for continuation lines. This has the
// possibility of making this function VERY slow (we look at all
// scrollback) so we should try to avoid this in the future by
// setting a flag or something if we have EVER seen a semantic
// prompt sequence.
.unknown => false,
};
// Find the start of the prompt.
var saw_semantic_prompt = is_known;
const start: Pin = start: {
var it = pin.rowIterator(.left_up, null);
var it_prev = it.next().?;
while (it.next()) |p| {
const row = p.rowAndCell().row;
switch (row.semantic_prompt) {
// A prompt, we continue searching.
.prompt, .prompt_continuation, .input => saw_semantic_prompt = true,
// See comment about "unknown" a few lines above. If we have
// previously seen a semantic prompt then if we see an unknown
// we treat it as a boundary.
.unknown => if (saw_semantic_prompt) break :start it_prev,
// Command output or unknown, definitely not a prompt.
.command => break :start it_prev,
}
it_prev = p;
}
break :start it_prev;
};
// If we never saw a semantic prompt flag, then we can't trust our
// start value and we return null. This scenario usually means that
// semantic prompts aren't enabled via the shell.
if (!saw_semantic_prompt) return null;
// Find the end of the prompt.
const end: Pin = end: {
var it = pin.rowIterator(.right_down, null);
var it_prev = it.next().?;
it_prev.x = it_prev.node.data.size.cols - 1;
while (it.next()) |p| {
const row = p.rowAndCell().row;
switch (row.semantic_prompt) {
// A prompt, we continue searching.
.prompt, .prompt_continuation, .input => {},
// Command output or unknown, definitely not a prompt.
.command, .unknown => break :end it_prev,
}
it_prev = p;
it_prev.x = it_prev.node.data.size.cols - 1;
}
break :end it_prev;
};
return Selection.init(start, end, false);
}
pub const LineIterator = struct {
screen: *const Screen,
current: ?Pin = null,
pub fn next(self: *LineIterator) ?Selection {
const current = self.current orelse return null;
const result = self.screen.selectLine(.{
.pin = current,
.whitespace = null,
.semantic_prompt_boundary = false,
}) orelse {
self.current = null;
return null;
};
self.current = result.end().down(1);
return result;
}
};
/// Returns an iterator to move through the soft-wrapped lines starting
/// from pin.
pub fn lineIterator(self: *const Screen, start: Pin) LineIterator {
return LineIterator{
.screen = self,
.current = start,
};
}
/// Returns the change in x/y that is needed to reach "to" from "from"
/// within a prompt. If "to" is before or after the prompt bounds then
/// the result will be bounded to the prompt.
///
/// This feature requires shell integration. If shell integration is not
/// enabled, this will always return zero for both x and y (no path).
pub fn promptPath(
self: *Screen,
from: Pin,
to: Pin,
) struct {
x: isize,
y: isize,
} {
// Get our prompt bounds assuming "from" is at a prompt.
const bounds = self.selectPrompt(from) orelse return .{ .x = 0, .y = 0 };
// Get our actual "to" point clamped to the bounds of the prompt.
const to_clamped = if (bounds.contains(self, to))
to
else if (to.before(bounds.start()))
bounds.start()
else
bounds.end();
// Convert to points
const from_pt = self.pages.pointFromPin(.screen, from).?.screen;
const to_pt = self.pages.pointFromPin(.screen, to_clamped).?.screen;
// Basic math to calculate our path.
const from_x: isize = @intCast(from_pt.x);
const from_y: isize = @intCast(from_pt.y);
const to_x: isize = @intCast(to_pt.x);
const to_y: isize = @intCast(to_pt.y);
return .{ .x = to_x - from_x, .y = to_y - from_y };
}
/// Dump the screen to a string. The writer given should be buffered;
/// this function does not attempt to efficiently write and generally writes
/// one byte at a time.
pub fn dumpString(
self: *const Screen,
writer: anytype,
opts: PageList.EncodeUtf8Options,
) anyerror!void {
try self.pages.encodeUtf8(writer, opts);
}
/// You should use dumpString, this is a restricted version mostly for
/// legacy and convenience reasons for unit tests.
pub fn dumpStringAlloc(
self: *const Screen,
alloc: Allocator,
tl: point.Point,
) ![]const u8 {
var builder = std.ArrayList(u8).init(alloc);
defer builder.deinit();
try self.dumpString(builder.writer(), .{
.tl = self.pages.getTopLeft(tl),
.br = self.pages.getBottomRight(tl) orelse return error.UnknownPoint,
.unwrap = false,
});
return try builder.toOwnedSlice();
}
/// You should use dumpString, this is a restricted version mostly for
/// legacy and convenience reasons for unit tests.
pub fn dumpStringAllocUnwrapped(
self: *const Screen,
alloc: Allocator,
tl: point.Point,
) ![]const u8 {
var builder = std.ArrayList(u8).init(alloc);
defer builder.deinit();
try self.dumpString(builder.writer(), .{
.tl = self.pages.getTopLeft(tl),
.br = self.pages.getBottomRight(tl) orelse return error.UnknownPoint,
.unwrap = true,
});
return try builder.toOwnedSlice();
}
/// This is basically a really jank version of Terminal.printString. We
/// have to reimplement it here because we want a way to print to the screen
/// to test it but don't want all the features of Terminal.
pub fn testWriteString(self: *Screen, text: []const u8) !void {
const view = try std.unicode.Utf8View.init(text);
var iter = view.iterator();
while (iter.nextCodepoint()) |c| {
// Explicit newline forces a new row
if (c == '\n') {
try self.cursorDownOrScroll();
self.cursorHorizontalAbsolute(0);
self.cursor.pending_wrap = false;
continue;
}
const width: usize = if (c <= 0xFF) 1 else @intCast(unicode.table.get(c).width);
if (width == 0) {
const cell = cell: {
var cell = self.cursorCellLeft(1);
switch (cell.wide) {
.narrow => {},
.wide => {},
.spacer_head => unreachable,
.spacer_tail => cell = self.cursorCellLeft(2),
}
break :cell cell;
};
try self.cursor.page_pin.node.data.appendGrapheme(
self.cursor.page_row,
cell,
c,
);
continue;
}
if (self.cursor.pending_wrap) {
assert(self.cursor.x == self.pages.cols - 1);
self.cursor.pending_wrap = false;
self.cursor.page_row.wrap = true;
try self.cursorDownOrScroll();
self.cursorHorizontalAbsolute(0);
self.cursor.page_row.wrap_continuation = true;
}
assert(width == 1 or width == 2);
switch (width) {
1 => {
self.cursor.page_cell.* = .{
.content_tag = .codepoint,
.content = .{ .codepoint = c },
.style_id = self.cursor.style_id,
.protected = self.cursor.protected,
};
// If we have a ref-counted style, increase.
if (self.cursor.style_id != style.default_id) {
const page = self.cursor.page_pin.node.data;
page.styles.use(page.memory, self.cursor.style_id);
self.cursor.page_row.styled = true;
}
},
2 => {
// Need a wide spacer head
if (self.cursor.x == self.pages.cols - 1) {
self.cursor.page_cell.* = .{
.content_tag = .codepoint,
.content = .{ .codepoint = 0 },
.wide = .spacer_head,
.protected = self.cursor.protected,
};
self.cursor.page_row.wrap = true;
try self.cursorDownOrScroll();
self.cursorHorizontalAbsolute(0);
self.cursor.page_row.wrap_continuation = true;
}
// Write our wide char
self.cursor.page_cell.* = .{
.content_tag = .codepoint,
.content = .{ .codepoint = c },
.style_id = self.cursor.style_id,
.wide = .wide,
.protected = self.cursor.protected,
};
// Write our tail
self.cursorRight(1);
self.cursor.page_cell.* = .{
.content_tag = .codepoint,
.content = .{ .codepoint = 0 },
.wide = .spacer_tail,
.protected = self.cursor.protected,
};
// 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;
page.styles.use(page.memory, self.cursor.style_id);
page.styles.use(page.memory, self.cursor.style_id);
self.cursor.page_row.styled = true;
}
},
else => unreachable,
}
if (self.cursor.x + 1 < self.pages.cols) {
self.cursorRight(1);
} else {
self.cursor.pending_wrap = true;
}
}
}
test "Screen read and write" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try Screen.init(alloc, 80, 24, 1000);
defer s.deinit();
try testing.expectEqual(@as(style.Id, 0), s.cursor.style_id);
try s.testWriteString("hello, world");
const str = try s.dumpStringAlloc(alloc, .{ .screen = .{} });
defer alloc.free(str);
try testing.expectEqualStrings("hello, world", str);
}
test "Screen read and write newline" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try Screen.init(alloc, 80, 24, 1000);
defer s.deinit();
try testing.expectEqual(@as(style.Id, 0), s.cursor.style_id);
try s.testWriteString("hello\nworld");
const str = try s.dumpStringAlloc(alloc, .{ .screen = .{} });
defer alloc.free(str);
try testing.expectEqualStrings("hello\nworld", str);
}
test "Screen read and write scrollback" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try Screen.init(alloc, 80, 2, 1000);
defer s.deinit();
try s.testWriteString("hello\nworld\ntest");
{
const str = try s.dumpStringAlloc(alloc, .{ .screen = .{} });
defer alloc.free(str);
try testing.expectEqualStrings("hello\nworld\ntest", str);
}
{
const str = try s.dumpStringAlloc(alloc, .{ .active = .{} });
defer alloc.free(str);
try testing.expectEqualStrings("world\ntest", str);
}
}
test "Screen read and write no scrollback small" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try Screen.init(alloc, 80, 2, 0);
defer s.deinit();
try s.testWriteString("hello\nworld\ntest");
{
const str = try s.dumpStringAlloc(alloc, .{ .screen = .{} });
defer alloc.free(str);
try testing.expectEqualStrings("world\ntest", str);
}
{
const str = try s.dumpStringAlloc(alloc, .{ .active = .{} });
defer alloc.free(str);
try testing.expectEqualStrings("world\ntest", str);
}
}
test "Screen read and write no scrollback large" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try Screen.init(alloc, 80, 2, 0);
defer s.deinit();
for (0..1_000) |i| {
var buf: [128]u8 = undefined;
const str = try std.fmt.bufPrint(&buf, "{}\n", .{i});
try s.testWriteString(str);
}
try s.testWriteString("1000");
{
const str = try s.dumpStringAlloc(alloc, .{ .screen = .{} });
defer alloc.free(str);
try testing.expectEqualStrings("999\n1000", str);
}
}
test "Screen cursorCopy x/y" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try Screen.init(alloc, 10, 10, 0);
defer s.deinit();
s.cursorAbsolute(2, 3);
try testing.expect(s.cursor.x == 2);
try testing.expect(s.cursor.y == 3);
var s2 = try Screen.init(alloc, 10, 10, 0);
defer s2.deinit();
try s2.cursorCopy(s.cursor, .{});
try testing.expect(s2.cursor.x == 2);
try testing.expect(s2.cursor.y == 3);
try s2.testWriteString("Hello");
{
const str = try s2.dumpStringAlloc(alloc, .{ .screen = .{} });
defer alloc.free(str);
try testing.expectEqualStrings("\n\n\n Hello", str);
}
}
test "Screen cursorCopy style deref" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try Screen.init(alloc, 10, 10, 0);
defer s.deinit();
var s2 = try Screen.init(alloc, 10, 10, 0);
defer s2.deinit();
const page = &s2.cursor.page_pin.node.data;
// Bold should create our style
try s2.setAttribute(.{ .bold = {} });
try testing.expectEqual(@as(usize, 1), page.styles.count());
try testing.expect(s2.cursor.style.flags.bold);
// Copy default style, should release our style
try s2.cursorCopy(s.cursor, .{});
try testing.expect(!s2.cursor.style.flags.bold);
try testing.expectEqual(@as(usize, 0), page.styles.count());
}
test "Screen cursorCopy style deref new page" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 10, 10, 0);
defer s.deinit();
var s2 = try Screen.init(alloc, 10, 10, 2048);
defer s2.deinit();
// We need to get the cursor on a new page.
const first_page_size = s2.pages.pages.first.?.data.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);
for (0..first_page_size - 5) |_| {
try s2.testWriteString("\n");
}
s2.pages.pages.first.?.data.pauseIntegrityChecks(false);
try s2.testWriteString("1\n2\n3\n4\n5\n6\n7\n8\n9\n10");
// s2.pages.diagram(...):
//
// +----------+ = PAGE 0
// ... : :
// +-------------+ ACTIVE
// 4300 |1 | | 0
// 4301 |2 | | 1
// 4302 |3 | | 2
// 4303 |4 | | 3
// 4304 |5 | | 4
// +----------+ :
// +----------+ : = PAGE 1
// 0 |6 | | 5
// 1 |7 | | 6
// 2 |8 | | 7
// 3 |9 | | 8
// 4 |10 | | 9
// : ^ : : = PIN 0
// +----------+ :
// +-------------+
// This should be PAGE 1
const page = &s2.cursor.page_pin.node.data;
// It should be the last page in the list.
try testing.expectEqual(&s2.pages.pages.last.?.data, page);
// It should have a previous page.
try testing.expect(s2.cursor.page_pin.node.prev != null);
// The cursor should be at 2, 9
try testing.expect(s2.cursor.x == 2);
try testing.expect(s2.cursor.y == 9);
// Bold should create our style in page 1.
try s2.setAttribute(.{ .bold = {} });
try testing.expectEqual(@as(usize, 1), page.styles.count());
try testing.expect(s2.cursor.style.flags.bold);
// Copy the cursor for the first screen. This should release
// the style from page 1 and move the cursor back to page 0.
try s2.cursorCopy(s.cursor, .{});
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);
// The cursor should be at 0, 0
try testing.expect(s2.cursor.x == 0);
try testing.expect(s2.cursor.y == 0);
}
test "Screen cursorCopy style copy" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try Screen.init(alloc, 10, 10, 0);
defer s.deinit();
try s.setAttribute(.{ .bold = {} });
var s2 = try Screen.init(alloc, 10, 10, 0);
defer s2.deinit();
const page = &s2.cursor.page_pin.node.data;
try s2.cursorCopy(s.cursor, .{});
try testing.expect(s2.cursor.style.flags.bold);
try testing.expectEqual(@as(usize, 1), page.styles.count());
}
test "Screen cursorCopy hyperlink deref" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try Screen.init(alloc, 10, 10, 0);
defer s.deinit();
var s2 = try Screen.init(alloc, 10, 10, 0);
defer s2.deinit();
const page = &s2.cursor.page_pin.node.data;
// Create a hyperlink for the cursor.
try s2.startHyperlink("https://example.com/", null);
try testing.expectEqual(@as(usize, 1), page.hyperlink_set.count());
try testing.expect(s2.cursor.hyperlink_id != 0);
// Copy a cursor with no hyperlink, should release our hyperlink.
try s2.cursorCopy(s.cursor, .{});
try testing.expectEqual(@as(usize, 0), page.hyperlink_set.count());
try testing.expect(s2.cursor.hyperlink_id == 0);
}
test "Screen cursorCopy hyperlink deref new page" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 10, 10, 0);
defer s.deinit();
var s2 = try Screen.init(alloc, 10, 10, 2048);
defer s2.deinit();
// We need to get the cursor on a new page.
const first_page_size = s2.pages.pages.first.?.data.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);
for (0..first_page_size - 5) |_| {
try s2.testWriteString("\n");
}
s2.pages.pages.first.?.data.pauseIntegrityChecks(false);
try s2.testWriteString("1\n2\n3\n4\n5\n6\n7\n8\n9\n10");
// s2.pages.diagram(...):
//
// +----------+ = PAGE 0
// ... : :
// +-------------+ ACTIVE
// 4300 |1 | | 0
// 4301 |2 | | 1
// 4302 |3 | | 2
// 4303 |4 | | 3
// 4304 |5 | | 4
// +----------+ :
// +----------+ : = PAGE 1
// 0 |6 | | 5
// 1 |7 | | 6
// 2 |8 | | 7
// 3 |9 | | 8
// 4 |10 | | 9
// : ^ : : = PIN 0
// +----------+ :
// +-------------+
// This should be PAGE 1
const page = &s2.cursor.page_pin.node.data;
// It should be the last page in the list.
try testing.expectEqual(&s2.pages.pages.last.?.data, page);
// It should have a previous page.
try testing.expect(s2.cursor.page_pin.node.prev != null);
// The cursor should be at 2, 9
try testing.expect(s2.cursor.x == 2);
try testing.expect(s2.cursor.y == 9);
// Create a hyperlink for the cursor, should be in page 1.
try s2.startHyperlink("https://example.com/", null);
try testing.expectEqual(@as(usize, 1), page.hyperlink_set.count());
try testing.expect(s2.cursor.hyperlink_id != 0);
// Copy the cursor for the first screen. This should release
// the hyperlink from page 1 and move the cursor back to page 0.
try s2.cursorCopy(s.cursor, .{});
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);
// The cursor should be at 0, 0
try testing.expect(s2.cursor.x == 0);
try testing.expect(s2.cursor.y == 0);
}
test "Screen cursorCopy hyperlink copy" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try Screen.init(alloc, 10, 10, 0);
defer s.deinit();
// 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.expect(s.cursor.hyperlink_id != 0);
var s2 = try Screen.init(alloc, 10, 10, 0);
defer s2.deinit();
const page = &s2.cursor.page_pin.node.data;
try testing.expectEqual(@as(usize, 0), page.hyperlink_set.count());
try testing.expect(s2.cursor.hyperlink_id == 0);
// Copy the cursor with the hyperlink.
try s2.cursorCopy(s.cursor, .{});
try testing.expectEqual(@as(usize, 1), page.hyperlink_set.count());
try testing.expect(s2.cursor.hyperlink_id != 0);
}
test "Screen cursorCopy hyperlink copy disabled" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try Screen.init(alloc, 10, 10, 0);
defer s.deinit();
// 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.expect(s.cursor.hyperlink_id != 0);
var s2 = try Screen.init(alloc, 10, 10, 0);
defer s2.deinit();
const page = &s2.cursor.page_pin.node.data;
try testing.expectEqual(@as(usize, 0), page.hyperlink_set.count());
try testing.expect(s2.cursor.hyperlink_id == 0);
// Copy the cursor with the hyperlink.
try s2.cursorCopy(s.cursor, .{ .hyperlink = false });
try testing.expectEqual(@as(usize, 0), page.hyperlink_set.count());
try testing.expect(s2.cursor.hyperlink_id == 0);
}
test "Screen style basics" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try Screen.init(alloc, 80, 24, 1000);
defer s.deinit();
const page = &s.cursor.page_pin.node.data;
try testing.expectEqual(@as(usize, 0), page.styles.count());
// Set a new style
try s.setAttribute(.{ .bold = {} });
try testing.expect(s.cursor.style_id != 0);
try testing.expectEqual(@as(usize, 1), page.styles.count());
try testing.expect(s.cursor.style.flags.bold);
// Set another style, we should still only have one since it was unused
try s.setAttribute(.{ .italic = {} });
try testing.expect(s.cursor.style_id != 0);
try testing.expectEqual(@as(usize, 1), page.styles.count());
try testing.expect(s.cursor.style.flags.italic);
}
test "Screen style reset to default" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try Screen.init(alloc, 80, 24, 1000);
defer s.deinit();
const page = &s.cursor.page_pin.node.data;
try testing.expectEqual(@as(usize, 0), page.styles.count());
// Set a new style
try s.setAttribute(.{ .bold = {} });
try testing.expect(s.cursor.style_id != 0);
try testing.expectEqual(@as(usize, 1), page.styles.count());
// Reset to default
try s.setAttribute(.{ .reset_bold = {} });
try testing.expect(s.cursor.style_id == 0);
try testing.expectEqual(@as(usize, 0), page.styles.count());
}
test "Screen style reset with unset" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try Screen.init(alloc, 80, 24, 1000);
defer s.deinit();
const page = &s.cursor.page_pin.node.data;
try testing.expectEqual(@as(usize, 0), page.styles.count());
// Set a new style
try s.setAttribute(.{ .bold = {} });
try testing.expect(s.cursor.style_id != 0);
try testing.expectEqual(@as(usize, 1), page.styles.count());
// Reset to default
try s.setAttribute(.{ .unset = {} });
try testing.expect(s.cursor.style_id == 0);
try testing.expectEqual(@as(usize, 0), page.styles.count());
}
test "Screen clearRows active one line" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try Screen.init(alloc, 80, 24, 1000);
defer s.deinit();
try s.testWriteString("hello, world");
s.clearRows(.{ .active = .{} }, null, false);
try testing.expect(s.pages.isDirty(.{ .active = .{ .x = 0, .y = 0 } }));
const str = try s.dumpStringAlloc(alloc, .{ .screen = .{} });
defer alloc.free(str);
try testing.expectEqualStrings("", str);
}
test "Screen clearRows active multi line" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try Screen.init(alloc, 80, 24, 1000);
defer s.deinit();
try s.testWriteString("hello\nworld");
s.clearRows(.{ .active = .{} }, null, false);
try testing.expect(s.pages.isDirty(.{ .active = .{ .x = 0, .y = 0 } }));
try testing.expect(s.pages.isDirty(.{ .active = .{ .x = 0, .y = 1 } }));
const str = try s.dumpStringAlloc(alloc, .{ .screen = .{} });
defer alloc.free(str);
try testing.expectEqualStrings("", str);
}
test "Screen clearRows active styled line" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try Screen.init(alloc, 80, 24, 1000);
defer s.deinit();
try s.setAttribute(.{ .bold = {} });
try s.testWriteString("hello world");
try s.setAttribute(.{ .unset = {} });
// We should have one style
const page = &s.cursor.page_pin.node.data;
try testing.expectEqual(@as(usize, 1), page.styles.count());
s.clearRows(.{ .active = .{} }, null, false);
// We should have none because active cleared it
try testing.expectEqual(@as(usize, 0), page.styles.count());
const str = try s.dumpStringAlloc(alloc, .{ .screen = .{} });
defer alloc.free(str);
try testing.expectEqualStrings("", str);
}
test "Screen clearRows protected" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try Screen.init(alloc, 80, 24, 1000);
defer s.deinit();
try s.testWriteString("UNPROTECTED");
s.cursor.protected = true;
try s.testWriteString("PROTECTED");
s.cursor.protected = false;
try s.testWriteString("UNPROTECTED");
try s.testWriteString("\n");
s.cursor.protected = true;
try s.testWriteString("PROTECTED");
s.cursor.protected = false;
try s.testWriteString("UNPROTECTED");
s.cursor.protected = true;
try s.testWriteString("PROTECTED");
s.cursor.protected = false;
s.clearRows(.{ .active = .{} }, null, true);
const str = try s.dumpStringAlloc(alloc, .{ .screen = .{} });
defer alloc.free(str);
try testing.expectEqualStrings(" PROTECTED\nPROTECTED PROTECTED", str);
}
test "Screen eraseRows history" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try Screen.init(alloc, 5, 5, 1000);
defer s.deinit();
try s.testWriteString("1\n2\n3\n4\n5\n6");
{
const str = try s.dumpStringAlloc(alloc, .{ .active = .{} });
defer alloc.free(str);
try testing.expectEqualStrings("2\n3\n4\n5\n6", str);
}
{
const str = try s.dumpStringAlloc(alloc, .{ .screen = .{} });
defer alloc.free(str);
try testing.expectEqualStrings("1\n2\n3\n4\n5\n6", str);
}
s.eraseRows(.{ .history = .{} }, null);
{
const str = try s.dumpStringAlloc(alloc, .{ .active = .{} });
defer alloc.free(str);
try testing.expectEqualStrings("2\n3\n4\n5\n6", str);
}
{
const str = try s.dumpStringAlloc(alloc, .{ .screen = .{} });
defer alloc.free(str);
try testing.expectEqualStrings("2\n3\n4\n5\n6", str);
}
}
test "Screen eraseRows history with more lines" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try Screen.init(alloc, 5, 5, 1000);
defer s.deinit();
try s.testWriteString("A\nB\nC\n1\n2\n3\n4\n5\n6");
{
const str = try s.dumpStringAlloc(alloc, .{ .active = .{} });
defer alloc.free(str);
try testing.expectEqualStrings("2\n3\n4\n5\n6", str);
}
{
const str = try s.dumpStringAlloc(alloc, .{ .screen = .{} });
defer alloc.free(str);
try testing.expectEqualStrings("A\nB\nC\n1\n2\n3\n4\n5\n6", str);
}
s.eraseRows(.{ .history = .{} }, null);
{
const str = try s.dumpStringAlloc(alloc, .{ .active = .{} });
defer alloc.free(str);
try testing.expectEqualStrings("2\n3\n4\n5\n6", str);
}
{
const str = try s.dumpStringAlloc(alloc, .{ .screen = .{} });
defer alloc.free(str);
try testing.expectEqualStrings("2\n3\n4\n5\n6", str);
}
}
test "Screen eraseRows active partial" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try Screen.init(alloc, 5, 5, 0);
defer s.deinit();
try s.testWriteString("1\n2\n3");
{
const str = try s.dumpStringAlloc(alloc, .{ .active = .{} });
defer alloc.free(str);
try testing.expectEqualStrings("1\n2\n3", str);
}
s.eraseRows(.{ .active = .{} }, .{ .active = .{ .y = 1 } });
{
const str = try s.dumpStringAlloc(alloc, .{ .active = .{} });
defer alloc.free(str);
try testing.expectEqualStrings("3", str);
}
{
const str = try s.dumpStringAlloc(alloc, .{ .screen = .{} });
defer alloc.free(str);
try testing.expectEqualStrings("3", str);
}
}
test "Screen: clearPrompt" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 5, 3, 0);
defer s.deinit();
const str = "1ABCD\n2EFGH\n3IJKL";
try s.testWriteString(str);
// Set one of the rows to be a prompt
{
s.cursorAbsolute(0, 1);
s.cursor.page_row.semantic_prompt = .prompt;
s.cursorAbsolute(0, 2);
s.cursor.page_row.semantic_prompt = .input;
}
s.clearPrompt();
{
const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} });
defer alloc.free(contents);
try testing.expectEqualStrings("1ABCD", contents);
}
}
test "Screen: clearPrompt continuation" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 5, 4, 0);
defer s.deinit();
const str = "1ABCD\n2EFGH\n3IJKL\n4MNOP";
try s.testWriteString(str);
// Set one of the rows to be a prompt followed by a continuation row
{
s.cursorAbsolute(0, 1);
s.cursor.page_row.semantic_prompt = .prompt;
s.cursorAbsolute(0, 2);
s.cursor.page_row.semantic_prompt = .prompt_continuation;
s.cursorAbsolute(0, 3);
s.cursor.page_row.semantic_prompt = .input;
}
s.clearPrompt();
{
const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} });
defer alloc.free(contents);
try testing.expectEqualStrings("1ABCD\n2EFGH", contents);
}
}
test "Screen: clearPrompt consecutive prompts" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 5, 3, 0);
defer s.deinit();
const str = "1ABCD\n2EFGH\n3IJKL";
try s.testWriteString(str);
// Set both rows to be prompts
{
s.cursorAbsolute(0, 1);
s.cursor.page_row.semantic_prompt = .input;
s.cursorAbsolute(0, 2);
s.cursor.page_row.semantic_prompt = .input;
}
s.clearPrompt();
{
const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} });
defer alloc.free(contents);
try testing.expectEqualStrings("1ABCD\n2EFGH", contents);
}
}
test "Screen: clearPrompt no prompt" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 5, 3, 0);
defer s.deinit();
const str = "1ABCD\n2EFGH\n3IJKL";
try s.testWriteString(str);
s.clearPrompt();
{
const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} });
defer alloc.free(contents);
try testing.expectEqualStrings(str, contents);
}
}
test "Screen: cursorDown across pages preserves style" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 10, 3, 1);
defer s.deinit();
// Scroll down enough to go to another page
const start_page = &s.pages.pages.last.?.data;
const rem = start_page.capacity.rows;
start_page.pauseIntegrityChecks(true);
for (0..rem) |_| try s.cursorDownOrScroll();
start_page.pauseIntegrityChecks(false);
// 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.cursor.page_pin.node.data;
try testing.expect(start_page != page);
}
// Scroll back to the previous page
s.cursorUp(1);
{
const page = &s.cursor.page_pin.node.data;
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 styleval = page.styles.get(
page.memory,
s.cursor.style_id,
);
try testing.expect(styleval.flags.bold);
}
// 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 styleval = page.styles.get(
page.memory,
s.cursor.style_id,
);
try testing.expect(styleval.flags.bold);
}
}
test "Screen: cursorUp across pages preserves style" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 10, 3, 1);
defer s.deinit();
// Scroll down enough to go to another page
const start_page = &s.pages.pages.last.?.data;
const rem = start_page.capacity.rows;
start_page.pauseIntegrityChecks(true);
for (0..rem) |_| try s.cursorDownOrScroll();
start_page.pauseIntegrityChecks(false);
// 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.cursor.page_pin.node.data;
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 styleval = page.styles.get(
page.memory,
s.cursor.style_id,
);
try testing.expect(styleval.flags.bold);
}
// Go back down into the prev page and we should have that style
s.cursorUp(1);
{
const page = &s.cursor.page_pin.node.data;
try testing.expect(start_page == page);
const styleval = page.styles.get(
page.memory,
s.cursor.style_id,
);
try testing.expect(styleval.flags.bold);
}
}
test "Screen: cursorAbsolute across pages preserves style" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 10, 3, 1);
defer s.deinit();
// Scroll down enough to go to another page
const start_page = &s.pages.pages.last.?.data;
const rem = start_page.capacity.rows;
start_page.pauseIntegrityChecks(true);
for (0..rem) |_| try s.cursorDownOrScroll();
start_page.pauseIntegrityChecks(false);
// 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.cursor.page_pin.node.data;
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 styleval = page.styles.get(
page.memory,
s.cursor.style_id,
);
try testing.expect(styleval.flags.bold);
}
// 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;
try testing.expect(start_page == page);
const styleval = page.styles.get(
page.memory,
s.cursor.style_id,
);
try testing.expect(styleval.flags.bold);
}
}
test "Screen: cursorAbsolute to page with insufficient capacity" {
// This test checks for a very specific edge case
// which previously resulted in memory corruption.
//
// The conditions for this edge case are as such:
// - The cursor has an associated style or other managed memory.
// - The cursor moves to a different page.
// - The new page is at capacity and must have its capacity adjusted.
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 10, 3, 1);
defer s.deinit();
// Scroll down enough to go to another page
const start_page = &s.pages.pages.last.?.data;
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;
// 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
// above enough for a new page to show up.
try testing.expect(start_page != new_page);
// Add styles to the start page until it reaches capacity.
{
// Pause integrity checks because they're slow and
// we're not testing this, this is just setup.
start_page.pauseIntegrityChecks(true);
defer start_page.pauseIntegrityChecks(false);
defer start_page.assertIntegrity();
var n: u24 = 1;
while (start_page.styles.add(
start_page.memory,
.{ .bg_color = .{ .rgb = @bitCast(n) } },
)) |_| n += 1 else |_| {}
}
// Set a style on the cursor.
try s.setAttribute(.{ .bold = {} });
{
const styleval = new_page.styles.get(
new_page.memory,
s.cursor.style_id,
);
try testing.expect(styleval.flags.bold);
}
// 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;
// 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);
// To make sure we DID change pages we check we're not on new_page.
try testing.expect(new_page != cur_page);
const styleval = cur_page.styles.get(
cur_page.memory,
s.cursor.style_id,
);
try testing.expect(styleval.flags.bold);
}
s.cursor.page_pin.node.data.assertIntegrity();
new_page.assertIntegrity();
}
test "Screen: scrolling" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 10, 3, 0);
defer s.deinit();
try s.setAttribute(.{ .direct_color_bg = .{ .r = 155 } });
try s.testWriteString("1ABCD\n2EFGH\n3IJKL");
// Scroll down, should still be bottom
try s.cursorDownScroll();
{
const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} });
defer alloc.free(contents);
try testing.expectEqualStrings("2EFGH\n3IJKL", contents);
}
{
const list_cell = s.pages.getCell(.{ .active = .{ .x = 0, .y = 2 } }).?;
const cell = list_cell.cell;
try testing.expect(cell.content_tag == .bg_color_rgb);
try testing.expectEqual(Cell.RGB{
.r = 155,
.g = 0,
.b = 0,
}, cell.content.color_rgb);
}
// Everything is dirty because we have no scrollback
try testing.expect(s.pages.isDirty(.{ .active = .{ .x = 0, .y = 0 } }));
try testing.expect(s.pages.isDirty(.{ .active = .{ .x = 0, .y = 1 } }));
try testing.expect(s.pages.isDirty(.{ .active = .{ .x = 0, .y = 2 } }));
// Scrolling to the bottom does nothing
s.scroll(.{ .active = {} });
{
const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} });
defer alloc.free(contents);
try testing.expectEqualStrings("2EFGH\n3IJKL", contents);
}
}
test "Screen: scrolling with a single-row screen no scrollback" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 10, 1, 0);
defer s.deinit();
try s.testWriteString("1ABCD");
// Scroll down, should still be bottom
try s.cursorDownScroll();
{
const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} });
defer alloc.free(contents);
try testing.expectEqualStrings("", contents);
}
// Screen should be dirty
try testing.expect(s.pages.isDirty(.{ .active = .{ .x = 0, .y = 0 } }));
}
test "Screen: scrolling with a single-row screen with scrollback" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 10, 1, 1);
defer s.deinit();
try s.testWriteString("1ABCD");
// Scroll down, should still be bottom
try s.cursorDownScroll();
{
const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} });
defer alloc.free(contents);
try testing.expectEqualStrings("", contents);
}
// Active should be dirty
try testing.expect(s.pages.isDirty(.{ .active = .{ .x = 0, .y = 0 } }));
// Scrollback also dirty because cursor moved from there
try testing.expect(s.pages.isDirty(.{ .screen = .{ .x = 0, .y = 0 } }));
s.scroll(.{ .delta_row = -1 });
{
const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} });
defer alloc.free(contents);
try testing.expectEqualStrings("1ABCD", contents);
}
}
test "Screen: scrolling across pages preserves style" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 10, 3, 1);
defer s.deinit();
try s.setAttribute(.{ .bold = {} });
try s.testWriteString("1ABCD\n2EFGH\n3IJKL");
const start_page = &s.pages.pages.last.?.data;
// Scroll down enough to go to another page
const rem = start_page.capacity.rows - start_page.size.rows + 1;
start_page.pauseIntegrityChecks(true);
for (0..rem) |_| try s.cursorDownOrScroll();
start_page.pauseIntegrityChecks(false);
// 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;
try testing.expect(start_page != page);
const styleval = page.styles.get(
page.memory,
s.cursor.style_id,
);
try testing.expect(styleval.flags.bold);
}
test "Screen: scroll down from 0" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 10, 3, 0);
defer s.deinit();
try s.testWriteString("1ABCD\n2EFGH\n3IJKL");
// Scrolling up does nothing, but allows it
s.scroll(.{ .delta_row = -1 });
try testing.expect(s.pages.viewport == .active);
{
const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} });
defer alloc.free(contents);
try testing.expectEqualStrings("1ABCD\n2EFGH\n3IJKL", contents);
}
}
test "Screen: scrollback various cases" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 10, 3, 1);
defer s.deinit();
try s.testWriteString("1ABCD\n2EFGH\n3IJKL");
try s.cursorDownScroll();
{
const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} });
defer alloc.free(contents);
try testing.expectEqualStrings("2EFGH\n3IJKL", contents);
}
// Scrolling to the bottom
s.scroll(.{ .active = {} });
{
const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} });
defer alloc.free(contents);
try testing.expectEqualStrings("2EFGH\n3IJKL", contents);
}
// Scrolling back should make it visible again
s.scroll(.{ .delta_row = -1 });
try testing.expect(s.pages.viewport != .active);
{
const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} });
defer alloc.free(contents);
try testing.expectEqualStrings("1ABCD\n2EFGH\n3IJKL", contents);
}
// Scrolling back again should do nothing
s.scroll(.{ .delta_row = -1 });
{
const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} });
defer alloc.free(contents);
try testing.expectEqualStrings("1ABCD\n2EFGH\n3IJKL", contents);
}
// Scrolling to the bottom
s.scroll(.{ .active = {} });
{
const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} });
defer alloc.free(contents);
try testing.expectEqualStrings("2EFGH\n3IJKL", contents);
}
// Scrolling forward with no grow should do nothing
s.scroll(.{ .delta_row = 1 });
{
const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} });
defer alloc.free(contents);
try testing.expectEqualStrings("2EFGH\n3IJKL", contents);
}
// Scrolling to the top should work
s.scroll(.{ .top = {} });
{
const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} });
defer alloc.free(contents);
try testing.expectEqualStrings("1ABCD\n2EFGH\n3IJKL", contents);
}
// Should be able to easily clear active area only
s.clearRows(.{ .active = .{} }, null, false);
{
const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} });
defer alloc.free(contents);
try testing.expectEqualStrings("1ABCD", contents);
}
// Scrolling to the bottom
s.scroll(.{ .active = {} });
{
const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} });
defer alloc.free(contents);
try testing.expectEqualStrings("", contents);
}
}
test "Screen: scrollback with multi-row delta" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 10, 3, 3);
defer s.deinit();
try s.testWriteString("1ABCD\n2EFGH\n3IJKL\n4ABCD\n5EFGH\n6IJKL");
// Scroll to top
s.scroll(.{ .top = {} });
{
const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} });
defer alloc.free(contents);
try testing.expectEqualStrings("1ABCD\n2EFGH\n3IJKL", contents);
}
// Scroll down multiple
s.scroll(.{ .delta_row = 5 });
try testing.expect(s.pages.viewport == .active);
{
const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} });
defer alloc.free(contents);
try testing.expectEqualStrings("4ABCD\n5EFGH\n6IJKL", contents);
}
}
test "Screen: scrollback empty" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 10, 3, 50);
defer s.deinit();
try s.testWriteString("1ABCD\n2EFGH\n3IJKL");
s.scroll(.{ .delta_row = 1 });
{
const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} });
defer alloc.free(contents);
try testing.expectEqualStrings("1ABCD\n2EFGH\n3IJKL", contents);
}
}
test "Screen: scrollback doesn't move viewport if not at bottom" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 10, 3, 3);
defer s.deinit();
try s.testWriteString("1ABCD\n2EFGH\n3IJKL\n4ABCD\n5EFGH");
// First test: we scroll up by 1, so we're not at the bottom anymore.
s.scroll(.{ .delta_row = -1 });
{
const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} });
defer alloc.free(contents);
try testing.expectEqualStrings("2EFGH\n3IJKL\n4ABCD", contents);
}
// Next, we scroll back down by 1, this grows the scrollback but we
// shouldn't move.
try s.cursorDownScroll();
{
const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} });
defer alloc.free(contents);
try testing.expectEqualStrings("2EFGH\n3IJKL\n4ABCD", contents);
}
// Scroll again, this clears scrollback so we should move viewports
// but still see the same thing since our original view fits.
try s.cursorDownScroll();
{
const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} });
defer alloc.free(contents);
try testing.expectEqualStrings("2EFGH\n3IJKL\n4ABCD", contents);
}
}
test "Screen: scrolling moves selection" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 5, 3, 1);
defer s.deinit();
try s.testWriteString("1ABCD\n2EFGH\n3IJKL");
// Select a single line
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,
));
// Scroll down, should still be bottom
try s.cursorDownScroll();
// Our selection should've moved up
{
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()).?);
}
{
// Test our contents rotated
const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} });
defer alloc.free(contents);
try testing.expectEqualStrings("2EFGH\n3IJKL", contents);
}
// Scrolling to the bottom does nothing
s.scroll(.{ .active = {} });
// Our selection should've stayed the same
{
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()).?);
}
{
// Test our contents rotated
const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} });
defer alloc.free(contents);
try testing.expectEqualStrings("2EFGH\n3IJKL", contents);
}
// Scroll up again
try s.cursorDownScroll();
{
// Test our contents rotated
const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} });
defer alloc.free(contents);
try testing.expectEqualStrings("3IJKL", contents);
}
// Our selection should be null because it left the screen.
{
const sel = s.selection.?;
try testing.expect(s.pages.pointFromPin(.active, sel.start()) == null);
try testing.expect(s.pages.pointFromPin(.active, sel.end()) == null);
}
}
test "Screen: scrolling moves viewport" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 10, 3, 1);
defer s.deinit();
try s.testWriteString("1ABCD\n2EFGH\n3IJKL\n");
try s.testWriteString("1ABCD\n2EFGH\n3IJKL");
s.scroll(.{ .delta_row = -2 });
{
// Test our contents rotated
const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} });
defer alloc.free(contents);
try testing.expectEqualStrings("2EFGH\n3IJKL\n1ABCD", contents);
}
{
try testing.expectEqual(point.Point{ .screen = .{
.x = 0,
.y = 1,
} }, s.pages.pointFromPin(.screen, s.pages.getTopLeft(.viewport)));
}
}
test "Screen: scrolling when viewport is pruned" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 215, 3, 1);
defer s.deinit();
// Write some to create scrollback and move back into our scrollback.
try s.testWriteString("1ABCD\n2EFGH\n3IJKL\n");
try s.testWriteString("1ABCD\n2EFGH\n3IJKL");
s.scroll(.{ .delta_row = -2 });
// Our viewport is now somewhere pinned. Create so much scrollback
// that we prune it.
try s.testWriteString("\n");
for (0..1000) |_| try s.testWriteString("1ABCD\n2EFGH\n3IJKL\n");
try s.testWriteString("1ABCD\n2EFGH\n3IJKL");
{
try testing.expectEqual(point.Point{ .screen = .{
.x = 0,
.y = 0,
} }, s.pages.pointFromPin(.screen, s.pages.getTopLeft(.viewport)));
}
}
test "Screen: scroll and clear full screen" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 10, 3, 5);
defer s.deinit();
try s.testWriteString("1ABCD\n2EFGH\n3IJKL");
{
const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} });
defer alloc.free(contents);
try testing.expectEqualStrings("1ABCD\n2EFGH\n3IJKL", contents);
}
try s.scrollClear();
{
const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} });
defer alloc.free(contents);
try testing.expectEqualStrings("", contents);
}
{
const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} });
defer alloc.free(contents);
try testing.expectEqualStrings("1ABCD\n2EFGH\n3IJKL", contents);
}
}
test "Screen: scroll and clear partial screen" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 10, 3, 5);
defer s.deinit();
try s.testWriteString("1ABCD\n2EFGH");
{
const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} });
defer alloc.free(contents);
try testing.expectEqualStrings("1ABCD\n2EFGH", contents);
}
try s.scrollClear();
{
const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} });
defer alloc.free(contents);
try testing.expectEqualStrings("", contents);
}
{
const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} });
defer alloc.free(contents);
try testing.expectEqualStrings("1ABCD\n2EFGH", contents);
}
}
test "Screen: scroll and clear empty screen" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 10, 3, 5);
defer s.deinit();
try s.scrollClear();
{
const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} });
defer alloc.free(contents);
try testing.expectEqualStrings("", contents);
}
{
const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} });
defer alloc.free(contents);
try testing.expectEqualStrings("", contents);
}
}
test "Screen: scroll and clear ignore blank lines" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 10, 3, 10);
defer s.deinit();
try s.testWriteString("1ABCD\n2EFGH");
try s.scrollClear();
{
const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} });
defer alloc.free(contents);
try testing.expectEqualStrings("", contents);
}
// Move back to top-left
s.cursorAbsolute(0, 0);
// Write and clear
try s.testWriteString("3ABCD\n");
{
const contents = try s.dumpStringAlloc(alloc, .{ .active = .{} });
defer alloc.free(contents);
try testing.expectEqualStrings("3ABCD", contents);
}
try s.scrollClear();
{
const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} });
defer alloc.free(contents);
try testing.expectEqualStrings("", contents);
}
// Move back to top-left
s.cursorAbsolute(0, 0);
try s.testWriteString("X");
{
const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} });
defer alloc.free(contents);
try testing.expectEqualStrings("1ABCD\n2EFGH\n3ABCD\nX", contents);
}
}
test "Screen: scroll above same page" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 10, 3, 10);
defer s.deinit();
try s.setAttribute(.{ .direct_color_bg = .{ .r = 155 } });
try s.testWriteString("1ABCD\n2EFGH\n3IJKL");
s.cursorAbsolute(0, 1);
s.pages.clearDirty();
// At this point:
// +-------------+ ACTIVE
// +----------+ : = PAGE 0
// 0 |1ABCD00000| | 0
// 1 |2EFGH00000| | 1
// :^ : : = PIN 0
// 2 |3IJKL00000| | 2
// +----------+ :
// +-------------+
try s.cursorScrollAbove();
// +----------+ = PAGE 0
// 0 |1ABCD00000|
// +-------------+ ACTIVE
// 1 |2EFGH00000| | 0
// 2 | | | 1
// :^ : : = PIN 0
// 3 |3IJKL00000| | 2
// +----------+ :
// +-------------+
// try s.pages.diagram(std.io.getStdErr().writer());
{
const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} });
defer alloc.free(contents);
try testing.expectEqualStrings("2EFGH\n\n3IJKL", contents);
}
{
const list_cell = s.pages.getCell(.{ .active = .{ .x = 0, .y = 1 } }).?;
const cell = list_cell.cell;
try testing.expect(cell.content_tag == .bg_color_rgb);
try testing.expectEqual(Cell.RGB{
.r = 155,
.g = 0,
.b = 0,
}, cell.content.color_rgb);
}
// Page 0 row 1 (active row 0) is dirty because the cursor moved off of it.
try testing.expect(s.pages.isDirty(.{ .active = .{ .x = 0, .y = 0 } }));
// Page 0 row 2 (active row 1) is dirty because it was cleared.
try testing.expect(s.pages.isDirty(.{ .active = .{ .x = 0, .y = 1 } }));
// Page 0 row 3 (active row 2) is dirty because it's new.
try testing.expect(s.pages.isDirty(.{ .active = .{ .x = 0, .y = 2 } }));
}
test "Screen: scroll above same page but cursor on previous page" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 10, 5, 10);
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);
for (0..first_page_size - 3) |_| try s.testWriteString("\n");
s.pages.pages.first.?.data.pauseIntegrityChecks(false);
try s.setAttribute(.{ .direct_color_bg = .{ .r = 155 } });
try s.testWriteString("1A\n2B\n3C\n4D\n5E");
s.cursorAbsolute(0, 1);
s.pages.clearDirty();
// Ensure we're still on the first page and have a second
try testing.expect(s.cursor.page_pin.node == s.pages.pages.first.?);
try testing.expect(s.pages.pages.first.?.next != null);
// At this point:
// +----------+ = PAGE 0
// ... : :
// +-------------+ ACTIVE
// 4305 |1A00000000| | 0
// 4306 |2B00000000| | 1
// :^ : : = PIN 0
// 4307 |3C00000000| | 2
// +----------+ :
// +----------+ : = PAGE 1
// 0 |4D00000000| | 3
// 1 |5E00000000| | 4
// +----------+ :
// +-------------+
try s.cursorScrollAbove();
// +----------+ = PAGE 0
// ... : :
// 4305 |1A00000000|
// +-------------+ ACTIVE
// 4306 |2B00000000| | 0
// 4307 | | | 1
// :^ : : = PIN 0
// +----------+ :
// +----------+ : = PAGE 1
// 0 |3C00000000| | 2
// 1 |4D00000000| | 3
// 2 |5E00000000| | 4
// +----------+ :
// +-------------+
// try s.pages.diagram(std.io.getStdErr().writer());
{
const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} });
defer alloc.free(contents);
try testing.expectEqualStrings("2B\n\n3C\n4D\n5E", contents);
}
{
const list_cell = s.pages.getCell(.{ .active = .{ .x = 0, .y = 1 } }).?;
const cell = list_cell.cell;
try testing.expect(cell.content_tag == .bg_color_rgb);
try testing.expectEqual(Cell.RGB{
.r = 155,
.g = 0,
.b = 0,
}, cell.content.color_rgb);
}
// Page 0's penultimate row is dirty because the cursor moved off of it.
try testing.expect(s.pages.isDirty(.{ .active = .{ .x = 0, .y = 0 } }));
// The rest of the rows are dirty because they've been modified or are new.
try testing.expect(s.pages.isDirty(.{ .active = .{ .x = 0, .y = 1 } }));
try testing.expect(s.pages.isDirty(.{ .active = .{ .x = 0, .y = 2 } }));
try testing.expect(s.pages.isDirty(.{ .active = .{ .x = 0, .y = 3 } }));
try testing.expect(s.pages.isDirty(.{ .active = .{ .x = 0, .y = 4 } }));
}
test "Screen: scroll above same page but cursor on previous page last row" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 10, 5, 10);
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);
for (0..first_page_size - 2) |_| try s.testWriteString("\n");
s.pages.pages.first.?.data.pauseIntegrityChecks(false);
try s.setAttribute(.{ .direct_color_bg = .{ .r = 155 } });
try s.testWriteString("1A\n2B\n3C\n4D\n5E");
s.cursorAbsolute(0, 1);
s.pages.clearDirty();
// Ensure we're still on the first page and have a second
try testing.expect(s.cursor.page_pin.node == s.pages.pages.first.?);
try testing.expect(s.pages.pages.first.?.next != null);
// At this point:
// +----------+ = PAGE 0
// ... : :
// +-------------+ ACTIVE
// 4306 |1A00000000| | 0
// 4307 |2B00000000| | 1
// :^ : : = PIN 0
// +----------+ :
// +----------+ : = PAGE 1
// 0 |3C00000000| | 2
// 1 |4D00000000| | 3
// 2 |5E00000000| | 4
// +----------+ :
// +-------------+
try s.cursorScrollAbove();
// +----------+ = PAGE 0
// ... : :
// 4306 |1A00000000|
// +-------------+ ACTIVE
// 4307 |2B00000000| | 0
// +----------+ :
// +----------+ : = PAGE 1
// 0 | | | 1
// :^ : : = PIN 0
// 1 |3C00000000| | 2
// 2 |4D00000000| | 3
// 3 |5E00000000| | 4
// +----------+ :
// +-------------+
// try s.pages.diagram(std.io.getStdErr().writer());
{
const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} });
defer alloc.free(contents);
try testing.expectEqualStrings("2B\n\n3C\n4D\n5E", contents);
}
{
const list_cell = s.pages.getCell(.{ .active = .{ .x = 0, .y = 1 } }).?;
const cell = list_cell.cell;
try testing.expect(cell.content_tag == .bg_color_rgb);
try testing.expectEqual(Cell.RGB{
.r = 155,
.g = 0,
.b = 0,
}, cell.content.color_rgb);
}
// Page 0's final row is dirty because the cursor moved off of it.
try testing.expect(s.pages.isDirty(.{ .active = .{ .x = 0, .y = 0 } }));
// Page 1's rows are all dirty because every row was moved.
try testing.expect(s.pages.isDirty(.{ .active = .{ .x = 0, .y = 1 } }));
try testing.expect(s.pages.isDirty(.{ .active = .{ .x = 0, .y = 2 } }));
try testing.expect(s.pages.isDirty(.{ .active = .{ .x = 0, .y = 3 } }));
try testing.expect(s.pages.isDirty(.{ .active = .{ .x = 0, .y = 4 } }));
// Attempt to clear the style from the cursor and
// then assert the integrity of both of our pages.
//
// 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();
}
test "Screen: scroll above creates new page" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 10, 3, 10);
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);
for (0..first_page_size - 3) |_| try s.testWriteString("\n");
s.pages.pages.first.?.data.pauseIntegrityChecks(false);
try s.setAttribute(.{ .direct_color_bg = .{ .r = 155 } });
try s.testWriteString("1ABCD\n2EFGH\n3IJKL");
s.cursorAbsolute(0, 1);
s.pages.clearDirty();
// Ensure we're still on the first page
try testing.expect(s.cursor.page_pin.node == s.pages.pages.first.?);
// At this point:
// +----------+ = PAGE 0
// ... : :
// +-------------+ ACTIVE
// 4305 |1ABCD00000| | 0
// 4306 |2EFGH00000| | 1
// :^ : : = PIN 0
// 4307 |3IJKL00000| | 2
// +----------+ :
// +-------------+
try s.cursorScrollAbove();
// +----------+ = PAGE 0
// ... : :
// 4305 |1ABCD00000|
// +-------------+ ACTIVE
// 4306 |2EFGH00000| | 0
// 4307 | | | 1
// :^ : : = PIN 0
// +----------+ :
// +----------+ : = PAGE 1
// 0 |3IJKL00000| | 2
// +----------+ :
// +-------------+
// try s.pages.diagram(std.io.getStdErr().writer());
{
const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} });
defer alloc.free(contents);
try testing.expectEqualStrings("2EFGH\n\n3IJKL", contents);
}
{
const list_cell = s.pages.getCell(.{ .active = .{ .x = 0, .y = 1 } }).?;
const cell = list_cell.cell;
try testing.expect(cell.content_tag == .bg_color_rgb);
try testing.expectEqual(Cell.RGB{
.r = 155,
.g = 0,
.b = 0,
}, cell.content.color_rgb);
}
// Page 0's penultimate row is dirty because the cursor moved off of it.
try testing.expect(s.pages.isDirty(.{ .active = .{ .x = 0, .y = 0 } }));
// Page 0's final row is dirty because it was cleared.
try testing.expect(s.pages.isDirty(.{ .active = .{ .x = 0, .y = 1 } }));
// Page 1's row is dirty because it's new.
try testing.expect(s.pages.isDirty(.{ .active = .{ .x = 0, .y = 2 } }));
}
test "Screen: scroll above no scrollback bottom of page" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 10, 3, 0);
defer s.deinit();
const first_page_size = s.pages.pages.first.?.data.capacity.rows;
s.pages.pages.first.?.data.pauseIntegrityChecks(true);
for (0..first_page_size - 3) |_| try s.testWriteString("\n");
s.pages.pages.first.?.data.pauseIntegrityChecks(false);
try s.setAttribute(.{ .direct_color_bg = .{ .r = 155 } });
try s.testWriteString("1ABCD\n2EFGH\n3IJKL");
s.cursorAbsolute(0, 1);
s.pages.clearDirty();
// At this point:
// +-------------+ ACTIVE
// +----------+ : = PAGE 0
// 0 |1ABCD00000| | 0
// 1 |2EFGH00000| | 1
// :^ : : = PIN 0
// 2 |3IJKL00000| | 2
// +----------+ :
// +-------------+
try s.cursorScrollAbove();
// +----------+ = PAGE 0
// 0 |1ABCD00000|
// +-------------+ ACTIVE
// 1 |2EFGH00000| | 0
// 2 | | | 1
// :^ : : = PIN 0
// 3 |3IJKL00000| | 2
// +----------+ :
// +-------------+
//try s.pages.diagram(std.io.getStdErr().writer());
{
const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} });
defer alloc.free(contents);
try testing.expectEqualStrings("2EFGH\n\n3IJKL", contents);
}
{
const list_cell = s.pages.getCell(.{ .active = .{ .x = 0, .y = 1 } }).?;
const cell = list_cell.cell;
try testing.expect(cell.content_tag == .bg_color_rgb);
try testing.expectEqual(Cell.RGB{
.r = 155,
.g = 0,
.b = 0,
}, cell.content.color_rgb);
}
// Page 0 row 1 (active row 0) is dirty because the cursor moved off of it.
try testing.expect(s.pages.isDirty(.{ .active = .{ .x = 0, .y = 0 } }));
// Page 0 row 2 (active row 1) is dirty because it was cleared.
try testing.expect(s.pages.isDirty(.{ .active = .{ .x = 0, .y = 1 } }));
// Page 0 row 3 (active row 2) is dirty because it is new.
try testing.expect(s.pages.isDirty(.{ .active = .{ .x = 0, .y = 2 } }));
}
test "Screen: clone" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 10, 3, 10);
defer s.deinit();
try s.testWriteString("1ABCD\n2EFGH");
{
const contents = try s.dumpStringAlloc(alloc, .{ .active = .{} });
defer alloc.free(contents);
try testing.expectEqualStrings("1ABCD\n2EFGH", contents);
}
try testing.expectEqual(@as(usize, 5), s.cursor.x);
try testing.expectEqual(@as(usize, 1), s.cursor.y);
// Clone
var s2 = try s.clone(alloc, .{ .active = .{} }, null);
defer s2.deinit();
{
const contents = try s2.dumpStringAlloc(alloc, .{ .active = .{} });
defer alloc.free(contents);
try testing.expectEqualStrings("1ABCD\n2EFGH", contents);
}
try testing.expectEqual(@as(usize, 5), s2.cursor.x);
try testing.expectEqual(@as(usize, 1), s2.cursor.y);
// Write to s1, should not be in s2
try s.testWriteString("\n34567");
{
const contents = try s.dumpStringAlloc(alloc, .{ .active = .{} });
defer alloc.free(contents);
try testing.expectEqualStrings("1ABCD\n2EFGH\n34567", contents);
}
{
const contents = try s2.dumpStringAlloc(alloc, .{ .active = .{} });
defer alloc.free(contents);
try testing.expectEqualStrings("1ABCD\n2EFGH", contents);
}
try testing.expectEqual(@as(usize, 5), s2.cursor.x);
try testing.expectEqual(@as(usize, 1), s2.cursor.y);
}
test "Screen: clone partial" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 10, 3, 10);
defer s.deinit();
try s.testWriteString("1ABCD\n2EFGH");
{
const contents = try s.dumpStringAlloc(alloc, .{ .active = .{} });
defer alloc.free(contents);
try testing.expectEqualStrings("1ABCD\n2EFGH", contents);
}
try testing.expectEqual(@as(usize, 5), s.cursor.x);
try testing.expectEqual(@as(usize, 1), s.cursor.y);
// Clone
var s2 = try s.clone(alloc, .{ .active = .{ .y = 1 } }, null);
defer s2.deinit();
{
const contents = try s2.dumpStringAlloc(alloc, .{ .active = .{} });
defer alloc.free(contents);
try testing.expectEqualStrings("2EFGH", contents);
}
// Cursor is shifted since we cloned partial
try testing.expectEqual(@as(usize, 5), s2.cursor.x);
try testing.expectEqual(@as(usize, 0), s2.cursor.y);
}
test "Screen: clone partial cursor out of bounds" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 10, 3, 10);
defer s.deinit();
try s.testWriteString("1ABCD\n2EFGH");
{
const contents = try s.dumpStringAlloc(alloc, .{ .active = .{} });
defer alloc.free(contents);
try testing.expectEqualStrings("1ABCD\n2EFGH", contents);
}
try testing.expectEqual(@as(usize, 5), s.cursor.x);
try testing.expectEqual(@as(usize, 1), s.cursor.y);
// Clone
var s2 = try s.clone(
alloc,
.{ .active = .{ .y = 0 } },
.{ .active = .{ .y = 0 } },
);
defer s2.deinit();
{
const contents = try s2.dumpStringAlloc(alloc, .{ .active = .{} });
defer alloc.free(contents);
try testing.expectEqualStrings("1ABCD", contents);
}
// Cursor is shifted since we cloned partial
try testing.expectEqual(@as(usize, 0), s2.cursor.x);
try testing.expectEqual(@as(usize, 0), s2.cursor.y);
}
test "Screen: clone contains full selection" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 5, 3, 1);
defer s.deinit();
try s.testWriteString("1ABCD\n2EFGH\n3IJKL");
// Select a single line
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,
));
// Clone
var s2 = try s.clone(
alloc,
.{ .active = .{} },
null,
);
defer s2.deinit();
// Our selection should remain valid
{
const sel = s2.selection.?;
try testing.expectEqual(point.Point{ .active = .{
.x = 0,
.y = 1,
} }, s2.pages.pointFromPin(.active, sel.start()).?);
try testing.expectEqual(point.Point{ .active = .{
.x = s2.pages.cols - 1,
.y = 1,
} }, s2.pages.pointFromPin(.active, sel.end()).?);
}
}
test "Screen: clone contains none of selection" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 5, 3, 1);
defer s.deinit();
try s.testWriteString("1ABCD\n2EFGH\n3IJKL");
// Select a single line
try s.select(Selection.init(
s.pages.pin(.{ .active = .{ .x = 0, .y = 0 } }).?,
s.pages.pin(.{ .active = .{ .x = s.pages.cols - 1, .y = 0 } }).?,
false,
));
// Clone
var s2 = try s.clone(
alloc,
.{ .active = .{ .y = 1 } },
null,
);
defer s2.deinit();
// Our selection should be null
try testing.expect(s2.selection == null);
}
test "Screen: clone contains selection start cutoff" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 5, 3, 1);
defer s.deinit();
try s.testWriteString("1ABCD\n2EFGH\n3IJKL");
// Select a single line
try s.select(Selection.init(
s.pages.pin(.{ .active = .{ .x = 0, .y = 0 } }).?,
s.pages.pin(.{ .active = .{ .x = s.pages.cols - 1, .y = 1 } }).?,
false,
));
// Clone
var s2 = try s.clone(
alloc,
.{ .active = .{ .y = 1 } },
null,
);
defer s2.deinit();
// Our selection should remain valid
{
const sel = s2.selection.?;
try testing.expectEqual(point.Point{ .active = .{
.x = 0,
.y = 0,
} }, s2.pages.pointFromPin(.active, sel.start()).?);
try testing.expectEqual(point.Point{ .active = .{
.x = s2.pages.cols - 1,
.y = 0,
} }, s2.pages.pointFromPin(.active, sel.end()).?);
}
}
test "Screen: clone contains selection end cutoff" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 5, 3, 1);
defer s.deinit();
try s.testWriteString("1ABCD\n2EFGH\n3IJKL");
// Select a single line
try s.select(Selection.init(
s.pages.pin(.{ .active = .{ .x = 0, .y = 1 } }).?,
s.pages.pin(.{ .active = .{ .x = 2, .y = 2 } }).?,
false,
));
// Clone
var s2 = try s.clone(
alloc,
.{ .active = .{ .y = 0 } },
.{ .active = .{ .y = 1 } },
);
defer s2.deinit();
// Our selection should remain valid
{
const sel = s2.selection.?;
try testing.expectEqual(point.Point{ .active = .{
.x = 0,
.y = 1,
} }, s2.pages.pointFromPin(.active, sel.start()).?);
try testing.expectEqual(point.Point{ .active = .{
.x = s2.pages.cols - 1,
.y = 2,
} }, s2.pages.pointFromPin(.active, sel.end()).?);
}
}
test "Screen: clone contains selection end cutoff reversed" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 5, 3, 1);
defer s.deinit();
try s.testWriteString("1ABCD\n2EFGH\n3IJKL");
// Select a single line
try s.select(Selection.init(
s.pages.pin(.{ .active = .{ .x = 2, .y = 2 } }).?,
s.pages.pin(.{ .active = .{ .x = 0, .y = 1 } }).?,
false,
));
// Clone
var s2 = try s.clone(
alloc,
.{ .active = .{ .y = 0 } },
.{ .active = .{ .y = 1 } },
);
defer s2.deinit();
// Our selection should remain valid
{
const sel = s2.selection.?;
try testing.expectEqual(point.Point{ .active = .{
.x = 0,
.y = 1,
} }, s2.pages.pointFromPin(.active, sel.start()).?);
try testing.expectEqual(point.Point{ .active = .{
.x = s2.pages.cols - 1,
.y = 2,
} }, s2.pages.pointFromPin(.active, sel.end()).?);
}
}
test "Screen: clone contains subset of selection" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 5, 4, 1);
defer s.deinit();
try s.testWriteString("1ABCD\n2EFGH\n3IJKL\n4ABCD");
// Select the full screen
try s.select(Selection.init(
s.pages.pin(.{ .active = .{ .x = 0, .y = 0 } }).?,
s.pages.pin(.{ .active = .{ .x = 0, .y = 3 } }).?,
false,
));
// Clone
var s2 = try s.clone(
alloc,
.{ .active = .{ .y = 1 } },
.{ .active = .{ .y = 2 } },
);
defer s2.deinit();
// Our selection should remain valid
{
const sel = s2.selection.?;
try testing.expectEqual(point.Point{ .active = .{
.x = 0,
.y = 0,
} }, s2.pages.pointFromPin(.active, sel.start()).?);
try testing.expectEqual(point.Point{ .active = .{
.x = s2.pages.cols - 1,
.y = 3,
} }, s2.pages.pointFromPin(.active, sel.end()).?);
}
}
test "Screen: clone basic" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 10, 3, 0);
defer s.deinit();
try s.testWriteString("1ABCD\n2EFGH\n3IJKL");
{
var s2 = try s.clone(
alloc,
.{ .active = .{ .y = 1 } },
.{ .active = .{ .y = 1 } },
);
defer s2.deinit();
// Test our contents rotated
const contents = try s2.dumpStringAlloc(alloc, .{ .active = .{} });
defer alloc.free(contents);
try testing.expectEqualStrings("2EFGH", contents);
}
{
var s2 = try s.clone(
alloc,
.{ .active = .{ .y = 1 } },
.{ .active = .{ .y = 2 } },
);
defer s2.deinit();
// Test our contents rotated
const contents = try s2.dumpStringAlloc(alloc, .{ .active = .{} });
defer alloc.free(contents);
try testing.expectEqualStrings("2EFGH\n3IJKL", contents);
}
}
test "Screen: clone empty viewport" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 10, 3, 0);
defer s.deinit();
{
var s2 = try s.clone(
alloc,
.{ .viewport = .{ .y = 0 } },
.{ .viewport = .{ .y = 0 } },
);
defer s2.deinit();
// Test our contents rotated
const contents = try s2.dumpStringAlloc(alloc, .{ .viewport = .{} });
defer alloc.free(contents);
try testing.expectEqualStrings("", contents);
}
}
test "Screen: clone one line viewport" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 10, 3, 0);
defer s.deinit();
try s.testWriteString("1ABC");
{
var s2 = try s.clone(
alloc,
.{ .viewport = .{ .y = 0 } },
.{ .viewport = .{ .y = 0 } },
);
defer s2.deinit();
// Test our contents
const contents = try s2.dumpStringAlloc(alloc, .{ .viewport = .{} });
defer alloc.free(contents);
try testing.expectEqualStrings("1ABC", contents);
}
}
test "Screen: clone empty active" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 10, 3, 0);
defer s.deinit();
{
var s2 = try s.clone(
alloc,
.{ .active = .{ .y = 0 } },
.{ .active = .{ .y = 0 } },
);
defer s2.deinit();
// Test our contents rotated
const contents = try s2.dumpStringAlloc(alloc, .{ .active = .{} });
defer alloc.free(contents);
try testing.expectEqualStrings("", contents);
}
}
test "Screen: clone one line active with extra space" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 10, 3, 0);
defer s.deinit();
try s.testWriteString("1ABC");
{
var s2 = try s.clone(
alloc,
.{ .active = .{ .y = 0 } },
null,
);
defer s2.deinit();
// Test our contents rotated
const contents = try s2.dumpStringAlloc(alloc, .{ .active = .{} });
defer alloc.free(contents);
try testing.expectEqualStrings("1ABC", contents);
}
}
test "Screen: clear history with no history" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 10, 3, 3);
defer s.deinit();
try s.testWriteString("4ABCD\n5EFGH\n6IJKL");
try testing.expect(s.pages.viewport == .active);
s.eraseRows(.{ .history = .{} }, null);
try testing.expect(s.pages.viewport == .active);
{
// Test our contents rotated
const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} });
defer alloc.free(contents);
try testing.expectEqualStrings("4ABCD\n5EFGH\n6IJKL", contents);
}
{
// Test our contents rotated
const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} });
defer alloc.free(contents);
try testing.expectEqualStrings("4ABCD\n5EFGH\n6IJKL", contents);
}
}
test "Screen: clear history" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 10, 3, 3);
defer s.deinit();
try s.testWriteString("1ABCD\n2EFGH\n3IJKL\n4ABCD\n5EFGH\n6IJKL");
try testing.expect(s.pages.viewport == .active);
// Scroll to top
s.scroll(.{ .top = {} });
{
// Test our contents rotated
const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} });
defer alloc.free(contents);
try testing.expectEqualStrings("1ABCD\n2EFGH\n3IJKL", contents);
}
s.eraseRows(.{ .history = .{} }, null);
try testing.expect(s.pages.viewport == .active);
{
// Test our contents rotated
const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} });
defer alloc.free(contents);
try testing.expectEqualStrings("4ABCD\n5EFGH\n6IJKL", contents);
}
{
// Test our contents rotated
const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} });
defer alloc.free(contents);
try testing.expectEqualStrings("4ABCD\n5EFGH\n6IJKL", contents);
}
}
test "Screen: clear above cursor" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 10, 10, 3);
defer s.deinit();
try s.testWriteString("4ABCD\n5EFGH\n6IJKL");
s.clearRows(
.{ .active = .{ .y = 0 } },
.{ .active = .{ .y = s.cursor.y - 1 } },
false,
);
{
const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} });
defer alloc.free(contents);
try testing.expectEqualStrings("\n\n6IJKL", contents);
}
{
const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} });
defer alloc.free(contents);
try testing.expectEqualStrings("\n\n6IJKL", contents);
}
try testing.expectEqual(@as(usize, 5), s.cursor.x);
try testing.expectEqual(@as(usize, 2), s.cursor.y);
}
test "Screen: clear above cursor with history" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 10, 3, 3);
defer s.deinit();
try s.testWriteString("1ABCD\n2EFGH\n3IJKL\n");
try s.testWriteString("4ABCD\n5EFGH\n6IJKL");
s.clearRows(
.{ .active = .{ .y = 0 } },
.{ .active = .{ .y = s.cursor.y - 1 } },
false,
);
{
const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} });
defer alloc.free(contents);
try testing.expectEqualStrings("\n\n6IJKL", contents);
}
{
const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} });
defer alloc.free(contents);
try testing.expectEqualStrings("1ABCD\n2EFGH\n3IJKL\n\n\n6IJKL", contents);
}
try testing.expectEqual(@as(usize, 5), s.cursor.x);
try testing.expectEqual(@as(usize, 2), s.cursor.y);
}
test "Screen: resize (no reflow) more rows" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 10, 3, 0);
defer s.deinit();
const str = "1ABCD\n2EFGH\n3IJKL";
try s.testWriteString(str);
// Resize
try s.resizeWithoutReflow(10, 10);
{
const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} });
defer alloc.free(contents);
try testing.expectEqualStrings(str, contents);
}
}
test "Screen: resize (no reflow) less rows" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 10, 3, 0);
defer s.deinit();
const str = "1ABCD\n2EFGH\n3IJKL";
try s.testWriteString(str);
try testing.expectEqual(5, s.cursor.x);
try testing.expectEqual(2, s.cursor.y);
try s.resizeWithoutReflow(10, 2);
// Since we shrunk, we should adjust our cursor
try testing.expectEqual(5, s.cursor.x);
try testing.expectEqual(1, s.cursor.y);
{
const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} });
defer alloc.free(contents);
try testing.expectEqualStrings("2EFGH\n3IJKL", contents);
}
}
test "Screen: resize (no reflow) less rows trims blank lines" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 10, 3, 0);
defer s.deinit();
const str = "1ABCD";
try s.testWriteString(str);
// Write only a background color into the remaining rows
for (1..s.pages.rows) |y| {
const list_cell = s.pages.getCell(.{ .active = .{
.x = 0,
.y = @intCast(y),
} }).?;
list_cell.cell.* = .{
.content_tag = .bg_color_rgb,
.content = .{ .color_rgb = .{ .r = 0xFF, .g = 0, .b = 0 } },
};
}
const cursor = s.cursor;
try s.resizeWithoutReflow(6, 2);
// Cursor should not move
try testing.expectEqual(cursor.x, s.cursor.x);
try testing.expectEqual(cursor.y, s.cursor.y);
{
const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} });
defer alloc.free(contents);
try testing.expectEqualStrings("1ABCD", contents);
}
}
test "Screen: resize (no reflow) more rows trims blank lines" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 10, 3, 0);
defer s.deinit();
const str = "1ABCD";
try s.testWriteString(str);
// Write only a background color into the remaining rows
for (1..s.pages.rows) |y| {
const list_cell = s.pages.getCell(.{ .active = .{
.x = 0,
.y = @intCast(y),
} }).?;
list_cell.cell.* = .{
.content_tag = .bg_color_rgb,
.content = .{ .color_rgb = .{ .r = 0xFF, .g = 0, .b = 0 } },
};
}
const cursor = s.cursor;
try s.resizeWithoutReflow(10, 7);
// Cursor should not move
try testing.expectEqual(cursor.x, s.cursor.x);
try testing.expectEqual(cursor.y, s.cursor.y);
{
const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} });
defer alloc.free(contents);
try testing.expectEqualStrings("1ABCD", contents);
}
}
test "Screen: resize (no reflow) more cols" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 10, 3, 0);
defer s.deinit();
const str = "1ABCD\n2EFGH\n3IJKL";
try s.testWriteString(str);
try s.resizeWithoutReflow(20, 3);
{
const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} });
defer alloc.free(contents);
try testing.expectEqualStrings(str, contents);
}
}
test "Screen: resize (no reflow) less cols" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 10, 3, 0);
defer s.deinit();
const str = "1ABCD\n2EFGH\n3IJKL";
try s.testWriteString(str);
try s.resizeWithoutReflow(4, 3);
{
const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} });
defer alloc.free(contents);
const expected = "1ABC\n2EFG\n3IJK";
try testing.expectEqualStrings(expected, contents);
}
}
test "Screen: resize (no reflow) more rows with scrollback cursor end" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 7, 3, 2);
defer s.deinit();
const str = "1ABCD\n2EFGH\n3IJKL\n4ABCD\n5EFGH";
try s.testWriteString(str);
try s.resizeWithoutReflow(7, 10);
{
const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} });
defer alloc.free(contents);
try testing.expectEqualStrings(str, contents);
}
}
test "Screen: resize (no reflow) less rows with scrollback" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 7, 3, 2);
defer s.deinit();
const str = "1ABCD\n2EFGH\n3IJKL\n4ABCD\n5EFGH";
try s.testWriteString(str);
try s.resizeWithoutReflow(7, 2);
{
const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} });
defer alloc.free(contents);
const expected = "4ABCD\n5EFGH";
try testing.expectEqualStrings(expected, contents);
}
}
// https://github.com/mitchellh/ghostty/issues/1030
test "Screen: resize (no reflow) less rows with empty trailing" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 5, 3, 5);
defer s.deinit();
const str = "1\n2\n3\n4\n5\n6\n7\n8";
try s.testWriteString(str);
try s.scrollClear();
s.cursorAbsolute(0, 0);
try s.testWriteString("A\nB");
const cursor = s.cursor;
try s.resizeWithoutReflow(5, 2);
try testing.expectEqual(cursor.x, s.cursor.x);
try testing.expectEqual(cursor.y, s.cursor.y);
{
const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} });
defer alloc.free(contents);
try testing.expectEqualStrings("A\nB", contents);
}
}
test "Screen: resize (no reflow) more rows with soft wrapping" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 2, 3, 3);
defer s.deinit();
const str = "1A2B\n3C4E\n5F6G";
try s.testWriteString(str);
// Every second row should be wrapped
for (0..6) |y| {
const list_cell = s.pages.getCell(.{ .screen = .{
.x = 0,
.y = @intCast(y),
} }).?;
const row = list_cell.row;
const wrapped = (y % 2 == 0);
try testing.expectEqual(wrapped, row.wrap);
}
// Resize
try s.resizeWithoutReflow(2, 10);
{
const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} });
defer alloc.free(contents);
const expected = "1A\n2B\n3C\n4E\n5F\n6G";
try testing.expectEqualStrings(expected, contents);
}
// Every second row should be wrapped
for (0..6) |y| {
const list_cell = s.pages.getCell(.{ .screen = .{
.x = 0,
.y = @intCast(y),
} }).?;
const row = list_cell.row;
const wrapped = (y % 2 == 0);
try testing.expectEqual(wrapped, row.wrap);
}
}
test "Screen: resize more rows no scrollback" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 5, 3, 0);
defer s.deinit();
const str = "1ABCD\n2EFGH\n3IJKL";
try s.testWriteString(str);
const cursor = s.cursor;
try s.resize(5, 10);
// Cursor should not move
try testing.expectEqual(cursor.x, s.cursor.x);
try testing.expectEqual(cursor.y, s.cursor.y);
{
const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} });
defer alloc.free(contents);
try testing.expectEqualStrings(str, contents);
}
{
const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} });
defer alloc.free(contents);
try testing.expectEqualStrings(str, contents);
}
}
test "Screen: resize more rows with empty scrollback" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 5, 3, 10);
defer s.deinit();
const str = "1ABCD\n2EFGH\n3IJKL";
try s.testWriteString(str);
const cursor = s.cursor;
try s.resize(5, 10);
// Cursor should not move
try testing.expectEqual(cursor.x, s.cursor.x);
try testing.expectEqual(cursor.y, s.cursor.y);
{
const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} });
defer alloc.free(contents);
try testing.expectEqualStrings(str, contents);
}
{
const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} });
defer alloc.free(contents);
try testing.expectEqualStrings(str, contents);
}
}
test "Screen: resize more rows with populated scrollback" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 5, 3, 5);
defer s.deinit();
const str = "1ABCD\n2EFGH\n3IJKL\n4ABCD\n5EFGH";
try s.testWriteString(str);
{
const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} });
defer alloc.free(contents);
const expected = "3IJKL\n4ABCD\n5EFGH";
try testing.expectEqualStrings(expected, contents);
}
// Set our cursor to be on the "4"
s.cursorAbsolute(0, 1);
{
const list_cell = s.pages.getCell(.{ .active = .{
.x = s.cursor.x,
.y = s.cursor.y,
} }).?;
try testing.expectEqual(@as(u21, '4'), list_cell.cell.content.codepoint);
}
// Resize
try s.resize(5, 10);
// Cursor should still be on the "4"
{
const list_cell = s.pages.getCell(.{ .active = .{
.x = s.cursor.x,
.y = s.cursor.y,
} }).?;
try testing.expectEqual(@as(u21, '4'), list_cell.cell.content.codepoint);
}
{
const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} });
defer alloc.free(contents);
const expected = "3IJKL\n4ABCD\n5EFGH";
try testing.expectEqualStrings(expected, contents);
}
}
test "Screen: resize more cols no reflow" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 5, 3, 0);
defer s.deinit();
const str = "1ABCD\n2EFGH\n3IJKL";
try s.testWriteString(str);
const cursor = s.cursor;
try s.resize(10, 3);
// Cursor should not move
try testing.expectEqual(cursor.x, s.cursor.x);
try testing.expectEqual(cursor.y, s.cursor.y);
{
const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} });
defer alloc.free(contents);
try testing.expectEqualStrings(str, contents);
}
{
const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} });
defer alloc.free(contents);
try testing.expectEqualStrings(str, contents);
}
}
// https://github.com/mitchellh/ghostty/issues/272#issuecomment-1676038963
test "Screen: resize more cols perfect split" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 5, 3, 0);
defer s.deinit();
const str = "1ABCD2EFGH3IJKL";
try s.testWriteString(str);
try s.resize(10, 3);
{
const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} });
defer alloc.free(contents);
try testing.expectEqualStrings("1ABCD2EFGH\n3IJKL", contents);
}
}
// https://github.com/mitchellh/ghostty/issues/1159
test "Screen: resize (no reflow) more cols with scrollback scrolled up" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 5, 3, 5);
defer s.deinit();
const str = "1\n2\n3\n4\n5\n6\n7\n8";
try s.testWriteString(str);
// Cursor at bottom
try testing.expectEqual(@as(size.CellCountInt, 1), s.cursor.x);
try testing.expectEqual(@as(size.CellCountInt, 2), s.cursor.y);
s.scroll(.{ .delta_row = -4 });
{
const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} });
defer alloc.free(contents);
try testing.expectEqualStrings("2\n3\n4", contents);
}
try s.resize(8, 3);
{
const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} });
defer alloc.free(contents);
try testing.expectEqualStrings(str, contents);
}
// Cursor remains at bottom
try testing.expectEqual(@as(size.CellCountInt, 1), s.cursor.x);
try testing.expectEqual(@as(size.CellCountInt, 2), s.cursor.y);
}
// https://github.com/mitchellh/ghostty/issues/1159
test "Screen: resize (no reflow) less cols with scrollback scrolled up" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 5, 3, 5);
defer s.deinit();
const str = "1\n2\n3\n4\n5\n6\n7\n8";
try s.testWriteString(str);
// Cursor at bottom
try testing.expectEqual(@as(size.CellCountInt, 1), s.cursor.x);
try testing.expectEqual(@as(size.CellCountInt, 2), s.cursor.y);
s.scroll(.{ .delta_row = -4 });
{
const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} });
defer alloc.free(contents);
try testing.expectEqualStrings("2\n3\n4", contents);
}
try s.resize(4, 3);
{
const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} });
defer alloc.free(contents);
try testing.expectEqualStrings(str, contents);
}
{
const contents = try s.dumpStringAlloc(alloc, .{ .active = .{} });
defer alloc.free(contents);
try testing.expectEqualStrings("6\n7\n8", contents);
}
// Cursor remains at bottom
try testing.expectEqual(@as(size.CellCountInt, 1), s.cursor.x);
try testing.expectEqual(@as(size.CellCountInt, 2), s.cursor.y);
// Old implementation doesn't do this but it makes sense to me:
// {
// const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} });
// defer alloc.free(contents);
// try testing.expectEqualStrings("2\n3\n4", contents);
// }
}
test "Screen: resize more cols no reflow preserves semantic prompt" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 5, 3, 0);
defer s.deinit();
const str = "1ABCD\n2EFGH\n3IJKL";
try s.testWriteString(str);
// Set one of the rows to be a prompt
{
s.cursorAbsolute(0, 1);
s.cursor.page_row.semantic_prompt = .prompt;
}
try s.resize(10, 3);
{
const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} });
defer alloc.free(contents);
try testing.expectEqualStrings(str, contents);
}
{
const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} });
defer alloc.free(contents);
try testing.expectEqualStrings(str, contents);
}
// Our one row should still be a semantic prompt, the others should not.
{
const list_cell = s.pages.getCell(.{ .active = .{ .x = 0, .y = 0 } }).?;
try testing.expect(list_cell.row.semantic_prompt == .unknown);
}
{
const list_cell = s.pages.getCell(.{ .active = .{ .x = 0, .y = 1 } }).?;
try testing.expect(list_cell.row.semantic_prompt == .prompt);
}
{
const list_cell = s.pages.getCell(.{ .active = .{ .x = 0, .y = 2 } }).?;
try testing.expect(list_cell.row.semantic_prompt == .unknown);
}
}
test "Screen: resize more cols with reflow that fits full width" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 5, 3, 0);
defer s.deinit();
const str = "1ABCD2EFGH\n3IJKL";
try s.testWriteString(str);
// Verify we soft wrapped
{
const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} });
defer alloc.free(contents);
const expected = "1ABCD\n2EFGH\n3IJKL";
try testing.expectEqualStrings(expected, contents);
}
// Let's put our cursor on row 2, where the soft wrap is
s.cursorAbsolute(0, 1);
{
const list_cell = s.pages.getCell(.{ .active = .{
.x = s.cursor.x,
.y = s.cursor.y,
} }).?;
try testing.expectEqual(@as(u21, '2'), list_cell.cell.content.codepoint);
}
// Resize and verify we undid the soft wrap because we have space now
try s.resize(10, 3);
{
const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} });
defer alloc.free(contents);
try testing.expectEqualStrings(str, contents);
}
// Our cursor should've moved
try testing.expectEqual(@as(usize, 5), s.cursor.x);
try testing.expectEqual(@as(usize, 0), s.cursor.y);
}
test "Screen: resize more cols with reflow that ends in newline" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 6, 3, 0);
defer s.deinit();
const str = "1ABCD2EFGH\n3IJKL";
try s.testWriteString(str);
// Verify we soft wrapped
{
const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} });
defer alloc.free(contents);
const expected = "1ABCD2\nEFGH\n3IJKL";
try testing.expectEqualStrings(expected, contents);
}
// Let's put our cursor on the last row
s.cursorAbsolute(0, 2);
{
const list_cell = s.pages.getCell(.{ .active = .{
.x = s.cursor.x,
.y = s.cursor.y,
} }).?;
try testing.expectEqual(@as(u21, '3'), list_cell.cell.content.codepoint);
}
// Resize and verify we undid the soft wrap because we have space now
try s.resize(10, 3);
{
const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} });
defer alloc.free(contents);
try testing.expectEqualStrings(str, contents);
}
// Our cursor should still be on the 3
{
const list_cell = s.pages.getCell(.{ .active = .{
.x = s.cursor.x,
.y = s.cursor.y,
} }).?;
try testing.expectEqual(@as(u21, '3'), list_cell.cell.content.codepoint);
}
}
test "Screen: resize more cols with reflow that forces more wrapping" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 5, 3, 0);
defer s.deinit();
const str = "1ABCD2EFGH\n3IJKL";
try s.testWriteString(str);
// Let's put our cursor on row 2, where the soft wrap is
s.cursorAbsolute(0, 1);
{
const list_cell = s.pages.getCell(.{ .active = .{
.x = s.cursor.x,
.y = s.cursor.y,
} }).?;
try testing.expectEqual(@as(u21, '2'), list_cell.cell.content.codepoint);
}
// Verify we soft wrapped
{
const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} });
defer alloc.free(contents);
const expected = "1ABCD\n2EFGH\n3IJKL";
try testing.expectEqualStrings(expected, contents);
}
// Resize and verify we undid the soft wrap because we have space now
try s.resize(7, 3);
{
const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} });
defer alloc.free(contents);
const expected = "1ABCD2E\nFGH\n3IJKL";
try testing.expectEqualStrings(expected, contents);
}
// Our cursor should've moved
try testing.expectEqual(@as(size.CellCountInt, 5), s.cursor.x);
try testing.expectEqual(@as(size.CellCountInt, 0), s.cursor.y);
}
test "Screen: resize more cols with reflow that unwraps multiple times" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 5, 3, 0);
defer s.deinit();
const str = "1ABCD2EFGH3IJKL";
try s.testWriteString(str);
// Let's put our cursor on row 2, where the soft wrap is
s.cursorAbsolute(0, 2);
{
const list_cell = s.pages.getCell(.{ .active = .{
.x = s.cursor.x,
.y = s.cursor.y,
} }).?;
try testing.expectEqual(@as(u21, '3'), list_cell.cell.content.codepoint);
}
// Verify we soft wrapped
{
const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} });
defer alloc.free(contents);
const expected = "1ABCD\n2EFGH\n3IJKL";
try testing.expectEqualStrings(expected, contents);
}
// Resize and verify we undid the soft wrap because we have space now
try s.resize(15, 3);
{
const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} });
defer alloc.free(contents);
const expected = "1ABCD2EFGH3IJKL";
try testing.expectEqualStrings(expected, contents);
}
// Our cursor should've moved
try testing.expectEqual(@as(size.CellCountInt, 10), s.cursor.x);
try testing.expectEqual(@as(size.CellCountInt, 0), s.cursor.y);
}
test "Screen: resize more cols with populated scrollback" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 5, 3, 5);
defer s.deinit();
const str = "1ABCD\n2EFGH\n3IJKL\n4ABCD5EFGH";
try s.testWriteString(str);
{
const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} });
defer alloc.free(contents);
const expected = "3IJKL\n4ABCD\n5EFGH";
try testing.expectEqualStrings(expected, contents);
}
// // Set our cursor to be on the "5"
s.cursorAbsolute(0, 2);
{
const list_cell = s.pages.getCell(.{ .active = .{
.x = s.cursor.x,
.y = s.cursor.y,
} }).?;
try testing.expectEqual(@as(u21, '5'), list_cell.cell.content.codepoint);
}
// Resize
try s.resize(10, 3);
{
const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} });
defer alloc.free(contents);
const expected = "2EFGH\n3IJKL\n4ABCD5EFGH";
try testing.expectEqualStrings(expected, contents);
}
// Cursor should still be on the "5"
{
const list_cell = s.pages.getCell(.{ .active = .{
.x = s.cursor.x,
.y = s.cursor.y,
} }).?;
try testing.expectEqual(@as(u21, '5'), list_cell.cell.content.codepoint);
}
}
test "Screen: resize more cols with reflow" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 2, 3, 5);
defer s.deinit();
const str = "1ABC\n2DEF\n3ABC\n4DEF";
try s.testWriteString(str);
// Let's put our cursor on row 2, where the soft wrap is
s.cursorAbsolute(0, 2);
{
const list_cell = s.pages.getCell(.{ .active = .{
.x = s.cursor.x,
.y = s.cursor.y,
} }).?;
try testing.expectEqual(@as(u32, 'E'), list_cell.cell.content.codepoint);
}
// Verify we soft wrapped
{
const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} });
defer alloc.free(contents);
const expected = "BC\n4D\nEF";
try testing.expectEqualStrings(expected, contents);
}
// Resize and verify we undid the soft wrap because we have space now
try s.resize(7, 3);
{
const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} });
defer alloc.free(contents);
const expected = "1ABC\n2DEF\n3ABC\n4DEF";
try testing.expectEqualStrings(expected, contents);
}
// Our cursor should've moved
try testing.expectEqual(@as(size.CellCountInt, 2), s.cursor.x);
try testing.expectEqual(@as(size.CellCountInt, 2), s.cursor.y);
}
test "Screen: resize more rows and cols with wrapping" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 2, 4, 0);
defer s.deinit();
const str = "1A2B\n3C4D";
try s.testWriteString(str);
{
const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} });
defer alloc.free(contents);
const expected = "1A\n2B\n3C\n4D";
try testing.expectEqualStrings(expected, contents);
}
try s.resize(5, 10);
// Cursor should move due to wrapping
try testing.expectEqual(@as(size.CellCountInt, 3), s.cursor.x);
try testing.expectEqual(@as(size.CellCountInt, 1), s.cursor.y);
{
const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} });
defer alloc.free(contents);
try testing.expectEqualStrings(str, contents);
}
{
const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} });
defer alloc.free(contents);
try testing.expectEqualStrings(str, contents);
}
}
test "Screen: resize less rows no scrollback" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 5, 3, 0);
defer s.deinit();
const str = "1ABCD\n2EFGH\n3IJKL";
try s.testWriteString(str);
s.cursorAbsolute(0, 0);
const cursor = s.cursor;
try s.resize(5, 1);
// Cursor should not move
try testing.expectEqual(cursor.x, s.cursor.x);
try testing.expectEqual(cursor.y, s.cursor.y);
{
const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} });
defer alloc.free(contents);
const expected = "3IJKL";
try testing.expectEqualStrings(expected, contents);
}
{
const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} });
defer alloc.free(contents);
const expected = "3IJKL";
try testing.expectEqualStrings(expected, contents);
}
}
test "Screen: resize less rows moving cursor" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 5, 3, 0);
defer s.deinit();
const str = "1ABCD\n2EFGH\n3IJKL";
try s.testWriteString(str);
// Put our cursor on the last line
s.cursorAbsolute(1, 2);
{
const list_cell = s.pages.getCell(.{ .active = .{
.x = s.cursor.x,
.y = s.cursor.y,
} }).?;
try testing.expectEqual(@as(u32, 'I'), list_cell.cell.content.codepoint);
}
// Resize
try s.resize(5, 1);
{
const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} });
defer alloc.free(contents);
const expected = "3IJKL";
try testing.expectEqualStrings(expected, contents);
}
{
const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} });
defer alloc.free(contents);
const expected = "3IJKL";
try testing.expectEqualStrings(expected, contents);
}
// Cursor should be on the last line
try testing.expectEqual(@as(size.CellCountInt, 1), s.cursor.x);
try testing.expectEqual(@as(size.CellCountInt, 0), s.cursor.y);
}
test "Screen: resize less rows with empty scrollback" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 5, 3, 10);
defer s.deinit();
const str = "1ABCD\n2EFGH\n3IJKL";
try s.testWriteString(str);
try s.resize(5, 1);
{
const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} });
defer alloc.free(contents);
try testing.expectEqualStrings(str, contents);
}
{
const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} });
defer alloc.free(contents);
const expected = "3IJKL";
try testing.expectEqualStrings(expected, contents);
}
}
test "Screen: resize less rows with populated scrollback" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 5, 3, 5);
defer s.deinit();
const str = "1ABCD\n2EFGH\n3IJKL\n4ABCD\n5EFGH";
try s.testWriteString(str);
{
const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} });
defer alloc.free(contents);
const expected = "3IJKL\n4ABCD\n5EFGH";
try testing.expectEqualStrings(expected, contents);
}
// Resize
try s.resize(5, 1);
{
const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} });
defer alloc.free(contents);
try testing.expectEqualStrings(str, contents);
}
{
const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} });
defer alloc.free(contents);
const expected = "5EFGH";
try testing.expectEqualStrings(expected, contents);
}
}
test "Screen: resize less rows with full scrollback" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 5, 3, 3);
defer s.deinit();
const str = "00000\n1ABCD\n2EFGH\n3IJKL\n4ABCD\n5EFGH";
try s.testWriteString(str);
{
const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} });
defer alloc.free(contents);
const expected = "3IJKL\n4ABCD\n5EFGH";
try testing.expectEqualStrings(expected, contents);
}
try testing.expectEqual(@as(size.CellCountInt, 4), s.cursor.x);
try testing.expectEqual(@as(size.CellCountInt, 2), s.cursor.y);
// Resize
try s.resize(5, 2);
// Cursor should stay in the same relative place (bottom of the
// screen, same character).
try testing.expectEqual(@as(size.CellCountInt, 4), s.cursor.x);
try testing.expectEqual(@as(size.CellCountInt, 1), s.cursor.y);
{
const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} });
defer alloc.free(contents);
const expected = "00000\n1ABCD\n2EFGH\n3IJKL\n4ABCD\n5EFGH";
try testing.expectEqualStrings(expected, contents);
}
{
const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} });
defer alloc.free(contents);
const expected = "4ABCD\n5EFGH";
try testing.expectEqualStrings(expected, contents);
}
}
test "Screen: resize less cols no reflow" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 5, 3, 0);
defer s.deinit();
const str = "1AB\n2EF\n3IJ";
try s.testWriteString(str);
s.cursorAbsolute(0, 0);
const cursor = s.cursor;
try s.resize(3, 3);
// Cursor should not move
try testing.expectEqual(cursor.x, s.cursor.x);
try testing.expectEqual(cursor.y, s.cursor.y);
{
const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} });
defer alloc.free(contents);
try testing.expectEqualStrings(str, contents);
}
{
const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} });
defer alloc.free(contents);
try testing.expectEqualStrings(str, contents);
}
}
test "Screen: resize less cols with reflow but row space" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 5, 3, 1);
defer s.deinit();
const str = "1ABCD";
try s.testWriteString(str);
// Put our cursor on the end
s.cursorAbsolute(4, 0);
{
const list_cell = s.pages.getCell(.{ .active = .{
.x = s.cursor.x,
.y = s.cursor.y,
} }).?;
try testing.expectEqual(@as(u32, 'D'), list_cell.cell.content.codepoint);
}
try s.resize(3, 3);
{
const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} });
defer alloc.free(contents);
const expected = "1AB\nCD";
try testing.expectEqualStrings(expected, contents);
}
{
const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} });
defer alloc.free(contents);
const expected = "1AB\nCD";
try testing.expectEqualStrings(expected, contents);
}
// Cursor should be on the last line
try testing.expectEqual(@as(size.CellCountInt, 1), s.cursor.x);
try testing.expectEqual(@as(size.CellCountInt, 1), s.cursor.y);
}
test "Screen: resize less cols with reflow with trimmed rows" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 5, 3, 0);
defer s.deinit();
const str = "3IJKL\n4ABCD\n5EFGH";
try s.testWriteString(str);
try s.resize(3, 3);
{
const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} });
defer alloc.free(contents);
const expected = "CD\n5EF\nGH";
try testing.expectEqualStrings(expected, contents);
}
{
const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} });
defer alloc.free(contents);
const expected = "CD\n5EF\nGH";
try testing.expectEqualStrings(expected, contents);
}
}
test "Screen: resize less cols with reflow with trimmed rows and scrollback" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 5, 3, 1);
defer s.deinit();
const str = "3IJKL\n4ABCD\n5EFGH";
try s.testWriteString(str);
try s.resize(3, 3);
{
const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} });
defer alloc.free(contents);
const expected = "CD\n5EF\nGH";
try testing.expectEqualStrings(expected, contents);
}
{
const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} });
defer alloc.free(contents);
const expected = "3IJ\nKL\n4AB\nCD\n5EF\nGH";
try testing.expectEqualStrings(expected, contents);
}
}
test "Screen: resize less cols with reflow previously wrapped" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 5, 3, 0);
defer s.deinit();
const str = "3IJKL4ABCD5EFGH";
try s.testWriteString(str);
// Check
{
const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} });
defer alloc.free(contents);
const expected = "3IJKL\n4ABCD\n5EFGH";
try testing.expectEqualStrings(expected, contents);
}
try s.resize(3, 3);
// {
// const contents = try s.testString(alloc, .viewport);
// defer alloc.free(contents);
// const expected = "CD\n5EF\nGH";
// try testing.expectEqualStrings(expected, contents);
// }
{
const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} });
defer alloc.free(contents);
const expected = "ABC\nD5E\nFGH";
try testing.expectEqualStrings(expected, contents);
}
}
test "Screen: resize less cols with reflow and scrollback" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 5, 3, 5);
defer s.deinit();
const str = "1A\n2B\n3C\n4D\n5E";
try s.testWriteString(str);
// Put our cursor on the end
s.cursorAbsolute(1, s.pages.rows - 1);
{
const list_cell = s.pages.getCell(.{ .active = .{
.x = s.cursor.x,
.y = s.cursor.y,
} }).?;
try testing.expectEqual(@as(u32, 'E'), list_cell.cell.content.codepoint);
}
try s.resize(3, 3);
{
const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} });
defer alloc.free(contents);
const expected = "3C\n4D\n5E";
try testing.expectEqualStrings(expected, contents);
}
// Cursor should be on the last line
try testing.expectEqual(@as(size.CellCountInt, 1), s.cursor.x);
try testing.expectEqual(@as(size.CellCountInt, 2), s.cursor.y);
}
test "Screen: resize less cols with reflow previously wrapped and scrollback" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 5, 3, 2);
defer s.deinit();
const str = "1ABCD2EFGH3IJKL4ABCD5EFGH";
try s.testWriteString(str);
// Check
{
const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} });
defer alloc.free(contents);
const expected = "3IJKL\n4ABCD\n5EFGH";
try testing.expectEqualStrings(expected, contents);
}
// Put our cursor on the end
s.cursorAbsolute(s.pages.cols - 1, s.pages.rows - 1);
{
const list_cell = s.pages.getCell(.{ .active = .{
.x = s.cursor.x,
.y = s.cursor.y,
} }).?;
try testing.expectEqual(@as(u32, 'H'), list_cell.cell.content.codepoint);
}
try s.resize(3, 3);
{
const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} });
defer alloc.free(contents);
const expected = "CD5\nEFG\nH";
try testing.expectEqualStrings(expected, contents);
}
{
const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} });
defer alloc.free(contents);
const expected = "1AB\nCD2\nEFG\nH3I\nJKL\n4AB\nCD5\nEFG\nH";
try testing.expectEqualStrings(expected, contents);
}
// Cursor should be on the last line
try testing.expectEqual(@as(size.CellCountInt, 0), s.cursor.x);
try testing.expectEqual(@as(size.CellCountInt, 2), s.cursor.y);
{
const list_cell = s.pages.getCell(.{ .active = .{
.x = s.cursor.x,
.y = s.cursor.y,
} }).?;
try testing.expectEqual(@as(u32, 'H'), list_cell.cell.content.codepoint);
}
}
test "Screen: resize less cols with scrollback keeps cursor row" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 5, 3, 5);
defer s.deinit();
const str = "1A\n2B\n3C\n4D\n5E";
try s.testWriteString(str);
// Lets do a scroll and clear operation
try s.scrollClear();
// Move our cursor to the beginning
s.cursorAbsolute(0, 0);
try s.resize(3, 3);
{
const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} });
defer alloc.free(contents);
const expected = "";
try testing.expectEqualStrings(expected, contents);
}
// Cursor should be on the last line
try testing.expectEqual(@as(size.CellCountInt, 0), s.cursor.x);
try testing.expectEqual(@as(size.CellCountInt, 0), s.cursor.y);
}
test "Screen: resize more rows, less cols with reflow with scrollback" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 5, 3, 3);
defer s.deinit();
const str = "1ABCD\n2EFGH3IJKL\n4MNOP";
try s.testWriteString(str);
{
const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} });
defer alloc.free(contents);
const expected = "1ABCD\n2EFGH\n3IJKL\n4MNOP";
try testing.expectEqualStrings(expected, contents);
}
{
const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} });
defer alloc.free(contents);
const expected = "2EFGH\n3IJKL\n4MNOP";
try testing.expectEqualStrings(expected, contents);
}
try s.resize(2, 10);
{
const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} });
defer alloc.free(contents);
const expected = "BC\nD\n2E\nFG\nH3\nIJ\nKL\n4M\nNO\nP";
try testing.expectEqualStrings(expected, contents);
}
{
const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} });
defer alloc.free(contents);
const expected = "1A\nBC\nD\n2E\nFG\nH3\nIJ\nKL\n4M\nNO\nP";
try testing.expectEqualStrings(expected, contents);
}
}
// This seems like it should work fine but for some reason in practice
// in the initial implementation I found this bug! This is a regression
// test for that.
test "Screen: resize more rows then shrink again" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 5, 3, 10);
defer s.deinit();
const str = "1ABC";
try s.testWriteString(str);
// Grow
try s.resize(5, 10);
{
const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} });
defer alloc.free(contents);
try testing.expectEqualStrings(str, contents);
}
{
const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} });
defer alloc.free(contents);
try testing.expectEqualStrings(str, contents);
}
// Shrink
try s.resize(5, 3);
{
const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} });
defer alloc.free(contents);
try testing.expectEqualStrings(str, contents);
}
{
const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} });
defer alloc.free(contents);
try testing.expectEqualStrings(str, contents);
}
// Grow again
try s.resize(5, 10);
{
const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} });
defer alloc.free(contents);
try testing.expectEqualStrings(str, contents);
}
{
const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} });
defer alloc.free(contents);
try testing.expectEqualStrings(str, contents);
}
}
test "Screen: resize less cols to eliminate wide char" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 2, 1, 0);
defer s.deinit();
const str = "😀";
try s.testWriteString(str);
{
const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} });
defer alloc.free(contents);
try testing.expectEqualStrings(str, contents);
}
{
const list_cell = s.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?;
const cell = list_cell.cell;
try testing.expectEqual(Cell.Wide.wide, cell.wide);
try testing.expectEqual(@as(u21, '😀'), cell.content.codepoint);
}
// Resize to 1 column can't fit a wide char. So it should be deleted.
try s.resize(1, 1);
{
const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} });
defer alloc.free(contents);
try testing.expectEqualStrings("", contents);
}
{
const list_cell = s.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?;
const cell = list_cell.cell;
try testing.expectEqual(@as(u21, 0), cell.content.codepoint);
try testing.expectEqual(Cell.Wide.narrow, cell.wide);
}
}
test "Screen: resize less cols to wrap wide char" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 3, 3, 0);
defer s.deinit();
const str = "x😀";
try s.testWriteString(str);
{
const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} });
defer alloc.free(contents);
try testing.expectEqualStrings(str, contents);
}
{
const list_cell = s.pages.getCell(.{ .screen = .{ .x = 1, .y = 0 } }).?;
const cell = list_cell.cell;
try testing.expectEqual(Cell.Wide.wide, cell.wide);
try testing.expectEqual(@as(u21, '😀'), cell.content.codepoint);
}
{
const list_cell = s.pages.getCell(.{ .screen = .{ .x = 2, .y = 0 } }).?;
const cell = list_cell.cell;
try testing.expectEqual(Cell.Wide.spacer_tail, cell.wide);
}
try s.resize(2, 3);
{
const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} });
defer alloc.free(contents);
try testing.expectEqualStrings("x\n😀", contents);
}
{
const list_cell = s.pages.getCell(.{ .screen = .{ .x = 1, .y = 0 } }).?;
const cell = list_cell.cell;
try testing.expectEqual(Cell.Wide.spacer_head, cell.wide);
try testing.expect(list_cell.row.wrap);
}
}
test "Screen: resize less cols to eliminate wide char with row space" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 2, 2, 0);
defer s.deinit();
const str = "😀";
try s.testWriteString(str);
{
const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} });
defer alloc.free(contents);
try testing.expectEqualStrings(str, contents);
}
{
const list_cell = s.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?;
const cell = list_cell.cell;
try testing.expectEqual(Cell.Wide.wide, cell.wide);
try testing.expectEqual(@as(u21, '😀'), cell.content.codepoint);
}
{
const list_cell = s.pages.getCell(.{ .screen = .{ .x = 1, .y = 0 } }).?;
const cell = list_cell.cell;
try testing.expectEqual(Cell.Wide.spacer_tail, cell.wide);
}
try s.resize(1, 2);
{
const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} });
defer alloc.free(contents);
try testing.expectEqualStrings("", contents);
}
}
test "Screen: resize more cols with wide spacer head" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 3, 2, 0);
defer s.deinit();
const str = " 😀";
try s.testWriteString(str);
{
const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} });
defer alloc.free(contents);
try testing.expectEqualStrings(" \n😀", contents);
}
// So this is the key point: we end up with a wide spacer head at
// the end of row 1, then the emoji, then a wide spacer tail on row 2.
// We should expect that if we resize to more cols, the wide spacer
// head is replaced with the emoji.
{
const list_cell = s.pages.getCell(.{ .screen = .{ .x = 2, .y = 0 } }).?;
const cell = list_cell.cell;
try testing.expectEqual(Cell.Wide.spacer_head, cell.wide);
}
{
const list_cell = s.pages.getCell(.{ .screen = .{ .x = 0, .y = 1 } }).?;
const cell = list_cell.cell;
try testing.expectEqual(Cell.Wide.wide, cell.wide);
}
{
const list_cell = s.pages.getCell(.{ .screen = .{ .x = 1, .y = 1 } }).?;
const cell = list_cell.cell;
try testing.expectEqual(Cell.Wide.spacer_tail, cell.wide);
}
try s.resize(4, 2);
{
const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} });
defer alloc.free(contents);
try testing.expectEqualStrings(str, contents);
}
{
const list_cell = s.pages.getCell(.{ .screen = .{ .x = 2, .y = 0 } }).?;
const cell = list_cell.cell;
try testing.expectEqual(Cell.Wide.wide, cell.wide);
try testing.expectEqual(@as(u21, '😀'), cell.content.codepoint);
}
{
const list_cell = s.pages.getCell(.{ .screen = .{ .x = 3, .y = 0 } }).?;
const cell = list_cell.cell;
try testing.expectEqual(Cell.Wide.spacer_tail, cell.wide);
}
}
test "Screen: resize more cols with wide spacer head multiple lines" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 3, 3, 0);
defer s.deinit();
const str = "xxxyy😀";
try s.testWriteString(str);
{
const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} });
defer alloc.free(contents);
try testing.expectEqualStrings("xxx\nyy\n😀", contents);
}
// Similar to the "wide spacer head" test, but this time we'er going
// to increase our columns such that multiple rows are unwrapped.
{
const list_cell = s.pages.getCell(.{ .screen = .{ .x = 2, .y = 1 } }).?;
const cell = list_cell.cell;
try testing.expectEqual(Cell.Wide.spacer_head, cell.wide);
}
{
const list_cell = s.pages.getCell(.{ .screen = .{ .x = 0, .y = 2 } }).?;
const cell = list_cell.cell;
try testing.expectEqual(Cell.Wide.wide, cell.wide);
}
{
const list_cell = s.pages.getCell(.{ .screen = .{ .x = 1, .y = 2 } }).?;
const cell = list_cell.cell;
try testing.expectEqual(Cell.Wide.spacer_tail, cell.wide);
}
try s.resize(8, 2);
{
const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} });
defer alloc.free(contents);
try testing.expectEqualStrings(str, contents);
}
{
const list_cell = s.pages.getCell(.{ .screen = .{ .x = 5, .y = 0 } }).?;
const cell = list_cell.cell;
try testing.expectEqual(Cell.Wide.wide, cell.wide);
try testing.expectEqual(@as(u21, '😀'), cell.content.codepoint);
}
{
const list_cell = s.pages.getCell(.{ .screen = .{ .x = 6, .y = 0 } }).?;
const cell = list_cell.cell;
try testing.expectEqual(Cell.Wide.spacer_tail, cell.wide);
}
}
test "Screen: resize more cols requiring a wide spacer head" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 2, 2, 0);
defer s.deinit();
const str = "xx😀";
try s.testWriteString(str);
{
const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} });
defer alloc.free(contents);
try testing.expectEqualStrings("xx\n😀", contents);
}
{
const list_cell = s.pages.getCell(.{ .screen = .{ .x = 0, .y = 1 } }).?;
const cell = list_cell.cell;
try testing.expectEqual(Cell.Wide.wide, cell.wide);
}
{
const list_cell = s.pages.getCell(.{ .screen = .{ .x = 1, .y = 1 } }).?;
const cell = list_cell.cell;
try testing.expectEqual(Cell.Wide.spacer_tail, cell.wide);
}
// This resizes to 3 columns, which isn't enough space for our wide
// char to enter row 1. But we need to mark the wide spacer head on the
// end of the first row since we're wrapping to the next row.
try s.resize(3, 2);
{
const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} });
defer alloc.free(contents);
try testing.expectEqualStrings("xx\n😀", contents);
}
{
const list_cell = s.pages.getCell(.{ .screen = .{ .x = 2, .y = 0 } }).?;
const cell = list_cell.cell;
try testing.expectEqual(Cell.Wide.spacer_head, cell.wide);
}
{
const list_cell = s.pages.getCell(.{ .screen = .{ .x = 0, .y = 1 } }).?;
const cell = list_cell.cell;
try testing.expectEqual(Cell.Wide.wide, cell.wide);
try testing.expectEqual(@as(u21, '😀'), cell.content.codepoint);
}
{
const list_cell = s.pages.getCell(.{ .screen = .{ .x = 1, .y = 1 } }).?;
const cell = list_cell.cell;
try testing.expectEqual(Cell.Wide.spacer_tail, cell.wide);
}
}
test "Screen: select untracked" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 10, 10, 0);
defer s.deinit();
try s.testWriteString("ABC DEF\n 123\n456");
try testing.expect(s.selection == null);
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());
try s.select(null);
try testing.expectEqual(tracked, s.pages.countTrackedPins());
}
test "Screen: selectAll" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 10, 10, 0);
defer s.deinit();
{
try s.testWriteString("ABC DEF\n 123\n456");
var sel = s.selectAll().?;
defer sel.deinit(&s);
try testing.expectEqual(point.Point{ .screen = .{
.x = 0,
.y = 0,
} }, s.pages.pointFromPin(.screen, sel.start()).?);
try testing.expectEqual(point.Point{ .screen = .{
.x = 2,
.y = 2,
} }, s.pages.pointFromPin(.screen, sel.end()).?);
}
{
try s.testWriteString("\nFOO\n BAR\n BAZ\n QWERTY\n 12345678");
var sel = s.selectAll().?;
defer sel.deinit(&s);
try testing.expectEqual(point.Point{ .screen = .{
.x = 0,
.y = 0,
} }, s.pages.pointFromPin(.screen, sel.start()).?);
try testing.expectEqual(point.Point{ .screen = .{
.x = 8,
.y = 7,
} }, s.pages.pointFromPin(.screen, sel.end()).?);
}
}
test "Screen: selectLine" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 10, 10, 0);
defer s.deinit();
try s.testWriteString("ABC DEF\n 123\n456");
// Outside of active area
// try testing.expect(s.selectLine(.{ .x = 13, .y = 0 }) == null);
// try testing.expect(s.selectLine(.{ .x = 0, .y = 5 }) == null);
// Going forward
{
var sel = s.selectLine(.{ .pin = s.pages.pin(.{ .active = .{
.x = 0,
.y = 0,
} }).? }).?;
defer sel.deinit(&s);
try testing.expectEqual(point.Point{ .screen = .{
.x = 0,
.y = 0,
} }, s.pages.pointFromPin(.screen, sel.start()).?);
try testing.expectEqual(point.Point{ .screen = .{
.x = 7,
.y = 0,
} }, s.pages.pointFromPin(.screen, sel.end()).?);
}
// Going backward
{
var sel = s.selectLine(.{ .pin = s.pages.pin(.{ .active = .{
.x = 7,
.y = 0,
} }).? }).?;
defer sel.deinit(&s);
try testing.expectEqual(point.Point{ .screen = .{
.x = 0,
.y = 0,
} }, s.pages.pointFromPin(.screen, sel.start()).?);
try testing.expectEqual(point.Point{ .screen = .{
.x = 7,
.y = 0,
} }, s.pages.pointFromPin(.screen, sel.end()).?);
}
// Going forward and backward
{
var sel = s.selectLine(.{ .pin = s.pages.pin(.{ .active = .{
.x = 3,
.y = 0,
} }).? }).?;
defer sel.deinit(&s);
try testing.expectEqual(point.Point{ .screen = .{
.x = 0,
.y = 0,
} }, s.pages.pointFromPin(.screen, sel.start()).?);
try testing.expectEqual(point.Point{ .screen = .{
.x = 7,
.y = 0,
} }, s.pages.pointFromPin(.screen, sel.end()).?);
}
// Outside active area
{
var sel = s.selectLine(.{ .pin = s.pages.pin(.{ .active = .{
.x = 9,
.y = 0,
} }).? }).?;
defer sel.deinit(&s);
try testing.expectEqual(point.Point{ .screen = .{
.x = 0,
.y = 0,
} }, s.pages.pointFromPin(.screen, sel.start()).?);
try testing.expectEqual(point.Point{ .screen = .{
.x = 7,
.y = 0,
} }, s.pages.pointFromPin(.screen, sel.end()).?);
}
}
test "Screen: selectLine across soft-wrap" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 5, 10, 0);
defer s.deinit();
try s.testWriteString(" 12 34012 \n 123");
// Going forward
{
var sel = s.selectLine(.{ .pin = s.pages.pin(.{ .active = .{
.x = 1,
.y = 0,
} }).? }).?;
defer sel.deinit(&s);
try testing.expectEqual(point.Point{ .screen = .{
.x = 1,
.y = 0,
} }, s.pages.pointFromPin(.screen, sel.start()).?);
try testing.expectEqual(point.Point{ .screen = .{
.x = 3,
.y = 1,
} }, s.pages.pointFromPin(.screen, sel.end()).?);
}
}
test "Screen: selectLine across full soft-wrap" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 5, 5, 0);
defer s.deinit();
try s.testWriteString("1ABCD2EFGH\n3IJKL");
{
var sel = s.selectLine(.{ .pin = s.pages.pin(.{ .active = .{
.x = 2,
.y = 1,
} }).? }).?;
defer sel.deinit(&s);
try testing.expectEqual(point.Point{ .screen = .{
.x = 0,
.y = 0,
} }, s.pages.pointFromPin(.screen, sel.start()).?);
try testing.expectEqual(point.Point{ .screen = .{
.x = 4,
.y = 1,
} }, s.pages.pointFromPin(.screen, sel.end()).?);
}
}
test "Screen: selectLine across soft-wrap ignores blank lines" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 5, 10, 0);
defer s.deinit();
try s.testWriteString(" 12 34012 \n 123");
// Going forward
{
var sel = s.selectLine(.{ .pin = s.pages.pin(.{ .active = .{
.x = 1,
.y = 0,
} }).? }).?;
defer sel.deinit(&s);
try testing.expectEqual(point.Point{ .screen = .{
.x = 1,
.y = 0,
} }, s.pages.pointFromPin(.screen, sel.start()).?);
try testing.expectEqual(point.Point{ .screen = .{
.x = 3,
.y = 1,
} }, s.pages.pointFromPin(.screen, sel.end()).?);
}
// Going backward
{
var sel = s.selectLine(.{ .pin = s.pages.pin(.{ .active = .{
.x = 1,
.y = 1,
} }).? }).?;
defer sel.deinit(&s);
try testing.expectEqual(point.Point{ .screen = .{
.x = 1,
.y = 0,
} }, s.pages.pointFromPin(.screen, sel.start()).?);
try testing.expectEqual(point.Point{ .screen = .{
.x = 3,
.y = 1,
} }, s.pages.pointFromPin(.screen, sel.end()).?);
}
// Going forward and backward
{
var sel = s.selectLine(.{ .pin = s.pages.pin(.{ .active = .{
.x = 3,
.y = 0,
} }).? }).?;
defer sel.deinit(&s);
try testing.expectEqual(point.Point{ .screen = .{
.x = 1,
.y = 0,
} }, s.pages.pointFromPin(.screen, sel.start()).?);
try testing.expectEqual(point.Point{ .screen = .{
.x = 3,
.y = 1,
} }, s.pages.pointFromPin(.screen, sel.end()).?);
}
}
test "Screen: selectLine disabled whitespace trimming" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 5, 10, 0);
defer s.deinit();
try s.testWriteString(" 12 34012 \n 123");
// Going forward
{
var sel = s.selectLine(.{
.pin = s.pages.pin(.{ .active = .{
.x = 1,
.y = 0,
} }).?,
.whitespace = null,
}).?;
defer sel.deinit(&s);
try testing.expectEqual(point.Point{ .screen = .{
.x = 0,
.y = 0,
} }, s.pages.pointFromPin(.screen, sel.start()).?);
try testing.expectEqual(point.Point{ .screen = .{
.x = 4,
.y = 2,
} }, s.pages.pointFromPin(.screen, sel.end()).?);
}
// Non-wrapped
{
var sel = s.selectLine(.{
.pin = s.pages.pin(.{ .active = .{
.x = 1,
.y = 3,
} }).?,
.whitespace = null,
}).?;
defer sel.deinit(&s);
try testing.expectEqual(point.Point{ .screen = .{
.x = 0,
.y = 3,
} }, s.pages.pointFromPin(.screen, sel.start()).?);
try testing.expectEqual(point.Point{ .screen = .{
.x = 4,
.y = 3,
} }, s.pages.pointFromPin(.screen, sel.end()).?);
}
}
test "Screen: selectLine with scrollback" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 2, 3, 5);
defer s.deinit();
try s.testWriteString("1A\n2B\n3C\n4D\n5E");
// Selecting first line
{
var sel = s.selectLine(.{ .pin = s.pages.pin(.{ .active = .{
.x = 0,
.y = 0,
} }).? }).?;
defer sel.deinit(&s);
try testing.expectEqual(point.Point{ .active = .{
.x = 0,
.y = 0,
} }, s.pages.pointFromPin(.active, sel.start()).?);
try testing.expectEqual(point.Point{ .active = .{
.x = 1,
.y = 0,
} }, s.pages.pointFromPin(.active, sel.end()).?);
}
// Selecting last line
{
var sel = s.selectLine(.{ .pin = s.pages.pin(.{ .active = .{
.x = 0,
.y = 2,
} }).? }).?;
defer sel.deinit(&s);
try testing.expectEqual(point.Point{ .active = .{
.x = 0,
.y = 2,
} }, s.pages.pointFromPin(.active, sel.start()).?);
try testing.expectEqual(point.Point{ .active = .{
.x = 1,
.y = 2,
} }, s.pages.pointFromPin(.active, sel.end()).?);
}
}
// https://github.com/mitchellh/ghostty/issues/1329
test "Screen: selectLine semantic prompt boundary" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 5, 10, 0);
defer s.deinit();
try s.testWriteString("ABCDE\nA > ");
{
const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} });
defer alloc.free(contents);
try testing.expectEqualStrings("ABCDE\nA \n> ", contents);
}
{
const pin = s.pages.pin(.{ .screen = .{ .y = 1 } }).?;
const row = pin.rowAndCell().row;
row.semantic_prompt = .prompt;
}
// Selecting output stops at the prompt even if soft-wrapped
{
var sel = s.selectLine(.{ .pin = s.pages.pin(.{ .active = .{
.x = 1,
.y = 1,
} }).? }).?;
defer sel.deinit(&s);
try testing.expectEqual(point.Point{ .active = .{
.x = 0,
.y = 1,
} }, s.pages.pointFromPin(.active, sel.start()).?);
try testing.expectEqual(point.Point{ .active = .{
.x = 0,
.y = 1,
} }, s.pages.pointFromPin(.active, sel.end()).?);
}
{
var sel = s.selectLine(.{ .pin = s.pages.pin(.{ .active = .{
.x = 1,
.y = 2,
} }).? }).?;
defer sel.deinit(&s);
try testing.expectEqual(point.Point{ .active = .{
.x = 0,
.y = 2,
} }, s.pages.pointFromPin(.active, sel.start()).?);
try testing.expectEqual(point.Point{ .active = .{
.x = 0,
.y = 2,
} }, s.pages.pointFromPin(.active, sel.end()).?);
}
}
test "Screen: selectWord" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 10, 10, 0);
defer s.deinit();
try s.testWriteString("ABC DEF\n 123\n456");
// Outside of active area
// try testing.expect(s.selectWord(.{ .x = 9, .y = 0 }) == null);
// try testing.expect(s.selectWord(.{ .x = 0, .y = 5 }) == null);
// Going forward
{
var sel = s.selectWord(s.pages.pin(.{ .active = .{
.x = 0,
.y = 0,
} }).?).?;
defer sel.deinit(&s);
try testing.expectEqual(point.Point{ .screen = .{
.x = 0,
.y = 0,
} }, s.pages.pointFromPin(.screen, sel.start()).?);
try testing.expectEqual(point.Point{ .screen = .{
.x = 2,
.y = 0,
} }, s.pages.pointFromPin(.screen, sel.end()).?);
}
// Going backward
{
var sel = s.selectWord(s.pages.pin(.{ .active = .{
.x = 2,
.y = 0,
} }).?).?;
defer sel.deinit(&s);
try testing.expectEqual(point.Point{ .screen = .{
.x = 0,
.y = 0,
} }, s.pages.pointFromPin(.screen, sel.start()).?);
try testing.expectEqual(point.Point{ .screen = .{
.x = 2,
.y = 0,
} }, s.pages.pointFromPin(.screen, sel.end()).?);
}
// Going forward and backward
{
var sel = s.selectWord(s.pages.pin(.{ .active = .{
.x = 1,
.y = 0,
} }).?).?;
defer sel.deinit(&s);
try testing.expectEqual(point.Point{ .screen = .{
.x = 0,
.y = 0,
} }, s.pages.pointFromPin(.screen, sel.start()).?);
try testing.expectEqual(point.Point{ .screen = .{
.x = 2,
.y = 0,
} }, s.pages.pointFromPin(.screen, sel.end()).?);
}
// Whitespace
{
var sel = s.selectWord(s.pages.pin(.{ .active = .{
.x = 3,
.y = 0,
} }).?).?;
defer sel.deinit(&s);
try testing.expectEqual(point.Point{ .screen = .{
.x = 3,
.y = 0,
} }, s.pages.pointFromPin(.screen, sel.start()).?);
try testing.expectEqual(point.Point{ .screen = .{
.x = 4,
.y = 0,
} }, s.pages.pointFromPin(.screen, sel.end()).?);
}
// Whitespace single char
{
var sel = s.selectWord(s.pages.pin(.{ .active = .{
.x = 0,
.y = 1,
} }).?).?;
defer sel.deinit(&s);
try testing.expectEqual(point.Point{ .screen = .{
.x = 0,
.y = 1,
} }, s.pages.pointFromPin(.screen, sel.start()).?);
try testing.expectEqual(point.Point{ .screen = .{
.x = 0,
.y = 1,
} }, s.pages.pointFromPin(.screen, sel.end()).?);
}
// End of screen
{
var sel = s.selectWord(s.pages.pin(.{ .active = .{
.x = 1,
.y = 2,
} }).?).?;
defer sel.deinit(&s);
try testing.expectEqual(point.Point{ .screen = .{
.x = 0,
.y = 2,
} }, s.pages.pointFromPin(.screen, sel.start()).?);
try testing.expectEqual(point.Point{ .screen = .{
.x = 2,
.y = 2,
} }, s.pages.pointFromPin(.screen, sel.end()).?);
}
}
test "Screen: selectWord across soft-wrap" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 5, 10, 0);
defer s.deinit();
try s.testWriteString(" 1234012\n 123");
{
const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} });
defer alloc.free(contents);
try testing.expectEqualStrings(" 1234\n012\n 123", contents);
}
// Going forward
{
var sel = s.selectWord(s.pages.pin(.{ .active = .{
.x = 1,
.y = 0,
} }).?).?;
defer sel.deinit(&s);
try testing.expectEqual(point.Point{ .screen = .{
.x = 1,
.y = 0,
} }, s.pages.pointFromPin(.screen, sel.start()).?);
try testing.expectEqual(point.Point{ .screen = .{
.x = 2,
.y = 1,
} }, s.pages.pointFromPin(.screen, sel.end()).?);
}
// Going backward
{
var sel = s.selectWord(s.pages.pin(.{ .active = .{
.x = 1,
.y = 1,
} }).?).?;
defer sel.deinit(&s);
try testing.expectEqual(point.Point{ .screen = .{
.x = 1,
.y = 0,
} }, s.pages.pointFromPin(.screen, sel.start()).?);
try testing.expectEqual(point.Point{ .screen = .{
.x = 2,
.y = 1,
} }, s.pages.pointFromPin(.screen, sel.end()).?);
}
// Going forward and backward
{
var sel = s.selectWord(s.pages.pin(.{ .active = .{
.x = 3,
.y = 0,
} }).?).?;
defer sel.deinit(&s);
try testing.expectEqual(point.Point{ .screen = .{
.x = 1,
.y = 0,
} }, s.pages.pointFromPin(.screen, sel.start()).?);
try testing.expectEqual(point.Point{ .screen = .{
.x = 2,
.y = 1,
} }, s.pages.pointFromPin(.screen, sel.end()).?);
}
}
test "Screen: selectWord whitespace across soft-wrap" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 5, 10, 0);
defer s.deinit();
try s.testWriteString("1 1\n 123");
// Going forward
{
var sel = s.selectWord(s.pages.pin(.{ .active = .{
.x = 1,
.y = 0,
} }).?).?;
defer sel.deinit(&s);
try testing.expectEqual(point.Point{ .screen = .{
.x = 1,
.y = 0,
} }, s.pages.pointFromPin(.screen, sel.start()).?);
try testing.expectEqual(point.Point{ .screen = .{
.x = 2,
.y = 1,
} }, s.pages.pointFromPin(.screen, sel.end()).?);
}
// Going backward
{
var sel = s.selectWord(s.pages.pin(.{ .active = .{
.x = 1,
.y = 1,
} }).?).?;
defer sel.deinit(&s);
try testing.expectEqual(point.Point{ .screen = .{
.x = 1,
.y = 0,
} }, s.pages.pointFromPin(.screen, sel.start()).?);
try testing.expectEqual(point.Point{ .screen = .{
.x = 2,
.y = 1,
} }, s.pages.pointFromPin(.screen, sel.end()).?);
}
// Going forward and backward
{
var sel = s.selectWord(s.pages.pin(.{ .active = .{
.x = 3,
.y = 0,
} }).?).?;
defer sel.deinit(&s);
try testing.expectEqual(point.Point{ .screen = .{
.x = 1,
.y = 0,
} }, s.pages.pointFromPin(.screen, sel.start()).?);
try testing.expectEqual(point.Point{ .screen = .{
.x = 2,
.y = 1,
} }, s.pages.pointFromPin(.screen, sel.end()).?);
}
}
test "Screen: selectWord with character boundary" {
const testing = std.testing;
const alloc = testing.allocator;
const cases = [_][]const u8{
" 'abc' \n123",
" \"abc\" \n123",
" │abc│ \n123",
" `abc` \n123",
" |abc| \n123",
" :abc: \n123",
" ,abc, \n123",
" (abc( \n123",
" )abc) \n123",
" [abc[ \n123",
" ]abc] \n123",
" {abc{ \n123",
" }abc} \n123",
" <abc< \n123",
" >abc> \n123",
" $abc$ \n123",
};
for (cases) |case| {
var s = try init(alloc, 20, 10, 0);
defer s.deinit();
try s.testWriteString(case);
// Inside character forward
{
var sel = s.selectWord(s.pages.pin(.{ .active = .{
.x = 2,
.y = 0,
} }).?).?;
defer sel.deinit(&s);
try testing.expectEqual(point.Point{ .screen = .{
.x = 2,
.y = 0,
} }, s.pages.pointFromPin(.screen, sel.start()).?);
try testing.expectEqual(point.Point{ .screen = .{
.x = 4,
.y = 0,
} }, s.pages.pointFromPin(.screen, sel.end()).?);
}
// Inside character backward
{
var sel = s.selectWord(s.pages.pin(.{ .active = .{
.x = 4,
.y = 0,
} }).?).?;
defer sel.deinit(&s);
try testing.expectEqual(point.Point{ .screen = .{
.x = 2,
.y = 0,
} }, s.pages.pointFromPin(.screen, sel.start()).?);
try testing.expectEqual(point.Point{ .screen = .{
.x = 4,
.y = 0,
} }, s.pages.pointFromPin(.screen, sel.end()).?);
}
// Inside character bidirectional
{
var sel = s.selectWord(s.pages.pin(.{ .active = .{
.x = 3,
.y = 0,
} }).?).?;
defer sel.deinit(&s);
try testing.expectEqual(point.Point{ .screen = .{
.x = 2,
.y = 0,
} }, s.pages.pointFromPin(.screen, sel.start()).?);
try testing.expectEqual(point.Point{ .screen = .{
.x = 4,
.y = 0,
} }, s.pages.pointFromPin(.screen, sel.end()).?);
}
// On quote
// NOTE: this behavior is not ideal, so we can change this one day,
// but I think its also not that important compared to the above.
{
var sel = s.selectWord(s.pages.pin(.{ .active = .{
.x = 1,
.y = 0,
} }).?).?;
defer sel.deinit(&s);
try testing.expectEqual(point.Point{ .screen = .{
.x = 0,
.y = 0,
} }, s.pages.pointFromPin(.screen, sel.start()).?);
try testing.expectEqual(point.Point{ .screen = .{
.x = 1,
.y = 0,
} }, s.pages.pointFromPin(.screen, sel.end()).?);
}
}
}
test "Screen: selectOutput" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 10, 15, 0);
defer s.deinit();
// zig fmt: off
{
// line number:
try s.testWriteString("output1\n"); // 0
try s.testWriteString("output1\n"); // 1
try s.testWriteString("prompt2\n"); // 2
try s.testWriteString("input2\n"); // 3
try s.testWriteString("output2\n"); // 4
try s.testWriteString("output2\n"); // 5
try s.testWriteString("prompt3$ input3\n"); // 6
try s.testWriteString("output3\n"); // 7
try s.testWriteString("output3\n"); // 8
try s.testWriteString("output3"); // 9
}
// zig fmt: on
{
const pin = s.pages.pin(.{ .screen = .{ .y = 2 } }).?;
const row = pin.rowAndCell().row;
row.semantic_prompt = .prompt;
}
{
const pin = s.pages.pin(.{ .screen = .{ .y = 3 } }).?;
const row = pin.rowAndCell().row;
row.semantic_prompt = .input;
}
{
const pin = s.pages.pin(.{ .screen = .{ .y = 4 } }).?;
const row = pin.rowAndCell().row;
row.semantic_prompt = .command;
}
{
const pin = s.pages.pin(.{ .screen = .{ .y = 6 } }).?;
const row = pin.rowAndCell().row;
row.semantic_prompt = .input;
}
{
const pin = s.pages.pin(.{ .screen = .{ .y = 7 } }).?;
const row = pin.rowAndCell().row;
row.semantic_prompt = .command;
}
// No start marker, should select from the beginning
{
var sel = s.selectOutput(s.pages.pin(.{ .active = .{
.x = 1,
.y = 1,
} }).?).?;
defer sel.deinit(&s);
try testing.expectEqual(point.Point{ .active = .{
.x = 0,
.y = 0,
} }, s.pages.pointFromPin(.active, sel.start()).?);
try testing.expectEqual(point.Point{ .active = .{
.x = 9,
.y = 1,
} }, s.pages.pointFromPin(.active, sel.end()).?);
}
// Both start and end markers, should select between them
{
var sel = s.selectOutput(s.pages.pin(.{ .active = .{
.x = 3,
.y = 5,
} }).?).?;
defer sel.deinit(&s);
try testing.expectEqual(point.Point{ .active = .{
.x = 0,
.y = 4,
} }, s.pages.pointFromPin(.active, sel.start()).?);
try testing.expectEqual(point.Point{ .active = .{
.x = 9,
.y = 5,
} }, s.pages.pointFromPin(.active, sel.end()).?);
}
// No end marker, should select till the end
{
var sel = s.selectOutput(s.pages.pin(.{ .active = .{
.x = 2,
.y = 7,
} }).?).?;
defer sel.deinit(&s);
try testing.expectEqual(point.Point{ .active = .{
.x = 0,
.y = 7,
} }, s.pages.pointFromPin(.active, sel.start()).?);
try testing.expectEqual(point.Point{ .active = .{
.x = 9,
.y = 10,
} }, s.pages.pointFromPin(.active, sel.end()).?);
}
// input / prompt at y = 0, pt.y = 0
{
s.deinit();
s = try init(alloc, 10, 5, 0);
try s.testWriteString("prompt1$ input1\n");
try s.testWriteString("output1\n");
try s.testWriteString("prompt2\n");
{
const pin = s.pages.pin(.{ .screen = .{ .y = 0 } }).?;
const row = pin.rowAndCell().row;
row.semantic_prompt = .input;
}
{
const pin = s.pages.pin(.{ .screen = .{ .y = 1 } }).?;
const row = pin.rowAndCell().row;
row.semantic_prompt = .command;
}
try testing.expect(s.selectOutput(s.pages.pin(.{ .active = .{
.x = 2,
.y = 0,
} }).?) == null);
}
}
test "Screen: selectPrompt basics" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 10, 15, 0);
defer s.deinit();
// zig fmt: off
{
// line number:
try s.testWriteString("output1\n"); // 0
try s.testWriteString("output1\n"); // 1
try s.testWriteString("prompt2\n"); // 2
try s.testWriteString("input2\n"); // 3
try s.testWriteString("output2\n"); // 4
try s.testWriteString("output2\n"); // 5
try s.testWriteString("prompt3$ input3\n"); // 6
try s.testWriteString("output3\n"); // 7
try s.testWriteString("output3\n"); // 8
try s.testWriteString("output3"); // 9
}
// zig fmt: on
{
const pin = s.pages.pin(.{ .screen = .{ .y = 2 } }).?;
const row = pin.rowAndCell().row;
row.semantic_prompt = .prompt;
}
{
const pin = s.pages.pin(.{ .screen = .{ .y = 3 } }).?;
const row = pin.rowAndCell().row;
row.semantic_prompt = .input;
}
{
const pin = s.pages.pin(.{ .screen = .{ .y = 4 } }).?;
const row = pin.rowAndCell().row;
row.semantic_prompt = .command;
}
{
const pin = s.pages.pin(.{ .screen = .{ .y = 6 } }).?;
const row = pin.rowAndCell().row;
row.semantic_prompt = .input;
}
{
const pin = s.pages.pin(.{ .screen = .{ .y = 7 } }).?;
const row = pin.rowAndCell().row;
row.semantic_prompt = .command;
}
// Not at a prompt
{
const sel = s.selectPrompt(s.pages.pin(.{ .active = .{
.x = 0,
.y = 1,
} }).?);
try testing.expect(sel == null);
}
{
const sel = s.selectPrompt(s.pages.pin(.{ .active = .{
.x = 0,
.y = 8,
} }).?);
try testing.expect(sel == null);
}
// Single line prompt
{
var sel = s.selectPrompt(s.pages.pin(.{ .active = .{
.x = 1,
.y = 6,
} }).?).?;
defer sel.deinit(&s);
try testing.expectEqual(point.Point{ .screen = .{
.x = 0,
.y = 6,
} }, s.pages.pointFromPin(.screen, sel.start()).?);
try testing.expectEqual(point.Point{ .screen = .{
.x = 9,
.y = 6,
} }, s.pages.pointFromPin(.screen, sel.end()).?);
}
// Multi line prompt
{
var sel = s.selectPrompt(s.pages.pin(.{ .active = .{
.x = 1,
.y = 3,
} }).?).?;
defer sel.deinit(&s);
try testing.expectEqual(point.Point{ .screen = .{
.x = 0,
.y = 2,
} }, s.pages.pointFromPin(.screen, sel.start()).?);
try testing.expectEqual(point.Point{ .screen = .{
.x = 9,
.y = 3,
} }, s.pages.pointFromPin(.screen, sel.end()).?);
}
}
test "Screen: selectPrompt prompt at start" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 10, 15, 0);
defer s.deinit();
// zig fmt: off
{
// line number:
try s.testWriteString("prompt1\n"); // 0
try s.testWriteString("input1\n"); // 1
try s.testWriteString("output2\n"); // 2
try s.testWriteString("output2\n"); // 3
}
// zig fmt: on
{
const pin = s.pages.pin(.{ .screen = .{ .y = 0 } }).?;
const row = pin.rowAndCell().row;
row.semantic_prompt = .prompt;
}
{
const pin = s.pages.pin(.{ .screen = .{ .y = 1 } }).?;
const row = pin.rowAndCell().row;
row.semantic_prompt = .input;
}
{
const pin = s.pages.pin(.{ .screen = .{ .y = 2 } }).?;
const row = pin.rowAndCell().row;
row.semantic_prompt = .command;
}
// Not at a prompt
{
const sel = s.selectPrompt(s.pages.pin(.{ .active = .{
.x = 0,
.y = 3,
} }).?);
try testing.expect(sel == null);
}
// Multi line prompt
{
var sel = s.selectPrompt(s.pages.pin(.{ .active = .{
.x = 1,
.y = 1,
} }).?).?;
defer sel.deinit(&s);
try testing.expectEqual(point.Point{ .screen = .{
.x = 0,
.y = 0,
} }, s.pages.pointFromPin(.screen, sel.start()).?);
try testing.expectEqual(point.Point{ .screen = .{
.x = 9,
.y = 1,
} }, s.pages.pointFromPin(.screen, sel.end()).?);
}
}
test "Screen: selectPrompt prompt at end" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 10, 15, 0);
defer s.deinit();
// zig fmt: off
{
// line number:
try s.testWriteString("output2\n"); // 0
try s.testWriteString("output2\n"); // 1
try s.testWriteString("prompt1\n"); // 2
try s.testWriteString("input1\n"); // 3
}
// zig fmt: on
{
const pin = s.pages.pin(.{ .screen = .{ .y = 2 } }).?;
const row = pin.rowAndCell().row;
row.semantic_prompt = .prompt;
}
{
const pin = s.pages.pin(.{ .screen = .{ .y = 3 } }).?;
const row = pin.rowAndCell().row;
row.semantic_prompt = .input;
}
// Not at a prompt
{
const sel = s.selectPrompt(s.pages.pin(.{ .active = .{
.x = 0,
.y = 1,
} }).?);
try testing.expect(sel == null);
}
// Multi line prompt
{
var sel = s.selectPrompt(s.pages.pin(.{ .active = .{
.x = 1,
.y = 2,
} }).?).?;
defer sel.deinit(&s);
try testing.expectEqual(point.Point{ .screen = .{
.x = 0,
.y = 2,
} }, s.pages.pointFromPin(.screen, sel.start()).?);
try testing.expectEqual(point.Point{ .screen = .{
.x = 9,
.y = 3,
} }, s.pages.pointFromPin(.screen, sel.end()).?);
}
}
test "Screen: promptPath" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 10, 15, 0);
defer s.deinit();
// zig fmt: off
{
// line number:
try s.testWriteString("output1\n"); // 0
try s.testWriteString("output1\n"); // 1
try s.testWriteString("prompt2\n"); // 2
try s.testWriteString("input2\n"); // 3
try s.testWriteString("output2\n"); // 4
try s.testWriteString("output2\n"); // 5
try s.testWriteString("prompt3$ input3\n"); // 6
try s.testWriteString("output3\n"); // 7
try s.testWriteString("output3\n"); // 8
try s.testWriteString("output3"); // 9
}
// zig fmt: on
{
const pin = s.pages.pin(.{ .screen = .{ .y = 2 } }).?;
const row = pin.rowAndCell().row;
row.semantic_prompt = .prompt;
}
{
const pin = s.pages.pin(.{ .screen = .{ .y = 3 } }).?;
const row = pin.rowAndCell().row;
row.semantic_prompt = .input;
}
{
const pin = s.pages.pin(.{ .screen = .{ .y = 4 } }).?;
const row = pin.rowAndCell().row;
row.semantic_prompt = .command;
}
{
const pin = s.pages.pin(.{ .screen = .{ .y = 6 } }).?;
const row = pin.rowAndCell().row;
row.semantic_prompt = .input;
}
{
const pin = s.pages.pin(.{ .screen = .{ .y = 7 } }).?;
const row = pin.rowAndCell().row;
row.semantic_prompt = .command;
}
// From is not in the prompt
{
const path = s.promptPath(
s.pages.pin(.{ .active = .{ .x = 0, .y = 1 } }).?,
s.pages.pin(.{ .active = .{ .x = 0, .y = 2 } }).?,
);
try testing.expectEqual(@as(isize, 0), path.x);
try testing.expectEqual(@as(isize, 0), path.y);
}
// Same line
{
const path = s.promptPath(
s.pages.pin(.{ .active = .{ .x = 6, .y = 2 } }).?,
s.pages.pin(.{ .active = .{ .x = 3, .y = 2 } }).?,
);
try testing.expectEqual(@as(isize, -3), path.x);
try testing.expectEqual(@as(isize, 0), path.y);
}
// Different lines
{
const path = s.promptPath(
s.pages.pin(.{ .active = .{ .x = 6, .y = 2 } }).?,
s.pages.pin(.{ .active = .{ .x = 3, .y = 3 } }).?,
);
try testing.expectEqual(@as(isize, -3), path.x);
try testing.expectEqual(@as(isize, 1), path.y);
}
// To is out of bounds before
{
const path = s.promptPath(
s.pages.pin(.{ .active = .{ .x = 6, .y = 2 } }).?,
s.pages.pin(.{ .active = .{ .x = 3, .y = 1 } }).?,
);
try testing.expectEqual(@as(isize, -6), path.x);
try testing.expectEqual(@as(isize, 0), path.y);
}
// To is out of bounds after
{
const path = s.promptPath(
s.pages.pin(.{ .active = .{ .x = 6, .y = 2 } }).?,
s.pages.pin(.{ .active = .{ .x = 3, .y = 9 } }).?,
);
try testing.expectEqual(@as(isize, 3), path.x);
try testing.expectEqual(@as(isize, 1), path.y);
}
}
test "Screen: selectionString basic" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 5, 3, 0);
defer s.deinit();
const str = "1ABCD\n2EFGH\n3IJKL";
try s.testWriteString(str);
{
const sel = Selection.init(
s.pages.pin(.{ .screen = .{ .x = 0, .y = 1 } }).?,
s.pages.pin(.{ .screen = .{ .x = 2, .y = 2 } }).?,
false,
);
const contents = try s.selectionString(alloc, .{
.sel = sel,
.trim = true,
});
defer alloc.free(contents);
const expected = "2EFGH\n3IJ";
try testing.expectEqualStrings(expected, contents);
}
}
test "Screen: selectionString start outside of written area" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 5, 10, 0);
defer s.deinit();
const str = "1ABCD\n2EFGH\n3IJKL";
try s.testWriteString(str);
{
const sel = Selection.init(
s.pages.pin(.{ .screen = .{ .x = 0, .y = 5 } }).?,
s.pages.pin(.{ .screen = .{ .x = 2, .y = 6 } }).?,
false,
);
const contents = try s.selectionString(alloc, .{
.sel = sel,
.trim = true,
});
defer alloc.free(contents);
const expected = "";
try testing.expectEqualStrings(expected, contents);
}
}
test "Screen: selectionString end outside of written area" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 5, 10, 0);
defer s.deinit();
const str = "1ABCD\n2EFGH\n3IJKL";
try s.testWriteString(str);
{
const sel = Selection.init(
s.pages.pin(.{ .screen = .{ .x = 0, .y = 2 } }).?,
s.pages.pin(.{ .screen = .{ .x = 2, .y = 6 } }).?,
false,
);
const contents = try s.selectionString(alloc, .{
.sel = sel,
.trim = true,
});
defer alloc.free(contents);
const expected = "3IJKL";
try testing.expectEqualStrings(expected, contents);
}
}
test "Screen: selectionString trim space" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 5, 3, 0);
defer s.deinit();
const str = "1AB \n2EFGH\n3IJKL";
try s.testWriteString(str);
const sel = Selection.init(
s.pages.pin(.{ .screen = .{ .x = 0, .y = 0 } }).?,
s.pages.pin(.{ .screen = .{ .x = 2, .y = 1 } }).?,
false,
);
{
const contents = try s.selectionString(alloc, .{
.sel = sel,
.trim = true,
});
defer alloc.free(contents);
const expected = "1AB\n2EF";
try testing.expectEqualStrings(expected, contents);
}
// No trim
{
const contents = try s.selectionString(alloc, .{
.sel = sel,
.trim = false,
});
defer alloc.free(contents);
const expected = "1AB \n2EF";
try testing.expectEqualStrings(expected, contents);
}
}
test "Screen: selectionString trim empty line" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 5, 5, 0);
defer s.deinit();
const str = "1AB \n\n2EFGH\n3IJKL";
try s.testWriteString(str);
const sel = Selection.init(
s.pages.pin(.{ .screen = .{ .x = 0, .y = 0 } }).?,
s.pages.pin(.{ .screen = .{ .x = 2, .y = 2 } }).?,
false,
);
{
const contents = try s.selectionString(alloc, .{
.sel = sel,
.trim = true,
});
defer alloc.free(contents);
const expected = "1AB\n\n2EF";
try testing.expectEqualStrings(expected, contents);
}
// No trim
{
const contents = try s.selectionString(alloc, .{
.sel = sel,
.trim = false,
});
defer alloc.free(contents);
const expected = "1AB \n \n2EF";
try testing.expectEqualStrings(expected, contents);
}
}
test "Screen: selectionString soft wrap" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 5, 3, 0);
defer s.deinit();
const str = "1ABCD2EFGH3IJKL";
try s.testWriteString(str);
{
const sel = Selection.init(
s.pages.pin(.{ .screen = .{ .x = 0, .y = 1 } }).?,
s.pages.pin(.{ .screen = .{ .x = 2, .y = 2 } }).?,
false,
);
const contents = try s.selectionString(alloc, .{
.sel = sel,
.trim = true,
});
defer alloc.free(contents);
const expected = "2EFGH3IJ";
try testing.expectEqualStrings(expected, contents);
}
}
test "Screen: selectionString wide char" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 5, 3, 0);
defer s.deinit();
const str = "1A⚡";
try s.testWriteString(str);
{
const sel = Selection.init(
s.pages.pin(.{ .screen = .{ .x = 0, .y = 0 } }).?,
s.pages.pin(.{ .screen = .{ .x = 3, .y = 0 } }).?,
false,
);
const contents = try s.selectionString(alloc, .{
.sel = sel,
.trim = true,
});
defer alloc.free(contents);
const expected = str;
try testing.expectEqualStrings(expected, contents);
}
{
const sel = Selection.init(
s.pages.pin(.{ .screen = .{ .x = 0, .y = 0 } }).?,
s.pages.pin(.{ .screen = .{ .x = 2, .y = 0 } }).?,
false,
);
const contents = try s.selectionString(alloc, .{
.sel = sel,
.trim = true,
});
defer alloc.free(contents);
const expected = str;
try testing.expectEqualStrings(expected, contents);
}
{
const sel = Selection.init(
s.pages.pin(.{ .screen = .{ .x = 3, .y = 0 } }).?,
s.pages.pin(.{ .screen = .{ .x = 3, .y = 0 } }).?,
false,
);
const contents = try s.selectionString(alloc, .{
.sel = sel,
.trim = true,
});
defer alloc.free(contents);
const expected = "";
try testing.expectEqualStrings(expected, contents);
}
}
test "Screen: selectionString wide char with header" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 5, 3, 0);
defer s.deinit();
const str = "1ABC⚡";
try s.testWriteString(str);
{
const sel = Selection.init(
s.pages.pin(.{ .screen = .{ .x = 0, .y = 0 } }).?,
s.pages.pin(.{ .screen = .{ .x = 4, .y = 0 } }).?,
false,
);
const contents = try s.selectionString(alloc, .{
.sel = sel,
.trim = true,
});
defer alloc.free(contents);
const expected = str;
try testing.expectEqualStrings(expected, contents);
}
}
// https://github.com/mitchellh/ghostty/issues/289
test "Screen: selectionString empty with soft wrap" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 5, 2, 0);
defer s.deinit();
// Let me describe the situation that caused this because this
// test is not obvious. By writing an emoji below, we introduce
// one cell with the emoji and one cell as a "wide char spacer".
// We then soft wrap the line by writing spaces.
//
// By selecting only the tail, we'd select nothing and we had
// a logic error that would cause a crash.
try s.testWriteString("👨");
try s.testWriteString(" ");
{
const sel = Selection.init(
s.pages.pin(.{ .screen = .{ .x = 1, .y = 0 } }).?,
s.pages.pin(.{ .screen = .{ .x = 2, .y = 0 } }).?,
false,
);
const contents = try s.selectionString(alloc, .{
.sel = sel,
.trim = true,
});
defer alloc.free(contents);
const expected = "👨";
try testing.expectEqualStrings(expected, contents);
}
}
test "Screen: selectionString with zero width joiner" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 10, 1, 0);
defer s.deinit();
const str = "👨‍"; // this has a ZWJ
try s.testWriteString(str);
// Integrity check
{
const pin = s.pages.pin(.{ .screen = .{ .y = 0, .x = 0 } }).?;
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).?;
try testing.expectEqual(@as(usize, 1), cps.len);
}
// The real test
{
const sel = Selection.init(
s.pages.pin(.{ .screen = .{ .x = 0, .y = 0 } }).?,
s.pages.pin(.{ .screen = .{ .x = 1, .y = 0 } }).?,
false,
);
const contents = try s.selectionString(alloc, .{
.sel = sel,
.trim = true,
});
defer alloc.free(contents);
const expected = "👨‍";
try testing.expectEqualStrings(expected, contents);
}
}
test "Screen: selectionString, rectangle, basic" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 30, 5, 0);
defer s.deinit();
const str =
\\Lorem ipsum dolor
\\sit amet, consectetur
\\adipiscing elit, sed do
\\eiusmod tempor incididunt
\\ut labore et dolore
;
const sel = Selection.init(
s.pages.pin(.{ .screen = .{ .x = 2, .y = 1 } }).?,
s.pages.pin(.{ .screen = .{ .x = 6, .y = 3 } }).?,
true,
);
const expected =
\\t ame
\\ipisc
\\usmod
;
try s.testWriteString(str);
const contents = try s.selectionString(alloc, .{
.sel = sel,
.trim = true,
});
defer alloc.free(contents);
try testing.expectEqualStrings(expected, contents);
}
test "Screen: selectionString, rectangle, w/EOL" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 30, 5, 0);
defer s.deinit();
const str =
\\Lorem ipsum dolor
\\sit amet, consectetur
\\adipiscing elit, sed do
\\eiusmod tempor incididunt
\\ut labore et dolore
;
const sel = Selection.init(
s.pages.pin(.{ .screen = .{ .x = 12, .y = 0 } }).?,
s.pages.pin(.{ .screen = .{ .x = 26, .y = 4 } }).?,
true,
);
const expected =
\\dolor
\\nsectetur
\\lit, sed do
\\or incididunt
\\ dolore
;
try s.testWriteString(str);
const contents = try s.selectionString(alloc, .{
.sel = sel,
.trim = true,
});
defer alloc.free(contents);
try testing.expectEqualStrings(expected, contents);
}
test "Screen: selectionString, rectangle, more complex w/breaks" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 30, 8, 0);
defer s.deinit();
const str =
\\Lorem ipsum dolor
\\sit amet, consectetur
\\adipiscing elit, sed do
\\eiusmod tempor incididunt
\\ut labore et dolore
\\
\\magna aliqua. Ut enim
\\ad minim veniam, quis
;
const sel = Selection.init(
s.pages.pin(.{ .screen = .{ .x = 11, .y = 2 } }).?,
s.pages.pin(.{ .screen = .{ .x = 26, .y = 7 } }).?,
true,
);
const expected =
\\elit, sed do
\\por incididunt
\\t dolore
\\
\\a. Ut enim
\\niam, quis
;
try s.testWriteString(str);
const contents = try s.selectionString(alloc, .{
.sel = sel,
.trim = true,
});
defer alloc.free(contents);
try testing.expectEqualStrings(expected, contents);
}
test "Screen: selectionString multi-page" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 10, 3, 2048);
defer s.deinit();
const first_page_size = s.pages.pages.first.?.data.capacity.rows;
// Lazy way to seek to the first page boundary.
s.pages.pages.first.?.data.pauseIntegrityChecks(true);
for (0..first_page_size - 1) |_| {
try s.testWriteString("\n");
}
s.pages.pages.first.?.data.pauseIntegrityChecks(false);
try s.testWriteString("123456789\n!@#$%^&*(\n123456789");
{
const sel = Selection.init(
s.pages.pin(.{ .active = .{ .x = 0, .y = 0 } }).?,
s.pages.pin(.{ .active = .{ .x = 2, .y = 2 } }).?,
false,
);
const contents = try s.selectionString(alloc, .{
.sel = sel,
.trim = true,
});
defer alloc.free(contents);
const expected = "123456789\n!@#$%^&*(\n123";
try testing.expectEqualStrings(expected, contents);
}
}
test "Screen: lineIterator" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 5, 5, 0);
defer s.deinit();
const str = "1ABCD\n2EFGH";
try s.testWriteString(str);
// Test the line iterator
var iter = s.lineIterator(s.pages.pin(.{ .viewport = .{} }).?);
{
const sel = iter.next().?;
const actual = try s.selectionString(alloc, .{
.sel = sel,
.trim = false,
});
defer alloc.free(actual);
try testing.expectEqualStrings("1ABCD", actual);
}
{
const sel = iter.next().?;
const actual = try s.selectionString(alloc, .{
.sel = sel,
.trim = false,
});
defer alloc.free(actual);
try testing.expectEqualStrings("2EFGH", actual);
}
}
test "Screen: lineIterator soft wrap" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 5, 5, 0);
defer s.deinit();
const str = "1ABCD2EFGH\n3ABCD";
try s.testWriteString(str);
// Test the line iterator
var iter = s.lineIterator(s.pages.pin(.{ .viewport = .{} }).?);
{
const sel = iter.next().?;
const actual = try s.selectionString(alloc, .{
.sel = sel,
.trim = false,
});
defer alloc.free(actual);
try testing.expectEqualStrings("1ABCD2EFGH", actual);
}
{
const sel = iter.next().?;
const actual = try s.selectionString(alloc, .{
.sel = sel,
.trim = false,
});
defer alloc.free(actual);
try testing.expectEqualStrings("3ABCD", actual);
}
// try testing.expect(iter.next() == null);
}
test "Screen: hyperlink start/end" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 5, 5, 0);
defer s.deinit();
try testing.expect(s.cursor.hyperlink_id == 0);
{
const page = &s.cursor.page_pin.node.data;
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;
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;
try testing.expectEqual(0, page.hyperlink_set.count());
}
}
test "Screen: hyperlink reuse" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 5, 5, 0);
defer s.deinit();
try testing.expect(s.cursor.hyperlink_id == 0);
{
const page = &s.cursor.page_pin.node.data;
try testing.expectEqual(0, page.hyperlink_set.count());
}
// Use it for the first time
try s.startHyperlink("http://example.com", null);
try testing.expect(s.cursor.hyperlink_id != 0);
const id = s.cursor.hyperlink_id;
// Reuse the same hyperlink, expect we have the same ID
try s.startHyperlink("http://example.com", null);
try testing.expectEqual(id, s.cursor.hyperlink_id);
{
const page = &s.cursor.page_pin.node.data;
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;
try testing.expectEqual(0, page.hyperlink_set.count());
}
}
test "Screen: hyperlink cursor state on resize" {
const testing = std.testing;
const alloc = testing.allocator;
// This test depends on underlying PageList implementation so
// it may be invalid one day. It's here to document/verify the
// current behavior.
var s = try init(alloc, 5, 10, 0);
defer s.deinit();
// Start a hyperlink
try s.startHyperlink("http://example.com", null);
try testing.expect(s.cursor.hyperlink_id != 0);
{
const page = &s.cursor.page_pin.node.data;
try testing.expectEqual(1, page.hyperlink_set.count());
}
// Resize. Any column growth will trigger a page to be reallocated.
try s.resize(10, 10);
try testing.expect(s.cursor.hyperlink_id != 0);
{
const page = &s.cursor.page_pin.node.data;
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;
try testing.expectEqual(0, page.hyperlink_set.count());
}
}
test "Screen: adjustCapacity cursor style ref count" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 5, 5, 0);
defer s.deinit();
try s.setAttribute(.{ .bold = {} });
try s.testWriteString("1ABCD");
{
const page = &s.pages.pages.last.?.data;
try testing.expectEqual(
6, // All chars + cursor
page.styles.refCount(page.memory, s.cursor.style_id),
);
}
// This forces the page to change.
_ = try s.adjustCapacity(
s.cursor.page_pin.node,
.{ .grapheme_bytes = s.cursor.page_pin.node.data.capacity.grapheme_bytes * 2 },
);
// Our ref counts should still be the same
{
const page = &s.pages.pages.last.?.data;
try testing.expectEqual(
6, // All chars + cursor
page.styles.refCount(page.memory, s.cursor.style_id),
);
}
}
test "Screen UTF8 cell map with newlines" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try Screen.init(alloc, 80, 24, 0);
defer s.deinit();
try s.testWriteString("A\n\nB\n\nC");
var cell_map = Page.CellMap.init(alloc);
defer cell_map.deinit();
var builder = std.ArrayList(u8).init(alloc);
defer builder.deinit();
try s.dumpString(builder.writer(), .{
.tl = s.pages.getTopLeft(.screen),
.br = s.pages.getBottomRight(.screen),
.cell_map = &cell_map,
});
try testing.expectEqual(7, builder.items.len);
try testing.expectEqualStrings("A\n\nB\n\nC", builder.items);
try testing.expectEqual(builder.items.len, cell_map.items.len);
try testing.expectEqual(Page.CellMapEntry{
.x = 0,
.y = 0,
}, cell_map.items[0]);
try testing.expectEqual(Page.CellMapEntry{
.x = 1,
.y = 0,
}, cell_map.items[1]);
try testing.expectEqual(Page.CellMapEntry{
.x = 0,
.y = 1,
}, cell_map.items[2]);
try testing.expectEqual(Page.CellMapEntry{
.x = 0,
.y = 2,
}, cell_map.items[3]);
}
test "Screen UTF8 cell map with blank prefix" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try Screen.init(alloc, 80, 24, 0);
defer s.deinit();
s.cursorAbsolute(2, 1);
try s.testWriteString("B");
var cell_map = Page.CellMap.init(alloc);
defer cell_map.deinit();
var builder = std.ArrayList(u8).init(alloc);
defer builder.deinit();
try s.dumpString(builder.writer(), .{
.tl = s.pages.getTopLeft(.screen),
.br = s.pages.getBottomRight(.screen),
.cell_map = &cell_map,
});
try testing.expectEqualStrings("\n B", builder.items);
try testing.expectEqual(builder.items.len, cell_map.items.len);
try testing.expectEqual(Page.CellMapEntry{
.x = 0,
.y = 0,
}, cell_map.items[0]);
try testing.expectEqual(Page.CellMapEntry{
.x = 0,
.y = 1,
}, cell_map.items[1]);
try testing.expectEqual(Page.CellMapEntry{
.x = 1,
.y = 1,
}, cell_map.items[2]);
try testing.expectEqual(Page.CellMapEntry{
.x = 2,
.y = 1,
}, cell_map.items[3]);
}