From f2af0983cf3d1ecd677122a41ed91a184b2b06b2 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 30 Aug 2022 16:35:44 -0700 Subject: [PATCH 01/34] dedicated circular buffer --- src/terminal/circ_buf.zig | 219 ++++++++++++++++++++++++++++++++++++++ src/terminal/main.zig | 6 +- 2 files changed, 222 insertions(+), 3 deletions(-) create mode 100644 src/terminal/circ_buf.zig diff --git a/src/terminal/circ_buf.zig b/src/terminal/circ_buf.zig new file mode 100644 index 000000000..971b332dd --- /dev/null +++ b/src/terminal/circ_buf.zig @@ -0,0 +1,219 @@ +const std = @import("std"); +const assert = std.debug.assert; +const Allocator = std.mem.Allocator; + +/// Returns a circular buffer containing type T. +pub fn CircBuf(comptime T: type) type { + return struct { + const Self = @This(); + + // Implementation note: there's a lot of unsafe addition of usize + // here in this implementation that can technically overflow. If someone + // wants to fix this and make it overflow safe (use subtractions for + // checks prior to additions) then I welcome it. In reality, we'd + // have to be a really, really large terminal screen to even worry + // about this so I'm punting it. + + storage: []T, + head: usize, + tail: usize, + + // We could remove this and just use math with head/tail to figure + // it out, but our usage of circular buffers stores so much data that + // this minor overhead is not worth optimizing out. + full: bool, + + /// Initialize a new circular buffer that can store size elements. + pub fn init(alloc: Allocator, size: usize) !Self { + var buf = try alloc.alloc(T, size); + std.mem.set(T, buf, std.mem.zeroes(T)); + + return Self{ + .storage = buf, + .head = 0, + .tail = 0, + .full = false, + }; + } + + pub fn deinit(self: *Self, alloc: Allocator) void { + alloc.free(self.storage); + self.* = undefined; + } + + /// Returns if the buffer is currently empty. To check if its + /// full, just check the "full" attribute. + pub fn empty(self: Self) bool { + return !self.full and self.head == self.tail; + } + + /// Returns the total capacity allocated for this buffer. + pub fn capacity(self: Self) usize { + return self.storage.len; + } + + /// Returns the length in elements that are used. + pub fn len(self: Self) usize { + if (self.full) return self.storage.len; + if (self.head >= self.tail) return self.head - self.tail; + return self.storage.len - (self.tail - self.head); + } + + /// Delete the oldest n values from the buffer. If there are less + /// than n values in the buffer, it'll delete everything. + pub fn deleteOldest(self: *Self, n: usize) void { + // If we're not full, we can just advance the tail. We know + // it'll be less than the length because otherwise we'd be full. + self.tail += @minimum(self.len(), n); + if (self.tail >= self.storage.len) self.tail -= self.storage.len; + self.full = false; + } + + /// Returns a pointer to the value at offset with the given length, + /// and considers this full amount of data "written" if it is beyond + /// the end of our buffer. This never "rotates" the buffer because + /// the offset can only be within the size of the buffer. + pub fn getPtrSlice(self: *Self, offset: usize, slice_len: usize) [2][]T { + assert(slice_len > 0); + + // End offset is the last offset (exclusive) for our slice. + // We use exclusive because it makes the math easier and it + // matches Zigs slicing parameterization. + const end_offset = offset + slice_len; + + // If our slice can't fit it in our length, then we need to advance. + if (end_offset > self.len()) self.advance(end_offset - self.len()); + + // Our start and end indexes into the storage buffer + const start_idx = self.storageOffset(offset); + const end_idx = self.storageOffset(end_offset - 1); + // std.log.warn("A={} B={}", .{ start_idx, end_idx }); + + // Optimistically, our data fits in one slice + if (end_idx >= start_idx) { + return .{ + self.storage[start_idx .. end_idx + 1], + self.storage[0..0], // So there is an empty slice + }; + } + + return .{ + self.storage[start_idx..], + self.storage[0 .. end_idx + 1], + }; + } + + /// Advances the head/tail so that we can store amount. + fn advance(self: *Self, amount: usize) void { + assert(amount <= self.storage.len - self.len()); + + // Optimistically add our amount + self.head += amount; + + // If we exceeded the length of the buffer, wrap around. + if (self.head >= self.storage.len) { + self.head = self.head - self.storage.len; + self.tail = self.head; + } + + // We're full if the head reached the tail. The head can never + // pass the tail because advance asserts amount is only in + // available space left + self.full = self.head == self.tail; + } + + /// For a given offset from zero, this returns the offset in the + /// storage buffer where this data can be found. + fn storageOffset(self: Self, offset: usize) usize { + assert(offset < self.storage.len); + + // This should be subtraction ideally to avoid overflows but + // it would take a really, really, huge buffer to overflow. + const fits_offset = self.tail + offset; + if (fits_offset < self.storage.len) return fits_offset; + return fits_offset - self.storage.len; + } + }; +} + +test { + const testing = std.testing; + const alloc = testing.allocator; + + const Buf = CircBuf(u8); + var buf = try Buf.init(alloc, 12); + defer buf.deinit(alloc); + + try testing.expect(buf.empty()); + try testing.expectEqual(@as(usize, 0), buf.len()); +} + +test "getPtrSlice fits" { + const testing = std.testing; + const alloc = testing.allocator; + + const Buf = CircBuf(u8); + var buf = try Buf.init(alloc, 12); + defer buf.deinit(alloc); + + const slices = buf.getPtrSlice(0, 11); + try testing.expectEqual(@as(usize, 11), slices[0].len); + try testing.expectEqual(@as(usize, 0), slices[1].len); + try testing.expectEqual(@as(usize, 11), buf.len()); +} + +test "getPtrSlice wraps" { + const testing = std.testing; + const alloc = testing.allocator; + + const Buf = CircBuf(u8); + var buf = try Buf.init(alloc, 4); + defer buf.deinit(alloc); + + // Fill the buffer + _ = buf.getPtrSlice(0, buf.capacity()); + try testing.expect(buf.full); + try testing.expectEqual(@as(usize, 4), buf.len()); + + // Delete + buf.deleteOldest(2); + try testing.expect(!buf.full); + try testing.expectEqual(@as(usize, 2), buf.len()); + + // Get a slice that doesn't grow + { + const slices = buf.getPtrSlice(0, 2); + try testing.expectEqual(@as(usize, 2), slices[0].len); + try testing.expectEqual(@as(usize, 0), slices[1].len); + try testing.expectEqual(@as(usize, 2), buf.len()); + slices[0][0] = 1; + slices[0][1] = 2; + } + + // Get a slice that does grow, and forces wrap + { + const slices = buf.getPtrSlice(2, 2); + try testing.expectEqual(@as(usize, 2), slices[0].len); + try testing.expectEqual(@as(usize, 0), slices[1].len); + try testing.expectEqual(@as(usize, 4), buf.len()); + + // should be empty + try testing.expectEqual(@as(u8, 0), slices[0][0]); + try testing.expectEqual(@as(u8, 0), slices[0][1]); + slices[0][0] = 3; + slices[0][1] = 4; + } + + // Get a slice across boundaries + { + const slices = buf.getPtrSlice(0, 4); + try testing.expectEqual(@as(usize, 2), slices[0].len); + try testing.expectEqual(@as(usize, 2), slices[1].len); + try testing.expectEqual(@as(usize, 4), buf.len()); + + try testing.expectEqual(@as(u8, 1), slices[0][0]); + try testing.expectEqual(@as(u8, 2), slices[0][1]); + try testing.expectEqual(@as(u8, 3), slices[1][0]); + try testing.expectEqual(@as(u8, 4), slices[1][1]); + } +} diff --git a/src/terminal/main.zig b/src/terminal/main.zig index a1a1d0d34..c46b6dbc3 100644 --- a/src/terminal/main.zig +++ b/src/terminal/main.zig @@ -40,7 +40,7 @@ test { _ = Terminal; _ = Screen; - _ = @import("osc.zig"); - _ = @import("parse_table.zig"); - _ = @import("Tabstops.zig"); +test { + @import("std").testing.refAllDecls(@This()); + _ = @import("circ_buf.zig"); } From 19b46b608477ba6a9e8635a1ab1b38b02936cfcb Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 30 Aug 2022 16:39:49 -0700 Subject: [PATCH 02/34] starting the new screen implementation --- src/terminal/Screen2.zig | 167 +++++++++++++++++++++++++++++++++++++++ src/terminal/main.zig | 13 +-- 2 files changed, 168 insertions(+), 12 deletions(-) create mode 100644 src/terminal/Screen2.zig diff --git a/src/terminal/Screen2.zig b/src/terminal/Screen2.zig new file mode 100644 index 000000000..a63865e21 --- /dev/null +++ b/src/terminal/Screen2.zig @@ -0,0 +1,167 @@ +//! 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. +//! +const Screen = @This(); + +const std = @import("std"); +const assert = std.debug.assert; +const Allocator = std.mem.Allocator; + +const color = @import("color.zig"); +const CircBuf = @import("circ_buf.zig").CircBuf; + +const log = std.log.scoped(.screen); + +/// This is a single item within the storage buffer. We use a union to +/// have different types of data in a single contiguous buffer. +/// +/// Note: the union is extern so that it follows the same memory layout +/// semantics as C, which allows us to have a tightly packed union. +const StorageCell = extern union { + row_header: RowHeader, + cell: Cell, + + test { + // log.warn("header={}@{} cell={}@{} storage={}@{}", .{ + // @sizeOf(RowHeader), + // @alignOf(RowHeader), + // @sizeOf(Cell), + // @alignOf(Cell), + // @sizeOf(StorageCell), + // @alignOf(StorageCell), + // }); + + // 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. + try std.testing.expectEqual(@sizeOf(Cell), @sizeOf(StorageCell)); + } +}; + +/// The row header is at the start of every row within the storage buffer. +/// It can store row-specific data. +const RowHeader = struct { + dirty: bool, + + /// If true, this row is soft-wrapped. The first cell of the next + /// row is a continuous of this row. + wrap: bool, +}; + +/// Cell is a single cell within the screen. +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, + + /// Foreground and background color. attrs.has_{bg/fg} must be checked + /// to see if these are useful values. + fg: color.RGB = undefined, + bg: color.RGB = undefined, + + /// On/off attributes that can be set + attrs: packed struct { + has_bg: bool = false, + has_fg: bool = false, + + bold: bool = false, + faint: bool = false, + underline: bool = false, + inverse: 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 preceeding + /// wide character (tail) or following (head). + wide_spacer_tail: bool = false, + wide_spacer_head: bool = false, + } = .{}, + + /// True if the cell should be skipped for drawing + pub fn empty(self: Cell) bool { + return self.char == 0; + } + + 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={} {}", .{ @sizeOf(Cell), @alignOf(Cell) }); + try std.testing.expectEqual(12, @sizeOf(Cell)); + } +}; + +const StorageBuf = CircBuf(StorageCell); + +/// The allocator used for all the storage operations +alloc: Allocator, + +/// The full set of storage. +storage: StorageBuf, + +/// 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, + +/// 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 + @minimum(max_scrollback, rows)) * (cols + 1); + + return Screen{ + .alloc = alloc, + .storage = try StorageBuf.init(alloc, buf_size), + .rows = rows, + .cols = cols, + .max_scrollback = max_scrollback, + }; +} + +pub fn deinit(self: *Screen) void { + self.storage.deinit(self.alloc); +} + +test { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 3, 5, 0); + defer s.deinit(); +} diff --git a/src/terminal/main.zig b/src/terminal/main.zig index c46b6dbc3..38f93d762 100644 --- a/src/terminal/main.zig +++ b/src/terminal/main.zig @@ -27,18 +27,7 @@ pub const EraseLine = csi.EraseLine; pub const TabClear = csi.TabClear; pub const Attribute = sgr.Attribute; -test { - _ = ansi; - _ = charsets; - _ = color; - _ = csi; - _ = point; - _ = sgr; - _ = stream; - _ = Parser; - _ = Selection; - _ = Terminal; - _ = Screen; +pub const Screen2 = @import("Screen2.zig"); test { @import("std").testing.refAllDecls(@This()); From 001ec979a263ec212467582c07aca15afc9cf6a1 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 30 Aug 2022 17:33:25 -0700 Subject: [PATCH 03/34] big API surface for screen2, can test write/read now --- src/terminal/Screen2.zig | 327 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 325 insertions(+), 2 deletions(-) diff --git a/src/terminal/Screen2.zig b/src/terminal/Screen2.zig index a63865e21..16995f00b 100644 --- a/src/terminal/Screen2.zig +++ b/src/terminal/Screen2.zig @@ -20,6 +20,7 @@ const std = @import("std"); const assert = std.debug.assert; const Allocator = std.mem.Allocator; +const utf8proc = @import("utf8proc"); const color = @import("color.zig"); const CircBuf = @import("circ_buf.zig").CircBuf; @@ -31,7 +32,7 @@ const log = std.log.scoped(.screen); /// Note: the union is extern so that it follows the same memory layout /// semantics as C, which allows us to have a tightly packed union. const StorageCell = extern union { - row_header: RowHeader, + header: RowHeader, cell: Cell, test { @@ -62,7 +63,7 @@ const RowHeader = struct { }; /// Cell is a single cell within the screen. -const Cell = struct { +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 @@ -117,6 +118,158 @@ const Cell = struct { } }; +/// A row is a single row in the screen. +pub const Row = struct { + /// 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, + + /// 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.wrap = v; + } + + /// 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); + return &self.storage[x + 1].cell; + } + + /// Read-only iterator for the cells in the row. + pub fn cellIterator(self: Row) CellIterator { + return .{ .row = self }; + } +}; + +/// Used to iterate through the rows of a specific region. +pub const RowIterator = struct { + screen: *Screen, + tag: RowIndexTag, + value: usize = 0, + + pub fn next(self: *RowIterator) ?Row { + if (self.value >= self.tag.maxLen(self.screen)) 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; + } +}; + +/// 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: { + assert(y < RowIndexTag.screen.maxLen(screen)); + break :y y; + }, + + .viewport => |y| y: { + assert(y < RowIndexTag.viewport.maxLen(screen)); + break :y y + screen.viewport; + }, + + .active => |y| y: { + assert(y < RowIndexTag.active.maxLen(screen)); + break :y RowIndexTag.history.maxLen(screen) + y; + }, + + .history => |y| y: { + 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 fn maxLen(self: RowIndexTag, screen: *const Screen) usize { + const rows_written = screen.rowsWritten(); + + return switch (self) { + // Screen can be any of the written rows + .screen => rows_written, + + // Viewport can be any of the written rows or the max size + // of a viewport. + .viewport => @minimum(screen.rows, rows_written), + + // 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 => if (rows_written > screen.rows) rows_written - screen.rows else 0, + + // 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, + }; + } + + /// 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 }, + }; + } +}; + const StorageBuf = CircBuf(StorageCell); /// The allocator used for all the storage operations @@ -133,6 +286,9 @@ cols: usize, /// 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, + /// Initialize a new screen. pub fn init( alloc: Allocator, @@ -151,6 +307,7 @@ pub fn init( .rows = rows, .cols = cols, .max_scrollback = max_scrollback, + .viewport = 0, }; } @@ -158,10 +315,176 @@ pub fn deinit(self: *Screen) void { self.storage.deinit(self.alloc); } +/// 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 { + return .{ .screen = self, .tag = tag }; +} + +/// 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 { + // 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); + + return .{ .storage = slices[0] }; +} + +/// 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().screen * (self.cols + 1); +} + +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); +} + +/// 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 = 0; + var x: usize = 0; + + 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; + continue; + } + + // If we're writing past the end of the active area, scroll. + if (y >= self.rows) { + y -= 1; + @panic("TODO"); + //self.scroll(.{ .delta = 1 }); + } + + // Get our row + var row = self.getRow(.{ .active = y }); + + // 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; + @panic("TODO"); + //self.scroll(.{ .delta = 1 }); + } + row = self.getRow(.{ .active = y }); + } + + // If our character is double-width, handle it. + const width = utf8proc.charwidth(c); + assert(width == 1 or width == 2); + switch (width) { + 1 => { + const cell = row.getCellPtr(x); + cell.char = @intCast(u32, c); + }, + + 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; + @panic("TODO"); + //self.scroll(.{ .delta = 1 }); + } + row = self.getRow(.{ .active = y }); + } + + { + const cell = row.getCellPtr(x); + cell.char = @intCast(u32, c); + cell.attrs.wide = true; + } + + { + x += 1; + const cell = row.getCellPtr(x); + cell.char = ' '; + cell.attrs.wide_spacer_tail = true; + } + }, + + else => unreachable, + } + + x += 1; + } +} + +/// 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 { + const buf = try alloc.alloc(u8, self.storage.len() * 4); + + var i: usize = 0; + var y: usize = 0; + var rows = self.rowIterator(tag); + while (rows.next()) |row| { + defer y += 1; + + if (y > 0) { + buf[i] = '\n'; + i += 1; + } + + var cells = row.cellIterator(); + while (cells.next()) |cell| { + // TODO: handle character after null + if (cell.char > 0) { + i += try std.unicode.utf8Encode(@intCast(u21, cell.char), buf[i..]); + } + } + } + + // Never render the final newline + const str = std.mem.trimRight(u8, buf[0..i], "\n"); + return try alloc.realloc(buf, str.len); +} + test { const testing = std.testing; const alloc = testing.allocator; var s = try init(alloc, 3, 5, 0); defer s.deinit(); + + const str = "1ABCD\n2EFGH\n3IJKL"; + s.testWriteString(str); + { + var contents = try s.testString(alloc, .screen); + defer alloc.free(contents); + try testing.expectEqualStrings(str, contents); + } } From f6f8fee8040711e17df40ff5c61ab29043d43b5c Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 31 Aug 2022 11:44:39 -0700 Subject: [PATCH 04/34] screen2: scrolling (to a certain extent), copying in tests --- src/terminal/Screen2.zig | 401 ++++++++++++++++++++++++++++++++++++-- src/terminal/circ_buf.zig | 1 + 2 files changed, 391 insertions(+), 11 deletions(-) diff --git a/src/terminal/Screen2.zig b/src/terminal/Screen2.zig index 16995f00b..d91f66f43 100644 --- a/src/terminal/Screen2.zig +++ b/src/terminal/Screen2.zig @@ -73,7 +73,7 @@ pub const Cell = struct { /// 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, + char: u32 = 0, /// Foreground and background color. attrs.has_{bg/fg} must be checked /// to see if these are useful values. @@ -131,6 +131,16 @@ pub const Row = struct { self.storage[0].header.wrap = v; } + /// Clear the row, making all cells empty. + pub fn clear(self: Row) void { + self.fill(.{}); + } + + /// Fill the entire row with a copy of a single cell. + pub fn fill(self: Row, cell: Cell) void { + std.mem.set(StorageCell, self.storage[1..], .{ .cell = 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 @@ -299,7 +309,8 @@ pub fn init( // * 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 + @minimum(max_scrollback, rows)) * (cols + 1); + const buf_size = (rows + max_scrollback) * (cols + 1); + //const buf_size = (rows + @minimum(max_scrollback, rows)) * (cols + 1); return Screen{ .alloc = alloc, @@ -315,6 +326,11 @@ pub fn deinit(self: *Screen) void { self.storage.deinit(self.alloc); } +/// Returns true if the viewport is scrolled to the bottom of the screen. +pub fn viewportIsBottom(self: Screen) bool { + return self.viewport >= RowIndexTag.history.maxLen(&self); +} + /// 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. @@ -341,9 +357,12 @@ pub fn getRow(self: *Screen, index: RowIndex) Row { /// invalid. fn rowOffset(self: Screen, index: RowIndex) usize { // +1 for row header - return index.toScreen().screen * (self.cols + 1); + 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 @@ -352,6 +371,118 @@ fn rowsWritten(self: Screen) usize { 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); +} + +/// 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". + delta: isize, + + /// Same as delta but scrolling down will not grow the scrollback. + /// Scrolling down at the bottom will do nothing (similar to how + /// delta at the top does nothing). + delta_no_grow: isize, +}; + +/// 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) void { + 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 = RowIndexTag.history.maxLen(self), + + // TODO: deltas greater than the entire scrollback + .delta => |delta| self.scrollDelta(delta, true), + .delta_no_grow => |delta| self.scrollDelta(delta, false), + } +} + +fn scrollDelta(self: *Screen, delta: isize, grow: bool) void { + // 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 -|= @intCast(usize, -delta); + return; + } + + // If we're scrolling down and not growing, then we just + // add to the viewport and clamp at the bottom. + const viewport_max = RowIndexTag.history.maxLen(self); + if (!grow) { + self.viewport = @minimum( + viewport_max, + self.viewport +| @intCast(usize, delta), + ); + return; + } + + // { + // const rows_capacity = self.rowsCapacity(); + // const rows_written = self.rowsWritten(); + // log.warn("rows_written={} rows_capacity={} vp={} vp_new={}", .{ + // rows_written, + // rows_capacity, + // self.viewport, + // self.viewport + @intCast(usize, delta), + // }); + // } + + // 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. + self.viewport +|= @intCast(usize, delta); + if (self.viewport <= viewport_max) return; + + // Our viewport is bigger than our max. The number of new rows we need + // in our buffer is our value minus the max. + const new_rows_needed = self.viewport - viewport_max; + + // If we can fit this into our existing capacity, then just grow to it. + const rows_capacity = self.rowsCapacity(); + const rows_written = self.rowsWritten(); + if (rows_written + new_rows_needed <= rows_capacity) { + // Ensure we have "written" this data into the circular buffer. + _ = self.storage.getPtrSlice( + self.viewport * (self.cols + 1), + self.cols + 1, + ); + return; + } + + // We can't fit our new rows into the capacity, so the amount + // between what we need and the capacity needs to be deleted. We + // scroll "up" by that much to offset this. + const rows_to_delete = (rows_written + new_rows_needed) - rows_capacity; + self.viewport -= rows_to_delete; + self.storage.deleteOldest(rows_to_delete * (self.cols + 1)); + + // If we grew down like this, we must be at the bottom. + assert(self.viewportIsBottom()); +} + /// 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. @@ -372,8 +503,7 @@ pub fn testWriteString(self: *Screen, text: []const u8) void { // If we're writing past the end of the active area, scroll. if (y >= self.rows) { y -= 1; - @panic("TODO"); - //self.scroll(.{ .delta = 1 }); + self.scroll(.{ .delta = 1 }); } // Get our row @@ -386,8 +516,7 @@ pub fn testWriteString(self: *Screen, text: []const u8) void { x = 0; if (y >= self.rows) { y -= 1; - @panic("TODO"); - //self.scroll(.{ .delta = 1 }); + self.scroll(.{ .delta = 1 }); } row = self.getRow(.{ .active = y }); } @@ -413,8 +542,7 @@ pub fn testWriteString(self: *Screen, text: []const u8) void { x = 0; if (y >= self.rows) { y -= 1; - @panic("TODO"); - //self.scroll(.{ .delta = 1 }); + self.scroll(.{ .delta = 1 }); } row = self.getRow(.{ .active = y }); } @@ -473,18 +601,269 @@ pub fn testString(self: *Screen, alloc: Allocator, tag: RowIndexTag) ![]const u8 return try alloc.realloc(buf, str.len); } -test { +test "Screen" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 3, 5, 0); + 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"; s.testWriteString(str); + try testing.expect(s.rowsWritten() == 3); { var 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' }); + var contents = try s.testString(alloc, .screen); + defer alloc.free(contents); + try testing.expectEqualStrings("AAAAA\nAAAAA\nAAAAA", contents); + } +} + +test "Screen: scrolling" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 3, 5, 0); + defer s.deinit(); + s.testWriteString("1ABCD\n2EFGH\n3IJKL"); + try testing.expect(s.viewportIsBottom()); + + // Scroll down, should still be bottom + s.scroll(.{ .delta = 1 }); + try testing.expect(s.viewportIsBottom()); + + // Test our row index + try testing.expectEqual(@as(usize, 0), s.rowOffset(.{ .active = 0 })); + try testing.expectEqual(@as(usize, 6), s.rowOffset(.{ .active = 1 })); + try testing.expectEqual(@as(usize, 12), s.rowOffset(.{ .active = 2 })); + + { + // Test our contents rotated + var contents = try s.testString(alloc, .viewport); + defer alloc.free(contents); + try testing.expectEqualStrings("2EFGH\n3IJKL", contents); + } + + // Scrolling to the bottom does nothing + s.scroll(.{ .bottom = {} }); + + { + // Test our contents rotated + var 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(); + s.testWriteString("1ABCD\n2EFGH\n3IJKL"); + + // Scrolling up does nothing, but allows it + s.scroll(.{ .delta = -1 }); + try testing.expect(s.viewportIsBottom()); + + { + // Test our contents rotated + var 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(); + s.testWriteString("1ABCD\n2EFGH\n3IJKL"); + s.scroll(.{ .delta = 1 }); + + { + // Test our contents rotated + var contents = try s.testString(alloc, .viewport); + defer alloc.free(contents); + try testing.expectEqualStrings("2EFGH\n3IJKL", contents); + } + + // Scrolling to the bottom + s.scroll(.{ .bottom = {} }); + try testing.expect(s.viewportIsBottom()); + + { + // Test our contents rotated + var contents = try s.testString(alloc, .viewport); + defer alloc.free(contents); + try testing.expectEqualStrings("2EFGH\n3IJKL", contents); + } + + // Scrolling back should make it visible again + s.scroll(.{ .delta = -1 }); + try testing.expect(!s.viewportIsBottom()); + + { + // Test our contents rotated + var contents = try s.testString(alloc, .viewport); + defer alloc.free(contents); + try testing.expectEqualStrings("1ABCD\n2EFGH\n3IJKL", contents); + } + + // Scrolling back again should do nothing + s.scroll(.{ .delta = -1 }); + + { + // Test our contents rotated + var contents = try s.testString(alloc, .viewport); + defer alloc.free(contents); + try testing.expectEqualStrings("1ABCD\n2EFGH\n3IJKL", contents); + } + + // Scrolling to the bottom + s.scroll(.{ .bottom = {} }); + + { + // Test our contents rotated + var contents = try s.testString(alloc, .viewport); + defer alloc.free(contents); + try testing.expectEqualStrings("2EFGH\n3IJKL", contents); + } + + // Scrolling forward with no grow should do nothing + s.scroll(.{ .delta_no_grow = 1 }); + + { + // Test our contents rotated + var contents = try s.testString(alloc, .viewport); + defer alloc.free(contents); + try testing.expectEqualStrings("2EFGH\n3IJKL", contents); + } + + // Scrolling to the top should work + s.scroll(.{ .top = {} }); + + { + // Test our contents rotated + var 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(); + { + var contents = try s.testString(alloc, .viewport); + defer alloc.free(contents); + try testing.expectEqualStrings("1ABCD", contents); + } + + // Scrolling to the bottom + s.scroll(.{ .bottom = {} }); + + { + // Test our contents rotated + var contents = try s.testString(alloc, .viewport); + defer alloc.free(contents); + try testing.expectEqualStrings("", contents); + } +} + +test "Screen: scrollback empty" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 3, 5, 50); + defer s.deinit(); + s.testWriteString("1ABCD\n2EFGH\n3IJKL"); + s.scroll(.{ .delta_no_grow = 1 }); + + { + // Test our contents + var contents = try s.testString(alloc, .viewport); + defer alloc.free(contents); + try testing.expectEqualStrings("1ABCD\n2EFGH\n3IJKL", 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"; + s.testWriteString(str); + { + var 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"; + s.testWriteString(str); + { + var contents = try s.testString(alloc, .viewport); + defer alloc.free(contents); + const expected = "3IJKL"; + try testing.expectEqualStrings(expected, contents); + } + { + // Test our contents + var contents = try s.testString(alloc, .screen); + defer alloc.free(contents); + try testing.expectEqualStrings("1ABCD\n2EFGH\n3IJKL", contents); + } + + { + var contents = try s.testString(alloc, .history); + defer alloc.free(contents); + const expected = "1ABCD\n2EFGH"; + try testing.expectEqualStrings(expected, contents); + } } diff --git a/src/terminal/circ_buf.zig b/src/terminal/circ_buf.zig index 971b332dd..7f2c823f8 100644 --- a/src/terminal/circ_buf.zig +++ b/src/terminal/circ_buf.zig @@ -75,6 +75,7 @@ pub fn CircBuf(comptime T: type) type { /// the offset can only be within the size of the buffer. pub fn getPtrSlice(self: *Self, offset: usize, slice_len: usize) [2][]T { assert(slice_len > 0); + assert(offset + slice_len <= self.capacity()); // End offset is the last offset (exclusive) for our slice. // We use exclusive because it makes the math easier and it From c7b7e3cb4476ab2248e8f486784b0c95ae6e2565 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 31 Aug 2022 14:58:43 -0700 Subject: [PATCH 05/34] screen2: selection --- src/terminal/Screen2.zig | 331 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 330 insertions(+), 1 deletion(-) diff --git a/src/terminal/Screen2.zig b/src/terminal/Screen2.zig index d91f66f43..f919ac192 100644 --- a/src/terminal/Screen2.zig +++ b/src/terminal/Screen2.zig @@ -23,6 +23,7 @@ const Allocator = std.mem.Allocator; const utf8proc = @import("utf8proc"); const color = @import("color.zig"); const CircBuf = @import("circ_buf.zig").CircBuf; +const Selection = @import("Selection.zig"); const log = std.log.scoped(.screen); @@ -54,7 +55,7 @@ const StorageCell = extern union { /// The row header is at the start of every row within the storage buffer. /// It can store row-specific data. -const RowHeader = struct { +pub const RowHeader = struct { dirty: bool, /// If true, this row is soft-wrapped. The first cell of the next @@ -131,6 +132,11 @@ pub const Row = struct { self.storage[0].header.wrap = v; } + /// Retrieve the header for this row. + pub fn header(self: Row) RowHeader { + return self.storage[0].header; + } + /// Clear the row, making all cells empty. pub fn clear(self: Row) void { self.fill(.{}); @@ -141,6 +147,12 @@ pub const Row = struct { std.mem.set(StorageCell, self.storage[1..], .{ .cell = cell }); } + /// 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 @@ -150,6 +162,11 @@ pub const Row = struct { return &self.storage[x + 1].cell; } + /// Copy the row src into this row. + pub fn copyRow(self: Row, src: Row) void { + std.mem.copy(StorageCell, self.storage[1..], src.storage[1..]); + } + /// Read-only iterator for the cells in the row. pub fn cellIterator(self: Row) CellIterator { return .{ .row = self }; @@ -352,6 +369,15 @@ pub fn getRow(self: *Screen, index: RowIndex) Row { return .{ .storage = slices[0] }; } +/// 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); + dst_row.copyRow(src_row); +} + /// 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. @@ -483,6 +509,166 @@ fn scrollDelta(self: *Screen, delta: isize, grow: bool) void { assert(self.viewportIsBottom()); } +/// 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) ![:0]const u8 { + // Get the slices for the string + const slices = self.selectionSlices(sel); + + // We can now know how much space we'll need to store the string. We loop + // over and UTF8-encode and calculate the exact size required. We will be + // off here by at most "newlines" values in the worst case that every + // single line is soft-wrapped. + const chars = chars: { + var count: usize = 0; + const arr = [_][]StorageCell{ slices.top, slices.bot }; + for (arr) |slice| { + for (slice) |cell, i| { + // detect row headers + if (@mod(i, self.cols + 1) == 0) { + // We use each row header as an opportunity to "count" + // a new row, and therefore count a possible newline. + count += 1; + continue; + } + + var buf: [4]u8 = undefined; + const char = if (cell.cell.char > 0) cell.cell.char else ' '; + count += try std.unicode.utf8Encode(@intCast(u21, char), &buf); + } + } + + break :chars count; + }; + const buf = try alloc.alloc(u8, chars + 1); + errdefer alloc.free(buf); + + // Connect the text from the two slices + const arr = [_][]StorageCell{ slices.top, slices.bot }; + var buf_i: usize = 0; + var row_count: usize = 0; + for (arr) |slice| { + var 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; + + // Our end index is usually a full row, but if we're the final + // row then we just use the length. + const end_idx = @minimum(slice.len, start_idx + self.cols + 1); + + // We may have to skip some cells from the beginning if we're + // the first row. + var skip: usize = if (row_count == 0) slices.top_offset else 0; + + const row: Row = .{ .storage = slice[start_idx..end_idx] }; + var it = row.cellIterator(); + while (it.next()) |cell| { + if (skip > 0) { + skip -= 1; + continue; + } + + // Skip spacers + if (cell.attrs.wide_spacer_head or + cell.attrs.wide_spacer_tail) continue; + + const char = if (cell.char > 0) cell.char else ' '; + buf_i += try std.unicode.utf8Encode(@intCast(u21, char), buf[buf_i..]); + } + + // If this row is not soft-wrapped, add a newline + if (!row.header().wrap) { + buf[buf_i] = '\n'; + buf_i += 1; + } + } + } + + // Remove our trailing newline, its never correct. + if (buf[buf_i - 1] == '\n') buf_i -= 1; + + // Add null termination + buf[buf_i] = 0; + + // Realloc so our free length is exactly correct + const result = try alloc.realloc(buf, buf_i + 1); + return result[0..buf_i :0]; +} + +/// 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) struct { + rows: usize, + + // 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, + top: []StorageCell, + bot: []StorageCell, +} { + // Note: this function is tested via selectionString + + assert(sel_raw.start.y < self.rowsWritten()); + assert(sel_raw.end.y < self.rowsWritten()); + assert(sel_raw.start.x < self.cols); + assert(sel_raw.end.x < self.cols); + + const sel = sel: { + var sel = sel_raw; + + // 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.end.x -= 1; + } + } + + break :sel sel; + }; + + // Get the true "top" and "bottom" + const sel_top = sel.topLeft(); + const sel_bot = sel.bottomRight(); + + // 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, + .top_offset = sel_top.x, + .top = slices[0], + .bot = slices[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. @@ -867,3 +1053,146 @@ test "Screen: history region with scrollback" { 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(); + s.testWriteString("1ABCD\n2EFGH\n3IJKL"); + + // Copy + s.scroll(.{ .delta = 1 }); + s.copyRow(.{ .active = 2 }, .{ .active = 0 }); + + // Test our contents + var contents = try s.testString(alloc, .viewport); + defer alloc.free(contents); + try testing.expectEqualStrings("2EFGH\n3IJKL\n2EFGH", contents); +} + +test "Screen: selectionString" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 3, 5, 0); + defer s.deinit(); + const str = "1ABCD\n2EFGH\n3IJKL"; + s.testWriteString(str); + + { + var contents = try s.selectionString(alloc, .{ + .start = .{ .x = 0, .y = 1 }, + .end = .{ .x = 2, .y = 2 }, + }); + defer alloc.free(contents); + const expected = "2EFGH\n3IJ"; + 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"; + s.testWriteString(str); + + { + var contents = try s.selectionString(alloc, .{ + .start = .{ .x = 0, .y = 1 }, + .end = .{ .x = 2, .y = 2 }, + }); + 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(); + 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. + s.scroll(.{ .delta = 1 }); + try testing.expect(s.viewportIsBottom()); + s.testWriteString("1ABCD\n2EFGH\n3IJKL"); + + { + var contents = try s.selectionString(alloc, .{ + .start = .{ .x = 0, .y = 1 }, + .end = .{ .x = 2, .y = 2 }, + }); + 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⚡"; + s.testWriteString(str); + + { + var contents = try s.selectionString(alloc, .{ + .start = .{ .x = 0, .y = 0 }, + .end = .{ .x = 3, .y = 0 }, + }); + defer alloc.free(contents); + const expected = str; + try testing.expectEqualStrings(expected, contents); + } + + { + var contents = try s.selectionString(alloc, .{ + .start = .{ .x = 0, .y = 0 }, + .end = .{ .x = 2, .y = 0 }, + }); + defer alloc.free(contents); + const expected = str; + try testing.expectEqualStrings(expected, contents); + } + + { + var contents = try s.selectionString(alloc, .{ + .start = .{ .x = 3, .y = 0 }, + .end = .{ .x = 3, .y = 0 }, + }); + 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⚡"; + s.testWriteString(str); + + { + var contents = try s.selectionString(alloc, .{ + .start = .{ .x = 0, .y = 0 }, + .end = .{ .x = 4, .y = 0 }, + }); + defer alloc.free(contents); + const expected = str; + try testing.expectEqualStrings(expected, contents); + } +} From e8009f89cf652eebe9d9a109053097e5630f9e74 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 31 Aug 2022 15:13:30 -0700 Subject: [PATCH 06/34] add cursor to new screen --- src/terminal/Screen2.zig | 31 ++++++++++++++++++++----------- 1 file changed, 20 insertions(+), 11 deletions(-) diff --git a/src/terminal/Screen2.zig b/src/terminal/Screen2.zig index f919ac192..0556066b1 100644 --- a/src/terminal/Screen2.zig +++ b/src/terminal/Screen2.zig @@ -27,6 +27,20 @@ const Selection = @import("Selection.zig"); const log = std.log.scoped(.screen); +/// 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, + + // 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, +}; + /// This is a single item within the storage buffer. We use a union to /// have different types of data in a single contiguous buffer. /// @@ -316,6 +330,12 @@ max_scrollback: usize, /// The row (offset from the top) where the viewport currently is. viewport: usize, +/// Each screen maintains its own cursor state. +cursor: Cursor = .{}, + +/// Saved cursor saved with DECSC (ESC 7). +saved_cursor: Cursor = .{}, + /// Initialize a new screen. pub fn init( alloc: Allocator, @@ -465,17 +485,6 @@ fn scrollDelta(self: *Screen, delta: isize, grow: bool) void { return; } - // { - // const rows_capacity = self.rowsCapacity(); - // const rows_written = self.rowsWritten(); - // log.warn("rows_written={} rows_capacity={} vp={} vp_new={}", .{ - // rows_written, - // rows_capacity, - // self.viewport, - // self.viewport + @intCast(usize, delta), - // }); - // } - // 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. From 10ec5f509ead6233795d81c6eecc0e77474322ff Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 31 Aug 2022 15:27:45 -0700 Subject: [PATCH 07/34] circbuf doesn't use zeroes, it takes a default value --- src/terminal/Screen2.zig | 2 +- src/terminal/circ_buf.zig | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/terminal/Screen2.zig b/src/terminal/Screen2.zig index 0556066b1..fa5826ee1 100644 --- a/src/terminal/Screen2.zig +++ b/src/terminal/Screen2.zig @@ -311,7 +311,7 @@ pub const RowIndexTag = enum { } }; -const StorageBuf = CircBuf(StorageCell); +const StorageBuf = CircBuf(StorageCell, .{ .cell = .{} }); /// The allocator used for all the storage operations alloc: Allocator, diff --git a/src/terminal/circ_buf.zig b/src/terminal/circ_buf.zig index 7f2c823f8..fd79d0dc3 100644 --- a/src/terminal/circ_buf.zig +++ b/src/terminal/circ_buf.zig @@ -3,7 +3,7 @@ const assert = std.debug.assert; const Allocator = std.mem.Allocator; /// Returns a circular buffer containing type T. -pub fn CircBuf(comptime T: type) type { +pub fn CircBuf(comptime T: type, comptime default: T) type { return struct { const Self = @This(); @@ -26,7 +26,7 @@ pub fn CircBuf(comptime T: type) type { /// Initialize a new circular buffer that can store size elements. pub fn init(alloc: Allocator, size: usize) !Self { var buf = try alloc.alloc(T, size); - std.mem.set(T, buf, std.mem.zeroes(T)); + std.mem.set(T, buf, default); return Self{ .storage = buf, @@ -141,7 +141,7 @@ test { const testing = std.testing; const alloc = testing.allocator; - const Buf = CircBuf(u8); + const Buf = CircBuf(u8, 0); var buf = try Buf.init(alloc, 12); defer buf.deinit(alloc); @@ -153,7 +153,7 @@ test "getPtrSlice fits" { const testing = std.testing; const alloc = testing.allocator; - const Buf = CircBuf(u8); + const Buf = CircBuf(u8, 0); var buf = try Buf.init(alloc, 12); defer buf.deinit(alloc); @@ -167,7 +167,7 @@ test "getPtrSlice wraps" { const testing = std.testing; const alloc = testing.allocator; - const Buf = CircBuf(u8); + const Buf = CircBuf(u8, 0); var buf = try Buf.init(alloc, 4); defer buf.deinit(alloc); From cb06bf48739d98185b0212b961ca8a7c419e769f Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 31 Aug 2022 16:01:16 -0700 Subject: [PATCH 08/34] use non-extern unions so we get safety checks --- src/terminal/Screen2.zig | 53 +++++++++++++++++++++++++++++++--------- 1 file changed, 41 insertions(+), 12 deletions(-) diff --git a/src/terminal/Screen2.zig b/src/terminal/Screen2.zig index fa5826ee1..609e261ec 100644 --- a/src/terminal/Screen2.zig +++ b/src/terminal/Screen2.zig @@ -17,6 +17,7 @@ const Screen = @This(); const std = @import("std"); +const builtin = @import("builtin"); const assert = std.debug.assert; const Allocator = std.mem.Allocator; @@ -43,10 +44,7 @@ pub const Cursor = struct { /// This is a single item within the storage buffer. We use a union to /// have different types of data in a single contiguous buffer. -/// -/// Note: the union is extern so that it follows the same memory layout -/// semantics as C, which allows us to have a tightly packed union. -const StorageCell = extern union { +const StorageCell = union { header: RowHeader, cell: Cell, @@ -59,22 +57,36 @@ const StorageCell = extern union { // @sizeOf(StorageCell), // @alignOf(StorageCell), // }); + } - // 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. - try std.testing.expectEqual(@sizeOf(Cell), @sizeOf(StorageCell)); + comptime { + // We only check this during ReleaseFast because safety checks + // have to be disabled to get this size. + if (builtin.mode == .ReleaseFast) { + // 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 { - dirty: bool, + /// Used internally to track if this row has been initialized. + init: bool = false, + + /// True if one of the cells in this row has been changed + dirty: bool = false, /// If true, this row is soft-wrapped. The first cell of the next /// row is a continuous of this row. - wrap: bool, + wrap: bool = false, }; /// Cell is a single cell within the screen. @@ -163,6 +175,7 @@ pub const Row = struct { /// Get a single immutable cell. pub fn getCell(self: Row, x: usize) Cell { + assert(self.header().init); assert(x < self.storage.len - 1); return self.storage[x + 1].cell; } @@ -172,19 +185,31 @@ pub const Row = struct { /// 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(self.header().init); assert(x < self.storage.len - 1); return &self.storage[x + 1].cell; } /// Copy the row src into this row. pub fn copyRow(self: Row, src: Row) void { + assert(self.header().init); std.mem.copy(StorageCell, self.storage[1..], src.storage[1..]); } /// Read-only iterator for the cells in the row. pub fn cellIterator(self: Row) CellIterator { + assert(self.header().init); return .{ .row = self }; } + + /// If this row isn't initialized, this sets all our cells to the + /// proper union tag so that it is properly zeroed. + fn initIfNeeded(self: Row) void { + if (!self.storage[0].header.init) { + self.fill(.{}); + self.storage[0].header.init = true; + } + } }; /// Used to iterate through the rows of a specific region. @@ -311,7 +336,9 @@ pub const RowIndexTag = enum { } }; -const StorageBuf = CircBuf(StorageCell, .{ .cell = .{} }); +// 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 = .{} }); /// The allocator used for all the storage operations alloc: Allocator, @@ -386,7 +413,9 @@ pub fn getRow(self: *Screen, index: RowIndex) Row { const slices = self.storage.getPtrSlice(offset, self.cols + 1); assert(slices[0].len == self.cols + 1 and slices[1].len == 0); - return .{ .storage = slices[0] }; + const row: Row = .{ .storage = slices[0] }; + row.initIfNeeded(); + return row; } /// Copy the row at src to dst. From b7b83db1191453e6b21af63d0911a658898e1c69 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 31 Aug 2022 17:08:02 -0700 Subject: [PATCH 09/34] fix some circbuf bugs (add tests) and add rotation --- src/terminal/circ_buf.zig | 145 ++++++++++++++++++++++++++++++++++++-- 1 file changed, 141 insertions(+), 4 deletions(-) diff --git a/src/terminal/circ_buf.zig b/src/terminal/circ_buf.zig index fd79d0dc3..58645e911 100644 --- a/src/terminal/circ_buf.zig +++ b/src/terminal/circ_buf.zig @@ -41,6 +41,29 @@ pub fn CircBuf(comptime T: type, comptime default: T) type { self.* = undefined; } + /// Rotate the data so that it is zero-aligned. + fn rotateToZero(self: *Self, alloc: Allocator) !void { + // TODO: this does this in the worst possible way by allocating. + // rewrite to not allocate, its possible, I'm just lazy right now. + + var buf = try alloc.alloc(T, self.storage.len); + defer { + self.head = if (self.full) 0 else self.len(); + self.tail = 0; + alloc.free(self.storage); + self.storage = buf; + } + + if (!self.full and self.head >= self.tail) { + std.mem.copy(T, buf, self.storage[self.tail..self.head]); + return; + } + + const middle = self.storage.len - self.tail; + std.mem.copy(T, buf, self.storage[self.tail..]); + std.mem.copy(T, buf[middle..], self.storage[0..self.head]); + } + /// Returns if the buffer is currently empty. To check if its /// full, just check the "full" attribute. pub fn empty(self: Self) bool { @@ -112,10 +135,10 @@ pub fn CircBuf(comptime T: type, comptime default: T) type { self.head += amount; // If we exceeded the length of the buffer, wrap around. - if (self.head >= self.storage.len) { - self.head = self.head - self.storage.len; - self.tail = self.head; - } + if (self.head >= self.storage.len) self.head = self.head - self.storage.len; + + // If we're full, we have to keep tail lined up. + if (self.full) self.tail = self.head; // We're full if the head reached the tail. The head can never // pass the tail because advance asserts amount is only in @@ -218,3 +241,117 @@ test "getPtrSlice wraps" { try testing.expectEqual(@as(u8, 4), slices[1][1]); } } + +test "rotateToZero" { + const testing = std.testing; + const alloc = testing.allocator; + + const Buf = CircBuf(u8, 0); + var buf = try Buf.init(alloc, 12); + defer buf.deinit(alloc); + + _ = buf.getPtrSlice(0, 11); + try buf.rotateToZero(alloc); +} + +test "rotateToZero offset" { + const testing = std.testing; + const alloc = testing.allocator; + + const Buf = CircBuf(u8, 0); + var buf = try Buf.init(alloc, 4); + defer buf.deinit(alloc); + + // Fill the buffer + _ = buf.getPtrSlice(0, 3); + try testing.expectEqual(@as(usize, 3), buf.len()); + + // Delete + buf.deleteOldest(2); + try testing.expect(!buf.full); + try testing.expectEqual(@as(usize, 1), buf.len()); + try testing.expect(buf.tail > 0 and buf.head >= buf.tail); + + // Rotate to zero + try buf.rotateToZero(alloc); + try testing.expectEqual(@as(usize, 0), buf.tail); + try testing.expectEqual(@as(usize, 1), buf.head); +} + +test "rotateToZero wraps" { + const testing = std.testing; + const alloc = testing.allocator; + + const Buf = CircBuf(u8, 0); + var buf = try Buf.init(alloc, 4); + defer buf.deinit(alloc); + + // Fill the buffer + _ = buf.getPtrSlice(0, 3); + try testing.expectEqual(@as(usize, 3), buf.len()); + try testing.expect(buf.tail == 0 and buf.head == 3); + + // Delete all + buf.deleteOldest(3); + try testing.expectEqual(@as(usize, 0), buf.len()); + try testing.expect(buf.tail == 3 and buf.head == 3); + + // Refill to force a wrap + { + const slices = buf.getPtrSlice(0, 3); + slices[0][0] = 1; + slices[1][0] = 2; + slices[1][1] = 3; + try testing.expectEqual(@as(usize, 3), buf.len()); + try testing.expect(buf.tail == 3 and buf.head == 2); + } + + // Rotate to zero + try buf.rotateToZero(alloc); + try testing.expectEqual(@as(usize, 0), buf.tail); + try testing.expectEqual(@as(usize, 3), buf.head); + { + const slices = buf.getPtrSlice(0, 3); + try testing.expectEqual(@as(u8, 1), slices[0][0]); + try testing.expectEqual(@as(u8, 2), slices[0][1]); + try testing.expectEqual(@as(u8, 3), slices[0][2]); + } +} + +test "rotateToZero full no wrap" { + const testing = std.testing; + const alloc = testing.allocator; + + const Buf = CircBuf(u8, 0); + var buf = try Buf.init(alloc, 4); + defer buf.deinit(alloc); + + // Fill the buffer + _ = buf.getPtrSlice(0, 3); + + // Delete all + buf.deleteOldest(3); + + // Refill to force a wrap + { + const slices = buf.getPtrSlice(0, 4); + try testing.expect(buf.full); + slices[0][0] = 1; + slices[1][0] = 2; + slices[1][1] = 3; + slices[1][2] = 4; + } + + // Rotate to zero + try buf.rotateToZero(alloc); + try testing.expect(buf.full); + try testing.expectEqual(@as(usize, 0), buf.tail); + try testing.expectEqual(@as(usize, 0), buf.head); + { + const slices = buf.getPtrSlice(0, 4); + try testing.expectEqual(@as(u8, 1), slices[0][0]); + try testing.expectEqual(@as(u8, 2), slices[0][1]); + try testing.expectEqual(@as(u8, 3), slices[0][2]); + try testing.expectEqual(@as(u8, 4), slices[0][3]); + } +} From 50612002a84f94222d2a3c0363e287dab3b12f3e Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 31 Aug 2022 17:17:43 -0700 Subject: [PATCH 10/34] circbuf resize operation --- src/terminal/circ_buf.zig | 89 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 89 insertions(+) diff --git a/src/terminal/circ_buf.zig b/src/terminal/circ_buf.zig index 58645e911..af26c1a4b 100644 --- a/src/terminal/circ_buf.zig +++ b/src/terminal/circ_buf.zig @@ -41,6 +41,30 @@ pub fn CircBuf(comptime T: type, comptime default: T) type { self.* = undefined; } + /// Resize the buffer to the given size (larger or smaller). + /// If larger, new values will be set to the default value. + pub fn resize(self: *Self, alloc: Allocator, size: usize) !void { + // Rotate to zero so it is aligned. + try self.rotateToZero(alloc); + + // Reallocate, this adds to the end so we're ready to go. + const prev_len = self.len(); + const prev_cap = self.storage.len; + self.storage = try alloc.realloc(self.storage, size); + + // If we grew, we need to set our new defaults. We can add it + // at the end since we rotated to start. + if (size > prev_cap) { + std.mem.set(T, self.storage[prev_cap..], default); + + // Fix up our head/tail + if (self.full) { + self.head = prev_len; + self.full = false; + } + } + } + /// Rotate the data so that it is zero-aligned. fn rotateToZero(self: *Self, alloc: Allocator) !void { // TODO: this does this in the worst possible way by allocating. @@ -355,3 +379,68 @@ test "rotateToZero full no wrap" { try testing.expectEqual(@as(u8, 4), slices[0][3]); } } + +test "resize grow" { + const testing = std.testing; + const alloc = testing.allocator; + + const Buf = CircBuf(u8, 0); + var buf = try Buf.init(alloc, 4); + defer buf.deinit(alloc); + + // Fill and write + { + const slices = buf.getPtrSlice(0, 4); + try testing.expect(buf.full); + slices[0][0] = 1; + slices[0][1] = 2; + slices[0][2] = 3; + slices[0][3] = 4; + } + + // Resize + try buf.resize(alloc, 6); + try testing.expect(!buf.full); + try testing.expectEqual(@as(usize, 4), buf.len()); + try testing.expectEqual(@as(usize, 6), buf.capacity()); + + { + const slices = buf.getPtrSlice(0, 4); + try testing.expectEqual(@as(u8, 1), slices[0][0]); + try testing.expectEqual(@as(u8, 2), slices[0][1]); + try testing.expectEqual(@as(u8, 3), slices[0][2]); + try testing.expectEqual(@as(u8, 4), slices[0][3]); + } +} + +test "resize shrink" { + const testing = std.testing; + const alloc = testing.allocator; + + const Buf = CircBuf(u8, 0); + var buf = try Buf.init(alloc, 4); + defer buf.deinit(alloc); + + // Fill and write + { + const slices = buf.getPtrSlice(0, 4); + try testing.expect(buf.full); + slices[0][0] = 1; + slices[0][1] = 2; + slices[0][2] = 3; + slices[0][3] = 4; + } + + // Resize + try buf.resize(alloc, 3); + try testing.expect(buf.full); + try testing.expectEqual(@as(usize, 3), buf.len()); + try testing.expectEqual(@as(usize, 3), buf.capacity()); + + { + const slices = buf.getPtrSlice(0, 3); + try testing.expectEqual(@as(u8, 1), slices[0][0]); + try testing.expectEqual(@as(u8, 2), slices[0][1]); + try testing.expectEqual(@as(u8, 3), slices[0][2]); + } +} From a192249c77f09b601e71fe1c746bf9ea70f7c534 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 31 Aug 2022 19:33:28 -0700 Subject: [PATCH 11/34] circbuf rotateToZero does nothing if its already at zero --- src/terminal/circ_buf.zig | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/terminal/circ_buf.zig b/src/terminal/circ_buf.zig index af26c1a4b..12259ee7e 100644 --- a/src/terminal/circ_buf.zig +++ b/src/terminal/circ_buf.zig @@ -70,6 +70,9 @@ pub fn CircBuf(comptime T: type, comptime default: T) type { // TODO: this does this in the worst possible way by allocating. // rewrite to not allocate, its possible, I'm just lazy right now. + // If we're already at zero then do nothing. + if (self.tail == 0) return; + var buf = try alloc.alloc(T, self.storage.len); defer { self.head = if (self.full) 0 else self.len(); From cf969b27eba7430f20add1f318c31802a86f603c Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 31 Aug 2022 19:54:10 -0700 Subject: [PATCH 12/34] screen2: dynamically allow scrollback when its needed --- src/terminal/Screen2.zig | 111 ++++++++++++++++++++++++--------------- 1 file changed, 69 insertions(+), 42 deletions(-) diff --git a/src/terminal/Screen2.zig b/src/terminal/Screen2.zig index 609e261ec..10f0eb36f 100644 --- a/src/terminal/Screen2.zig +++ b/src/terminal/Screen2.zig @@ -373,8 +373,7 @@ pub fn init( // * 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 + max_scrollback) * (cols + 1); - //const buf_size = (rows + @minimum(max_scrollback, rows)) * (cols + 1); + const buf_size = (rows + @minimum(max_scrollback, rows)) * (cols + 1); return Screen{ .alloc = alloc, @@ -453,6 +452,12 @@ fn rowsCapacity(self: Screen) usize { 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); +} + /// Scroll behaviors for the scroll function. pub const Scroll = union(enum) { /// Scroll to the top of the scroll buffer. The first line of the @@ -479,7 +484,7 @@ pub const Scroll = union(enum) { /// "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) void { +pub fn scroll(self: *Screen, behavior: Scroll) !void { switch (behavior) { // Setting viewport offset to zero makes row 0 be at self.top // which is the top! @@ -490,12 +495,12 @@ pub fn scroll(self: *Screen, behavior: Scroll) void { .bottom => self.viewport = RowIndexTag.history.maxLen(self), // TODO: deltas greater than the entire scrollback - .delta => |delta| self.scrollDelta(delta, true), - .delta_no_grow => |delta| self.scrollDelta(delta, false), + .delta => |delta| try self.scrollDelta(delta, true), + .delta_no_grow => |delta| try self.scrollDelta(delta, false), } } -fn scrollDelta(self: *Screen, delta: isize, grow: bool) void { +fn scrollDelta(self: *Screen, delta: isize, grow: bool) !void { // 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) { @@ -524,10 +529,32 @@ fn scrollDelta(self: *Screen, delta: isize, grow: bool) void { // in our buffer is our value minus the max. const new_rows_needed = self.viewport - viewport_max; - // If we can fit this into our existing capacity, then just grow to it. - const rows_capacity = self.rowsCapacity(); + // If we can't fit into our capacity but we have space, resize the + // buffer to allocate more scrollback. const rows_written = self.rowsWritten(); - if (rows_written + new_rows_needed <= rows_capacity) { + 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 = @maximum( + rows_final * (self.cols + 1), + self.rows * 2, + ); + + // Allocate what we can. + try self.storage.resize( + self.alloc, + @minimum(max_capacity, needed_capacity), + ); + } + } + + // If we can fit into our capacity, then just grow to it. + if (rows_final <= self.rowsCapacity()) { // Ensure we have "written" this data into the circular buffer. _ = self.storage.getPtrSlice( self.viewport * (self.cols + 1), @@ -539,7 +566,7 @@ fn scrollDelta(self: *Screen, delta: isize, grow: bool) void { // We can't fit our new rows into the capacity, so the amount // between what we need and the capacity needs to be deleted. We // scroll "up" by that much to offset this. - const rows_to_delete = (rows_written + new_rows_needed) - rows_capacity; + const rows_to_delete = rows_final - self.rowsCapacity(); self.viewport -= rows_to_delete; self.storage.deleteOldest(rows_to_delete * (self.cols + 1)); @@ -710,7 +737,7 @@ fn selectionSlices(self: *Screen, sel_raw: Selection) struct { /// 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 { +pub fn testWriteString(self: *Screen, text: []const u8) !void { var y: usize = 0; var x: usize = 0; @@ -727,7 +754,7 @@ pub fn testWriteString(self: *Screen, text: []const u8) void { // If we're writing past the end of the active area, scroll. if (y >= self.rows) { y -= 1; - self.scroll(.{ .delta = 1 }); + try self.scroll(.{ .delta = 1 }); } // Get our row @@ -740,7 +767,7 @@ pub fn testWriteString(self: *Screen, text: []const u8) void { x = 0; if (y >= self.rows) { y -= 1; - self.scroll(.{ .delta = 1 }); + try self.scroll(.{ .delta = 1 }); } row = self.getRow(.{ .active = y }); } @@ -766,7 +793,7 @@ pub fn testWriteString(self: *Screen, text: []const u8) void { x = 0; if (y >= self.rows) { y -= 1; - self.scroll(.{ .delta = 1 }); + try self.scroll(.{ .delta = 1 }); } row = self.getRow(.{ .active = y }); } @@ -835,7 +862,7 @@ test "Screen" { // Sanity check that our test helpers work const str = "1ABCD\n2EFGH\n3IJKL"; - s.testWriteString(str); + try s.testWriteString(str); try testing.expect(s.rowsWritten() == 3); { var contents = try s.testString(alloc, .screen); @@ -872,11 +899,11 @@ test "Screen: scrolling" { var s = try init(alloc, 3, 5, 0); defer s.deinit(); - s.testWriteString("1ABCD\n2EFGH\n3IJKL"); + try s.testWriteString("1ABCD\n2EFGH\n3IJKL"); try testing.expect(s.viewportIsBottom()); // Scroll down, should still be bottom - s.scroll(.{ .delta = 1 }); + try s.scroll(.{ .delta = 1 }); try testing.expect(s.viewportIsBottom()); // Test our row index @@ -892,7 +919,7 @@ test "Screen: scrolling" { } // Scrolling to the bottom does nothing - s.scroll(.{ .bottom = {} }); + try s.scroll(.{ .bottom = {} }); { // Test our contents rotated @@ -908,10 +935,10 @@ test "Screen: scroll down from 0" { var s = try init(alloc, 3, 5, 0); defer s.deinit(); - s.testWriteString("1ABCD\n2EFGH\n3IJKL"); + try s.testWriteString("1ABCD\n2EFGH\n3IJKL"); // Scrolling up does nothing, but allows it - s.scroll(.{ .delta = -1 }); + try s.scroll(.{ .delta = -1 }); try testing.expect(s.viewportIsBottom()); { @@ -928,8 +955,8 @@ test "Screen: scrollback" { var s = try init(alloc, 3, 5, 1); defer s.deinit(); - s.testWriteString("1ABCD\n2EFGH\n3IJKL"); - s.scroll(.{ .delta = 1 }); + try s.testWriteString("1ABCD\n2EFGH\n3IJKL"); + try s.scroll(.{ .delta = 1 }); { // Test our contents rotated @@ -939,7 +966,7 @@ test "Screen: scrollback" { } // Scrolling to the bottom - s.scroll(.{ .bottom = {} }); + try s.scroll(.{ .bottom = {} }); try testing.expect(s.viewportIsBottom()); { @@ -950,7 +977,7 @@ test "Screen: scrollback" { } // Scrolling back should make it visible again - s.scroll(.{ .delta = -1 }); + try s.scroll(.{ .delta = -1 }); try testing.expect(!s.viewportIsBottom()); { @@ -961,7 +988,7 @@ test "Screen: scrollback" { } // Scrolling back again should do nothing - s.scroll(.{ .delta = -1 }); + try s.scroll(.{ .delta = -1 }); { // Test our contents rotated @@ -971,7 +998,7 @@ test "Screen: scrollback" { } // Scrolling to the bottom - s.scroll(.{ .bottom = {} }); + try s.scroll(.{ .bottom = {} }); { // Test our contents rotated @@ -981,7 +1008,7 @@ test "Screen: scrollback" { } // Scrolling forward with no grow should do nothing - s.scroll(.{ .delta_no_grow = 1 }); + try s.scroll(.{ .delta_no_grow = 1 }); { // Test our contents rotated @@ -991,7 +1018,7 @@ test "Screen: scrollback" { } // Scrolling to the top should work - s.scroll(.{ .top = {} }); + try s.scroll(.{ .top = {} }); { // Test our contents rotated @@ -1010,7 +1037,7 @@ test "Screen: scrollback" { } // Scrolling to the bottom - s.scroll(.{ .bottom = {} }); + try s.scroll(.{ .bottom = {} }); { // Test our contents rotated @@ -1026,8 +1053,8 @@ test "Screen: scrollback empty" { var s = try init(alloc, 3, 5, 50); defer s.deinit(); - s.testWriteString("1ABCD\n2EFGH\n3IJKL"); - s.scroll(.{ .delta_no_grow = 1 }); + try s.testWriteString("1ABCD\n2EFGH\n3IJKL"); + try s.scroll(.{ .delta_no_grow = 1 }); { // Test our contents @@ -1046,7 +1073,7 @@ test "Screen: history region with no scrollback" { // Write a bunch that WOULD invoke scrollback if exists const str = "1ABCD\n2EFGH\n3IJKL"; - s.testWriteString(str); + try s.testWriteString(str); { var contents = try s.testString(alloc, .screen); defer alloc.free(contents); @@ -1070,7 +1097,7 @@ test "Screen: history region with scrollback" { // Write a bunch that WOULD invoke scrollback if exists const str = "1ABCD\n2EFGH\n3IJKL"; - s.testWriteString(str); + try s.testWriteString(str); { var contents = try s.testString(alloc, .viewport); defer alloc.free(contents); @@ -1097,10 +1124,10 @@ test "Screen: row copy" { var s = try init(alloc, 3, 5, 0); defer s.deinit(); - s.testWriteString("1ABCD\n2EFGH\n3IJKL"); + try s.testWriteString("1ABCD\n2EFGH\n3IJKL"); // Copy - s.scroll(.{ .delta = 1 }); + try s.scroll(.{ .delta = 1 }); s.copyRow(.{ .active = 2 }, .{ .active = 0 }); // Test our contents @@ -1116,7 +1143,7 @@ test "Screen: selectionString" { var s = try init(alloc, 3, 5, 0); defer s.deinit(); const str = "1ABCD\n2EFGH\n3IJKL"; - s.testWriteString(str); + try s.testWriteString(str); { var contents = try s.selectionString(alloc, .{ @@ -1136,7 +1163,7 @@ test "Screen: selectionString soft wrap" { var s = try init(alloc, 3, 5, 0); defer s.deinit(); const str = "1ABCD2EFGH3IJKL"; - s.testWriteString(str); + try s.testWriteString(str); { var contents = try s.selectionString(alloc, .{ @@ -1155,14 +1182,14 @@ test "Screen: selectionString wrap around" { var s = try init(alloc, 3, 5, 0); defer s.deinit(); - s.testWriteString("1ABCD\n2EFGH\n3IJKL"); + 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. - s.scroll(.{ .delta = 1 }); + try s.scroll(.{ .delta = 1 }); try testing.expect(s.viewportIsBottom()); - s.testWriteString("1ABCD\n2EFGH\n3IJKL"); + try s.testWriteString("1ABCD\n2EFGH\n3IJKL"); { var contents = try s.selectionString(alloc, .{ @@ -1182,7 +1209,7 @@ test "Screen: selectionString wide char" { var s = try init(alloc, 3, 5, 0); defer s.deinit(); const str = "1A⚡"; - s.testWriteString(str); + try s.testWriteString(str); { var contents = try s.selectionString(alloc, .{ @@ -1222,7 +1249,7 @@ test "Screen: selectionString wide char with header" { var s = try init(alloc, 3, 5, 0); defer s.deinit(); const str = "1ABC⚡"; - s.testWriteString(str); + try s.testWriteString(str); { var contents = try s.selectionString(alloc, .{ From 4056f2abf94ddd4933f14f1da8f4133c0d5e18dd Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 31 Aug 2022 20:48:48 -0700 Subject: [PATCH 13/34] new screen supports resize without reflow --- src/terminal/Screen2.zig | 121 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 119 insertions(+), 2 deletions(-) diff --git a/src/terminal/Screen2.zig b/src/terminal/Screen2.zig index 10f0eb36f..094d21575 100644 --- a/src/terminal/Screen2.zig +++ b/src/terminal/Screen2.zig @@ -190,10 +190,11 @@ pub const Row = struct { return &self.storage[x + 1].cell; } - /// Copy the row src into this row. + /// Copy the row src into this row. The row can be from another screen. pub fn copyRow(self: Row, src: Row) void { assert(self.header().init); - std.mem.copy(StorageCell, self.storage[1..], src.storage[1..]); + const end = @minimum(src.storage.len, self.storage.len); + std.mem.copy(StorageCell, self.storage[1..], src.storage[1..end]); } /// Read-only iterator for the cells in the row. @@ -734,6 +735,53 @@ fn selectionSlices(self: *Screen, sel_raw: Selection) struct { }; } +/// 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 { + // 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; + + // 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 = old.rowsWritten() * (cols + 1); + const new_max_capacity = self.maxCapacity(); + const buf_size = @minimum(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); + + // Move our cursor if we have to so it stays on the screen. + self.cursor.x = @minimum(self.cursor.x, self.cols - 1); + self.cursor.y = @minimum(self.cursor.y, self.rows - 1); + + // Rewrite all our rows + var y: usize = 0; + var row_it = old.rowIterator(.screen); + while (row_it.next()) |old_row| { + // If we're past the end, scroll + if (y >= self.rows) { + y -= 1; + try self.scroll(.{ .delta = 1 }); + } + + // Get this row + const new_row = self.getRow(.{ .active = y }); + new_row.copyRow(old_row); + + // Next row + y += 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. @@ -1261,3 +1309,72 @@ test "Screen: selectionString wide char with header" { try testing.expectEqualStrings(expected, contents); } } + +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); + try s.resizeWithoutReflow(10, 5); + + { + var contents = try s.testString(alloc, .viewport); + defer alloc.free(contents); + try testing.expectEqualStrings(str, contents); + } +} + +test "Screen: resize (no reflow) less rows" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 3, 5, 0); + defer s.deinit(); + const str = "1ABCD\n2EFGH\n3IJKL"; + try s.testWriteString(str); + try s.resizeWithoutReflow(2, 5); + + { + var contents = try s.testString(alloc, .viewport); + defer alloc.free(contents); + try testing.expectEqualStrings("2EFGH\n3IJKL", 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); + + { + var 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); + + { + var contents = try s.testString(alloc, .viewport); + defer alloc.free(contents); + const expected = "1ABC\n2EFG\n3IJK"; + try testing.expectEqualStrings(expected, contents); + } +} From 89e931e468e9ef94e8d1017e4980fc43947bc798 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 31 Aug 2022 21:29:28 -0700 Subject: [PATCH 14/34] our new resize without reflow supports scrollback so test that --- src/terminal/Screen2.zig | 75 +++++++++++++++++++++++++++------------ src/terminal/circ_buf.zig | 4 +++ 2 files changed, 57 insertions(+), 22 deletions(-) diff --git a/src/terminal/Screen2.zig b/src/terminal/Screen2.zig index 094d21575..65c204e18 100644 --- a/src/terminal/Screen2.zig +++ b/src/terminal/Screen2.zig @@ -554,25 +554,23 @@ fn scrollDelta(self: *Screen, delta: isize, grow: bool) !void { } } - // If we can fit into our capacity, then just grow to it. - if (rows_final <= self.rowsCapacity()) { - // Ensure we have "written" this data into the circular buffer. - _ = self.storage.getPtrSlice( - self.viewport * (self.cols + 1), - self.cols + 1, - ); - return; - } + // 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(); + self.viewport -= rows_to_delete; + self.storage.deleteOldest(rows_to_delete * (self.cols + 1)); - // We can't fit our new rows into the capacity, so the amount - // between what we need and the capacity needs to be deleted. We - // scroll "up" by that much to offset this. - const rows_to_delete = rows_final - self.rowsCapacity(); - self.viewport -= rows_to_delete; - self.storage.deleteOldest(rows_to_delete * (self.cols + 1)); + // If we grew down like this, we must be at the bottom. + assert(self.viewportIsBottom()); - // If we grew down like this, we must be at the bottom. - assert(self.viewportIsBottom()); + break :deleted rows_to_delete; + } else 0; + + // Ensure we have "written" our last row so that it shows up + _ = self.storage.getPtrSlice( + (rows_final - rows_deleted - 1) * (self.cols + 1), + self.cols + 1, + ); } /// Returns the raw text associated with a selection. This will unwrap @@ -763,6 +761,9 @@ pub fn resizeWithoutReflow(self: *Screen, rows: usize, cols: usize) !void { self.cursor.x = @minimum(self.cursor.x, self.cols - 1); self.cursor.y = @minimum(self.cursor.y, self.rows - 1); + // Our viewport resets to the top because we're going to rewrite the screen + self.viewport = 0; + // Rewrite all our rows var y: usize = 0; var row_it = old.rowIterator(.screen); @@ -954,11 +955,6 @@ test "Screen: scrolling" { try s.scroll(.{ .delta = 1 }); try testing.expect(s.viewportIsBottom()); - // Test our row index - try testing.expectEqual(@as(usize, 0), s.rowOffset(.{ .active = 0 })); - try testing.expectEqual(@as(usize, 6), s.rowOffset(.{ .active = 1 })); - try testing.expectEqual(@as(usize, 12), s.rowOffset(.{ .active = 2 })); - { // Test our contents rotated var contents = try s.testString(alloc, .viewport); @@ -1378,3 +1374,38 @@ test "Screen: resize (no reflow) less cols" { try testing.expectEqualStrings(expected, contents); } } + +test "Screen: resize (no reflow) more 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(10, 5); + + { + var 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); + + { + var contents = try s.testString(alloc, .screen); + defer alloc.free(contents); + const expected = "2EFGH\n3IJKL\n4ABCD\n5EFGH"; + try testing.expectEqualStrings(expected, contents); + } +} diff --git a/src/terminal/circ_buf.zig b/src/terminal/circ_buf.zig index 12259ee7e..59928df1b 100644 --- a/src/terminal/circ_buf.zig +++ b/src/terminal/circ_buf.zig @@ -112,6 +112,10 @@ pub fn CircBuf(comptime T: type, comptime default: T) type { /// Delete the oldest n values from the buffer. If there are less /// than n values in the buffer, it'll delete everything. pub fn deleteOldest(self: *Self, n: usize) void { + // Clear the values back to default + const slices = self.getPtrSlice(0, n); + for (slices) |slice| std.mem.set(T, slice, default); + // If we're not full, we can just advance the tail. We know // it'll be less than the length because otherwise we'd be full. self.tail += @minimum(self.len(), n); From 2f2b12a32f1af032c80cd4a2017f22cb6d2d4e5c Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 31 Aug 2022 21:51:01 -0700 Subject: [PATCH 15/34] resize without reflow preserves cursor better --- src/terminal/Screen2.zig | 160 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 156 insertions(+), 4 deletions(-) diff --git a/src/terminal/Screen2.zig b/src/terminal/Screen2.zig index 65c204e18..f0fc8803c 100644 --- a/src/terminal/Screen2.zig +++ b/src/terminal/Screen2.zig @@ -23,6 +23,7 @@ const Allocator = std.mem.Allocator; const utf8proc = @import("utf8proc"); const color = @import("color.zig"); +const point = @import("point.zig"); const CircBuf = @import("circ_buf.zig").CircBuf; const Selection = @import("Selection.zig"); @@ -395,6 +396,13 @@ pub fn viewportIsBottom(self: Screen) bool { return self.viewport >= RowIndexTag.history.maxLen(&self); } +/// 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); +} + /// 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. @@ -757,10 +765,6 @@ pub fn resizeWithoutReflow(self: *Screen, rows: usize, cols: usize) !void { errdefer self.storage.deinit(self.alloc); defer old.storage.deinit(self.alloc); - // Move our cursor if we have to so it stays on the screen. - self.cursor.x = @minimum(self.cursor.x, self.cols - 1); - self.cursor.y = @minimum(self.cursor.y, self.rows - 1); - // Our viewport resets to the top because we're going to rewrite the screen self.viewport = 0; @@ -781,6 +785,43 @@ pub fn resizeWithoutReflow(self: *Screen, rows: usize, cols: usize) !void { // 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 accomodate 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 = @minimum(old.cursor.x, self.cols - 1); + self.cursor.y = if (old_cursor_y_screen < RowIndexTag.screen.maxLen(self)) + old_cursor_y_screen - RowIndexTag.history.maxLen(self) + else + self.rows - 1; +} + +/// 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; + + // 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; + } + + // TODO + try self.resizeWithoutReflow(rows, cols); } /// Writes a basic string into the screen for testing. Newlines (\n) separate @@ -1409,3 +1450,114 @@ test "Screen: resize (no reflow) less rows with scrollback" { try testing.expectEqualStrings(expected, contents); } } + +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); + + { + var contents = try s.testString(alloc, .viewport); + defer alloc.free(contents); + try testing.expectEqualStrings(str, contents); + } + { + var 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); + + { + var contents = try s.testString(alloc, .viewport); + defer alloc.free(contents); + try testing.expectEqualStrings(str, contents); + } + { + var 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); + { + var 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); + + { + var contents = try s.testString(alloc, .viewport); + 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); + + { + var contents = try s.testString(alloc, .viewport); + defer alloc.free(contents); + try testing.expectEqualStrings(str, contents); + } + { + var contents = try s.testString(alloc, .screen); + defer alloc.free(contents); + try testing.expectEqualStrings(str, contents); + } +} From 77c8ec0a202367e020b0d963dd1278693caf3b7b Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 1 Sep 2022 00:58:47 -0700 Subject: [PATCH 16/34] the big screen switchover --- src/Grid.zig | 25 +- src/Window.zig | 18 +- src/font/Shaper.zig | 18 +- src/terminal/Screen.zig | 2349 +++++++++++++----------------------- src/terminal/Screen2.zig | 1563 ------------------------ src/terminal/ScreenOld.zig | 2248 ++++++++++++++++++++++++++++++++++ src/terminal/Terminal.zig | 261 ++-- src/terminal/main.zig | 3 - src/terminal/point.zig | 16 +- 9 files changed, 3275 insertions(+), 3226 deletions(-) delete mode 100644 src/terminal/Screen2.zig create mode 100644 src/terminal/ScreenOld.zig diff --git a/src/Grid.zig b/src/Grid.zig index 4dc8b1007..36fc1345e 100644 --- a/src/Grid.zig +++ b/src/Grid.zig @@ -322,7 +322,7 @@ pub fn deinit(self: *Grid) void { /// /// Note this doesn't have to typically be manually called. Internally, /// the renderer will do this when it needs more memory space. -pub fn rebuildCells(self: *Grid, term: Terminal) !void { +pub fn rebuildCells(self: *Grid, term: *Terminal) !void { const t = trace(@src()); defer t.end(); @@ -344,9 +344,13 @@ pub fn rebuildCells(self: *Grid, term: Terminal) !void { // Build each cell var rowIter = term.screen.rowIterator(.viewport); var y: usize = 0; - while (rowIter.next()) |line| { + while (rowIter.next()) |row| { defer y += 1; - for (line) |cell, x| { + + var cellIter = row.cellIterator(); + var x: usize = 0; + while (cellIter.next()) |cell| { + defer x += 1; assert(try self.updateCell(term, cell, x, y)); } } @@ -358,7 +362,7 @@ pub fn rebuildCells(self: *Grid, term: Terminal) !void { /// This should be called prior to render to finalize the cells and prepare /// for render. This performs tasks such as preparing the cursor, refreshing /// the cells if necessary, etc. -pub fn finalizeCells(self: *Grid, term: Terminal) !void { +pub fn finalizeCells(self: *Grid, term: *Terminal) !void { // Add the cursor // TODO: only add cursor if it changed if (self.cells.items.len < self.cells.capacity) @@ -375,10 +379,11 @@ pub fn finalizeCells(self: *Grid, term: Terminal) !void { try self.flushAtlas(); } -fn addCursor(self: *Grid, term: Terminal) void { +fn addCursor(self: *Grid, term: *Terminal) void { // Add the cursor if (self.cursor_visible and term.screen.viewportIsBottom()) { const cell = term.screen.getCell( + .active, term.screen.cursor.y, term.screen.cursor.x, ); @@ -410,7 +415,7 @@ fn addCursor(self: *Grid, term: Terminal) void { /// needed. pub fn updateCell( self: *Grid, - term: Terminal, + term: *Terminal, cell: terminal.Screen.Cell, x: usize, y: usize, @@ -454,14 +459,14 @@ pub fn updateCell( const res: BgFg = if (!cell.attrs.inverse) .{ // In normal mode, background and fg match the cell. We // un-optionalize the fg by defaulting to our fg color. - .bg = cell.bg, - .fg = cell.fg orelse self.foreground, + .bg = if (cell.attrs.has_bg) cell.bg else null, + .fg = if (cell.attrs.has_fg) cell.fg else self.foreground, } else .{ // In inverted mode, the background MUST be set to something // (is never null) so it is either the fg or default fg. The // fg is either the bg or default background. - .bg = cell.fg orelse self.foreground, - .fg = cell.bg orelse self.background, + .bg = if (cell.attrs.has_fg) cell.fg else self.foreground, + .fg = if (cell.attrs.has_bg) cell.bg else self.background, }; break :colors res; }; diff --git a/src/Window.zig b/src/Window.zig index 6c855928f..f0407f7e8 100644 --- a/src/Window.zig +++ b/src/Window.zig @@ -529,7 +529,8 @@ fn charCallback(window: glfw.Window, codepoint: u21) void { // We want to scroll to the bottom // TODO: detect if we're at the bottom to avoid the render call here. - win.terminal.scrollViewport(.{ .bottom = {} }); + win.terminal.scrollViewport(.{ .bottom = {} }) catch |err| + log.err("error scrolling viewport err={}", .{err}); win.render_timer.schedule() catch |err| log.err("error scheduling render in charCallback err={}", .{err}); @@ -785,7 +786,8 @@ fn scrollCallback(window: glfw.Window, xoff: f64, yoff: f64) void { const sign: isize = if (yoff > 0) -1 else 1; const delta: isize = sign * @maximum(@divFloor(win.grid.size.rows, 15), 1); log.info("scroll: delta={}", .{delta}); - win.terminal.scrollViewport(.{ .delta = delta }); + win.terminal.scrollViewport(.{ .delta = delta }) catch |err| + log.err("error scrolling viewport err={}", .{err}); // Schedule render since scrolling usually does something. // TODO(perf): we can only schedule render if we know scrolling @@ -1338,11 +1340,11 @@ fn renderTimerCallback(t: *libuv.Timer) void { gl.clear(gl.c.GL_COLOR_BUFFER_BIT); // For now, rebuild all cells - win.grid.rebuildCells(win.terminal) catch |err| + win.grid.rebuildCells(&win.terminal) catch |err| log.err("error calling rebuildCells in render timer err={}", .{err}); // Finalize the cells prior to render - win.grid.finalizeCells(win.terminal) catch |err| + win.grid.finalizeCells(&win.terminal) catch |err| log.err("error calling updateCells in render timer err={}", .{err}); // Render the grid @@ -1382,7 +1384,7 @@ pub fn horizontalTab(self: *Window) !void { } pub fn linefeed(self: *Window) !void { - self.terminal.linefeed(); + try self.terminal.linefeed(); } pub fn carriageReturn(self: *Window) !void { @@ -1426,7 +1428,7 @@ pub fn setCursorPos(self: *Window, row: u16, col: u16) !void { pub fn eraseDisplay(self: *Window, mode: terminal.EraseDisplay) !void { if (mode == .complete) { // Whenever we erase the full display, scroll to bottom. - self.terminal.scrollViewport(.{ .bottom = {} }); + try self.terminal.scrollViewport(.{ .bottom = {} }); try self.render_timer.schedule(); } @@ -1462,12 +1464,12 @@ pub fn reverseIndex(self: *Window) !void { } pub fn index(self: *Window) !void { - self.terminal.index(); + try self.terminal.index(); } pub fn nextLine(self: *Window) !void { self.terminal.carriageReturn(); - self.terminal.index(); + try self.terminal.index(); } pub fn setTopAndBottomMargin(self: *Window, top: u16, bot: u16) !void { diff --git a/src/font/Shaper.zig b/src/font/Shaper.zig index f0bd78182..26dfc9a7d 100644 --- a/src/font/Shaper.zig +++ b/src/font/Shaper.zig @@ -74,7 +74,7 @@ pub const RunIterator = struct { i: usize = 0, pub fn next(self: *RunIterator, alloc: Allocator) !?TextRun { - if (self.i >= self.row.len) return null; + if (self.i >= self.row.lenCells()) return null; // Track the font for our curent run var current_font: Group.FontIndex = .{}; @@ -85,8 +85,8 @@ pub const RunIterator = struct { // Go through cell by cell and accumulate while we build our run. var j: usize = self.i; - while (j < self.row.len) : (j += 1) { - const cell = self.row[j]; + while (j < self.row.lenCells()) : (j += 1) { + const cell = self.row.getCell(j); // Ignore tailing wide spacers, this will get fixed up by the shaper if (cell.empty() or cell.attrs.wide_spacer_tail) continue; @@ -129,8 +129,8 @@ test "run iterator" { { // Make a screen with some data var screen = try terminal.Screen.init(alloc, 3, 5, 0); - defer screen.deinit(alloc); - screen.testWriteString("ABCD"); + defer screen.deinit(); + try screen.testWriteString("ABCD"); // Get our run iterator var shaper = testdata.shaper; @@ -143,8 +143,8 @@ test "run iterator" { { // Make a screen with some data var screen = try terminal.Screen.init(alloc, 3, 5, 0); - defer screen.deinit(alloc); - screen.testWriteString("A😃D"); + defer screen.deinit(); + try screen.testWriteString("A😃D"); // Get our run iterator var shaper = testdata.shaper; @@ -175,8 +175,8 @@ test "shape" { // Make a screen with some data var screen = try terminal.Screen.init(alloc, 3, 10, 0); - defer screen.deinit(alloc); - screen.testWriteString(buf[0..buf_idx]); + defer screen.deinit(); + try screen.testWriteString(buf[0..buf_idx]); // Get our run iterator var shaper = testdata.shaper; diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index 1a8890705..12dcca646 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -16,28 +16,19 @@ //! const Screen = @This(); -// FUTURE: Today this is implemented as a single contiguous ring buffer. -// If we increase the scrollback, we perform a full memory copy. For small -// scrollback, this is pretty cheap. For large (or infinite) scrollback, -// this starts to get pretty nasty. We should change this in the future to -// use a segmented list or something similar. I want to keep all the visible -// area contiguous so its not a simple drop-in. We can take a look at this -// one day. - const std = @import("std"); -const utf8proc = @import("utf8proc"); +const builtin = @import("builtin"); const assert = std.debug.assert; const Allocator = std.mem.Allocator; +const utf8proc = @import("utf8proc"); const color = @import("color.zig"); const point = @import("point.zig"); +const CircBuf = @import("circ_buf.zig").CircBuf; const Selection = @import("Selection.zig"); const log = std.log.scoped(.screen); -/// A row is a set of cells. -pub const Row = []Cell; - /// Cursor represents the cursor state. pub const Cursor = struct { // x, y where the cursor currently exists (0-indexed). This x/y is @@ -52,28 +43,81 @@ pub const Cursor = struct { pending_wrap: bool = false, }; +/// 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 (builtin.mode == .ReleaseFast) { + // 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 { + /// Used internally to track if this row has been initialized. + init: bool = false, + + /// True if one of the cells in this row has been changed + dirty: bool = false, + + /// If true, this row is soft-wrapped. The first cell of the next + /// row is a continuous of this row. + wrap: bool = false, +}; + /// Cell is a single cell within the screen. pub const Cell = struct { - /// Each cell contains exactly one character. The character is UTF-32 - /// encoded (just the Unicode codepoint). - char: u32, + /// 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. null means to use the default. - fg: ?color.RGB = null, - bg: ?color.RGB = null, + /// Foreground and background color. attrs.has_{bg/fg} must be checked + /// to see if these are useful values. + fg: color.RGB = undefined, + bg: color.RGB = undefined, /// On/off attributes that can be set attrs: packed struct { + has_bg: bool = false, + has_fg: bool = false, + bold: bool = false, faint: bool = false, underline: bool = false, inverse: bool = false, - /// If 1, this line is soft-wrapped. Only the last cell in a row - /// should have this set. The first cell of the next row is actually - /// part of this row in raw input. - wrap: 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, @@ -91,19 +135,101 @@ pub const Cell = struct { 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))); + // const cell: Cell = .{ .char = 0 }; + // _ = @bitCast(u8, cell.attrs); + // try std.testing.expectEqual(1, @sizeOf(@TypeOf(cell.attrs))); } test { - //log.warn("CELL={}", .{@sizeOf(Cell)}); - try std.testing.expectEqual(16, @sizeOf(Cell)); + //log.warn("CELL={} {}", .{ @sizeOf(Cell), @alignOf(Cell) }); + try std.testing.expectEqual(12, @sizeOf(Cell)); } }; +/// A row is a single row in the screen. +pub const Row = struct { + /// 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, + + /// 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.wrap = v; + } + + /// 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; + } + + /// 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 { + std.mem.set(StorageCell, self.storage[1..], .{ .cell = cell }); + } + + /// Fill a slice of a row. + pub fn fillSlice(self: Row, cell: Cell, start: usize, len: usize) void { + assert(len <= self.storage.len - 1); + std.mem.set(StorageCell, self.storage[start + 1 .. len + 1], .{ .cell = cell }); + } + + /// Get a single immutable cell. + pub fn getCell(self: Row, x: usize) Cell { + assert(self.header().init); + 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(self.header().init); + assert(x < self.storage.len - 1); + return &self.storage[x + 1].cell; + } + + /// Copy the row src into this row. The row can be from another screen. + pub fn copyRow(self: Row, src: Row) void { + assert(self.header().init); + const end = @minimum(src.storage.len, self.storage.len); + std.mem.copy(StorageCell, self.storage[1..], src.storage[1..end]); + } + + /// Read-only iterator for the cells in the row. + pub fn cellIterator(self: Row) CellIterator { + assert(self.header().init); + return .{ .row = self }; + } + + /// If this row isn't initialized, this sets all our cells to the + /// proper union tag so that it is properly zeroed. + fn initIfNeeded(self: Row) void { + if (!self.storage[0].header.init) { + self.fill(.{}); + self.storage[0].header.init = true; + } + } +}; + +/// Used to iterate through the rows of a specific region. pub const RowIterator = struct { - screen: *const Screen, + screen: *Screen, tag: RowIndexTag, value: usize = 0, @@ -116,6 +242,19 @@ pub const RowIterator = struct { } }; +/// 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; + } +}; + /// 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, @@ -139,6 +278,34 @@ pub const RowIndex = union(RowIndexTag) { /// 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: { + assert(y < RowIndexTag.screen.maxLen(screen)); + break :y y; + }, + + .viewport => |y| y: { + assert(y < RowIndexTag.viewport.maxLen(screen)); + break :y y + screen.viewport; + }, + + .active => |y| y: { + assert(y < RowIndexTag.active.maxLen(screen)); + break :y RowIndexTag.history.maxLen(screen) + y; + }, + + .history => |y| y: { + assert(y < RowIndexTag.history.maxLen(screen)); + break :y y; + }, + }; + + return .{ .screen = y }; + } }; /// The tags of RowIndex @@ -152,13 +319,25 @@ pub const RowIndexTag = enum { /// so it is 1-indexed. If the value is zero, it means that this /// section of the screen is empty or disabled. pub fn maxLen(self: RowIndexTag, screen: *const Screen) usize { + const rows_written = screen.rowsWritten(); + return switch (self) { - // The max of the screen is "bottom" so that we don't read - // past the pre-allocated space. - .screen => screen.bottom, - .viewport => screen.rows, + // Screen can be any of the written rows + .screen => rows_written, + + // Viewport can be any of the written rows or the max size + // of a viewport. + .viewport => @minimum(screen.rows, rows_written), + + // 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 => if (rows_written > screen.rows) rows_written - screen.rows else 0, + + // 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, - .history => screen.bottomOffset(), + //TODO .active => @minimum(rows_written, screen.rows), }; } @@ -173,33 +352,32 @@ pub const RowIndexTag = enum { } }; -/// Each screen maintains its own cursor state. -cursor: Cursor = .{}, +// 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 = .{} }); -/// Saved cursor saved with DECSC (ESC 7). -saved_cursor: Cursor = .{}, +/// The allocator used for all the storage operations +alloc: Allocator, -/// The full list of rows, including any scrollback. -storage: []Cell, +/// The full set of storage. +storage: StorageBuf, -/// The top and bottom of the scroll area. The first visible row if the terminal -/// window were scrolled all the way to the top. The last visible row if the -/// terminal were scrolled all the way to the bottom. -top: usize, -bottom: usize, - -/// The offset of the visible area within the storage. This is from the -/// "top" field. So the actual index of the first row is -/// `storage[top + visible_offset]`. -visible_offset: usize, +/// 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 number of rows and columns in the visible space. -rows: usize, -cols: usize, +/// The row (offset from the top) where the viewport currently is. +viewport: usize, + +/// Each screen maintains its own cursor state. +cursor: Cursor = .{}, + +/// Saved cursor saved with DECSC (ESC 7). +saved_cursor: Cursor = .{}, /// Initialize a new screen. pub fn init( @@ -208,128 +386,104 @@ pub fn init( cols: usize, max_scrollback: usize, ) !Screen { - // Allocate enough storage to cover every row and column in the visible - // area. This wastes some up front memory but saves allocations later. - // TODO: dynamically allocate scrollback - const buf = try alloc.alloc(Cell, (rows + max_scrollback) * cols); - std.mem.set(Cell, buf, .{ .char = 0 }); + // * 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 + @minimum(max_scrollback, rows)) * (cols + 1); return Screen{ - .cursor = .{}, - .storage = buf, - .top = 0, - .bottom = rows, - .visible_offset = 0, - .max_scrollback = max_scrollback, + .alloc = alloc, + .storage = try StorageBuf.init(alloc, buf_size), .rows = rows, .cols = cols, + .max_scrollback = max_scrollback, + .viewport = 0, }; } -pub fn deinit(self: *Screen, alloc: Allocator) void { - alloc.free(self.storage); - self.* = undefined; +pub fn deinit(self: *Screen) void { + self.storage.deinit(self.alloc); } -/// This returns true if the viewport is anchored at the bottom currently. +/// Returns true if the viewport is scrolled to the bottom of the screen. pub fn viewportIsBottom(self: Screen) bool { - return self.visible_offset == self.bottomOffset(); + return self.viewport >= RowIndexTag.history.maxLen(&self); } -fn bottomOffset(self: Screen) usize { - return self.bottom - self.rows; +/// 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: *const Screen, tag: RowIndexTag) RowIterator { +pub fn rowIterator(self: *Screen, tag: RowIndexTag) RowIterator { return .{ .screen = self, .tag = tag }; } -/// Region gets the contiguous portions of memory that constitute an -/// entire region. This is an efficient way to clear regions, for example -/// since you can memcpy directly into it. -/// -/// This has two elements because internally we use a ring buffer and -/// so any region can be split into two if it crosses the ring buffer -/// boundary. -pub fn region(self: *const Screen, tag: RowIndexTag) [2][]Cell { - const max_len = tag.maxLen(self); - if (max_len == 0) { - // This region is disabled or empty - return .{ self.storage[0..0], self.storage[0..0] }; - } +/// 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 { + // Get our offset into storage + const offset = index.toScreen(self).screen * (self.cols + 1); - const top = self.rowIndex(tag.index(0)); - const bot = self.rowIndex(tag.index(max_len - 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); - // The bottom and top are available in one contiguous slice. - if (bot >= top) { - return .{ - self.storage[top .. bot + self.cols], - self.storage[0..0], // just so its a valid slice, but zero length - }; - } - - // The bottom and top are split into two slices, so we slice to the - // bottom of the storage, then from the top. - return .{ - self.storage[top..self.storage.len], - self.storage[0 .. bot + self.cols], - }; + const row: Row = .{ .storage = slices[0] }; + row.initIfNeeded(); + return row; } -/// Get a single row in the active area by index (0-indexed). -pub fn getRow(self: Screen, idx: RowIndex) Row { - // Get the index of the first byte of the the row at index. - const real_idx = self.rowIndex(idx); - - // The storage is sliced to return exactly the number of columns. - return self.storage[real_idx .. real_idx + self.cols]; +/// 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); + dst_row.copyRow(src_row); } -/// Get a single cell in the active area. row and col are 0-indexed. -pub fn getCell(self: Screen, row: usize, col: usize) *Cell { - assert(row < self.rows); - assert(col < self.cols); - const row_idx = self.rowIndex(.{ .active = row }); - return &self.storage[row_idx + col]; +/// 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 index for the given row (0-indexed) into the underlying -/// storage array. The row is 0-indexed from the top of the screen. -fn rowIndex(self: *const Screen, idx: RowIndex) usize { - const y = switch (idx) { - .screen => |y| y: { - assert(y < RowIndexTag.screen.maxLen(self)); - break :y y; - }, - - .viewport => |y| y: { - assert(y < RowIndexTag.viewport.maxLen(self)); - break :y y + self.visible_offset; - }, - - .active => |y| y: { - assert(y < RowIndexTag.active.maxLen(self)); - break :y self.bottomOffset() + y; - }, - - .history => |y| y: { - assert(y < RowIndexTag.history.maxLen(self)); - break :y y; - }, - }; - - const val = (self.top + y) * self.cols; - if (val < self.storage.len) return val; - return val - self.storage.len; +/// 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); } -/// Returns the total number of rows in the screen. -inline fn totalRows(self: Screen) usize { - return self.storage.len / self.cols; +/// 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); } /// Scroll behaviors for the scroll function. @@ -358,119 +512,309 @@ pub const Scroll = union(enum) { /// "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) void { +pub fn scroll(self: *Screen, behavior: Scroll) !void { switch (behavior) { // Setting viewport offset to zero makes row 0 be at self.top // which is the top! - .top => self.visible_offset = 0, + .top => self.viewport = 0, - // Calc the bottom by going from top of scrollback (self.top) - // to the end of the storage, then subtract the number of visible - // rows. - .bottom => self.visible_offset = self.bottom - self.rows, + // Bottom is the end of the history area (end of history is the + // top of the active area). + .bottom => self.viewport = RowIndexTag.history.maxLen(self), // TODO: deltas greater than the entire scrollback - .delta => |delta| self.scrollDelta(delta, true), - .delta_no_grow => |delta| self.scrollDelta(delta, false), + .delta => |delta| try self.scrollDelta(delta, true), + .delta_no_grow => |delta| try self.scrollDelta(delta, false), } } -fn scrollDelta(self: *Screen, delta: isize, grow: bool) void { - // log.info("offsets before: top={} bottom={} visible={}", .{ - // self.top, - // self.bottom, - // self.visible_offset, - // }); - // defer { - // log.info("offsets after: top={} bottom={} visible={}", .{ - // self.top, - // self.bottom, - // self.visible_offset, - // }); - // } - +fn scrollDelta(self: *Screen, delta: isize, grow: bool) !void { // 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.visible_offset -|= @intCast(usize, -delta); + self.viewport -|= @intCast(usize, -delta); return; } - // If we're scrolling down, we have more work to do beacuse we - // need to determine if we're overwriting our scrollback. - self.visible_offset +|= @intCast(usize, delta); - if (grow) { - self.bottom +|= @intCast(usize, delta); - } else { - // If we're not growing, then we want to ensure we don't scroll - // off the bottom. Calculate the number of rows we can see. If we - // can see less than the number of rows we have available, then scroll - // back a bit. - const visible_bottom = self.visible_offset + self.rows; - if (visible_bottom > self.bottom) { - self.visible_offset = self.bottom - self.rows; + // If we're scrolling down and not growing, then we just + // add to the viewport and clamp at the bottom. + const viewport_max = RowIndexTag.history.maxLen(self); + if (!grow) { + self.viewport = @minimum( + viewport_max, + self.viewport +| @intCast(usize, delta), + ); + return; + } - // We can also fast-track this case because we know we won't - // be overlapping at all so we can return immediately. - 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. + self.viewport +|= @intCast(usize, delta); + if (self.viewport <= viewport_max) return; + + // Our viewport is bigger than our max. The number of new rows we need + // in our buffer is our value minus the max. + const new_rows_needed = self.viewport - viewport_max; + + // If we can't fit into our capacity but we have space, resize the + // buffer to allocate more scrollback. + const rows_written = self.rowsWritten(); + 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 = @maximum( + rows_final * (self.cols + 1), + self.rows * 2, + ); + + // Allocate what we can. + try self.storage.resize( + self.alloc, + @minimum(max_capacity, needed_capacity), + ); } } - // TODO: can optimize scrollback = 0 + // 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(); + self.viewport -= rows_to_delete; + self.storage.deleteOldest(rows_to_delete * (self.cols + 1)); - // Determine if we need to clear rows. - assert(@mod(self.storage.len, self.cols) == 0); - const storage_rows = self.storage.len / self.cols; - const visible_zero = self.top + self.visible_offset; - const rows_overlapped = if (visible_zero >= storage_rows) overlap: { - // We're wrapping from the top of the visible area. In this - // scenario, we just check that we have enough space from - // our true visible top to zero. - const visible_top = visible_zero - storage_rows; - const rows_available = self.top - visible_top; - if (rows_available >= self.rows) return; + // If we grew down like this, we must be at the bottom. + assert(self.viewportIsBottom()); - // We overlap our missing rows - break :overlap self.rows - rows_available; - } else overlap: { - // First check: if we have enough space in the storage buffer - // FORWARD to accomodate all our rows, then we're fine. - const rows_forward = storage_rows - (self.top + self.visible_offset); - if (rows_forward >= self.rows) return; + break :deleted rows_to_delete; + } else 0; - // Second check: if we have enough space PRIOR to zero when - // wrapped, then we're fine. - const rows_wrapped = self.rows - rows_forward; - if (rows_wrapped < self.top) return; - - // We need to clear the rows in the overlap and move the top - // of the scrollback buffer. - break :overlap rows_wrapped - self.top; - }; - - // If we are growing, then we clear the overlap and reset zero - if (grow) { - // Clear our overlap - const clear_start = self.top * self.cols; - const clear_end = clear_start + (rows_overlapped * self.cols); - std.mem.set(Cell, self.storage[clear_start..clear_end], .{ .char = 0 }); - - // Move to accomodate overlap. This deletes scrollback. - self.top = @mod(self.top + rows_overlapped, storage_rows); - - // The new bottom is right up against the new top since we're using - // the full buffer. The bottom is therefore the full size of the storage. - self.bottom = storage_rows; - } - - // Move back the number of overlapped - self.visible_offset -= rows_overlapped; + // Ensure we have "written" our last row so that it shows up + _ = self.storage.getPtrSlice( + (rows_final - rows_deleted - 1) * (self.cols + 1), + self.cols + 1, + ); } -/// Copy row at src to dst. -pub fn copyRow(self: *Screen, dst: usize, src: usize) void { - const src_row = self.getRow(.{ .active = src }); - const dst_row = self.getRow(.{ .active = dst }); - std.mem.copy(Cell, dst_row, src_row); +/// 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) ![:0]const u8 { + // Get the slices for the string + const slices = self.selectionSlices(sel); + + // We can now know how much space we'll need to store the string. We loop + // over and UTF8-encode and calculate the exact size required. We will be + // off here by at most "newlines" values in the worst case that every + // single line is soft-wrapped. + const chars = chars: { + var count: usize = 0; + const arr = [_][]StorageCell{ slices.top, slices.bot }; + for (arr) |slice| { + for (slice) |cell, i| { + // detect row headers + if (@mod(i, self.cols + 1) == 0) { + // We use each row header as an opportunity to "count" + // a new row, and therefore count a possible newline. + count += 1; + continue; + } + + var buf: [4]u8 = undefined; + const char = if (cell.cell.char > 0) cell.cell.char else ' '; + count += try std.unicode.utf8Encode(@intCast(u21, char), &buf); + } + } + + break :chars count; + }; + const buf = try alloc.alloc(u8, chars + 1); + errdefer alloc.free(buf); + + // Connect the text from the two slices + const arr = [_][]StorageCell{ slices.top, slices.bot }; + var buf_i: usize = 0; + var row_count: usize = 0; + for (arr) |slice| { + var 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; + + // Our end index is usually a full row, but if we're the final + // row then we just use the length. + const end_idx = @minimum(slice.len, start_idx + self.cols + 1); + + // We may have to skip some cells from the beginning if we're + // the first row. + var skip: usize = if (row_count == 0) slices.top_offset else 0; + + const row: Row = .{ .storage = slice[start_idx..end_idx] }; + var it = row.cellIterator(); + while (it.next()) |cell| { + if (skip > 0) { + skip -= 1; + continue; + } + + // Skip spacers + if (cell.attrs.wide_spacer_head or + cell.attrs.wide_spacer_tail) continue; + + const char = if (cell.char > 0) cell.char else ' '; + buf_i += try std.unicode.utf8Encode(@intCast(u21, char), buf[buf_i..]); + } + + // If this row is not soft-wrapped, add a newline + if (!row.header().wrap) { + buf[buf_i] = '\n'; + buf_i += 1; + } + } + } + + // Remove our trailing newline, its never correct. + if (buf[buf_i - 1] == '\n') buf_i -= 1; + + // Add null termination + buf[buf_i] = 0; + + // Realloc so our free length is exactly correct + const result = try alloc.realloc(buf, buf_i + 1); + return result[0..buf_i :0]; +} + +/// 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) struct { + rows: usize, + + // 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, + top: []StorageCell, + bot: []StorageCell, +} { + // Note: this function is tested via selectionString + + assert(sel_raw.start.y < self.rowsWritten()); + assert(sel_raw.end.y < self.rowsWritten()); + assert(sel_raw.start.x < self.cols); + assert(sel_raw.end.x < self.cols); + + const sel = sel: { + var sel = sel_raw; + + // 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.end.x -= 1; + } + } + + break :sel sel; + }; + + // Get the true "top" and "bottom" + const sel_top = sel.topLeft(); + const sel_bot = sel.bottomRight(); + + // 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, + .top_offset = sel_top.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 { + // 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; + + // 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 = old.rowsWritten() * (cols + 1); + const new_max_capacity = self.maxCapacity(); + const buf_size = @minimum(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 resets to the top because we're going to rewrite the screen + self.viewport = 0; + + // Rewrite all our rows + var y: usize = 0; + var row_it = old.rowIterator(.screen); + while (row_it.next()) |old_row| { + // If we're past the end, scroll + if (y >= self.rows) { + y -= 1; + try self.scroll(.{ .delta = 1 }); + } + + // Get this row + const new_row = self.getRow(.{ .active = y }); + 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 accomodate 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 = @minimum(old.cursor.x, self.cols - 1); + self.cursor.y = if (old_cursor_y_screen < RowIndexTag.screen.maxLen(self)) + old_cursor_y_screen - RowIndexTag.history.maxLen(self) + else + self.rows - 1; } /// Resize the screen. The rows or cols can be bigger or smaller. This @@ -483,621 +827,26 @@ pub fn copyRow(self: *Screen, dst: usize, src: usize) void { /// /// This will trim data if the size is getting smaller. This will reflow the /// soft wrapped text. -pub fn resize(self: *Screen, alloc: Allocator, rows: usize, cols: usize) !void { - defer { - assert(self.cursor.x < self.cols); - assert(self.cursor.y < self.rows); - assert(self.rows == rows); - assert(self.cols == cols); +pub fn resize(self: *Screen, rows: usize, cols: usize) !void { + if (self.cols == cols) { + // No resize necessary + if (self.rows == rows) return; + + // 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; } - // If the rows increased, we alloc space for the new rows (w/ existing cols) - // and move the viewport such that the bottom is in view. - if (rows > self.rows) { - var storage = try alloc.alloc( - Cell, - (rows + self.max_scrollback) * self.cols, - ); - - // Copy our screen into the new storage area. Since we're growing - // rows, we know that the full buffer will fit so we copy it in - // order. - const reg = self.region(.screen); - std.mem.copy(Cell, storage, reg[0]); - std.mem.copy(Cell, storage[reg[0].len..], reg[1]); - std.mem.set(Cell, storage[reg[0].len + reg[1].len ..], .{ .char = 0 }); - - // Modify our storage, our lines have grown - alloc.free(self.storage); - self.storage = storage; - - // Fix our row count - self.rows = rows; - - // Store our visible offset so we can move our cursor accordingly. - const old_offset = self.visible_offset; - - // Top is now 0 because we reoriented the ring buffer to be ordered. - // Bottom must be at least "rows" since we always show at least that - // much in the viewport. - self.top = 0; - self.bottom = @maximum(rows, self.bottom); - self.scroll(.{ .bottom = {} }); - - // Move our cursor to account for the new rows. The old offset - // should always be bigger (or the same) than the new offset since - // we are adding rows. - self.cursor.y += old_offset - self.visible_offset; - } - - // 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 storage = try alloc.alloc( - Cell, - (self.rows + self.max_scrollback) * cols, - ); - std.mem.set(Cell, storage, .{ .char = 0 }); - - // 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.Viewport{ - .x = self.cursor.x, - .y = self.cursor.y, - }).toScreen(self); - - // Nothing can fail from this point forward (no "try" expressions) - // so replace our storage. We defer freeing the "old" value because - // we need to access the old screen to copy. - var old = self.*; - defer { - assert(old.storage.ptr != self.storage.ptr); - alloc.free(old.storage); - } - self.storage = storage; - self.cols = cols; - - // Whether we need to move the cursor or not - var new_cursor: ?point.ScreenPoint = null; - - // Iterate over the screen since we need to check for reflow. - var iter = old.rowIterator(.screen); - var y: usize = 0; - while (iter.next()) |row| { - // No matter what we copy this row - var new_row = self.getRow(.{ .screen = y }); - std.mem.copy(Cell, new_row, row); - - // 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) { - assert(new_cursor == null); // should only happen once - new_cursor = .{ .y = y, .x = cursor_pos.x }; - } - - // If no reflow, just keep going - if (!row[row.len - 1].attrs.wrap) { - 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[row.len - 1].attrs.wrap = false; - - // We maintain an x coord so that we can set cursors properly - var x: usize = row.len; - new_row = new_row[x..]; - wrapping: while (iter.next()) |wrapped_row| { - // Trim the row from the right so that we ignore all trailing - // empty chars and don't wrap them. - const trimmed_row = trim: { - var i: usize = wrapped_row.len; - while (i > 0) : (i -= 1) if (!wrapped_row[i - 1].empty()) break; - break :trim wrapped_row[0..i]; - }; - - var wrapped_rem = trimmed_row; - while (wrapped_rem.len > 0) { - // If the wrapped row fits nicely... - if (wrapped_rem.len <= new_row.len) { - // Copy the row - std.mem.copy(Cell, new_row, wrapped_rem); - - // If our cursor is in this line, then we have to move it - // onto the new line because it got unwrapped. - if (cursor_pos.y == iter.value - 1 and new_cursor == null) { - new_cursor = .{ .y = y, .x = cursor_pos.x + x }; - } - - // If this row isn't also wrapped, we're done! - if (!wrapped_rem[wrapped_rem.len - 1].attrs.wrap) { - y += 1; - - // If we were able to copy the entire row then - // we shortened the screen by one. We need to reflect - // this in our viewport. - if (wrapped_rem.len == trimmed_row.len and - self.visible_offset > 0) - { - self.visible_offset -= 1; - self.bottom -= 1; - } - - break :wrapping; - } - - // Wrapped again! - new_row[wrapped_rem.len - 1].attrs.wrap = false; - new_row = new_row[wrapped_rem.len..]; - x += wrapped_rem.len; - break; - } - - // The row doesn't fit, meaning we have to soft-wrap the - // new row but probably at a diff boundary. - std.mem.copy(Cell, new_row, wrapped_rem[0..new_row.len]); - new_row[new_row.len - 1].attrs.wrap = true; - - // We still need to copy the remainder - wrapped_rem = wrapped_rem[new_row.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 < new_row.len) - { - assert(new_cursor == null); // should only happen once - new_cursor = .{ .y = y, .x = x + cursor_pos.x }; - } - - // Move to a new line in our new screen - y += 1; - x = 0; - new_row = self.getRow(.{ .screen = y }); - } - } - } - - // 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; - } - } - - // If our rows got smaller, we trim the scrollback. - if (rows < self.rows) { - var storage = try alloc.alloc( - Cell, - (rows + self.max_scrollback) * self.cols, - ); - - // Get the slices for our full screen. We only copy the end of it - // that fits into our new memory region. We know we have the same - // number of columns in this block so we can just copy as-is. - const reg = self.region(.screen); - - // Trim the empty space off the end. The "end" might go into - // "top" since bottom may be empty or only implies the wraparound - // on the ring buffer. - const top = reg[0]; - const bot = reg[1]; - const bot_trimmed = trim: { - var i: usize = bot.len; - while (i > 0) : (i -= 1) if (!bot[i - 1].empty()) break; - i += self.cols - @mod(i, self.cols); - i = @minimum(bot.len, i); - break :trim bot[0..i]; - }; - const top_trimmed = if (bot.len > 0 and bot_trimmed.len == bot.len) noop: { - // We do nothing here because it means that we hit real content - // in the "bottom" so we don't want to trim zeros off the top - // when they might actually be useful. - break :noop top; - } else trim: { - var i: usize = top.len; - while (i > 0) : (i -= 1) if (!top[i - 1].empty()) break; - i += self.cols - @mod(i, self.cols); - i = @minimum(top.len, i); - break :trim top[0..i]; - }; - - // The trimmed also have to be cleanly divisible by rows since - // the copy and other math below depends on this invariant. - assert(@mod(bot_trimmed.len, self.cols) == 0); - assert(@mod(top_trimmed.len, self.cols) == 0); - - // Copy the top and bottom into the storage - const bot_len = @minimum(bot_trimmed.len, storage.len); - const top_len = @minimum(top_trimmed.len, storage.len - bot_len); - std.mem.copy(Cell, storage, top_trimmed[top_trimmed.len - top_len ..]); - std.mem.copy(Cell, storage[top_len..], bot_trimmed[bot_trimmed.len - bot_len ..]); - std.mem.set(Cell, storage[top_len + bot_len ..], .{ .char = 0 }); - - // Calculate the number of rows we copied since this will be - // our new "bottom". This should always divide cleanly because - // our cols haven't changed. - assert(@mod(top_len + bot_len, self.cols) == 0); - const copied_rows = (top_len + bot_len) / self.cols; - - // Modify our storage - alloc.free(self.storage); - self.storage = storage; - - // If our cursor was past the end of our old value, we pull it back. - if (self.cursor.y >= rows) { - self.cursor.y -= self.rows - rows; - } - - // Fix our row count - self.rows = rows; - - // Top is now 0 because we reoriented the ring buffer to be ordered. - // Bottom must be at least "rows" since we always show at least that - // much in the viewport. - self.top = 0; - self.bottom = @maximum(rows, copied_rows); - //log.warn("bot={} top={} copied={}", .{ bot_len, top_len, copied_rows }); - //log.warn("BOTTOM={}", .{self.bottom}); - self.scroll(.{ .bottom = {} }); - } - - // If our cols got smaller, we have to reflow text. This is the worst - // possible case because we can't do any easy trick sto get reflow, - // we just have to iterate over the screen and "print", wrapping as - // needed. - if (cols < self.cols) { - var storage = try alloc.alloc( - Cell, - (self.rows + self.max_scrollback) * cols, - ); - std.mem.set(Cell, storage, .{ .char = 0 }); - - // Convert our cursor coordinates to screen coordinates because - // we may have to reflow the cursor if the line it is on is moved. - var cursor_pos = (point.Viewport{ - .x = self.cursor.x, - .y = self.cursor.y, - }).toScreen(self); - - // Nothing can fail from this point forward (no "try" expressions) - // so replace our storage. We defer freeing the "old" value because - // we need to access the old screen to copy. - var old = self.*; - defer { - assert(old.storage.ptr != self.storage.ptr); - alloc.free(old.storage); - } - self.storage = storage; - self.cols = cols; - - // Whether we need to move the cursor or not - var new_cursor: ?point.ScreenPoint = null; - - // Iterate over the screen since we need to check for reflow. - var iter = old.rowIterator(.screen); - var x: usize = 0; - var y: usize = 0; - while (iter.next()) |row| { - // Trim the row from the right so that we ignore all trailing - // empty chars and don't wrap them. - const trimmed_row = trim: { - var i: usize = row.len; - while (i > 0) { - if (!row[i - 1].empty()) break; - i -= 1; - } - - break :trim row[0..i]; - }; - - // Copy all the cells into our row. - for (trimmed_row) |cell, i| { - // Soft wrap if we have to - if (x == self.cols) { - var last_cell = self.getCell(y, x - 1); - last_cell.attrs.wrap = true; - x = 0; - y += 1; - } - - // If our y is more than our rows, we need to scroll - if (y >= self.rows) { - self.scroll(.{ .delta = 1 }); - y = self.rows - 1; - x = 0; - } - - // If our cursor is on this point, we need to move it. - if (cursor_pos.y == iter.value - 1 and - cursor_pos.x == i) - { - assert(new_cursor == null); - new_cursor = .{ .x = x, .y = self.visible_offset + y }; - } - - // Copy the old cell, unset the old wrap state - // log.warn("y={} x={} rows={}", .{ y, x, self.rows }); - var new_cell = self.getCell(y, x); - new_cell.* = cell; - new_cell.attrs.wrap = false; - - // Next - x += 1; - } - - // If our cursor is on this line but not in a content area, - // then we just set it to be at the end. - if (cursor_pos.y == iter.value - 1 and - cursor_pos.x >= trimmed_row.len) - { - assert(new_cursor == null); - new_cursor = .{ - .x = @minimum(cursor_pos.x, self.cols - 1), - .y = self.visible_offset + y, - }; - } - - // If we aren't wrapping, then move to the next row - if (trimmed_row.len == 0 or - !trimmed_row[trimmed_row.len - 1].attrs.wrap) - { - y += 1; - x = 0; - } - } - - // 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; - } else { - // TODO: why is this necessary? Without this, neovim will - // crash when we shrink the window to the smallest size - self.cursor.x = @minimum(self.cursor.x, self.cols - 1); - self.cursor.y = @minimum(self.cursor.y, self.rows - 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, alloc: Allocator, rows: usize, cols: usize) !void { - // Resize without reflow not supported for now with scrollback. - assert(self.max_scrollback == 0); - - // Make a copy so we can access the old indexes. - const old = self.*; - - // Reallocate the storage - self.storage = try alloc.alloc(Cell, (rows + self.max_scrollback) * cols); - defer alloc.free(old.storage); - std.mem.set(Cell, self.storage, .{ .char = 0 }); - self.top = 0; - self.bottom = rows; - self.rows = rows; - self.cols = cols; - - // Move our cursor if we have to so it stays on the screen. - self.cursor.x = @minimum(self.cursor.x, self.cols - 1); - self.cursor.y = @minimum(self.cursor.y, self.rows - 1); - - // If we're increasing height, then copy all rows (start at 0). - // Otherwise start at the latest row that includes the bottom row, - // aka strip the top. - var y: usize = if (rows >= old.rows) 0 else old.rows - rows; - const start = y; - const col_end = @minimum(old.cols, cols); - while (y < old.rows) : (y += 1) { - // Copy the old row into the new row, just losing the columsn - // if we got thinner. - const old_row = old.getRow(.{ .viewport = y }); - const new_row = self.getRow(.{ .viewport = y - start }); - std.mem.copy(Cell, new_row, old_row[0..col_end]); - - // If our new row is wider, then we copy zeroes into the rest. - if (new_row.len > old_row.len) { - std.mem.set(Cell, new_row[old_row.len..], .{ .char = 0 }); - } - } - - // If we grew rows, then set the remaining data to zero. - if (rows > old.rows) { - std.mem.set(Cell, self.storage[self.rowIndex(.{ .viewport = old.rows })..], .{ .char = 0 }); - } -} - -/// Returns the raw text associated with a selection. This will unwrap -/// soft-wrapped edges. The returned slice is owned by the caller. -pub fn selectionString(self: Screen, alloc: Allocator, sel: Selection) ![:0]const u8 { - // Get the slices for the string - const slices = self.selectionSlices(sel); - - // We can now know how much space we'll need to store the string. We loop - // over and UTF8-encode and calculate the exact size required. We will be - // off here by at most "newlines" values in the worst case that every - // single line is soft-wrapped. - const newlines = @divFloor(slices.top.len + slices.bot.len, self.cols) + 1; - const chars = chars: { - var count: usize = 0; - const arr = [_][]Cell{ slices.top, slices.bot }; - for (arr) |slice| { - for (slice) |cell| { - var buf: [4]u8 = undefined; - const char = if (cell.char > 0) cell.char else ' '; - count += try std.unicode.utf8Encode(@intCast(u21, char), &buf); - } - } - - break :chars count; - }; - const buf = try alloc.alloc(u8, chars + newlines + 1); - errdefer alloc.free(buf); - - var i: usize = 0; - for (slices.top) |cell, idx| { - // If our index cleanly divides into the col count then we're - // at a newline and we add it. - if (idx > 0 and - @mod(idx + slices.top_offset, self.cols) == 0 and - !slices.top[idx - 1].attrs.wrap) - { - buf[i] = '\n'; - i += 1; - } - - // Skip spacers - if (cell.attrs.wide_spacer_head or - cell.attrs.wide_spacer_tail) continue; - - const char = if (cell.char > 0) cell.char else ' '; - i += try std.unicode.utf8Encode(@intCast(u21, char), buf[i..]); - } - - for (slices.bot) |cell, idx| { - // We don't use "top_offset" here because the bot by definition - // is never offset, it always starts at index 0 so we can just check - // the index directly. - if (@mod(idx, self.cols) == 0) { - // Determine if we soft-wrapped. For the bottom slice this is - // a bit unique because if we're at idx 0, we actually need to - // check the end of the top. - const wrapped = if (idx > 0) - slices.bot[idx - 1].attrs.wrap - else - slices.top[slices.top.len - 1].attrs.wrap; - - if (!wrapped) { - buf[i] = '\n'; - i += 1; - } - } - - // Skip spacers - if (cell.attrs.wide_spacer_head or - cell.attrs.wide_spacer_tail) continue; - - const char = if (cell.char > 0) cell.char else ' '; - i += try std.unicode.utf8Encode(@intCast(u21, char), buf[i..]); - } - - // Add null termination - buf[i] = 0; - - // Realloc so our free length is exactly correct - const result = try alloc.realloc(buf, i + 1); - return result[0..i :0]; -} - -/// 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) struct { - // 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, - top: []Cell, - bot: []Cell, -} { - // Note: this function is tested via selectionString - - assert(sel_raw.start.y < self.totalRows()); - assert(sel_raw.end.y < self.totalRows()); - assert(sel_raw.start.x < self.cols); - assert(sel_raw.end.x < self.cols); - - const sel = sel: { - var sel = sel_raw; - - // 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 }); - if (row[sel.end.x].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 }); - if (row[sel.start.x].attrs.wide_spacer_tail) { - sel.end.x -= 1; - } - } - - break :sel sel; - }; - - // Get the true "top" and "bottom" - const sel_top = sel.topLeft(); - const sel_bot = sel.bottomRight(); - const top = self.rowIndex(.{ .screen = sel_top.y }); - const bot = self.rowIndex(.{ .screen = sel_bot.y }); - - // The bottom and top are available in one contiguous slice. - if (bot >= top) { - return .{ - .top_offset = sel_top.x, - .top = self.storage[top + sel_top.x .. bot + sel_bot.x + 1], - .bot = self.storage[0..0], // just so its a valid slice, but zero length - }; - } - - // The bottom and top are split into two slices, so we slice to the - // bottom of the storage, then from the top. - return .{ - .top_offset = sel_top.x, - .top = self.storage[top + sel_top.x .. self.storage.len], - .bot = self.storage[0 .. bot + sel_bot.x + 1], - }; -} - -/// 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. -pub fn testString(self: Screen, alloc: Allocator, tag: RowIndexTag) ![]const u8 { - const buf = try alloc.alloc(u8, self.storage.len + self.rows + 1); - - var i: usize = 0; - var y: usize = 0; - var rows = self.rowIterator(tag); - while (rows.next()) |row| { - defer y += 1; - - if (y > 0) { - buf[i] = '\n'; - i += 1; - } - - for (row) |cell| { - // TODO: handle character after null - if (cell.char > 0) { - i += try std.unicode.utf8Encode(@intCast(u21, cell.char), buf[i..]); - } - } - } - - // Never render the final newline - const str = std.mem.trimRight(u8, buf[0..i], "\n"); - return try alloc.realloc(buf, str.len); + // TODO + try self.resizeWithoutReflow(rows, cols); } /// 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. -pub fn testWriteString(self: *Screen, text: []const u8) void { +/// will occur. This will automatically handle basic wide chars. +pub fn testWriteString(self: *Screen, text: []const u8) !void { var y: usize = 0; var x: usize = 0; @@ -1114,7 +863,7 @@ pub fn testWriteString(self: *Screen, text: []const u8) void { // If we're writing past the end of the active area, scroll. if (y >= self.rows) { y -= 1; - self.scroll(.{ .delta = 1 }); + try self.scroll(.{ .delta = 1 }); } // Get our row @@ -1122,12 +871,12 @@ pub fn testWriteString(self: *Screen, text: []const u8) void { // If we're writing past the end, we need to soft wrap. if (x == self.cols) { - row[x - 1].attrs.wrap = true; + row.setWrapped(true); y += 1; x = 0; if (y >= self.rows) { y -= 1; - self.scroll(.{ .delta = 1 }); + try self.scroll(.{ .delta = 1 }); } row = self.getRow(.{ .active = y }); } @@ -1136,30 +885,40 @@ pub fn testWriteString(self: *Screen, text: []const u8) void { const width = utf8proc.charwidth(c); assert(width == 1 or width == 2); switch (width) { - 1 => row[x].char = @intCast(u32, c), + 1 => { + const cell = row.getCellPtr(x); + cell.char = @intCast(u32, c); + }, 2 => { if (x == self.cols - 1) { - row[x].char = ' '; - row[x].attrs.wide_spacer_head = true; + const cell = row.getCellPtr(x); + cell.char = ' '; + cell.attrs.wide_spacer_head = true; // wrap - row[x].attrs.wrap = true; + row.setWrapped(true); y += 1; x = 0; if (y >= self.rows) { y -= 1; - self.scroll(.{ .delta = 1 }); + try self.scroll(.{ .delta = 1 }); } row = self.getRow(.{ .active = y }); } - row[x].char = @intCast(u32, c); - row[x].attrs.wide = true; + { + const cell = row.getCellPtr(x); + cell.char = @intCast(u32, c); + cell.attrs.wide = true; + } - x += 1; - row[x].char = ' '; - row[x].attrs.wide_spacer_tail = true; + { + x += 1; + const cell = row.getCellPtr(x); + cell.char = ' '; + cell.attrs.wide_spacer_tail = true; + } }, else => unreachable, @@ -1169,16 +928,51 @@ pub fn testWriteString(self: *Screen, text: []const u8) void { } } +/// 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 { + const buf = try alloc.alloc(u8, self.storage.len() * 4); + + var i: usize = 0; + var y: usize = 0; + var rows = self.rowIterator(tag); + while (rows.next()) |row| { + defer y += 1; + + if (y > 0) { + buf[i] = '\n'; + i += 1; + } + + var cells = row.cellIterator(); + while (cells.next()) |cell| { + // TODO: handle character after null + if (cell.char > 0) { + i += try std.unicode.utf8Encode(@intCast(u21, cell.char), buf[i..]); + } + } + } + + // Never render the final newline + const str = std.mem.trimRight(u8, buf[0..i], "\n"); + return try alloc.realloc(buf, str.len); +} + test "Screen" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 3, 5, 0); - defer s.deinit(alloc); + 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"; - s.testWriteString(str); + try s.testWriteString(str); + try testing.expect(s.rowsWritten() == 3); { var contents = try s.testString(alloc, .screen); defer alloc.free(contents); @@ -1191,7 +985,7 @@ test "Screen" { while (iter.next()) |row| { // Rows should be pointer equivalent to getRow const row_other = s.getRow(.{ .viewport = count }); - try testing.expectEqual(row.ptr, row_other.ptr); + try testing.expectEqual(row.storage.ptr, row_other.storage.ptr); count += 1; } @@ -1199,10 +993,9 @@ test "Screen" { try testing.expectEqual(@as(usize, 3), count); // Should be able to easily clear screen - const reg = s.region(.viewport); - std.mem.set(Cell, reg[0], .{ .char = 'A' }); - std.mem.set(Cell, reg[1], .{ .char = 'A' }); { + var it = s.rowIterator(.viewport); + while (it.next()) |row| row.fill(.{ .char = 'A' }); var contents = try s.testString(alloc, .screen); defer alloc.free(contents); try testing.expectEqualStrings("AAAAA\nAAAAA\nAAAAA", contents); @@ -1214,20 +1007,14 @@ test "Screen: scrolling" { const alloc = testing.allocator; var s = try init(alloc, 3, 5, 0); - defer s.deinit(alloc); - s.testWriteString("1ABCD\n2EFGH\n3IJKL"); - + defer s.deinit(); + try s.testWriteString("1ABCD\n2EFGH\n3IJKL"); try testing.expect(s.viewportIsBottom()); // Scroll down, should still be bottom - s.scroll(.{ .delta = 1 }); + try s.scroll(.{ .delta = 1 }); try testing.expect(s.viewportIsBottom()); - // Test our row index - try testing.expectEqual(@as(usize, 5), s.rowIndex(.{ .active = 0 })); - try testing.expectEqual(@as(usize, 10), s.rowIndex(.{ .active = 1 })); - try testing.expectEqual(@as(usize, 0), s.rowIndex(.{ .active = 2 })); - { // Test our contents rotated var contents = try s.testString(alloc, .viewport); @@ -1236,7 +1023,7 @@ test "Screen: scrolling" { } // Scrolling to the bottom does nothing - s.scroll(.{ .bottom = {} }); + try s.scroll(.{ .bottom = {} }); { // Test our contents rotated @@ -1246,35 +1033,16 @@ test "Screen: scrolling" { } } -// TODO -// test "Screen: scrolling more than size" { -// const testing = std.testing; -// const alloc = testing.allocator; -// -// var s = try init(alloc, 3, 5, 3); -// defer s.deinit(alloc); -// s.testWriteString("1ABCD\n2EFGH\n3IJKL"); -// -// try testing.expect(s.viewportIsBottom()); -// -// // Scroll down, should still be bottom -// s.scroll(.{ .delta = 7 }); -// try testing.expect(s.viewportIsBottom()); -// -// // Test our row index -// try testing.expectEqual(@as(usize, 5), s.rowIndex(0)); -// try testing.expectEqual(@as(usize, 10), s.rowIndex(1)); -// try testing.expectEqual(@as(usize, 15), s.rowIndex(2)); -// } - 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(alloc); - s.testWriteString("1ABCD\n2EFGH\n3IJKL"); - s.scroll(.{ .delta = -1 }); + defer s.deinit(); + try s.testWriteString("1ABCD\n2EFGH\n3IJKL"); + + // Scrolling up does nothing, but allows it + try s.scroll(.{ .delta = -1 }); try testing.expect(s.viewportIsBottom()); { @@ -1290,14 +1058,9 @@ test "Screen: scrollback" { const alloc = testing.allocator; var s = try init(alloc, 3, 5, 1); - defer s.deinit(alloc); - s.testWriteString("1ABCD\n2EFGH\n3IJKL"); - s.scroll(.{ .delta = 1 }); - - // Test our row index - try testing.expectEqual(@as(usize, 5), s.rowIndex(.{ .active = 0 })); - try testing.expectEqual(@as(usize, 10), s.rowIndex(.{ .active = 1 })); - try testing.expectEqual(@as(usize, 15), s.rowIndex(.{ .active = 2 })); + defer s.deinit(); + try s.testWriteString("1ABCD\n2EFGH\n3IJKL"); + try s.scroll(.{ .delta = 1 }); { // Test our contents rotated @@ -1307,7 +1070,7 @@ test "Screen: scrollback" { } // Scrolling to the bottom - s.scroll(.{ .bottom = {} }); + try s.scroll(.{ .bottom = {} }); try testing.expect(s.viewportIsBottom()); { @@ -1318,7 +1081,7 @@ test "Screen: scrollback" { } // Scrolling back should make it visible again - s.scroll(.{ .delta = -1 }); + try s.scroll(.{ .delta = -1 }); try testing.expect(!s.viewportIsBottom()); { @@ -1329,7 +1092,7 @@ test "Screen: scrollback" { } // Scrolling back again should do nothing - s.scroll(.{ .delta = -1 }); + try s.scroll(.{ .delta = -1 }); { // Test our contents rotated @@ -1339,7 +1102,7 @@ test "Screen: scrollback" { } // Scrolling to the bottom - s.scroll(.{ .bottom = {} }); + try s.scroll(.{ .bottom = {} }); { // Test our contents rotated @@ -1349,7 +1112,7 @@ test "Screen: scrollback" { } // Scrolling forward with no grow should do nothing - s.scroll(.{ .delta_no_grow = 1 }); + try s.scroll(.{ .delta_no_grow = 1 }); { // Test our contents rotated @@ -1359,7 +1122,7 @@ test "Screen: scrollback" { } // Scrolling to the top should work - s.scroll(.{ .top = {} }); + try s.scroll(.{ .top = {} }); { // Test our contents rotated @@ -1369,9 +1132,8 @@ test "Screen: scrollback" { } // Should be able to easily clear active area only - const reg = s.region(.active); - std.mem.set(Cell, reg[0], .{ .char = 0 }); - std.mem.set(Cell, reg[1], .{ .char = 0 }); + var it = s.rowIterator(.active); + while (it.next()) |row| row.clear(.{}); { var contents = try s.testString(alloc, .viewport); defer alloc.free(contents); @@ -1379,7 +1141,7 @@ test "Screen: scrollback" { } // Scrolling to the bottom - s.scroll(.{ .bottom = {} }); + try s.scroll(.{ .bottom = {} }); { // Test our contents rotated @@ -1389,14 +1151,49 @@ test "Screen: scrollback" { } } +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 + var contents = try s.testString(alloc, .viewport); + defer alloc.free(contents); + try testing.expectEqualStrings("1ABCD\n2EFGH\n3IJKL", contents); + } + + // Scroll down a ton + try s.scroll(.{ .delta = 5 }); + try testing.expect(s.viewportIsBottom()); + { + // Test our contents rotated + var contents = try s.testString(alloc, .screen); + defer alloc.free(contents); + try testing.expectEqualStrings("3IJKL\n4ABCD\n5EFGH\n6IJKL", contents); + } + { + // Test our contents rotated + var contents = try s.testString(alloc, .viewport); + defer alloc.free(contents); + try testing.expectEqualStrings("6IJKL", contents); + } +} + test "Screen: scrollback empty" { const testing = std.testing; const alloc = testing.allocator; var s = try init(alloc, 3, 5, 50); - defer s.deinit(alloc); - s.testWriteString("1ABCD\n2EFGH\n3IJKL"); - s.scroll(.{ .delta_no_grow = 1 }); + defer s.deinit(); + try s.testWriteString("1ABCD\n2EFGH\n3IJKL"); + try s.scroll(.{ .delta_no_grow = 1 }); { // Test our contents @@ -1406,16 +1203,16 @@ test "Screen: scrollback empty" { } } -test "Screen: history region with scrollback" { +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(alloc); + defer s.deinit(); // Write a bunch that WOULD invoke scrollback if exists const str = "1ABCD\n2EFGH\n3IJKL"; - s.testWriteString(str); + try s.testWriteString(str); { var contents = try s.testString(alloc, .screen); defer alloc.free(contents); @@ -1424,9 +1221,10 @@ test "Screen: history region with scrollback" { } // Verify no scrollback - const reg = s.region(.history); - try testing.expect(reg[0].len == 0); - try testing.expect(reg[1].len == 0); + 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" { @@ -1434,11 +1232,11 @@ test "Screen: history region with scrollback" { const alloc = testing.allocator; var s = try init(alloc, 1, 5, 2); - defer s.deinit(alloc); + defer s.deinit(); // Write a bunch that WOULD invoke scrollback if exists const str = "1ABCD\n2EFGH\n3IJKL"; - s.testWriteString(str); + try s.testWriteString(str); { var contents = try s.testString(alloc, .viewport); defer alloc.free(contents); @@ -1452,11 +1250,6 @@ test "Screen: history region with scrollback" { try testing.expectEqualStrings("1ABCD\n2EFGH\n3IJKL", contents); } - // Verify history region - const reg = s.region(.history); - try testing.expect(reg[0].len > 0); - try testing.expect(reg[1].len >= 0); - { var contents = try s.testString(alloc, .history); defer alloc.free(contents); @@ -1464,18 +1257,17 @@ test "Screen: history region with scrollback" { 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(alloc); - s.testWriteString("1ABCD\n2EFGH\n3IJKL"); + defer s.deinit(); + try s.testWriteString("1ABCD\n2EFGH\n3IJKL"); // Copy - s.scroll(.{ .delta = 1 }); - s.copyRow(2, 0); + try s.scroll(.{ .delta = 1 }); + s.copyRow(.{ .active = 2 }, .{ .active = 0 }); // Test our contents var contents = try s.testString(alloc, .viewport); @@ -1488,9 +1280,9 @@ test "Screen: selectionString" { const alloc = testing.allocator; var s = try init(alloc, 3, 5, 0); - defer s.deinit(alloc); + defer s.deinit(); const str = "1ABCD\n2EFGH\n3IJKL"; - s.testWriteString(str); + try s.testWriteString(str); { var contents = try s.selectionString(alloc, .{ @@ -1508,9 +1300,9 @@ test "Screen: selectionString soft wrap" { const alloc = testing.allocator; var s = try init(alloc, 3, 5, 0); - defer s.deinit(alloc); + defer s.deinit(); const str = "1ABCD2EFGH3IJKL"; - s.testWriteString(str); + try s.testWriteString(str); { var contents = try s.selectionString(alloc, .{ @@ -1528,16 +1320,15 @@ test "Screen: selectionString wrap around" { const alloc = testing.allocator; var s = try init(alloc, 3, 5, 0); - defer s.deinit(alloc); - s.testWriteString("1ABCD\n2EFGH\n3IJKL"); + 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. - s.scroll(.{ .delta = 1 }); + try s.scroll(.{ .delta = 1 }); try testing.expect(s.viewportIsBottom()); - try testing.expectEqual(@as(usize, 0), s.rowIndex(.{ .active = 2 })); - s.testWriteString("1ABCD\n2EFGH\n3IJKL"); + try s.testWriteString("1ABCD\n2EFGH\n3IJKL"); { var contents = try s.selectionString(alloc, .{ @@ -1555,9 +1346,9 @@ test "Screen: selectionString wide char" { const alloc = testing.allocator; var s = try init(alloc, 3, 5, 0); - defer s.deinit(alloc); + defer s.deinit(); const str = "1A⚡"; - s.testWriteString(str); + try s.testWriteString(str); { var contents = try s.selectionString(alloc, .{ @@ -1595,9 +1386,9 @@ test "Screen: selectionString wide char with header" { const alloc = testing.allocator; var s = try init(alloc, 3, 5, 0); - defer s.deinit(alloc); + defer s.deinit(); const str = "1ABC⚡"; - s.testWriteString(str); + try s.testWriteString(str); { var contents = try s.selectionString(alloc, .{ @@ -1610,16 +1401,120 @@ test "Screen: selectionString wide char with header" { } } +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); + try s.resizeWithoutReflow(10, 5); + + { + var contents = try s.testString(alloc, .viewport); + defer alloc.free(contents); + try testing.expectEqualStrings(str, contents); + } +} + +test "Screen: resize (no reflow) less rows" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 3, 5, 0); + defer s.deinit(); + const str = "1ABCD\n2EFGH\n3IJKL"; + try s.testWriteString(str); + try s.resizeWithoutReflow(2, 5); + + { + var contents = try s.testString(alloc, .viewport); + defer alloc.free(contents); + try testing.expectEqualStrings("2EFGH\n3IJKL", 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); + + { + var 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); + + { + var 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" { + 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); + + { + var 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); + + { + var contents = try s.testString(alloc, .screen); + defer alloc.free(contents); + const expected = "2EFGH\n3IJKL\n4ABCD\n5EFGH"; + try testing.expectEqualStrings(expected, contents); + } +} + 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(alloc); + defer s.deinit(); const str = "1ABCD\n2EFGH\n3IJKL"; - s.testWriteString(str); + try s.testWriteString(str); const cursor = s.cursor; - try s.resize(alloc, 10, 5); + try s.resize(10, 5); // Cursor should not move try testing.expectEqual(cursor, s.cursor); @@ -1641,12 +1536,11 @@ test "Screen: resize more rows with empty scrollback" { const alloc = testing.allocator; var s = try init(alloc, 3, 5, 10); - defer s.deinit(alloc); + defer s.deinit(); const str = "1ABCD\n2EFGH\n3IJKL"; - s.testWriteString(str); + try s.testWriteString(str); const cursor = s.cursor; - try s.resize(alloc, 10, 5); - try testing.expectEqual(@as(usize, 20), s.totalRows()); + try s.resize(10, 5); // Cursor should not move try testing.expectEqual(cursor, s.cursor); @@ -1668,9 +1562,9 @@ test "Screen: resize more rows with populated scrollback" { const alloc = testing.allocator; var s = try init(alloc, 3, 5, 5); - defer s.deinit(alloc); + defer s.deinit(); const str = "1ABCD\n2EFGH\n3IJKL\n4ABCD\n5EFGH"; - s.testWriteString(str); + try s.testWriteString(str); { var contents = try s.testString(alloc, .viewport); defer alloc.free(contents); @@ -1681,17 +1575,13 @@ test "Screen: resize more rows with populated scrollback" { // Set our cursor to be on the "4" s.cursor.x = 0; s.cursor.y = 1; - try testing.expectEqual(@as(u32, '4'), s.getCell(s.cursor.y, s.cursor.x).char); + try testing.expectEqual(@as(u32, '4'), s.getCell(.active, s.cursor.y, s.cursor.x).char); // Resize - try s.resize(alloc, 10, 5); - try testing.expectEqual(@as(usize, 15), s.totalRows()); + try s.resize(10, 5); // Cursor should still be on the "4" - try testing.expectEqual(@as(u32, '4'), s.getCell(s.cursor.y, s.cursor.x).char); - // s.cursor.x = 0; - // s.cursor.y = 1; - //try testing.expectEqual(cursor, s.cursor); + try testing.expectEqual(@as(u32, '4'), s.getCell(.active, s.cursor.y, s.cursor.x).char); { var contents = try s.testString(alloc, .viewport); @@ -1705,11 +1595,11 @@ test "Screen: resize more cols no reflow" { const alloc = testing.allocator; var s = try init(alloc, 3, 5, 0); - defer s.deinit(alloc); + defer s.deinit(); const str = "1ABCD\n2EFGH\n3IJKL"; - s.testWriteString(str); + try s.testWriteString(str); const cursor = s.cursor; - try s.resize(alloc, 3, 10); + try s.resize(3, 10); // Cursor should not move try testing.expectEqual(cursor, s.cursor); @@ -1725,524 +1615,3 @@ test "Screen: resize more cols no reflow" { try testing.expectEqualStrings(str, 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(alloc); - const str = "1ABCD2EFGH\n3IJKL"; - s.testWriteString(str); - - // Verify we soft wrapped - { - var 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(s.cursor.y, s.cursor.x).char); - - // Resize and verify we undid the soft wrap because we have space now - try s.resize(alloc, 3, 10); - { - var 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(alloc); - const str = "1ABCD2EFGH\n3IJKL"; - s.testWriteString(str); - - // Verify we soft wrapped - { - var 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(s.cursor.y, s.cursor.x).char); - - // Resize and verify we undid the soft wrap because we have space now - try s.resize(alloc, 3, 10); - { - var 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(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(alloc); - const str = "1ABCD2EFGH\n3IJKL"; - 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(s.cursor.y, s.cursor.x).char); - - // Verify we soft wrapped - { - var 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(alloc, 3, 7); - { - var 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(alloc); - const str = "1ABCD2EFGH3IJKL"; - 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(s.cursor.y, s.cursor.x).char); - - // Verify we soft wrapped - { - var 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(alloc, 3, 15); - { - var 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(alloc); - const str = "1ABCD\n2EFGH\n3IJKL\n4ABCD5EFGH"; - s.testWriteString(str); - { - var 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(s.cursor.y, s.cursor.x).char); - - // Resize - try s.resize(alloc, 3, 10); - - // Cursor should still be on the "5" - log.warn("cursor={}", .{s.cursor}); - try testing.expectEqual(@as(u32, '5'), s.getCell(s.cursor.y, s.cursor.x).char); - - { - var contents = try s.testString(alloc, .viewport); - defer alloc.free(contents); - const expected = "2EFGH\n3IJKL\n4ABCD5EFGH"; - try testing.expectEqualStrings(expected, contents); - } -} - -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(alloc); - const str = "1ABCD\n2EFGH\n3IJKL"; - s.testWriteString(str); - const cursor = s.cursor; - try s.resize(alloc, 1, 5); - - // Cursor should not move - try testing.expectEqual(cursor, s.cursor); - - { - var contents = try s.testString(alloc, .viewport); - defer alloc.free(contents); - const expected = "3IJKL"; - try testing.expectEqualStrings(expected, contents); - } - { - var 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(alloc); - const str = "1ABCD\n2EFGH\n3IJKL"; - 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(s.cursor.y, s.cursor.x).char); - - // Resize - try s.resize(alloc, 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); - - { - var contents = try s.testString(alloc, .viewport); - defer alloc.free(contents); - const expected = "3IJKL"; - try testing.expectEqualStrings(expected, contents); - } - { - var 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(alloc); - const str = "1ABCD\n2EFGH\n3IJKL"; - s.testWriteString(str); - try s.resize(alloc, 1, 5); - - { - var contents = try s.testString(alloc, .screen); - defer alloc.free(contents); - try testing.expectEqualStrings(str, contents); - } - { - var 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(alloc); - const str = "1ABCD\n2EFGH\n3IJKL\n4ABCD\n5EFGH"; - s.testWriteString(str); - { - var contents = try s.testString(alloc, .viewport); - defer alloc.free(contents); - const expected = "3IJKL\n4ABCD\n5EFGH"; - try testing.expectEqualStrings(expected, contents); - } - - // Resize - try s.resize(alloc, 1, 5); - - { - var contents = try s.testString(alloc, .screen); - defer alloc.free(contents); - try testing.expectEqualStrings(str, contents); - } - { - var contents = try s.testString(alloc, .viewport); - defer alloc.free(contents); - const expected = "5EFGH"; - 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(alloc); - const str = "1AB\n2EF\n3IJ"; - s.testWriteString(str); - const cursor = s.cursor; - try s.resize(alloc, 3, 3); - - // Cursor should not move - try testing.expectEqual(cursor, s.cursor); - - { - var contents = try s.testString(alloc, .viewport); - defer alloc.free(contents); - try testing.expectEqualStrings(str, contents); - } - { - var contents = try s.testString(alloc, .screen); - defer alloc.free(contents); - try testing.expectEqualStrings(str, contents); - } -} - -test "Screen: resize less cols with reflow but row space" { - const testing = std.testing; - const alloc = testing.allocator; - - var s = try init(alloc, 3, 5, 0); - defer s.deinit(alloc); - const str = "1ABCD"; - 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(s.cursor.y, s.cursor.x).char); - - try s.resize(alloc, 3, 3); - { - var contents = try s.testString(alloc, .viewport); - defer alloc.free(contents); - const expected = "1AB\nCD"; - try testing.expectEqualStrings(expected, contents); - } - { - var 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(alloc); - const str = "3IJKL\n4ABCD\n5EFGH"; - s.testWriteString(str); - try s.resize(alloc, 3, 3); - - { - var contents = try s.testString(alloc, .viewport); - defer alloc.free(contents); - const expected = "CD\n5EF\nGH"; - try testing.expectEqualStrings(expected, contents); - } - { - var 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(alloc); - const str = "3IJKL\n4ABCD\n5EFGH"; - s.testWriteString(str); - try s.resize(alloc, 3, 3); - - { - var contents = try s.testString(alloc, .viewport); - defer alloc.free(contents); - const expected = "CD\n5EF\nGH"; - try testing.expectEqualStrings(expected, contents); - } - { - var contents = try s.testString(alloc, .screen); - defer alloc.free(contents); - const expected = "4AB\nCD\n5EF\nGH"; - 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(alloc); - const str = "1ABC"; - s.testWriteString(str); - - // Grow - try s.resize(alloc, 10, 5); - { - var contents = try s.testString(alloc, .viewport); - defer alloc.free(contents); - try testing.expectEqualStrings(str, contents); - } - { - var contents = try s.testString(alloc, .screen); - defer alloc.free(contents); - try testing.expectEqualStrings(str, contents); - } - - // Shrink - try s.resize(alloc, 3, 5); - { - var contents = try s.testString(alloc, .screen); - defer alloc.free(contents); - try testing.expectEqualStrings(str, contents); - } - { - var contents = try s.testString(alloc, .viewport); - defer alloc.free(contents); - try testing.expectEqualStrings(str, contents); - } - - // Grow again - try s.resize(alloc, 10, 5); - { - var contents = try s.testString(alloc, .viewport); - defer alloc.free(contents); - try testing.expectEqualStrings(str, contents); - } - { - var contents = try s.testString(alloc, .screen); - defer alloc.free(contents); - try testing.expectEqualStrings(str, contents); - } -} - -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(alloc); - const str = "1ABCD\n2EFGH\n3IJKL"; - s.testWriteString(str); - try s.resizeWithoutReflow(alloc, 10, 5); - - { - var contents = try s.testString(alloc, .viewport); - defer alloc.free(contents); - try testing.expectEqualStrings(str, contents); - } -} - -test "Screen: resize (no reflow) less rows" { - const testing = std.testing; - const alloc = testing.allocator; - - var s = try init(alloc, 3, 5, 0); - defer s.deinit(alloc); - const str = "1ABCD\n2EFGH\n3IJKL"; - s.testWriteString(str); - try s.resizeWithoutReflow(alloc, 2, 5); - - { - var contents = try s.testString(alloc, .viewport); - defer alloc.free(contents); - try testing.expectEqualStrings("2EFGH\n3IJKL", 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(alloc); - const str = "1ABCD\n2EFGH\n3IJKL"; - s.testWriteString(str); - try s.resizeWithoutReflow(alloc, 3, 10); - - { - var 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(alloc); - const str = "1ABCD\n2EFGH\n3IJKL"; - s.testWriteString(str); - try s.resizeWithoutReflow(alloc, 3, 4); - - { - var contents = try s.testString(alloc, .viewport); - defer alloc.free(contents); - const expected = "1ABC\n2EFG\n3IJK"; - try testing.expectEqualStrings(expected, contents); - } -} diff --git a/src/terminal/Screen2.zig b/src/terminal/Screen2.zig deleted file mode 100644 index f0fc8803c..000000000 --- a/src/terminal/Screen2.zig +++ /dev/null @@ -1,1563 +0,0 @@ -//! 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. -//! -const Screen = @This(); - -const std = @import("std"); -const builtin = @import("builtin"); -const assert = std.debug.assert; -const Allocator = std.mem.Allocator; - -const utf8proc = @import("utf8proc"); -const color = @import("color.zig"); -const point = @import("point.zig"); -const CircBuf = @import("circ_buf.zig").CircBuf; -const Selection = @import("Selection.zig"); - -const log = std.log.scoped(.screen); - -/// 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, - - // 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, -}; - -/// 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 (builtin.mode == .ReleaseFast) { - // 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 { - /// Used internally to track if this row has been initialized. - init: bool = false, - - /// True if one of the cells in this row has been changed - dirty: bool = false, - - /// If true, this row is soft-wrapped. The first cell of the next - /// row is a continuous of this row. - wrap: bool = 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. attrs.has_{bg/fg} must be checked - /// to see if these are useful values. - fg: color.RGB = undefined, - bg: color.RGB = undefined, - - /// On/off attributes that can be set - attrs: packed struct { - has_bg: bool = false, - has_fg: bool = false, - - bold: bool = false, - faint: bool = false, - underline: bool = false, - inverse: 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 preceeding - /// wide character (tail) or following (head). - wide_spacer_tail: bool = false, - wide_spacer_head: bool = false, - } = .{}, - - /// True if the cell should be skipped for drawing - pub fn empty(self: Cell) bool { - return self.char == 0; - } - - 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={} {}", .{ @sizeOf(Cell), @alignOf(Cell) }); - try std.testing.expectEqual(12, @sizeOf(Cell)); - } -}; - -/// A row is a single row in the screen. -pub const Row = struct { - /// 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, - - /// 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.wrap = v; - } - - /// Retrieve the header for this row. - pub fn header(self: Row) RowHeader { - return self.storage[0].header; - } - - /// Clear the row, making all cells empty. - pub fn clear(self: Row) void { - self.fill(.{}); - } - - /// Fill the entire row with a copy of a single cell. - pub fn fill(self: Row, cell: Cell) void { - std.mem.set(StorageCell, self.storage[1..], .{ .cell = cell }); - } - - /// Get a single immutable cell. - pub fn getCell(self: Row, x: usize) Cell { - assert(self.header().init); - 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(self.header().init); - assert(x < self.storage.len - 1); - return &self.storage[x + 1].cell; - } - - /// Copy the row src into this row. The row can be from another screen. - pub fn copyRow(self: Row, src: Row) void { - assert(self.header().init); - const end = @minimum(src.storage.len, self.storage.len); - std.mem.copy(StorageCell, self.storage[1..], src.storage[1..end]); - } - - /// Read-only iterator for the cells in the row. - pub fn cellIterator(self: Row) CellIterator { - assert(self.header().init); - return .{ .row = self }; - } - - /// If this row isn't initialized, this sets all our cells to the - /// proper union tag so that it is properly zeroed. - fn initIfNeeded(self: Row) void { - if (!self.storage[0].header.init) { - self.fill(.{}); - self.storage[0].header.init = true; - } - } -}; - -/// Used to iterate through the rows of a specific region. -pub const RowIterator = struct { - screen: *Screen, - tag: RowIndexTag, - value: usize = 0, - - pub fn next(self: *RowIterator) ?Row { - if (self.value >= self.tag.maxLen(self.screen)) 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; - } -}; - -/// 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: { - assert(y < RowIndexTag.screen.maxLen(screen)); - break :y y; - }, - - .viewport => |y| y: { - assert(y < RowIndexTag.viewport.maxLen(screen)); - break :y y + screen.viewport; - }, - - .active => |y| y: { - assert(y < RowIndexTag.active.maxLen(screen)); - break :y RowIndexTag.history.maxLen(screen) + y; - }, - - .history => |y| y: { - 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 fn maxLen(self: RowIndexTag, screen: *const Screen) usize { - const rows_written = screen.rowsWritten(); - - return switch (self) { - // Screen can be any of the written rows - .screen => rows_written, - - // Viewport can be any of the written rows or the max size - // of a viewport. - .viewport => @minimum(screen.rows, rows_written), - - // 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 => if (rows_written > screen.rows) rows_written - screen.rows else 0, - - // 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, - }; - } - - /// 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 }, - }; - } -}; - -// 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 = .{} }); - -/// The allocator used for all the storage operations -alloc: Allocator, - -/// The full set of storage. -storage: StorageBuf, - -/// 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, - -/// Each screen maintains its own cursor state. -cursor: Cursor = .{}, - -/// Saved cursor saved with DECSC (ESC 7). -saved_cursor: Cursor = .{}, - -/// 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 + @minimum(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, - }; -} - -pub fn deinit(self: *Screen) void { - self.storage.deinit(self.alloc); -} - -/// Returns true if the viewport is scrolled to the bottom of the screen. -pub fn viewportIsBottom(self: Screen) bool { - return self.viewport >= RowIndexTag.history.maxLen(&self); -} - -/// 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); -} - -/// 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 { - return .{ .screen = self, .tag = tag }; -} - -/// 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 { - // 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 = .{ .storage = slices[0] }; - row.initIfNeeded(); - 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); - dst_row.copyRow(src_row); -} - -/// 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); -} - -/// 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". - delta: isize, - - /// Same as delta but scrolling down will not grow the scrollback. - /// Scrolling down at the bottom will do nothing (similar to how - /// delta at the top does nothing). - delta_no_grow: isize, -}; - -/// 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) !void { - 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 = RowIndexTag.history.maxLen(self), - - // TODO: deltas greater than the entire scrollback - .delta => |delta| try self.scrollDelta(delta, true), - .delta_no_grow => |delta| try self.scrollDelta(delta, false), - } -} - -fn scrollDelta(self: *Screen, delta: isize, grow: bool) !void { - // 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 -|= @intCast(usize, -delta); - return; - } - - // If we're scrolling down and not growing, then we just - // add to the viewport and clamp at the bottom. - const viewport_max = RowIndexTag.history.maxLen(self); - if (!grow) { - self.viewport = @minimum( - viewport_max, - self.viewport +| @intCast(usize, 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. - self.viewport +|= @intCast(usize, delta); - if (self.viewport <= viewport_max) return; - - // Our viewport is bigger than our max. The number of new rows we need - // in our buffer is our value minus the max. - const new_rows_needed = self.viewport - viewport_max; - - // If we can't fit into our capacity but we have space, resize the - // buffer to allocate more scrollback. - const rows_written = self.rowsWritten(); - 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 = @maximum( - rows_final * (self.cols + 1), - self.rows * 2, - ); - - // Allocate what we can. - try self.storage.resize( - self.alloc, - @minimum(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(); - self.viewport -= rows_to_delete; - self.storage.deleteOldest(rows_to_delete * (self.cols + 1)); - - // If we grew down like this, we must be at the bottom. - assert(self.viewportIsBottom()); - - break :deleted rows_to_delete; - } else 0; - - // Ensure we have "written" our last row so that it shows up - _ = self.storage.getPtrSlice( - (rows_final - rows_deleted - 1) * (self.cols + 1), - self.cols + 1, - ); -} - -/// 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) ![:0]const u8 { - // Get the slices for the string - const slices = self.selectionSlices(sel); - - // We can now know how much space we'll need to store the string. We loop - // over and UTF8-encode and calculate the exact size required. We will be - // off here by at most "newlines" values in the worst case that every - // single line is soft-wrapped. - const chars = chars: { - var count: usize = 0; - const arr = [_][]StorageCell{ slices.top, slices.bot }; - for (arr) |slice| { - for (slice) |cell, i| { - // detect row headers - if (@mod(i, self.cols + 1) == 0) { - // We use each row header as an opportunity to "count" - // a new row, and therefore count a possible newline. - count += 1; - continue; - } - - var buf: [4]u8 = undefined; - const char = if (cell.cell.char > 0) cell.cell.char else ' '; - count += try std.unicode.utf8Encode(@intCast(u21, char), &buf); - } - } - - break :chars count; - }; - const buf = try alloc.alloc(u8, chars + 1); - errdefer alloc.free(buf); - - // Connect the text from the two slices - const arr = [_][]StorageCell{ slices.top, slices.bot }; - var buf_i: usize = 0; - var row_count: usize = 0; - for (arr) |slice| { - var 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; - - // Our end index is usually a full row, but if we're the final - // row then we just use the length. - const end_idx = @minimum(slice.len, start_idx + self.cols + 1); - - // We may have to skip some cells from the beginning if we're - // the first row. - var skip: usize = if (row_count == 0) slices.top_offset else 0; - - const row: Row = .{ .storage = slice[start_idx..end_idx] }; - var it = row.cellIterator(); - while (it.next()) |cell| { - if (skip > 0) { - skip -= 1; - continue; - } - - // Skip spacers - if (cell.attrs.wide_spacer_head or - cell.attrs.wide_spacer_tail) continue; - - const char = if (cell.char > 0) cell.char else ' '; - buf_i += try std.unicode.utf8Encode(@intCast(u21, char), buf[buf_i..]); - } - - // If this row is not soft-wrapped, add a newline - if (!row.header().wrap) { - buf[buf_i] = '\n'; - buf_i += 1; - } - } - } - - // Remove our trailing newline, its never correct. - if (buf[buf_i - 1] == '\n') buf_i -= 1; - - // Add null termination - buf[buf_i] = 0; - - // Realloc so our free length is exactly correct - const result = try alloc.realloc(buf, buf_i + 1); - return result[0..buf_i :0]; -} - -/// 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) struct { - rows: usize, - - // 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, - top: []StorageCell, - bot: []StorageCell, -} { - // Note: this function is tested via selectionString - - assert(sel_raw.start.y < self.rowsWritten()); - assert(sel_raw.end.y < self.rowsWritten()); - assert(sel_raw.start.x < self.cols); - assert(sel_raw.end.x < self.cols); - - const sel = sel: { - var sel = sel_raw; - - // 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.end.x -= 1; - } - } - - break :sel sel; - }; - - // Get the true "top" and "bottom" - const sel_top = sel.topLeft(); - const sel_bot = sel.bottomRight(); - - // 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, - .top_offset = sel_top.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 { - // 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; - - // 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 = old.rowsWritten() * (cols + 1); - const new_max_capacity = self.maxCapacity(); - const buf_size = @minimum(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 resets to the top because we're going to rewrite the screen - self.viewport = 0; - - // Rewrite all our rows - var y: usize = 0; - var row_it = old.rowIterator(.screen); - while (row_it.next()) |old_row| { - // If we're past the end, scroll - if (y >= self.rows) { - y -= 1; - try self.scroll(.{ .delta = 1 }); - } - - // Get this row - const new_row = self.getRow(.{ .active = y }); - 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 accomodate 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 = @minimum(old.cursor.x, self.cols - 1); - self.cursor.y = if (old_cursor_y_screen < RowIndexTag.screen.maxLen(self)) - old_cursor_y_screen - RowIndexTag.history.maxLen(self) - else - self.rows - 1; -} - -/// 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; - - // 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; - } - - // TODO - try self.resizeWithoutReflow(rows, cols); -} - -/// 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 = 0; - var x: usize = 0; - - 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; - continue; - } - - // If we're writing past the end of the active area, scroll. - if (y >= self.rows) { - y -= 1; - try self.scroll(.{ .delta = 1 }); - } - - // Get our row - var row = self.getRow(.{ .active = y }); - - // 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(.{ .delta = 1 }); - } - row = self.getRow(.{ .active = y }); - } - - // If our character is double-width, handle it. - const width = utf8proc.charwidth(c); - assert(width == 1 or width == 2); - switch (width) { - 1 => { - const cell = row.getCellPtr(x); - cell.char = @intCast(u32, c); - }, - - 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(.{ .delta = 1 }); - } - row = self.getRow(.{ .active = y }); - } - - { - const cell = row.getCellPtr(x); - cell.char = @intCast(u32, c); - cell.attrs.wide = true; - } - - { - x += 1; - const cell = row.getCellPtr(x); - cell.char = ' '; - cell.attrs.wide_spacer_tail = true; - } - }, - - else => unreachable, - } - - x += 1; - } -} - -/// 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 { - const buf = try alloc.alloc(u8, self.storage.len() * 4); - - var i: usize = 0; - var y: usize = 0; - var rows = self.rowIterator(tag); - while (rows.next()) |row| { - defer y += 1; - - if (y > 0) { - buf[i] = '\n'; - i += 1; - } - - var cells = row.cellIterator(); - while (cells.next()) |cell| { - // TODO: handle character after null - if (cell.char > 0) { - i += try std.unicode.utf8Encode(@intCast(u21, cell.char), buf[i..]); - } - } - } - - // Never render the final newline - const str = std.mem.trimRight(u8, buf[0..i], "\n"); - return try alloc.realloc(buf, str.len); -} - -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); - { - var 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' }); - var contents = try s.testString(alloc, .screen); - defer alloc.free(contents); - try testing.expectEqualStrings("AAAAA\nAAAAA\nAAAAA", contents); - } -} - -test "Screen: scrolling" { - 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 - try s.scroll(.{ .delta = 1 }); - try testing.expect(s.viewportIsBottom()); - - { - // Test our contents rotated - var 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 = {} }); - - { - // Test our contents rotated - var 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(.{ .delta = -1 }); - try testing.expect(s.viewportIsBottom()); - - { - // Test our contents rotated - var 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(.{ .delta = 1 }); - - { - // Test our contents rotated - var 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 - var 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(.{ .delta = -1 }); - try testing.expect(!s.viewportIsBottom()); - - { - // Test our contents rotated - var 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(.{ .delta = -1 }); - - { - // Test our contents rotated - var 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 - var 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(.{ .delta_no_grow = 1 }); - - { - // Test our contents rotated - var 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 - var 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(); - { - var 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 - var contents = try s.testString(alloc, .viewport); - defer alloc.free(contents); - try testing.expectEqualStrings("", 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(.{ .delta_no_grow = 1 }); - - { - // Test our contents - var contents = try s.testString(alloc, .viewport); - defer alloc.free(contents); - try testing.expectEqualStrings("1ABCD\n2EFGH\n3IJKL", 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); - { - var 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); - { - var contents = try s.testString(alloc, .viewport); - defer alloc.free(contents); - const expected = "3IJKL"; - try testing.expectEqualStrings(expected, contents); - } - { - // Test our contents - var contents = try s.testString(alloc, .screen); - defer alloc.free(contents); - try testing.expectEqualStrings("1ABCD\n2EFGH\n3IJKL", contents); - } - - { - var 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(.{ .delta = 1 }); - s.copyRow(.{ .active = 2 }, .{ .active = 0 }); - - // Test our contents - var contents = try s.testString(alloc, .viewport); - defer alloc.free(contents); - try testing.expectEqualStrings("2EFGH\n3IJKL\n2EFGH", contents); -} - -test "Screen: selectionString" { - 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); - - { - var contents = try s.selectionString(alloc, .{ - .start = .{ .x = 0, .y = 1 }, - .end = .{ .x = 2, .y = 2 }, - }); - defer alloc.free(contents); - const expected = "2EFGH\n3IJ"; - 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); - - { - var contents = try s.selectionString(alloc, .{ - .start = .{ .x = 0, .y = 1 }, - .end = .{ .x = 2, .y = 2 }, - }); - 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(.{ .delta = 1 }); - try testing.expect(s.viewportIsBottom()); - try s.testWriteString("1ABCD\n2EFGH\n3IJKL"); - - { - var contents = try s.selectionString(alloc, .{ - .start = .{ .x = 0, .y = 1 }, - .end = .{ .x = 2, .y = 2 }, - }); - 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); - - { - var contents = try s.selectionString(alloc, .{ - .start = .{ .x = 0, .y = 0 }, - .end = .{ .x = 3, .y = 0 }, - }); - defer alloc.free(contents); - const expected = str; - try testing.expectEqualStrings(expected, contents); - } - - { - var contents = try s.selectionString(alloc, .{ - .start = .{ .x = 0, .y = 0 }, - .end = .{ .x = 2, .y = 0 }, - }); - defer alloc.free(contents); - const expected = str; - try testing.expectEqualStrings(expected, contents); - } - - { - var contents = try s.selectionString(alloc, .{ - .start = .{ .x = 3, .y = 0 }, - .end = .{ .x = 3, .y = 0 }, - }); - 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); - - { - var contents = try s.selectionString(alloc, .{ - .start = .{ .x = 0, .y = 0 }, - .end = .{ .x = 4, .y = 0 }, - }); - defer alloc.free(contents); - const expected = str; - try testing.expectEqualStrings(expected, contents); - } -} - -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); - try s.resizeWithoutReflow(10, 5); - - { - var contents = try s.testString(alloc, .viewport); - defer alloc.free(contents); - try testing.expectEqualStrings(str, contents); - } -} - -test "Screen: resize (no reflow) less rows" { - const testing = std.testing; - const alloc = testing.allocator; - - var s = try init(alloc, 3, 5, 0); - defer s.deinit(); - const str = "1ABCD\n2EFGH\n3IJKL"; - try s.testWriteString(str); - try s.resizeWithoutReflow(2, 5); - - { - var contents = try s.testString(alloc, .viewport); - defer alloc.free(contents); - try testing.expectEqualStrings("2EFGH\n3IJKL", 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); - - { - var 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); - - { - var 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" { - 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); - - { - var 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); - - { - var contents = try s.testString(alloc, .screen); - defer alloc.free(contents); - const expected = "2EFGH\n3IJKL\n4ABCD\n5EFGH"; - try testing.expectEqualStrings(expected, contents); - } -} - -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); - - { - var contents = try s.testString(alloc, .viewport); - defer alloc.free(contents); - try testing.expectEqualStrings(str, contents); - } - { - var 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); - - { - var contents = try s.testString(alloc, .viewport); - defer alloc.free(contents); - try testing.expectEqualStrings(str, contents); - } - { - var 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); - { - var 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); - - { - var contents = try s.testString(alloc, .viewport); - 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); - - { - var contents = try s.testString(alloc, .viewport); - defer alloc.free(contents); - try testing.expectEqualStrings(str, contents); - } - { - var contents = try s.testString(alloc, .screen); - defer alloc.free(contents); - try testing.expectEqualStrings(str, contents); - } -} diff --git a/src/terminal/ScreenOld.zig b/src/terminal/ScreenOld.zig new file mode 100644 index 000000000..1a8890705 --- /dev/null +++ b/src/terminal/ScreenOld.zig @@ -0,0 +1,2248 @@ +//! 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. +//! +const Screen = @This(); + +// FUTURE: Today this is implemented as a single contiguous ring buffer. +// If we increase the scrollback, we perform a full memory copy. For small +// scrollback, this is pretty cheap. For large (or infinite) scrollback, +// this starts to get pretty nasty. We should change this in the future to +// use a segmented list or something similar. I want to keep all the visible +// area contiguous so its not a simple drop-in. We can take a look at this +// one day. + +const std = @import("std"); +const utf8proc = @import("utf8proc"); +const assert = std.debug.assert; +const Allocator = std.mem.Allocator; + +const color = @import("color.zig"); +const point = @import("point.zig"); +const Selection = @import("Selection.zig"); + +const log = std.log.scoped(.screen); + +/// A row is a set of cells. +pub const Row = []Cell; + +/// 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, + + // 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, +}; + +/// Cell is a single cell within the screen. +pub const Cell = struct { + /// Each cell contains exactly one character. The character is UTF-32 + /// encoded (just the Unicode codepoint). + char: u32, + + /// Foreground and background color. null means to use the default. + fg: ?color.RGB = null, + bg: ?color.RGB = null, + + /// On/off attributes that can be set + attrs: packed struct { + bold: bool = false, + faint: bool = false, + underline: bool = false, + inverse: bool = false, + + /// If 1, this line is soft-wrapped. Only the last cell in a row + /// should have this set. The first cell of the next row is actually + /// part of this row in raw input. + wrap: 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 preceeding + /// wide character (tail) or following (head). + wide_spacer_tail: bool = false, + wide_spacer_head: bool = false, + } = .{}, + + /// True if the cell should be skipped for drawing + pub fn empty(self: Cell) bool { + return self.char == 0; + } + + 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={}", .{@sizeOf(Cell)}); + try std.testing.expectEqual(16, @sizeOf(Cell)); + } +}; + +pub const RowIterator = struct { + screen: *const Screen, + tag: RowIndexTag, + value: usize = 0, + + pub fn next(self: *RowIterator) ?Row { + if (self.value >= self.tag.maxLen(self.screen)) return null; + const idx = self.tag.index(self.value); + const res = self.screen.getRow(idx); + self.value += 1; + return res; + } +}; + +/// 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, +}; + +/// 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 fn maxLen(self: RowIndexTag, screen: *const Screen) usize { + return switch (self) { + // The max of the screen is "bottom" so that we don't read + // past the pre-allocated space. + .screen => screen.bottom, + .viewport => screen.rows, + .active => screen.rows, + .history => screen.bottomOffset(), + }; + } + + /// 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 }, + }; + } +}; + +/// Each screen maintains its own cursor state. +cursor: Cursor = .{}, + +/// Saved cursor saved with DECSC (ESC 7). +saved_cursor: Cursor = .{}, + +/// The full list of rows, including any scrollback. +storage: []Cell, + +/// The top and bottom of the scroll area. The first visible row if the terminal +/// window were scrolled all the way to the top. The last visible row if the +/// terminal were scrolled all the way to the bottom. +top: usize, +bottom: usize, + +/// The offset of the visible area within the storage. This is from the +/// "top" field. So the actual index of the first row is +/// `storage[top + visible_offset]`. +visible_offset: 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 number of rows and columns in the visible space. +rows: usize, +cols: usize, + +/// Initialize a new screen. +pub fn init( + alloc: Allocator, + rows: usize, + cols: usize, + max_scrollback: usize, +) !Screen { + // Allocate enough storage to cover every row and column in the visible + // area. This wastes some up front memory but saves allocations later. + // TODO: dynamically allocate scrollback + const buf = try alloc.alloc(Cell, (rows + max_scrollback) * cols); + std.mem.set(Cell, buf, .{ .char = 0 }); + + return Screen{ + .cursor = .{}, + .storage = buf, + .top = 0, + .bottom = rows, + .visible_offset = 0, + .max_scrollback = max_scrollback, + .rows = rows, + .cols = cols, + }; +} + +pub fn deinit(self: *Screen, alloc: Allocator) void { + alloc.free(self.storage); + self.* = undefined; +} + +/// This returns true if the viewport is anchored at the bottom currently. +pub fn viewportIsBottom(self: Screen) bool { + return self.visible_offset == self.bottomOffset(); +} + +fn bottomOffset(self: Screen) usize { + return self.bottom - self.rows; +} + +/// 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: *const Screen, tag: RowIndexTag) RowIterator { + return .{ .screen = self, .tag = tag }; +} + +/// Region gets the contiguous portions of memory that constitute an +/// entire region. This is an efficient way to clear regions, for example +/// since you can memcpy directly into it. +/// +/// This has two elements because internally we use a ring buffer and +/// so any region can be split into two if it crosses the ring buffer +/// boundary. +pub fn region(self: *const Screen, tag: RowIndexTag) [2][]Cell { + const max_len = tag.maxLen(self); + if (max_len == 0) { + // This region is disabled or empty + return .{ self.storage[0..0], self.storage[0..0] }; + } + + const top = self.rowIndex(tag.index(0)); + const bot = self.rowIndex(tag.index(max_len - 1)); + + // The bottom and top are available in one contiguous slice. + if (bot >= top) { + return .{ + self.storage[top .. bot + self.cols], + self.storage[0..0], // just so its a valid slice, but zero length + }; + } + + // The bottom and top are split into two slices, so we slice to the + // bottom of the storage, then from the top. + return .{ + self.storage[top..self.storage.len], + self.storage[0 .. bot + self.cols], + }; +} + +/// Get a single row in the active area by index (0-indexed). +pub fn getRow(self: Screen, idx: RowIndex) Row { + // Get the index of the first byte of the the row at index. + const real_idx = self.rowIndex(idx); + + // The storage is sliced to return exactly the number of columns. + return self.storage[real_idx .. real_idx + self.cols]; +} + +/// Get a single cell in the active area. row and col are 0-indexed. +pub fn getCell(self: Screen, row: usize, col: usize) *Cell { + assert(row < self.rows); + assert(col < self.cols); + const row_idx = self.rowIndex(.{ .active = row }); + return &self.storage[row_idx + col]; +} + +/// Returns the index for the given row (0-indexed) into the underlying +/// storage array. The row is 0-indexed from the top of the screen. +fn rowIndex(self: *const Screen, idx: RowIndex) usize { + const y = switch (idx) { + .screen => |y| y: { + assert(y < RowIndexTag.screen.maxLen(self)); + break :y y; + }, + + .viewport => |y| y: { + assert(y < RowIndexTag.viewport.maxLen(self)); + break :y y + self.visible_offset; + }, + + .active => |y| y: { + assert(y < RowIndexTag.active.maxLen(self)); + break :y self.bottomOffset() + y; + }, + + .history => |y| y: { + assert(y < RowIndexTag.history.maxLen(self)); + break :y y; + }, + }; + + const val = (self.top + y) * self.cols; + if (val < self.storage.len) return val; + return val - self.storage.len; +} + +/// Returns the total number of rows in the screen. +inline fn totalRows(self: Screen) usize { + return self.storage.len / self.cols; +} + +/// 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". + delta: isize, + + /// Same as delta but scrolling down will not grow the scrollback. + /// Scrolling down at the bottom will do nothing (similar to how + /// delta at the top does nothing). + delta_no_grow: isize, +}; + +/// 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) void { + switch (behavior) { + // Setting viewport offset to zero makes row 0 be at self.top + // which is the top! + .top => self.visible_offset = 0, + + // Calc the bottom by going from top of scrollback (self.top) + // to the end of the storage, then subtract the number of visible + // rows. + .bottom => self.visible_offset = self.bottom - self.rows, + + // TODO: deltas greater than the entire scrollback + .delta => |delta| self.scrollDelta(delta, true), + .delta_no_grow => |delta| self.scrollDelta(delta, false), + } +} + +fn scrollDelta(self: *Screen, delta: isize, grow: bool) void { + // log.info("offsets before: top={} bottom={} visible={}", .{ + // self.top, + // self.bottom, + // self.visible_offset, + // }); + // defer { + // log.info("offsets after: top={} bottom={} visible={}", .{ + // self.top, + // self.bottom, + // self.visible_offset, + // }); + // } + + // If we're scrolling up, then we just subtract and we're done. + if (delta < 0) { + self.visible_offset -|= @intCast(usize, -delta); + return; + } + + // If we're scrolling down, we have more work to do beacuse we + // need to determine if we're overwriting our scrollback. + self.visible_offset +|= @intCast(usize, delta); + if (grow) { + self.bottom +|= @intCast(usize, delta); + } else { + // If we're not growing, then we want to ensure we don't scroll + // off the bottom. Calculate the number of rows we can see. If we + // can see less than the number of rows we have available, then scroll + // back a bit. + const visible_bottom = self.visible_offset + self.rows; + if (visible_bottom > self.bottom) { + self.visible_offset = self.bottom - self.rows; + + // We can also fast-track this case because we know we won't + // be overlapping at all so we can return immediately. + return; + } + } + + // TODO: can optimize scrollback = 0 + + // Determine if we need to clear rows. + assert(@mod(self.storage.len, self.cols) == 0); + const storage_rows = self.storage.len / self.cols; + const visible_zero = self.top + self.visible_offset; + const rows_overlapped = if (visible_zero >= storage_rows) overlap: { + // We're wrapping from the top of the visible area. In this + // scenario, we just check that we have enough space from + // our true visible top to zero. + const visible_top = visible_zero - storage_rows; + const rows_available = self.top - visible_top; + if (rows_available >= self.rows) return; + + // We overlap our missing rows + break :overlap self.rows - rows_available; + } else overlap: { + // First check: if we have enough space in the storage buffer + // FORWARD to accomodate all our rows, then we're fine. + const rows_forward = storage_rows - (self.top + self.visible_offset); + if (rows_forward >= self.rows) return; + + // Second check: if we have enough space PRIOR to zero when + // wrapped, then we're fine. + const rows_wrapped = self.rows - rows_forward; + if (rows_wrapped < self.top) return; + + // We need to clear the rows in the overlap and move the top + // of the scrollback buffer. + break :overlap rows_wrapped - self.top; + }; + + // If we are growing, then we clear the overlap and reset zero + if (grow) { + // Clear our overlap + const clear_start = self.top * self.cols; + const clear_end = clear_start + (rows_overlapped * self.cols); + std.mem.set(Cell, self.storage[clear_start..clear_end], .{ .char = 0 }); + + // Move to accomodate overlap. This deletes scrollback. + self.top = @mod(self.top + rows_overlapped, storage_rows); + + // The new bottom is right up against the new top since we're using + // the full buffer. The bottom is therefore the full size of the storage. + self.bottom = storage_rows; + } + + // Move back the number of overlapped + self.visible_offset -= rows_overlapped; +} + +/// Copy row at src to dst. +pub fn copyRow(self: *Screen, dst: usize, src: usize) void { + const src_row = self.getRow(.{ .active = src }); + const dst_row = self.getRow(.{ .active = dst }); + std.mem.copy(Cell, dst_row, src_row); +} + +/// 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, alloc: Allocator, rows: usize, cols: usize) !void { + defer { + assert(self.cursor.x < self.cols); + assert(self.cursor.y < self.rows); + assert(self.rows == rows); + assert(self.cols == cols); + } + + // If the rows increased, we alloc space for the new rows (w/ existing cols) + // and move the viewport such that the bottom is in view. + if (rows > self.rows) { + var storage = try alloc.alloc( + Cell, + (rows + self.max_scrollback) * self.cols, + ); + + // Copy our screen into the new storage area. Since we're growing + // rows, we know that the full buffer will fit so we copy it in + // order. + const reg = self.region(.screen); + std.mem.copy(Cell, storage, reg[0]); + std.mem.copy(Cell, storage[reg[0].len..], reg[1]); + std.mem.set(Cell, storage[reg[0].len + reg[1].len ..], .{ .char = 0 }); + + // Modify our storage, our lines have grown + alloc.free(self.storage); + self.storage = storage; + + // Fix our row count + self.rows = rows; + + // Store our visible offset so we can move our cursor accordingly. + const old_offset = self.visible_offset; + + // Top is now 0 because we reoriented the ring buffer to be ordered. + // Bottom must be at least "rows" since we always show at least that + // much in the viewport. + self.top = 0; + self.bottom = @maximum(rows, self.bottom); + self.scroll(.{ .bottom = {} }); + + // Move our cursor to account for the new rows. The old offset + // should always be bigger (or the same) than the new offset since + // we are adding rows. + self.cursor.y += old_offset - self.visible_offset; + } + + // 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 storage = try alloc.alloc( + Cell, + (self.rows + self.max_scrollback) * cols, + ); + std.mem.set(Cell, storage, .{ .char = 0 }); + + // 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.Viewport{ + .x = self.cursor.x, + .y = self.cursor.y, + }).toScreen(self); + + // Nothing can fail from this point forward (no "try" expressions) + // so replace our storage. We defer freeing the "old" value because + // we need to access the old screen to copy. + var old = self.*; + defer { + assert(old.storage.ptr != self.storage.ptr); + alloc.free(old.storage); + } + self.storage = storage; + self.cols = cols; + + // Whether we need to move the cursor or not + var new_cursor: ?point.ScreenPoint = null; + + // Iterate over the screen since we need to check for reflow. + var iter = old.rowIterator(.screen); + var y: usize = 0; + while (iter.next()) |row| { + // No matter what we copy this row + var new_row = self.getRow(.{ .screen = y }); + std.mem.copy(Cell, new_row, row); + + // 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) { + assert(new_cursor == null); // should only happen once + new_cursor = .{ .y = y, .x = cursor_pos.x }; + } + + // If no reflow, just keep going + if (!row[row.len - 1].attrs.wrap) { + 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[row.len - 1].attrs.wrap = false; + + // We maintain an x coord so that we can set cursors properly + var x: usize = row.len; + new_row = new_row[x..]; + wrapping: while (iter.next()) |wrapped_row| { + // Trim the row from the right so that we ignore all trailing + // empty chars and don't wrap them. + const trimmed_row = trim: { + var i: usize = wrapped_row.len; + while (i > 0) : (i -= 1) if (!wrapped_row[i - 1].empty()) break; + break :trim wrapped_row[0..i]; + }; + + var wrapped_rem = trimmed_row; + while (wrapped_rem.len > 0) { + // If the wrapped row fits nicely... + if (wrapped_rem.len <= new_row.len) { + // Copy the row + std.mem.copy(Cell, new_row, wrapped_rem); + + // If our cursor is in this line, then we have to move it + // onto the new line because it got unwrapped. + if (cursor_pos.y == iter.value - 1 and new_cursor == null) { + new_cursor = .{ .y = y, .x = cursor_pos.x + x }; + } + + // If this row isn't also wrapped, we're done! + if (!wrapped_rem[wrapped_rem.len - 1].attrs.wrap) { + y += 1; + + // If we were able to copy the entire row then + // we shortened the screen by one. We need to reflect + // this in our viewport. + if (wrapped_rem.len == trimmed_row.len and + self.visible_offset > 0) + { + self.visible_offset -= 1; + self.bottom -= 1; + } + + break :wrapping; + } + + // Wrapped again! + new_row[wrapped_rem.len - 1].attrs.wrap = false; + new_row = new_row[wrapped_rem.len..]; + x += wrapped_rem.len; + break; + } + + // The row doesn't fit, meaning we have to soft-wrap the + // new row but probably at a diff boundary. + std.mem.copy(Cell, new_row, wrapped_rem[0..new_row.len]); + new_row[new_row.len - 1].attrs.wrap = true; + + // We still need to copy the remainder + wrapped_rem = wrapped_rem[new_row.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 < new_row.len) + { + assert(new_cursor == null); // should only happen once + new_cursor = .{ .y = y, .x = x + cursor_pos.x }; + } + + // Move to a new line in our new screen + y += 1; + x = 0; + new_row = self.getRow(.{ .screen = y }); + } + } + } + + // 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; + } + } + + // If our rows got smaller, we trim the scrollback. + if (rows < self.rows) { + var storage = try alloc.alloc( + Cell, + (rows + self.max_scrollback) * self.cols, + ); + + // Get the slices for our full screen. We only copy the end of it + // that fits into our new memory region. We know we have the same + // number of columns in this block so we can just copy as-is. + const reg = self.region(.screen); + + // Trim the empty space off the end. The "end" might go into + // "top" since bottom may be empty or only implies the wraparound + // on the ring buffer. + const top = reg[0]; + const bot = reg[1]; + const bot_trimmed = trim: { + var i: usize = bot.len; + while (i > 0) : (i -= 1) if (!bot[i - 1].empty()) break; + i += self.cols - @mod(i, self.cols); + i = @minimum(bot.len, i); + break :trim bot[0..i]; + }; + const top_trimmed = if (bot.len > 0 and bot_trimmed.len == bot.len) noop: { + // We do nothing here because it means that we hit real content + // in the "bottom" so we don't want to trim zeros off the top + // when they might actually be useful. + break :noop top; + } else trim: { + var i: usize = top.len; + while (i > 0) : (i -= 1) if (!top[i - 1].empty()) break; + i += self.cols - @mod(i, self.cols); + i = @minimum(top.len, i); + break :trim top[0..i]; + }; + + // The trimmed also have to be cleanly divisible by rows since + // the copy and other math below depends on this invariant. + assert(@mod(bot_trimmed.len, self.cols) == 0); + assert(@mod(top_trimmed.len, self.cols) == 0); + + // Copy the top and bottom into the storage + const bot_len = @minimum(bot_trimmed.len, storage.len); + const top_len = @minimum(top_trimmed.len, storage.len - bot_len); + std.mem.copy(Cell, storage, top_trimmed[top_trimmed.len - top_len ..]); + std.mem.copy(Cell, storage[top_len..], bot_trimmed[bot_trimmed.len - bot_len ..]); + std.mem.set(Cell, storage[top_len + bot_len ..], .{ .char = 0 }); + + // Calculate the number of rows we copied since this will be + // our new "bottom". This should always divide cleanly because + // our cols haven't changed. + assert(@mod(top_len + bot_len, self.cols) == 0); + const copied_rows = (top_len + bot_len) / self.cols; + + // Modify our storage + alloc.free(self.storage); + self.storage = storage; + + // If our cursor was past the end of our old value, we pull it back. + if (self.cursor.y >= rows) { + self.cursor.y -= self.rows - rows; + } + + // Fix our row count + self.rows = rows; + + // Top is now 0 because we reoriented the ring buffer to be ordered. + // Bottom must be at least "rows" since we always show at least that + // much in the viewport. + self.top = 0; + self.bottom = @maximum(rows, copied_rows); + //log.warn("bot={} top={} copied={}", .{ bot_len, top_len, copied_rows }); + //log.warn("BOTTOM={}", .{self.bottom}); + self.scroll(.{ .bottom = {} }); + } + + // If our cols got smaller, we have to reflow text. This is the worst + // possible case because we can't do any easy trick sto get reflow, + // we just have to iterate over the screen and "print", wrapping as + // needed. + if (cols < self.cols) { + var storage = try alloc.alloc( + Cell, + (self.rows + self.max_scrollback) * cols, + ); + std.mem.set(Cell, storage, .{ .char = 0 }); + + // Convert our cursor coordinates to screen coordinates because + // we may have to reflow the cursor if the line it is on is moved. + var cursor_pos = (point.Viewport{ + .x = self.cursor.x, + .y = self.cursor.y, + }).toScreen(self); + + // Nothing can fail from this point forward (no "try" expressions) + // so replace our storage. We defer freeing the "old" value because + // we need to access the old screen to copy. + var old = self.*; + defer { + assert(old.storage.ptr != self.storage.ptr); + alloc.free(old.storage); + } + self.storage = storage; + self.cols = cols; + + // Whether we need to move the cursor or not + var new_cursor: ?point.ScreenPoint = null; + + // Iterate over the screen since we need to check for reflow. + var iter = old.rowIterator(.screen); + var x: usize = 0; + var y: usize = 0; + while (iter.next()) |row| { + // Trim the row from the right so that we ignore all trailing + // empty chars and don't wrap them. + const trimmed_row = trim: { + var i: usize = row.len; + while (i > 0) { + if (!row[i - 1].empty()) break; + i -= 1; + } + + break :trim row[0..i]; + }; + + // Copy all the cells into our row. + for (trimmed_row) |cell, i| { + // Soft wrap if we have to + if (x == self.cols) { + var last_cell = self.getCell(y, x - 1); + last_cell.attrs.wrap = true; + x = 0; + y += 1; + } + + // If our y is more than our rows, we need to scroll + if (y >= self.rows) { + self.scroll(.{ .delta = 1 }); + y = self.rows - 1; + x = 0; + } + + // If our cursor is on this point, we need to move it. + if (cursor_pos.y == iter.value - 1 and + cursor_pos.x == i) + { + assert(new_cursor == null); + new_cursor = .{ .x = x, .y = self.visible_offset + y }; + } + + // Copy the old cell, unset the old wrap state + // log.warn("y={} x={} rows={}", .{ y, x, self.rows }); + var new_cell = self.getCell(y, x); + new_cell.* = cell; + new_cell.attrs.wrap = false; + + // Next + x += 1; + } + + // If our cursor is on this line but not in a content area, + // then we just set it to be at the end. + if (cursor_pos.y == iter.value - 1 and + cursor_pos.x >= trimmed_row.len) + { + assert(new_cursor == null); + new_cursor = .{ + .x = @minimum(cursor_pos.x, self.cols - 1), + .y = self.visible_offset + y, + }; + } + + // If we aren't wrapping, then move to the next row + if (trimmed_row.len == 0 or + !trimmed_row[trimmed_row.len - 1].attrs.wrap) + { + y += 1; + x = 0; + } + } + + // 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; + } else { + // TODO: why is this necessary? Without this, neovim will + // crash when we shrink the window to the smallest size + self.cursor.x = @minimum(self.cursor.x, self.cols - 1); + self.cursor.y = @minimum(self.cursor.y, self.rows - 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, alloc: Allocator, rows: usize, cols: usize) !void { + // Resize without reflow not supported for now with scrollback. + assert(self.max_scrollback == 0); + + // Make a copy so we can access the old indexes. + const old = self.*; + + // Reallocate the storage + self.storage = try alloc.alloc(Cell, (rows + self.max_scrollback) * cols); + defer alloc.free(old.storage); + std.mem.set(Cell, self.storage, .{ .char = 0 }); + self.top = 0; + self.bottom = rows; + self.rows = rows; + self.cols = cols; + + // Move our cursor if we have to so it stays on the screen. + self.cursor.x = @minimum(self.cursor.x, self.cols - 1); + self.cursor.y = @minimum(self.cursor.y, self.rows - 1); + + // If we're increasing height, then copy all rows (start at 0). + // Otherwise start at the latest row that includes the bottom row, + // aka strip the top. + var y: usize = if (rows >= old.rows) 0 else old.rows - rows; + const start = y; + const col_end = @minimum(old.cols, cols); + while (y < old.rows) : (y += 1) { + // Copy the old row into the new row, just losing the columsn + // if we got thinner. + const old_row = old.getRow(.{ .viewport = y }); + const new_row = self.getRow(.{ .viewport = y - start }); + std.mem.copy(Cell, new_row, old_row[0..col_end]); + + // If our new row is wider, then we copy zeroes into the rest. + if (new_row.len > old_row.len) { + std.mem.set(Cell, new_row[old_row.len..], .{ .char = 0 }); + } + } + + // If we grew rows, then set the remaining data to zero. + if (rows > old.rows) { + std.mem.set(Cell, self.storage[self.rowIndex(.{ .viewport = old.rows })..], .{ .char = 0 }); + } +} + +/// Returns the raw text associated with a selection. This will unwrap +/// soft-wrapped edges. The returned slice is owned by the caller. +pub fn selectionString(self: Screen, alloc: Allocator, sel: Selection) ![:0]const u8 { + // Get the slices for the string + const slices = self.selectionSlices(sel); + + // We can now know how much space we'll need to store the string. We loop + // over and UTF8-encode and calculate the exact size required. We will be + // off here by at most "newlines" values in the worst case that every + // single line is soft-wrapped. + const newlines = @divFloor(slices.top.len + slices.bot.len, self.cols) + 1; + const chars = chars: { + var count: usize = 0; + const arr = [_][]Cell{ slices.top, slices.bot }; + for (arr) |slice| { + for (slice) |cell| { + var buf: [4]u8 = undefined; + const char = if (cell.char > 0) cell.char else ' '; + count += try std.unicode.utf8Encode(@intCast(u21, char), &buf); + } + } + + break :chars count; + }; + const buf = try alloc.alloc(u8, chars + newlines + 1); + errdefer alloc.free(buf); + + var i: usize = 0; + for (slices.top) |cell, idx| { + // If our index cleanly divides into the col count then we're + // at a newline and we add it. + if (idx > 0 and + @mod(idx + slices.top_offset, self.cols) == 0 and + !slices.top[idx - 1].attrs.wrap) + { + buf[i] = '\n'; + i += 1; + } + + // Skip spacers + if (cell.attrs.wide_spacer_head or + cell.attrs.wide_spacer_tail) continue; + + const char = if (cell.char > 0) cell.char else ' '; + i += try std.unicode.utf8Encode(@intCast(u21, char), buf[i..]); + } + + for (slices.bot) |cell, idx| { + // We don't use "top_offset" here because the bot by definition + // is never offset, it always starts at index 0 so we can just check + // the index directly. + if (@mod(idx, self.cols) == 0) { + // Determine if we soft-wrapped. For the bottom slice this is + // a bit unique because if we're at idx 0, we actually need to + // check the end of the top. + const wrapped = if (idx > 0) + slices.bot[idx - 1].attrs.wrap + else + slices.top[slices.top.len - 1].attrs.wrap; + + if (!wrapped) { + buf[i] = '\n'; + i += 1; + } + } + + // Skip spacers + if (cell.attrs.wide_spacer_head or + cell.attrs.wide_spacer_tail) continue; + + const char = if (cell.char > 0) cell.char else ' '; + i += try std.unicode.utf8Encode(@intCast(u21, char), buf[i..]); + } + + // Add null termination + buf[i] = 0; + + // Realloc so our free length is exactly correct + const result = try alloc.realloc(buf, i + 1); + return result[0..i :0]; +} + +/// 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) struct { + // 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, + top: []Cell, + bot: []Cell, +} { + // Note: this function is tested via selectionString + + assert(sel_raw.start.y < self.totalRows()); + assert(sel_raw.end.y < self.totalRows()); + assert(sel_raw.start.x < self.cols); + assert(sel_raw.end.x < self.cols); + + const sel = sel: { + var sel = sel_raw; + + // 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 }); + if (row[sel.end.x].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 }); + if (row[sel.start.x].attrs.wide_spacer_tail) { + sel.end.x -= 1; + } + } + + break :sel sel; + }; + + // Get the true "top" and "bottom" + const sel_top = sel.topLeft(); + const sel_bot = sel.bottomRight(); + const top = self.rowIndex(.{ .screen = sel_top.y }); + const bot = self.rowIndex(.{ .screen = sel_bot.y }); + + // The bottom and top are available in one contiguous slice. + if (bot >= top) { + return .{ + .top_offset = sel_top.x, + .top = self.storage[top + sel_top.x .. bot + sel_bot.x + 1], + .bot = self.storage[0..0], // just so its a valid slice, but zero length + }; + } + + // The bottom and top are split into two slices, so we slice to the + // bottom of the storage, then from the top. + return .{ + .top_offset = sel_top.x, + .top = self.storage[top + sel_top.x .. self.storage.len], + .bot = self.storage[0 .. bot + sel_bot.x + 1], + }; +} + +/// 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. +pub fn testString(self: Screen, alloc: Allocator, tag: RowIndexTag) ![]const u8 { + const buf = try alloc.alloc(u8, self.storage.len + self.rows + 1); + + var i: usize = 0; + var y: usize = 0; + var rows = self.rowIterator(tag); + while (rows.next()) |row| { + defer y += 1; + + if (y > 0) { + buf[i] = '\n'; + i += 1; + } + + for (row) |cell| { + // TODO: handle character after null + if (cell.char > 0) { + i += try std.unicode.utf8Encode(@intCast(u21, cell.char), buf[i..]); + } + } + } + + // Never render the final newline + const str = std.mem.trimRight(u8, buf[0..i], "\n"); + return try alloc.realloc(buf, str.len); +} + +/// 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. +pub fn testWriteString(self: *Screen, text: []const u8) void { + var y: usize = 0; + var x: usize = 0; + + 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; + continue; + } + + // If we're writing past the end of the active area, scroll. + if (y >= self.rows) { + y -= 1; + self.scroll(.{ .delta = 1 }); + } + + // Get our row + var row = self.getRow(.{ .active = y }); + + // If we're writing past the end, we need to soft wrap. + if (x == self.cols) { + row[x - 1].attrs.wrap = true; + y += 1; + x = 0; + if (y >= self.rows) { + y -= 1; + self.scroll(.{ .delta = 1 }); + } + row = self.getRow(.{ .active = y }); + } + + // If our character is double-width, handle it. + const width = utf8proc.charwidth(c); + assert(width == 1 or width == 2); + switch (width) { + 1 => row[x].char = @intCast(u32, c), + + 2 => { + if (x == self.cols - 1) { + row[x].char = ' '; + row[x].attrs.wide_spacer_head = true; + + // wrap + row[x].attrs.wrap = true; + y += 1; + x = 0; + if (y >= self.rows) { + y -= 1; + self.scroll(.{ .delta = 1 }); + } + row = self.getRow(.{ .active = y }); + } + + row[x].char = @intCast(u32, c); + row[x].attrs.wide = true; + + x += 1; + row[x].char = ' '; + row[x].attrs.wide_spacer_tail = true; + }, + + else => unreachable, + } + + x += 1; + } +} + +test "Screen" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 3, 5, 0); + defer s.deinit(alloc); + + // Sanity check that our test helpers work + const str = "1ABCD\n2EFGH\n3IJKL"; + s.testWriteString(str); + { + var 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.ptr, row_other.ptr); + count += 1; + } + + // Should go through all rows + try testing.expectEqual(@as(usize, 3), count); + + // Should be able to easily clear screen + const reg = s.region(.viewport); + std.mem.set(Cell, reg[0], .{ .char = 'A' }); + std.mem.set(Cell, reg[1], .{ .char = 'A' }); + { + var contents = try s.testString(alloc, .screen); + defer alloc.free(contents); + try testing.expectEqualStrings("AAAAA\nAAAAA\nAAAAA", contents); + } +} + +test "Screen: scrolling" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 3, 5, 0); + defer s.deinit(alloc); + s.testWriteString("1ABCD\n2EFGH\n3IJKL"); + + try testing.expect(s.viewportIsBottom()); + + // Scroll down, should still be bottom + s.scroll(.{ .delta = 1 }); + try testing.expect(s.viewportIsBottom()); + + // Test our row index + try testing.expectEqual(@as(usize, 5), s.rowIndex(.{ .active = 0 })); + try testing.expectEqual(@as(usize, 10), s.rowIndex(.{ .active = 1 })); + try testing.expectEqual(@as(usize, 0), s.rowIndex(.{ .active = 2 })); + + { + // Test our contents rotated + var contents = try s.testString(alloc, .viewport); + defer alloc.free(contents); + try testing.expectEqualStrings("2EFGH\n3IJKL", contents); + } + + // Scrolling to the bottom does nothing + s.scroll(.{ .bottom = {} }); + + { + // Test our contents rotated + var contents = try s.testString(alloc, .viewport); + defer alloc.free(contents); + try testing.expectEqualStrings("2EFGH\n3IJKL", contents); + } +} + +// TODO +// test "Screen: scrolling more than size" { +// const testing = std.testing; +// const alloc = testing.allocator; +// +// var s = try init(alloc, 3, 5, 3); +// defer s.deinit(alloc); +// s.testWriteString("1ABCD\n2EFGH\n3IJKL"); +// +// try testing.expect(s.viewportIsBottom()); +// +// // Scroll down, should still be bottom +// s.scroll(.{ .delta = 7 }); +// try testing.expect(s.viewportIsBottom()); +// +// // Test our row index +// try testing.expectEqual(@as(usize, 5), s.rowIndex(0)); +// try testing.expectEqual(@as(usize, 10), s.rowIndex(1)); +// try testing.expectEqual(@as(usize, 15), s.rowIndex(2)); +// } + +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(alloc); + s.testWriteString("1ABCD\n2EFGH\n3IJKL"); + s.scroll(.{ .delta = -1 }); + try testing.expect(s.viewportIsBottom()); + + { + // Test our contents rotated + var 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(alloc); + s.testWriteString("1ABCD\n2EFGH\n3IJKL"); + s.scroll(.{ .delta = 1 }); + + // Test our row index + try testing.expectEqual(@as(usize, 5), s.rowIndex(.{ .active = 0 })); + try testing.expectEqual(@as(usize, 10), s.rowIndex(.{ .active = 1 })); + try testing.expectEqual(@as(usize, 15), s.rowIndex(.{ .active = 2 })); + + { + // Test our contents rotated + var contents = try s.testString(alloc, .viewport); + defer alloc.free(contents); + try testing.expectEqualStrings("2EFGH\n3IJKL", contents); + } + + // Scrolling to the bottom + s.scroll(.{ .bottom = {} }); + try testing.expect(s.viewportIsBottom()); + + { + // Test our contents rotated + var contents = try s.testString(alloc, .viewport); + defer alloc.free(contents); + try testing.expectEqualStrings("2EFGH\n3IJKL", contents); + } + + // Scrolling back should make it visible again + s.scroll(.{ .delta = -1 }); + try testing.expect(!s.viewportIsBottom()); + + { + // Test our contents rotated + var contents = try s.testString(alloc, .viewport); + defer alloc.free(contents); + try testing.expectEqualStrings("1ABCD\n2EFGH\n3IJKL", contents); + } + + // Scrolling back again should do nothing + s.scroll(.{ .delta = -1 }); + + { + // Test our contents rotated + var contents = try s.testString(alloc, .viewport); + defer alloc.free(contents); + try testing.expectEqualStrings("1ABCD\n2EFGH\n3IJKL", contents); + } + + // Scrolling to the bottom + s.scroll(.{ .bottom = {} }); + + { + // Test our contents rotated + var contents = try s.testString(alloc, .viewport); + defer alloc.free(contents); + try testing.expectEqualStrings("2EFGH\n3IJKL", contents); + } + + // Scrolling forward with no grow should do nothing + s.scroll(.{ .delta_no_grow = 1 }); + + { + // Test our contents rotated + var contents = try s.testString(alloc, .viewport); + defer alloc.free(contents); + try testing.expectEqualStrings("2EFGH\n3IJKL", contents); + } + + // Scrolling to the top should work + s.scroll(.{ .top = {} }); + + { + // Test our contents rotated + var 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 + const reg = s.region(.active); + std.mem.set(Cell, reg[0], .{ .char = 0 }); + std.mem.set(Cell, reg[1], .{ .char = 0 }); + { + var contents = try s.testString(alloc, .viewport); + defer alloc.free(contents); + try testing.expectEqualStrings("1ABCD", contents); + } + + // Scrolling to the bottom + s.scroll(.{ .bottom = {} }); + + { + // Test our contents rotated + var contents = try s.testString(alloc, .viewport); + defer alloc.free(contents); + try testing.expectEqualStrings("", contents); + } +} + +test "Screen: scrollback empty" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 3, 5, 50); + defer s.deinit(alloc); + s.testWriteString("1ABCD\n2EFGH\n3IJKL"); + s.scroll(.{ .delta_no_grow = 1 }); + + { + // Test our contents + var contents = try s.testString(alloc, .viewport); + defer alloc.free(contents); + try testing.expectEqualStrings("1ABCD\n2EFGH\n3IJKL", contents); + } +} + +test "Screen: history region with scrollback" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 1, 5, 0); + defer s.deinit(alloc); + + // Write a bunch that WOULD invoke scrollback if exists + const str = "1ABCD\n2EFGH\n3IJKL"; + s.testWriteString(str); + { + var contents = try s.testString(alloc, .screen); + defer alloc.free(contents); + const expected = "3IJKL"; + try testing.expectEqualStrings(expected, contents); + } + + // Verify no scrollback + const reg = s.region(.history); + try testing.expect(reg[0].len == 0); + try testing.expect(reg[1].len == 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(alloc); + + // Write a bunch that WOULD invoke scrollback if exists + const str = "1ABCD\n2EFGH\n3IJKL"; + s.testWriteString(str); + { + var contents = try s.testString(alloc, .viewport); + defer alloc.free(contents); + const expected = "3IJKL"; + try testing.expectEqualStrings(expected, contents); + } + { + // Test our contents + var contents = try s.testString(alloc, .screen); + defer alloc.free(contents); + try testing.expectEqualStrings("1ABCD\n2EFGH\n3IJKL", contents); + } + + // Verify history region + const reg = s.region(.history); + try testing.expect(reg[0].len > 0); + try testing.expect(reg[1].len >= 0); + + { + var 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(alloc); + s.testWriteString("1ABCD\n2EFGH\n3IJKL"); + + // Copy + s.scroll(.{ .delta = 1 }); + s.copyRow(2, 0); + + // Test our contents + var contents = try s.testString(alloc, .viewport); + defer alloc.free(contents); + try testing.expectEqualStrings("2EFGH\n3IJKL\n2EFGH", contents); +} + +test "Screen: selectionString" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 3, 5, 0); + defer s.deinit(alloc); + const str = "1ABCD\n2EFGH\n3IJKL"; + s.testWriteString(str); + + { + var contents = try s.selectionString(alloc, .{ + .start = .{ .x = 0, .y = 1 }, + .end = .{ .x = 2, .y = 2 }, + }); + defer alloc.free(contents); + const expected = "2EFGH\n3IJ"; + 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(alloc); + const str = "1ABCD2EFGH3IJKL"; + s.testWriteString(str); + + { + var contents = try s.selectionString(alloc, .{ + .start = .{ .x = 0, .y = 1 }, + .end = .{ .x = 2, .y = 2 }, + }); + 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(alloc); + 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. + s.scroll(.{ .delta = 1 }); + try testing.expect(s.viewportIsBottom()); + try testing.expectEqual(@as(usize, 0), s.rowIndex(.{ .active = 2 })); + s.testWriteString("1ABCD\n2EFGH\n3IJKL"); + + { + var contents = try s.selectionString(alloc, .{ + .start = .{ .x = 0, .y = 1 }, + .end = .{ .x = 2, .y = 2 }, + }); + 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(alloc); + const str = "1A⚡"; + s.testWriteString(str); + + { + var contents = try s.selectionString(alloc, .{ + .start = .{ .x = 0, .y = 0 }, + .end = .{ .x = 3, .y = 0 }, + }); + defer alloc.free(contents); + const expected = str; + try testing.expectEqualStrings(expected, contents); + } + + { + var contents = try s.selectionString(alloc, .{ + .start = .{ .x = 0, .y = 0 }, + .end = .{ .x = 2, .y = 0 }, + }); + defer alloc.free(contents); + const expected = str; + try testing.expectEqualStrings(expected, contents); + } + + { + var contents = try s.selectionString(alloc, .{ + .start = .{ .x = 3, .y = 0 }, + .end = .{ .x = 3, .y = 0 }, + }); + 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(alloc); + const str = "1ABC⚡"; + s.testWriteString(str); + + { + var contents = try s.selectionString(alloc, .{ + .start = .{ .x = 0, .y = 0 }, + .end = .{ .x = 4, .y = 0 }, + }); + defer alloc.free(contents); + const expected = str; + try testing.expectEqualStrings(expected, contents); + } +} + +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(alloc); + const str = "1ABCD\n2EFGH\n3IJKL"; + s.testWriteString(str); + const cursor = s.cursor; + try s.resize(alloc, 10, 5); + + // Cursor should not move + try testing.expectEqual(cursor, s.cursor); + + { + var contents = try s.testString(alloc, .viewport); + defer alloc.free(contents); + try testing.expectEqualStrings(str, contents); + } + { + var 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(alloc); + const str = "1ABCD\n2EFGH\n3IJKL"; + s.testWriteString(str); + const cursor = s.cursor; + try s.resize(alloc, 10, 5); + try testing.expectEqual(@as(usize, 20), s.totalRows()); + + // Cursor should not move + try testing.expectEqual(cursor, s.cursor); + + { + var contents = try s.testString(alloc, .viewport); + defer alloc.free(contents); + try testing.expectEqualStrings(str, contents); + } + { + var 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(alloc); + const str = "1ABCD\n2EFGH\n3IJKL\n4ABCD\n5EFGH"; + s.testWriteString(str); + { + var 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(s.cursor.y, s.cursor.x).char); + + // Resize + try s.resize(alloc, 10, 5); + try testing.expectEqual(@as(usize, 15), s.totalRows()); + + // Cursor should still be on the "4" + try testing.expectEqual(@as(u32, '4'), s.getCell(s.cursor.y, s.cursor.x).char); + // s.cursor.x = 0; + // s.cursor.y = 1; + //try testing.expectEqual(cursor, s.cursor); + + { + var contents = try s.testString(alloc, .viewport); + 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(alloc); + const str = "1ABCD\n2EFGH\n3IJKL"; + s.testWriteString(str); + const cursor = s.cursor; + try s.resize(alloc, 3, 10); + + // Cursor should not move + try testing.expectEqual(cursor, s.cursor); + + { + var contents = try s.testString(alloc, .viewport); + defer alloc.free(contents); + try testing.expectEqualStrings(str, contents); + } + { + var contents = try s.testString(alloc, .screen); + defer alloc.free(contents); + try testing.expectEqualStrings(str, 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(alloc); + const str = "1ABCD2EFGH\n3IJKL"; + s.testWriteString(str); + + // Verify we soft wrapped + { + var 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(s.cursor.y, s.cursor.x).char); + + // Resize and verify we undid the soft wrap because we have space now + try s.resize(alloc, 3, 10); + { + var 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(alloc); + const str = "1ABCD2EFGH\n3IJKL"; + s.testWriteString(str); + + // Verify we soft wrapped + { + var 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(s.cursor.y, s.cursor.x).char); + + // Resize and verify we undid the soft wrap because we have space now + try s.resize(alloc, 3, 10); + { + var 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(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(alloc); + const str = "1ABCD2EFGH\n3IJKL"; + 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(s.cursor.y, s.cursor.x).char); + + // Verify we soft wrapped + { + var 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(alloc, 3, 7); + { + var 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(alloc); + const str = "1ABCD2EFGH3IJKL"; + 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(s.cursor.y, s.cursor.x).char); + + // Verify we soft wrapped + { + var 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(alloc, 3, 15); + { + var 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(alloc); + const str = "1ABCD\n2EFGH\n3IJKL\n4ABCD5EFGH"; + s.testWriteString(str); + { + var 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(s.cursor.y, s.cursor.x).char); + + // Resize + try s.resize(alloc, 3, 10); + + // Cursor should still be on the "5" + log.warn("cursor={}", .{s.cursor}); + try testing.expectEqual(@as(u32, '5'), s.getCell(s.cursor.y, s.cursor.x).char); + + { + var contents = try s.testString(alloc, .viewport); + defer alloc.free(contents); + const expected = "2EFGH\n3IJKL\n4ABCD5EFGH"; + try testing.expectEqualStrings(expected, contents); + } +} + +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(alloc); + const str = "1ABCD\n2EFGH\n3IJKL"; + s.testWriteString(str); + const cursor = s.cursor; + try s.resize(alloc, 1, 5); + + // Cursor should not move + try testing.expectEqual(cursor, s.cursor); + + { + var contents = try s.testString(alloc, .viewport); + defer alloc.free(contents); + const expected = "3IJKL"; + try testing.expectEqualStrings(expected, contents); + } + { + var 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(alloc); + const str = "1ABCD\n2EFGH\n3IJKL"; + 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(s.cursor.y, s.cursor.x).char); + + // Resize + try s.resize(alloc, 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); + + { + var contents = try s.testString(alloc, .viewport); + defer alloc.free(contents); + const expected = "3IJKL"; + try testing.expectEqualStrings(expected, contents); + } + { + var 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(alloc); + const str = "1ABCD\n2EFGH\n3IJKL"; + s.testWriteString(str); + try s.resize(alloc, 1, 5); + + { + var contents = try s.testString(alloc, .screen); + defer alloc.free(contents); + try testing.expectEqualStrings(str, contents); + } + { + var 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(alloc); + const str = "1ABCD\n2EFGH\n3IJKL\n4ABCD\n5EFGH"; + s.testWriteString(str); + { + var contents = try s.testString(alloc, .viewport); + defer alloc.free(contents); + const expected = "3IJKL\n4ABCD\n5EFGH"; + try testing.expectEqualStrings(expected, contents); + } + + // Resize + try s.resize(alloc, 1, 5); + + { + var contents = try s.testString(alloc, .screen); + defer alloc.free(contents); + try testing.expectEqualStrings(str, contents); + } + { + var contents = try s.testString(alloc, .viewport); + defer alloc.free(contents); + const expected = "5EFGH"; + 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(alloc); + const str = "1AB\n2EF\n3IJ"; + s.testWriteString(str); + const cursor = s.cursor; + try s.resize(alloc, 3, 3); + + // Cursor should not move + try testing.expectEqual(cursor, s.cursor); + + { + var contents = try s.testString(alloc, .viewport); + defer alloc.free(contents); + try testing.expectEqualStrings(str, contents); + } + { + var contents = try s.testString(alloc, .screen); + defer alloc.free(contents); + try testing.expectEqualStrings(str, contents); + } +} + +test "Screen: resize less cols with reflow but row space" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 3, 5, 0); + defer s.deinit(alloc); + const str = "1ABCD"; + 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(s.cursor.y, s.cursor.x).char); + + try s.resize(alloc, 3, 3); + { + var contents = try s.testString(alloc, .viewport); + defer alloc.free(contents); + const expected = "1AB\nCD"; + try testing.expectEqualStrings(expected, contents); + } + { + var 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(alloc); + const str = "3IJKL\n4ABCD\n5EFGH"; + s.testWriteString(str); + try s.resize(alloc, 3, 3); + + { + var contents = try s.testString(alloc, .viewport); + defer alloc.free(contents); + const expected = "CD\n5EF\nGH"; + try testing.expectEqualStrings(expected, contents); + } + { + var 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(alloc); + const str = "3IJKL\n4ABCD\n5EFGH"; + s.testWriteString(str); + try s.resize(alloc, 3, 3); + + { + var contents = try s.testString(alloc, .viewport); + defer alloc.free(contents); + const expected = "CD\n5EF\nGH"; + try testing.expectEqualStrings(expected, contents); + } + { + var contents = try s.testString(alloc, .screen); + defer alloc.free(contents); + const expected = "4AB\nCD\n5EF\nGH"; + 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(alloc); + const str = "1ABC"; + s.testWriteString(str); + + // Grow + try s.resize(alloc, 10, 5); + { + var contents = try s.testString(alloc, .viewport); + defer alloc.free(contents); + try testing.expectEqualStrings(str, contents); + } + { + var contents = try s.testString(alloc, .screen); + defer alloc.free(contents); + try testing.expectEqualStrings(str, contents); + } + + // Shrink + try s.resize(alloc, 3, 5); + { + var contents = try s.testString(alloc, .screen); + defer alloc.free(contents); + try testing.expectEqualStrings(str, contents); + } + { + var contents = try s.testString(alloc, .viewport); + defer alloc.free(contents); + try testing.expectEqualStrings(str, contents); + } + + // Grow again + try s.resize(alloc, 10, 5); + { + var contents = try s.testString(alloc, .viewport); + defer alloc.free(contents); + try testing.expectEqualStrings(str, contents); + } + { + var contents = try s.testString(alloc, .screen); + defer alloc.free(contents); + try testing.expectEqualStrings(str, contents); + } +} + +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(alloc); + const str = "1ABCD\n2EFGH\n3IJKL"; + s.testWriteString(str); + try s.resizeWithoutReflow(alloc, 10, 5); + + { + var contents = try s.testString(alloc, .viewport); + defer alloc.free(contents); + try testing.expectEqualStrings(str, contents); + } +} + +test "Screen: resize (no reflow) less rows" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 3, 5, 0); + defer s.deinit(alloc); + const str = "1ABCD\n2EFGH\n3IJKL"; + s.testWriteString(str); + try s.resizeWithoutReflow(alloc, 2, 5); + + { + var contents = try s.testString(alloc, .viewport); + defer alloc.free(contents); + try testing.expectEqualStrings("2EFGH\n3IJKL", 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(alloc); + const str = "1ABCD\n2EFGH\n3IJKL"; + s.testWriteString(str); + try s.resizeWithoutReflow(alloc, 3, 10); + + { + var 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(alloc); + const str = "1ABCD\n2EFGH\n3IJKL"; + s.testWriteString(str); + try s.resizeWithoutReflow(alloc, 3, 4); + + { + var contents = try s.testString(alloc, .viewport); + defer alloc.free(contents); + const expected = "1ABC\n2EFG\n3IJK"; + try testing.expectEqualStrings(expected, contents); + } +} diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index 5dfcbe9d5..8b15ef055 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -149,8 +149,8 @@ pub fn init(alloc: Allocator, cols: usize, rows: usize) !Terminal { pub fn deinit(self: *Terminal, alloc: Allocator) void { self.tabstops.deinit(alloc); - self.screen.deinit(alloc); - self.secondary_screen.deinit(alloc); + self.screen.deinit(); + self.secondary_screen.deinit(); self.* = undefined; } @@ -286,11 +286,11 @@ pub fn resize(self: *Terminal, alloc: Allocator, cols_req: usize, rows: usize) ! // If we're making the screen smaller, dealloc the unused items. if (self.active_screen == .primary) { - try self.screen.resize(alloc, rows, cols); - try self.secondary_screen.resizeWithoutReflow(alloc, rows, cols); + try self.screen.resize(rows, cols); + try self.secondary_screen.resizeWithoutReflow(rows, cols); } else { - try self.screen.resizeWithoutReflow(alloc, rows, cols); - try self.secondary_screen.resize(alloc, rows, cols); + try self.screen.resizeWithoutReflow(rows, cols); + try self.secondary_screen.resize(rows, cols); } // Set our size @@ -308,7 +308,7 @@ pub fn resize(self: *Terminal, alloc: Allocator, cols_req: usize, rows: usize) ! /// encoded as "\n". This omits any formatting such as fg/bg. /// /// The caller must free the string. -pub fn plainString(self: Terminal, alloc: Allocator) ![]const u8 { +pub fn plainString(self: *Terminal, alloc: Allocator) ![]const u8 { return try self.screen.testString(alloc, .viewport); } @@ -336,8 +336,8 @@ pub fn setAttribute(self: *Terminal, attr: sgr.Attribute) !void { switch (attr) { .unset => { - self.screen.cursor.pen.fg = null; - self.screen.cursor.pen.bg = null; + self.screen.cursor.pen.attrs.has_fg = false; + self.screen.cursor.pen.attrs.has_bg = false; self.screen.cursor.pen.attrs = .{}; }, @@ -362,6 +362,7 @@ pub fn setAttribute(self: *Terminal, attr: sgr.Attribute) !void { }, .direct_color_fg => |rgb| { + self.screen.cursor.pen.attrs.has_fg = true; self.screen.cursor.pen.fg = .{ .r = rgb.r, .g = rgb.g, @@ -370,6 +371,7 @@ pub fn setAttribute(self: *Terminal, attr: sgr.Attribute) !void { }, .direct_color_bg => |rgb| { + self.screen.cursor.pen.attrs.has_bg = true; self.screen.cursor.pen.bg = .{ .r = rgb.r, .g = rgb.g, @@ -377,21 +379,39 @@ pub fn setAttribute(self: *Terminal, attr: sgr.Attribute) !void { }; }, - .@"8_fg" => |n| self.screen.cursor.pen.fg = color.default[@enumToInt(n)], + .@"8_fg" => |n| { + self.screen.cursor.pen.attrs.has_fg = true; + self.screen.cursor.pen.fg = color.default[@enumToInt(n)]; + }, - .@"8_bg" => |n| self.screen.cursor.pen.bg = color.default[@enumToInt(n)], + .@"8_bg" => |n| { + self.screen.cursor.pen.attrs.has_bg = true; + self.screen.cursor.pen.bg = color.default[@enumToInt(n)]; + }, - .reset_fg => self.screen.cursor.pen.fg = null, + .reset_fg => self.screen.cursor.pen.attrs.has_fg = false, - .reset_bg => self.screen.cursor.pen.bg = null, + .reset_bg => self.screen.cursor.pen.attrs.has_bg = false, - .@"8_bright_fg" => |n| self.screen.cursor.pen.fg = color.default[@enumToInt(n)], + .@"8_bright_fg" => |n| { + self.screen.cursor.pen.attrs.has_fg = true; + self.screen.cursor.pen.fg = color.default[@enumToInt(n)]; + }, - .@"8_bright_bg" => |n| self.screen.cursor.pen.bg = color.default[@enumToInt(n)], + .@"8_bright_bg" => |n| { + self.screen.cursor.pen.attrs.has_bg = true; + self.screen.cursor.pen.bg = color.default[@enumToInt(n)]; + }, - .@"256_fg" => |idx| self.screen.cursor.pen.fg = color.default[idx], + .@"256_fg" => |idx| { + self.screen.cursor.pen.attrs.has_fg = true; + self.screen.cursor.pen.fg = color.default[idx]; + }, - .@"256_bg" => |idx| self.screen.cursor.pen.bg = color.default[idx], + .@"256_bg" => |idx| { + self.screen.cursor.pen.attrs.has_bg = true; + self.screen.cursor.pen.bg = color.default[idx]; + }, else => return error.InvalidAttribute, } @@ -440,7 +460,7 @@ pub fn print(self: *Terminal, c: u21) !void { // If we're soft-wrapping, then handle that first. if (self.screen.cursor.pending_wrap and self.modes.autowrap) - _ = self.printWrap(); + try self.printWrap(); switch (width) { // Single cell is very easy: just write in the cell @@ -457,7 +477,7 @@ pub fn print(self: *Terminal, c: u21) !void { if (self.screen.cursor.x == self.cols - 1) { const spacer_head = self.printCell(' '); spacer_head.attrs.wide_spacer_head = true; - _ = self.printWrap(); + try self.printWrap(); } const wide_cell = self.printCell(c); @@ -503,10 +523,8 @@ fn printCell(self: *Terminal, unmapped_c: u21) *Screen.Cell { break :c @intCast(u21, table[@intCast(u8, unmapped_c)]); }; - const cell = self.screen.getCell( - self.screen.cursor.y, - self.screen.cursor.x, - ); + const row = self.screen.getRow(.{ .active = self.screen.cursor.y }); + const cell = row.getCellPtr(self.screen.cursor.x); // If this cell is wide char then we need to clear it. // We ignore wide spacer HEADS because we can just write @@ -515,7 +533,7 @@ fn printCell(self: *Terminal, unmapped_c: u21) *Screen.Cell { const x = self.screen.cursor.x + 1; assert(x < self.cols); - const spacer_cell = self.screen.getCell(self.screen.cursor.y, x); + const spacer_cell = row.getCellPtr(x); spacer_cell.attrs.wide_spacer_tail = false; if (self.screen.cursor.x <= 1) { @@ -525,7 +543,7 @@ fn printCell(self: *Terminal, unmapped_c: u21) *Screen.Cell { assert(self.screen.cursor.x > 0); const x = self.screen.cursor.x - 1; - const wide_cell = self.screen.getCell(self.screen.cursor.y, x); + const wide_cell = row.getCellPtr(x); wide_cell.attrs.wide = false; if (self.screen.cursor.x <= 1) { @@ -539,26 +557,20 @@ fn printCell(self: *Terminal, unmapped_c: u21) *Screen.Cell { return cell; } -fn printWrap(self: *Terminal) *Screen.Cell { - // Mark that the cell is wrapped, which guarantees that there is - // at least one cell after it in the next row. - const cell = self.screen.getCell( - self.screen.cursor.y, - self.screen.cursor.x, - ); - cell.attrs.wrap = true; +fn printWrap(self: *Terminal) !void { + const row = self.screen.getRow(.{ .active = self.screen.cursor.y }); + row.setWrapped(true); // Move to the next line - self.index(); + try self.index(); self.screen.cursor.x = 0; - - return cell; } fn clearWideSpacerHead(self: *Terminal) void { // TODO: handle deleting wide char on row 0 of active assert(self.screen.cursor.y >= 1); - const cell = self.screen.getCell( + const cell = self.screen.getCellPtr( + .active, self.screen.cursor.y - 1, self.cols - 1, ); @@ -578,14 +590,11 @@ pub fn decaln(self: *Terminal) void { // Fill with Es, does not move cursor. We reset fg/bg so we can just // optimize here by doing row copies. const filled = self.screen.getRow(.{ .active = 0 }); - var col: usize = 0; - while (col < self.cols) : (col += 1) { - filled[col] = .{ .char = 'E' }; - } + filled.fill(.{ .char = 'E' }); var row: usize = 1; while (row < self.rows) : (row += 1) { - std.mem.copy(Screen.Cell, self.screen.getRow(.{ .active = row }), filled); + self.screen.getRow(.{ .active = row }).copyRow(filled); } } @@ -601,7 +610,7 @@ pub fn decaln(self: *Terminal) void { /// move the cursor one line down /// /// This unsets the pending wrap state without wrapping. -pub fn index(self: *Terminal) void { +pub fn index(self: *Terminal) !void { const tracy = trace(@src()); defer tracy.end(); @@ -625,7 +634,7 @@ pub fn index(self: *Terminal) void { if (self.scrolling_region.top == 0 and self.scrolling_region.bottom == self.rows - 1) { - self.screen.scroll(.{ .delta = 1 }); + try self.screen.scroll(.{ .delta = 1 }); } else { // TODO: test self.scrollUp(1); @@ -736,9 +745,8 @@ pub fn eraseDisplay( switch (mode) { .complete => { - const region = self.screen.region(.active); - std.mem.set(Screen.Cell, region[0], self.screen.cursor.pen); - std.mem.set(Screen.Cell, region[1], self.screen.cursor.pen); + var it = self.screen.rowIterator(.active); + while (it.next()) |row| row.clear(self.screen.cursor.pen); // Unsets pending wrap state self.screen.cursor.pending_wrap = false; @@ -748,7 +756,7 @@ pub fn eraseDisplay( // All lines to the right (including the cursor) var x: usize = self.screen.cursor.x; while (x < self.cols) : (x += 1) { - const cell = self.getOrPutCell(x, self.screen.cursor.y); + const cell = self.screen.getCellPtr(.active, self.screen.cursor.y, x); cell.* = self.screen.cursor.pen; cell.char = 0; } @@ -758,7 +766,7 @@ pub fn eraseDisplay( while (y < self.rows) : (y += 1) { x = 0; while (x < self.cols) : (x += 1) { - const cell = self.getOrPutCell(x, y); + const cell = self.screen.getCellPtr(.active, y, x); cell.* = self.screen.cursor.pen; cell.char = 0; } @@ -772,7 +780,7 @@ pub fn eraseDisplay( // Erase to the left (including the cursor) var x: usize = 0; while (x <= self.screen.cursor.x) : (x += 1) { - const cell = self.getOrPutCell(x, self.screen.cursor.y); + const cell = self.screen.getCellPtr(.active, self.screen.cursor.y, x); cell.* = self.screen.cursor.pen; cell.char = 0; } @@ -782,7 +790,7 @@ pub fn eraseDisplay( while (y < self.screen.cursor.y) : (y += 1) { x = 0; while (x < self.cols) : (x += 1) { - const cell = self.getOrPutCell(x, y); + const cell = self.screen.getCellPtr(.active, y, x); cell.* = self.screen.cursor.pen; cell.char = 0; } @@ -793,14 +801,9 @@ pub fn eraseDisplay( }, .scrollback => { - const region = self.screen.region(.history); - std.mem.set(Screen.Cell, region[0], self.screen.cursor.pen); - std.mem.set(Screen.Cell, region[1], self.screen.cursor.pen); - - // TODO: move this logic to the Screen implementation - self.screen.top = self.screen.visible_offset; - self.screen.bottom = self.screen.bottom - self.screen.visible_offset; - self.screen.visible_offset = 0; + var it = self.screen.rowIterator(.history); + while (it.next()) |row| row.clear(self.screen.cursor.pen); + @panic("MOVE TO SCREEN SO CIRC BUF IS CORRECT"); }, } } @@ -817,12 +820,12 @@ pub fn eraseLine( switch (mode) { .right => { const row = self.screen.getRow(.{ .active = self.screen.cursor.y }); - std.mem.set(Screen.Cell, row[self.screen.cursor.x..], self.screen.cursor.pen); + row.fillSlice(self.screen.cursor.pen, self.screen.cursor.x, self.cols); }, .left => { const row = self.screen.getRow(.{ .active = self.screen.cursor.y }); - std.mem.set(Screen.Cell, row[0 .. self.screen.cursor.x + 1], self.screen.cursor.pen); + row.fillSlice(self.screen.cursor.pen, 0, self.screen.cursor.x + 1); // Unsets pending wrap state self.screen.cursor.pending_wrap = false; @@ -830,7 +833,7 @@ pub fn eraseLine( .complete => { const row = self.screen.getRow(.{ .active = self.screen.cursor.y }); - std.mem.set(Screen.Cell, row, self.screen.cursor.pen); + row.fill(self.screen.cursor.pen); }, else => { @@ -864,8 +867,9 @@ pub fn deleteChars(self: *Terminal, count: usize) !void { var i: usize = self.screen.cursor.x; while (i < end) : (i += 1) { const j = i + count; - line[i] = line[j]; - line[j].char = 0; + const j_cell = line.getCellPtr(j); + line.getCellPtr(i).* = j_cell.*; + j_cell.char = 0; } } @@ -879,12 +883,10 @@ pub fn eraseChars(self: *Terminal, count: usize) void { const end = @minimum(self.cols, self.screen.cursor.x + count); // Shift - var x: usize = self.screen.cursor.x; - while (x < end) : (x += 1) { - const cell = self.getOrPutCell(x, self.screen.cursor.y); - cell.* = self.screen.cursor.pen; - cell.char = 0; - } + var pen = self.screen.cursor.pen; + pen.char = 0; + const row = self.screen.getRow(.{ .active = self.screen.cursor.y }); + row.fillSlice(pen, self.screen.cursor.x, end); } /// Move the cursor to the left amount cells. If amount is 0, adjust it to 1. @@ -994,11 +996,11 @@ pub fn carriageReturn(self: *Terminal) void { } /// Linefeed moves the cursor to the next line. -pub fn linefeed(self: *Terminal) void { +pub fn linefeed(self: *Terminal) !void { const tracy = trace(@src()); defer tracy.end(); - self.index(); + try self.index(); } /// Inserts spaces at current cursor position moving existing cell contents @@ -1031,7 +1033,7 @@ pub fn insertBlanks(self: *Terminal, count: usize) void { // This is the number of spaces we have left to shift existing data. // If count is bigger than the available space left after the cursor, // we may have no space at all for copying. - const copyable = row.len - pivot; + const copyable = self.screen.cols - pivot; if (copyable > 0) { // This is the index of the final copyable value that we need to copy. const copyable_end = start + copyable - 1; @@ -1040,16 +1042,16 @@ pub fn insertBlanks(self: *Terminal, count: usize) void { // allocated new space, otherwise we'll copy duplicates. var i: usize = 0; while (i < copyable) : (i += 1) { - const to = row.len - 1 - i; + const to = self.screen.cols - 1 - i; const from = copyable_end - i; - row[to] = row[from]; + row.getCellPtr(to).* = row.getCell(from); } } // Insert zero var pen = self.screen.cursor.pen; pen.char = ' '; // NOTE: this should be 0 but we need space for tests - std.mem.set(Screen.Cell, row[start..pivot], pen); + row.fillSlice(pen, start, pivot); } /// Insert amount lines at the current cursor row. The contents of the line @@ -1091,18 +1093,14 @@ pub fn insertLines(self: *Terminal, count: usize) void { // Ensure we have the lines populated to the end while (y > top) : (y -= 1) { - self.screen.copyRow(y, y - adjusted_count); + self.screen.copyRow(.{ .active = y }, .{ .active = y - adjusted_count }); } // Insert count blank lines y = self.screen.cursor.y; while (y < self.screen.cursor.y + adjusted_count) : (y += 1) { - var x: usize = 0; - while (x < self.cols) : (x += 1) { - const cell = self.getOrPutCell(x, y); - cell.* = self.screen.cursor.pen; - cell.char = 0; - } + const row = self.screen.getRow(.{ .active = y }); + row.clear(self.screen.cursor.pen); } } @@ -1140,12 +1138,12 @@ pub fn deleteLines(self: *Terminal, count: usize) void { // Scroll up the count amount. var y: usize = self.screen.cursor.y; while (y <= self.scrolling_region.bottom - adjusted_count) : (y += 1) { - self.screen.copyRow(y, y + adjusted_count); + self.screen.copyRow(.{ .active = y }, .{ .active = y + adjusted_count }); } while (y <= self.scrolling_region.bottom) : (y += 1) { const row = self.screen.getRow(.{ .active = y }); - std.mem.set(Screen.Cell, row, self.screen.cursor.pen); + row.fill(self.screen.cursor.pen); } } @@ -1194,11 +1192,11 @@ pub const ScrollViewport = union(enum) { }; /// Scroll the viewport of the terminal grid. -pub fn scrollViewport(self: *Terminal, behavior: ScrollViewport) void { +pub fn scrollViewport(self: *Terminal, behavior: ScrollViewport) !void { const tracy = trace(@src()); defer tracy.end(); - self.screen.scroll(switch (behavior) { + try self.screen.scroll(switch (behavior) { .top => .{ .top = {} }, .bottom => .{ .bottom = {} }, .delta => |delta| .{ .delta_no_grow = delta }, @@ -1236,13 +1234,6 @@ pub fn setScrollingRegion(self: *Terminal, top: usize, bottom: usize) void { self.setCursorPos(1, 1); } -fn getOrPutCell(self: *Terminal, x: usize, y: usize) *Screen.Cell { - const tracy = trace(@src()); - defer tracy.end(); - - return self.screen.getCell(y, x); -} - test "Terminal: input with no control characters" { var t = try init(testing.allocator, 80, 80); defer t.deinit(testing.allocator); @@ -1282,9 +1273,9 @@ test "Terminal: print writes to bottom if scrolled" { // Make newlines so we create scrollback // 3 pushes hello off the screen - t.index(); - t.index(); - t.index(); + try t.index(); + try t.index(); + try t.index(); { var str = try t.plainString(testing.allocator); defer testing.allocator.free(str); @@ -1292,7 +1283,7 @@ test "Terminal: print writes to bottom if scrolled" { } // Scroll to the top - t.scrollViewport(.{ .top = {} }); + try t.scrollViewport(.{ .top = {} }); { var str = try t.plainString(testing.allocator); defer testing.allocator.free(str); @@ -1301,7 +1292,7 @@ test "Terminal: print writes to bottom if scrolled" { // Type try t.print('A'); - t.scrollViewport(.{ .bottom = {} }); + try t.scrollViewport(.{ .bottom = {} }); { var str = try t.plainString(testing.allocator); defer testing.allocator.free(str); @@ -1378,7 +1369,7 @@ test "Terminal: linefeed and carriage return" { // Basic grid writing for ("hello") |c| try t.print(c); t.carriageReturn(); - t.linefeed(); + try t.linefeed(); for ("world") |c| try t.print(c); try testing.expectEqual(@as(usize, 1), t.screen.cursor.y); try testing.expectEqual(@as(usize, 5), t.screen.cursor.x); @@ -1396,7 +1387,7 @@ test "Terminal: linefeed unsets pending wrap" { // Basic grid writing for ("hello") |c| try t.print(c); try testing.expect(t.screen.cursor.pending_wrap == true); - t.linefeed(); + try t.linefeed(); try testing.expect(t.screen.cursor.pending_wrap == false); } @@ -1540,13 +1531,13 @@ test "Terminal: deleteLines" { // Initial value try t.print('A'); t.carriageReturn(); - t.linefeed(); + try t.linefeed(); try t.print('B'); t.carriageReturn(); - t.linefeed(); + try t.linefeed(); try t.print('C'); t.carriageReturn(); - t.linefeed(); + try t.linefeed(); try t.print('D'); t.cursorUp(2); @@ -1554,7 +1545,7 @@ test "Terminal: deleteLines" { try t.print('E'); t.carriageReturn(); - t.linefeed(); + try t.linefeed(); // We should be try testing.expectEqual(@as(usize, 0), t.screen.cursor.x); @@ -1575,13 +1566,13 @@ test "Terminal: deleteLines with scroll region" { // Initial value try t.print('A'); t.carriageReturn(); - t.linefeed(); + try t.linefeed(); try t.print('B'); t.carriageReturn(); - t.linefeed(); + try t.linefeed(); try t.print('C'); t.carriageReturn(); - t.linefeed(); + try t.linefeed(); try t.print('D'); t.setScrollingRegion(1, 3); @@ -1590,7 +1581,7 @@ test "Terminal: deleteLines with scroll region" { try t.print('E'); t.carriageReturn(); - t.linefeed(); + try t.linefeed(); // We should be // try testing.expectEqual(@as(usize, 0), t.screen.cursor.x); @@ -1611,16 +1602,16 @@ test "Terminal: insertLines" { // Initial value try t.print('A'); t.carriageReturn(); - t.linefeed(); + try t.linefeed(); try t.print('B'); t.carriageReturn(); - t.linefeed(); + try t.linefeed(); try t.print('C'); t.carriageReturn(); - t.linefeed(); + try t.linefeed(); try t.print('D'); t.carriageReturn(); - t.linefeed(); + try t.linefeed(); try t.print('E'); // Move to row 2 @@ -1644,16 +1635,16 @@ test "Terminal: insertLines with scroll region" { // Initial value try t.print('A'); t.carriageReturn(); - t.linefeed(); + try t.linefeed(); try t.print('B'); t.carriageReturn(); - t.linefeed(); + try t.linefeed(); try t.print('C'); t.carriageReturn(); - t.linefeed(); + try t.linefeed(); try t.print('D'); t.carriageReturn(); - t.linefeed(); + try t.linefeed(); try t.print('E'); t.setScrollingRegion(1, 2); @@ -1677,16 +1668,16 @@ test "Terminal: insertLines more than remaining" { // Initial value try t.print('A'); t.carriageReturn(); - t.linefeed(); + try t.linefeed(); try t.print('B'); t.carriageReturn(); - t.linefeed(); + try t.linefeed(); try t.print('C'); t.carriageReturn(); - t.linefeed(); + try t.linefeed(); try t.print('D'); t.carriageReturn(); - t.linefeed(); + try t.linefeed(); try t.print('E'); // Move to row 2 @@ -1710,17 +1701,17 @@ test "Terminal: reverseIndex" { // Initial value try t.print('A'); t.carriageReturn(); - t.linefeed(); + try t.linefeed(); try t.print('B'); t.carriageReturn(); - t.linefeed(); + try t.linefeed(); try t.print('C'); try t.reverseIndex(); try t.print('D'); t.carriageReturn(); - t.linefeed(); + try t.linefeed(); t.carriageReturn(); - t.linefeed(); + try t.linefeed(); { var str = try t.plainString(testing.allocator); @@ -1736,24 +1727,24 @@ test "Terminal: reverseIndex from the top" { try t.print('A'); t.carriageReturn(); - t.linefeed(); + try t.linefeed(); try t.print('B'); t.carriageReturn(); - t.linefeed(); + try t.linefeed(); t.carriageReturn(); - t.linefeed(); + try t.linefeed(); t.setCursorPos(1, 1); try t.reverseIndex(); try t.print('D'); t.carriageReturn(); - t.linefeed(); + try t.linefeed(); t.setCursorPos(1, 1); try t.reverseIndex(); try t.print('E'); t.carriageReturn(); - t.linefeed(); + try t.linefeed(); { var str = try t.plainString(testing.allocator); @@ -1767,7 +1758,7 @@ test "Terminal: index" { var t = try init(alloc, 2, 5); defer t.deinit(alloc); - t.index(); + try t.index(); try t.print('A'); { @@ -1784,7 +1775,7 @@ test "Terminal: index from the bottom" { t.setCursorPos(5, 1); try t.print('A'); - t.index(); + try t.index(); try t.print('B'); @@ -1802,7 +1793,7 @@ test "Terminal: index outside of scrolling region" { try testing.expectEqual(@as(usize, 0), t.screen.cursor.y); t.setScrollingRegion(2, 5); - t.index(); + try t.index(); try testing.expectEqual(@as(usize, 1), t.screen.cursor.y); } @@ -1814,7 +1805,7 @@ test "Terminal: index from the bottom outside of scroll region" { t.setScrollingRegion(1, 2); t.setCursorPos(5, 1); try t.print('A'); - t.index(); + try t.index(); try t.print('B'); { @@ -1832,7 +1823,7 @@ test "Terminal: DECALN" { // Initial value try t.print('A'); t.carriageReturn(); - t.linefeed(); + try t.linefeed(); try t.print('B'); t.decaln(); diff --git a/src/terminal/main.zig b/src/terminal/main.zig index 38f93d762..097bade52 100644 --- a/src/terminal/main.zig +++ b/src/terminal/main.zig @@ -27,9 +27,6 @@ pub const EraseLine = csi.EraseLine; pub const TabClear = csi.TabClear; pub const Attribute = sgr.Attribute; -pub const Screen2 = @import("Screen2.zig"); - test { @import("std").testing.refAllDecls(@This()); - _ = @import("circ_buf.zig"); } diff --git a/src/terminal/point.zig b/src/terminal/point.zig index 85b093bf9..db9af75cc 100644 --- a/src/terminal/point.zig +++ b/src/terminal/point.zig @@ -16,7 +16,7 @@ pub const Viewport = struct { // get the full offset from the top. return .{ .x = self.x, - .y = screen.visible_offset + self.y, + .y = screen.viewport + self.y, }; } @@ -25,7 +25,7 @@ pub const Viewport = struct { const alloc = testing.allocator; var s = try Screen.init(alloc, 3, 5, 0); - defer s.deinit(alloc); + defer s.deinit(); try testing.expectEqual(ScreenPoint{ .x = 1, @@ -38,24 +38,24 @@ pub const Viewport = struct { const alloc = testing.allocator; var s = try Screen.init(alloc, 3, 5, 3); - defer s.deinit(alloc); + defer s.deinit(); // At the bottom - s.scroll(.{ .delta = 6 }); + try s.scroll(.{ .delta = 6 }); try testing.expectEqual(ScreenPoint{ .x = 0, - .y = 3, + .y = 6, }, (Viewport{ .x = 0, .y = 0 }).toScreen(&s)); // Move the viewport a bit up - s.scroll(.{ .delta = -1 }); + try s.scroll(.{ .delta = -1 }); try testing.expectEqual(ScreenPoint{ .x = 0, - .y = 2, + .y = 5, }, (Viewport{ .x = 0, .y = 0 }).toScreen(&s)); // Move the viewport to top - s.scroll(.{ .top = {} }); + try s.scroll(.{ .top = {} }); try testing.expectEqual(ScreenPoint{ .x = 0, .y = 0, From dba027d307834f4d8b0f3480815b7471ef85ca4f Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 1 Sep 2022 01:09:06 -0700 Subject: [PATCH 17/34] resize should maintain minimum of screen rows --- src/terminal/Screen.zig | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index 12dcca646..5f101fab1 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -775,7 +775,7 @@ pub fn resizeWithoutReflow(self: *Screen, rows: usize, cols: usize) !void { // 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 = old.rowsWritten() * (cols + 1); + const old_len = @maximum(old.rowsWritten(), rows) * (cols + 1); const new_max_capacity = self.maxCapacity(); const buf_size = @minimum(old_len, new_max_capacity); @@ -1505,6 +1505,23 @@ test "Screen: resize (no reflow) less rows with scrollback" { } } +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 more rows no scrollback" { const testing = std.testing; const alloc = testing.allocator; From d404be2993632f20f20d28b6d339df69b8da1764 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 1 Sep 2022 01:15:20 -0700 Subject: [PATCH 18/34] grow our scrollback much faster --- src/terminal/Screen.zig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index 5f101fab1..38b704831 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -570,7 +570,7 @@ fn scrollDelta(self: *Screen, delta: isize, grow: bool) !void { // to chunk it. const needed_capacity = @maximum( rows_final * (self.cols + 1), - self.rows * 2, + @minimum(self.storage.capacity() * 2, max_capacity), ); // Allocate what we can. From 30a14d230ed118363650259aca953474cd3681ed Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 1 Sep 2022 17:53:40 -0700 Subject: [PATCH 19/34] process ASCII events manually to avoid function call overhead --- src/Window.zig | 40 ++++++++++++++++++++++++++++++++++++--- src/terminal/Parser.zig | 4 ++++ src/terminal/Screen.zig | 4 ++++ src/terminal/Terminal.zig | 8 +++++++- src/terminal/main.zig | 1 + 5 files changed, 53 insertions(+), 4 deletions(-) diff --git a/src/Window.zig b/src/Window.zig index f0407f7e8..6b1674dcf 100644 --- a/src/Window.zig +++ b/src/Window.zig @@ -1271,9 +1271,43 @@ fn ttyRead(t: *libuv.Tty, n: isize, buf: []const u8) void { // Schedule a render win.render_timer.schedule() catch unreachable; - // Process the terminal data - win.terminal_stream.nextSlice(buf[0..@intCast(usize, n)]) catch |err| - log.err("error processing terminal data: {}", .{err}); + // Process the terminal data. This is an extremely hot part of the + // terminal emulator, so we do some abstraction leakage to avoid + // function calls and unnecessary logic. + // + // The ground state is the only state that we can see and print/execute + // ASCII, so we only execute this hot path if we're already in the ground + // state. + // + // Empirically, this alone improved throughput of large text output by ~20%. + var i: usize = 0; + const end = @intCast(usize, n); + if (win.terminal_stream.parser.state == .ground) { + for (buf[i..end]) |c| { + switch (terminal.parse_table.table[c][@enumToInt(terminal.Parser.State.ground)].action) { + // Print, call directly. + .print => win.print(@intCast(u21, c)) catch |err| + log.err("error processing terminal data: {}", .{err}), + + // C0 execute, let our stream handle this one but otherwise + // continue since we're guaranteed to be back in ground. + .execute => win.terminal_stream.next(c) catch |err| + log.err("error processing terminal data: {}", .{err}), + + // Otherwise, break out and go the slow path until we're + // back in ground. + else => break, + } + + i += 1; + } + } + + if (i < end) { + //log.warn("SLOW={}", .{end - i}); + win.terminal_stream.nextSlice(buf[i..end]) catch |err| + log.err("error processing terminal data: {}", .{err}); + } } fn ttyWrite(req: *libuv.WriteReq, status: i32) void { diff --git a/src/terminal/Parser.zig b/src/terminal/Parser.zig index cd77007e9..032fa42d9 100644 --- a/src/terminal/Parser.zig +++ b/src/terminal/Parser.zig @@ -6,6 +6,7 @@ const Parser = @This(); const std = @import("std"); const builtin = @import("builtin"); +const trace = @import("tracy").trace; const testing = std.testing; const table = @import("parse_table.zig").table; const osc = @import("osc.zig"); @@ -212,6 +213,9 @@ pub fn init() Parser { /// Up to 3 actions may need to be exected -- in order -- representing /// the state exit, transition, and entry actions. pub fn next(self: *Parser, c: u8) [3]?Action { + const tracy = trace(@src()); + defer tracy.end(); + // If we're processing UTF-8, we handle this manually. if (self.state == .utf8) { return .{ self.next_utf8(c), null, null }; diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index 38b704831..169d9171c 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -22,6 +22,7 @@ const assert = std.debug.assert; const Allocator = std.mem.Allocator; const utf8proc = @import("utf8proc"); +const trace = @import("tracy").trace; const color = @import("color.zig"); const point = @import("point.zig"); const CircBuf = @import("circ_buf.zig").CircBuf; @@ -432,6 +433,9 @@ pub fn rowIterator(self: *Screen, tag: RowIndexTag) RowIterator { /// 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); diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index 8b15ef055..7650d032a 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -464,7 +464,7 @@ pub fn print(self: *Terminal, c: u21) !void { switch (width) { // Single cell is very easy: just write in the cell - 1 => _ = self.printCell(c), + 1 => _ = @call(.{ .modifier = .always_inline }, self.printCell, .{c}), // Wide character requires a spacer. We print this by // using two cells: the first is flagged "wide" and has the @@ -505,6 +505,9 @@ pub fn print(self: *Terminal, c: u21) !void { } fn printCell(self: *Terminal, unmapped_c: u21) *Screen.Cell { + // const tracy = trace(@src()); + // defer tracy.end(); + const c = c: { // TODO: non-utf8 handling, gr @@ -558,6 +561,9 @@ fn printCell(self: *Terminal, unmapped_c: u21) *Screen.Cell { } fn printWrap(self: *Terminal) !void { + const tracy = trace(@src()); + defer tracy.end(); + const row = self.screen.getRow(.{ .active = self.screen.cursor.y }); row.setWrapped(true); diff --git a/src/terminal/main.zig b/src/terminal/main.zig index 097bade52..4472116c6 100644 --- a/src/terminal/main.zig +++ b/src/terminal/main.zig @@ -7,6 +7,7 @@ const csi = @import("csi.zig"); const sgr = @import("sgr.zig"); pub const point = @import("point.zig"); pub const color = @import("color.zig"); +pub const parse_table = @import("parse_table.zig"); pub const Charset = charsets.Charset; pub const CharsetSlot = charsets.Slots; From 73581eee0b13f8dd50ffb606dc01138f434091bf Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 1 Sep 2022 18:36:27 -0700 Subject: [PATCH 20/34] small optimizations --- src/Window.zig | 11 +++++++---- src/terminal/Screen.zig | 13 +++++++------ src/terminal/stream.zig | 2 +- 3 files changed, 15 insertions(+), 11 deletions(-) diff --git a/src/Window.zig b/src/Window.zig index 6b1674dcf..076826e73 100644 --- a/src/Window.zig +++ b/src/Window.zig @@ -1291,11 +1291,13 @@ fn ttyRead(t: *libuv.Tty, n: isize, buf: []const u8) void { // C0 execute, let our stream handle this one but otherwise // continue since we're guaranteed to be back in ground. - .execute => win.terminal_stream.next(c) catch |err| + .execute => win.terminal_stream.execute(c) catch |err| log.err("error processing terminal data: {}", .{err}), // Otherwise, break out and go the slow path until we're - // back in ground. + // back in ground. There is a slight optimization here where + // could try to find the next transition to ground but when + // I implemented that it didn't materially change performance. else => break, } @@ -1304,7 +1306,6 @@ fn ttyRead(t: *libuv.Tty, n: isize, buf: []const u8) void { } if (i < end) { - //log.warn("SLOW={}", .{end - i}); win.terminal_stream.nextSlice(buf[i..end]) catch |err| log.err("error processing terminal data: {}", .{err}); } @@ -1418,7 +1419,9 @@ pub fn horizontalTab(self: *Window) !void { } pub fn linefeed(self: *Window) !void { - try self.terminal.linefeed(); + // Small optimization: call index instead of linefeed because they're + // identical and this avoids one layer of function call overhead. + try self.terminal.index(); } pub fn carriageReturn(self: *Window) !void { diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index 169d9171c..74dc614cb 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -319,20 +319,21 @@ pub const RowIndexTag = enum { /// 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 fn maxLen(self: RowIndexTag, screen: *const Screen) usize { - const rows_written = screen.rowsWritten(); - + pub inline fn maxLen(self: RowIndexTag, screen: *const Screen) usize { return switch (self) { // Screen can be any of the written rows - .screen => rows_written, + .screen => screen.rowsWritten(), // Viewport can be any of the written rows or the max size // of a viewport. - .viewport => @minimum(screen.rows, rows_written), + .viewport => @minimum(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 => if (rows_written > screen.rows) rows_written - screen.rows else 0, + .history => history: { + const rows_written = screen.rowsWritten(); + break :history if (rows_written > screen.rows) rows_written - screen.rows else 0; + }, // Active area can be any number of rows. We ignore rows // written here because this is the only row index that can diff --git a/src/terminal/stream.zig b/src/terminal/stream.zig index b8494c8a5..e554c59fe 100644 --- a/src/terminal/stream.zig +++ b/src/terminal/stream.zig @@ -68,7 +68,7 @@ pub fn Stream(comptime Handler: type) type { } } - fn execute(self: *Self, c: u8) !void { + pub fn execute(self: *Self, c: u8) !void { // log.warn("C0: {}", .{c}); switch (@intToEnum(ansi.C0, c)) { .NUL => {}, From 5aa6d79519aa8db0981a820f5964358ff32b9c52 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 1 Sep 2022 21:36:59 -0700 Subject: [PATCH 21/34] screen: cache history offset This is a super hot calculation so taking up memory to cache it resulted in 15m less function calls when catting a 10mb file, and ~5% speedup. --- src/terminal/Screen.zig | 44 ++++++++++++++++++++++++++++------------- 1 file changed, 30 insertions(+), 14 deletions(-) diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index 74dc614cb..b52a46c8e 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -296,7 +296,7 @@ pub const RowIndex = union(RowIndexTag) { .active => |y| y: { assert(y < RowIndexTag.active.maxLen(screen)); - break :y RowIndexTag.history.maxLen(screen) + y; + break :y screen.history + y; }, .history => |y| y: { @@ -320,6 +320,9 @@ pub const RowIndexTag = enum { /// 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(), @@ -330,10 +333,7 @@ pub const RowIndexTag = enum { // 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 => history: { - const rows_written = screen.rowsWritten(); - break :history if (rows_written > screen.rows) rows_written - screen.rows else 0; - }, + .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 @@ -375,6 +375,13 @@ 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 = .{}, @@ -400,6 +407,7 @@ pub fn init( .cols = cols, .max_scrollback = max_scrollback, .viewport = 0, + .history = 0, }; } @@ -409,7 +417,7 @@ pub fn deinit(self: *Screen) void { /// Returns true if the viewport is scrolled to the bottom of the screen. pub fn viewportIsBottom(self: Screen) bool { - return self.viewport >= RowIndexTag.history.maxLen(&self); + return self.viewport >= self.history; } /// Shortcut for getRow followed by getCell as a quick way to read a cell. @@ -525,7 +533,7 @@ pub fn scroll(self: *Screen, behavior: Scroll) !void { // Bottom is the end of the history area (end of history is the // top of the active area). - .bottom => self.viewport = RowIndexTag.history.maxLen(self), + .bottom => self.viewport = self.history, // TODO: deltas greater than the entire scrollback .delta => |delta| try self.scrollDelta(delta, true), @@ -543,10 +551,9 @@ fn scrollDelta(self: *Screen, delta: isize, grow: bool) !void { // If we're scrolling down and not growing, then we just // add to the viewport and clamp at the bottom. - const viewport_max = RowIndexTag.history.maxLen(self); if (!grow) { self.viewport = @minimum( - viewport_max, + self.history, self.viewport +| @intCast(usize, delta), ); return; @@ -556,11 +563,11 @@ fn scrollDelta(self: *Screen, delta: isize, grow: bool) !void { // allowed to scroll to the bottom (the end of the history), then we // have space and we just return. self.viewport +|= @intCast(usize, delta); - if (self.viewport <= viewport_max) return; + if (self.viewport <= self.history) return; // Our viewport is bigger than our max. The number of new rows we need // in our buffer is our value minus the max. - const new_rows_needed = self.viewport - viewport_max; + const new_rows_needed = self.viewport - self.history; // If we can't fit into our capacity but we have space, resize the // buffer to allocate more scrollback. @@ -598,9 +605,16 @@ fn scrollDelta(self: *Screen, delta: isize, grow: bool) !void { break :deleted rows_to_delete; } else 0; + // 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 _ = self.storage.getPtrSlice( - (rows_final - rows_deleted - 1) * (self.cols + 1), + (rows_written_final - 1) * (self.cols + 1), self.cols + 1, ); } @@ -789,8 +803,10 @@ pub fn resizeWithoutReflow(self: *Screen, rows: usize, cols: usize) !void { errdefer self.storage.deinit(self.alloc); defer old.storage.deinit(self.alloc); - // Our viewport resets to the top because we're going to rewrite the screen + // Our viewport and history resets to the top because we're going to + // rewrite the screen self.viewport = 0; + self.history = 0; // Rewrite all our rows var y: usize = 0; @@ -817,7 +833,7 @@ pub fn resizeWithoutReflow(self: *Screen, rows: usize, cols: usize) !void { const old_cursor_y_screen = RowIndexTag.active.index(old.cursor.y).toScreen(&old).screen; self.cursor.x = @minimum(old.cursor.x, self.cols - 1); self.cursor.y = if (old_cursor_y_screen < RowIndexTag.screen.maxLen(self)) - old_cursor_y_screen - RowIndexTag.history.maxLen(self) + old_cursor_y_screen - self.history else self.rows - 1; } From 57725cf3a490660bbcc6b436635d2e67d269c00a Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 1 Sep 2022 22:05:42 -0700 Subject: [PATCH 22/34] cache row iterator max value --- pkg/tracy/tracy.zig | 3 +++ src/terminal/Screen.zig | 15 +++++++++++++-- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/pkg/tracy/tracy.zig b/pkg/tracy/tracy.zig index 1e7aa4d78..d9b243fe9 100644 --- a/pkg/tracy/tracy.zig +++ b/pkg/tracy/tracy.zig @@ -15,6 +15,9 @@ pub usingnamespace if (enabled) Impl else Noop; const Impl = struct { const c = @cImport({ + //uncomment to enable callstacks, very slow + //@cDefine("TRACY_CALLSTACK", ""); + @cDefine("TRACY_ENABLE", ""); @cInclude("TracyC.h"); }); diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index b52a46c8e..74993e15c 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -232,10 +232,11 @@ pub const Row = struct { pub const RowIterator = struct { screen: *Screen, tag: RowIndexTag, + max: usize, value: usize = 0, pub fn next(self: *RowIterator) ?Row { - if (self.value >= self.tag.maxLen(self.screen)) return null; + if (self.value >= self.max) return null; const idx = self.tag.index(self.value); const res = self.screen.getRow(idx); self.value += 1; @@ -436,7 +437,14 @@ pub fn getCellPtr(self: *Screen, tag: RowIndexTag, y: usize, x: usize) *Cell { /// 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 { - return .{ .screen = self, .tag = tag }; + const tracy = trace(@src()); + defer tracy.end(); + + return .{ + .screen = self, + .tag = tag, + .max = tag.maxLen(self), + }; } /// Returns the row at the given index. This row is writable, although @@ -783,6 +791,9 @@ fn selectionSlices(self: *Screen, sel_raw: Selection) struct { /// 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(); + // Make a copy so we can access the old indexes. var old = self.*; errdefer self.* = old; From 8d6e8eb0aa137e7875cbe6b90cc68a899c400007 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 1 Sep 2022 22:21:54 -0700 Subject: [PATCH 23/34] asserts not being optimized away, use comptime gate --- src/terminal/Screen.zig | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index 74993e15c..5376864d2 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -286,22 +286,26 @@ pub const RowIndex = union(RowIndexTag) { pub fn toScreen(self: RowIndex, screen: *const Screen) RowIndex { const y = switch (self) { .screen => |y| y: { - assert(y < RowIndexTag.screen.maxLen(screen)); + // 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: { - assert(y < RowIndexTag.viewport.maxLen(screen)); + if (std.debug.runtime_safety) assert(y < RowIndexTag.viewport.maxLen(screen)); break :y y + screen.viewport; }, .active => |y| y: { - assert(y < RowIndexTag.active.maxLen(screen)); + if (std.debug.runtime_safety) assert(y < RowIndexTag.active.maxLen(screen)); break :y screen.history + y; }, .history => |y| y: { - assert(y < RowIndexTag.history.maxLen(screen)); + if (std.debug.runtime_safety) assert(y < RowIndexTag.history.maxLen(screen)); break :y y; }, }; From 81b805b8c2a03f307533a2f861e7eb89826b60f9 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 1 Sep 2022 22:48:33 -0700 Subject: [PATCH 24/34] use an arena allocator for tty allocs libuv always called the alloc cb right before read, and read owns the buffer. By using an arena, we're probably just reusing the same buffer over and over again. This should be quite fast. In tracing, this indeed changes the MTPC on ttyReadAlloc from ~750ns to ~275ns. I'll take it! --- src/Window.zig | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/src/Window.zig b/src/Window.zig index 076826e73..43a3919a8 100644 --- a/src/Window.zig +++ b/src/Window.zig @@ -33,6 +33,7 @@ const WRITE_REQ_PREALLOC = std.math.pow(usize, 2, 5); /// Allocator alloc: Allocator, +alloc_io_arena: std.heap.ArenaAllocator, /// The glfw window handle. window: glfw.Window, @@ -323,8 +324,15 @@ pub fn create(alloc: Allocator, loop: libuv.Loop, config: *const Config) !*Windo errdefer cursor.destroy(); try window.setCursor(cursor); + // Create our IO allocator arena. Libuv appears to guarantee (in code, + // not in docs) that read_alloc is called directly before a read so + // we can use an arena to make allocation faster. + var io_arena = std.heap.ArenaAllocator.init(alloc); + errdefer io_arena.deinit(); + self.* = .{ .alloc = alloc, + .alloc_io_arena = io_arena, .window = window, .cursor = cursor, .focused = false, @@ -410,6 +418,8 @@ pub fn destroy(self: *Window) void { // We can destroy the cursor right away. glfw will just revert any // windows using it to the default. self.cursor.destroy(); + + self.alloc_io_arena.deinit(); } pub fn shouldClose(self: Window) bool { @@ -1235,7 +1245,8 @@ fn ttyReadAlloc(t: *libuv.Tty, size: usize) ?[]u8 { const tracy = trace(@src()); defer tracy.end(); - const alloc = t.loop().getData(Allocator).?.*; + const win = t.getData(Window) orelse return null; + const alloc = win.alloc_io_arena.allocator(); return alloc.alloc(u8, size) catch null; } @@ -1245,7 +1256,10 @@ fn ttyRead(t: *libuv.Tty, n: isize, buf: []const u8) void { defer tracy.end(); const win = t.getData(Window).?; - defer win.alloc.free(buf); + defer { + const alloc = win.alloc_io_arena.allocator(); + alloc.free(buf); + } // log.info("DATA: {d}", .{n}); // log.info("DATA: {any}", .{buf[0..@intCast(usize, n)]}); From d22a323896bdc861552e98b20e36ed3018d7cea2 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 1 Sep 2022 23:25:21 -0700 Subject: [PATCH 25/34] clean up some assertions --- src/terminal/Screen.zig | 8 ++------ src/terminal/circ_buf.zig | 5 ++++- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index 5376864d2..1ecc1656e 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -566,7 +566,7 @@ fn scrollDelta(self: *Screen, delta: isize, grow: bool) !void { if (!grow) { self.viewport = @minimum( self.history, - self.viewport +| @intCast(usize, delta), + self.viewport + @intCast(usize, delta), ); return; } @@ -574,7 +574,7 @@ fn scrollDelta(self: *Screen, delta: isize, grow: bool) !void { // 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. - self.viewport +|= @intCast(usize, delta); + self.viewport += @intCast(usize, delta); if (self.viewport <= self.history) return; // Our viewport is bigger than our max. The number of new rows we need @@ -610,10 +610,6 @@ fn scrollDelta(self: *Screen, delta: isize, grow: bool) !void { const rows_to_delete = rows_final - self.rowsCapacity(); self.viewport -= rows_to_delete; self.storage.deleteOldest(rows_to_delete * (self.cols + 1)); - - // If we grew down like this, we must be at the bottom. - assert(self.viewportIsBottom()); - break :deleted rows_to_delete; } else 0; diff --git a/src/terminal/circ_buf.zig b/src/terminal/circ_buf.zig index 59928df1b..1202874f7 100644 --- a/src/terminal/circ_buf.zig +++ b/src/terminal/circ_buf.zig @@ -112,6 +112,8 @@ pub fn CircBuf(comptime T: type, comptime default: T) type { /// Delete the oldest n values from the buffer. If there are less /// than n values in the buffer, it'll delete everything. pub fn deleteOldest(self: *Self, n: usize) void { + assert(n <= self.storage.len); + // Clear the values back to default const slices = self.getPtrSlice(0, n); for (slices) |slice| std.mem.set(T, slice, default); @@ -128,7 +130,8 @@ pub fn CircBuf(comptime T: type, comptime default: T) type { /// the end of our buffer. This never "rotates" the buffer because /// the offset can only be within the size of the buffer. pub fn getPtrSlice(self: *Self, offset: usize, slice_len: usize) [2][]T { - assert(slice_len > 0); + // Note: this assertion is very important, it hints the compiler + // which generates ~10% faster code than without it. assert(offset + slice_len <= self.capacity()); // End offset is the last offset (exclusive) for our slice. From 41f2b756ae51b30e8a821ecbf4dabe269e8931fe Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 3 Sep 2022 12:18:45 -0700 Subject: [PATCH 26/34] fix scrolling and new row calculation --- src/terminal/Screen.zig | 106 ++++++++++++++++++++-------------------- src/terminal/point.zig | 4 +- 2 files changed, 55 insertions(+), 55 deletions(-) diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index 1ecc1656e..2153d348c 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -422,7 +422,7 @@ pub fn deinit(self: *Screen) void { /// Returns true if the viewport is scrolled to the bottom of the screen. pub fn viewportIsBottom(self: Screen) bool { - return self.viewport >= self.history; + return self.viewport == self.history; } /// Shortcut for getRow followed by getCell as a quick way to read a cell. @@ -577,54 +577,60 @@ fn scrollDelta(self: *Screen, delta: isize, grow: bool) !void { self.viewport += @intCast(usize, delta); if (self.viewport <= self.history) return; - // Our viewport is bigger than our max. The number of new rows we need - // in our buffer is our value minus the max. - const new_rows_needed = self.viewport - self.history; - - // If we can't fit into our capacity but we have space, resize the - // buffer to allocate more scrollback. + // 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 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 = @maximum( - rows_final * (self.cols + 1), - @minimum(self.storage.capacity() * 2, max_capacity), - ); + const viewport_bottom = self.viewport + self.rows; + if (viewport_bottom > rows_written) { + // 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; - // Allocate what we can. - try self.storage.resize( - self.alloc, - @minimum(max_capacity, needed_capacity), - ); + // 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 = @maximum( + rows_final * (self.cols + 1), + @minimum(self.storage.capacity() * 2, max_capacity), + ); + + // Allocate what we can. + try self.storage.resize( + self.alloc, + @minimum(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(); + self.viewport -= rows_to_delete; + self.storage.deleteOldest(rows_to_delete * (self.cols + 1)); + break :deleted rows_to_delete; + } else 0; + + // 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 + _ = self.storage.getPtrSlice( + (rows_written_final - 1) * (self.cols + 1), + self.cols + 1, + ); } - - // 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(); - self.viewport -= rows_to_delete; - self.storage.deleteOldest(rows_to_delete * (self.cols + 1)); - break :deleted rows_to_delete; - } else 0; - - // 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 - _ = self.storage.getPtrSlice( - (rows_written_final - 1) * (self.cols + 1), - self.cols + 1, - ); } /// Returns the raw text associated with a selection. This will unwrap @@ -1202,19 +1208,13 @@ test "Screen: scrollback with large delta" { } // Scroll down a ton - try s.scroll(.{ .delta = 5 }); + try s.scroll(.{ .delta_no_grow = 5 }); try testing.expect(s.viewportIsBottom()); - { - // Test our contents rotated - var contents = try s.testString(alloc, .screen); - defer alloc.free(contents); - try testing.expectEqualStrings("3IJKL\n4ABCD\n5EFGH\n6IJKL", contents); - } { // Test our contents rotated var contents = try s.testString(alloc, .viewport); defer alloc.free(contents); - try testing.expectEqualStrings("6IJKL", contents); + try testing.expectEqualStrings("4ABCD\n5EFGH\n6IJKL", contents); } } diff --git a/src/terminal/point.zig b/src/terminal/point.zig index db9af75cc..a52a46a9d 100644 --- a/src/terminal/point.zig +++ b/src/terminal/point.zig @@ -44,14 +44,14 @@ pub const Viewport = struct { try s.scroll(.{ .delta = 6 }); try testing.expectEqual(ScreenPoint{ .x = 0, - .y = 6, + .y = 3, }, (Viewport{ .x = 0, .y = 0 }).toScreen(&s)); // Move the viewport a bit up try s.scroll(.{ .delta = -1 }); try testing.expectEqual(ScreenPoint{ .x = 0, - .y = 5, + .y = 2, }, (Viewport{ .x = 0, .y = 0 }).toScreen(&s)); // Move the viewport to top From 2583b8cb655f04fb87c9249971dbd4c29de35bc7 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 3 Sep 2022 12:25:26 -0700 Subject: [PATCH 27/34] fix y pos on resize --- src/terminal/Screen.zig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index 2153d348c..53261bb57 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -849,7 +849,7 @@ pub fn resizeWithoutReflow(self: *Screen, rows: usize, cols: usize) !void { // the same scrollback. const old_cursor_y_screen = RowIndexTag.active.index(old.cursor.y).toScreen(&old).screen; self.cursor.x = @minimum(old.cursor.x, self.cols - 1); - self.cursor.y = if (old_cursor_y_screen < RowIndexTag.screen.maxLen(self)) + self.cursor.y = if (old_cursor_y_screen <= RowIndexTag.screen.maxLen(self)) old_cursor_y_screen - self.history else self.rows - 1; From d6e243cde163b708de26745c30e3f12d07b86301 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 3 Sep 2022 17:28:26 -0700 Subject: [PATCH 28/34] resize with reflow more cols --- src/terminal/Screen.zig | 332 ++++++++++++++++++++++++++++++++++++++++ src/terminal/point.zig | 6 +- 2 files changed, 335 insertions(+), 3 deletions(-) diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index 53261bb57..2a9210814 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -800,6 +800,9 @@ 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; + // Make a copy so we can access the old indexes. var old = self.*; errdefer self.* = old; @@ -877,6 +880,159 @@ pub fn resize(self: *Screen, rows: usize, cols: usize) !void { return; } + // 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 + @maximum(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); + + // 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.Viewport{ + .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) { + y -= 1; + try self.scroll(.{ .delta = 1 }); + } + + // Get this row + var new_row = self.getRow(.{ .active = y }); + new_row.copyRow(old_row); + + // 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) { + assert(new_cursor == null); // should only happen once + new_cursor = .{ .y = self.rowsWritten() - 1, .x = cursor_pos.x }; + } + + // If no reflow, just keep going + if (!old_row.header().wrap) { + 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); + + // We maintain an x coord so that we can set cursors properly + var x: usize = old.cols; + wrapping: while (iter.next()) |wrapped_row| { + // Trim the row from the right so that we ignore all trailing + // empty chars and don't wrap them. + const trimmed_row = trim: { + var i: usize = old.cols; + while (i > 0) : (i -= 1) if (!wrapped_row.getCell(i - 1).empty()) break; + break :trim wrapped_row.storage[1 .. i + 1]; + }; + + var wrapped_rem = trimmed_row; + while (wrapped_rem.len > 0) { + // If the wrapped row fits nicely... + const new_row_rem = self.cols - x; + if (wrapped_rem.len <= new_row_rem) { + // Copy the row + std.mem.copy(StorageCell, new_row.storage[x + 1 ..], wrapped_rem); + + // If our cursor is in this line, then we have to move it + // onto the new line because it got unwrapped. + if (cursor_pos.y == iter.value - 1 and new_cursor == null) { + new_cursor = .{ .y = self.rowsWritten() - 1, .x = cursor_pos.x + x }; + } + + // If this row isn't also wrapped, we're done! + if (!wrapped_row.header().wrap) { + y += 1; + + // If we were able to copy the entire row then + // we shortened the screen by one. We need to reflect + // this in our viewport. + if (wrapped_rem.len == trimmed_row.len and old.viewport > 0) { + old.viewport -= 1; + } + + break :wrapping; + } + + // Wrapped again! + x += wrapped_rem.len; + break; + } + + // The row doesn't fit, meaning we have to soft-wrap the + // new row but probably at a diff boundary. + std.mem.copy( + StorageCell, + new_row.storage[x + 1 ..], + wrapped_rem[0..new_row_rem], + ); + new_row.setWrapped(true); + + // We still need to copy the remainder + wrapped_rem = wrapped_rem[new_row_rem..]; + + // 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 < new_row_rem) + { + assert(new_cursor == null); // should only happen once + new_cursor = .{ .y = self.rowsWritten() - 1, .x = x + cursor_pos.x }; + } + + // Move to a new line in our new screen + y += 1; + x = 0; + + // If we're past the end, scroll + if (y >= self.rows) { + y -= 1; + try self.scroll(.{ .delta = 1 }); + } + + // Get this row + new_row = self.getRow(.{ .active = y }); + } + } + + self.viewport = old.viewport; + } + + // 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; + } + } + // TODO try self.resizeWithoutReflow(rows, cols); } @@ -1664,3 +1820,179 @@ test "Screen: resize more cols no reflow" { try testing.expectEqualStrings(str, 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 + { + var 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); + { + var 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 + { + var 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); + { + var 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 + { + var 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); + { + var 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 + { + var 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); + { + var 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); + { + var 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" + log.warn("cursor={}", .{s.cursor}); + try testing.expectEqual(@as(u32, '5'), s.getCell(.active, s.cursor.y, s.cursor.x).char); + + { + var contents = try s.testString(alloc, .viewport); + defer alloc.free(contents); + const expected = "2EFGH\n3IJKL\n4ABCD5EFGH"; + try testing.expectEqualStrings(expected, contents); + } +} diff --git a/src/terminal/point.zig b/src/terminal/point.zig index a52a46a9d..a1c59e156 100644 --- a/src/terminal/point.zig +++ b/src/terminal/point.zig @@ -83,15 +83,15 @@ pub const ScreenPoint = struct { // TODO: test // Before viewport - if (self.y < screen.visible_offset) return .{ .x = 0, .y = 0 }; + if (self.y < screen.viewport) return .{ .x = 0, .y = 0 }; // After viewport - if (self.y > screen.visible_offset + screen.rows) return .{ + if (self.y > screen.viewport + screen.rows) return .{ .x = screen.cols - 1, .y = screen.rows - 1, }; - return .{ .x = self.x, .y = self.y - screen.visible_offset }; + return .{ .x = self.x, .y = self.y - screen.viewport }; } test "before" { From 1121438d8a55a037aa7bba7e2bc78699a2c02715 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 3 Sep 2022 17:50:04 -0700 Subject: [PATCH 29/34] maybe clean up resize more cols --- src/terminal/Screen.zig | 76 +++++++++++++++++++---------------------- 1 file changed, 35 insertions(+), 41 deletions(-) diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index 2a9210814..f11b5159f 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -921,8 +921,8 @@ pub fn resize(self: *Screen, rows: usize, cols: usize) !void { var new_row = self.getRow(.{ .active = y }); new_row.copyRow(old_row); - // 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. + // 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.rowsWritten() - 1, .x = cursor_pos.x }; @@ -946,67 +946,63 @@ pub fn resize(self: *Screen, rows: usize, cols: usize) !void { wrapping: while (iter.next()) |wrapped_row| { // Trim the row from the right so that we ignore all trailing // empty chars and don't wrap them. - const trimmed_row = trim: { + const wrapped_cells = trim: { var i: usize = old.cols; while (i > 0) : (i -= 1) if (!wrapped_row.getCell(i - 1).empty()) break; break :trim wrapped_row.storage[1 .. i + 1]; }; - var wrapped_rem = trimmed_row; - while (wrapped_rem.len > 0) { - // If the wrapped row fits nicely... + var wrapped_i: usize = 0; + while (wrapped_i < wrapped_cells.len) { + // Remaining space in our new row const new_row_rem = self.cols - x; - if (wrapped_rem.len <= new_row_rem) { - // Copy the row - std.mem.copy(StorageCell, new_row.storage[x + 1 ..], wrapped_rem); - // If our cursor is in this line, then we have to move it - // onto the new line because it got unwrapped. - if (cursor_pos.y == iter.value - 1 and new_cursor == null) { - new_cursor = .{ .y = self.rowsWritten() - 1, .x = cursor_pos.x + x }; - } + // Remaining cells in our wrapped row + const wrapped_cells_rem = wrapped_cells.len - wrapped_i; - // If this row isn't also wrapped, we're done! - if (!wrapped_row.header().wrap) { - y += 1; - - // If we were able to copy the entire row then - // we shortened the screen by one. We need to reflect - // this in our viewport. - if (wrapped_rem.len == trimmed_row.len and old.viewport > 0) { - old.viewport -= 1; - } - - break :wrapping; - } - - // Wrapped again! - x += wrapped_rem.len; - break; - } + // We copy as much as we can into our new row + const copy_len = @minimum(new_row_rem, wrapped_cells_rem); // The row doesn't fit, meaning we have to soft-wrap the // new row but probably at a diff boundary. std.mem.copy( StorageCell, new_row.storage[x + 1 ..], - wrapped_rem[0..new_row_rem], + wrapped_cells[wrapped_i .. wrapped_i + copy_len], ); - new_row.setWrapped(true); - - // We still need to copy the remainder - wrapped_rem = wrapped_rem[new_row_rem..]; // 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 < new_row_rem) + cursor_pos.x < copy_len and + new_cursor == null) { - assert(new_cursor == null); // should only happen once new_cursor = .{ .y = self.rowsWritten() - 1, .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().wrap) { + // If we were able to copy the entire row then + // we shortened the screen by one. We need to reflect + // this in our viewport. + if (wrapped_i == 0 and old.viewport > 0) old.viewport -= 1; + + 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; @@ -1015,8 +1011,6 @@ pub fn resize(self: *Screen, rows: usize, cols: usize) !void { y -= 1; try self.scroll(.{ .delta = 1 }); } - - // Get this row new_row = self.getRow(.{ .active = y }); } } From 7134ddec4e05c846f78056a5b84ef2556337f5ce Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 3 Sep 2022 17:59:24 -0700 Subject: [PATCH 30/34] resize less rows --- src/terminal/Screen.zig | 119 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 118 insertions(+), 1 deletion(-) diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index f11b5159f..98b4c9439 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -853,7 +853,7 @@ pub fn resizeWithoutReflow(self: *Screen, rows: usize, cols: usize) !void { const old_cursor_y_screen = RowIndexTag.active.index(old.cursor.y).toScreen(&old).screen; self.cursor.x = @minimum(old.cursor.x, self.cols - 1); self.cursor.y = if (old_cursor_y_screen <= RowIndexTag.screen.maxLen(self)) - old_cursor_y_screen - self.history + old_cursor_y_screen -| self.history else self.rows - 1; } @@ -1990,3 +1990,120 @@ test "Screen: resize more cols with populated scrollback" { try testing.expectEqualStrings(expected, contents); } } + +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); + const cursor = s.cursor; + try s.resize(1, 5); + + // Cursor should not move + try testing.expectEqual(cursor, s.cursor); + + { + var contents = try s.testString(alloc, .viewport); + defer alloc.free(contents); + const expected = "3IJKL"; + try testing.expectEqualStrings(expected, contents); + } + { + var 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); + + { + var contents = try s.testString(alloc, .viewport); + defer alloc.free(contents); + const expected = "3IJKL"; + try testing.expectEqualStrings(expected, contents); + } + { + var 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); + + { + var contents = try s.testString(alloc, .screen); + defer alloc.free(contents); + try testing.expectEqualStrings(str, contents); + } + { + var 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); + { + var 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); + + { + var contents = try s.testString(alloc, .screen); + defer alloc.free(contents); + try testing.expectEqualStrings(str, contents); + } + { + var contents = try s.testString(alloc, .viewport); + defer alloc.free(contents); + const expected = "5EFGH"; + try testing.expectEqualStrings(expected, contents); + } +} From 3d0ec16ad4b2006ff98e740b67eb32c4666cb71e Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 3 Sep 2022 21:25:06 -0700 Subject: [PATCH 31/34] resize with shrinking cols --- src/terminal/Screen.zig | 277 ++++- src/terminal/ScreenOld.zig | 2248 ------------------------------------ 2 files changed, 275 insertions(+), 2250 deletions(-) delete mode 100644 src/terminal/ScreenOld.zig diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index 98b4c9439..6bb8d2586 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -1027,8 +1027,122 @@ pub fn resize(self: *Screen, rows: usize, cols: usize) !void { } } - // TODO - try self.resizeWithoutReflow(rows, 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, cols); + + // If our cols got smaller, we have to reflow text. This is the worst + // possible case because we can't do any easy trick sto 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 + @maximum(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); + + // Convert our cursor coordinates to screen coordinates because + // we may have to reflow the cursor if the line it is on is moved. + var cursor_pos = (point.Viewport{ + .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 x: usize = 0; + var y: usize = 0; + while (iter.next()) |old_row| { + // Trim the row from the right so that we ignore all trailing + // empty chars and don't wrap them. + const trimmed_row = trim: { + var i: usize = old.cols; + while (i > 0) : (i -= 1) if (!old_row.getCell(i - 1).empty()) break; + break :trim old_row.storage[1 .. i + 1]; + }; + + // Copy all the cells into our row. + for (trimmed_row) |cell, i| { + // Soft wrap if we have to + if (x == self.cols) { + var row = self.getRow(.{ .active = y }); + row.setWrapped(true); + x = 0; + y += 1; + } + + // If our y is more than our rows, we need to scroll + if (y >= self.rows) { + try self.scroll(.{ .delta = 1 }); + y -= 1; + x = 0; + } + + // If our cursor is on this point, we need to move it. + if (cursor_pos.y == iter.value - 1 and + cursor_pos.x == i) + { + assert(new_cursor == null); + new_cursor = .{ .x = x, .y = self.viewport + y }; + } + + // Copy the old cell, unset the old wrap state + // log.warn("y={} x={} rows={}", .{ y, x, self.rows }); + var new_cell = self.getCellPtr(.active, y, x); + new_cell.* = cell.cell; + + // Next + x += 1; + } + + // If our cursor is on this line but not in a content area, + // then we just set it to be at the end. + if (cursor_pos.y == iter.value - 1 and + cursor_pos.x >= trimmed_row.len) + { + assert(new_cursor == null); + new_cursor = .{ + .x = @minimum(cursor_pos.x, self.cols - 1), + .y = self.viewport + y, + }; + } + + // If we aren't wrapping, then move to the next row + if (trimmed_row.len == 0 or + !old_row.header().wrap) + { + y += 1; + x = 0; + } + } + + // 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; + } 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 = @minimum(self.cursor.x, self.cols - 1); + self.cursor.y = @minimum(self.cursor.y, self.rows - 1); + } + } } /// Writes a basic string into the screen for testing. Newlines (\n) separate @@ -2107,3 +2221,162 @@ test "Screen: resize less rows with populated scrollback" { 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); + const cursor = s.cursor; + try s.resize(3, 3); + + // Cursor should not move + try testing.expectEqual(cursor, s.cursor); + + { + var contents = try s.testString(alloc, .viewport); + defer alloc.free(contents); + try testing.expectEqualStrings(str, contents); + } + { + var contents = try s.testString(alloc, .screen); + defer alloc.free(contents); + try testing.expectEqualStrings(str, contents); + } +} + +test "Screen: resize less cols with reflow but row space" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 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); + { + var contents = try s.testString(alloc, .viewport); + defer alloc.free(contents); + const expected = "1AB\nCD"; + try testing.expectEqualStrings(expected, contents); + } + { + var 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); + + { + var contents = try s.testString(alloc, .viewport); + defer alloc.free(contents); + const expected = "CD\n5EF\nGH"; + try testing.expectEqualStrings(expected, contents); + } + { + var 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); + + { + var contents = try s.testString(alloc, .viewport); + defer alloc.free(contents); + const expected = "CD\n5EF\nGH"; + try testing.expectEqualStrings(expected, contents); + } + { + var contents = try s.testString(alloc, .screen); + defer alloc.free(contents); + const expected = "4AB\nCD\n5EF\nGH"; + 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); + { + var contents = try s.testString(alloc, .viewport); + defer alloc.free(contents); + try testing.expectEqualStrings(str, contents); + } + { + var contents = try s.testString(alloc, .screen); + defer alloc.free(contents); + try testing.expectEqualStrings(str, contents); + } + + // Shrink + try s.resize(3, 5); + { + var contents = try s.testString(alloc, .screen); + defer alloc.free(contents); + try testing.expectEqualStrings(str, contents); + } + { + var contents = try s.testString(alloc, .viewport); + defer alloc.free(contents); + try testing.expectEqualStrings(str, contents); + } + + // Grow again + try s.resize(10, 5); + { + var contents = try s.testString(alloc, .viewport); + defer alloc.free(contents); + try testing.expectEqualStrings(str, contents); + } + { + var contents = try s.testString(alloc, .screen); + defer alloc.free(contents); + try testing.expectEqualStrings(str, contents); + } +} diff --git a/src/terminal/ScreenOld.zig b/src/terminal/ScreenOld.zig deleted file mode 100644 index 1a8890705..000000000 --- a/src/terminal/ScreenOld.zig +++ /dev/null @@ -1,2248 +0,0 @@ -//! 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. -//! -const Screen = @This(); - -// FUTURE: Today this is implemented as a single contiguous ring buffer. -// If we increase the scrollback, we perform a full memory copy. For small -// scrollback, this is pretty cheap. For large (or infinite) scrollback, -// this starts to get pretty nasty. We should change this in the future to -// use a segmented list or something similar. I want to keep all the visible -// area contiguous so its not a simple drop-in. We can take a look at this -// one day. - -const std = @import("std"); -const utf8proc = @import("utf8proc"); -const assert = std.debug.assert; -const Allocator = std.mem.Allocator; - -const color = @import("color.zig"); -const point = @import("point.zig"); -const Selection = @import("Selection.zig"); - -const log = std.log.scoped(.screen); - -/// A row is a set of cells. -pub const Row = []Cell; - -/// 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, - - // 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, -}; - -/// Cell is a single cell within the screen. -pub const Cell = struct { - /// Each cell contains exactly one character. The character is UTF-32 - /// encoded (just the Unicode codepoint). - char: u32, - - /// Foreground and background color. null means to use the default. - fg: ?color.RGB = null, - bg: ?color.RGB = null, - - /// On/off attributes that can be set - attrs: packed struct { - bold: bool = false, - faint: bool = false, - underline: bool = false, - inverse: bool = false, - - /// If 1, this line is soft-wrapped. Only the last cell in a row - /// should have this set. The first cell of the next row is actually - /// part of this row in raw input. - wrap: 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 preceeding - /// wide character (tail) or following (head). - wide_spacer_tail: bool = false, - wide_spacer_head: bool = false, - } = .{}, - - /// True if the cell should be skipped for drawing - pub fn empty(self: Cell) bool { - return self.char == 0; - } - - 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={}", .{@sizeOf(Cell)}); - try std.testing.expectEqual(16, @sizeOf(Cell)); - } -}; - -pub const RowIterator = struct { - screen: *const Screen, - tag: RowIndexTag, - value: usize = 0, - - pub fn next(self: *RowIterator) ?Row { - if (self.value >= self.tag.maxLen(self.screen)) return null; - const idx = self.tag.index(self.value); - const res = self.screen.getRow(idx); - self.value += 1; - return res; - } -}; - -/// 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, -}; - -/// 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 fn maxLen(self: RowIndexTag, screen: *const Screen) usize { - return switch (self) { - // The max of the screen is "bottom" so that we don't read - // past the pre-allocated space. - .screen => screen.bottom, - .viewport => screen.rows, - .active => screen.rows, - .history => screen.bottomOffset(), - }; - } - - /// 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 }, - }; - } -}; - -/// Each screen maintains its own cursor state. -cursor: Cursor = .{}, - -/// Saved cursor saved with DECSC (ESC 7). -saved_cursor: Cursor = .{}, - -/// The full list of rows, including any scrollback. -storage: []Cell, - -/// The top and bottom of the scroll area. The first visible row if the terminal -/// window were scrolled all the way to the top. The last visible row if the -/// terminal were scrolled all the way to the bottom. -top: usize, -bottom: usize, - -/// The offset of the visible area within the storage. This is from the -/// "top" field. So the actual index of the first row is -/// `storage[top + visible_offset]`. -visible_offset: 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 number of rows and columns in the visible space. -rows: usize, -cols: usize, - -/// Initialize a new screen. -pub fn init( - alloc: Allocator, - rows: usize, - cols: usize, - max_scrollback: usize, -) !Screen { - // Allocate enough storage to cover every row and column in the visible - // area. This wastes some up front memory but saves allocations later. - // TODO: dynamically allocate scrollback - const buf = try alloc.alloc(Cell, (rows + max_scrollback) * cols); - std.mem.set(Cell, buf, .{ .char = 0 }); - - return Screen{ - .cursor = .{}, - .storage = buf, - .top = 0, - .bottom = rows, - .visible_offset = 0, - .max_scrollback = max_scrollback, - .rows = rows, - .cols = cols, - }; -} - -pub fn deinit(self: *Screen, alloc: Allocator) void { - alloc.free(self.storage); - self.* = undefined; -} - -/// This returns true if the viewport is anchored at the bottom currently. -pub fn viewportIsBottom(self: Screen) bool { - return self.visible_offset == self.bottomOffset(); -} - -fn bottomOffset(self: Screen) usize { - return self.bottom - self.rows; -} - -/// 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: *const Screen, tag: RowIndexTag) RowIterator { - return .{ .screen = self, .tag = tag }; -} - -/// Region gets the contiguous portions of memory that constitute an -/// entire region. This is an efficient way to clear regions, for example -/// since you can memcpy directly into it. -/// -/// This has two elements because internally we use a ring buffer and -/// so any region can be split into two if it crosses the ring buffer -/// boundary. -pub fn region(self: *const Screen, tag: RowIndexTag) [2][]Cell { - const max_len = tag.maxLen(self); - if (max_len == 0) { - // This region is disabled or empty - return .{ self.storage[0..0], self.storage[0..0] }; - } - - const top = self.rowIndex(tag.index(0)); - const bot = self.rowIndex(tag.index(max_len - 1)); - - // The bottom and top are available in one contiguous slice. - if (bot >= top) { - return .{ - self.storage[top .. bot + self.cols], - self.storage[0..0], // just so its a valid slice, but zero length - }; - } - - // The bottom and top are split into two slices, so we slice to the - // bottom of the storage, then from the top. - return .{ - self.storage[top..self.storage.len], - self.storage[0 .. bot + self.cols], - }; -} - -/// Get a single row in the active area by index (0-indexed). -pub fn getRow(self: Screen, idx: RowIndex) Row { - // Get the index of the first byte of the the row at index. - const real_idx = self.rowIndex(idx); - - // The storage is sliced to return exactly the number of columns. - return self.storage[real_idx .. real_idx + self.cols]; -} - -/// Get a single cell in the active area. row and col are 0-indexed. -pub fn getCell(self: Screen, row: usize, col: usize) *Cell { - assert(row < self.rows); - assert(col < self.cols); - const row_idx = self.rowIndex(.{ .active = row }); - return &self.storage[row_idx + col]; -} - -/// Returns the index for the given row (0-indexed) into the underlying -/// storage array. The row is 0-indexed from the top of the screen. -fn rowIndex(self: *const Screen, idx: RowIndex) usize { - const y = switch (idx) { - .screen => |y| y: { - assert(y < RowIndexTag.screen.maxLen(self)); - break :y y; - }, - - .viewport => |y| y: { - assert(y < RowIndexTag.viewport.maxLen(self)); - break :y y + self.visible_offset; - }, - - .active => |y| y: { - assert(y < RowIndexTag.active.maxLen(self)); - break :y self.bottomOffset() + y; - }, - - .history => |y| y: { - assert(y < RowIndexTag.history.maxLen(self)); - break :y y; - }, - }; - - const val = (self.top + y) * self.cols; - if (val < self.storage.len) return val; - return val - self.storage.len; -} - -/// Returns the total number of rows in the screen. -inline fn totalRows(self: Screen) usize { - return self.storage.len / self.cols; -} - -/// 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". - delta: isize, - - /// Same as delta but scrolling down will not grow the scrollback. - /// Scrolling down at the bottom will do nothing (similar to how - /// delta at the top does nothing). - delta_no_grow: isize, -}; - -/// 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) void { - switch (behavior) { - // Setting viewport offset to zero makes row 0 be at self.top - // which is the top! - .top => self.visible_offset = 0, - - // Calc the bottom by going from top of scrollback (self.top) - // to the end of the storage, then subtract the number of visible - // rows. - .bottom => self.visible_offset = self.bottom - self.rows, - - // TODO: deltas greater than the entire scrollback - .delta => |delta| self.scrollDelta(delta, true), - .delta_no_grow => |delta| self.scrollDelta(delta, false), - } -} - -fn scrollDelta(self: *Screen, delta: isize, grow: bool) void { - // log.info("offsets before: top={} bottom={} visible={}", .{ - // self.top, - // self.bottom, - // self.visible_offset, - // }); - // defer { - // log.info("offsets after: top={} bottom={} visible={}", .{ - // self.top, - // self.bottom, - // self.visible_offset, - // }); - // } - - // If we're scrolling up, then we just subtract and we're done. - if (delta < 0) { - self.visible_offset -|= @intCast(usize, -delta); - return; - } - - // If we're scrolling down, we have more work to do beacuse we - // need to determine if we're overwriting our scrollback. - self.visible_offset +|= @intCast(usize, delta); - if (grow) { - self.bottom +|= @intCast(usize, delta); - } else { - // If we're not growing, then we want to ensure we don't scroll - // off the bottom. Calculate the number of rows we can see. If we - // can see less than the number of rows we have available, then scroll - // back a bit. - const visible_bottom = self.visible_offset + self.rows; - if (visible_bottom > self.bottom) { - self.visible_offset = self.bottom - self.rows; - - // We can also fast-track this case because we know we won't - // be overlapping at all so we can return immediately. - return; - } - } - - // TODO: can optimize scrollback = 0 - - // Determine if we need to clear rows. - assert(@mod(self.storage.len, self.cols) == 0); - const storage_rows = self.storage.len / self.cols; - const visible_zero = self.top + self.visible_offset; - const rows_overlapped = if (visible_zero >= storage_rows) overlap: { - // We're wrapping from the top of the visible area. In this - // scenario, we just check that we have enough space from - // our true visible top to zero. - const visible_top = visible_zero - storage_rows; - const rows_available = self.top - visible_top; - if (rows_available >= self.rows) return; - - // We overlap our missing rows - break :overlap self.rows - rows_available; - } else overlap: { - // First check: if we have enough space in the storage buffer - // FORWARD to accomodate all our rows, then we're fine. - const rows_forward = storage_rows - (self.top + self.visible_offset); - if (rows_forward >= self.rows) return; - - // Second check: if we have enough space PRIOR to zero when - // wrapped, then we're fine. - const rows_wrapped = self.rows - rows_forward; - if (rows_wrapped < self.top) return; - - // We need to clear the rows in the overlap and move the top - // of the scrollback buffer. - break :overlap rows_wrapped - self.top; - }; - - // If we are growing, then we clear the overlap and reset zero - if (grow) { - // Clear our overlap - const clear_start = self.top * self.cols; - const clear_end = clear_start + (rows_overlapped * self.cols); - std.mem.set(Cell, self.storage[clear_start..clear_end], .{ .char = 0 }); - - // Move to accomodate overlap. This deletes scrollback. - self.top = @mod(self.top + rows_overlapped, storage_rows); - - // The new bottom is right up against the new top since we're using - // the full buffer. The bottom is therefore the full size of the storage. - self.bottom = storage_rows; - } - - // Move back the number of overlapped - self.visible_offset -= rows_overlapped; -} - -/// Copy row at src to dst. -pub fn copyRow(self: *Screen, dst: usize, src: usize) void { - const src_row = self.getRow(.{ .active = src }); - const dst_row = self.getRow(.{ .active = dst }); - std.mem.copy(Cell, dst_row, src_row); -} - -/// 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, alloc: Allocator, rows: usize, cols: usize) !void { - defer { - assert(self.cursor.x < self.cols); - assert(self.cursor.y < self.rows); - assert(self.rows == rows); - assert(self.cols == cols); - } - - // If the rows increased, we alloc space for the new rows (w/ existing cols) - // and move the viewport such that the bottom is in view. - if (rows > self.rows) { - var storage = try alloc.alloc( - Cell, - (rows + self.max_scrollback) * self.cols, - ); - - // Copy our screen into the new storage area. Since we're growing - // rows, we know that the full buffer will fit so we copy it in - // order. - const reg = self.region(.screen); - std.mem.copy(Cell, storage, reg[0]); - std.mem.copy(Cell, storage[reg[0].len..], reg[1]); - std.mem.set(Cell, storage[reg[0].len + reg[1].len ..], .{ .char = 0 }); - - // Modify our storage, our lines have grown - alloc.free(self.storage); - self.storage = storage; - - // Fix our row count - self.rows = rows; - - // Store our visible offset so we can move our cursor accordingly. - const old_offset = self.visible_offset; - - // Top is now 0 because we reoriented the ring buffer to be ordered. - // Bottom must be at least "rows" since we always show at least that - // much in the viewport. - self.top = 0; - self.bottom = @maximum(rows, self.bottom); - self.scroll(.{ .bottom = {} }); - - // Move our cursor to account for the new rows. The old offset - // should always be bigger (or the same) than the new offset since - // we are adding rows. - self.cursor.y += old_offset - self.visible_offset; - } - - // 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 storage = try alloc.alloc( - Cell, - (self.rows + self.max_scrollback) * cols, - ); - std.mem.set(Cell, storage, .{ .char = 0 }); - - // 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.Viewport{ - .x = self.cursor.x, - .y = self.cursor.y, - }).toScreen(self); - - // Nothing can fail from this point forward (no "try" expressions) - // so replace our storage. We defer freeing the "old" value because - // we need to access the old screen to copy. - var old = self.*; - defer { - assert(old.storage.ptr != self.storage.ptr); - alloc.free(old.storage); - } - self.storage = storage; - self.cols = cols; - - // Whether we need to move the cursor or not - var new_cursor: ?point.ScreenPoint = null; - - // Iterate over the screen since we need to check for reflow. - var iter = old.rowIterator(.screen); - var y: usize = 0; - while (iter.next()) |row| { - // No matter what we copy this row - var new_row = self.getRow(.{ .screen = y }); - std.mem.copy(Cell, new_row, row); - - // 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) { - assert(new_cursor == null); // should only happen once - new_cursor = .{ .y = y, .x = cursor_pos.x }; - } - - // If no reflow, just keep going - if (!row[row.len - 1].attrs.wrap) { - 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[row.len - 1].attrs.wrap = false; - - // We maintain an x coord so that we can set cursors properly - var x: usize = row.len; - new_row = new_row[x..]; - wrapping: while (iter.next()) |wrapped_row| { - // Trim the row from the right so that we ignore all trailing - // empty chars and don't wrap them. - const trimmed_row = trim: { - var i: usize = wrapped_row.len; - while (i > 0) : (i -= 1) if (!wrapped_row[i - 1].empty()) break; - break :trim wrapped_row[0..i]; - }; - - var wrapped_rem = trimmed_row; - while (wrapped_rem.len > 0) { - // If the wrapped row fits nicely... - if (wrapped_rem.len <= new_row.len) { - // Copy the row - std.mem.copy(Cell, new_row, wrapped_rem); - - // If our cursor is in this line, then we have to move it - // onto the new line because it got unwrapped. - if (cursor_pos.y == iter.value - 1 and new_cursor == null) { - new_cursor = .{ .y = y, .x = cursor_pos.x + x }; - } - - // If this row isn't also wrapped, we're done! - if (!wrapped_rem[wrapped_rem.len - 1].attrs.wrap) { - y += 1; - - // If we were able to copy the entire row then - // we shortened the screen by one. We need to reflect - // this in our viewport. - if (wrapped_rem.len == trimmed_row.len and - self.visible_offset > 0) - { - self.visible_offset -= 1; - self.bottom -= 1; - } - - break :wrapping; - } - - // Wrapped again! - new_row[wrapped_rem.len - 1].attrs.wrap = false; - new_row = new_row[wrapped_rem.len..]; - x += wrapped_rem.len; - break; - } - - // The row doesn't fit, meaning we have to soft-wrap the - // new row but probably at a diff boundary. - std.mem.copy(Cell, new_row, wrapped_rem[0..new_row.len]); - new_row[new_row.len - 1].attrs.wrap = true; - - // We still need to copy the remainder - wrapped_rem = wrapped_rem[new_row.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 < new_row.len) - { - assert(new_cursor == null); // should only happen once - new_cursor = .{ .y = y, .x = x + cursor_pos.x }; - } - - // Move to a new line in our new screen - y += 1; - x = 0; - new_row = self.getRow(.{ .screen = y }); - } - } - } - - // 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; - } - } - - // If our rows got smaller, we trim the scrollback. - if (rows < self.rows) { - var storage = try alloc.alloc( - Cell, - (rows + self.max_scrollback) * self.cols, - ); - - // Get the slices for our full screen. We only copy the end of it - // that fits into our new memory region. We know we have the same - // number of columns in this block so we can just copy as-is. - const reg = self.region(.screen); - - // Trim the empty space off the end. The "end" might go into - // "top" since bottom may be empty or only implies the wraparound - // on the ring buffer. - const top = reg[0]; - const bot = reg[1]; - const bot_trimmed = trim: { - var i: usize = bot.len; - while (i > 0) : (i -= 1) if (!bot[i - 1].empty()) break; - i += self.cols - @mod(i, self.cols); - i = @minimum(bot.len, i); - break :trim bot[0..i]; - }; - const top_trimmed = if (bot.len > 0 and bot_trimmed.len == bot.len) noop: { - // We do nothing here because it means that we hit real content - // in the "bottom" so we don't want to trim zeros off the top - // when they might actually be useful. - break :noop top; - } else trim: { - var i: usize = top.len; - while (i > 0) : (i -= 1) if (!top[i - 1].empty()) break; - i += self.cols - @mod(i, self.cols); - i = @minimum(top.len, i); - break :trim top[0..i]; - }; - - // The trimmed also have to be cleanly divisible by rows since - // the copy and other math below depends on this invariant. - assert(@mod(bot_trimmed.len, self.cols) == 0); - assert(@mod(top_trimmed.len, self.cols) == 0); - - // Copy the top and bottom into the storage - const bot_len = @minimum(bot_trimmed.len, storage.len); - const top_len = @minimum(top_trimmed.len, storage.len - bot_len); - std.mem.copy(Cell, storage, top_trimmed[top_trimmed.len - top_len ..]); - std.mem.copy(Cell, storage[top_len..], bot_trimmed[bot_trimmed.len - bot_len ..]); - std.mem.set(Cell, storage[top_len + bot_len ..], .{ .char = 0 }); - - // Calculate the number of rows we copied since this will be - // our new "bottom". This should always divide cleanly because - // our cols haven't changed. - assert(@mod(top_len + bot_len, self.cols) == 0); - const copied_rows = (top_len + bot_len) / self.cols; - - // Modify our storage - alloc.free(self.storage); - self.storage = storage; - - // If our cursor was past the end of our old value, we pull it back. - if (self.cursor.y >= rows) { - self.cursor.y -= self.rows - rows; - } - - // Fix our row count - self.rows = rows; - - // Top is now 0 because we reoriented the ring buffer to be ordered. - // Bottom must be at least "rows" since we always show at least that - // much in the viewport. - self.top = 0; - self.bottom = @maximum(rows, copied_rows); - //log.warn("bot={} top={} copied={}", .{ bot_len, top_len, copied_rows }); - //log.warn("BOTTOM={}", .{self.bottom}); - self.scroll(.{ .bottom = {} }); - } - - // If our cols got smaller, we have to reflow text. This is the worst - // possible case because we can't do any easy trick sto get reflow, - // we just have to iterate over the screen and "print", wrapping as - // needed. - if (cols < self.cols) { - var storage = try alloc.alloc( - Cell, - (self.rows + self.max_scrollback) * cols, - ); - std.mem.set(Cell, storage, .{ .char = 0 }); - - // Convert our cursor coordinates to screen coordinates because - // we may have to reflow the cursor if the line it is on is moved. - var cursor_pos = (point.Viewport{ - .x = self.cursor.x, - .y = self.cursor.y, - }).toScreen(self); - - // Nothing can fail from this point forward (no "try" expressions) - // so replace our storage. We defer freeing the "old" value because - // we need to access the old screen to copy. - var old = self.*; - defer { - assert(old.storage.ptr != self.storage.ptr); - alloc.free(old.storage); - } - self.storage = storage; - self.cols = cols; - - // Whether we need to move the cursor or not - var new_cursor: ?point.ScreenPoint = null; - - // Iterate over the screen since we need to check for reflow. - var iter = old.rowIterator(.screen); - var x: usize = 0; - var y: usize = 0; - while (iter.next()) |row| { - // Trim the row from the right so that we ignore all trailing - // empty chars and don't wrap them. - const trimmed_row = trim: { - var i: usize = row.len; - while (i > 0) { - if (!row[i - 1].empty()) break; - i -= 1; - } - - break :trim row[0..i]; - }; - - // Copy all the cells into our row. - for (trimmed_row) |cell, i| { - // Soft wrap if we have to - if (x == self.cols) { - var last_cell = self.getCell(y, x - 1); - last_cell.attrs.wrap = true; - x = 0; - y += 1; - } - - // If our y is more than our rows, we need to scroll - if (y >= self.rows) { - self.scroll(.{ .delta = 1 }); - y = self.rows - 1; - x = 0; - } - - // If our cursor is on this point, we need to move it. - if (cursor_pos.y == iter.value - 1 and - cursor_pos.x == i) - { - assert(new_cursor == null); - new_cursor = .{ .x = x, .y = self.visible_offset + y }; - } - - // Copy the old cell, unset the old wrap state - // log.warn("y={} x={} rows={}", .{ y, x, self.rows }); - var new_cell = self.getCell(y, x); - new_cell.* = cell; - new_cell.attrs.wrap = false; - - // Next - x += 1; - } - - // If our cursor is on this line but not in a content area, - // then we just set it to be at the end. - if (cursor_pos.y == iter.value - 1 and - cursor_pos.x >= trimmed_row.len) - { - assert(new_cursor == null); - new_cursor = .{ - .x = @minimum(cursor_pos.x, self.cols - 1), - .y = self.visible_offset + y, - }; - } - - // If we aren't wrapping, then move to the next row - if (trimmed_row.len == 0 or - !trimmed_row[trimmed_row.len - 1].attrs.wrap) - { - y += 1; - x = 0; - } - } - - // 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; - } else { - // TODO: why is this necessary? Without this, neovim will - // crash when we shrink the window to the smallest size - self.cursor.x = @minimum(self.cursor.x, self.cols - 1); - self.cursor.y = @minimum(self.cursor.y, self.rows - 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, alloc: Allocator, rows: usize, cols: usize) !void { - // Resize without reflow not supported for now with scrollback. - assert(self.max_scrollback == 0); - - // Make a copy so we can access the old indexes. - const old = self.*; - - // Reallocate the storage - self.storage = try alloc.alloc(Cell, (rows + self.max_scrollback) * cols); - defer alloc.free(old.storage); - std.mem.set(Cell, self.storage, .{ .char = 0 }); - self.top = 0; - self.bottom = rows; - self.rows = rows; - self.cols = cols; - - // Move our cursor if we have to so it stays on the screen. - self.cursor.x = @minimum(self.cursor.x, self.cols - 1); - self.cursor.y = @minimum(self.cursor.y, self.rows - 1); - - // If we're increasing height, then copy all rows (start at 0). - // Otherwise start at the latest row that includes the bottom row, - // aka strip the top. - var y: usize = if (rows >= old.rows) 0 else old.rows - rows; - const start = y; - const col_end = @minimum(old.cols, cols); - while (y < old.rows) : (y += 1) { - // Copy the old row into the new row, just losing the columsn - // if we got thinner. - const old_row = old.getRow(.{ .viewport = y }); - const new_row = self.getRow(.{ .viewport = y - start }); - std.mem.copy(Cell, new_row, old_row[0..col_end]); - - // If our new row is wider, then we copy zeroes into the rest. - if (new_row.len > old_row.len) { - std.mem.set(Cell, new_row[old_row.len..], .{ .char = 0 }); - } - } - - // If we grew rows, then set the remaining data to zero. - if (rows > old.rows) { - std.mem.set(Cell, self.storage[self.rowIndex(.{ .viewport = old.rows })..], .{ .char = 0 }); - } -} - -/// Returns the raw text associated with a selection. This will unwrap -/// soft-wrapped edges. The returned slice is owned by the caller. -pub fn selectionString(self: Screen, alloc: Allocator, sel: Selection) ![:0]const u8 { - // Get the slices for the string - const slices = self.selectionSlices(sel); - - // We can now know how much space we'll need to store the string. We loop - // over and UTF8-encode and calculate the exact size required. We will be - // off here by at most "newlines" values in the worst case that every - // single line is soft-wrapped. - const newlines = @divFloor(slices.top.len + slices.bot.len, self.cols) + 1; - const chars = chars: { - var count: usize = 0; - const arr = [_][]Cell{ slices.top, slices.bot }; - for (arr) |slice| { - for (slice) |cell| { - var buf: [4]u8 = undefined; - const char = if (cell.char > 0) cell.char else ' '; - count += try std.unicode.utf8Encode(@intCast(u21, char), &buf); - } - } - - break :chars count; - }; - const buf = try alloc.alloc(u8, chars + newlines + 1); - errdefer alloc.free(buf); - - var i: usize = 0; - for (slices.top) |cell, idx| { - // If our index cleanly divides into the col count then we're - // at a newline and we add it. - if (idx > 0 and - @mod(idx + slices.top_offset, self.cols) == 0 and - !slices.top[idx - 1].attrs.wrap) - { - buf[i] = '\n'; - i += 1; - } - - // Skip spacers - if (cell.attrs.wide_spacer_head or - cell.attrs.wide_spacer_tail) continue; - - const char = if (cell.char > 0) cell.char else ' '; - i += try std.unicode.utf8Encode(@intCast(u21, char), buf[i..]); - } - - for (slices.bot) |cell, idx| { - // We don't use "top_offset" here because the bot by definition - // is never offset, it always starts at index 0 so we can just check - // the index directly. - if (@mod(idx, self.cols) == 0) { - // Determine if we soft-wrapped. For the bottom slice this is - // a bit unique because if we're at idx 0, we actually need to - // check the end of the top. - const wrapped = if (idx > 0) - slices.bot[idx - 1].attrs.wrap - else - slices.top[slices.top.len - 1].attrs.wrap; - - if (!wrapped) { - buf[i] = '\n'; - i += 1; - } - } - - // Skip spacers - if (cell.attrs.wide_spacer_head or - cell.attrs.wide_spacer_tail) continue; - - const char = if (cell.char > 0) cell.char else ' '; - i += try std.unicode.utf8Encode(@intCast(u21, char), buf[i..]); - } - - // Add null termination - buf[i] = 0; - - // Realloc so our free length is exactly correct - const result = try alloc.realloc(buf, i + 1); - return result[0..i :0]; -} - -/// 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) struct { - // 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, - top: []Cell, - bot: []Cell, -} { - // Note: this function is tested via selectionString - - assert(sel_raw.start.y < self.totalRows()); - assert(sel_raw.end.y < self.totalRows()); - assert(sel_raw.start.x < self.cols); - assert(sel_raw.end.x < self.cols); - - const sel = sel: { - var sel = sel_raw; - - // 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 }); - if (row[sel.end.x].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 }); - if (row[sel.start.x].attrs.wide_spacer_tail) { - sel.end.x -= 1; - } - } - - break :sel sel; - }; - - // Get the true "top" and "bottom" - const sel_top = sel.topLeft(); - const sel_bot = sel.bottomRight(); - const top = self.rowIndex(.{ .screen = sel_top.y }); - const bot = self.rowIndex(.{ .screen = sel_bot.y }); - - // The bottom and top are available in one contiguous slice. - if (bot >= top) { - return .{ - .top_offset = sel_top.x, - .top = self.storage[top + sel_top.x .. bot + sel_bot.x + 1], - .bot = self.storage[0..0], // just so its a valid slice, but zero length - }; - } - - // The bottom and top are split into two slices, so we slice to the - // bottom of the storage, then from the top. - return .{ - .top_offset = sel_top.x, - .top = self.storage[top + sel_top.x .. self.storage.len], - .bot = self.storage[0 .. bot + sel_bot.x + 1], - }; -} - -/// 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. -pub fn testString(self: Screen, alloc: Allocator, tag: RowIndexTag) ![]const u8 { - const buf = try alloc.alloc(u8, self.storage.len + self.rows + 1); - - var i: usize = 0; - var y: usize = 0; - var rows = self.rowIterator(tag); - while (rows.next()) |row| { - defer y += 1; - - if (y > 0) { - buf[i] = '\n'; - i += 1; - } - - for (row) |cell| { - // TODO: handle character after null - if (cell.char > 0) { - i += try std.unicode.utf8Encode(@intCast(u21, cell.char), buf[i..]); - } - } - } - - // Never render the final newline - const str = std.mem.trimRight(u8, buf[0..i], "\n"); - return try alloc.realloc(buf, str.len); -} - -/// 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. -pub fn testWriteString(self: *Screen, text: []const u8) void { - var y: usize = 0; - var x: usize = 0; - - 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; - continue; - } - - // If we're writing past the end of the active area, scroll. - if (y >= self.rows) { - y -= 1; - self.scroll(.{ .delta = 1 }); - } - - // Get our row - var row = self.getRow(.{ .active = y }); - - // If we're writing past the end, we need to soft wrap. - if (x == self.cols) { - row[x - 1].attrs.wrap = true; - y += 1; - x = 0; - if (y >= self.rows) { - y -= 1; - self.scroll(.{ .delta = 1 }); - } - row = self.getRow(.{ .active = y }); - } - - // If our character is double-width, handle it. - const width = utf8proc.charwidth(c); - assert(width == 1 or width == 2); - switch (width) { - 1 => row[x].char = @intCast(u32, c), - - 2 => { - if (x == self.cols - 1) { - row[x].char = ' '; - row[x].attrs.wide_spacer_head = true; - - // wrap - row[x].attrs.wrap = true; - y += 1; - x = 0; - if (y >= self.rows) { - y -= 1; - self.scroll(.{ .delta = 1 }); - } - row = self.getRow(.{ .active = y }); - } - - row[x].char = @intCast(u32, c); - row[x].attrs.wide = true; - - x += 1; - row[x].char = ' '; - row[x].attrs.wide_spacer_tail = true; - }, - - else => unreachable, - } - - x += 1; - } -} - -test "Screen" { - const testing = std.testing; - const alloc = testing.allocator; - - var s = try init(alloc, 3, 5, 0); - defer s.deinit(alloc); - - // Sanity check that our test helpers work - const str = "1ABCD\n2EFGH\n3IJKL"; - s.testWriteString(str); - { - var 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.ptr, row_other.ptr); - count += 1; - } - - // Should go through all rows - try testing.expectEqual(@as(usize, 3), count); - - // Should be able to easily clear screen - const reg = s.region(.viewport); - std.mem.set(Cell, reg[0], .{ .char = 'A' }); - std.mem.set(Cell, reg[1], .{ .char = 'A' }); - { - var contents = try s.testString(alloc, .screen); - defer alloc.free(contents); - try testing.expectEqualStrings("AAAAA\nAAAAA\nAAAAA", contents); - } -} - -test "Screen: scrolling" { - const testing = std.testing; - const alloc = testing.allocator; - - var s = try init(alloc, 3, 5, 0); - defer s.deinit(alloc); - s.testWriteString("1ABCD\n2EFGH\n3IJKL"); - - try testing.expect(s.viewportIsBottom()); - - // Scroll down, should still be bottom - s.scroll(.{ .delta = 1 }); - try testing.expect(s.viewportIsBottom()); - - // Test our row index - try testing.expectEqual(@as(usize, 5), s.rowIndex(.{ .active = 0 })); - try testing.expectEqual(@as(usize, 10), s.rowIndex(.{ .active = 1 })); - try testing.expectEqual(@as(usize, 0), s.rowIndex(.{ .active = 2 })); - - { - // Test our contents rotated - var contents = try s.testString(alloc, .viewport); - defer alloc.free(contents); - try testing.expectEqualStrings("2EFGH\n3IJKL", contents); - } - - // Scrolling to the bottom does nothing - s.scroll(.{ .bottom = {} }); - - { - // Test our contents rotated - var contents = try s.testString(alloc, .viewport); - defer alloc.free(contents); - try testing.expectEqualStrings("2EFGH\n3IJKL", contents); - } -} - -// TODO -// test "Screen: scrolling more than size" { -// const testing = std.testing; -// const alloc = testing.allocator; -// -// var s = try init(alloc, 3, 5, 3); -// defer s.deinit(alloc); -// s.testWriteString("1ABCD\n2EFGH\n3IJKL"); -// -// try testing.expect(s.viewportIsBottom()); -// -// // Scroll down, should still be bottom -// s.scroll(.{ .delta = 7 }); -// try testing.expect(s.viewportIsBottom()); -// -// // Test our row index -// try testing.expectEqual(@as(usize, 5), s.rowIndex(0)); -// try testing.expectEqual(@as(usize, 10), s.rowIndex(1)); -// try testing.expectEqual(@as(usize, 15), s.rowIndex(2)); -// } - -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(alloc); - s.testWriteString("1ABCD\n2EFGH\n3IJKL"); - s.scroll(.{ .delta = -1 }); - try testing.expect(s.viewportIsBottom()); - - { - // Test our contents rotated - var 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(alloc); - s.testWriteString("1ABCD\n2EFGH\n3IJKL"); - s.scroll(.{ .delta = 1 }); - - // Test our row index - try testing.expectEqual(@as(usize, 5), s.rowIndex(.{ .active = 0 })); - try testing.expectEqual(@as(usize, 10), s.rowIndex(.{ .active = 1 })); - try testing.expectEqual(@as(usize, 15), s.rowIndex(.{ .active = 2 })); - - { - // Test our contents rotated - var contents = try s.testString(alloc, .viewport); - defer alloc.free(contents); - try testing.expectEqualStrings("2EFGH\n3IJKL", contents); - } - - // Scrolling to the bottom - s.scroll(.{ .bottom = {} }); - try testing.expect(s.viewportIsBottom()); - - { - // Test our contents rotated - var contents = try s.testString(alloc, .viewport); - defer alloc.free(contents); - try testing.expectEqualStrings("2EFGH\n3IJKL", contents); - } - - // Scrolling back should make it visible again - s.scroll(.{ .delta = -1 }); - try testing.expect(!s.viewportIsBottom()); - - { - // Test our contents rotated - var contents = try s.testString(alloc, .viewport); - defer alloc.free(contents); - try testing.expectEqualStrings("1ABCD\n2EFGH\n3IJKL", contents); - } - - // Scrolling back again should do nothing - s.scroll(.{ .delta = -1 }); - - { - // Test our contents rotated - var contents = try s.testString(alloc, .viewport); - defer alloc.free(contents); - try testing.expectEqualStrings("1ABCD\n2EFGH\n3IJKL", contents); - } - - // Scrolling to the bottom - s.scroll(.{ .bottom = {} }); - - { - // Test our contents rotated - var contents = try s.testString(alloc, .viewport); - defer alloc.free(contents); - try testing.expectEqualStrings("2EFGH\n3IJKL", contents); - } - - // Scrolling forward with no grow should do nothing - s.scroll(.{ .delta_no_grow = 1 }); - - { - // Test our contents rotated - var contents = try s.testString(alloc, .viewport); - defer alloc.free(contents); - try testing.expectEqualStrings("2EFGH\n3IJKL", contents); - } - - // Scrolling to the top should work - s.scroll(.{ .top = {} }); - - { - // Test our contents rotated - var 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 - const reg = s.region(.active); - std.mem.set(Cell, reg[0], .{ .char = 0 }); - std.mem.set(Cell, reg[1], .{ .char = 0 }); - { - var contents = try s.testString(alloc, .viewport); - defer alloc.free(contents); - try testing.expectEqualStrings("1ABCD", contents); - } - - // Scrolling to the bottom - s.scroll(.{ .bottom = {} }); - - { - // Test our contents rotated - var contents = try s.testString(alloc, .viewport); - defer alloc.free(contents); - try testing.expectEqualStrings("", contents); - } -} - -test "Screen: scrollback empty" { - const testing = std.testing; - const alloc = testing.allocator; - - var s = try init(alloc, 3, 5, 50); - defer s.deinit(alloc); - s.testWriteString("1ABCD\n2EFGH\n3IJKL"); - s.scroll(.{ .delta_no_grow = 1 }); - - { - // Test our contents - var contents = try s.testString(alloc, .viewport); - defer alloc.free(contents); - try testing.expectEqualStrings("1ABCD\n2EFGH\n3IJKL", contents); - } -} - -test "Screen: history region with scrollback" { - const testing = std.testing; - const alloc = testing.allocator; - - var s = try init(alloc, 1, 5, 0); - defer s.deinit(alloc); - - // Write a bunch that WOULD invoke scrollback if exists - const str = "1ABCD\n2EFGH\n3IJKL"; - s.testWriteString(str); - { - var contents = try s.testString(alloc, .screen); - defer alloc.free(contents); - const expected = "3IJKL"; - try testing.expectEqualStrings(expected, contents); - } - - // Verify no scrollback - const reg = s.region(.history); - try testing.expect(reg[0].len == 0); - try testing.expect(reg[1].len == 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(alloc); - - // Write a bunch that WOULD invoke scrollback if exists - const str = "1ABCD\n2EFGH\n3IJKL"; - s.testWriteString(str); - { - var contents = try s.testString(alloc, .viewport); - defer alloc.free(contents); - const expected = "3IJKL"; - try testing.expectEqualStrings(expected, contents); - } - { - // Test our contents - var contents = try s.testString(alloc, .screen); - defer alloc.free(contents); - try testing.expectEqualStrings("1ABCD\n2EFGH\n3IJKL", contents); - } - - // Verify history region - const reg = s.region(.history); - try testing.expect(reg[0].len > 0); - try testing.expect(reg[1].len >= 0); - - { - var 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(alloc); - s.testWriteString("1ABCD\n2EFGH\n3IJKL"); - - // Copy - s.scroll(.{ .delta = 1 }); - s.copyRow(2, 0); - - // Test our contents - var contents = try s.testString(alloc, .viewport); - defer alloc.free(contents); - try testing.expectEqualStrings("2EFGH\n3IJKL\n2EFGH", contents); -} - -test "Screen: selectionString" { - const testing = std.testing; - const alloc = testing.allocator; - - var s = try init(alloc, 3, 5, 0); - defer s.deinit(alloc); - const str = "1ABCD\n2EFGH\n3IJKL"; - s.testWriteString(str); - - { - var contents = try s.selectionString(alloc, .{ - .start = .{ .x = 0, .y = 1 }, - .end = .{ .x = 2, .y = 2 }, - }); - defer alloc.free(contents); - const expected = "2EFGH\n3IJ"; - 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(alloc); - const str = "1ABCD2EFGH3IJKL"; - s.testWriteString(str); - - { - var contents = try s.selectionString(alloc, .{ - .start = .{ .x = 0, .y = 1 }, - .end = .{ .x = 2, .y = 2 }, - }); - 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(alloc); - 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. - s.scroll(.{ .delta = 1 }); - try testing.expect(s.viewportIsBottom()); - try testing.expectEqual(@as(usize, 0), s.rowIndex(.{ .active = 2 })); - s.testWriteString("1ABCD\n2EFGH\n3IJKL"); - - { - var contents = try s.selectionString(alloc, .{ - .start = .{ .x = 0, .y = 1 }, - .end = .{ .x = 2, .y = 2 }, - }); - 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(alloc); - const str = "1A⚡"; - s.testWriteString(str); - - { - var contents = try s.selectionString(alloc, .{ - .start = .{ .x = 0, .y = 0 }, - .end = .{ .x = 3, .y = 0 }, - }); - defer alloc.free(contents); - const expected = str; - try testing.expectEqualStrings(expected, contents); - } - - { - var contents = try s.selectionString(alloc, .{ - .start = .{ .x = 0, .y = 0 }, - .end = .{ .x = 2, .y = 0 }, - }); - defer alloc.free(contents); - const expected = str; - try testing.expectEqualStrings(expected, contents); - } - - { - var contents = try s.selectionString(alloc, .{ - .start = .{ .x = 3, .y = 0 }, - .end = .{ .x = 3, .y = 0 }, - }); - 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(alloc); - const str = "1ABC⚡"; - s.testWriteString(str); - - { - var contents = try s.selectionString(alloc, .{ - .start = .{ .x = 0, .y = 0 }, - .end = .{ .x = 4, .y = 0 }, - }); - defer alloc.free(contents); - const expected = str; - try testing.expectEqualStrings(expected, contents); - } -} - -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(alloc); - const str = "1ABCD\n2EFGH\n3IJKL"; - s.testWriteString(str); - const cursor = s.cursor; - try s.resize(alloc, 10, 5); - - // Cursor should not move - try testing.expectEqual(cursor, s.cursor); - - { - var contents = try s.testString(alloc, .viewport); - defer alloc.free(contents); - try testing.expectEqualStrings(str, contents); - } - { - var 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(alloc); - const str = "1ABCD\n2EFGH\n3IJKL"; - s.testWriteString(str); - const cursor = s.cursor; - try s.resize(alloc, 10, 5); - try testing.expectEqual(@as(usize, 20), s.totalRows()); - - // Cursor should not move - try testing.expectEqual(cursor, s.cursor); - - { - var contents = try s.testString(alloc, .viewport); - defer alloc.free(contents); - try testing.expectEqualStrings(str, contents); - } - { - var 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(alloc); - const str = "1ABCD\n2EFGH\n3IJKL\n4ABCD\n5EFGH"; - s.testWriteString(str); - { - var 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(s.cursor.y, s.cursor.x).char); - - // Resize - try s.resize(alloc, 10, 5); - try testing.expectEqual(@as(usize, 15), s.totalRows()); - - // Cursor should still be on the "4" - try testing.expectEqual(@as(u32, '4'), s.getCell(s.cursor.y, s.cursor.x).char); - // s.cursor.x = 0; - // s.cursor.y = 1; - //try testing.expectEqual(cursor, s.cursor); - - { - var contents = try s.testString(alloc, .viewport); - 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(alloc); - const str = "1ABCD\n2EFGH\n3IJKL"; - s.testWriteString(str); - const cursor = s.cursor; - try s.resize(alloc, 3, 10); - - // Cursor should not move - try testing.expectEqual(cursor, s.cursor); - - { - var contents = try s.testString(alloc, .viewport); - defer alloc.free(contents); - try testing.expectEqualStrings(str, contents); - } - { - var contents = try s.testString(alloc, .screen); - defer alloc.free(contents); - try testing.expectEqualStrings(str, 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(alloc); - const str = "1ABCD2EFGH\n3IJKL"; - s.testWriteString(str); - - // Verify we soft wrapped - { - var 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(s.cursor.y, s.cursor.x).char); - - // Resize and verify we undid the soft wrap because we have space now - try s.resize(alloc, 3, 10); - { - var 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(alloc); - const str = "1ABCD2EFGH\n3IJKL"; - s.testWriteString(str); - - // Verify we soft wrapped - { - var 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(s.cursor.y, s.cursor.x).char); - - // Resize and verify we undid the soft wrap because we have space now - try s.resize(alloc, 3, 10); - { - var 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(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(alloc); - const str = "1ABCD2EFGH\n3IJKL"; - 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(s.cursor.y, s.cursor.x).char); - - // Verify we soft wrapped - { - var 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(alloc, 3, 7); - { - var 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(alloc); - const str = "1ABCD2EFGH3IJKL"; - 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(s.cursor.y, s.cursor.x).char); - - // Verify we soft wrapped - { - var 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(alloc, 3, 15); - { - var 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(alloc); - const str = "1ABCD\n2EFGH\n3IJKL\n4ABCD5EFGH"; - s.testWriteString(str); - { - var 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(s.cursor.y, s.cursor.x).char); - - // Resize - try s.resize(alloc, 3, 10); - - // Cursor should still be on the "5" - log.warn("cursor={}", .{s.cursor}); - try testing.expectEqual(@as(u32, '5'), s.getCell(s.cursor.y, s.cursor.x).char); - - { - var contents = try s.testString(alloc, .viewport); - defer alloc.free(contents); - const expected = "2EFGH\n3IJKL\n4ABCD5EFGH"; - try testing.expectEqualStrings(expected, contents); - } -} - -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(alloc); - const str = "1ABCD\n2EFGH\n3IJKL"; - s.testWriteString(str); - const cursor = s.cursor; - try s.resize(alloc, 1, 5); - - // Cursor should not move - try testing.expectEqual(cursor, s.cursor); - - { - var contents = try s.testString(alloc, .viewport); - defer alloc.free(contents); - const expected = "3IJKL"; - try testing.expectEqualStrings(expected, contents); - } - { - var 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(alloc); - const str = "1ABCD\n2EFGH\n3IJKL"; - 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(s.cursor.y, s.cursor.x).char); - - // Resize - try s.resize(alloc, 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); - - { - var contents = try s.testString(alloc, .viewport); - defer alloc.free(contents); - const expected = "3IJKL"; - try testing.expectEqualStrings(expected, contents); - } - { - var 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(alloc); - const str = "1ABCD\n2EFGH\n3IJKL"; - s.testWriteString(str); - try s.resize(alloc, 1, 5); - - { - var contents = try s.testString(alloc, .screen); - defer alloc.free(contents); - try testing.expectEqualStrings(str, contents); - } - { - var 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(alloc); - const str = "1ABCD\n2EFGH\n3IJKL\n4ABCD\n5EFGH"; - s.testWriteString(str); - { - var contents = try s.testString(alloc, .viewport); - defer alloc.free(contents); - const expected = "3IJKL\n4ABCD\n5EFGH"; - try testing.expectEqualStrings(expected, contents); - } - - // Resize - try s.resize(alloc, 1, 5); - - { - var contents = try s.testString(alloc, .screen); - defer alloc.free(contents); - try testing.expectEqualStrings(str, contents); - } - { - var contents = try s.testString(alloc, .viewport); - defer alloc.free(contents); - const expected = "5EFGH"; - 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(alloc); - const str = "1AB\n2EF\n3IJ"; - s.testWriteString(str); - const cursor = s.cursor; - try s.resize(alloc, 3, 3); - - // Cursor should not move - try testing.expectEqual(cursor, s.cursor); - - { - var contents = try s.testString(alloc, .viewport); - defer alloc.free(contents); - try testing.expectEqualStrings(str, contents); - } - { - var contents = try s.testString(alloc, .screen); - defer alloc.free(contents); - try testing.expectEqualStrings(str, contents); - } -} - -test "Screen: resize less cols with reflow but row space" { - const testing = std.testing; - const alloc = testing.allocator; - - var s = try init(alloc, 3, 5, 0); - defer s.deinit(alloc); - const str = "1ABCD"; - 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(s.cursor.y, s.cursor.x).char); - - try s.resize(alloc, 3, 3); - { - var contents = try s.testString(alloc, .viewport); - defer alloc.free(contents); - const expected = "1AB\nCD"; - try testing.expectEqualStrings(expected, contents); - } - { - var 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(alloc); - const str = "3IJKL\n4ABCD\n5EFGH"; - s.testWriteString(str); - try s.resize(alloc, 3, 3); - - { - var contents = try s.testString(alloc, .viewport); - defer alloc.free(contents); - const expected = "CD\n5EF\nGH"; - try testing.expectEqualStrings(expected, contents); - } - { - var 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(alloc); - const str = "3IJKL\n4ABCD\n5EFGH"; - s.testWriteString(str); - try s.resize(alloc, 3, 3); - - { - var contents = try s.testString(alloc, .viewport); - defer alloc.free(contents); - const expected = "CD\n5EF\nGH"; - try testing.expectEqualStrings(expected, contents); - } - { - var contents = try s.testString(alloc, .screen); - defer alloc.free(contents); - const expected = "4AB\nCD\n5EF\nGH"; - 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(alloc); - const str = "1ABC"; - s.testWriteString(str); - - // Grow - try s.resize(alloc, 10, 5); - { - var contents = try s.testString(alloc, .viewport); - defer alloc.free(contents); - try testing.expectEqualStrings(str, contents); - } - { - var contents = try s.testString(alloc, .screen); - defer alloc.free(contents); - try testing.expectEqualStrings(str, contents); - } - - // Shrink - try s.resize(alloc, 3, 5); - { - var contents = try s.testString(alloc, .screen); - defer alloc.free(contents); - try testing.expectEqualStrings(str, contents); - } - { - var contents = try s.testString(alloc, .viewport); - defer alloc.free(contents); - try testing.expectEqualStrings(str, contents); - } - - // Grow again - try s.resize(alloc, 10, 5); - { - var contents = try s.testString(alloc, .viewport); - defer alloc.free(contents); - try testing.expectEqualStrings(str, contents); - } - { - var contents = try s.testString(alloc, .screen); - defer alloc.free(contents); - try testing.expectEqualStrings(str, contents); - } -} - -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(alloc); - const str = "1ABCD\n2EFGH\n3IJKL"; - s.testWriteString(str); - try s.resizeWithoutReflow(alloc, 10, 5); - - { - var contents = try s.testString(alloc, .viewport); - defer alloc.free(contents); - try testing.expectEqualStrings(str, contents); - } -} - -test "Screen: resize (no reflow) less rows" { - const testing = std.testing; - const alloc = testing.allocator; - - var s = try init(alloc, 3, 5, 0); - defer s.deinit(alloc); - const str = "1ABCD\n2EFGH\n3IJKL"; - s.testWriteString(str); - try s.resizeWithoutReflow(alloc, 2, 5); - - { - var contents = try s.testString(alloc, .viewport); - defer alloc.free(contents); - try testing.expectEqualStrings("2EFGH\n3IJKL", 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(alloc); - const str = "1ABCD\n2EFGH\n3IJKL"; - s.testWriteString(str); - try s.resizeWithoutReflow(alloc, 3, 10); - - { - var 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(alloc); - const str = "1ABCD\n2EFGH\n3IJKL"; - s.testWriteString(str); - try s.resizeWithoutReflow(alloc, 3, 4); - - { - var contents = try s.testString(alloc, .viewport); - defer alloc.free(contents); - const expected = "1ABC\n2EFG\n3IJK"; - try testing.expectEqualStrings(expected, contents); - } -} From c0e36bedd6d303c71b59df75cc7a459046210a22 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 3 Sep 2022 21:26:49 -0700 Subject: [PATCH 32/34] remove dirty for now --- src/terminal/Screen.zig | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index 6bb8d2586..f5155d31c 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -83,9 +83,6 @@ pub const RowHeader = struct { /// Used internally to track if this row has been initialized. init: bool = false, - /// True if one of the cells in this row has been changed - dirty: bool = false, - /// If true, this row is soft-wrapped. The first cell of the next /// row is a continuous of this row. wrap: bool = false, From 6373900dfa3d9f1522c4e37b66d92b43b3192111 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 3 Sep 2022 21:35:43 -0700 Subject: [PATCH 33/34] comment on screen struct --- src/terminal/Screen.zig | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index f5155d31c..0b2a09a42 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -14,6 +14,39 @@ //! * Viewport - The area that is currently visible to the user. This //! can be thought of as the current window into the screen. //! +//! 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"); From f3f60e47d167c1f291f01596ff28e68952689f07 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 3 Sep 2022 21:40:03 -0700 Subject: [PATCH 34/34] clearHistory --- src/terminal/Screen.zig | 71 +++++++++++++++++++++++++++++++++++++++ src/terminal/Terminal.zig | 6 +--- 2 files changed, 72 insertions(+), 5 deletions(-) diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index 0b2a09a42..2535edaf7 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -541,6 +541,19 @@ fn maxCapacity(self: Screen) usize { return (self.rows + self.max_scrollback) * (self.cols + 1); } +/// Clear all the history. This moves the viewport back to the "top", too. +pub fn clearHistory(self: *Screen) void { + // 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; +} + /// Scroll behaviors for the scroll function. pub const Scroll = union(enum) { /// Scroll to the top of the scroll buffer. The first line of the @@ -1601,6 +1614,64 @@ test "Screen: row copy" { try testing.expectEqualStrings("2EFGH\n3IJKL\n2EFGH", 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()); + s.clearHistory(); + try testing.expect(s.viewportIsBottom()); + { + // Test our contents rotated + var contents = try s.testString(alloc, .viewport); + defer alloc.free(contents); + try testing.expectEqualStrings("4ABCD\n5EFGH\n6IJKL", contents); + } + { + // Test our contents rotated + var 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 + var contents = try s.testString(alloc, .viewport); + defer alloc.free(contents); + try testing.expectEqualStrings("1ABCD\n2EFGH\n3IJKL", contents); + } + + s.clearHistory(); + try testing.expect(s.viewportIsBottom()); + { + // Test our contents rotated + var contents = try s.testString(alloc, .viewport); + defer alloc.free(contents); + try testing.expectEqualStrings("4ABCD\n5EFGH\n6IJKL", contents); + } + { + // Test our contents rotated + var contents = try s.testString(alloc, .screen); + defer alloc.free(contents); + try testing.expectEqualStrings("4ABCD\n5EFGH\n6IJKL", contents); + } +} + test "Screen: selectionString" { const testing = std.testing; const alloc = testing.allocator; diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index 7650d032a..81b39fda8 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -806,11 +806,7 @@ pub fn eraseDisplay( self.screen.cursor.pending_wrap = false; }, - .scrollback => { - var it = self.screen.rowIterator(.history); - while (it.next()) |row| row.clear(self.screen.cursor.pen); - @panic("MOVE TO SCREEN SO CIRC BUF IS CORRECT"); - }, + .scrollback => self.screen.clearHistory(), } }