mirror of
https://github.com/ghostty-org/ghostty.git
synced 2025-07-14 15:56:13 +03:00
7714 lines
254 KiB
Zig
7714 lines
254 KiB
Zig
//! 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 = "1️A️B️C️D️\n2️E️F️G️H️\n3️I️J️K️L️";
|
||
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 = "1️A️B️C️D️\n2️E️F️G️H️\n3️I️J️K️L️";
|
||
const contents = try s.testString(alloc, .viewport);
|
||
defer alloc.free(contents);
|
||
try testing.expectEqualStrings(expected, contents);
|
||
}
|
||
{
|
||
const expected = "1️A️B️C️D️\n2️E️F️G️H️\n3️I️J️K️L️";
|
||
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 = "1️A️B️\n2️E️F️\n3️I️J️";
|
||
const contents = try s.testString(alloc, .viewport);
|
||
defer alloc.free(contents);
|
||
try testing.expectEqualStrings(expected, contents);
|
||
}
|
||
{
|
||
const expected = "1️A️B️\n2️E️F️\n3️I️J️";
|
||
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));
|
||
}
|