ghostty/src/terminal/Screen.zig

7714 lines
254 KiB
Zig
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! Screen represents the internal storage for a terminal screen, including
//! scrollback. This is implemented as a single continuous ring buffer.
//!
//! Definitions:
//!
//! * Screen - The full screen (active + history).
//! * Active - The area that is the current edit-able screen (the
//! bottom of the scrollback). This is "edit-able" because it is
//! the only part that escape sequences such as set cursor position
//! actually affect.
//! * History - The area that contains the lines prior to the active
//! area. This is the scrollback area. Escape sequences can no longer
//! affect this area.
//! * Viewport - The area that is currently visible to the user. This
//! can be thought of as the current window into the screen.
//! * Row - A single visible row in the screen.
//! * Line - A single line of text. This may map to multiple rows if
//! the row is soft-wrapped.
//!
//! The internal storage of the screen is stored in a circular buffer
//! with roughly the following format:
//!
//! Storage (Circular Buffer)
//! ┌─────────────────────────────────────┐
//! │ ┌─────┐┌─────┐┌─────┐ ┌─────┐ │
//! │ │ Hdr ││Cell ││Cell │ ... │Cell │ │
//! │ │ ││ 0 ││ 1 │ │ N-1 │ │
//! │ └─────┘└─────┘└─────┘ └─────┘ │
//! │ ┌─────┐┌─────┐┌─────┐ ┌─────┐ │
//! │ │ Hdr ││Cell ││Cell │ ... │Cell │ │
//! │ │ ││ 0 ││ 1 │ │ N-1 │ │
//! │ └─────┘└─────┘└─────┘ └─────┘ │
//! │ ┌─────┐┌─────┐┌─────┐ ┌─────┐ │
//! │ │ Hdr ││Cell ││Cell │ ... │Cell │ │
//! │ │ ││ 0 ││ 1 │ │ N-1 │ │
//! │ └─────┘└─────┘└─────┘ └─────┘ │
//! └─────────────────────────────────────┘
//!
//! There are R rows with N columns. Each row has an extra "cell" which is
//! the row header. The row header is used to track metadata about the row.
//! Each cell itself is a union (see StorageCell) of either the header or
//! the cell.
//!
//! The storage is in a circular buffer so that scrollback can be handled
//! without copying rows. The circular buffer is implemented in circ_buf.zig.
//! The top of the circular buffer (index 0) is the top of the screen,
//! i.e. the scrollback if there is a lot of data.
//!
//! The top of the active area (or end of the history area, same thing) is
//! cached in `self.history` and is an offset in rows. This could always be
//! calculated but profiling showed that caching it saves a lot of time in
//! hot loops for minimal memory cost.
const Screen = @This();
const std = @import("std");
const builtin = @import("builtin");
const assert = std.debug.assert;
const Allocator = std.mem.Allocator;
const ziglyph = @import("ziglyph");
const trace = @import("tracy").trace;
const ansi = @import("ansi.zig");
const modes = @import("modes.zig");
const sgr = @import("sgr.zig");
const color = @import("color.zig");
const kitty = @import("kitty.zig");
const point = @import("point.zig");
const CircBuf = @import("../circ_buf.zig").CircBuf;
const Selection = @import("Selection.zig");
const StringMap = @import("StringMap.zig");
const fastmem = @import("../fastmem.zig");
const charsets = @import("charsets.zig");
const log = std.log.scoped(.screen);
/// State required for all charset operations.
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);
};
/// Cursor represents the cursor state.
pub const Cursor = struct {
/// x, y where the cursor currently exists (0-indexed). This x/y is
/// always the offset in the active area.
x: usize = 0,
y: usize = 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.
style: Style = .block,
/// pen is the current cell styling to apply to new cells.
pen: Cell = .{ .char = 0 },
/// The last column flag (LCF) used to do soft wrapping.
pending_wrap: bool = false,
/// 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 Style = enum { bar, block, underline };
/// Saved cursor state. This contains more than just Cursor members
/// because additional state is stored.
pub const Saved = struct {
x: usize,
y: usize,
pen: Cell,
pending_wrap: bool,
origin: bool,
charset: CharsetState,
};
};
/// This is a single item within the storage buffer. We use a union to
/// have different types of data in a single contiguous buffer.
const StorageCell = union {
header: RowHeader,
cell: Cell,
test {
// log.warn("header={}@{} cell={}@{} storage={}@{}", .{
// @sizeOf(RowHeader),
// @alignOf(RowHeader),
// @sizeOf(Cell),
// @alignOf(Cell),
// @sizeOf(StorageCell),
// @alignOf(StorageCell),
// });
}
comptime {
// We only check this during ReleaseFast because safety checks
// have to be disabled to get this size.
if (!std.debug.runtime_safety) {
// We want to be at most the size of a cell always. We have WAY
// more cells than other fields, so we don't want to pay the cost
// of padding due to other fields.
assert(@sizeOf(Cell) == @sizeOf(StorageCell));
} else {
// Extra u32 for the tag for safety checks. This is subject to
// change depending on the Zig compiler...
assert((@sizeOf(Cell) + @sizeOf(u32)) == @sizeOf(StorageCell));
}
}
};
/// The row header is at the start of every row within the storage buffer.
/// It can store row-specific data.
pub const RowHeader = struct {
pub const Id = u32;
/// The ID of this row, used to uniquely identify this row. The cells
/// are also ID'd by id + cell index (0-indexed). This will wrap around
/// when it reaches the maximum value for the type. For caching purposes,
/// when wrapping happens, all rows in the screen will be marked dirty.
id: Id = 0,
// Packed flags
flags: packed struct {
/// If true, this row is soft-wrapped. The first cell of the next
/// row is a continuous of this row.
wrap: bool = false,
/// True if this row has had changes. It is up to the caller to
/// set this to false. See the methods on Row to see what will set
/// this to true.
dirty: bool = false,
/// True if any cell in this row has a grapheme associated with it.
grapheme: bool = false,
/// True if this row is an active prompt (awaiting input). This is
/// set to false when the semantic prompt events (OSC 133) are received.
/// There are scenarios where the shell may never send this event, so
/// in order to reliably test prompt status, you need to iterate
/// backwards from the cursor to check the current line status going
/// back.
semantic_prompt: SemanticPrompt = .unknown,
} = .{},
/// Semantic prompt type.
pub const SemanticPrompt = enum(u3) {
/// Unknown, the running application didn't tell us for this line.
unknown = 0,
/// This is a prompt line, meaning it only contains the shell prompt.
/// For poorly behaving shells, this may also be the input.
prompt = 1,
prompt_continuation = 2,
/// This line contains the input area. We don't currently track
/// where this actually is in the line, so we just assume it is somewhere.
input = 3,
/// This line is the start of command output.
command = 4,
};
};
/// The color associated with a single cell's foreground or background.
const CellColor = union(enum) {
none,
indexed: u8,
rgb: color.RGB,
pub fn eql(self: CellColor, other: CellColor) bool {
return switch (self) {
.none => other == .none,
.indexed => |i| switch (other) {
.indexed => other.indexed == i,
else => false,
},
.rgb => |rgb| switch (other) {
.rgb => other.rgb.eql(rgb),
else => false,
},
};
}
};
/// Cell is a single cell within the screen.
pub const Cell = struct {
/// The primary unicode codepoint for this cell. Most cells (almost all)
/// contain exactly one unicode codepoint. However, it is possible for
/// cells to contain multiple if multiple codepoints are used to create
/// a single grapheme cluster.
///
/// In the case multiple codepoints make up a single grapheme, the
/// additional codepoints can be looked up in the hash map on the
/// Screen. Since multi-codepoints graphemes are rare, we don't want to
/// waste memory for every cell, so we use a side lookup for it.
char: u32 = 0,
/// Foreground and background color.
fg: CellColor = .none,
bg: CellColor = .none,
/// Underline color.
/// NOTE(mitchellh): This is very rarely set so ideally we wouldn't waste
/// cell space for this. For now its on this struct because it is convenient
/// but we should consider a lookaside table for this.
underline_fg: color.RGB = .{},
/// On/off attributes that can be set
attrs: packed struct {
bold: bool = false,
italic: bool = false,
faint: bool = false,
blink: bool = false,
inverse: bool = false,
invisible: bool = false,
strikethrough: bool = false,
underline: sgr.Attribute.Underline = .none,
underline_color: bool = false,
protected: bool = false,
/// True if this is a wide character. This char takes up
/// two cells. The following cell ALWAYS is a space.
wide: bool = false,
/// Notes that this only exists to be blank for a preceding
/// wide character (tail) or following (head).
wide_spacer_tail: bool = false,
wide_spacer_head: bool = false,
/// True if this cell has additional codepoints to form a complete
/// grapheme cluster. If this is true, then the row grapheme flag must
/// also be true. The grapheme code points can be looked up in the
/// screen grapheme map.
grapheme: bool = false,
/// Returns only the attributes related to style.
pub fn styleAttrs(self: @This()) @This() {
var copy = self;
copy.wide = false;
copy.wide_spacer_tail = false;
copy.wide_spacer_head = false;
copy.grapheme = false;
return copy;
}
} = .{},
/// True if the cell should be skipped for drawing
pub fn empty(self: Cell) bool {
// Get our backing integer for our packed struct of attributes
const AttrInt = @Type(.{ .Int = .{
.signedness = .unsigned,
.bits = @bitSizeOf(@TypeOf(self.attrs)),
} });
// We're empty if we have no char AND we have no styling
return self.char == 0 and
self.fg == .none and
self.bg == .none and
@as(AttrInt, @bitCast(self.attrs)) == 0;
}
/// The width of the cell.
///
/// This uses the legacy calculation of a per-codepoint width calculation
/// to determine the width. This legacy calculation is incorrect because
/// it doesn't take into account multi-codepoint graphemes.
///
/// The goal of this function is to match the expectation of shells
/// that aren't grapheme aware (at the time of writing this comment: none
/// are grapheme aware). This means it should match wcswidth.
pub fn widthLegacy(self: Cell) u8 {
// Wide is always 2
if (self.attrs.wide) return 2;
// Wide spacers are always 0 because their width is accounted for
// in the wide char.
if (self.attrs.wide_spacer_tail or self.attrs.wide_spacer_head) return 0;
return 1;
}
test "widthLegacy" {
const testing = std.testing;
var c: Cell = .{};
try testing.expectEqual(@as(u16, 1), c.widthLegacy());
c = .{ .attrs = .{ .wide = true } };
try testing.expectEqual(@as(u16, 2), c.widthLegacy());
c = .{ .attrs = .{ .wide_spacer_tail = true } };
try testing.expectEqual(@as(u16, 0), c.widthLegacy());
}
test {
// We use this test to ensure we always get the right size of the attrs
// const cell: Cell = .{ .char = 0 };
// _ = @bitCast(u8, cell.attrs);
// try std.testing.expectEqual(1, @sizeOf(@TypeOf(cell.attrs)));
}
test {
//log.warn("CELL={} bits={} {}", .{ @sizeOf(Cell), @bitSizeOf(Cell), @alignOf(Cell) });
try std.testing.expectEqual(20, @sizeOf(Cell));
}
};
/// A row is a single row in the screen.
pub const Row = struct {
/// The screen this row is part of.
screen: *Screen,
/// Raw internal storage, do NOT write to this, use only the
/// helpers. Writing directly to this can easily mess up state
/// causing future crashes or misrendering.
storage: []StorageCell,
/// Returns the ID for this row. You can turn this into a cell ID
/// by adding the cell offset plus 1 (so it is 1-indexed).
pub inline fn getId(self: Row) RowHeader.Id {
return self.storage[0].header.id;
}
/// Set that this row is soft-wrapped. This doesn't change the contents
/// of this row so the row won't be marked dirty.
pub fn setWrapped(self: Row, v: bool) void {
self.storage[0].header.flags.wrap = v;
}
/// Set a row as dirty or not. Generally you only set a row as NOT dirty.
/// Various Row functions manage flagging dirty to true.
pub fn setDirty(self: Row, v: bool) void {
self.storage[0].header.flags.dirty = v;
}
pub inline fn isDirty(self: Row) bool {
return self.storage[0].header.flags.dirty;
}
pub inline fn isWrapped(self: Row) bool {
return self.storage[0].header.flags.wrap;
}
/// Set the semantic prompt state for this row.
pub fn setSemanticPrompt(self: Row, p: RowHeader.SemanticPrompt) void {
self.storage[0].header.flags.semantic_prompt = p;
}
/// Retrieve the semantic prompt state for this row.
pub fn getSemanticPrompt(self: Row) RowHeader.SemanticPrompt {
return self.storage[0].header.flags.semantic_prompt;
}
/// Retrieve the header for this row.
pub fn header(self: Row) RowHeader {
return self.storage[0].header;
}
/// Returns the number of cells in this row.
pub fn lenCells(self: Row) usize {
return self.storage.len - 1;
}
/// Returns true if the row only has empty characters. This ignores
/// styling (i.e. styling does not count as non-empty).
pub fn isEmpty(self: Row) bool {
const len = self.storage.len;
for (self.storage[1..len]) |cell| {
if (cell.cell.char != 0) return false;
}
return true;
}
/// Clear the row, making all cells empty.
pub fn clear(self: Row, pen: Cell) void {
var empty_pen = pen;
empty_pen.char = 0;
self.fill(empty_pen);
}
/// Fill the entire row with a copy of a single cell.
pub fn fill(self: Row, cell: Cell) void {
self.fillSlice(cell, 0, self.storage.len - 1);
}
/// Fill a slice of a row.
pub fn fillSlice(self: Row, cell: Cell, start: usize, len: usize) void {
assert(len <= self.storage.len - 1);
assert(!cell.attrs.grapheme); // you can't fill with graphemes
// Always mark the row as dirty for this.
self.storage[0].header.flags.dirty = true;
// If our row has no graphemes, then this is a fast copy
if (!self.storage[0].header.flags.grapheme) {
@memset(self.storage[start + 1 .. len + 1], .{ .cell = cell });
return;
}
// We have graphemes, so we have to clear those first.
for (self.storage[start + 1 .. len + 1], 0..) |*storage_cell, x| {
if (storage_cell.cell.attrs.grapheme) self.clearGraphemes(x);
storage_cell.* = .{ .cell = cell };
}
// We only reset the grapheme flag if we fill the whole row, for now.
// We can improve performance by more correctly setting this but I'm
// going to defer that until we can measure.
if (start == 0 and len == self.storage.len - 1) {
self.storage[0].header.flags.grapheme = false;
}
}
/// Get a single immutable cell.
pub fn getCell(self: Row, x: usize) Cell {
assert(x < self.storage.len - 1);
return self.storage[x + 1].cell;
}
/// Get a pointr to the cell at column x (0-indexed). This always
/// assumes that the cell was modified, notifying the renderer on the
/// next call to re-render this cell. Any change detection to avoid
/// this should be done prior.
pub fn getCellPtr(self: Row, x: usize) *Cell {
assert(x < self.storage.len - 1);
// Always mark the row as dirty for this.
self.storage[0].header.flags.dirty = true;
return &self.storage[x + 1].cell;
}
/// Attach a grapheme codepoint to the given cell.
pub fn attachGrapheme(self: Row, x: usize, cp: u21) !void {
assert(x < self.storage.len - 1);
const cell = &self.storage[x + 1].cell;
const key = self.getId() + x + 1;
const gop = try self.screen.graphemes.getOrPut(self.screen.alloc, key);
errdefer if (!gop.found_existing) {
_ = self.screen.graphemes.remove(key);
};
// Our row now has a grapheme
self.storage[0].header.flags.grapheme = true;
// Our row is now dirty
self.storage[0].header.flags.dirty = true;
// If we weren't previously a grapheme and we found an existing value
// it means that it is old grapheme data. Just delete that.
if (!cell.attrs.grapheme and gop.found_existing) {
cell.attrs.grapheme = true;
gop.value_ptr.deinit(self.screen.alloc);
gop.value_ptr.* = .{ .one = cp };
return;
}
// If we didn't have a previous value, attach the single codepoint.
if (!gop.found_existing) {
cell.attrs.grapheme = true;
gop.value_ptr.* = .{ .one = cp };
return;
}
// We have an existing value, promote
assert(cell.attrs.grapheme);
try gop.value_ptr.append(self.screen.alloc, cp);
}
/// Removes all graphemes associated with a cell.
pub fn clearGraphemes(self: Row, x: usize) void {
assert(x < self.storage.len - 1);
// Our row is now dirty
self.storage[0].header.flags.dirty = true;
const cell = &self.storage[x + 1].cell;
const key = self.getId() + x + 1;
cell.attrs.grapheme = false;
if (self.screen.graphemes.fetchRemove(key)) |kv| {
kv.value.deinit(self.screen.alloc);
}
}
/// Copy a single cell from column x in src to column x in this row.
pub fn copyCell(self: Row, src: Row, x: usize) !void {
const dst_cell = self.getCellPtr(x);
const src_cell = src.getCellPtr(x);
// If our destination has graphemes, we have to clear them.
if (dst_cell.attrs.grapheme) self.clearGraphemes(x);
dst_cell.* = src_cell.*;
// If the source doesn't have any graphemes, then we can just copy.
if (!src_cell.attrs.grapheme) return;
// Source cell has graphemes. Copy them.
const src_key = src.getId() + x + 1;
const src_data = src.screen.graphemes.get(src_key) orelse return;
const dst_key = self.getId() + x + 1;
const dst_gop = try self.screen.graphemes.getOrPut(self.screen.alloc, dst_key);
dst_gop.value_ptr.* = try src_data.copy(self.screen.alloc);
self.storage[0].header.flags.grapheme = true;
}
/// Copy the row src into this row. The row can be from another screen.
pub fn copyRow(self: Row, src: Row) !void {
// If we have graphemes, clear first to unset them.
if (self.storage[0].header.flags.grapheme) self.clear(.{});
// Copy the flags
self.storage[0].header.flags = src.storage[0].header.flags;
// Always mark the row as dirty for this.
self.storage[0].header.flags.dirty = true;
// If the source has no graphemes (likely) then this is fast.
const end = @min(src.storage.len, self.storage.len);
if (!src.storage[0].header.flags.grapheme) {
fastmem.copy(StorageCell, self.storage[1..], src.storage[1..end]);
return;
}
// Source has graphemes, this is slow.
for (src.storage[1..end], 0..) |storage, x| {
self.storage[x + 1] = .{ .cell = storage.cell };
// Copy grapheme data if it exists
if (storage.cell.attrs.grapheme) {
const src_key = src.getId() + x + 1;
const src_data = src.screen.graphemes.get(src_key) orelse continue;
const dst_key = self.getId() + x + 1;
const dst_gop = try self.screen.graphemes.getOrPut(self.screen.alloc, dst_key);
dst_gop.value_ptr.* = try src_data.copy(self.screen.alloc);
self.storage[0].header.flags.grapheme = true;
}
}
}
/// Read-only iterator for the cells in the row.
pub fn cellIterator(self: Row) CellIterator {
return .{ .row = self };
}
/// Returns the number of codepoints in the cell at column x,
/// including the primary codepoint.
pub fn codepointLen(self: Row, x: usize) usize {
var it = self.codepointIterator(x);
return it.len() + 1;
}
/// Read-only iterator for the grapheme codepoints in a cell. This only
/// iterates over the EXTRA GRAPHEME codepoints and not the primary
/// codepoint in cell.char.
pub fn codepointIterator(self: Row, x: usize) CodepointIterator {
const cell = &self.storage[x + 1].cell;
if (!cell.attrs.grapheme) return .{ .data = .{ .zero = {} } };
const key = self.getId() + x + 1;
const data: GraphemeData = self.screen.graphemes.get(key) orelse data: {
// This is probably a bug somewhere in our internal state,
// but we don't want to just hard crash so its easier to just
// have zero codepoints.
log.debug("cell with grapheme flag but no grapheme data", .{});
break :data .{ .zero = {} };
};
return .{ .data = data };
}
/// Returns true if this cell is the end of a grapheme cluster.
///
/// NOTE: If/when "real" grapheme cluster support is in then
/// this will be removed because every cell will represent exactly
/// one grapheme cluster.
pub fn graphemeBreak(self: Row, x: usize) bool {
const cell = &self.storage[x + 1].cell;
// Right now, if we are a grapheme, we only store ZWJs on
// the grapheme data so that means we can't be a break.
if (cell.attrs.grapheme) return false;
// If we are a tail then we check our prior cell.
if (cell.attrs.wide_spacer_tail and x > 0) {
return self.graphemeBreak(x - 1);
}
// If we are a wide char, then we have to check our prior cell.
if (cell.attrs.wide and x > 0) {
return self.graphemeBreak(x - 1);
}
return true;
}
};
/// Used to iterate through the rows of a specific region.
pub const RowIterator = struct {
screen: *Screen,
tag: RowIndexTag,
max: usize,
value: usize = 0,
pub fn next(self: *RowIterator) ?Row {
if (self.value >= self.max) return null;
const idx = self.tag.index(self.value);
const res = self.screen.getRow(idx);
self.value += 1;
return res;
}
};
/// Used to iterate through the rows of a specific region.
pub const CellIterator = struct {
row: Row,
i: usize = 0,
pub fn next(self: *CellIterator) ?Cell {
if (self.i >= self.row.storage.len - 1) return null;
const res = self.row.storage[self.i + 1].cell;
self.i += 1;
return res;
}
};
/// Used to iterate through the codepoints of a cell. This only iterates
/// over the extra grapheme codepoints and not the primary codepoint.
pub const CodepointIterator = struct {
data: GraphemeData,
i: usize = 0,
/// Returns the number of codepoints in the iterator.
pub fn len(self: CodepointIterator) usize {
switch (self.data) {
.zero => return 0,
.one => return 1,
.two => return 2,
.three => return 3,
.four => return 4,
.many => |v| return v.len,
}
}
pub fn next(self: *CodepointIterator) ?u21 {
switch (self.data) {
.zero => return null,
.one => |v| {
if (self.i >= 1) return null;
self.i += 1;
return v;
},
.two => |v| {
if (self.i >= v.len) return null;
defer self.i += 1;
return v[self.i];
},
.three => |v| {
if (self.i >= v.len) return null;
defer self.i += 1;
return v[self.i];
},
.four => |v| {
if (self.i >= v.len) return null;
defer self.i += 1;
return v[self.i];
},
.many => |v| {
if (self.i >= v.len) return null;
defer self.i += 1;
return v[self.i];
},
}
}
pub fn reset(self: *CodepointIterator) void {
self.i = 0;
}
};
/// RowIndex represents a row within the screen. There are various meanings
/// of a row index and this union represents the available types. For example,
/// when talking about row "0" you may want the first row in the viewport,
/// the first row in the scrollback, or the first row in the active area.
///
/// All row indexes are 0-indexed.
pub const RowIndex = union(RowIndexTag) {
/// The index is from the top of the screen. The screen includes all
/// the history.
screen: usize,
/// The index is from the top of the viewport. Therefore, depending
/// on where the user has scrolled the viewport, "0" is different.
viewport: usize,
/// The index is from the top of the active area. The active area is
/// always "rows" tall, and 0 is the top row. The active area is the
/// "edit-able" area where the terminal cursor is.
active: usize,
/// The index is from the top of the history (scrollback) to just
/// prior to the active area.
history: usize,
/// Convert this row index into a screen offset. This will validate
/// the value so even if it is already a screen value, this may error.
pub fn toScreen(self: RowIndex, screen: *const Screen) RowIndex {
const y = switch (self) {
.screen => |y| y: {
// NOTE for this and others below: Zig is supposed to optimize
// away assert in releasefast but for some reason these were
// not being optimized away. I don't know why. For these asserts
// only, I comptime gate them.
if (std.debug.runtime_safety) assert(y < RowIndexTag.screen.maxLen(screen));
break :y y;
},
.viewport => |y| y: {
if (std.debug.runtime_safety) assert(y < RowIndexTag.viewport.maxLen(screen));
break :y y + screen.viewport;
},
.active => |y| y: {
if (std.debug.runtime_safety) assert(y < RowIndexTag.active.maxLen(screen));
break :y screen.history + y;
},
.history => |y| y: {
if (std.debug.runtime_safety) assert(y < RowIndexTag.history.maxLen(screen));
break :y y;
},
};
return .{ .screen = y };
}
};
/// The tags of RowIndex
pub const RowIndexTag = enum {
screen,
viewport,
active,
history,
/// The max length for a given tag. This is a length, not an index,
/// so it is 1-indexed. If the value is zero, it means that this
/// section of the screen is empty or disabled.
pub inline fn maxLen(self: RowIndexTag, screen: *const Screen) usize {
const tracy = trace(@src());
defer tracy.end();
return switch (self) {
// Screen can be any of the written rows
.screen => screen.rowsWritten(),
// Viewport can be any of the written rows or the max size
// of a viewport.
.viewport => @max(1, @min(screen.rows, screen.rowsWritten())),
// History is all the way up to the top of our active area. If
// we haven't filled our active area, there is no history.
.history => screen.history,
// Active area can be any number of rows. We ignore rows
// written here because this is the only row index that can
// actively grow our rows.
.active => screen.rows,
//TODO .active => @min(rows_written, screen.rows),
};
}
/// Construct a RowIndex from a tag.
pub fn index(self: RowIndexTag, value: usize) RowIndex {
return switch (self) {
.screen => .{ .screen = value },
.viewport => .{ .viewport = value },
.active => .{ .active = value },
.history => .{ .history = value },
};
}
};
/// Stores the extra unicode codepoints that form a complete grapheme
/// cluster alongside a cell. We store this separately from a Cell because
/// grapheme clusters are relatively rare (depending on the language) and
/// we don't want to pay for the full cost all the time.
pub const GraphemeData = union(enum) {
// The named counts allow us to avoid allocators. We do this because
// []u21 is sizeof([4]u21) anyways so if we can store avoid small allocations
// we prefer it. Grapheme clusters are almost always <= 4 codepoints.
zero: void,
one: u21,
two: [2]u21,
three: [3]u21,
four: [4]u21,
many: []u21,
pub fn deinit(self: GraphemeData, alloc: Allocator) void {
switch (self) {
.many => |v| alloc.free(v),
else => {},
}
}
/// Append the codepoint cp to the grapheme data.
pub fn append(self: *GraphemeData, alloc: Allocator, cp: u21) !void {
switch (self.*) {
.zero => self.* = .{ .one = cp },
.one => |v| self.* = .{ .two = .{ v, cp } },
.two => |v| self.* = .{ .three = .{ v[0], v[1], cp } },
.three => |v| self.* = .{ .four = .{ v[0], v[1], v[2], cp } },
.four => |v| {
const many = try alloc.alloc(u21, 5);
fastmem.copy(u21, many, &v);
many[4] = cp;
self.* = .{ .many = many };
},
.many => |v| {
// Note: this is super inefficient, we should use an arraylist
// or something so we have extra capacity.
const many = try alloc.realloc(v, v.len + 1);
many[v.len] = cp;
self.* = .{ .many = many };
},
}
}
pub fn copy(self: GraphemeData, alloc: Allocator) !GraphemeData {
// If we're not many we're not allocated so just copy on stack.
if (self != .many) return self;
// Heap allocated
return GraphemeData{ .many = try alloc.dupe(u21, self.many) };
}
test {
log.warn("Grapheme={}", .{@sizeOf(GraphemeData)});
}
test "append" {
const testing = std.testing;
const alloc = testing.allocator;
var data: GraphemeData = .{ .one = 1 };
defer data.deinit(alloc);
try data.append(alloc, 2);
try testing.expectEqual(GraphemeData{ .two = .{ 1, 2 } }, data);
try data.append(alloc, 3);
try testing.expectEqual(GraphemeData{ .three = .{ 1, 2, 3 } }, data);
try data.append(alloc, 4);
try testing.expectEqual(GraphemeData{ .four = .{ 1, 2, 3, 4 } }, data);
try data.append(alloc, 5);
try testing.expect(data == .many);
try testing.expectEqualSlices(u21, &[_]u21{ 1, 2, 3, 4, 5 }, data.many);
try data.append(alloc, 6);
try testing.expect(data == .many);
try testing.expectEqualSlices(u21, &[_]u21{ 1, 2, 3, 4, 5, 6 }, data.many);
}
comptime {
// We want to keep this at most the size of the tag + []u21 so that
// at most we're paying for the cost of a slice.
//assert(@sizeOf(GraphemeData) == 24);
}
};
/// A line represents a line of text, potentially across soft-wrapped
/// boundaries. This differs from row, which is a single physical row within
/// the terminal screen.
pub const Line = struct {
screen: *Screen,
tag: RowIndexTag,
start: usize,
len: usize,
/// Return the string for this line.
pub fn string(self: *const Line, alloc: Allocator) ![:0]const u8 {
return try self.screen.selectionString(alloc, self.selection(), true);
}
/// Receive the string for this line along with the byte-to-point mapping.
pub fn stringMap(self: *const Line, alloc: Allocator) !StringMap {
return try self.screen.selectionStringMap(alloc, self.selection());
}
/// Return a selection that covers the entire line.
pub fn selection(self: *const Line) Selection {
// Get the start and end screen point.
const start_idx = self.tag.index(self.start).toScreen(self.screen).screen;
const end_idx = self.tag.index(self.start + (self.len - 1)).toScreen(self.screen).screen;
// Convert the start and end screen points into a selection across
// the entire rows. We then use selectionString because it handles
// unwrapping, graphemes, etc.
return .{
.start = .{ .y = start_idx, .x = 0 },
.end = .{ .y = end_idx, .x = self.screen.cols - 1 },
};
}
};
/// Iterator over textual lines within the terminal. This will unwrap
/// wrapped lines and consider them a single line.
pub const LineIterator = struct {
row_it: RowIterator,
pub fn next(self: *LineIterator) ?Line {
const start = self.row_it.value;
// Get our current row
var row = self.row_it.next() orelse return null;
var len: usize = 1;
// While the row is wrapped we keep iterating over the rows
// and incrementing the length.
while (row.isWrapped()) {
// Note: this orelse shouldn't happen. A wrapped row should
// always have a next row. However, this isn't the place where
// we want to assert that.
row = self.row_it.next() orelse break;
len += 1;
}
return .{
.screen = self.row_it.screen,
.tag = self.row_it.tag,
.start = start,
.len = len,
};
}
};
// Initialize to header and not a cell so that we can check header.init
// to know if the remainder of the row has been initialized or not.
const StorageBuf = CircBuf(StorageCell, .{ .header = .{} });
/// Stores a mapping of cell ID (row ID + cell offset + 1) to
/// graphemes associated with a cell. To know if a cell has graphemes,
/// check the "grapheme" flag of a cell.
const GraphemeMap = std.AutoHashMapUnmanaged(usize, GraphemeData);
/// The allocator used for all the storage operations
alloc: Allocator,
/// The full set of storage.
storage: StorageBuf,
/// Graphemes associated with our current screen.
graphemes: GraphemeMap = .{},
/// The next ID to assign to a row. The value of this is NOT assigned.
next_row_id: RowHeader.Id = 1,
/// The number of rows and columns in the visible space.
rows: usize,
cols: usize,
/// The maximum number of lines that are available in scrollback. This
/// is in addition to the number of visible rows.
max_scrollback: usize,
/// The row (offset from the top) where the viewport currently is.
viewport: usize,
/// The amount of history (scrollback) that has been written so far. This
/// can be calculated dynamically using the storage buffer but its an
/// extremely hot piece of data so we cache it. Empirically this eliminates
/// millions of function calls and saves seconds under high scroll scenarios
/// (i.e. reading a large file).
history: usize,
/// Each screen maintains its own cursor state.
cursor: Cursor = .{},
/// Saved cursor saved with DECSC (ESC 7).
saved_cursor: ?Cursor.Saved = null,
/// The selection for this screen (if any).
selection: ?Selection = null,
/// The kitty keyboard settings.
kitty_keyboard: kitty.KeyFlagStack = .{},
/// Kitty graphics protocol state.
kitty_images: kitty.graphics.ImageStorage = .{},
/// 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,
/// Initialize a new screen.
pub fn init(
alloc: Allocator,
rows: usize,
cols: usize,
max_scrollback: usize,
) !Screen {
// * Our buffer size is preallocated to fit double our visible space
// or the maximum scrollback whichever is smaller.
// * We add +1 to cols to fit the row header
const buf_size = (rows + @min(max_scrollback, rows)) * (cols + 1);
return Screen{
.alloc = alloc,
.storage = try StorageBuf.init(alloc, buf_size),
.rows = rows,
.cols = cols,
.max_scrollback = max_scrollback,
.viewport = 0,
.history = 0,
};
}
pub fn deinit(self: *Screen) void {
self.kitty_images.deinit(self.alloc);
self.storage.deinit(self.alloc);
self.deinitGraphemes();
}
fn deinitGraphemes(self: *Screen) void {
var grapheme_it = self.graphemes.valueIterator();
while (grapheme_it.next()) |data| data.deinit(self.alloc);
self.graphemes.deinit(self.alloc);
}
/// Copy the screen portion given by top and bottom into a new screen instance.
/// This clone is meant for read-only access and hasn't been tested for
/// mutability.
pub fn clone(self: *Screen, alloc: Allocator, top: RowIndex, bottom: RowIndex) !Screen {
// Convert our top/bottom to screen coordinates
const top_y = top.toScreen(self).screen;
const bot_y = bottom.toScreen(self).screen;
assert(bot_y >= top_y);
const height = (bot_y - top_y) + 1;
// We also figure out the "max y" we can have based on the number
// of rows written. This is used to prevent from reading out of the
// circular buffer where we might have no initialized data yet.
const max_y = max_y: {
const rows_written = self.rowsWritten();
const index = RowIndex{ .active = @min(rows_written -| 1, self.rows - 1) };
break :max_y index.toScreen(self).screen;
};
// The "real" Y value we use is whichever is smaller: the bottom
// requested or the max. This prevents from reading zero data.
// The "real" height is the amount of height of data we can actually
// copy.
const real_y = @min(bot_y, max_y);
const real_height = (real_y - top_y) + 1;
//log.warn("bot={} max={} top={} real={}", .{ bot_y, max_y, top_y, real_y });
// Init a new screen that exactly fits the height. The height is the
// non-real value because we still want the requested height by the
// caller.
var result = try init(alloc, height, self.cols, 0);
errdefer result.deinit();
// Copy some data
result.cursor = self.cursor;
// Get the pointer to our source buffer
const len = real_height * (self.cols + 1);
const src = self.storage.getPtrSlice(top_y * (self.cols + 1), len);
// Get a direct pointer into our storage buffer. This should always be
// one slice because we created a perfectly fitting buffer.
const dst = result.storage.getPtrSlice(0, len);
assert(dst[1].len == 0);
// Perform the copy
fastmem.copy(StorageCell, dst[0], src[0]);
fastmem.copy(StorageCell, dst[0][src[0].len..], src[1]);
// If there are graphemes, we just copy them all
if (self.graphemes.count() > 0) {
// Clone the map
const graphemes = try self.graphemes.clone(alloc);
// Go through all the values and clone the data because it MAY
// (rarely) be allocated.
var it = graphemes.iterator();
while (it.next()) |kv| {
kv.value_ptr.* = try kv.value_ptr.copy(alloc);
}
result.graphemes = graphemes;
}
return result;
}
/// Returns true if the viewport is scrolled to the bottom of the screen.
pub fn viewportIsBottom(self: Screen) bool {
return self.viewport == self.history;
}
/// Shortcut for getRow followed by getCell as a quick way to read a cell.
/// This is particularly useful for quickly reading the cell under a cursor
/// with `getCell(.active, cursor.y, cursor.x)`.
pub fn getCell(self: *Screen, tag: RowIndexTag, y: usize, x: usize) Cell {
return self.getRow(tag.index(y)).getCell(x);
}
/// Shortcut for getRow followed by getCellPtr as a quick way to read a cell.
pub fn getCellPtr(self: *Screen, tag: RowIndexTag, y: usize, x: usize) *Cell {
return self.getRow(tag.index(y)).getCellPtr(x);
}
/// Returns an iterator that can be used to iterate over all of the rows
/// from index zero of the given row index type. This can therefore iterate
/// from row 0 of the active area, history, viewport, etc.
pub fn rowIterator(self: *Screen, tag: RowIndexTag) RowIterator {
const tracy = trace(@src());
defer tracy.end();
return .{
.screen = self,
.tag = tag,
.max = tag.maxLen(self),
};
}
/// Returns an iterator that iterates over the lines of the screen. A line
/// is a single line of text which may wrap across multiple rows. A row
/// is a single physical row of the terminal.
pub fn lineIterator(self: *Screen, tag: RowIndexTag) LineIterator {
return .{ .row_it = self.rowIterator(tag) };
}
/// Returns the line that contains the given point. This may be null if the
/// point is outside the screen.
pub fn getLine(self: *Screen, pt: point.ScreenPoint) ?Line {
// If our y is outside of our written area, we have no line.
if (pt.y >= RowIndexTag.screen.maxLen(self)) return null;
if (pt.x >= self.cols) return null;
// Find the starting y. We go back and as soon as we find a row that
// isn't wrapped, we know the NEXT line is the one we want.
const start_y: usize = if (pt.y == 0) 0 else start_y: {
for (1..pt.y) |y| {
const bot_y = pt.y - y;
const row = self.getRow(.{ .screen = bot_y });
if (!row.isWrapped()) break :start_y bot_y + 1;
}
break :start_y 0;
};
// Find the end y, which is the first row that isn't wrapped.
const end_y = end_y: {
for (pt.y..self.rowsWritten()) |y| {
const row = self.getRow(.{ .screen = y });
if (!row.isWrapped()) break :end_y y;
}
break :end_y self.rowsWritten() - 1;
};
return .{
.screen = self,
.tag = .screen,
.start = start_y,
.len = (end_y - start_y) + 1,
};
}
/// Returns the row at the given index. This row is writable, although
/// only the active area should probably be written to.
pub fn getRow(self: *Screen, index: RowIndex) Row {
const tracy = trace(@src());
defer tracy.end();
// Get our offset into storage
const offset = index.toScreen(self).screen * (self.cols + 1);
// Get the slices into the storage. This should never wrap because
// we're perfectly aligned on row boundaries.
const slices = self.storage.getPtrSlice(offset, self.cols + 1);
assert(slices[0].len == self.cols + 1 and slices[1].len == 0);
const row: Row = .{ .screen = self, .storage = slices[0] };
if (row.storage[0].header.id == 0) {
const Id = @TypeOf(self.next_row_id);
const id = self.next_row_id;
self.next_row_id +%= @as(Id, @intCast(self.cols));
// Store the header
row.storage[0].header.id = id;
// We only set dirty and fill if its not dirty. If its dirty
// we assume this row has been written but just hasn't had
// an ID assigned yet.
if (!row.storage[0].header.flags.dirty) {
// Mark that we're dirty since we're a new row
row.storage[0].header.flags.dirty = true;
// We only need to fill with runtime safety because unions are
// tag-checked. Otherwise, the default value of zero will be valid.
if (std.debug.runtime_safety) row.fill(.{});
}
}
return row;
}
/// Copy the row at src to dst.
pub fn copyRow(self: *Screen, dst: RowIndex, src: RowIndex) !void {
// One day we can make this more efficient but for now
// we do the easy thing.
const dst_row = self.getRow(dst);
const src_row = self.getRow(src);
try dst_row.copyRow(src_row);
}
/// Scroll rows in a region up. Rows that go beyond the region
/// top or bottom are deleted, and new rows inserted are blank according
/// to the current pen.
///
/// This does NOT create any new scrollback. This modifies an existing
/// region within the screen (including possibly the scrollback if
/// the top/bottom are within it).
///
/// This can be used to implement terminal scroll regions efficiently.
pub fn scrollRegionUp(self: *Screen, top: RowIndex, bottom: RowIndex, count_req: usize) void {
const tracy = trace(@src());
defer tracy.end();
// Avoid a lot of work if we're doing nothing.
if (count_req == 0) return;
// Convert our top/bottom to screen y values. This is the y offset
// in the entire screen buffer.
const top_y = top.toScreen(self).screen;
const bot_y = bottom.toScreen(self).screen;
// If top is outside of the range of bot, we do nothing.
if (top_y >= bot_y) return;
// We can only scroll up to the number of rows in the region. The "+ 1"
// is because our y values are 0-based and count is 1-based.
const count = @min(count_req, bot_y - top_y + 1);
// Get the storage pointer for the full scroll region. We're going to
// be modifying the whole thing so we get it right away.
const height = (bot_y - top_y) + 1;
const len = height * (self.cols + 1);
const slices = self.storage.getPtrSlice(top_y * (self.cols + 1), len);
// The total amount we're going to copy
const total_copy = (height - count) * (self.cols + 1);
// The pen we'll use for new cells (only the BG attribute is applied to new
// cells)
const pen: Cell = switch (self.cursor.pen.bg) {
.none => .{},
else => |bg| .{ .bg = bg },
};
// Fast-path is that we have a contiguous buffer in our circular buffer.
// In this case we can do some memmoves.
if (slices[1].len == 0) {
const buf = slices[0];
{
// Our copy starts "count" rows below and is the length of
// the remainder of the data. Our destination is the top since
// we're scrolling up.
//
// Note we do NOT need to set any row headers to dirty because
// the row contents are not changing for the row ID.
const dst = buf;
const src_offset = count * (self.cols + 1);
const src = buf[src_offset..];
assert(@intFromPtr(dst.ptr) < @intFromPtr(src.ptr));
fastmem.move(StorageCell, dst, src);
}
{
// Copy in our empties. The destination is the bottom
// count rows. We first fill with the pen values since there
// is a lot more of that.
const dst_offset = total_copy;
const dst = buf[dst_offset..];
@memset(dst, .{ .cell = pen });
// Then we make sure our row headers are zeroed out. We set
// the value to a dirty row header so that the renderer re-draws.
//
// NOTE: we do NOT set a valid row ID here. The next time getRow
// is called it will be initialized. This should work fine as
// far as I can tell. It is important to set dirty so that the
// renderer knows to redraw this.
var i: usize = dst_offset;
while (i < buf.len) : (i += self.cols + 1) {
buf[i] = .{ .header = .{
.flags = .{ .dirty = true },
} };
}
}
return;
}
// If we're split across two buffers this is a "slow" path. This shouldn't
// happen with the "active" area but it appears it does... in the future
// I plan on changing scroll region stuff to make it much faster so for
// now we just deal with this slow path.
// This is the offset where we have to start copying.
const src_offset = count * (self.cols + 1);
// Perform the copy and calculate where we need to start zero-ing.
const zero_offset: [2]usize = if (src_offset < slices[0].len) zero_offset: {
var remaining: usize = len;
// Source starts in the top... so we can copy some from there.
const dst = slices[0];
const src = slices[0][src_offset..];
assert(@intFromPtr(dst.ptr) < @intFromPtr(src.ptr));
fastmem.move(StorageCell, dst, src);
remaining = total_copy - src.len;
if (remaining == 0) break :zero_offset .{ src.len, 0 };
// We have data remaining, which means that we have to grab some
// from the bottom slice.
const dst2 = slices[0][src.len..];
const src2_len = @min(dst2.len, remaining);
const src2 = slices[1][0..src2_len];
fastmem.copy(StorageCell, dst2, src2);
remaining -= src2_len;
if (remaining == 0) break :zero_offset .{ src.len + src2.len, 0 };
// We still have data remaining, which means we copy into the bot.
const dst3 = slices[1];
const src3 = slices[1][src2_len .. src2_len + remaining];
fastmem.move(StorageCell, dst3, src3);
break :zero_offset .{ slices[0].len, src3.len };
} else zero_offset: {
var remaining: usize = len;
// Source is in the bottom, so we copy from there into top.
const bot_src_offset = src_offset - slices[0].len;
const dst = slices[0];
const src = slices[1][bot_src_offset..];
const src_len = @min(dst.len, src.len);
fastmem.copy(StorageCell, dst, src[0..src_len]);
remaining = total_copy - src_len;
if (remaining == 0) break :zero_offset .{ src_len, 0 };
// We have data remaining, this has to go into the bottom.
const dst2 = slices[1];
const src2_offset = bot_src_offset + src_len;
const src2 = slices[1][src2_offset..];
const src2_len = remaining;
fastmem.move(StorageCell, dst2, src2[0..src2_len]);
break :zero_offset .{ src_len, src2_len };
};
// Zero
for (zero_offset, 0..) |offset, i| {
if (offset >= slices[i].len) continue;
const dst = slices[i][offset..];
@memset(dst, .{ .cell = pen });
var j: usize = offset;
while (j < slices[i].len) : (j += self.cols + 1) {
slices[i][j] = .{ .header = .{
.flags = .{ .dirty = true },
} };
}
}
}
/// Returns the offset into the storage buffer that the given row can
/// be found. This assumes valid input and will crash if the input is
/// invalid.
fn rowOffset(self: Screen, index: RowIndex) usize {
// +1 for row header
return index.toScreen(&self).screen * (self.cols + 1);
}
/// Returns the number of rows that have actually been written to the
/// screen. This assumes a row is "written" if getRow was ever called
/// on the row.
fn rowsWritten(self: Screen) usize {
// The number of rows we've actually written into our buffer
// This should always be cleanly divisible since we only request
// data in row chunks from the buffer.
assert(@mod(self.storage.len(), self.cols + 1) == 0);
return self.storage.len() / (self.cols + 1);
}
/// The number of rows our backing storage supports. This should
/// always be self.rows but we use the backing storage as a source of truth.
fn rowsCapacity(self: Screen) usize {
assert(@mod(self.storage.capacity(), self.cols + 1) == 0);
return self.storage.capacity() / (self.cols + 1);
}
/// The maximum possible capacity of the underlying buffer if we reached
/// the max scrollback.
fn maxCapacity(self: Screen) usize {
return (self.rows + self.max_scrollback) * (self.cols + 1);
}
pub const ClearMode = enum {
/// Delete all history. This will also move the viewport area to the top
/// so that the viewport area never contains history. This does NOT
/// change the active area.
history,
/// Clear all the lines above the cursor in the active area. This does
/// not touch history.
above_cursor,
};
/// Clear the screen contents according to the given mode.
pub fn clear(self: *Screen, mode: ClearMode) !void {
switch (mode) {
.history => {
// If there is no history, do nothing.
if (self.history == 0) return;
// Delete all our history
self.storage.deleteOldest(self.history * (self.cols + 1));
self.history = 0;
// Back to the top
self.viewport = 0;
},
.above_cursor => {
// First we copy all the rows from our cursor down to the top
// of the active area.
var y: usize = self.cursor.y;
const y_max = @min(self.rows, self.rowsWritten()) - 1;
const copy_n = (y_max - y) + 1;
while (y <= y_max) : (y += 1) {
const dst_y = y - self.cursor.y;
const dst = self.getRow(.{ .active = dst_y });
const src = self.getRow(.{ .active = y });
try dst.copyRow(src);
}
// Next we want to clear all the rows below the copied amount.
y = copy_n;
while (y <= y_max) : (y += 1) {
const dst = self.getRow(.{ .active = y });
dst.clear(.{});
}
// Move our cursor to the top
self.cursor.y = 0;
// Scroll to the top of the viewport
self.viewport = self.history;
},
}
}
/// 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 y_max = self.rowsWritten() - 1;
const start: point.ScreenPoint = start: {
var y: usize = 0;
while (y <= y_max) : (y += 1) {
const current_row = self.getRow(.{ .screen = y });
var x: usize = 0;
while (x < self.cols) : (x += 1) {
const cell = current_row.getCell(x);
// Empty is whitespace
if (cell.empty()) continue;
// Non-empty means we found it.
const this_whitespace = std.mem.indexOfAny(
u32,
whitespace,
&[_]u32{cell.char},
) != null;
if (this_whitespace) continue;
break :start .{ .x = x, .y = y };
}
}
// There is no start point and therefore no line that can be selected.
return null;
};
const end: point.ScreenPoint = end: {
var y: usize = y_max;
while (true) {
const current_row = self.getRow(.{ .screen = y });
var x: usize = 0;
while (x < self.cols) : (x += 1) {
const real_x = self.cols - x - 1;
const cell = current_row.getCell(real_x);
// Empty or whitespace, ignore.
if (cell.empty()) continue;
const this_whitespace = std.mem.indexOfAny(
u32,
whitespace,
&[_]u32{cell.char},
) != null;
if (this_whitespace) continue;
// Got it
break :end .{ .x = real_x, .y = y };
}
if (y == 0) break;
y -= 1;
}
};
return Selection{
.start = start,
.end = end,
};
}
/// 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: *Screen, pt: point.ScreenPoint) ?Selection {
// Whitespace characters for selection purposes
const whitespace = &[_]u32{ 0, ' ', '\t' };
// Impossible to select anything outside of the area we've written.
const y_max = self.rowsWritten() - 1;
if (pt.y > y_max or pt.x >= self.cols) return null;
// The real start of the row is the first row in the soft-wrap.
const start_row: usize = start_row: {
if (pt.y == 0) break :start_row 0;
var y: usize = pt.y - 1;
while (true) {
const current = self.getRow(.{ .screen = y });
if (!current.header().flags.wrap) break :start_row y + 1;
if (y == 0) break :start_row y;
y -= 1;
}
unreachable;
};
// The real end of the row is the final row in the soft-wrap.
const end_row: usize = end_row: {
var y: usize = pt.y;
while (y <= y_max) : (y += 1) {
const current = self.getRow(.{ .screen = y });
if (y == y_max or !current.header().flags.wrap) break :end_row y;
}
unreachable;
};
// Go forward from the start to find the first non-whitespace character.
const start: point.ScreenPoint = start: {
var y: usize = start_row;
while (y <= y_max) : (y += 1) {
const current_row = self.getRow(.{ .screen = y });
var x: usize = 0;
while (x < self.cols) : (x += 1) {
const cell = current_row.getCell(x);
// Empty is whitespace
if (cell.empty()) continue;
// Non-empty means we found it.
const this_whitespace = std.mem.indexOfAny(
u32,
whitespace,
&[_]u32{cell.char},
) != null;
if (this_whitespace) continue;
break :start .{ .x = x, .y = y };
}
}
// There is no start point and therefore no line that can be selected.
return null;
};
// Go backward from the end to find the first non-whitespace character.
const end: point.ScreenPoint = end: {
var y: usize = end_row;
while (true) {
const current_row = self.getRow(.{ .screen = y });
var x: usize = 0;
while (x < self.cols) : (x += 1) {
const real_x = self.cols - x - 1;
const cell = current_row.getCell(real_x);
// Empty or whitespace, ignore.
if (cell.empty()) continue;
const this_whitespace = std.mem.indexOfAny(
u32,
whitespace,
&[_]u32{cell.char},
) != null;
if (this_whitespace) continue;
// Got it
break :end .{ .x = real_x, .y = y };
}
if (y == 0) break;
y -= 1;
}
// There is no start point and therefore no line that can be selected.
return null;
};
return Selection{
.start = start,
.end = end,
};
}
/// 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.
pub fn selectWordBetween(
self: *Screen,
start_pt: point.ScreenPoint,
end_pt: point.ScreenPoint,
) ?Selection {
const dir: point.Direction = if (start_pt.before(end_pt)) .right_down else .left_up;
var it = start_pt.iterator(self, dir);
while (it.next()) |pt| {
// Boundary conditions
switch (dir) {
.right_down => if (end_pt.before(pt)) return null,
.left_up => if (pt.before(end_pt)) return null,
}
// If we found a word, then return it
if (self.selectWord(pt)) |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, pt: point.ScreenPoint) ?Selection {
// Boundary characters for selection purposes
const boundary = &[_]u32{ 0, ' ', '\t', '\'', '"' };
// Impossible to select anything outside of the area we've written.
const y_max = self.rowsWritten() - 1;
if (pt.y > y_max) return null;
// Get our row
const row = self.getRow(.{ .screen = pt.y });
const start_cell = row.getCell(pt.x);
// If our cell is empty we can't select a word, because we can't select
// areas where the screen is not yet written.
if (start_cell.empty()) 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.char}) != null;
// Go forwards to find our end boundary
const end: point.ScreenPoint = boundary: {
var prev: point.ScreenPoint = pt;
var y: usize = pt.y;
var x: usize = pt.x;
while (y <= y_max) : (y += 1) {
const current_row = self.getRow(.{ .screen = y });
// Go through all the remainining cells on this row until
// we reach a boundary condition.
while (x < self.cols) : (x += 1) {
const cell = current_row.getCell(x);
// If we reached an empty cell its always a boundary
if (cell.empty()) break :boundary prev;
// If we do not match our expected set, we hit a boundary
const this_boundary = std.mem.indexOfAny(
u32,
boundary,
&[_]u32{cell.char},
) != null;
if (this_boundary != expect_boundary) break :boundary prev;
// Increase our prev
prev.x = x;
prev.y = y;
}
// If we aren't wrapping, then we're done this is a boundary.
if (!current_row.header().flags.wrap) break :boundary prev;
// If we are wrapping, reset some values and search the next line.
x = 0;
}
break :boundary .{ .x = self.cols - 1, .y = y_max };
};
// Go backwards to find our start boundary
const start: point.ScreenPoint = boundary: {
var current_row = row;
var prev: point.ScreenPoint = pt;
var y: usize = pt.y;
var x: usize = pt.x;
while (true) {
// Go through all the remainining cells on this row until
// we reach a boundary condition.
while (x > 0) : (x -= 1) {
const cell = current_row.getCell(x - 1);
const this_boundary = std.mem.indexOfAny(
u32,
boundary,
&[_]u32{cell.char},
) != null;
if (this_boundary != expect_boundary) break :boundary prev;
// Update our prev
prev.x = x - 1;
prev.y = y;
}
// If we're at the start, we need to check if the previous line wrapped.
// If we are wrapped, we continue searching. If we are not wrapped,
// then we've hit a boundary.
assert(prev.x == 0);
// If we're at the end, we're done!
if (y == 0) break;
// If the previous row did not wrap, then we're done. Otherwise
// we keep searching.
y -= 1;
current_row = self.getRow(.{ .screen = y });
if (!current_row.header().flags.wrap) break :boundary prev;
// Set x to start at the first non-empty cell
x = self.cols;
while (x > 0) : (x -= 1) {
if (!current_row.getCell(x - 1).empty()) break;
}
}
break :boundary .{ .x = 0, .y = 0 };
};
return Selection{
.start = start,
.end = end,
};
}
/// 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, pt: point.ScreenPoint) ?Selection {
// Impossible to select anything outside of the area we've written.
const y_max = self.rowsWritten() - 1;
if (pt.y > y_max) return null;
const point_row = self.getRow(.{ .screen = pt.y });
switch (point_row.getSemanticPrompt()) {
.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: point.ScreenPoint = boundary: {
for (pt.y..y_max + 1) |y| {
const row = self.getRow(.{ .screen = y });
switch (row.getSemanticPrompt()) {
.input, .prompt_continuation, .prompt => {
const prev_row = self.getRow(.{ .screen = y - 1 });
break :boundary .{ .x = prev_row.lenCells(), .y = y - 1 };
},
else => {},
}
}
break :boundary .{ .x = self.cols - 1, .y = y_max };
};
// Go backwards to find our start boundary
// We are looking for output start markers
const start: point.ScreenPoint = boundary: {
var y: usize = pt.y;
while (y > 0) : (y -= 1) {
const row = self.getRow(.{ .screen = y });
switch (row.getSemanticPrompt()) {
.command => break :boundary .{ .x = 0, .y = y },
else => {},
}
}
break :boundary .{ .x = 0, .y = 0 };
};
return Selection{
.start = start,
.end = end,
};
}
/// 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, pt: point.ScreenPoint) ?Selection {
// Ensure that the line the point is on is a prompt.
const pt_row = self.getRow(.{ .screen = pt.y });
const is_known = switch (pt_row.getSemanticPrompt()) {
.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: usize = start: for (0..pt.y) |offset| {
const y = pt.y - offset;
const row = self.getRow(.{ .screen = y - 1 });
switch (row.getSemanticPrompt()) {
// 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 y,
// Command output or unknown, definitely not a prompt.
.command => break :start y,
}
} else 0;
// 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: usize = end: for (pt.y..self.rowsWritten()) |y| {
const row = self.getRow(.{ .screen = y });
switch (row.getSemanticPrompt()) {
// A prompt, we continue searching.
.prompt, .prompt_continuation, .input => {},
// Command output or unknown, definitely not a prompt.
.command, .unknown => break :end y - 1,
}
} else self.rowsWritten() - 1;
return .{
.start = .{ .x = 0, .y = start },
.end = .{ .x = self.cols - 1, .y = end },
};
}
/// 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: point.ScreenPoint,
to: point.ScreenPoint,
) 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(to))
to
else if (to.before(bounds.start))
bounds.start
else
bounds.end;
// Basic math to calculate our path.
const from_x: isize = @intCast(from.x);
const from_y: isize = @intCast(from.y);
const to_x: isize = @intCast(to_clamped.x);
const to_y: isize = @intCast(to_clamped.y);
return .{ .x = to_x - from_x, .y = to_y - from_y };
}
/// Scroll behaviors for the scroll function.
pub const Scroll = union(enum) {
/// Scroll to the top of the scroll buffer. The first line of the
/// viewport will be the top line of the scroll buffer.
top: void,
/// Scroll to the bottom, where the last line of the viewport
/// will be the last line of the buffer. TODO: are we sure?
bottom: void,
/// Scroll up (negative) or down (positive) some fixed amount.
/// Scrolling direction (up/down) describes the direction the viewport
/// moves, not the direction text moves. This is the colloquial way that
/// scrolling is described: "scroll the page down". This scrolls the
/// screen (potentially in addition to the viewport) and may therefore
/// create more rows if necessary.
screen: isize,
/// This is the same as "screen" but only scrolls the viewport. The
/// delta will be clamped at the current size of the screen and will
/// never create new scrollback.
viewport: isize,
/// Scroll so the given row is in view. If the row is in the viewport,
/// this will change nothing. If the row is outside the viewport, the
/// viewport will change so that this row is at the top of the viewport.
row: RowIndex,
/// Scroll down and move all viewport contents into the scrollback
/// so that the screen is clear. This isn't eqiuivalent to "screen" with
/// the value set to the viewport size because this will handle the case
/// that the viewport is not full.
///
/// This will ignore empty trailing rows. An empty row is a row that
/// has never been written to at all. A row with spaces is not empty.
clear: void,
};
/// Scroll the screen by the given behavior. Note that this will always
/// "move" the screen. It is up to the caller to determine if they actually
/// want to do that yet (i.e. are they writing to the end of the screen
/// or not).
pub fn scroll(self: *Screen, behavior: Scroll) Allocator.Error!void {
// 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) {
// Setting viewport offset to zero makes row 0 be at self.top
// which is the top!
.top => self.viewport = 0,
// Bottom is the end of the history area (end of history is the
// top of the active area).
.bottom => self.viewport = self.history,
// TODO: deltas greater than the entire scrollback
.screen => |delta| try self.scrollDelta(delta, false),
.viewport => |delta| try self.scrollDelta(delta, true),
// Scroll to a specific row
.row => |idx| self.scrollRow(idx),
// Scroll until the viewport is clear by moving the viewport contents
// into the scrollback.
.clear => try self.scrollClear(),
}
}
fn scrollClear(self: *Screen) Allocator.Error!void {
// The full amount of rows in the viewport
const full_amount = self.rowsWritten() - self.viewport;
// Find the number of non-empty rows
const non_empty = for (0..full_amount) |i| {
const rev_i = full_amount - i - 1;
const row = self.getRow(.{ .viewport = rev_i });
if (!row.isEmpty()) break rev_i + 1;
} else full_amount;
try self.scroll(.{ .screen = @intCast(non_empty) });
}
fn scrollRow(self: *Screen, idx: RowIndex) void {
// Convert the given row to a screen point.
const screen_idx = idx.toScreen(self);
const screen_pt: point.ScreenPoint = .{ .y = screen_idx.screen };
// Move the viewport so that the screen point is in view. We do the
// @min here so that we don't scroll down below where our "bottom"
// viewport is.
self.viewport = @min(self.history, screen_pt.y);
assert(screen_pt.inViewport(self));
}
fn scrollDelta(self: *Screen, delta: isize, viewport_only: bool) Allocator.Error!void {
const tracy = trace(@src());
defer tracy.end();
// Just in case, to avoid a bunch of stuff below.
if (delta == 0) return;
// If we're scrolling up, then we just subtract and we're done.
// We just clamp at 0 which blocks us from scrolling off the top.
if (delta < 0) {
self.viewport -|= @as(usize, @intCast(-delta));
return;
}
// If we're scrolling only the viewport, then we just add to the viewport.
if (viewport_only) {
self.viewport = @min(
self.history,
self.viewport + @as(usize, @intCast(delta)),
);
return;
}
// Add our delta to our viewport. If we're less than the max currently
// allowed to scroll to the bottom (the end of the history), then we
// have space and we just return.
const start_viewport_bottom = self.viewportIsBottom();
const viewport = self.history + @as(usize, @intCast(delta));
if (viewport <= self.history) return;
// If our viewport is past the top of our history then we potentially need
// to write more blank rows. If our viewport is more than our rows written
// then we expand out to there.
const rows_written = self.rowsWritten();
const viewport_bottom = viewport + self.rows;
if (viewport_bottom <= rows_written) return;
// The number of new rows we need is the number of rows off our
// previous bottom we are growing.
const new_rows_needed = viewport_bottom - rows_written;
// If we can't fit into our capacity but we have space, resize the
// buffer to allocate more scrollback.
const rows_final = rows_written + new_rows_needed;
if (rows_final > self.rowsCapacity()) {
const max_capacity = self.maxCapacity();
if (self.storage.capacity() < max_capacity) {
// The capacity we want to allocate. We take whatever is greater
// of what we actually need and two pages. We don't want to
// allocate one row at a time (common for scrolling) so we do this
// to chunk it.
const needed_capacity = @max(
rows_final * (self.cols + 1),
@min(self.storage.capacity() * 2, max_capacity),
);
// Allocate what we can.
try self.storage.resize(
self.alloc,
@min(max_capacity, needed_capacity),
);
}
}
// If we can't fit our rows into our capacity, we delete some scrollback.
const rows_deleted = if (rows_final > self.rowsCapacity()) deleted: {
const rows_to_delete = rows_final - self.rowsCapacity();
// Fast-path: we have no graphemes.
// Slow-path: we have graphemes, we have to check each row
// we're going to delete to see if they contain graphemes and
// clear the ones that do so we clear memory properly.
if (self.graphemes.count() > 0) {
var y: usize = 0;
while (y < rows_to_delete) : (y += 1) {
const row = self.getRow(.{ .active = y });
if (row.storage[0].header.flags.grapheme) row.clear(.{});
}
}
self.storage.deleteOldest(rows_to_delete * (self.cols + 1));
break :deleted rows_to_delete;
} else 0;
// If we are deleting rows and have a selection, then we need to offset
// the selection by the rows we're deleting.
if (self.selection) |*sel| {
// If we're deleting more rows than our Y values, we also move
// the X over to 0 because we're in the middle of the selection now.
if (rows_deleted > sel.start.y) sel.start.x = 0;
if (rows_deleted > sel.end.y) sel.end.x = 0;
// Remove the deleted rows from both y values. We use saturating
// subtraction so that we can detect when we're at zero.
sel.start.y -|= rows_deleted;
sel.end.y -|= rows_deleted;
// If the selection is now empty, just clear it.
if (sel.empty()) self.selection = null;
}
// If we have more rows than what shows on our screen, we have a
// history boundary.
const rows_written_final = rows_final - rows_deleted;
if (rows_written_final > self.rows) {
self.history = rows_written_final - self.rows;
}
// Ensure we have "written" our last row so that it shows up
const slices = self.storage.getPtrSlice(
(rows_written_final - 1) * (self.cols + 1),
self.cols + 1,
);
// We should never be wrapped here
assert(slices[1].len == 0);
// We only grabbed our new row(s), copy cells into the whole slice
const dst = slices[0];
// The pen we'll use for new cells (only the BG attribute is applied to new
// cells)
const pen: Cell = switch (self.cursor.pen.bg) {
.none => .{},
else => |bg| .{ .bg = bg },
};
@memset(dst, .{ .cell = pen });
// Then we make sure our row headers are zeroed out. We set
// the value to a dirty row header so that the renderer re-draws.
var i: usize = 0;
while (i < dst.len) : (i += self.cols + 1) {
dst[i] = .{ .header = .{
.flags = .{ .dirty = true },
} };
}
if (start_viewport_bottom) {
// If our viewport is on the bottom, we always update the viewport
// to the latest so that it remains in view.
self.viewport = self.history;
} else if (rows_deleted > 0) {
// If our viewport is NOT on the bottom, we want to keep our viewport
// where it was so that we don't jump around. However, we need to
// subtract the final rows written if we had to delete rows since
// that changes the viewport offset.
self.viewport -|= rows_deleted;
}
}
/// The options for where you can jump to on the screen.
pub const JumpTarget = union(enum) {
/// Jump forwards (positive) or backwards (negative) a set number of
/// prompts. If the absolute value is greater than the number of prompts
/// in either direction, jump to the furthest prompt.
prompt_delta: isize,
};
/// Jump the viewport to specific location.
pub fn jump(self: *Screen, target: JumpTarget) bool {
return switch (target) {
.prompt_delta => |delta| self.jumpPrompt(delta),
};
}
/// Jump the viewport forwards (positive) or backwards (negative) a set number of
/// prompts (delta). Returns true if the viewport changed and false if no jump
/// occurred.
fn jumpPrompt(self: *Screen, delta: isize) bool {
// If we aren't jumping any prompts then we don't need to do anything.
if (delta == 0) return false;
// The screen y value we start at
const start_y: isize = start_y: {
const idx: RowIndex = .{ .viewport = 0 };
const screen = idx.toScreen(self);
break :start_y @intCast(screen.screen);
};
// The maximum y in the positive direction. Negative is always 0.
const max_y: isize = @intCast(self.rowsWritten() - 1);
// Go line-by-line counting the number of prompts we see.
const step: isize = if (delta > 0) 1 else -1;
var y: isize = start_y + step;
const delta_start: usize = @intCast(if (delta > 0) delta else -delta);
var delta_rem: usize = delta_start;
while (y >= 0 and y <= max_y and delta_rem > 0) : (y += step) {
const row = self.getRow(.{ .screen = @intCast(y) });
switch (row.getSemanticPrompt()) {
.prompt, .prompt_continuation, .input => delta_rem -= 1,
.command, .unknown => {},
}
}
//log.warn("delta={} delta_rem={} start_y={} y={}", .{ delta, delta_rem, start_y, y });
// If we didn't find any, do nothing.
if (delta_rem == delta_start) return false;
// Done! We count the number of lines we changed and scroll.
const y_delta = (y - step) - start_y;
const new_y: usize = @intCast(start_y + y_delta);
const old_viewport = self.viewport;
self.scroll(.{ .row = .{ .screen = new_y } }) catch unreachable;
//log.warn("delta={} y_delta={} start_y={} new_y={}", .{ delta, y_delta, start_y, new_y });
return self.viewport != old_viewport;
}
/// 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,
sel: Selection,
trim: bool,
) ![:0]const u8 {
// Get the slices for the string
const slices = self.selectionSlices(sel);
// 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 = try std.ArrayList(u8).initCapacity(alloc, slices.rows * self.cols);
defer strbuilder.deinit();
// Get our string result.
try self.selectionSliceString(slices, &strbuilder, null);
// 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 (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);
strbuilder.appendAssumeCapacity('\n');
}
// Remove our trailing newline again
if (strbuilder.items.len > 0) strbuilder.items.len -= 1;
}
// Get our final string
const string = try strbuilder.toOwnedSliceSentinel(0);
errdefer alloc.free(string);
return string;
}
/// Returns the row text associated with a selection along with the
/// mapping of each individual byte in the string to the point in the screen.
fn selectionStringMap(
self: *Screen,
alloc: Allocator,
sel: Selection,
) !StringMap {
// Get the slices for the string
const slices = self.selectionSlices(sel);
// 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 = try std.ArrayList(u8).initCapacity(alloc, slices.rows * self.cols);
defer strbuilder.deinit();
var mapbuilder = try std.ArrayList(point.ScreenPoint).initCapacity(alloc, strbuilder.capacity);
defer mapbuilder.deinit();
// Get our results
try self.selectionSliceString(slices, &strbuilder, &mapbuilder);
// Get our final string
const string = try strbuilder.toOwnedSliceSentinel(0);
errdefer alloc.free(string);
const map = try mapbuilder.toOwnedSlice();
errdefer alloc.free(map);
return .{ .string = string, .map = map };
}
/// Takes a SelectionSlices value and builds the string and mapping for it.
fn selectionSliceString(
self: *Screen,
slices: SelectionSlices,
strbuilder: *std.ArrayList(u8),
mapbuilder: ?*std.ArrayList(point.ScreenPoint),
) !void {
// Connect the text from the two slices
const arr = [_][]StorageCell{ slices.top, slices.bot };
var row_count: usize = 0;
for (arr) |slice| {
const row_start: usize = row_count;
while (row_count < slices.rows) : (row_count += 1) {
const row_i = row_count - row_start;
// Calculate our start index. If we are beyond the length
// of this slice, then its time to move on (we exhausted top).
const start_idx = row_i * (self.cols + 1);
if (start_idx >= slice.len) break;
const end_idx = if (slices.sel.rectangle)
// Rectangle select: calculate end with bottom offset.
start_idx + slices.bot_offset + 2 // think "column count" + 1
else
// Normal select: our end index is usually a full row, but if
// we're the final row then we just use the length.
@min(slice.len, start_idx + self.cols + 1);
// We may have to skip some cells from the beginning if we're the
// first row, of if we're using rectangle select.
var skip: usize = if (row_count == 0 or slices.sel.rectangle) slices.top_offset else 0;
// If we have runtime safety we need to initialize the row
// so that the proper union tag is set. In release modes we
// don't need to do this because we zero the memory.
if (std.debug.runtime_safety) {
_ = self.getRow(.{ .screen = slices.sel.start.y + row_i });
}
const row: Row = .{ .screen = self, .storage = slice[start_idx..end_idx] };
var it = row.cellIterator();
var x: usize = 0;
while (it.next()) |cell| {
defer x += 1;
if (skip > 0) {
skip -= 1;
continue;
}
// Skip spacers
if (cell.attrs.wide_spacer_head or
cell.attrs.wide_spacer_tail) continue;
var buf: [4]u8 = undefined;
const char = if (cell.char > 0) cell.char else ' ';
{
const encode_len = try std.unicode.utf8Encode(@intCast(char), &buf);
try strbuilder.appendSlice(buf[0..encode_len]);
if (mapbuilder) |b| {
for (0..encode_len) |_| try b.append(.{
.x = x,
.y = slices.sel.start.y + row_i,
});
}
}
var cp_it = row.codepointIterator(x);
while (cp_it.next()) |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(.{
.x = x,
.y = slices.sel.start.y + row_i,
});
}
}
}
// If this row is not soft-wrapped or if we're using rectangle
// select, add a newline
if (!row.header().flags.wrap or slices.sel.rectangle) {
try strbuilder.append('\n');
if (mapbuilder) |b| {
try b.append(.{
.x = self.cols - 1,
.y = slices.sel.start.y + row_i,
});
}
}
}
}
// Remove our trailing newline, its never correct.
if (strbuilder.items.len > 0 and
strbuilder.items[strbuilder.items.len - 1] == '\n')
{
strbuilder.items.len -= 1;
if (mapbuilder) |b| b.items.len -= 1;
}
if (std.debug.runtime_safety) {
if (mapbuilder) |b| {
assert(strbuilder.items.len == b.items.len);
}
}
}
const SelectionSlices = struct {
rows: usize,
// The selection that the slices below represent. This may not
// be the same as the input selection since some normalization
// occurs.
sel: Selection,
// Top offset can be used to determine if a newline is required by
// seeing if the cell index plus the offset cleanly divides by screen cols.
top_offset: usize,
// Our bottom offset is used in rectangle select to always determine the
// maximum cell in a given row.
bot_offset: usize,
// Our selection storage cell chunks.
top: []StorageCell,
bot: []StorageCell,
};
/// Returns the slices that make up the selection, in order. There are at most
/// two parts to handle the ring buffer. If the selection fits in one contiguous
/// slice, then the second slice will have a length of zero.
fn selectionSlices(self: *Screen, sel_raw: Selection) SelectionSlices {
// Note: this function is tested via selectionString
// If the selection starts beyond the end of the screen, then we return empty
if (sel_raw.start.y >= self.rowsWritten()) return .{
.rows = 0,
.sel = sel_raw,
.top_offset = 0,
.bot_offset = 0,
.top = self.storage.storage[0..0],
.bot = self.storage.storage[0..0],
};
const sel = sel: {
var sel = sel_raw;
// Clamp the selection to the screen
if (sel.end.y >= self.rowsWritten()) {
sel.end.y = self.rowsWritten() - 1;
sel.end.x = self.cols - 1;
}
// If the end of our selection is a wide char leader, include the
// first part of the next line.
if (sel.end.x == self.cols - 1) {
const row = self.getRow(.{ .screen = sel.end.y });
const cell = row.getCell(sel.end.x);
if (cell.attrs.wide_spacer_head) {
sel.end.y += 1;
sel.end.x = 0;
}
}
// If the start of our selection is a wide char spacer, include the
// wide char.
if (sel.start.x > 0) {
const row = self.getRow(.{ .screen = sel.start.y });
const cell = row.getCell(sel.start.x);
if (cell.attrs.wide_spacer_tail) {
sel.start.x -= 1;
}
}
break :sel sel;
};
// Get the true "top" and "bottom"
const sel_top = sel.topLeft();
const sel_bot = sel.bottomRight();
const sel_isRect = sel.rectangle;
// We get the slices for the full top and bottom (inclusive).
const sel_top_offset = self.rowOffset(.{ .screen = sel_top.y });
const sel_bot_offset = self.rowOffset(.{ .screen = sel_bot.y });
const slices = self.storage.getPtrSlice(
sel_top_offset,
(sel_bot_offset - sel_top_offset) + (sel_bot.x + 2),
);
// The bottom and top are split into two slices, so we slice to the
// bottom of the storage, then from the top.
return .{
.rows = sel_bot.y - sel_top.y + 1,
.sel = .{ .start = sel_top, .end = sel_bot, .rectangle = sel_isRect },
.top_offset = sel_top.x,
.bot_offset = sel_bot.x,
.top = slices[0],
.bot = slices[1],
};
}
/// 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, rows: usize, cols: usize) !void {
const tracy = trace(@src());
defer tracy.end();
// If we're resizing to the same size, do nothing.
if (self.cols == cols and self.rows == rows) return;
// The number of no-character lines after our cursor. This is used
// to trim those lines on a resize first without generating history.
// This is only done if we don't have history yet.
//
// This matches macOS Terminal.app behavior. I chose to match that
// behavior because it seemed fine in an ocean of differing behavior
// between terminal apps. I'm completely open to changing it as long
// as resize behavior isn't regressed in a user-hostile way.
const trailing_blank_lines = blank: {
// If we aren't changing row length, then don't bother calculating
// because we aren't going to trim.
if (self.rows == rows) break :blank 0;
const blank = self.trailingBlankLines();
// If we are shrinking the number of rows, we don't want to trim
// off more blank rows than the number we're shrinking because it
// creates a jarring screen move experience.
if (self.rows > rows) break :blank @min(blank, self.rows - rows);
break :blank blank;
};
// Make a copy so we can access the old indexes.
var old = self.*;
errdefer self.* = old;
// Change our rows and cols so calculations make sense
self.rows = rows;
self.cols = cols;
// The end of the screen is the rows we wrote minus any blank lines
// we're trimming.
const end_of_screen_y = old.rowsWritten() - trailing_blank_lines;
// Calculate our buffer size. This is going to be either the old data
// with scrollback or the max capacity of our new size. We prefer the old
// length so we can save all the data (ignoring col truncation).
const old_len = @max(end_of_screen_y, rows) * (cols + 1);
const new_max_capacity = self.maxCapacity();
const buf_size = @min(old_len, new_max_capacity);
// Reallocate the storage
self.storage = try StorageBuf.init(self.alloc, buf_size);
errdefer self.storage.deinit(self.alloc);
defer old.storage.deinit(self.alloc);
// Our viewport and history resets to the top because we're going to
// rewrite the screen
self.viewport = 0;
self.history = 0;
// Reset our grapheme map and ensure the old one is deallocated
// on success.
self.graphemes = .{};
errdefer self.deinitGraphemes();
defer old.deinitGraphemes();
// Rewrite all our rows
var y: usize = 0;
for (0..end_of_screen_y) |it_y| {
const old_row = old.getRow(.{ .screen = it_y });
// If we're past the end, scroll
if (y >= self.rows) {
// If we're shrinking rows then its possible we'll trim scrollback
// and we have to account for how much we actually trimmed and
// reflect that in the cursor.
if (self.storage.len() >= self.maxCapacity()) {
old.cursor.y -|= 1;
}
y -= 1;
try self.scroll(.{ .screen = 1 });
}
// Get this row
const new_row = self.getRow(.{ .active = y });
try new_row.copyRow(old_row);
// Next row
y += 1;
}
// Convert our cursor to screen coordinates so we can preserve it.
// The cursor is normally in active coordinates, but by converting to
// screen we can accommodate keeping it on the same place if we retain
// the same scrollback.
const old_cursor_y_screen = RowIndexTag.active.index(old.cursor.y).toScreen(&old).screen;
self.cursor.x = @min(old.cursor.x, self.cols - 1);
self.cursor.y = if (old_cursor_y_screen <= RowIndexTag.screen.maxLen(self))
old_cursor_y_screen -| self.history
else
self.rows - 1;
// If our rows increased and our cursor is NOT at the bottom, we want
// to try to preserve the y value of the old cursor. In other words, we
// don't want to "pull down" scrollback. This is purely a UX feature.
if (self.rows > old.rows and
old.cursor.y < old.rows - 1 and
self.cursor.y > old.cursor.y)
{
const delta = self.cursor.y - old.cursor.y;
if (self.scroll(.{ .screen = @intCast(delta) })) {
self.cursor.y -= delta;
} else |err| {
// If this scroll fails its not that big of a deal so we just
// log and ignore.
log.warn("failed to scroll for resize, cursor may be off err={}", .{err});
}
}
}
/// Resize the screen. The rows or cols can be bigger or smaller. This
/// function can only be used to resize the viewport. The scrollback size
/// (in lines) can't be changed. But due to the resize, more or less scrollback
/// "space" becomes available due to the width of lines.
///
/// Due to the internal representation of a screen, this usually involves a
/// significant amount of copying compared to any other operations.
///
/// This will trim data if the size is getting smaller. This will reflow the
/// soft wrapped text.
pub fn resize(self: *Screen, rows: usize, cols: usize) !void {
if (self.cols == cols) {
// No resize necessary
if (self.rows == rows) return;
// No matter what we mark our image state as dirty
self.kitty_images.dirty = true;
// If we have the same number of columns, text can't possibly
// reflow in any way, so we do the quicker thing and do a resize
// without reflow checks.
try self.resizeWithoutReflow(rows, cols);
return;
}
// No matter what we mark our image state as dirty
self.kitty_images.dirty = true;
// Keep track if our cursor is at the bottom
const cursor_bottom = self.cursor.y == self.rows - 1;
// If our columns increased, we alloc space for the new column width
// and go through each row and reflow if necessary.
if (cols > self.cols) {
var old = self.*;
errdefer self.* = old;
// Allocate enough to store our screen plus history.
const buf_size = (self.rows + @max(self.history, self.max_scrollback)) * (cols + 1);
self.storage = try StorageBuf.init(self.alloc, buf_size);
errdefer self.storage.deinit(self.alloc);
defer old.storage.deinit(self.alloc);
// Copy grapheme map
self.graphemes = .{};
errdefer self.deinitGraphemes();
defer old.deinitGraphemes();
// Convert our cursor coordinates to screen coordinates because
// we may have to reflow the cursor if the line it is on is unwrapped.
const cursor_pos = (point.Active{
.x = old.cursor.x,
.y = old.cursor.y,
}).toScreen(&old);
// Whether we need to move the cursor or not
var new_cursor: ?point.ScreenPoint = null;
// Reset our variables because we're going to reprint the screen.
self.cols = cols;
self.viewport = 0;
self.history = 0;
// Iterate over the screen since we need to check for reflow.
var iter = old.rowIterator(.screen);
var y: usize = 0;
while (iter.next()) |old_row| {
// If we're past the end, scroll
if (y >= self.rows) {
try self.scroll(.{ .screen = 1 });
y -= 1;
}
// We need to check if our cursor was on this line. If so,
// we set the new cursor.
if (cursor_pos.y == iter.value - 1) {
assert(new_cursor == null); // should only happen once
new_cursor = .{ .y = self.history + y, .x = cursor_pos.x };
}
// At this point, we're always at x == 0 so we can just copy
// the row (we know old.cols < self.cols).
var new_row = self.getRow(.{ .active = y });
try new_row.copyRow(old_row);
if (!old_row.header().flags.wrap) {
// We used to do have this behavior, but it broke some programs.
// I know I copied this behavior while observing some other
// terminal, but I can't remember which one. I'm leaving this
// here in case we want to bring this back (with probably
// slightly different behavior).
//
// If we have no reflow, we attempt to extend any stylized
// cells at the end of the line if there is one.
// const len = old_row.lenCells();
// const end = new_row.getCell(len - 1);
// if ((end.char == 0 or end.char == ' ') and !end.empty()) {
// for (len..self.cols) |x| {
// const cell = new_row.getCellPtr(x);
// cell.* = end;
// }
// }
y += 1;
continue;
}
// We need to reflow. At this point things get a bit messy.
// The goal is to keep the messiness of reflow down here and
// only reloop when we're back to clean non-wrapped lines.
// Mark the last element as not wrapped
new_row.setWrapped(false);
// x is the offset where we start copying into new_row. Its also
// used for cursor tracking.
var x: usize = old.cols;
// Edge case: if the end of our old row is a wide spacer head,
// we want to overwrite it.
if (old_row.getCellPtr(x - 1).attrs.wide_spacer_head) x -= 1;
wrapping: while (iter.next()) |wrapped_row| {
const wrapped_cells = trim: {
var i: usize = old.cols;
// Trim the row from the right so that we ignore all trailing
// empty chars and don't wrap them. We only do this if the
// row is NOT wrapped again because the whitespace would be
// meaningful.
if (!wrapped_row.header().flags.wrap) {
while (i > 0) : (i -= 1) {
if (!wrapped_row.getCell(i - 1).empty()) break;
}
} else {
// If we are wrapped, then similar to above "edge case"
// we want to overwrite the wide spacer head if we end
// in one.
if (wrapped_row.getCellPtr(i - 1).attrs.wide_spacer_head) {
i -= 1;
}
}
break :trim wrapped_row.storage[1 .. i + 1];
};
var wrapped_i: usize = 0;
while (wrapped_i < wrapped_cells.len) {
// Remaining space in our new row
const new_row_rem = self.cols - x;
// Remaining cells in our wrapped row
const wrapped_cells_rem = wrapped_cells.len - wrapped_i;
// We copy as much as we can into our new row
const copy_len = if (new_row_rem <= wrapped_cells_rem) copy_len: {
// We are going to end up filling our new row. We need
// to check if the end of the row is a wide char and
// if so, we need to insert a wide char header and wrap
// there.
var proposed: usize = new_row_rem;
// If the end of our copy is wide, we copy one less and
// set the wide spacer header now since we're not going
// to write over it anyways.
if (proposed > 0 and wrapped_cells[wrapped_i + proposed - 1].cell.attrs.wide) {
proposed -= 1;
new_row.getCellPtr(x + proposed).* = .{
.char = ' ',
.attrs = .{ .wide_spacer_head = true },
};
}
break :copy_len proposed;
} else wrapped_cells_rem;
// The row doesn't fit, meaning we have to soft-wrap the
// new row but probably at a diff boundary.
fastmem.copy(
StorageCell,
new_row.storage[x + 1 ..],
wrapped_cells[wrapped_i .. wrapped_i + copy_len],
);
// We need to check if our cursor was on this line
// and in the part that WAS copied. If so, we need to move it.
if (cursor_pos.y == iter.value - 1 and
cursor_pos.x < copy_len and
new_cursor == null)
{
new_cursor = .{ .y = self.history + y, .x = x + cursor_pos.x };
}
// We copied the full amount left in this wrapped row.
if (copy_len == wrapped_cells_rem) {
// If this row isn't also wrapped, we're done!
if (!wrapped_row.header().flags.wrap) {
y += 1;
break :wrapping;
}
// Wrapped again!
x += wrapped_cells_rem;
break;
}
// We still need to copy the remainder
wrapped_i += copy_len;
// Move to a new line in our new screen
new_row.setWrapped(true);
y += 1;
x = 0;
// If we're past the end, scroll
if (y >= self.rows) {
y -= 1;
try self.scroll(.{ .screen = 1 });
}
new_row = self.getRow(.{ .active = y });
new_row.setSemanticPrompt(old_row.getSemanticPrompt());
}
}
}
// If we have a new cursor, we need to convert that to a viewport
// point and set it up.
if (new_cursor) |pos| {
const viewport_pos = pos.toViewport(self);
self.cursor.x = viewport_pos.x;
self.cursor.y = viewport_pos.y;
}
}
// We grow rows after cols so that we can do our unwrapping/reflow
// before we do a no-reflow grow.
if (rows > self.rows) try self.resizeWithoutReflow(rows, self.cols);
// If our rows got smaller, we trim the scrollback. We do this after
// handling cols growing so that we can save as many lines as we can.
// We do it before cols shrinking so we can save compute on that operation.
if (rows < self.rows) try self.resizeWithoutReflow(rows, self.cols);
// If our cols got smaller, we have to reflow text. This is the worst
// possible case because we can't do any easy tricks to get reflow,
// we just have to iterate over the screen and "print", wrapping as
// needed.
if (cols < self.cols) {
var old = self.*;
errdefer self.* = old;
// Allocate enough to store our screen plus history.
const buf_size = (self.rows + @max(self.history, self.max_scrollback)) * (cols + 1);
self.storage = try StorageBuf.init(self.alloc, buf_size);
errdefer self.storage.deinit(self.alloc);
defer old.storage.deinit(self.alloc);
// Create empty grapheme map. Cell IDs change so we can't just copy it,
// we'll rebuild it.
self.graphemes = .{};
errdefer self.deinitGraphemes();
defer old.deinitGraphemes();
// Convert our cursor coordinates to screen coordinates because
// we may have to reflow the cursor if the line it is on is moved.
const cursor_pos = (point.Active{
.x = old.cursor.x,
.y = old.cursor.y,
}).toScreen(&old);
// Whether we need to move the cursor or not
var new_cursor: ?point.ScreenPoint = null;
var new_cursor_wrap: usize = 0;
// Reset our variables because we're going to reprint the screen.
self.cols = cols;
self.viewport = 0;
self.history = 0;
// Iterate over the screen since we need to check for reflow. We
// clear all the trailing blank lines so that shells like zsh and
// fish that often clear the display below don't force us to have
// scrollback.
var old_y: usize = 0;
const end_y = RowIndexTag.screen.maxLen(&old) - old.trailingBlankLines();
var y: usize = 0;
while (old_y < end_y) : (old_y += 1) {
const old_row = old.getRow(.{ .screen = old_y });
const old_row_wrapped = old_row.header().flags.wrap;
const trimmed_row = self.trimRowForResizeLessCols(&old, old_row);
// If our y is more than our rows, we need to scroll
if (y >= self.rows) {
try self.scroll(.{ .screen = 1 });
y -= 1;
}
// Fast path: our old row is not wrapped AND our old row fits
// into our new smaller size AND this row has no grapheme clusters.
// In this case, we just do a fast copy and move on.
if (!old_row_wrapped and
trimmed_row.len <= self.cols and
!old_row.header().flags.grapheme)
{
// If our cursor is on this line, then set the new cursor.
if (cursor_pos.y == old_y) {
assert(new_cursor == null);
new_cursor = .{ .x = cursor_pos.x, .y = self.history + y };
}
const row = self.getRow(.{ .active = y });
row.setSemanticPrompt(old_row.getSemanticPrompt());
fastmem.copy(
StorageCell,
row.storage[1..],
trimmed_row,
);
y += 1;
continue;
}
// Slow path: the row is wrapped or doesn't fit so we have to
// wrap ourselves. In this case, we basically just "print and wrap"
var row = self.getRow(.{ .active = y });
row.setSemanticPrompt(old_row.getSemanticPrompt());
var x: usize = 0;
var cur_old_row = old_row;
var cur_old_row_wrapped = old_row_wrapped;
var cur_trimmed_row = trimmed_row;
while (true) {
for (cur_trimmed_row, 0..) |old_cell, old_x| {
var cell: StorageCell = old_cell;
// This is a really wild edge case if we're resizing down
// to 1 column. In reality this is pretty broken for end
// users so downstream should prevent this.
if (self.cols == 1 and
(cell.cell.attrs.wide or
cell.cell.attrs.wide_spacer_head or
cell.cell.attrs.wide_spacer_tail))
{
cell = .{ .cell = .{ .char = ' ' } };
}
// We need to wrap wide chars with a spacer head.
if (cell.cell.attrs.wide and x == self.cols - 1) {
row.getCellPtr(x).* = .{
.char = ' ',
.attrs = .{ .wide_spacer_head = true },
};
x += 1;
}
// Soft wrap if we have to.
if (x == self.cols) {
row.setWrapped(true);
x = 0;
y += 1;
// Wrapping can cause us to overflow our visible area.
// If so, scroll.
if (y >= self.rows) {
try self.scroll(.{ .screen = 1 });
y -= 1;
// Clear if our current cell is a wide spacer tail
if (cell.cell.attrs.wide_spacer_tail) {
cell = .{ .cell = .{} };
}
}
if (cursor_pos.y == old_y) {
// If this original y is where our cursor is, we
// track the number of wraps we do so we can try to
// keep this whole line on the screen.
new_cursor_wrap += 1;
}
row = self.getRow(.{ .active = y });
row.setSemanticPrompt(cur_old_row.getSemanticPrompt());
}
// If our cursor is on this char, then set the new cursor.
if (cursor_pos.y == old_y and cursor_pos.x == old_x) {
assert(new_cursor == null);
new_cursor = .{ .x = x, .y = self.history + y };
}
// Write the cell
const new_cell = row.getCellPtr(x);
new_cell.* = cell.cell;
// If the old cell is a multi-codepoint grapheme then we
// need to also attach the graphemes.
if (cell.cell.attrs.grapheme) {
var it = cur_old_row.codepointIterator(old_x);
while (it.next()) |cp| try row.attachGrapheme(x, cp);
}
x += 1;
}
// If we're done wrapping, we move on.
if (!cur_old_row_wrapped) {
y += 1;
break;
}
// If the old row is wrapped we continue with the loop with
// the next row.
old_y += 1;
cur_old_row = old.getRow(.{ .screen = old_y });
cur_old_row_wrapped = cur_old_row.header().flags.wrap;
cur_trimmed_row = self.trimRowForResizeLessCols(&old, cur_old_row);
}
}
// If we have a new cursor, we need to convert that to a viewport
// point and set it up.
if (new_cursor) |pos| {
const viewport_pos = pos.toViewport(self);
self.cursor.x = @min(viewport_pos.x, self.cols - 1);
self.cursor.y = @min(viewport_pos.y, self.rows - 1);
// We want to keep our cursor y at the same place. To do so, we
// scroll the screen. This scrolls all of the content so the cell
// the cursor is over doesn't change.
if (!cursor_bottom and old.cursor.y < self.cursor.y) scroll: {
const delta: isize = delta: {
var delta: isize = @intCast(self.cursor.y - old.cursor.y);
// new_cursor_wrap is the number of times the line that the
// cursor was on previously was wrapped to fit this new col
// width. We want to scroll that many times less so that
// the whole line the cursor was on attempts to remain
// in view.
delta -= @intCast(new_cursor_wrap);
if (delta <= 0) break :scroll;
break :delta delta;
};
self.scroll(.{ .screen = delta }) catch |err| {
log.warn("failed to scroll for resize, cursor may be off err={}", .{err});
break :scroll;
};
self.cursor.y -= @intCast(delta);
}
} else {
// TODO: why is this necessary? Without this, neovim will
// crash when we shrink the window to the smallest size. We
// never got a test case to cover this.
self.cursor.x = @min(self.cursor.x, self.cols - 1);
self.cursor.y = @min(self.cursor.y, self.rows - 1);
}
}
}
/// Counts the number of trailing lines from the cursor that are blank.
/// This is specifically used for resizing and isn't meant to be a general
/// purpose tool.
fn trailingBlankLines(self: *Screen) usize {
// Start one line below our cursor and continue to the last line
// of the screen or however many rows we have written.
const start = self.cursor.y + 1;
const end = @min(self.rowsWritten(), self.rows);
if (start >= end) return 0;
var blank: usize = 0;
for (0..(end - start)) |i| {
const y = end - i - 1;
const row = self.getRow(.{ .active = y });
if (!row.isEmpty()) break;
blank += 1;
}
return blank;
}
/// When resizing to less columns, this trims the row from the right
/// so we don't unnecessarily wrap. This will freely throw away trailing
/// colored but empty (character) cells. This matches Terminal.app behavior,
/// which isn't strictly correct but seems nice.
fn trimRowForResizeLessCols(self: *Screen, old: *Screen, row: Row) []StorageCell {
assert(old.cols > self.cols);
// We only trim if this isn't a wrapped line. If its a wrapped
// line we need to keep all the empty cells because they are
// meaningful whitespace before our wrap.
if (row.header().flags.wrap) return row.storage[1 .. old.cols + 1];
var i: usize = old.cols;
while (i > 0) : (i -= 1) {
const cell = row.getCell(i - 1);
if (!cell.empty()) {
// If we are beyond our new width and this is just
// an empty-character stylized cell, then we trim it.
// We also have to ignore wide spacers because they form
// a critical part of a wide character.
if (i > self.cols) {
if ((cell.char == 0 or cell.char == ' ') and
!cell.attrs.wide_spacer_tail and
!cell.attrs.wide_spacer_head) continue;
}
break;
}
}
return row.storage[1 .. i + 1];
}
/// Writes a basic string into the screen for testing. Newlines (\n) separate
/// each row. If a line is longer than the available columns, soft-wrapping
/// will occur. This will automatically handle basic wide chars.
pub fn testWriteString(self: *Screen, text: []const u8) !void {
var y: usize = self.cursor.y;
var x: usize = self.cursor.x;
var grapheme: struct {
x: usize = 0,
cell: ?*Cell = null,
} = .{};
const view = std.unicode.Utf8View.init(text) catch unreachable;
var iter = view.iterator();
while (iter.nextCodepoint()) |c| {
// Explicit newline forces a new row
if (c == '\n') {
y += 1;
x = 0;
grapheme = .{};
continue;
}
// If we're writing past the end of the active area, scroll.
if (y >= self.rows) {
y -= 1;
try self.scroll(.{ .screen = 1 });
}
// Get our row
var row = self.getRow(.{ .active = y });
// NOTE: graphemes are currently disabled
if (false) {
// If we have a previous cell, we check if we're part of a grapheme.
if (grapheme.cell) |prev_cell| {
const grapheme_break = brk: {
var state: u3 = 0;
var cp1 = @as(u21, @intCast(prev_cell.char));
if (prev_cell.attrs.grapheme) {
var it = row.codepointIterator(grapheme.x);
while (it.next()) |cp2| {
assert(!ziglyph.graphemeBreak(
cp1,
cp2,
&state,
));
cp1 = cp2;
}
}
break :brk ziglyph.graphemeBreak(cp1, c, &state);
};
if (!grapheme_break) {
try row.attachGrapheme(grapheme.x, c);
continue;
}
}
}
const width: usize = @intCast(@max(0, ziglyph.display_width.codePointWidth(c, .half)));
//log.warn("c={x} width={}", .{ c, width });
// Zero-width are attached as grapheme data.
// NOTE: if/when grapheme clustering is ever enabled (above) this
// is not necessary
if (width == 0) {
if (grapheme.cell != null) {
try row.attachGrapheme(grapheme.x, c);
}
continue;
}
// If we're writing past the end, we need to soft wrap.
if (x == self.cols) {
row.setWrapped(true);
y += 1;
x = 0;
if (y >= self.rows) {
y -= 1;
try self.scroll(.{ .screen = 1 });
}
row = self.getRow(.{ .active = y });
}
// If our character is double-width, handle it.
assert(width == 1 or width == 2);
switch (width) {
1 => {
const cell = row.getCellPtr(x);
cell.* = self.cursor.pen;
cell.char = @intCast(c);
grapheme.x = x;
grapheme.cell = cell;
},
2 => {
if (x == self.cols - 1) {
const cell = row.getCellPtr(x);
cell.char = ' ';
cell.attrs.wide_spacer_head = true;
// wrap
row.setWrapped(true);
y += 1;
x = 0;
if (y >= self.rows) {
y -= 1;
try self.scroll(.{ .screen = 1 });
}
row = self.getRow(.{ .active = y });
}
{
const cell = row.getCellPtr(x);
cell.* = self.cursor.pen;
cell.char = @intCast(c);
cell.attrs.wide = true;
grapheme.x = x;
grapheme.cell = cell;
}
{
x += 1;
const cell = row.getCellPtr(x);
cell.char = ' ';
cell.attrs.wide_spacer_tail = true;
}
},
else => unreachable,
}
x += 1;
}
// So the cursor doesn't go off screen
self.cursor.x = @min(x, self.cols - 1);
self.cursor.y = y;
}
/// Options for dumping the screen to a string.
pub const Dump = struct {
/// The start and end rows. These don't have to be in order, the dump
/// function will automatically sort them.
start: RowIndex,
end: RowIndex,
/// If true, this will unwrap soft-wrapped lines into a single line.
unwrap: bool = true,
};
/// 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.
///
/// TODO: look at selectionString implementation for more efficiency
/// TODO: change selectionString to use this too after above todo
pub fn dumpString(self: *Screen, writer: anytype, opts: Dump) !void {
const start_screen = opts.start.toScreen(self);
const end_screen = opts.end.toScreen(self);
// If we have no rows in our screen, do nothing.
const rows_written = self.rowsWritten();
if (rows_written == 0) return;
// Get the actual top and bottom y values. This handles situations
// where start/end are backwards.
const y_top = @min(start_screen.screen, end_screen.screen);
const y_bottom = @min(
@max(start_screen.screen, end_screen.screen),
rows_written - 1,
);
// This keeps track of the number of blank rows we see. We don't want
// to output blank rows unless they're followed by a non-blank row.
var blank_rows: usize = 0;
// Iterate through the rows
var y: usize = y_top;
while (y <= y_bottom) : (y += 1) {
const row = self.getRow(.{ .screen = y });
// Handle blank rows
if (row.isEmpty()) {
blank_rows += 1;
continue;
}
if (blank_rows > 0) {
for (0..blank_rows) |_| try writer.writeByte('\n');
blank_rows = 0;
}
if (!row.header().flags.wrap) {
// If we're not wrapped, we always add a newline.
blank_rows += 1;
} else if (!opts.unwrap) {
// If we are wrapped, we only add a new line if we're unwrapping
// soft-wrapped lines.
blank_rows += 1;
}
// Output each of the cells
var cells = row.cellIterator();
var spacers: usize = 0;
while (cells.next()) |cell| {
// Skip spacers
if (cell.attrs.wide_spacer_head or cell.attrs.wide_spacer_tail) continue;
// If we have a zero value, then we accumulate a counter. We
// only want to turn zero values into spaces if we have a non-zero
// char sometime later.
if (cell.char == 0) {
spacers += 1;
continue;
}
if (spacers > 0) {
for (0..spacers) |_| try writer.writeByte(' ');
spacers = 0;
}
const codepoint: u21 = @intCast(cell.char);
try writer.print("{u}", .{codepoint});
var it = row.codepointIterator(cells.i - 1);
while (it.next()) |cp| {
try writer.print("{u}", .{cp});
}
}
}
}
/// Turns the screen into a string. Different regions of the screen can
/// be selected using the "tag", i.e. if you want to output the viewport,
/// the scrollback, the full screen, etc.
///
/// This is only useful for testing.
pub fn testString(self: *Screen, alloc: Allocator, tag: RowIndexTag) ![]const u8 {
var builder = std.ArrayList(u8).init(alloc);
defer builder.deinit();
try self.dumpString(builder.writer(), .{
.start = tag.index(0),
.end = tag.index(tag.maxLen(self) - 1),
// historically our testString wants to view the screen as-is without
// unwrapping soft-wrapped lines so turn this off.
.unwrap = false,
});
return try builder.toOwnedSlice();
}
test "Row: isEmpty with no data" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 5, 5, 0);
defer s.deinit();
const row = s.getRow(.{ .active = 0 });
try testing.expect(row.isEmpty());
}
test "Row: isEmpty with a character at the end" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 5, 5, 0);
defer s.deinit();
const row = s.getRow(.{ .active = 0 });
const cell = row.getCellPtr(4);
cell.*.char = 'A';
try testing.expect(!row.isEmpty());
}
test "Row: isEmpty with only styled cells" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 5, 5, 0);
defer s.deinit();
const row = s.getRow(.{ .active = 0 });
for (0..s.cols) |x| {
const cell = row.getCellPtr(x);
cell.*.bg = .{ .rgb = .{ .r = 0xAA, .g = 0xBB, .b = 0xCC } };
}
try testing.expect(row.isEmpty());
}
test "Row: clear with graphemes" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 5, 5, 0);
defer s.deinit();
const row = s.getRow(.{ .active = 0 });
try testing.expect(row.getId() > 0);
try testing.expectEqual(@as(usize, 5), row.lenCells());
try testing.expect(!row.header().flags.grapheme);
// Lets add a cell with a grapheme
{
const cell = row.getCellPtr(2);
cell.*.char = 'A';
try row.attachGrapheme(2, 'B');
try testing.expect(cell.attrs.grapheme);
try testing.expect(row.header().flags.grapheme);
try testing.expect(s.graphemes.count() == 1);
}
// Clear the row
row.clear(.{});
try testing.expect(!row.header().flags.grapheme);
try testing.expect(s.graphemes.count() == 0);
}
test "Row: copy row with graphemes in destination" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 5, 5, 0);
defer s.deinit();
// Source row does NOT have graphemes
const row_src = s.getRow(.{ .active = 0 });
{
const cell = row_src.getCellPtr(2);
cell.*.char = 'A';
}
// Destination has graphemes
const row = s.getRow(.{ .active = 1 });
{
const cell = row.getCellPtr(1);
cell.*.char = 'B';
try row.attachGrapheme(1, 'C');
try testing.expect(cell.attrs.grapheme);
try testing.expect(row.header().flags.grapheme);
try testing.expect(s.graphemes.count() == 1);
}
// Copy
try row.copyRow(row_src);
try testing.expect(!row.header().flags.grapheme);
try testing.expect(s.graphemes.count() == 0);
}
test "Row: copy row with graphemes in source" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 5, 5, 0);
defer s.deinit();
// Source row does NOT have graphemes
const row_src = s.getRow(.{ .active = 0 });
{
const cell = row_src.getCellPtr(2);
cell.*.char = 'A';
try row_src.attachGrapheme(2, 'B');
try testing.expect(cell.attrs.grapheme);
try testing.expect(row_src.header().flags.grapheme);
try testing.expect(s.graphemes.count() == 1);
}
// Destination has no graphemes
const row = s.getRow(.{ .active = 1 });
try row.copyRow(row_src);
try testing.expect(row.header().flags.grapheme);
try testing.expect(s.graphemes.count() == 2);
row_src.clear(.{});
try testing.expect(s.graphemes.count() == 1);
}
test "Screen" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 5, 5, 0);
defer s.deinit();
try testing.expect(s.rowsWritten() == 0);
// Sanity check that our test helpers work
const str = "1ABCD\n2EFGH\n3IJKL";
try s.testWriteString(str);
try testing.expect(s.rowsWritten() == 3);
{
const contents = try s.testString(alloc, .screen);
defer alloc.free(contents);
try testing.expectEqualStrings(str, contents);
}
// Test the row iterator
var count: usize = 0;
var iter = s.rowIterator(.viewport);
while (iter.next()) |row| {
// Rows should be pointer equivalent to getRow
const row_other = s.getRow(.{ .viewport = count });
try testing.expectEqual(row.storage.ptr, row_other.storage.ptr);
count += 1;
}
// Should go through all rows
try testing.expectEqual(@as(usize, 3), count);
// Should be able to easily clear screen
{
var it = s.rowIterator(.viewport);
while (it.next()) |row| row.fill(.{ .char = 'A' });
const contents = try s.testString(alloc, .screen);
defer alloc.free(contents);
try testing.expectEqualStrings("AAAAA\nAAAAA\nAAAAA", contents);
}
}
test "Screen: write graphemes" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 5, 5, 0);
defer s.deinit();
// Sanity check that our test helpers work
var buf: [32]u8 = undefined;
var buf_idx: usize = 0;
buf_idx += try std.unicode.utf8Encode(0x1F44D, buf[buf_idx..]); // Thumbs up plain
buf_idx += try std.unicode.utf8Encode(0x1F44D, buf[buf_idx..]); // Thumbs up plain
buf_idx += try std.unicode.utf8Encode(0x1F3FD, buf[buf_idx..]); // Medium skin tone
// Note the assertions below are NOT the correct way to handle graphemes
// in general, but they're "correct" for historical purposes for terminals.
// For terminals, all double-wide codepoints are counted as part of the
// width.
try s.testWriteString(buf[0..buf_idx]);
try testing.expect(s.rowsWritten() == 2);
try testing.expectEqual(@as(usize, 2), s.cursor.x);
}
test "Screen: write long emoji" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 5, 30, 0);
defer s.deinit();
// Sanity check that our test helpers work
var buf: [32]u8 = undefined;
var buf_idx: usize = 0;
buf_idx += try std.unicode.utf8Encode(0x1F9D4, buf[buf_idx..]); // man: beard
buf_idx += try std.unicode.utf8Encode(0x1F3FB, buf[buf_idx..]); // light skin tone (Fitz 1-2)
buf_idx += try std.unicode.utf8Encode(0x200D, buf[buf_idx..]); // ZWJ
buf_idx += try std.unicode.utf8Encode(0x2642, buf[buf_idx..]); // male sign
buf_idx += try std.unicode.utf8Encode(0xFE0F, buf[buf_idx..]); // emoji representation
// Note the assertions below are NOT the correct way to handle graphemes
// in general, but they're "correct" for historical purposes for terminals.
// For terminals, all double-wide codepoints are counted as part of the
// width.
try s.testWriteString(buf[0..buf_idx]);
try testing.expect(s.rowsWritten() == 1);
try testing.expectEqual(@as(usize, 5), s.cursor.x);
}
test "Screen: lineIterator" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 5, 5, 0);
defer s.deinit();
// Sanity check that our test helpers work
const str = "1ABCD\n2EFGH";
try s.testWriteString(str);
// Test the line iterator
var iter = s.lineIterator(.viewport);
{
const line = iter.next().?;
const actual = try line.string(alloc);
defer alloc.free(actual);
try testing.expectEqualStrings("1ABCD", actual);
}
{
const line = iter.next().?;
const actual = try line.string(alloc);
defer alloc.free(actual);
try testing.expectEqualStrings("2EFGH", actual);
}
try testing.expect(iter.next() == null);
}
test "Screen: lineIterator soft wrap" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 5, 5, 0);
defer s.deinit();
// Sanity check that our test helpers work
const str = "1ABCD2EFGH\n3ABCD";
try s.testWriteString(str);
// Test the line iterator
var iter = s.lineIterator(.viewport);
{
const line = iter.next().?;
const actual = try line.string(alloc);
defer alloc.free(actual);
try testing.expectEqualStrings("1ABCD2EFGH", actual);
}
{
const line = iter.next().?;
const actual = try line.string(alloc);
defer alloc.free(actual);
try testing.expectEqualStrings("3ABCD", actual);
}
try testing.expect(iter.next() == null);
}
test "Screen: getLine soft wrap" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 5, 5, 0);
defer s.deinit();
// Sanity check that our test helpers work
const str = "1ABCD2EFGH\n3ABCD";
try s.testWriteString(str);
// Test the line iterator
{
const line = s.getLine(.{ .x = 2, .y = 1 }).?;
const actual = try line.string(alloc);
defer alloc.free(actual);
try testing.expectEqualStrings("1ABCD2EFGH", actual);
}
{
const line = s.getLine(.{ .x = 2, .y = 2 }).?;
const actual = try line.string(alloc);
defer alloc.free(actual);
try testing.expectEqualStrings("3ABCD", actual);
}
try testing.expect(s.getLine(.{ .x = 2, .y = 3 }) == null);
try testing.expect(s.getLine(.{ .x = 7, .y = 1 }) == null);
}
test "Screen: scrolling" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 3, 5, 0);
defer s.deinit();
s.cursor.pen.bg = .{ .rgb = .{ .r = 155 } };
try s.testWriteString("1ABCD\n2EFGH\n3IJKL");
try testing.expect(s.viewportIsBottom());
// Scroll down, should still be bottom
try s.scroll(.{ .screen = 1 });
try testing.expect(s.viewportIsBottom());
{
// Test our contents rotated
const contents = try s.testString(alloc, .viewport);
defer alloc.free(contents);
try testing.expectEqualStrings("2EFGH\n3IJKL", contents);
}
{
// Test that our new row has the correct background
const cell = s.getCell(.active, 2, 0);
try testing.expectEqual(@as(u8, 155), cell.bg.rgb.r);
}
// Scrolling to the bottom does nothing
try s.scroll(.{ .bottom = {} });
{
// Test our contents rotated
const contents = try s.testString(alloc, .viewport);
defer alloc.free(contents);
try testing.expectEqualStrings("2EFGH\n3IJKL", contents);
}
}
test "Screen: scroll down from 0" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 3, 5, 0);
defer s.deinit();
try s.testWriteString("1ABCD\n2EFGH\n3IJKL");
// Scrolling up does nothing, but allows it
try s.scroll(.{ .screen = -1 });
try testing.expect(s.viewportIsBottom());
{
// Test our contents rotated
const contents = try s.testString(alloc, .viewport);
defer alloc.free(contents);
try testing.expectEqualStrings("1ABCD\n2EFGH\n3IJKL", contents);
}
}
test "Screen: scrollback" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 3, 5, 1);
defer s.deinit();
try s.testWriteString("1ABCD\n2EFGH\n3IJKL");
try s.scroll(.{ .screen = 1 });
{
// Test our contents rotated
const contents = try s.testString(alloc, .viewport);
defer alloc.free(contents);
try testing.expectEqualStrings("2EFGH\n3IJKL", contents);
}
// Scrolling to the bottom
try s.scroll(.{ .bottom = {} });
try testing.expect(s.viewportIsBottom());
{
// Test our contents rotated
const contents = try s.testString(alloc, .viewport);
defer alloc.free(contents);
try testing.expectEqualStrings("2EFGH\n3IJKL", contents);
}
// Scrolling back should make it visible again
try s.scroll(.{ .screen = -1 });
try testing.expect(!s.viewportIsBottom());
{
// Test our contents rotated
const contents = try s.testString(alloc, .viewport);
defer alloc.free(contents);
try testing.expectEqualStrings("1ABCD\n2EFGH\n3IJKL", contents);
}
// Scrolling back again should do nothing
try s.scroll(.{ .screen = -1 });
{
// Test our contents rotated
const contents = try s.testString(alloc, .viewport);
defer alloc.free(contents);
try testing.expectEqualStrings("1ABCD\n2EFGH\n3IJKL", contents);
}
// Scrolling to the bottom
try s.scroll(.{ .bottom = {} });
{
// Test our contents rotated
const contents = try s.testString(alloc, .viewport);
defer alloc.free(contents);
try testing.expectEqualStrings("2EFGH\n3IJKL", contents);
}
// Scrolling forward with no grow should do nothing
try s.scroll(.{ .viewport = 1 });
{
// Test our contents rotated
const contents = try s.testString(alloc, .viewport);
defer alloc.free(contents);
try testing.expectEqualStrings("2EFGH\n3IJKL", contents);
}
// Scrolling to the top should work
try s.scroll(.{ .top = {} });
{
// Test our contents rotated
const contents = try s.testString(alloc, .viewport);
defer alloc.free(contents);
try testing.expectEqualStrings("1ABCD\n2EFGH\n3IJKL", contents);
}
// Should be able to easily clear active area only
var it = s.rowIterator(.active);
while (it.next()) |row| row.clear(.{});
{
const contents = try s.testString(alloc, .viewport);
defer alloc.free(contents);
try testing.expectEqualStrings("1ABCD", contents);
}
// Scrolling to the bottom
try s.scroll(.{ .bottom = {} });
{
// Test our contents rotated
const contents = try s.testString(alloc, .viewport);
defer alloc.free(contents);
try testing.expectEqualStrings("", contents);
}
}
test "Screen: scrollback with large delta" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 3, 5, 3);
defer s.deinit();
try s.testWriteString("1ABCD\n2EFGH\n3IJKL\n4ABCD\n5EFGH\n6IJKL");
try testing.expect(s.viewportIsBottom());
// Scroll to top
try s.scroll(.{ .top = {} });
{
// Test our contents rotated
const contents = try s.testString(alloc, .viewport);
defer alloc.free(contents);
try testing.expectEqualStrings("1ABCD\n2EFGH\n3IJKL", contents);
}
// Scroll down a ton
try s.scroll(.{ .viewport = 5 });
try testing.expect(s.viewportIsBottom());
{
// Test our contents rotated
const contents = try s.testString(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, 3, 5, 50);
defer s.deinit();
try s.testWriteString("1ABCD\n2EFGH\n3IJKL");
try s.scroll(.{ .viewport = 1 });
{
// Test our contents
const contents = try s.testString(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, 3, 5, 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.
try s.scroll(.{ .screen = -1 });
try testing.expect(!s.viewportIsBottom());
{
const contents = try s.testString(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.scroll(.{ .screen = 1 });
try testing.expect(!s.viewportIsBottom());
{
const contents = try s.testString(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.scroll(.{ .screen = 1 });
try testing.expect(!s.viewportIsBottom());
{
const contents = try s.testString(alloc, .viewport);
defer alloc.free(contents);
try testing.expectEqualStrings("2EFGH\n3IJKL\n4ABCD", contents);
}
// Scroll again, this again goes into scrollback but is now deleting
// what we were looking at. We should see changes.
try s.scroll(.{ .screen = 1 });
try testing.expect(!s.viewportIsBottom());
{
const contents = try s.testString(alloc, .viewport);
defer alloc.free(contents);
try testing.expectEqualStrings("3IJKL\n4ABCD\n5EFGH", contents);
}
}
test "Screen: scrolling moves selection" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 3, 5, 0);
defer s.deinit();
try s.testWriteString("1ABCD\n2EFGH\n3IJKL");
try testing.expect(s.viewportIsBottom());
// Select a single line
s.selection = .{
.start = .{ .x = 0, .y = 1 },
.end = .{ .x = s.cols - 1, .y = 1 },
};
// Scroll down, should still be bottom
try s.scroll(.{ .screen = 1 });
try testing.expect(s.viewportIsBottom());
// Our selection should've moved up
try testing.expectEqual(Selection{
.start = .{ .x = 0, .y = 0 },
.end = .{ .x = s.cols - 1, .y = 0 },
}, s.selection.?);
{
// Test our contents rotated
const contents = try s.testString(alloc, .viewport);
defer alloc.free(contents);
try testing.expectEqualStrings("2EFGH\n3IJKL", contents);
}
// Scrolling to the bottom does nothing
try s.scroll(.{ .bottom = {} });
// Our selection should've stayed the same
try testing.expectEqual(Selection{
.start = .{ .x = 0, .y = 0 },
.end = .{ .x = s.cols - 1, .y = 0 },
}, s.selection.?);
{
// Test our contents rotated
const contents = try s.testString(alloc, .viewport);
defer alloc.free(contents);
try testing.expectEqualStrings("2EFGH\n3IJKL", contents);
}
// Scroll up again
try s.scroll(.{ .screen = 1 });
// Our selection should be null because it left the screen.
try testing.expect(s.selection == null);
}
test "Screen: scrolling with scrollback available doesn't move selection" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 3, 5, 1);
defer s.deinit();
try s.testWriteString("1ABCD\n2EFGH\n3IJKL");
try testing.expect(s.viewportIsBottom());
// Select a single line
s.selection = .{
.start = .{ .x = 0, .y = 1 },
.end = .{ .x = s.cols - 1, .y = 1 },
};
// Scroll down, should still be bottom
try s.scroll(.{ .screen = 1 });
try testing.expect(s.viewportIsBottom());
// Our selection should NOT move since we have scrollback
try testing.expectEqual(Selection{
.start = .{ .x = 0, .y = 1 },
.end = .{ .x = s.cols - 1, .y = 1 },
}, s.selection.?);
{
// Test our contents rotated
const contents = try s.testString(alloc, .viewport);
defer alloc.free(contents);
try testing.expectEqualStrings("2EFGH\n3IJKL", contents);
}
// Scrolling back should make it visible again
try s.scroll(.{ .screen = -1 });
try testing.expect(!s.viewportIsBottom());
// Our selection should NOT move since we have scrollback
try testing.expectEqual(Selection{
.start = .{ .x = 0, .y = 1 },
.end = .{ .x = s.cols - 1, .y = 1 },
}, s.selection.?);
{
// Test our contents rotated
const contents = try s.testString(alloc, .viewport);
defer alloc.free(contents);
try testing.expectEqualStrings("1ABCD\n2EFGH\n3IJKL", contents);
}
// Scroll down, this sends us off the scrollback
try s.scroll(.{ .screen = 2 });
// Selection should be gone since we selected a line that went off.
try testing.expect(s.selection == null);
{
// Test our contents rotated
const contents = try s.testString(alloc, .viewport);
defer alloc.free(contents);
try testing.expectEqualStrings("3IJKL", contents);
}
}
test "Screen: scroll and clear full screen" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 3, 5, 5);
defer s.deinit();
try s.testWriteString("1ABCD\n2EFGH\n3IJKL");
{
const contents = try s.testString(alloc, .viewport);
defer alloc.free(contents);
try testing.expectEqualStrings("1ABCD\n2EFGH\n3IJKL", contents);
}
try s.scroll(.{ .clear = {} });
{
const contents = try s.testString(alloc, .viewport);
defer alloc.free(contents);
try testing.expectEqualStrings("", contents);
}
{
const contents = try s.testString(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, 3, 5, 5);
defer s.deinit();
try s.testWriteString("1ABCD\n2EFGH");
{
const contents = try s.testString(alloc, .viewport);
defer alloc.free(contents);
try testing.expectEqualStrings("1ABCD\n2EFGH", contents);
}
try s.scroll(.{ .clear = {} });
{
const contents = try s.testString(alloc, .viewport);
defer alloc.free(contents);
try testing.expectEqualStrings("", contents);
}
{
const contents = try s.testString(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, 3, 5, 5);
defer s.deinit();
try s.scroll(.{ .clear = {} });
try testing.expectEqual(@as(usize, 0), s.viewport);
}
test "Screen: scroll and clear ignore blank lines" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 3, 5, 10);
defer s.deinit();
try s.testWriteString("1ABCD\n2EFGH");
try s.scroll(.{ .clear = {} });
{
const contents = try s.testString(alloc, .viewport);
defer alloc.free(contents);
try testing.expectEqualStrings("", contents);
}
// Move back to top-left
s.cursor.x = 0;
s.cursor.y = 0;
// Write and clear
try s.testWriteString("3ABCD\n");
try s.scroll(.{ .clear = {} });
{
const contents = try s.testString(alloc, .viewport);
defer alloc.free(contents);
try testing.expectEqualStrings("", contents);
}
// Move back to top-left
s.cursor.x = 0;
s.cursor.y = 0;
try s.testWriteString("X");
{
const contents = try s.testString(alloc, .screen);
defer alloc.free(contents);
try testing.expectEqualStrings("1ABCD\n2EFGH\n3ABCD\nX", contents);
}
}
test "Screen: history region with no scrollback" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 1, 5, 0);
defer s.deinit();
// Write a bunch that WOULD invoke scrollback if exists
const str = "1ABCD\n2EFGH\n3IJKL";
try s.testWriteString(str);
{
const contents = try s.testString(alloc, .screen);
defer alloc.free(contents);
const expected = "3IJKL";
try testing.expectEqualStrings(expected, contents);
}
// Verify no scrollback
var it = s.rowIterator(.history);
var count: usize = 0;
while (it.next()) |_| count += 1;
try testing.expect(count == 0);
}
test "Screen: history region with scrollback" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 1, 5, 2);
defer s.deinit();
// Write a bunch that WOULD invoke scrollback if exists
const str = "1ABCD\n2EFGH\n3IJKL";
try s.testWriteString(str);
{
const contents = try s.testString(alloc, .viewport);
defer alloc.free(contents);
const expected = "3IJKL";
try testing.expectEqualStrings(expected, contents);
}
{
// Test our contents
const contents = try s.testString(alloc, .screen);
defer alloc.free(contents);
try testing.expectEqualStrings("1ABCD\n2EFGH\n3IJKL", contents);
}
{
const contents = try s.testString(alloc, .history);
defer alloc.free(contents);
const expected = "1ABCD\n2EFGH";
try testing.expectEqualStrings(expected, contents);
}
}
test "Screen: row copy" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 3, 5, 0);
defer s.deinit();
try s.testWriteString("1ABCD\n2EFGH\n3IJKL");
// Copy
try s.scroll(.{ .screen = 1 });
try s.copyRow(.{ .active = 2 }, .{ .active = 0 });
// Test our contents
const contents = try s.testString(alloc, .viewport);
defer alloc.free(contents);
try testing.expectEqualStrings("2EFGH\n3IJKL\n2EFGH", contents);
}
test "Screen: clone" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 3, 5, 0);
defer s.deinit();
try s.testWriteString("1ABCD\n2EFGH\n3IJKL");
try testing.expect(s.viewportIsBottom());
{
var s2 = try s.clone(alloc, .{ .active = 1 }, .{ .active = 1 });
defer s2.deinit();
// Test our contents rotated
const contents = try s2.testString(alloc, .viewport);
defer alloc.free(contents);
try testing.expectEqualStrings("2EFGH", contents);
}
{
var s2 = try s.clone(alloc, .{ .active = 1 }, .{ .active = 2 });
defer s2.deinit();
// Test our contents rotated
const contents = try s2.testString(alloc, .viewport);
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, 3, 5, 0);
defer s.deinit();
{
var s2 = try s.clone(alloc, .{ .viewport = 0 }, .{ .viewport = 0 });
defer s2.deinit();
// Test our contents rotated
const contents = try s2.testString(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, 3, 5, 0);
defer s.deinit();
try s.testWriteString("1ABC");
{
var s2 = try s.clone(alloc, .{ .viewport = 0 }, .{ .viewport = 0 });
defer s2.deinit();
// Test our contents
const contents = try s2.testString(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, 3, 5, 0);
defer s.deinit();
{
var s2 = try s.clone(alloc, .{ .active = 0 }, .{ .active = 0 });
defer s2.deinit();
// Test our contents rotated
const contents = try s2.testString(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, 3, 5, 0);
defer s.deinit();
try s.testWriteString("1ABC");
// Should have 1 line written
try testing.expectEqual(@as(usize, 1), s.rowsWritten());
{
var s2 = try s.clone(alloc, .{ .active = 0 }, .{ .active = s.rows - 1 });
defer s2.deinit();
// Test our contents rotated
const contents = try s2.testString(alloc, .active);
defer alloc.free(contents);
try testing.expectEqualStrings("1ABC", contents);
}
// Should still have no history. A bug was that we were generating history
// in this case which is not good! This was causing resizes to have all
// sorts of problems.
try testing.expectEqual(@as(usize, 1), s.rowsWritten());
}
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
{
const sel = s.selectLine(.{ .x = 0, .y = 0 }).?;
try testing.expectEqual(@as(usize, 0), sel.start.x);
try testing.expectEqual(@as(usize, 0), sel.start.y);
try testing.expectEqual(@as(usize, 7), sel.end.x);
try testing.expectEqual(@as(usize, 0), sel.end.y);
}
// Going backward
{
const sel = s.selectLine(.{ .x = 7, .y = 0 }).?;
try testing.expectEqual(@as(usize, 0), sel.start.x);
try testing.expectEqual(@as(usize, 0), sel.start.y);
try testing.expectEqual(@as(usize, 7), sel.end.x);
try testing.expectEqual(@as(usize, 0), sel.end.y);
}
// Going forward and backward
{
const sel = s.selectLine(.{ .x = 3, .y = 0 }).?;
try testing.expectEqual(@as(usize, 0), sel.start.x);
try testing.expectEqual(@as(usize, 0), sel.start.y);
try testing.expectEqual(@as(usize, 7), sel.end.x);
try testing.expectEqual(@as(usize, 0), sel.end.y);
}
// Outside active area
{
const sel = s.selectLine(.{ .x = 9, .y = 0 }).?;
try testing.expectEqual(@as(usize, 0), sel.start.x);
try testing.expectEqual(@as(usize, 0), sel.start.y);
try testing.expectEqual(@as(usize, 7), sel.end.x);
try testing.expectEqual(@as(usize, 0), sel.end.y);
}
}
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");
const sel = s.selectAll().?;
try testing.expectEqual(@as(usize, 0), sel.start.x);
try testing.expectEqual(@as(usize, 0), sel.start.y);
try testing.expectEqual(@as(usize, 2), sel.end.x);
try testing.expectEqual(@as(usize, 2), sel.end.y);
}
{
try s.testWriteString("\nFOO\n BAR\n BAZ\n QWERTY\n 12345678");
const sel = s.selectAll().?;
try testing.expectEqual(@as(usize, 0), sel.start.x);
try testing.expectEqual(@as(usize, 0), sel.start.y);
try testing.expectEqual(@as(usize, 8), sel.end.x);
try testing.expectEqual(@as(usize, 7), sel.end.y);
}
}
test "Screen: selectLine across soft-wrap" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 10, 5, 0);
defer s.deinit();
try s.testWriteString(" 12 34012 \n 123");
// Going forward
{
const sel = s.selectLine(.{ .x = 1, .y = 0 }).?;
try testing.expectEqual(@as(usize, 1), sel.start.x);
try testing.expectEqual(@as(usize, 0), sel.start.y);
try testing.expectEqual(@as(usize, 3), sel.end.x);
try testing.expectEqual(@as(usize, 1), sel.end.y);
}
}
test "Screen: selectLine across soft-wrap ignores blank lines" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 10, 5, 0);
defer s.deinit();
try s.testWriteString(" 12 34012 \n 123");
// Going forward
{
const sel = s.selectLine(.{ .x = 1, .y = 0 }).?;
try testing.expectEqual(@as(usize, 1), sel.start.x);
try testing.expectEqual(@as(usize, 0), sel.start.y);
try testing.expectEqual(@as(usize, 3), sel.end.x);
try testing.expectEqual(@as(usize, 1), sel.end.y);
}
// Going backward
{
const sel = s.selectLine(.{ .x = 1, .y = 1 }).?;
try testing.expectEqual(@as(usize, 1), sel.start.x);
try testing.expectEqual(@as(usize, 0), sel.start.y);
try testing.expectEqual(@as(usize, 3), sel.end.x);
try testing.expectEqual(@as(usize, 1), sel.end.y);
}
// Going forward and backward
{
const sel = s.selectLine(.{ .x = 3, .y = 0 }).?;
try testing.expectEqual(@as(usize, 1), sel.start.x);
try testing.expectEqual(@as(usize, 0), sel.start.y);
try testing.expectEqual(@as(usize, 3), sel.end.x);
try testing.expectEqual(@as(usize, 1), sel.end.y);
}
}
test "Screen: selectLine with scrollback" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 3, 2, 5);
defer s.deinit();
try s.testWriteString("1A\n2B\n3C\n4D\n5E");
// Selecting first line
{
const sel = s.selectLine(.{ .x = 0, .y = 0 }).?;
try testing.expectEqual(@as(usize, 0), sel.start.x);
try testing.expectEqual(@as(usize, 0), sel.start.y);
try testing.expectEqual(@as(usize, 1), sel.end.x);
try testing.expectEqual(@as(usize, 0), sel.end.y);
}
// Selecting last line
{
const sel = s.selectLine(.{ .x = 0, .y = 4 }).?;
try testing.expectEqual(@as(usize, 0), sel.start.x);
try testing.expectEqual(@as(usize, 4), sel.start.y);
try testing.expectEqual(@as(usize, 1), sel.end.x);
try testing.expectEqual(@as(usize, 4), sel.end.y);
}
}
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
{
const sel = s.selectWord(.{ .x = 0, .y = 0 }).?;
try testing.expectEqual(@as(usize, 0), sel.start.x);
try testing.expectEqual(@as(usize, 0), sel.start.y);
try testing.expectEqual(@as(usize, 2), sel.end.x);
try testing.expectEqual(@as(usize, 0), sel.end.y);
}
// Going backward
{
const sel = s.selectWord(.{ .x = 2, .y = 0 }).?;
try testing.expectEqual(@as(usize, 0), sel.start.x);
try testing.expectEqual(@as(usize, 0), sel.start.y);
try testing.expectEqual(@as(usize, 2), sel.end.x);
try testing.expectEqual(@as(usize, 0), sel.end.y);
}
// Going forward and backward
{
const sel = s.selectWord(.{ .x = 1, .y = 0 }).?;
try testing.expectEqual(@as(usize, 0), sel.start.x);
try testing.expectEqual(@as(usize, 0), sel.start.y);
try testing.expectEqual(@as(usize, 2), sel.end.x);
try testing.expectEqual(@as(usize, 0), sel.end.y);
}
// Whitespace
{
const sel = s.selectWord(.{ .x = 3, .y = 0 }).?;
try testing.expectEqual(@as(usize, 3), sel.start.x);
try testing.expectEqual(@as(usize, 0), sel.start.y);
try testing.expectEqual(@as(usize, 4), sel.end.x);
try testing.expectEqual(@as(usize, 0), sel.end.y);
}
// Whitespace single char
{
const sel = s.selectWord(.{ .x = 0, .y = 1 }).?;
try testing.expectEqual(@as(usize, 0), sel.start.x);
try testing.expectEqual(@as(usize, 1), sel.start.y);
try testing.expectEqual(@as(usize, 0), sel.end.x);
try testing.expectEqual(@as(usize, 1), sel.end.y);
}
// End of screen
{
const sel = s.selectWord(.{ .x = 1, .y = 2 }).?;
try testing.expectEqual(@as(usize, 0), sel.start.x);
try testing.expectEqual(@as(usize, 2), sel.start.y);
try testing.expectEqual(@as(usize, 2), sel.end.x);
try testing.expectEqual(@as(usize, 2), sel.end.y);
}
}
test "Screen: selectWord across soft-wrap" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 10, 5, 0);
defer s.deinit();
try s.testWriteString(" 1234012\n 123");
// Going forward
{
const sel = s.selectWord(.{ .x = 1, .y = 0 }).?;
try testing.expectEqual(@as(usize, 1), sel.start.x);
try testing.expectEqual(@as(usize, 0), sel.start.y);
try testing.expectEqual(@as(usize, 2), sel.end.x);
try testing.expectEqual(@as(usize, 1), sel.end.y);
}
// Going backward
{
const sel = s.selectWord(.{ .x = 1, .y = 1 }).?;
try testing.expectEqual(@as(usize, 1), sel.start.x);
try testing.expectEqual(@as(usize, 0), sel.start.y);
try testing.expectEqual(@as(usize, 2), sel.end.x);
try testing.expectEqual(@as(usize, 1), sel.end.y);
}
// Going forward and backward
{
const sel = s.selectWord(.{ .x = 3, .y = 0 }).?;
try testing.expectEqual(@as(usize, 1), sel.start.x);
try testing.expectEqual(@as(usize, 0), sel.start.y);
try testing.expectEqual(@as(usize, 2), sel.end.x);
try testing.expectEqual(@as(usize, 1), sel.end.y);
}
}
test "Screen: selectWord whitespace across soft-wrap" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 10, 5, 0);
defer s.deinit();
try s.testWriteString("1 1\n 123");
// Going forward
{
const sel = s.selectWord(.{ .x = 1, .y = 0 }).?;
try testing.expectEqual(@as(usize, 1), sel.start.x);
try testing.expectEqual(@as(usize, 0), sel.start.y);
try testing.expectEqual(@as(usize, 2), sel.end.x);
try testing.expectEqual(@as(usize, 1), sel.end.y);
}
// Going backward
{
const sel = s.selectWord(.{ .x = 1, .y = 1 }).?;
try testing.expectEqual(@as(usize, 1), sel.start.x);
try testing.expectEqual(@as(usize, 0), sel.start.y);
try testing.expectEqual(@as(usize, 2), sel.end.x);
try testing.expectEqual(@as(usize, 1), sel.end.y);
}
// Going forward and backward
{
const sel = s.selectWord(.{ .x = 3, .y = 0 }).?;
try testing.expectEqual(@as(usize, 1), sel.start.x);
try testing.expectEqual(@as(usize, 0), sel.start.y);
try testing.expectEqual(@as(usize, 2), sel.end.x);
try testing.expectEqual(@as(usize, 1), sel.end.y);
}
}
test "Screen: selectWord with single quote boundary" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 10, 20, 0);
defer s.deinit();
try s.testWriteString(" 'abc' \n123");
// Inside quotes forward
{
const sel = s.selectWord(.{ .x = 2, .y = 0 }).?;
try testing.expectEqual(@as(usize, 2), sel.start.x);
try testing.expectEqual(@as(usize, 0), sel.start.y);
try testing.expectEqual(@as(usize, 4), sel.end.x);
try testing.expectEqual(@as(usize, 0), sel.end.y);
}
// Inside quotes backward
{
const sel = s.selectWord(.{ .x = 4, .y = 0 }).?;
try testing.expectEqual(@as(usize, 2), sel.start.x);
try testing.expectEqual(@as(usize, 0), sel.start.y);
try testing.expectEqual(@as(usize, 4), sel.end.x);
try testing.expectEqual(@as(usize, 0), sel.end.y);
}
// Inside quotes bidirectional
{
const sel = s.selectWord(.{ .x = 3, .y = 0 }).?;
try testing.expectEqual(@as(usize, 2), sel.start.x);
try testing.expectEqual(@as(usize, 0), sel.start.y);
try testing.expectEqual(@as(usize, 4), sel.end.x);
try testing.expectEqual(@as(usize, 0), sel.end.y);
}
// 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.
{
const sel = s.selectWord(.{ .x = 1, .y = 0 }).?;
try testing.expectEqual(@as(usize, 0), sel.start.x);
try testing.expectEqual(@as(usize, 0), sel.start.y);
try testing.expectEqual(@as(usize, 1), sel.end.x);
try testing.expectEqual(@as(usize, 0), sel.end.y);
}
}
test "Screen: selectOutput" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 15, 10, 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
var row = s.getRow(.{ .screen = 2 });
row.setSemanticPrompt(.prompt);
row = s.getRow(.{ .screen = 3 });
row.setSemanticPrompt(.input);
row = s.getRow(.{ .screen = 4 });
row.setSemanticPrompt(.command);
row = s.getRow(.{ .screen = 6 });
row.setSemanticPrompt(.input);
row = s.getRow(.{ .screen = 7 });
row.setSemanticPrompt(.command);
// No start marker, should select from the beginning
{
const sel = s.selectOutput(.{ .x = 1, .y = 1 }).?;
try testing.expectEqual(@as(usize, 0), sel.start.x);
try testing.expectEqual(@as(usize, 0), sel.start.y);
try testing.expectEqual(@as(usize, 10), sel.end.x);
try testing.expectEqual(@as(usize, 1), sel.end.y);
}
// Both start and end markers, should select between them
{
const sel = s.selectOutput(.{ .x = 3, .y = 5 }).?;
try testing.expectEqual(@as(usize, 0), sel.start.x);
try testing.expectEqual(@as(usize, 4), sel.start.y);
try testing.expectEqual(@as(usize, 10), sel.end.x);
try testing.expectEqual(@as(usize, 5), sel.end.y);
}
// No end marker, should select till the end
{
const sel = s.selectOutput(.{ .x = 2, .y = 7 }).?;
try testing.expectEqual(@as(usize, 0), sel.start.x);
try testing.expectEqual(@as(usize, 7), sel.start.y);
try testing.expectEqual(@as(usize, 9), sel.end.x);
try testing.expectEqual(@as(usize, 10), sel.end.y);
}
// input / prompt at y = 0, pt.y = 0
{
s.deinit();
s = try init(alloc, 5, 10, 0);
try s.testWriteString("prompt1$ input1\n");
try s.testWriteString("output1\n");
try s.testWriteString("prompt2\n");
row = s.getRow(.{ .screen = 0 });
row.setSemanticPrompt(.input);
row = s.getRow(.{ .screen = 1 });
row.setSemanticPrompt(.command);
try testing.expect(s.selectOutput(.{ .x = 2, .y = 0 }) == null);
}
}
test "Screen: selectPrompt basics" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 15, 10, 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
var row = s.getRow(.{ .screen = 2 });
row.setSemanticPrompt(.prompt);
row = s.getRow(.{ .screen = 3 });
row.setSemanticPrompt(.input);
row = s.getRow(.{ .screen = 4 });
row.setSemanticPrompt(.command);
row = s.getRow(.{ .screen = 6 });
row.setSemanticPrompt(.input);
row = s.getRow(.{ .screen = 7 });
row.setSemanticPrompt(.command);
// Not at a prompt
{
const sel = s.selectPrompt(.{ .x = 0, .y = 1 });
try testing.expect(sel == null);
}
{
const sel = s.selectPrompt(.{ .x = 0, .y = 8 });
try testing.expect(sel == null);
}
// Single line prompt
{
const sel = s.selectPrompt(.{ .x = 1, .y = 6 }).?;
try testing.expectEqual(Selection{
.start = .{ .x = 0, .y = 6 },
.end = .{ .x = 9, .y = 6 },
}, sel);
}
// Multi line prompt
{
const sel = s.selectPrompt(.{ .x = 1, .y = 3 }).?;
try testing.expectEqual(Selection{
.start = .{ .x = 0, .y = 2 },
.end = .{ .x = 9, .y = 3 },
}, sel);
}
}
test "Screen: selectPrompt prompt at start" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 15, 10, 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
var row = s.getRow(.{ .screen = 0 });
row.setSemanticPrompt(.prompt);
row = s.getRow(.{ .screen = 1 });
row.setSemanticPrompt(.input);
row = s.getRow(.{ .screen = 2 });
row.setSemanticPrompt(.command);
// Not at a prompt
{
const sel = s.selectPrompt(.{ .x = 0, .y = 3 });
try testing.expect(sel == null);
}
// Multi line prompt
{
const sel = s.selectPrompt(.{ .x = 1, .y = 1 }).?;
try testing.expectEqual(Selection{
.start = .{ .x = 0, .y = 0 },
.end = .{ .x = 9, .y = 1 },
}, sel);
}
}
test "Screen: selectPrompt prompt at end" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 15, 10, 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
var row = s.getRow(.{ .screen = 2 });
row.setSemanticPrompt(.prompt);
row = s.getRow(.{ .screen = 3 });
row.setSemanticPrompt(.input);
// Not at a prompt
{
const sel = s.selectPrompt(.{ .x = 0, .y = 1 });
try testing.expect(sel == null);
}
// Multi line prompt
{
const sel = s.selectPrompt(.{ .x = 1, .y = 2 }).?;
try testing.expectEqual(Selection{
.start = .{ .x = 0, .y = 2 },
.end = .{ .x = 9, .y = 3 },
}, sel);
}
}
test "Screen: promtpPath" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 15, 10, 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
var row = s.getRow(.{ .screen = 2 });
row.setSemanticPrompt(.prompt);
row = s.getRow(.{ .screen = 3 });
row.setSemanticPrompt(.input);
row = s.getRow(.{ .screen = 4 });
row.setSemanticPrompt(.command);
row = s.getRow(.{ .screen = 6 });
row.setSemanticPrompt(.input);
row = s.getRow(.{ .screen = 7 });
row.setSemanticPrompt(.command);
// From is not in the prompt
{
const path = s.promptPath(
.{ .x = 0, .y = 1 },
.{ .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(
.{ .x = 6, .y = 2 },
.{ .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(
.{ .x = 6, .y = 2 },
.{ .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(
.{ .x = 6, .y = 2 },
.{ .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(
.{ .x = 6, .y = 2 },
.{ .x = 3, .y = 9 },
);
try testing.expectEqual(@as(isize, 3), path.x);
try testing.expectEqual(@as(isize, 1), path.y);
}
}
test "Screen: scrollRegionUp single" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 4, 5, 0);
defer s.deinit();
try s.testWriteString("1ABCD\n2EFGH\n3IJKL\n4ABCD");
s.scrollRegionUp(.{ .active = 1 }, .{ .active = 2 }, 1);
{
// Test our contents rotated
const contents = try s.testString(alloc, .screen);
defer alloc.free(contents);
try testing.expectEqualStrings("1ABCD\n3IJKL\n\n4ABCD", contents);
}
}
test "Screen: scrollRegionUp same line" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 4, 5, 0);
defer s.deinit();
try s.testWriteString("1ABCD\n2EFGH\n3IJKL\n4ABCD");
s.scrollRegionUp(.{ .active = 1 }, .{ .active = 1 }, 1);
{
// Test our contents rotated
const contents = try s.testString(alloc, .screen);
defer alloc.free(contents);
try testing.expectEqualStrings("1ABCD\n2EFGH\n3IJKL\n4ABCD", contents);
}
}
test "Screen: scrollRegionUp single with pen" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 4, 5, 0);
defer s.deinit();
try s.testWriteString("1ABCD\n2EFGH\n3IJKL\n4ABCD");
s.cursor.pen = .{ .char = 'X' };
s.cursor.pen.bg = .{ .rgb = .{ .r = 155 } };
s.cursor.pen.attrs.bold = true;
s.scrollRegionUp(.{ .active = 1 }, .{ .active = 2 }, 1);
{
// Test our contents rotated
const contents = try s.testString(alloc, .screen);
defer alloc.free(contents);
try testing.expectEqualStrings("1ABCD\n3IJKL\n\n4ABCD", contents);
const cell = s.getCell(.active, 2, 0);
try testing.expectEqual(@as(u8, 155), cell.bg.rgb.r);
try testing.expect(!cell.attrs.bold);
try testing.expect(s.cursor.pen.attrs.bold);
}
}
test "Screen: scrollRegionUp multiple" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 4, 5, 0);
defer s.deinit();
try s.testWriteString("1ABCD\n2EFGH\n3IJKL\n4ABCD");
s.scrollRegionUp(.{ .active = 1 }, .{ .active = 3 }, 1);
{
// Test our contents rotated
const contents = try s.testString(alloc, .screen);
defer alloc.free(contents);
try testing.expectEqualStrings("1ABCD\n3IJKL\n4ABCD", contents);
}
}
test "Screen: scrollRegionUp multiple count" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 4, 5, 0);
defer s.deinit();
try s.testWriteString("1ABCD\n2EFGH\n3IJKL\n4ABCD");
s.scrollRegionUp(.{ .active = 1 }, .{ .active = 3 }, 2);
{
// Test our contents rotated
const contents = try s.testString(alloc, .screen);
defer alloc.free(contents);
try testing.expectEqualStrings("1ABCD\n4ABCD", contents);
}
}
test "Screen: scrollRegionUp count greater than available lines" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 4, 5, 0);
defer s.deinit();
try s.testWriteString("1ABCD\n2EFGH\n3IJKL\n4ABCD");
s.scrollRegionUp(.{ .active = 1 }, .{ .active = 2 }, 10);
{
// Test our contents rotated
const contents = try s.testString(alloc, .screen);
defer alloc.free(contents);
try testing.expectEqualStrings("1ABCD\n\n\n4ABCD", contents);
}
}
test "Screen: scrollRegionUp fills with pen" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 4, 5, 0);
defer s.deinit();
try s.testWriteString("A\nB\nC\nD");
s.cursor.pen = .{ .char = 'X' };
s.cursor.pen.bg = .{ .rgb = .{ .r = 155 } };
s.cursor.pen.attrs.bold = true;
s.scrollRegionUp(.{ .active = 0 }, .{ .active = 2 }, 1);
{
// Test our contents rotated
const contents = try s.testString(alloc, .screen);
defer alloc.free(contents);
try testing.expectEqualStrings("B\nC\n\nD", contents);
const cell = s.getCell(.active, 2, 0);
try testing.expectEqual(@as(u8, 155), cell.bg.rgb.r);
try testing.expect(!cell.attrs.bold);
try testing.expect(s.cursor.pen.attrs.bold);
}
}
test "Screen: scrollRegionUp buffer wrap" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 3, 5, 0);
defer s.deinit();
try s.testWriteString("1ABCD\n2EFGH\n3IJKL");
// Scroll down, should still be bottom, but should wrap because
// we're out of space.
try s.scroll(.{ .screen = 1 });
s.cursor.x = 0;
try s.testWriteString("1ABCD\n2EFGH\n3IJKL\n4ABCD");
// Scroll
s.cursor.pen = .{ .char = 'X' };
s.cursor.pen.bg = .{ .rgb = .{ .r = 155 } };
s.cursor.pen.attrs.bold = true;
s.scrollRegionUp(.{ .screen = 0 }, .{ .screen = 2 }, 1);
{
// Test our contents rotated
const contents = try s.testString(alloc, .screen);
defer alloc.free(contents);
try testing.expectEqualStrings("3IJKL\n4ABCD", contents);
const cell = s.getCell(.active, 2, 0);
try testing.expectEqual(@as(u8, 155), cell.bg.rgb.r);
try testing.expect(!cell.attrs.bold);
try testing.expect(s.cursor.pen.attrs.bold);
}
}
test "Screen: scrollRegionUp buffer wrap alternate" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 3, 5, 0);
defer s.deinit();
try s.testWriteString("1ABCD\n2EFGH\n3IJKL");
// Scroll down, should still be bottom, but should wrap because
// we're out of space.
try s.scroll(.{ .screen = 1 });
s.cursor.x = 0;
try s.testWriteString("1ABCD\n2EFGH\n3IJKL\n4ABCD");
// Scroll
s.cursor.pen = .{ .char = 'X' };
s.cursor.pen.bg = .{ .rgb = .{ .r = 155 } };
s.cursor.pen.attrs.bold = true;
s.scrollRegionUp(.{ .screen = 0 }, .{ .screen = 2 }, 2);
{
// Test our contents rotated
const contents = try s.testString(alloc, .screen);
defer alloc.free(contents);
try testing.expectEqualStrings("4ABCD", contents);
const cell = s.getCell(.active, 2, 0);
try testing.expectEqual(@as(u8, 155), cell.bg.rgb.r);
try testing.expect(!cell.attrs.bold);
try testing.expect(s.cursor.pen.attrs.bold);
}
}
test "Screen: scrollRegionUp buffer wrap alternative with extra lines" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 5, 5, 0);
defer s.deinit();
// We artificially mess with the circular buffer here. This was discovered
// when debugging https://github.com/mitchellh/ghostty/issues/315. I
// don't know how to "naturally" get the circular buffer into this state
// although it is obviously possible, verified through various
// asciinema casts.
//
// I think the proper way to recreate this state would be to fill
// the screen, scroll the correct number of times, clear the screen
// with a fill. I can try that later to ensure we're hitting the same
// code path.
s.storage.head = 24;
s.storage.tail = 24;
s.storage.full = true;
// Scroll down, should still be bottom, but should wrap because
// we're out of space.
// try s.scroll(.{ .screen = 2 });
// s.cursor.x = 0;
try s.testWriteString("1ABCD\n2EFGH\n3IJKL\n4ABCD\n5EFGH");
// Scroll
s.scrollRegionUp(.{ .screen = 0 }, .{ .screen = 3 }, 2);
{
// Test our contents rotated
const contents = try s.testString(alloc, .screen);
defer alloc.free(contents);
try testing.expectEqualStrings("3IJKL\n4ABCD\n\n\n5EFGH", contents);
}
}
test "Screen: clear history with no history" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 3, 5, 3);
defer s.deinit();
try s.testWriteString("4ABCD\n5EFGH\n6IJKL");
try testing.expect(s.viewportIsBottom());
try s.clear(.history);
try testing.expect(s.viewportIsBottom());
{
// Test our contents rotated
const contents = try s.testString(alloc, .viewport);
defer alloc.free(contents);
try testing.expectEqualStrings("4ABCD\n5EFGH\n6IJKL", contents);
}
{
// Test our contents rotated
const contents = try s.testString(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, 3, 5, 3);
defer s.deinit();
try s.testWriteString("1ABCD\n2EFGH\n3IJKL\n4ABCD\n5EFGH\n6IJKL");
try testing.expect(s.viewportIsBottom());
// Scroll to top
try s.scroll(.{ .top = {} });
{
// Test our contents rotated
const contents = try s.testString(alloc, .viewport);
defer alloc.free(contents);
try testing.expectEqualStrings("1ABCD\n2EFGH\n3IJKL", contents);
}
try s.clear(.history);
try testing.expect(s.viewportIsBottom());
{
// Test our contents rotated
const contents = try s.testString(alloc, .viewport);
defer alloc.free(contents);
try testing.expectEqualStrings("4ABCD\n5EFGH\n6IJKL", contents);
}
{
// Test our contents rotated
const contents = try s.testString(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");
try testing.expect(s.viewportIsBottom());
try s.clear(.above_cursor);
try testing.expect(s.viewportIsBottom());
{
const contents = try s.testString(alloc, .viewport);
defer alloc.free(contents);
try testing.expectEqualStrings("6IJKL", contents);
}
{
const contents = try s.testString(alloc, .screen);
defer alloc.free(contents);
try testing.expectEqualStrings("6IJKL", contents);
}
try testing.expectEqual(@as(usize, 5), s.cursor.x);
try testing.expectEqual(@as(usize, 0), s.cursor.y);
}
test "Screen: clear above cursor with history" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 3, 10, 3);
defer s.deinit();
try s.testWriteString("1ABCD\n2EFGH\n3IJKL\n");
try s.testWriteString("4ABCD\n5EFGH\n6IJKL");
try testing.expect(s.viewportIsBottom());
try s.clear(.above_cursor);
try testing.expect(s.viewportIsBottom());
{
const contents = try s.testString(alloc, .viewport);
defer alloc.free(contents);
try testing.expectEqualStrings("6IJKL", contents);
}
{
const contents = try s.testString(alloc, .screen);
defer alloc.free(contents);
try testing.expectEqualStrings("1ABCD\n2EFGH\n3IJKL\n6IJKL", contents);
}
try testing.expectEqual(@as(usize, 5), s.cursor.x);
try testing.expectEqual(@as(usize, 0), s.cursor.y);
}
test "Screen: selectionString basic" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 3, 5, 0);
defer s.deinit();
const str = "1ABCD\n2EFGH\n3IJKL";
try s.testWriteString(str);
{
const contents = try s.selectionString(alloc, .{
.start = .{ .x = 0, .y = 1 },
.end = .{ .x = 2, .y = 2 },
}, 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, 10, 5, 0);
defer s.deinit();
const str = "1ABCD\n2EFGH\n3IJKL";
try s.testWriteString(str);
{
const contents = try s.selectionString(alloc, .{
.start = .{ .x = 0, .y = 5 },
.end = .{ .x = 2, .y = 6 },
}, 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, 10, 5, 0);
defer s.deinit();
const str = "1ABCD\n2EFGH\n3IJKL";
try s.testWriteString(str);
{
const contents = try s.selectionString(alloc, .{
.start = .{ .x = 0, .y = 2 },
.end = .{ .x = 2, .y = 6 },
}, 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, 3, 5, 0);
defer s.deinit();
const str = "1AB \n2EFGH\n3IJKL";
try s.testWriteString(str);
{
const contents = try s.selectionString(alloc, .{
.start = .{ .x = 0, .y = 0 },
.end = .{ .x = 2, .y = 1 },
}, true);
defer alloc.free(contents);
const expected = "1AB\n2EF";
try testing.expectEqualStrings(expected, contents);
}
// No trim
{
const contents = try s.selectionString(alloc, .{
.start = .{ .x = 0, .y = 0 },
.end = .{ .x = 2, .y = 1 },
}, 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 contents = try s.selectionString(alloc, .{
.start = .{ .x = 0, .y = 0 },
.end = .{ .x = 2, .y = 2 },
}, true);
defer alloc.free(contents);
const expected = "1AB\n\n2EF";
try testing.expectEqualStrings(expected, contents);
}
// No trim
{
const contents = try s.selectionString(alloc, .{
.start = .{ .x = 0, .y = 0 },
.end = .{ .x = 2, .y = 2 },
}, 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, 3, 5, 0);
defer s.deinit();
const str = "1ABCD2EFGH3IJKL";
try s.testWriteString(str);
{
const contents = try s.selectionString(alloc, .{
.start = .{ .x = 0, .y = 1 },
.end = .{ .x = 2, .y = 2 },
}, true);
defer alloc.free(contents);
const expected = "2EFGH3IJ";
try testing.expectEqualStrings(expected, contents);
}
}
test "Screen: selectionString wrap around" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 3, 5, 0);
defer s.deinit();
try s.testWriteString("1ABCD\n2EFGH\n3IJKL");
try testing.expect(s.viewportIsBottom());
// Scroll down, should still be bottom, but should wrap because
// we're out of space.
try s.scroll(.{ .screen = 1 });
try testing.expect(s.viewportIsBottom());
try s.testWriteString("1ABCD\n2EFGH\n3IJKL");
{
const contents = try s.selectionString(alloc, .{
.start = .{ .x = 0, .y = 1 },
.end = .{ .x = 2, .y = 2 },
}, true);
defer alloc.free(contents);
const expected = "2EFGH\n3IJ";
try testing.expectEqualStrings(expected, contents);
}
}
test "Screen: selectionString wide char" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 3, 5, 0);
defer s.deinit();
const str = "1A⚡";
try s.testWriteString(str);
{
const contents = try s.selectionString(alloc, .{
.start = .{ .x = 0, .y = 0 },
.end = .{ .x = 3, .y = 0 },
}, true);
defer alloc.free(contents);
const expected = str;
try testing.expectEqualStrings(expected, contents);
}
{
const contents = try s.selectionString(alloc, .{
.start = .{ .x = 0, .y = 0 },
.end = .{ .x = 2, .y = 0 },
}, true);
defer alloc.free(contents);
const expected = str;
try testing.expectEqualStrings(expected, contents);
}
{
const contents = try s.selectionString(alloc, .{
.start = .{ .x = 3, .y = 0 },
.end = .{ .x = 3, .y = 0 },
}, 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, 3, 5, 0);
defer s.deinit();
const str = "1ABC⚡";
try s.testWriteString(str);
{
const contents = try s.selectionString(alloc, .{
.start = .{ .x = 0, .y = 0 },
.end = .{ .x = 4, .y = 0 },
}, 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, 2, 5, 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 contents = try s.selectionString(alloc, .{
.start = .{ .x = 1, .y = 0 },
.end = .{ .x = 2, .y = 0 },
}, 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, 1, 10, 0);
defer s.deinit();
const str = "👨‍"; // this has a ZWJ
try s.testWriteString(str);
// Integrity check
const row = s.getRow(.{ .screen = 0 });
{
const cell = row.getCell(0);
try testing.expectEqual(@as(u32, 0x1F468), cell.char);
try testing.expect(cell.attrs.wide);
try testing.expectEqual(@as(usize, 2), row.codepointLen(0));
}
{
const cell = row.getCell(1);
try testing.expectEqual(@as(u32, ' '), cell.char);
try testing.expect(cell.attrs.wide_spacer_tail);
try testing.expectEqual(@as(usize, 1), row.codepointLen(1));
}
// The real test
{
const contents = try s.selectionString(alloc, .{
.start = .{ .x = 0, .y = 0 },
.end = .{ .x = 1, .y = 0 },
}, 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, 5, 30, 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{
.start = .{ .x = 2, .y = 1 },
.end = .{ .x = 6, .y = 3 },
.rectangle = true,
};
const expected =
\\t ame
\\ipisc
\\usmod
;
try s.testWriteString(str);
const contents = try s.selectionString(alloc, sel, 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, 5, 30, 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{
.start = .{ .x = 12, .y = 0 },
.end = .{ .x = 26, .y = 4 },
.rectangle = true,
};
const expected =
\\dolor
\\nsectetur
\\lit, sed do
\\or incididunt
\\ dolore
;
try s.testWriteString(str);
const contents = try s.selectionString(alloc, sel, 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, 8, 30, 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{
.start = .{ .x = 11, .y = 2 },
.end = .{ .x = 26, .y = 7 },
.rectangle = 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, true);
defer alloc.free(contents);
try testing.expectEqualStrings(expected, contents);
}
test "Screen: dirty with getCellPtr" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 3, 5, 0);
defer s.deinit();
try s.testWriteString("1ABCD\n2EFGH\n3IJKL");
try testing.expect(s.viewportIsBottom());
// Ensure all are dirty. Clear em.
var iter = s.rowIterator(.viewport);
while (iter.next()) |row| {
try testing.expect(row.isDirty());
row.setDirty(false);
}
// Reset our cursor onto the second row.
s.cursor.x = 0;
s.cursor.y = 1;
try s.testWriteString("foo");
{
const row = s.getRow(.{ .active = 0 });
try testing.expect(!row.isDirty());
}
{
const row = s.getRow(.{ .active = 1 });
try testing.expect(row.isDirty());
}
{
const row = s.getRow(.{ .active = 2 });
try testing.expect(!row.isDirty());
_ = row.getCell(0);
try testing.expect(!row.isDirty());
}
}
test "Screen: dirty with clear, fill, fillSlice, copyRow" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 3, 5, 0);
defer s.deinit();
try s.testWriteString("1ABCD\n2EFGH\n3IJKL");
try testing.expect(s.viewportIsBottom());
// Ensure all are dirty. Clear em.
var iter = s.rowIterator(.viewport);
while (iter.next()) |row| {
try testing.expect(row.isDirty());
row.setDirty(false);
}
{
const row = s.getRow(.{ .active = 0 });
try testing.expect(!row.isDirty());
row.clear(.{});
try testing.expect(row.isDirty());
row.setDirty(false);
}
{
const row = s.getRow(.{ .active = 0 });
try testing.expect(!row.isDirty());
row.fill(.{ .char = 'A' });
try testing.expect(row.isDirty());
row.setDirty(false);
}
{
const row = s.getRow(.{ .active = 0 });
try testing.expect(!row.isDirty());
row.fillSlice(.{ .char = 'A' }, 0, 2);
try testing.expect(row.isDirty());
row.setDirty(false);
}
{
const src = s.getRow(.{ .active = 0 });
const row = s.getRow(.{ .active = 1 });
try testing.expect(!row.isDirty());
try row.copyRow(src);
try testing.expect(!src.isDirty());
try testing.expect(row.isDirty());
row.setDirty(false);
}
}
test "Screen: dirty with graphemes" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 3, 5, 0);
defer s.deinit();
try s.testWriteString("1ABCD\n2EFGH\n3IJKL");
try testing.expect(s.viewportIsBottom());
// Ensure all are dirty. Clear em.
var iter = s.rowIterator(.viewport);
while (iter.next()) |row| {
try testing.expect(row.isDirty());
row.setDirty(false);
}
{
const row = s.getRow(.{ .active = 0 });
try testing.expect(!row.isDirty());
try row.attachGrapheme(0, 0xFE0F);
try testing.expect(row.isDirty());
row.setDirty(false);
row.clearGraphemes(0);
try testing.expect(row.isDirty());
row.setDirty(false);
}
}
test "Screen: resize (no reflow) more rows" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 3, 5, 0);
defer s.deinit();
const str = "1ABCD\n2EFGH\n3IJKL";
try s.testWriteString(str);
// Clear dirty rows
var iter = s.rowIterator(.viewport);
while (iter.next()) |row| row.setDirty(false);
// Resize
try s.resizeWithoutReflow(10, 5);
{
const contents = try s.testString(alloc, .viewport);
defer alloc.free(contents);
try testing.expectEqualStrings(str, contents);
}
// Everything should be dirty
iter = s.rowIterator(.viewport);
while (iter.next()) |row| try testing.expect(row.isDirty());
}
test "Screen: resize (no reflow) less rows" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 3, 5, 0);
defer s.deinit();
const str = "1ABCD\n2EFGH\n3IJKL";
try s.testWriteString(str);
try s.resizeWithoutReflow(2, 5);
{
const contents = try s.testString(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, 3, 5, 0);
defer s.deinit();
const str = "1ABCD";
try s.testWriteString(str);
// Write only a background color into the remaining rows
for (1..s.rows) |y| {
const row = s.getRow(.{ .active = y });
for (0..s.cols) |x| {
const cell = row.getCellPtr(x);
cell.*.bg = .{ .rgb = .{ .r = 0xFF, .g = 0, .b = 0 } };
}
}
// Make sure our cursor is at the end of the first line
s.cursor.x = 4;
s.cursor.y = 0;
const cursor = s.cursor;
try s.resizeWithoutReflow(2, 5);
// Cursor should not move
try testing.expectEqual(cursor, s.cursor);
{
const contents = try s.testString(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, 3, 5, 0);
defer s.deinit();
const str = "1ABCD";
try s.testWriteString(str);
// Write only a background color into the remaining rows
for (1..s.rows) |y| {
const row = s.getRow(.{ .active = y });
for (0..s.cols) |x| {
const cell = row.getCellPtr(x);
cell.*.bg = .{ .rgb = .{ .r = 0xFF, .g = 0, .b = 0 } };
}
}
// Make sure our cursor is at the end of the first line
s.cursor.x = 4;
s.cursor.y = 0;
const cursor = s.cursor;
try s.resizeWithoutReflow(7, 5);
// Cursor should not move
try testing.expectEqual(cursor, s.cursor);
{
const contents = try s.testString(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, 3, 5, 0);
defer s.deinit();
const str = "1ABCD\n2EFGH\n3IJKL";
try s.testWriteString(str);
try s.resizeWithoutReflow(3, 10);
{
const contents = try s.testString(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, 3, 5, 0);
defer s.deinit();
const str = "1ABCD\n2EFGH\n3IJKL";
try s.testWriteString(str);
try s.resizeWithoutReflow(3, 4);
{
const contents = try s.testString(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, 3, 5, 2);
defer s.deinit();
const str = "1ABCD\n2EFGH\n3IJKL\n4ABCD\n5EFGH";
try s.testWriteString(str);
try s.resizeWithoutReflow(10, 5);
{
const contents = try s.testString(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, 3, 5, 2);
defer s.deinit();
const str = "1ABCD\n2EFGH\n3IJKL\n4ABCD\n5EFGH";
try s.testWriteString(str);
try s.resizeWithoutReflow(2, 5);
{
const contents = try s.testString(alloc, .screen);
defer alloc.free(contents);
const expected = "2EFGH\n3IJKL\n4ABCD\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, 3, 5, 5);
defer s.deinit();
const str = "1\n2\n3\n4\n5\n6\n7\n8";
try s.testWriteString(str);
try s.scroll(.{ .clear = {} });
s.cursor.x = 0;
s.cursor.y = 0;
try s.testWriteString("A\nB");
const cursor = s.cursor;
try s.resizeWithoutReflow(2, 5);
try testing.expectEqual(cursor, s.cursor);
{
const contents = try s.testString(alloc, .viewport);
defer alloc.free(contents);
try testing.expectEqualStrings("A\nB", contents);
}
}
test "Screen: resize (no reflow) empty screen" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 5, 5, 0);
defer s.deinit();
try testing.expect(s.rowsWritten() == 0);
try testing.expectEqual(@as(usize, 5), s.rowsCapacity());
try s.resizeWithoutReflow(10, 10);
try testing.expect(s.rowsWritten() == 0);
// This is the primary test for this test, we want to ensure we
// always have at least enough capacity for our rows.
try testing.expectEqual(@as(usize, 10), s.rowsCapacity());
}
test "Screen: resize (no reflow) grapheme copy" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 3, 5, 0);
defer s.deinit();
const str = "1ABCD\n2EFGH\n3IJKL";
try s.testWriteString(str);
// Attach graphemes to all the columns
{
var iter = s.rowIterator(.viewport);
while (iter.next()) |row| {
var col: usize = 0;
while (col < s.cols) : (col += 1) {
try row.attachGrapheme(col, 0xFE0F);
}
}
}
// Clear dirty rows
{
var iter = s.rowIterator(.viewport);
while (iter.next()) |row| row.setDirty(false);
}
// Resize
try s.resizeWithoutReflow(10, 5);
{
const expected = "1ABCD\n2EFGH\n3IJKL";
const contents = try s.testString(alloc, .viewport);
defer alloc.free(contents);
try testing.expectEqualStrings(expected, contents);
}
// Everything should be dirty
{
var iter = s.rowIterator(.viewport);
while (iter.next()) |row| try testing.expect(row.isDirty());
}
}
test "Screen: resize (no reflow) more rows with soft wrapping" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 3, 2, 3);
defer s.deinit();
const str = "1A2B\n3C4E\n5F6G";
try s.testWriteString(str);
// Every second row should be wrapped
{
var y: usize = 0;
while (y < 6) : (y += 1) {
const row = s.getRow(.{ .screen = y });
const wrapped = (y % 2 == 0);
try testing.expectEqual(wrapped, row.header().flags.wrap);
}
}
// Resize
try s.resizeWithoutReflow(10, 2);
{
const contents = try s.testString(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
{
var y: usize = 0;
while (y < 6) : (y += 1) {
const row = s.getRow(.{ .screen = y });
const wrapped = (y % 2 == 0);
try testing.expectEqual(wrapped, row.header().flags.wrap);
}
}
}
test "Screen: resize more rows no scrollback" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 3, 5, 0);
defer s.deinit();
const str = "1ABCD\n2EFGH\n3IJKL";
try s.testWriteString(str);
const cursor = s.cursor;
try s.resize(10, 5);
// Cursor should not move
try testing.expectEqual(cursor, s.cursor);
{
const contents = try s.testString(alloc, .viewport);
defer alloc.free(contents);
try testing.expectEqualStrings(str, contents);
}
{
const contents = try s.testString(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, 3, 5, 10);
defer s.deinit();
const str = "1ABCD\n2EFGH\n3IJKL";
try s.testWriteString(str);
const cursor = s.cursor;
try s.resize(10, 5);
// Cursor should not move
try testing.expectEqual(cursor, s.cursor);
{
const contents = try s.testString(alloc, .viewport);
defer alloc.free(contents);
try testing.expectEqualStrings(str, contents);
}
{
const contents = try s.testString(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, 3, 5, 5);
defer s.deinit();
const str = "1ABCD\n2EFGH\n3IJKL\n4ABCD\n5EFGH";
try s.testWriteString(str);
{
const contents = try s.testString(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.cursor.x = 0;
s.cursor.y = 1;
try testing.expectEqual(@as(u32, '4'), s.getCell(.active, s.cursor.y, s.cursor.x).char);
// Resize
try s.resize(10, 5);
// Cursor should still be on the "4"
try testing.expectEqual(@as(u32, '4'), s.getCell(.active, s.cursor.y, s.cursor.x).char);
{
const contents = try s.testString(alloc, .viewport);
defer alloc.free(contents);
const expected = "3IJKL\n4ABCD\n5EFGH";
try testing.expectEqualStrings(expected, contents);
}
}
test "Screen: resize more rows and cols with wrapping" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 4, 2, 0);
defer s.deinit();
const str = "1A2B\n3C4D";
try s.testWriteString(str);
{
const contents = try s.testString(alloc, .viewport);
defer alloc.free(contents);
const expected = "1A\n2B\n3C\n4D";
try testing.expectEqualStrings(expected, contents);
}
try s.resize(10, 5);
// Cursor should move due to wrapping
try testing.expectEqual(@as(usize, 3), s.cursor.x);
try testing.expectEqual(@as(usize, 1), s.cursor.y);
{
const contents = try s.testString(alloc, .viewport);
defer alloc.free(contents);
try testing.expectEqualStrings(str, contents);
}
{
const contents = try s.testString(alloc, .screen);
defer alloc.free(contents);
try testing.expectEqualStrings(str, contents);
}
}
test "Screen: resize more cols no reflow" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 3, 5, 0);
defer s.deinit();
const str = "1ABCD\n2EFGH\n3IJKL";
try s.testWriteString(str);
const cursor = s.cursor;
try s.resize(3, 10);
// Cursor should not move
try testing.expectEqual(cursor, s.cursor);
{
const contents = try s.testString(alloc, .viewport);
defer alloc.free(contents);
try testing.expectEqualStrings(str, contents);
}
{
const contents = try s.testString(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, 3, 5, 0);
defer s.deinit();
const str = "1ABCD2EFGH3IJKL";
try s.testWriteString(str);
try s.resize(3, 10);
}
// 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, 3, 5, 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(usize, 1), s.cursor.x);
try testing.expectEqual(@as(usize, 2), s.cursor.y);
try s.scroll(.{ .viewport = -4 });
{
const contents = try s.testString(alloc, .viewport);
defer alloc.free(contents);
try testing.expectEqualStrings("2\n3\n4", contents);
}
try s.resize(3, 8);
{
const contents = try s.testString(alloc, .screen);
defer alloc.free(contents);
try testing.expectEqualStrings(str, contents);
}
// Cursor remains at bottom
try testing.expectEqual(@as(usize, 1), s.cursor.x);
try testing.expectEqual(@as(usize, 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, 3, 5, 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(usize, 1), s.cursor.x);
try testing.expectEqual(@as(usize, 2), s.cursor.y);
try s.scroll(.{ .viewport = -4 });
{
const contents = try s.testString(alloc, .viewport);
defer alloc.free(contents);
try testing.expectEqualStrings("2\n3\n4", contents);
}
try s.resize(3, 4);
{
const contents = try s.testString(alloc, .screen);
defer alloc.free(contents);
try testing.expectEqualStrings(str, contents);
}
// Cursor remains at bottom
try testing.expectEqual(@as(usize, 1), s.cursor.x);
try testing.expectEqual(@as(usize, 2), s.cursor.y);
}
test "Screen: resize more cols no reflow preserves semantic prompt" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 3, 5, 0);
defer s.deinit();
const str = "1ABCD\n2EFGH\n3IJKL";
try s.testWriteString(str);
// Set one of the rows to be a prompt
{
const row = s.getRow(.{ .active = 1 });
row.setSemanticPrompt(.prompt);
}
const cursor = s.cursor;
try s.resize(3, 10);
// Cursor should not move
try testing.expectEqual(cursor, s.cursor);
{
const contents = try s.testString(alloc, .viewport);
defer alloc.free(contents);
try testing.expectEqualStrings(str, contents);
}
{
const contents = try s.testString(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 row = s.getRow(.{ .active = 0 });
try testing.expect(row.getSemanticPrompt() == .unknown);
}
{
const row = s.getRow(.{ .active = 1 });
try testing.expect(row.getSemanticPrompt() == .prompt);
}
{
const row = s.getRow(.{ .active = 2 });
try testing.expect(row.getSemanticPrompt() == .unknown);
}
}
test "Screen: resize more cols grapheme map" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 3, 5, 0);
defer s.deinit();
const str = "1ABCD\n2EFGH\n3IJKL";
try s.testWriteString(str);
// Attach graphemes to all the columns
{
var iter = s.rowIterator(.viewport);
while (iter.next()) |row| {
var col: usize = 0;
while (col < s.cols) : (col += 1) {
try row.attachGrapheme(col, 0xFE0F);
}
}
}
const cursor = s.cursor;
try s.resize(3, 10);
// Cursor should not move
try testing.expectEqual(cursor, s.cursor);
{
const expected = "1ABCD\n2EFGH\n3IJKL";
const contents = try s.testString(alloc, .viewport);
defer alloc.free(contents);
try testing.expectEqualStrings(expected, contents);
}
{
const expected = "1ABCD\n2EFGH\n3IJKL";
const contents = try s.testString(alloc, .screen);
defer alloc.free(contents);
try testing.expectEqualStrings(expected, contents);
}
}
test "Screen: resize more cols with reflow that fits full width" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 3, 5, 0);
defer s.deinit();
const str = "1ABCD2EFGH\n3IJKL";
try s.testWriteString(str);
// Verify we soft wrapped
{
const contents = try s.testString(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.cursor.x = 0;
s.cursor.y = 1;
try testing.expectEqual(@as(u32, '2'), s.getCell(.active, s.cursor.y, s.cursor.x).char);
// Resize and verify we undid the soft wrap because we have space now
try s.resize(3, 10);
{
const contents = try s.testString(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, 3, 6, 0);
defer s.deinit();
const str = "1ABCD2EFGH\n3IJKL";
try s.testWriteString(str);
// Verify we soft wrapped
{
const contents = try s.testString(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.cursor.x = 0;
s.cursor.y = 2;
try testing.expectEqual(@as(u32, '3'), s.getCell(.active, s.cursor.y, s.cursor.x).char);
// Resize and verify we undid the soft wrap because we have space now
try s.resize(3, 10);
{
const contents = try s.testString(alloc, .viewport);
defer alloc.free(contents);
try testing.expectEqualStrings(str, contents);
}
// Our cursor should still be on the 3
try testing.expectEqual(@as(u32, '3'), s.getCell(.active, s.cursor.y, s.cursor.x).char);
}
test "Screen: resize more cols with reflow that forces more wrapping" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 3, 5, 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.cursor.x = 0;
s.cursor.y = 1;
try testing.expectEqual(@as(u32, '2'), s.getCell(.active, s.cursor.y, s.cursor.x).char);
// Verify we soft wrapped
{
const contents = try s.testString(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(3, 7);
{
const contents = try s.testString(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(usize, 5), s.cursor.x);
try testing.expectEqual(@as(usize, 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, 3, 5, 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.cursor.x = 0;
s.cursor.y = 2;
try testing.expectEqual(@as(u32, '3'), s.getCell(.active, s.cursor.y, s.cursor.x).char);
// Verify we soft wrapped
{
const contents = try s.testString(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(3, 15);
{
const contents = try s.testString(alloc, .viewport);
defer alloc.free(contents);
const expected = "1ABCD2EFGH3IJKL";
try testing.expectEqualStrings(expected, contents);
}
// Our cursor should've moved
try testing.expectEqual(@as(usize, 10), s.cursor.x);
try testing.expectEqual(@as(usize, 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, 3, 5, 5);
defer s.deinit();
const str = "1ABCD\n2EFGH\n3IJKL\n4ABCD5EFGH";
try s.testWriteString(str);
{
const contents = try s.testString(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.cursor.x = 0;
s.cursor.y = 2;
try testing.expectEqual(@as(u32, '5'), s.getCell(.active, s.cursor.y, s.cursor.x).char);
// Resize
try s.resize(3, 10);
// Cursor should still be on the "5"
try testing.expectEqual(@as(u32, '5'), s.getCell(.active, s.cursor.y, s.cursor.x).char);
{
const contents = try s.testString(alloc, .viewport);
defer alloc.free(contents);
const expected = "2EFGH\n3IJKL\n4ABCD5EFGH";
try testing.expectEqualStrings(expected, contents);
}
}
test "Screen: resize more cols with reflow" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 3, 2, 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.cursor.x = 0;
s.cursor.y = 2;
try testing.expectEqual(@as(u32, 'E'), s.getCell(.active, s.cursor.y, s.cursor.x).char);
// Verify we soft wrapped
{
const contents = try s.testString(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(3, 7);
{
const contents = try s.testString(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(usize, 2), s.cursor.x);
try testing.expectEqual(@as(usize, 2), s.cursor.y);
}
test "Screen: resize less rows no scrollback" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 3, 5, 0);
defer s.deinit();
const str = "1ABCD\n2EFGH\n3IJKL";
try s.testWriteString(str);
s.cursor.x = 0;
s.cursor.y = 0;
const cursor = s.cursor;
try s.resize(1, 5);
// Cursor should not move
try testing.expectEqual(cursor, s.cursor);
{
const contents = try s.testString(alloc, .viewport);
defer alloc.free(contents);
const expected = "3IJKL";
try testing.expectEqualStrings(expected, contents);
}
{
const contents = try s.testString(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, 3, 5, 0);
defer s.deinit();
const str = "1ABCD\n2EFGH\n3IJKL";
try s.testWriteString(str);
// Put our cursor on the last line
s.cursor.x = 1;
s.cursor.y = 2;
try testing.expectEqual(@as(u32, 'I'), s.getCell(.active, s.cursor.y, s.cursor.x).char);
// Resize
try s.resize(1, 5);
// Cursor should be on the last line
try testing.expectEqual(@as(usize, 1), s.cursor.x);
try testing.expectEqual(@as(usize, 0), s.cursor.y);
{
const contents = try s.testString(alloc, .viewport);
defer alloc.free(contents);
const expected = "3IJKL";
try testing.expectEqualStrings(expected, contents);
}
{
const contents = try s.testString(alloc, .screen);
defer alloc.free(contents);
const expected = "3IJKL";
try testing.expectEqualStrings(expected, contents);
}
}
test "Screen: resize less rows with empty scrollback" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 3, 5, 10);
defer s.deinit();
const str = "1ABCD\n2EFGH\n3IJKL";
try s.testWriteString(str);
try s.resize(1, 5);
{
const contents = try s.testString(alloc, .screen);
defer alloc.free(contents);
try testing.expectEqualStrings(str, contents);
}
{
const contents = try s.testString(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, 3, 5, 5);
defer s.deinit();
const str = "1ABCD\n2EFGH\n3IJKL\n4ABCD\n5EFGH";
try s.testWriteString(str);
{
const contents = try s.testString(alloc, .viewport);
defer alloc.free(contents);
const expected = "3IJKL\n4ABCD\n5EFGH";
try testing.expectEqualStrings(expected, contents);
}
// Resize
try s.resize(1, 5);
{
const contents = try s.testString(alloc, .screen);
defer alloc.free(contents);
try testing.expectEqualStrings(str, contents);
}
{
const contents = try s.testString(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, 3, 5, 3);
defer s.deinit();
const str = "00000\n1ABCD\n2EFGH\n3IJKL\n4ABCD\n5EFGH";
try s.testWriteString(str);
{
const contents = try s.testString(alloc, .viewport);
defer alloc.free(contents);
const expected = "3IJKL\n4ABCD\n5EFGH";
try testing.expectEqualStrings(expected, contents);
}
const cursor = s.cursor;
try testing.expectEqual(Cursor{ .x = 4, .y = 2 }, cursor);
// Resize
try s.resize(2, 5);
// Cursor should stay in the same relative place (bottom of the
// screen, same character).
try testing.expectEqual(Cursor{ .x = 4, .y = 1 }, s.cursor);
{
const contents = try s.testString(alloc, .screen);
defer alloc.free(contents);
const expected = "1ABCD\n2EFGH\n3IJKL\n4ABCD\n5EFGH";
try testing.expectEqualStrings(expected, contents);
}
{
const contents = try s.testString(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, 3, 5, 0);
defer s.deinit();
const str = "1AB\n2EF\n3IJ";
try s.testWriteString(str);
s.cursor.x = 0;
s.cursor.y = 0;
const cursor = s.cursor;
try s.resize(3, 3);
// Cursor should not move
try testing.expectEqual(cursor, s.cursor);
{
const contents = try s.testString(alloc, .viewport);
defer alloc.free(contents);
try testing.expectEqualStrings(str, contents);
}
{
const contents = try s.testString(alloc, .screen);
defer alloc.free(contents);
try testing.expectEqualStrings(str, contents);
}
}
test "Screen: resize less cols trailing background colors" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 3, 10, 0);
defer s.deinit();
const str = "1AB";
try s.testWriteString(str);
const cursor = s.cursor;
// Color our cells red
const pen: Cell = .{ .bg = .{ .rgb = .{ .r = 0xFF } } };
for (s.cursor.x..s.cols) |x| {
const row = s.getRow(.{ .active = s.cursor.y });
const cell = row.getCellPtr(x);
cell.* = pen;
}
for ((s.cursor.y + 1)..s.rows) |y| {
const row = s.getRow(.{ .active = y });
row.fill(pen);
}
try s.resize(3, 5);
// Cursor should not move
try testing.expectEqual(cursor, s.cursor);
{
const contents = try s.testString(alloc, .viewport);
defer alloc.free(contents);
try testing.expectEqualStrings(str, contents);
}
{
const contents = try s.testString(alloc, .screen);
defer alloc.free(contents);
try testing.expectEqualStrings(str, contents);
}
// Verify all our trailing cells have the color
for (s.cursor.x..s.cols) |x| {
const row = s.getRow(.{ .active = s.cursor.y });
const cell = row.getCellPtr(x);
try testing.expectEqual(pen, cell.*);
}
}
test "Screen: resize less cols with graphemes" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 3, 5, 0);
defer s.deinit();
const str = "1AB\n2EF\n3IJ";
try s.testWriteString(str);
// Attach graphemes to all the columns
{
var iter = s.rowIterator(.viewport);
while (iter.next()) |row| {
var col: usize = 0;
while (col < 3) : (col += 1) {
try row.attachGrapheme(col, 0xFE0F);
}
}
}
s.cursor.x = 0;
s.cursor.y = 0;
const cursor = s.cursor;
try s.resize(3, 3);
// Cursor should not move
try testing.expectEqual(cursor, s.cursor);
{
const expected = "1AB\n2EF\n3IJ";
const contents = try s.testString(alloc, .viewport);
defer alloc.free(contents);
try testing.expectEqualStrings(expected, contents);
}
{
const expected = "1AB\n2EF\n3IJ";
const contents = try s.testString(alloc, .screen);
defer alloc.free(contents);
try testing.expectEqualStrings(expected, contents);
}
}
test "Screen: resize less cols no reflow preserves semantic prompt" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 3, 5, 0);
defer s.deinit();
const str = "1AB\n2EF\n3IJ";
try s.testWriteString(str);
// Set one of the rows to be a prompt
{
const row = s.getRow(.{ .active = 1 });
row.setSemanticPrompt(.prompt);
}
s.cursor.x = 0;
s.cursor.y = 0;
const cursor = s.cursor;
try s.resize(3, 3);
// Cursor should not move
try testing.expectEqual(cursor, s.cursor);
{
const contents = try s.testString(alloc, .viewport);
defer alloc.free(contents);
try testing.expectEqualStrings(str, contents);
}
{
const contents = try s.testString(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 row = s.getRow(.{ .active = 0 });
try testing.expect(row.getSemanticPrompt() == .unknown);
}
{
const row = s.getRow(.{ .active = 1 });
try testing.expect(row.getSemanticPrompt() == .prompt);
}
{
const row = s.getRow(.{ .active = 2 });
try testing.expect(row.getSemanticPrompt() == .unknown);
}
}
test "Screen: resize less cols with reflow but row space" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 3, 5, 0);
defer s.deinit();
const str = "1ABCD";
try s.testWriteString(str);
// Put our cursor on the end
s.cursor.x = 4;
s.cursor.y = 0;
try testing.expectEqual(@as(u32, 'D'), s.getCell(.active, s.cursor.y, s.cursor.x).char);
try s.resize(3, 3);
{
const contents = try s.testString(alloc, .viewport);
defer alloc.free(contents);
const expected = "1AB\nCD";
try testing.expectEqualStrings(expected, contents);
}
{
const contents = try s.testString(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(usize, 1), s.cursor.x);
try testing.expectEqual(@as(usize, 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, 3, 5, 0);
defer s.deinit();
const str = "3IJKL\n4ABCD\n5EFGH";
try s.testWriteString(str);
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.testString(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, 3, 5, 1);
defer s.deinit();
const str = "3IJKL\n4ABCD\n5EFGH";
try s.testWriteString(str);
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.testString(alloc, .screen);
defer alloc.free(contents);
const expected = "4AB\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, 3, 5, 0);
defer s.deinit();
const str = "3IJKL4ABCD5EFGH";
try s.testWriteString(str);
// Check
{
const contents = try s.testString(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.testString(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, 3, 5, 5);
defer s.deinit();
const str = "1A\n2B\n3C\n4D\n5E";
try s.testWriteString(str);
// Put our cursor on the end
s.cursor.x = 1;
s.cursor.y = s.rows - 1;
try testing.expectEqual(@as(u32, 'E'), s.getCell(.active, s.cursor.y, s.cursor.x).char);
try s.resize(3, 3);
{
const contents = try s.testString(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(usize, 1), s.cursor.x);
try testing.expectEqual(@as(usize, 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, 3, 5, 2);
defer s.deinit();
const str = "1ABCD2EFGH3IJKL4ABCD5EFGH";
try s.testWriteString(str);
// Check
{
const contents = try s.testString(alloc, .viewport);
defer alloc.free(contents);
const expected = "3IJKL\n4ABCD\n5EFGH";
try testing.expectEqualStrings(expected, contents);
}
// Put our cursor on the end
s.cursor.x = s.cols - 1;
s.cursor.y = s.rows - 1;
try testing.expectEqual(@as(u32, 'H'), s.getCell(.active, s.cursor.y, s.cursor.x).char);
try s.resize(3, 3);
{
const contents = try s.testString(alloc, .viewport);
defer alloc.free(contents);
const expected = "CD5\nEFG\nH";
try testing.expectEqualStrings(expected, contents);
}
{
const contents = try s.testString(alloc, .screen);
defer alloc.free(contents);
const expected = "JKL\n4AB\nCD5\nEFG\nH";
try testing.expectEqualStrings(expected, contents);
}
// Cursor should be on the last line
try testing.expectEqual(@as(u32, 'H'), s.getCell(.active, s.cursor.y, s.cursor.x).char);
try testing.expectEqual(@as(usize, 0), s.cursor.x);
try testing.expectEqual(@as(usize, 2), s.cursor.y);
}
test "Screen: resize less cols with scrollback keeps cursor row" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 3, 5, 5);
defer s.deinit();
const str = "1A\n2B\n3C\n4D\n5E";
try s.testWriteString(str);
// Lets do a scroll and clear operation
try s.scroll(.{ .clear = {} });
// Move our cursor to the beginning
s.cursor.x = 0;
s.cursor.y = 0;
try s.resize(3, 3);
{
const contents = try s.testString(alloc, .viewport);
defer alloc.free(contents);
const expected = "";
try testing.expectEqualStrings(expected, contents);
}
// Cursor should be on the last line
try testing.expectEqual(@as(usize, 0), s.cursor.x);
try testing.expectEqual(@as(usize, 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, 3, 5, 3);
defer s.deinit();
const str = "1ABCD\n2EFGH3IJKL\n4MNOP";
try s.testWriteString(str);
{
const contents = try s.testString(alloc, .screen);
defer alloc.free(contents);
const expected = "1ABCD\n2EFGH\n3IJKL\n4MNOP";
try testing.expectEqualStrings(expected, contents);
}
{
const contents = try s.testString(alloc, .viewport);
defer alloc.free(contents);
const expected = "2EFGH\n3IJKL\n4MNOP";
try testing.expectEqualStrings(expected, contents);
}
try s.resize(10, 2);
{
const contents = try s.testString(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.testString(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, 3, 5, 10);
defer s.deinit();
const str = "1ABC";
try s.testWriteString(str);
// Grow
try s.resize(10, 5);
{
const contents = try s.testString(alloc, .viewport);
defer alloc.free(contents);
try testing.expectEqualStrings(str, contents);
}
{
const contents = try s.testString(alloc, .screen);
defer alloc.free(contents);
try testing.expectEqualStrings(str, contents);
}
// Shrink
try s.resize(3, 5);
{
const contents = try s.testString(alloc, .screen);
defer alloc.free(contents);
try testing.expectEqualStrings(str, contents);
}
{
const contents = try s.testString(alloc, .viewport);
defer alloc.free(contents);
try testing.expectEqualStrings(str, contents);
}
// Grow again
try s.resize(10, 5);
{
const contents = try s.testString(alloc, .viewport);
defer alloc.free(contents);
try testing.expectEqualStrings(str, contents);
}
{
const contents = try s.testString(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, 1, 2, 0);
defer s.deinit();
const str = "😀";
try s.testWriteString(str);
{
const contents = try s.testString(alloc, .screen);
defer alloc.free(contents);
try testing.expectEqualStrings(str, contents);
}
{
const cell = s.getCell(.screen, 0, 0);
try testing.expectEqual(@as(u32, '😀'), cell.char);
try testing.expect(cell.attrs.wide);
}
// Resize to 1 column can't fit a wide char. So it should be deleted.
try s.resize(1, 1);
{
const contents = try s.testString(alloc, .screen);
defer alloc.free(contents);
try testing.expectEqualStrings(" ", contents);
}
const cell = s.getCell(.screen, 0, 0);
try testing.expectEqual(@as(u32, ' '), cell.char);
try testing.expect(!cell.attrs.wide);
try testing.expect(!cell.attrs.wide_spacer_tail);
try testing.expect(!cell.attrs.wide_spacer_head);
}
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.testString(alloc, .screen);
defer alloc.free(contents);
try testing.expectEqualStrings(str, contents);
}
{
const cell = s.getCell(.screen, 0, 1);
try testing.expectEqual(@as(u32, '😀'), cell.char);
try testing.expect(cell.attrs.wide);
try testing.expect(s.getCell(.screen, 0, 2).attrs.wide_spacer_tail);
}
try s.resize(3, 2);
{
const contents = try s.testString(alloc, .screen);
defer alloc.free(contents);
try testing.expectEqualStrings("x\n😀", contents);
}
{
const cell = s.getCell(.screen, 0, 1);
try testing.expectEqual(@as(u32, ' '), cell.char);
try testing.expect(!cell.attrs.wide);
try testing.expect(!cell.attrs.wide_spacer_tail);
try testing.expect(cell.attrs.wide_spacer_head);
}
}
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.testString(alloc, .screen);
defer alloc.free(contents);
try testing.expectEqualStrings(str, contents);
}
{
const cell = s.getCell(.screen, 0, 0);
try testing.expectEqual(@as(u32, '😀'), cell.char);
try testing.expect(cell.attrs.wide);
try testing.expect(s.getCell(.screen, 0, 1).attrs.wide_spacer_tail);
}
try s.resize(2, 1);
{
const contents = try s.testString(alloc, .screen);
defer alloc.free(contents);
try testing.expectEqualStrings(" \n ", contents);
}
{
const cell = s.getCell(.screen, 0, 0);
try testing.expectEqual(@as(u32, ' '), cell.char);
try testing.expect(!cell.attrs.wide);
try testing.expect(!cell.attrs.wide_spacer_tail);
try testing.expect(!cell.attrs.wide_spacer_head);
}
}
test "Screen: resize more cols with wide spacer head" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 2, 3, 0);
defer s.deinit();
const str = " 😀";
try s.testWriteString(str);
{
const contents = try s.testString(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 cell = s.getCell(.screen, 0, 2);
try testing.expectEqual(@as(u32, ' '), cell.char);
try testing.expect(cell.attrs.wide_spacer_head);
try testing.expect(s.getCell(.screen, 1, 0).attrs.wide);
try testing.expect(s.getCell(.screen, 1, 1).attrs.wide_spacer_tail);
}
try s.resize(2, 4);
{
const contents = try s.testString(alloc, .screen);
defer alloc.free(contents);
try testing.expectEqualStrings(str, contents);
}
{
const cell = s.getCell(.screen, 0, 2);
try testing.expectEqual(@as(u32, '😀'), cell.char);
try testing.expect(!cell.attrs.wide_spacer_head);
try testing.expect(cell.attrs.wide);
try testing.expect(s.getCell(.screen, 0, 3).attrs.wide_spacer_tail);
}
}
test "Screen: resize less cols preserves grapheme cluster" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 1, 5, 0);
defer s.deinit();
const str: []const u8 = &.{ 0x43, 0xE2, 0x83, 0x90 }; // C⃐ (C with combining left arrow)
try s.testWriteString(str);
// We should have a single cell with all the codepoints
{
const row = s.getRow(.{ .screen = 0 });
try testing.expectEqual(@as(usize, 2), row.codepointLen(0));
}
{
const contents = try s.testString(alloc, .screen);
defer alloc.free(contents);
try testing.expectEqualStrings(str, contents);
}
// Resize to less columns. No wrapping, but we should still have
// the same grapheme cluster.
try s.resize(1, 4);
{
const contents = try s.testString(alloc, .screen);
defer alloc.free(contents);
try testing.expectEqualStrings(str, contents);
}
}
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.testString(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 cell = s.getCell(.screen, 1, 2);
try testing.expectEqual(@as(u32, ' '), cell.char);
try testing.expect(cell.attrs.wide_spacer_head);
try testing.expect(s.getCell(.screen, 2, 0).attrs.wide);
try testing.expect(s.getCell(.screen, 2, 1).attrs.wide_spacer_tail);
}
try s.resize(2, 8);
{
const contents = try s.testString(alloc, .screen);
defer alloc.free(contents);
try testing.expectEqualStrings(str, contents);
}
{
const cell = s.getCell(.screen, 0, 5);
try testing.expect(!cell.attrs.wide_spacer_head);
try testing.expectEqual(@as(u32, '😀'), cell.char);
try testing.expect(cell.attrs.wide);
try testing.expect(s.getCell(.screen, 0, 6).attrs.wide_spacer_tail);
}
}
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.testString(alloc, .screen);
defer alloc.free(contents);
try testing.expectEqualStrings("xx\n😀", contents);
}
{
try testing.expect(s.getCell(.screen, 1, 0).attrs.wide);
try testing.expect(s.getCell(.screen, 1, 1).attrs.wide_spacer_tail);
}
// 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(2, 3);
{
const contents = try s.testString(alloc, .screen);
defer alloc.free(contents);
try testing.expectEqualStrings("xx\n😀", contents);
}
{
const cell = s.getCell(.screen, 0, 2);
try testing.expectEqual(@as(u32, ' '), cell.char);
try testing.expect(cell.attrs.wide_spacer_head);
try testing.expect(s.getCell(.screen, 1, 0).attrs.wide);
try testing.expect(s.getCell(.screen, 1, 1).attrs.wide_spacer_tail);
}
{
const cell = s.getCell(.screen, 1, 0);
try testing.expectEqual(@as(u32, '😀'), cell.char);
try testing.expect(cell.attrs.wide);
try testing.expect(s.getCell(.screen, 1, 1).attrs.wide_spacer_tail);
}
}
test "Screen: jump zero" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 3, 5, 10);
defer s.deinit();
try s.testWriteString("1ABCD\n2EFGH\n3IJKL\n");
try s.testWriteString("4ABCD\n5EFGH\n6IJKL");
try testing.expect(s.viewportIsBottom());
// Set semantic prompts
{
const row = s.getRow(.{ .screen = 1 });
row.setSemanticPrompt(.prompt);
}
{
const row = s.getRow(.{ .screen = 5 });
row.setSemanticPrompt(.prompt);
}
try testing.expect(!s.jump(.{ .prompt_delta = 0 }));
try testing.expectEqual(@as(usize, 3), s.viewport);
}
test "Screen: jump to prompt" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 3, 5, 10);
defer s.deinit();
try s.testWriteString("1ABCD\n2EFGH\n3IJKL\n");
try s.testWriteString("4ABCD\n5EFGH\n6IJKL");
try testing.expect(s.viewportIsBottom());
// Set semantic prompts
{
const row = s.getRow(.{ .screen = 1 });
row.setSemanticPrompt(.prompt);
}
{
const row = s.getRow(.{ .screen = 5 });
row.setSemanticPrompt(.prompt);
}
// Jump back
try testing.expect(s.jump(.{ .prompt_delta = -1 }));
try testing.expectEqual(@as(usize, 1), s.viewport);
// Jump back
try testing.expect(!s.jump(.{ .prompt_delta = -1 }));
try testing.expectEqual(@as(usize, 1), s.viewport);
// Jump forward
try testing.expect(s.jump(.{ .prompt_delta = 1 }));
try testing.expectEqual(@as(usize, 3), s.viewport);
// Jump forward
try testing.expect(!s.jump(.{ .prompt_delta = 1 }));
try testing.expectEqual(@as(usize, 3), s.viewport);
}
test "Screen: row graphemeBreak" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 1, 10, 0);
defer s.deinit();
try s.testWriteString("x");
try s.testWriteString("👨A");
const row = s.getRow(.{ .screen = 0 });
// Normal char is a break
try testing.expect(row.graphemeBreak(0));
// Emoji with ZWJ is not
try testing.expect(!row.graphemeBreak(1));
}