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,