diff --git a/src/apprt/glfw.zig b/src/apprt/glfw.zig index 31c117c36..892c1ed05 100644 --- a/src/apprt/glfw.zig +++ b/src/apprt/glfw.zig @@ -59,6 +59,13 @@ pub const App = struct { while (true) { // Wait for any events from the app event loop. wakeup will post // an empty event so that this will return. + // + // Warning: a known issue on macOS is that this will block while + // a resize event is actively happening, which will prevent the + // app tick from happening. I don't know know a way around this + // but its not a big deal since we don't use glfw for the official + // mac app, but noting it in case anyone builds for macos using + // glfw. glfw.waitEvents(); // Tick the terminal app diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index c9f9b8c7b..a8c798d93 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -289,6 +289,17 @@ pub const Row = struct { return self.storage.len - 1; } + /// Returns true if the row only has empty characters. This ignores + /// styling (i.e. styling does not count as non-empty). + pub fn isEmpty(self: Row) bool { + const len = self.storage.len; + for (self.storage[1..len]) |cell| { + if (cell.cell.char != 0) return false; + } + + return true; + } + /// Clear the row, making all cells empty. pub fn clear(self: Row, pen: Cell) void { var empty_pen = pen; @@ -402,6 +413,9 @@ pub const Row = struct { // If we have graphemes, clear first to unset them. if (self.storage[0].header.flags.grapheme) self.clear(.{}); + // Copy the flags + self.storage[0].header.flags = src.storage[0].header.flags; + // Always mark the row as dirty for this. self.storage[0].header.flags.dirty = true; @@ -819,6 +833,7 @@ pub fn clone(self: *Screen, alloc: Allocator, top: RowIndex, bottom: RowIndex) ! // copy. const real_y = @min(bot_y, max_y); const real_height = (real_y - top_y) + 1; + //log.warn("bot={} max={} top={} real={}", .{ bot_y, max_y, top_y, real_y }); // Init a new screen that exactly fits the height. The height is the // non-real value because we still want the requested height by the @@ -1694,6 +1709,26 @@ pub fn resizeWithoutReflow(self: *Screen, rows: usize, cols: usize) !void { // If we're resizing to the same size, do nothing. if (self.cols == cols and self.rows == rows) return; + // The number of no-character lines after our cursor. This is used + // to trim those lines on a resize first without generating history. + // This is only done if we don't have history yet. + // + // This matches macOS Terminal.app behavior. I chose to match that + // behavior because it seemed fine in an ocean of differing behavior + // between terminal apps. I'm completely open to changing it as long + // as resize behavior isn't regressed in a user-hostile way. + const trailing_blank_lines = blank: { + // If we aren't changing row length, then don't bother calculating + // because we aren't going to trim. + if (self.rows == rows) break :blank 0; + + // If there is history, blank line counting is disabled and + // we generate scrollback. Why? Terminal.app does it, seems... fine. + if (self.history > 0) break :blank 0; + + break :blank self.trailingBlankLines(); + }; + // Make a copy so we can access the old indexes. var old = self.*; errdefer self.* = old; @@ -1702,10 +1737,14 @@ pub fn resizeWithoutReflow(self: *Screen, rows: usize, cols: usize) !void { self.rows = rows; self.cols = cols; + // The end of the screen is the rows we wrote minus any blank lines + // we're trimming. + const end_of_screen_y = old.rowsWritten() - trailing_blank_lines; + // Calculate our buffer size. This is going to be either the old data // with scrollback or the max capacity of our new size. We prefer the old // length so we can save all the data (ignoring col truncation). - const old_len = @max(old.rowsWritten(), rows) * (cols + 1); + const old_len = @max(end_of_screen_y, rows) * (cols + 1); const new_max_capacity = self.maxCapacity(); const buf_size = @min(old_len, new_max_capacity); @@ -1727,10 +1766,18 @@ pub fn resizeWithoutReflow(self: *Screen, rows: usize, cols: usize) !void { // Rewrite all our rows var y: usize = 0; - var row_it = old.rowIterator(.screen); - while (row_it.next()) |old_row| { + for (0..end_of_screen_y) |it_y| { + const old_row = old.getRow(.{ .screen = it_y }); + // If we're past the end, scroll if (y >= self.rows) { + // If we're shrinking rows then its possible we'll trim scrollback + // and we have to account for how much we actually trimmed and + // reflect that in the cursor. + if (self.storage.len() >= self.maxCapacity()) { + old.cursor.y -|= 1; + } + y -= 1; try self.scroll(.{ .delta = 1 }); } @@ -1777,9 +1824,6 @@ pub fn resize(self: *Screen, rows: usize, cols: usize) !void { return; } - // We grow rows first so we can make space for more reflow - if (rows > self.rows) try self.resizeWithoutReflow(rows, cols); - // 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) { @@ -1818,23 +1862,33 @@ pub fn resize(self: *Screen, rows: usize, cols: usize) !void { while (iter.next()) |old_row| { // If we're past the end, scroll if (y >= self.rows) { - y -= 1; try self.scroll(.{ .delta = 1 }); + y -= 1; } - // Get this row - var new_row = self.getRow(.{ .active = y }); - try new_row.copyRow(old_row); - // We need to check if our cursor was on this line. If so, // we set the new cursor. if (cursor_pos.y == iter.value - 1) { assert(new_cursor == null); // should only happen once - new_cursor = .{ .y = self.rowsWritten() - 1, .x = cursor_pos.x }; + new_cursor = .{ .y = self.history + y, .x = cursor_pos.x }; } - // If no reflow, just keep going + // At this point, we're always at x == 0 so we can just copy + // the row (we know old.cols < self.cols). + var new_row = self.getRow(.{ .active = y }); + try new_row.copyRow(old_row); if (!old_row.header().flags.wrap) { + // If we have no reflow, we attempt to extend any stylized + // cells at the end of the line if there is one. + const len = old_row.lenCells(); + const end = new_row.getCell(len - 1); + if ((end.char == 0 or end.char == ' ') and !end.empty()) { + for (len..self.cols) |x| { + const cell = new_row.getCellPtr(x); + cell.* = end; + } + } + y += 1; continue; } @@ -1850,10 +1904,14 @@ pub fn resize(self: *Screen, rows: usize, cols: usize) !void { var x: usize = old.cols; wrapping: while (iter.next()) |wrapped_row| { // Trim the row from the right so that we ignore all trailing - // empty chars and don't wrap them. + // empty chars and don't wrap them. We only do this if the + // row is NOT wrapped again because the whitespace would be + // meaningful. const wrapped_cells = trim: { var i: usize = old.cols; - while (i > 0) : (i -= 1) if (!wrapped_row.getCell(i - 1).empty()) break; + if (!wrapped_row.header().flags.wrap) { + while (i > 0) : (i -= 1) if (!wrapped_row.getCell(i - 1).empty()) break; + } break :trim wrapped_row.storage[1 .. i + 1]; }; @@ -1882,18 +1940,13 @@ pub fn resize(self: *Screen, rows: usize, cols: usize) !void { cursor_pos.x < copy_len and new_cursor == null) { - new_cursor = .{ .y = self.rowsWritten() - 1, .x = x + cursor_pos.x }; + new_cursor = .{ .y = self.history + y, .x = x + cursor_pos.x }; } // We copied the full amount left in this wrapped row. if (copy_len == wrapped_cells_rem) { // If this row isn't also wrapped, we're done! if (!wrapped_row.header().flags.wrap) { - // If we were able to copy the entire row then - // we shortened the screen by one. We need to reflect - // this in our viewport. - if (wrapped_i == 0 and old.viewport > 0) old.viewport -= 1; - y += 1; break :wrapping; } @@ -1919,8 +1972,6 @@ pub fn resize(self: *Screen, rows: usize, cols: usize) !void { new_row = self.getRow(.{ .active = y }); } } - - self.viewport = old.viewport; } // If we have a new cursor, we need to convert that to a viewport @@ -1932,6 +1983,10 @@ pub fn resize(self: *Screen, rows: usize, cols: usize) !void { } } + // We grow rows after cols so that we can do our unwrapping/reflow + // before we do a no-reflow grow. + if (rows > self.rows) try self.resizeWithoutReflow(rows, cols); + // If our rows got smaller, we trim the scrollback. We do this after // handling cols growing so that we can save as many lines as we can. // We do it before cols shrinking so we can save compute on that operation. @@ -1971,71 +2026,94 @@ pub fn resize(self: *Screen, rows: usize, cols: usize) !void { self.viewport = 0; self.history = 0; - // Iterate over the screen since we need to check for reflow. - var iter = old.rowIterator(.screen); - var x: usize = 0; + // Iterate over the screen since we need to check for reflow. We + // clear all the trailing blank lines so that shells like zsh and + // fish that often clear the display below don't force us to have + // scrollback. + var old_y: usize = 0; + const end_y = RowIndexTag.screen.maxLen(&old) - old.trailingBlankLines(); var y: usize = 0; - while (iter.next()) |old_row| { - // Trim the row from the right so that we ignore all trailing - // empty chars and don't wrap them. - const trimmed_row = trim: { - var i: usize = old.cols; - while (i > 0) : (i -= 1) if (!old_row.getCell(i - 1).empty()) break; - break :trim old_row.storage[1 .. i + 1]; - }; + while (old_y < end_y) : (old_y += 1) { + const old_row = old.getRow(.{ .screen = old_y }); + const old_row_wrapped = old_row.header().flags.wrap; + const trimmed_row = self.trimRowForResizeLessCols(&old, old_row); - // Copy all the cells into our row. - for (trimmed_row, 0..) |cell, i| { - // Soft wrap if we have to - if (x == self.cols) { - var row = self.getRow(.{ .active = y }); - row.setWrapped(true); - x = 0; - y += 1; - } + // If our y is more than our rows, we need to scroll + if (y >= self.rows) { + try self.scroll(.{ .delta = 1 }); + y -= 1; + } - // If our y is more than our rows, we need to scroll - if (y >= self.rows) { - try 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) - { + // Fast path: our old row is not wrapped AND our old row fits + // into our new smaller size. In this case, we just do a fast + // copy and move on. + if (!old_row_wrapped and trimmed_row.len <= self.cols) { + // If our cursor is on this line, then set the new cursor. + if (cursor_pos.y == old_y) { assert(new_cursor == null); - new_cursor = .{ .x = x, .y = self.viewport + y }; + new_cursor = .{ .x = cursor_pos.x, .y = self.history + y }; } - // Copy the old cell, unset the old wrap state - // log.warn("y={} x={} rows={}", .{ y, x, self.rows }); - var new_cell = self.getCellPtr(.active, y, x); - new_cell.* = cell.cell; + const row = self.getRow(.{ .active = y }); + fastmem.copy( + StorageCell, + row.storage[1..], + trimmed_row, + ); - // 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 = @min(cursor_pos.x, self.cols - 1), - .y = self.viewport + y, - }; - } - - // If we aren't wrapping, then move to the next row - if (trimmed_row.len == 0 or - !old_row.header().flags.wrap) - { y += 1; - x = 0; + continue; + } + + // Slow path: the row is wrapped or doesn't fit so we have to + // wrap ourselves. In this case, we basically just "print and wrap" + var row = self.getRow(.{ .active = y }); + var x: usize = 0; + var cur_old_row = old_row; + var cur_old_row_wrapped = old_row_wrapped; + var cur_trimmed_row = trimmed_row; + while (true) { + for (cur_trimmed_row, 0..) |cell, old_x| { + // Soft wrap if we have to. + if (x == self.cols) { + row.setWrapped(true); + x = 0; + y += 1; + + // Wrapping can cause us to overflow our visible area. + // If so, scroll. + if (y >= self.rows) { + try self.scroll(.{ .delta = 1 }); + y -= 1; + } + + row = self.getRow(.{ .active = y }); + } + + // If our cursor is on this char, then set the new cursor. + if (cursor_pos.y == old_y and cursor_pos.x == old_x) { + assert(new_cursor == null); + new_cursor = .{ .x = x, .y = self.history + y }; + } + + // Write the cell + var new_cell = row.getCellPtr(x); + new_cell.* = cell.cell; + x += 1; + } + + // If we're done wrapping, we move on. + if (!cur_old_row_wrapped) { + y += 1; + break; + } + + // If the old row is wrapped we continue with the loop with + // the next row. + old_y += 1; + cur_old_row = old.getRow(.{ .screen = old_y }); + cur_old_row_wrapped = cur_old_row.header().flags.wrap; + cur_trimmed_row = self.trimRowForResizeLessCols(&old, cur_old_row); } } @@ -2055,6 +2133,56 @@ pub fn resize(self: *Screen, rows: usize, cols: usize) !void { } } +/// Counts the number of trailing lines from the cursor that are blank. +/// This is specifically used for resizing and isn't meant to be a general +/// purpose tool. +fn trailingBlankLines(self: *Screen) usize { + // Start one line below our cursor and continue to the last line + // of the screen or however many rows we have written. + const start = self.cursor.y + 1; + const end = @min(self.rowsWritten(), self.rows); + if (start >= end) return 0; + + var blank: usize = 0; + for (0..(end - start)) |i| { + const y = end - i - 1; + const row = self.getRow(.{ .active = y }); + if (!row.isEmpty()) break; + blank += 1; + } + + return blank; +} + +/// When resizing to less columns, this trims the row from the right +/// so we don't unnecessarily wrap. This will freely throw away trailing +/// colored but empty (character) cells. This matches Terminal.app behavior, +/// which isn't strictly correct but seems nice. +fn trimRowForResizeLessCols(self: *Screen, old: *Screen, row: Row) []StorageCell { + assert(old.cols > self.cols); + + // We only trim if this isn't a wrapped line. If its a wrapped + // line we need to keep all the empty cells because they are + // meaningful whitespace before our wrap. + if (row.header().flags.wrap) return row.storage[1 .. old.cols + 1]; + + var i: usize = old.cols; + while (i > 0) : (i -= 1) { + const cell = row.getCell(i - 1); + if (!cell.empty()) { + // If we are beyond our new width and this is just + // an empty-character stylized cell, then we trim it. + if (i > self.cols) { + if (cell.char == 0 or cell.char == ' ') continue; + } + + break; + } + } + + return row.storage[1 .. i + 1]; +} + /// Writes a basic string into the screen for testing. Newlines (\n) separate /// each row. If a line is longer than the available columns, soft-wrapping /// will occur. This will automatically handle basic wide chars. @@ -2232,6 +2360,46 @@ pub fn testString(self: *Screen, alloc: Allocator, tag: RowIndexTag) ![]const u8 return try alloc.realloc(buf, str.len); } +test "Row: isEmpty with no data" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 5, 5, 0); + defer s.deinit(); + + const row = s.getRow(.{ .active = 0 }); + try testing.expect(row.isEmpty()); +} + +test "Row: isEmpty with a character at the end" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 5, 5, 0); + defer s.deinit(); + + const row = s.getRow(.{ .active = 0 }); + const cell = row.getCellPtr(4); + cell.*.char = 'A'; + try testing.expect(!row.isEmpty()); +} + +test "Row: isEmpty with only styled cells" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 5, 5, 0); + defer s.deinit(); + + const row = s.getRow(.{ .active = 0 }); + for (0..s.cols) |x| { + const cell = row.getCellPtr(x); + cell.*.bg = .{ .r = 0xAA, .g = 0xBB, .b = 0xCC }; + cell.*.attrs.has_bg = true; + } + try testing.expect(row.isEmpty()); +} + test "Row: clear with graphemes" { const testing = std.testing; const alloc = testing.allocator; @@ -3593,6 +3761,78 @@ test "Screen: resize (no reflow) less rows" { } } +test "Screen: resize (no reflow) less rows trims blank lines" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 3, 5, 0); + defer s.deinit(); + const str = "1ABCD"; + try s.testWriteString(str); + + // Write only a background color into the remaining rows + for (1..s.rows) |y| { + const row = s.getRow(.{ .active = y }); + for (0..s.cols) |x| { + const cell = row.getCellPtr(x); + cell.*.bg = .{ .r = 0xFF, .g = 0, .b = 0 }; + cell.*.attrs.has_bg = true; + } + } + + // Make sure our cursor is at the end of the first line + s.cursor.x = 4; + s.cursor.y = 0; + const cursor = s.cursor; + + try s.resizeWithoutReflow(2, 5); + + // Cursor should not move + try testing.expectEqual(cursor, s.cursor); + + { + var contents = try s.testString(alloc, .viewport); + defer alloc.free(contents); + try testing.expectEqualStrings("1ABCD", contents); + } +} + +test "Screen: resize (no reflow) more rows trims blank lines" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 3, 5, 0); + defer s.deinit(); + const str = "1ABCD"; + try s.testWriteString(str); + + // Write only a background color into the remaining rows + for (1..s.rows) |y| { + const row = s.getRow(.{ .active = y }); + for (0..s.cols) |x| { + const cell = row.getCellPtr(x); + cell.*.bg = .{ .r = 0xFF, .g = 0, .b = 0 }; + cell.*.attrs.has_bg = true; + } + } + + // Make sure our cursor is at the end of the first line + s.cursor.x = 4; + s.cursor.y = 0; + const cursor = s.cursor; + + try s.resizeWithoutReflow(7, 5); + + // Cursor should not move + try testing.expectEqual(cursor, s.cursor); + + { + var contents = try s.testString(alloc, .viewport); + defer alloc.free(contents); + try testing.expectEqualStrings("1ABCD", contents); + } +} + test "Screen: resize (no reflow) more cols" { const testing = std.testing; const alloc = testing.allocator; @@ -3721,6 +3961,45 @@ test "Screen: resize (no reflow) grapheme copy" { } } +test "Screen: resize (no reflow) more rows with soft wrapping" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 3, 2, 3); + defer s.deinit(); + const str = "1A2B\n3C4E\n5F6G"; + try s.testWriteString(str); + + // Every second row should be wrapped + { + var y: usize = 0; + while (y < 6) : (y += 1) { + const row = s.getRow(.{ .screen = y }); + const wrapped = (y % 2 == 0); + try testing.expectEqual(wrapped, row.header().flags.wrap); + } + } + + // Resize + try s.resizeWithoutReflow(10, 2); + { + var contents = try s.testString(alloc, .viewport); + defer alloc.free(contents); + const expected = "1A\n2B\n3C\n4E\n5F\n6G"; + try testing.expectEqualStrings(expected, contents); + } + + // Every second row should be wrapped + { + var y: usize = 0; + while (y < 6) : (y += 1) { + const row = s.getRow(.{ .screen = y }); + const wrapped = (y % 2 == 0); + try testing.expectEqual(wrapped, row.header().flags.wrap); + } + } +} + test "Screen: resize more rows no scrollback" { const testing = std.testing; const alloc = testing.allocator; @@ -3806,6 +4085,39 @@ test "Screen: resize more rows with populated scrollback" { } } +test "Screen: resize more rows and cols with wrapping" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 4, 2, 0); + defer s.deinit(); + const str = "1A2B\n3C4D"; + try s.testWriteString(str); + { + var contents = try s.testString(alloc, .viewport); + defer alloc.free(contents); + const expected = "1A\n2B\n3C\n4D"; + try testing.expectEqualStrings(expected, contents); + } + + try s.resize(10, 5); + + // Cursor should move due to wrapping + try testing.expectEqual(@as(usize, 3), s.cursor.x); + try testing.expectEqual(@as(usize, 1), s.cursor.y); + + { + 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 no reflow" { const testing = std.testing; const alloc = testing.allocator; @@ -3832,6 +4144,59 @@ test "Screen: resize more cols no reflow" { } } +test "Screen: resize more cols trailing background colors" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 3, 5, 0); + defer s.deinit(); + const str = "1AB"; + try s.testWriteString(str); + const cursor = s.cursor; + + // Color our cells red + const pen: Cell = .{ .bg = .{ .r = 0xFF }, .attrs = .{ .has_bg = true } }; + for (s.cursor.x..s.cols) |x| { + const row = s.getRow(.{ .active = s.cursor.y }); + const cell = row.getCellPtr(x); + cell.* = pen; + } + for ((s.cursor.y + 1)..s.rows) |y| { + const row = s.getRow(.{ .active = y }); + row.fill(pen); + } + + try s.resize(3, 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); + } + + // Verify all our trailing cells have the color + for (s.cursor.x..s.cols) |x| { + const row = s.getRow(.{ .active = s.cursor.y }); + const cell = row.getCellPtr(x); + try testing.expectEqual(pen, cell.*); + } + for ((s.cursor.y + 1)..s.rows) |y| { + const row = s.getRow(.{ .active = y }); + for (0..s.cols) |x| { + const cell = row.getCellPtr(x); + try testing.expectEqual(pen, cell.*); + } + } +} + test "Screen: resize more cols grapheme map" { const testing = std.testing; const alloc = testing.allocator; @@ -4046,6 +4411,43 @@ test "Screen: resize more cols with populated scrollback" { } } +test "Screen: resize more cols with reflow" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 3, 2, 5); + defer s.deinit(); + const str = "1ABC\n2DEF\n3ABC\n4DEF"; + try s.testWriteString(str); + + // Let's put our cursor on row 2, where the soft wrap is + s.cursor.x = 0; + s.cursor.y = 2; + try testing.expectEqual(@as(u32, 'E'), s.getCell(.active, s.cursor.y, s.cursor.x).char); + + // Verify we soft wrapped + { + var contents = try s.testString(alloc, .viewport); + defer alloc.free(contents); + const expected = "BC\n4D\nEF"; + try testing.expectEqualStrings(expected, contents); + } + + // Resize and verify we undid the soft wrap because we have space now + try s.resize(3, 7); + + { + var contents = try s.testString(alloc, .screen); + defer alloc.free(contents); + const expected = "1ABC\n2DEF\n3ABC\n4DEF"; + try testing.expectEqualStrings(expected, contents); + } + + // Our cursor should've moved + try testing.expectEqual(@as(usize, 2), s.cursor.x); + try testing.expectEqual(@as(usize, 2), s.cursor.y); +} + test "Screen: resize less rows no scrollback" { const testing = std.testing; const alloc = testing.allocator; @@ -4165,6 +4567,45 @@ test "Screen: resize less rows with populated scrollback" { } } +test "Screen: resize less rows with full scrollback" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 3, 5, 3); + defer s.deinit(); + const str = "00000\n1ABCD\n2EFGH\n3IJKL\n4ABCD\n5EFGH"; + try s.testWriteString(str); + { + var contents = try s.testString(alloc, .viewport); + defer alloc.free(contents); + const expected = "3IJKL\n4ABCD\n5EFGH"; + try testing.expectEqualStrings(expected, contents); + } + + const cursor = s.cursor; + try testing.expectEqual(Cursor{ .x = 4, .y = 2 }, cursor); + + // Resize + try s.resize(2, 5); + + // Cursor should stay in the same relative place (bottom of the + // screen, same character). + try testing.expectEqual(Cursor{ .x = 4, .y = 1 }, s.cursor); + + { + var contents = try s.testString(alloc, .screen); + defer alloc.free(contents); + const expected = "1ABCD\n2EFGH\n3IJKL\n4ABCD\n5EFGH"; + try testing.expectEqualStrings(expected, contents); + } + { + var contents = try s.testString(alloc, .viewport); + defer alloc.free(contents); + const expected = "4ABCD\n5EFGH"; + try testing.expectEqualStrings(expected, contents); + } +} + test "Screen: resize less cols no reflow" { const testing = std.testing; const alloc = testing.allocator; @@ -4193,6 +4634,52 @@ test "Screen: resize less cols no reflow" { } } +test "Screen: resize less cols trailing background colors" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 3, 10, 0); + defer s.deinit(); + const str = "1AB"; + try s.testWriteString(str); + const cursor = s.cursor; + + // Color our cells red + const pen: Cell = .{ .bg = .{ .r = 0xFF }, .attrs = .{ .has_bg = true } }; + for (s.cursor.x..s.cols) |x| { + const row = s.getRow(.{ .active = s.cursor.y }); + const cell = row.getCellPtr(x); + cell.* = pen; + } + for ((s.cursor.y + 1)..s.rows) |y| { + const row = s.getRow(.{ .active = y }); + row.fill(pen); + } + + try s.resize(3, 5); + + // Cursor should not move + try testing.expectEqual(cursor, s.cursor); + + { + 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); + } + + // Verify all our trailing cells have the color + for (s.cursor.x..s.cols) |x| { + const row = s.getRow(.{ .active = s.cursor.y }); + const cell = row.getCellPtr(x); + try testing.expectEqual(pen, cell.*); + } +} + test "Screen: resize less cols with graphemes" { const testing = std.testing; const alloc = testing.allocator; @@ -4314,6 +4801,110 @@ test "Screen: resize less cols with reflow with trimmed rows and scrollback" { } } +test "Screen: resize less cols with reflow previously wrapped" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 3, 5, 0); + defer s.deinit(); + const str = "3IJKL4ABCD5EFGH"; + try s.testWriteString(str); + + // Check + { + var contents = try s.testString(alloc, .screen); + defer alloc.free(contents); + const expected = "3IJKL\n4ABCD\n5EFGH"; + try testing.expectEqualStrings(expected, contents); + } + + try s.resize(3, 3); + + // { + // 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 = "ABC\nD5E\nFGH"; + try testing.expectEqualStrings(expected, contents); + } +} + +test "Screen: resize less cols with reflow and scrollback" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 3, 5, 5); + defer s.deinit(); + const str = "1A\n2B\n3C\n4D\n5E"; + try s.testWriteString(str); + + // Put our cursor on the end + s.cursor.x = 1; + s.cursor.y = s.rows - 1; + try testing.expectEqual(@as(u32, 'E'), s.getCell(.active, s.cursor.y, s.cursor.x).char); + + try s.resize(3, 3); + + { + var contents = try s.testString(alloc, .viewport); + defer alloc.free(contents); + const expected = "3C\n4D\n5E"; + try testing.expectEqualStrings(expected, contents); + } + + // Cursor should be on the last line + try testing.expectEqual(@as(usize, 1), s.cursor.x); + try testing.expectEqual(@as(usize, 2), s.cursor.y); +} + +test "Screen: resize less cols with reflow previously wrapped and scrollback" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 3, 5, 2); + defer s.deinit(); + const str = "1ABCD2EFGH3IJKL4ABCD5EFGH"; + try s.testWriteString(str); + + // Check + { + var contents = try s.testString(alloc, .viewport); + defer alloc.free(contents); + const expected = "3IJKL\n4ABCD\n5EFGH"; + try testing.expectEqualStrings(expected, contents); + } + + // Put our cursor on the end + s.cursor.x = s.cols - 1; + s.cursor.y = s.rows - 1; + try testing.expectEqual(@as(u32, 'H'), s.getCell(.active, s.cursor.y, s.cursor.x).char); + + try s.resize(3, 3); + + { + var contents = try s.testString(alloc, .viewport); + defer alloc.free(contents); + const expected = "CD5\nEFG\nH"; + try testing.expectEqualStrings(expected, contents); + } + { + var contents = try s.testString(alloc, .screen); + defer alloc.free(contents); + const expected = "JKL\n4AB\nCD5\nEFG\nH"; + try testing.expectEqualStrings(expected, contents); + } + + // Cursor should be on the last line + try testing.expectEqual(@as(u32, 'H'), s.getCell(.active, s.cursor.y, s.cursor.x).char); + try testing.expectEqual(@as(usize, 0), s.cursor.x); + try testing.expectEqual(@as(usize, 2), s.cursor.y); +} + // 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. diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index fb0189d03..b67ab58b7 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -912,19 +912,26 @@ pub fn eraseDisplay( .below => { // All lines to the right (including the cursor) - var x: usize = self.screen.cursor.x; - while (x < self.cols) : (x += 1) { - const cell = self.screen.getCellPtr(.active, self.screen.cursor.y, x); - cell.* = self.screen.cursor.pen; - cell.char = 0; + { + const row = self.screen.getRow(.{ .active = self.screen.cursor.y }); + row.setWrapped(false); + row.setDirty(true); + for (self.screen.cursor.x..self.cols) |x| { + if (row.header().flags.grapheme) row.clearGraphemes(x); + const cell = row.getCellPtr(x); + cell.* = self.screen.cursor.pen; + cell.char = 0; + } } // All lines below - var y: usize = self.screen.cursor.y + 1; - while (y < self.rows) : (y += 1) { - x = 0; - while (x < self.cols) : (x += 1) { - const cell = self.screen.getCellPtr(.active, y, x); + for ((self.screen.cursor.y + 1)..self.rows) |y| { + const row = self.screen.getRow(.{ .active = y }); + row.setWrapped(false); + row.setDirty(true); + for (0..self.cols) |x| { + if (row.header().flags.grapheme) row.clearGraphemes(x); + const cell = row.getCellPtr(x); cell.* = self.screen.cursor.pen; cell.char = 0; }