From f6f8fee8040711e17df40ff5c61ab29043d43b5c Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 31 Aug 2022 11:44:39 -0700 Subject: [PATCH] screen2: scrolling (to a certain extent), copying in tests --- src/terminal/Screen2.zig | 401 ++++++++++++++++++++++++++++++++++++-- src/terminal/circ_buf.zig | 1 + 2 files changed, 391 insertions(+), 11 deletions(-) diff --git a/src/terminal/Screen2.zig b/src/terminal/Screen2.zig index 16995f00b..d91f66f43 100644 --- a/src/terminal/Screen2.zig +++ b/src/terminal/Screen2.zig @@ -73,7 +73,7 @@ pub const Cell = struct { /// additional codepoints can be looked up in the hash map on the /// Screen. Since multi-codepoints graphemes are rare, we don't want to /// waste memory for every cell, so we use a side lookup for it. - char: u32, + char: u32 = 0, /// Foreground and background color. attrs.has_{bg/fg} must be checked /// to see if these are useful values. @@ -131,6 +131,16 @@ pub const Row = struct { self.storage[0].header.wrap = v; } + /// Clear the row, making all cells empty. + pub fn clear(self: Row) void { + self.fill(.{}); + } + + /// Fill the entire row with a copy of a single cell. + pub fn fill(self: Row, cell: Cell) void { + std.mem.set(StorageCell, self.storage[1..], .{ .cell = cell }); + } + /// Get a pointr to the cell at column x (0-indexed). This always /// assumes that the cell was modified, notifying the renderer on the /// next call to re-render this cell. Any change detection to avoid @@ -299,7 +309,8 @@ pub fn init( // * Our buffer size is preallocated to fit double our visible space // or the maximum scrollback whichever is smaller. // * We add +1 to cols to fit the row header - const buf_size = (rows + @minimum(max_scrollback, rows)) * (cols + 1); + const buf_size = (rows + max_scrollback) * (cols + 1); + //const buf_size = (rows + @minimum(max_scrollback, rows)) * (cols + 1); return Screen{ .alloc = alloc, @@ -315,6 +326,11 @@ pub fn deinit(self: *Screen) void { self.storage.deinit(self.alloc); } +/// Returns true if the viewport is scrolled to the bottom of the screen. +pub fn viewportIsBottom(self: Screen) bool { + return self.viewport >= RowIndexTag.history.maxLen(&self); +} + /// Returns an iterator that can be used to iterate over all of the rows /// from index zero of the given row index type. This can therefore iterate /// from row 0 of the active area, history, viewport, etc. @@ -341,9 +357,12 @@ pub fn getRow(self: *Screen, index: RowIndex) Row { /// invalid. fn rowOffset(self: Screen, index: RowIndex) usize { // +1 for row header - return index.toScreen().screen * (self.cols + 1); + return index.toScreen(&self).screen * (self.cols + 1); } +/// Returns the number of rows that have actually been written to the +/// screen. This assumes a row is "written" if getRow was ever called +/// on the row. fn rowsWritten(self: Screen) usize { // The number of rows we've actually written into our buffer // This should always be cleanly divisible since we only request @@ -352,6 +371,118 @@ fn rowsWritten(self: Screen) usize { return self.storage.len() / (self.cols + 1); } +/// The number of rows our backing storage supports. This should +/// always be self.rows but we use the backing storage as a source of truth. +fn rowsCapacity(self: Screen) usize { + assert(@mod(self.storage.capacity(), self.cols + 1) == 0); + return self.storage.capacity() / (self.cols + 1); +} + +/// Scroll behaviors for the scroll function. +pub const Scroll = union(enum) { + /// Scroll to the top of the scroll buffer. The first line of the + /// viewport will be the top line of the scroll buffer. + top: void, + + /// Scroll to the bottom, where the last line of the viewport + /// will be the last line of the buffer. TODO: are we sure? + bottom: void, + + /// Scroll up (negative) or down (positive) some fixed amount. + /// Scrolling direction (up/down) describes the direction the viewport + /// moves, not the direction text moves. This is the colloquial way that + /// scrolling is described: "scroll the page down". + delta: isize, + + /// Same as delta but scrolling down will not grow the scrollback. + /// Scrolling down at the bottom will do nothing (similar to how + /// delta at the top does nothing). + delta_no_grow: isize, +}; + +/// Scroll the screen by the given behavior. Note that this will always +/// "move" the screen. It is up to the caller to determine if they actually +/// want to do that yet (i.e. are they writing to the end of the screen +/// or not). +pub fn scroll(self: *Screen, behavior: Scroll) void { + switch (behavior) { + // Setting viewport offset to zero makes row 0 be at self.top + // which is the top! + .top => self.viewport = 0, + + // Bottom is the end of the history area (end of history is the + // top of the active area). + .bottom => self.viewport = RowIndexTag.history.maxLen(self), + + // TODO: deltas greater than the entire scrollback + .delta => |delta| self.scrollDelta(delta, true), + .delta_no_grow => |delta| self.scrollDelta(delta, false), + } +} + +fn scrollDelta(self: *Screen, delta: isize, grow: bool) void { + // If we're scrolling up, then we just subtract and we're done. + // We just clamp at 0 which blocks us from scrolling off the top. + if (delta < 0) { + self.viewport -|= @intCast(usize, -delta); + return; + } + + // If we're scrolling down and not growing, then we just + // add to the viewport and clamp at the bottom. + const viewport_max = RowIndexTag.history.maxLen(self); + if (!grow) { + self.viewport = @minimum( + viewport_max, + self.viewport +| @intCast(usize, delta), + ); + return; + } + + // { + // const rows_capacity = self.rowsCapacity(); + // const rows_written = self.rowsWritten(); + // log.warn("rows_written={} rows_capacity={} vp={} vp_new={}", .{ + // rows_written, + // rows_capacity, + // self.viewport, + // self.viewport + @intCast(usize, delta), + // }); + // } + + // Add our delta to our viewport. If we're less than the max currently + // allowed to scroll to the bottom (the end of the history), then we + // have space and we just return. + self.viewport +|= @intCast(usize, delta); + if (self.viewport <= viewport_max) return; + + // Our viewport is bigger than our max. The number of new rows we need + // in our buffer is our value minus the max. + const new_rows_needed = self.viewport - viewport_max; + + // If we can fit this into our existing capacity, then just grow to it. + const rows_capacity = self.rowsCapacity(); + const rows_written = self.rowsWritten(); + if (rows_written + new_rows_needed <= rows_capacity) { + // Ensure we have "written" this data into the circular buffer. + _ = self.storage.getPtrSlice( + self.viewport * (self.cols + 1), + self.cols + 1, + ); + return; + } + + // We can't fit our new rows into the capacity, so the amount + // between what we need and the capacity needs to be deleted. We + // scroll "up" by that much to offset this. + const rows_to_delete = (rows_written + new_rows_needed) - rows_capacity; + self.viewport -= rows_to_delete; + self.storage.deleteOldest(rows_to_delete * (self.cols + 1)); + + // If we grew down like this, we must be at the bottom. + assert(self.viewportIsBottom()); +} + /// Writes a basic string into the screen for testing. Newlines (\n) separate /// each row. If a line is longer than the available columns, soft-wrapping /// will occur. This will automatically handle basic wide chars. @@ -372,8 +503,7 @@ pub fn testWriteString(self: *Screen, text: []const u8) void { // If we're writing past the end of the active area, scroll. if (y >= self.rows) { y -= 1; - @panic("TODO"); - //self.scroll(.{ .delta = 1 }); + self.scroll(.{ .delta = 1 }); } // Get our row @@ -386,8 +516,7 @@ pub fn testWriteString(self: *Screen, text: []const u8) void { x = 0; if (y >= self.rows) { y -= 1; - @panic("TODO"); - //self.scroll(.{ .delta = 1 }); + self.scroll(.{ .delta = 1 }); } row = self.getRow(.{ .active = y }); } @@ -413,8 +542,7 @@ pub fn testWriteString(self: *Screen, text: []const u8) void { x = 0; if (y >= self.rows) { y -= 1; - @panic("TODO"); - //self.scroll(.{ .delta = 1 }); + self.scroll(.{ .delta = 1 }); } row = self.getRow(.{ .active = y }); } @@ -473,18 +601,269 @@ pub fn testString(self: *Screen, alloc: Allocator, tag: RowIndexTag) ![]const u8 return try alloc.realloc(buf, str.len); } -test { +test "Screen" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 3, 5, 0); + var s = try init(alloc, 5, 5, 0); defer s.deinit(); + try testing.expect(s.rowsWritten() == 0); + // Sanity check that our test helpers work const str = "1ABCD\n2EFGH\n3IJKL"; s.testWriteString(str); + try testing.expect(s.rowsWritten() == 3); { var contents = try s.testString(alloc, .screen); defer alloc.free(contents); try testing.expectEqualStrings(str, contents); } + + // Test the row iterator + var count: usize = 0; + var iter = s.rowIterator(.viewport); + while (iter.next()) |row| { + // Rows should be pointer equivalent to getRow + const row_other = s.getRow(.{ .viewport = count }); + try testing.expectEqual(row.storage.ptr, row_other.storage.ptr); + count += 1; + } + + // Should go through all rows + try testing.expectEqual(@as(usize, 3), count); + + // Should be able to easily clear screen + { + var it = s.rowIterator(.viewport); + while (it.next()) |row| row.fill(.{ .char = 'A' }); + var contents = try s.testString(alloc, .screen); + defer alloc.free(contents); + try testing.expectEqualStrings("AAAAA\nAAAAA\nAAAAA", contents); + } +} + +test "Screen: scrolling" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 3, 5, 0); + defer s.deinit(); + s.testWriteString("1ABCD\n2EFGH\n3IJKL"); + try testing.expect(s.viewportIsBottom()); + + // Scroll down, should still be bottom + s.scroll(.{ .delta = 1 }); + try testing.expect(s.viewportIsBottom()); + + // Test our row index + try testing.expectEqual(@as(usize, 0), s.rowOffset(.{ .active = 0 })); + try testing.expectEqual(@as(usize, 6), s.rowOffset(.{ .active = 1 })); + try testing.expectEqual(@as(usize, 12), s.rowOffset(.{ .active = 2 })); + + { + // Test our contents rotated + var contents = try s.testString(alloc, .viewport); + defer alloc.free(contents); + try testing.expectEqualStrings("2EFGH\n3IJKL", contents); + } + + // Scrolling to the bottom does nothing + s.scroll(.{ .bottom = {} }); + + { + // Test our contents rotated + var contents = try s.testString(alloc, .viewport); + defer alloc.free(contents); + try testing.expectEqualStrings("2EFGH\n3IJKL", contents); + } +} + +test "Screen: scroll down from 0" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 3, 5, 0); + defer s.deinit(); + s.testWriteString("1ABCD\n2EFGH\n3IJKL"); + + // Scrolling up does nothing, but allows it + s.scroll(.{ .delta = -1 }); + try testing.expect(s.viewportIsBottom()); + + { + // Test our contents rotated + var contents = try s.testString(alloc, .viewport); + defer alloc.free(contents); + try testing.expectEqualStrings("1ABCD\n2EFGH\n3IJKL", contents); + } +} + +test "Screen: scrollback" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 3, 5, 1); + defer s.deinit(); + s.testWriteString("1ABCD\n2EFGH\n3IJKL"); + s.scroll(.{ .delta = 1 }); + + { + // Test our contents rotated + var contents = try s.testString(alloc, .viewport); + defer alloc.free(contents); + try testing.expectEqualStrings("2EFGH\n3IJKL", contents); + } + + // Scrolling to the bottom + s.scroll(.{ .bottom = {} }); + try testing.expect(s.viewportIsBottom()); + + { + // Test our contents rotated + var contents = try s.testString(alloc, .viewport); + defer alloc.free(contents); + try testing.expectEqualStrings("2EFGH\n3IJKL", contents); + } + + // Scrolling back should make it visible again + s.scroll(.{ .delta = -1 }); + try testing.expect(!s.viewportIsBottom()); + + { + // Test our contents rotated + var contents = try s.testString(alloc, .viewport); + defer alloc.free(contents); + try testing.expectEqualStrings("1ABCD\n2EFGH\n3IJKL", contents); + } + + // Scrolling back again should do nothing + s.scroll(.{ .delta = -1 }); + + { + // Test our contents rotated + var contents = try s.testString(alloc, .viewport); + defer alloc.free(contents); + try testing.expectEqualStrings("1ABCD\n2EFGH\n3IJKL", contents); + } + + // Scrolling to the bottom + s.scroll(.{ .bottom = {} }); + + { + // Test our contents rotated + var contents = try s.testString(alloc, .viewport); + defer alloc.free(contents); + try testing.expectEqualStrings("2EFGH\n3IJKL", contents); + } + + // Scrolling forward with no grow should do nothing + s.scroll(.{ .delta_no_grow = 1 }); + + { + // Test our contents rotated + var contents = try s.testString(alloc, .viewport); + defer alloc.free(contents); + try testing.expectEqualStrings("2EFGH\n3IJKL", contents); + } + + // Scrolling to the top should work + s.scroll(.{ .top = {} }); + + { + // Test our contents rotated + var contents = try s.testString(alloc, .viewport); + defer alloc.free(contents); + try testing.expectEqualStrings("1ABCD\n2EFGH\n3IJKL", contents); + } + + // Should be able to easily clear active area only + var it = s.rowIterator(.active); + while (it.next()) |row| row.clear(); + { + var contents = try s.testString(alloc, .viewport); + defer alloc.free(contents); + try testing.expectEqualStrings("1ABCD", contents); + } + + // Scrolling to the bottom + s.scroll(.{ .bottom = {} }); + + { + // Test our contents rotated + var contents = try s.testString(alloc, .viewport); + defer alloc.free(contents); + try testing.expectEqualStrings("", contents); + } +} + +test "Screen: scrollback empty" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 3, 5, 50); + defer s.deinit(); + s.testWriteString("1ABCD\n2EFGH\n3IJKL"); + s.scroll(.{ .delta_no_grow = 1 }); + + { + // Test our contents + var contents = try s.testString(alloc, .viewport); + defer alloc.free(contents); + try testing.expectEqualStrings("1ABCD\n2EFGH\n3IJKL", contents); + } +} + +test "Screen: history region with no scrollback" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 1, 5, 0); + defer s.deinit(); + + // Write a bunch that WOULD invoke scrollback if exists + const str = "1ABCD\n2EFGH\n3IJKL"; + s.testWriteString(str); + { + var contents = try s.testString(alloc, .screen); + defer alloc.free(contents); + const expected = "3IJKL"; + try testing.expectEqualStrings(expected, contents); + } + + // Verify no scrollback + var it = s.rowIterator(.history); + var count: usize = 0; + while (it.next()) |_| count += 1; + try testing.expect(count == 0); +} + +test "Screen: history region with scrollback" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 1, 5, 2); + defer s.deinit(); + + // Write a bunch that WOULD invoke scrollback if exists + const str = "1ABCD\n2EFGH\n3IJKL"; + s.testWriteString(str); + { + var contents = try s.testString(alloc, .viewport); + defer alloc.free(contents); + const expected = "3IJKL"; + try testing.expectEqualStrings(expected, contents); + } + { + // Test our contents + var contents = try s.testString(alloc, .screen); + defer alloc.free(contents); + try testing.expectEqualStrings("1ABCD\n2EFGH\n3IJKL", contents); + } + + { + var contents = try s.testString(alloc, .history); + defer alloc.free(contents); + const expected = "1ABCD\n2EFGH"; + try testing.expectEqualStrings(expected, contents); + } } diff --git a/src/terminal/circ_buf.zig b/src/terminal/circ_buf.zig index 971b332dd..7f2c823f8 100644 --- a/src/terminal/circ_buf.zig +++ b/src/terminal/circ_buf.zig @@ -75,6 +75,7 @@ pub fn CircBuf(comptime T: type) type { /// the offset can only be within the size of the buffer. pub fn getPtrSlice(self: *Self, offset: usize, slice_len: usize) [2][]T { assert(slice_len > 0); + assert(offset + slice_len <= self.capacity()); // End offset is the last offset (exclusive) for our slice. // We use exclusive because it makes the math easier and it