diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index cf5543963..6fd50b54e 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -56,6 +56,7 @@ const Allocator = std.mem.Allocator; const utf8proc = @import("utf8proc"); const trace = @import("tracy").trace; +const ansi = @import("ansi.zig"); const sgr = @import("sgr.zig"); const color = @import("color.zig"); const kitty = @import("kitty.zig"); @@ -932,6 +933,13 @@ saved_charset: CharsetState = .{}, /// independent to each screen (primary and alternate) saved_origin_mode: bool = false, +/// The current or most recent protected mode. Once a protection mode is +/// set, this will never become "off" again until the screen is reset. +/// The current state of whether protection attributes should be set is +/// set on the Cell pen; this is only used to determine the most recent +/// protection mode since some sequences such as ECH depend on this. +protected_mode: ansi.ProtectedMode = .off, + /// Initialize a new screen. pub fn init( alloc: Allocator, diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index 87e772e35..c8e33c057 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -1320,7 +1320,6 @@ pub fn deleteChars(self: *Terminal, count: usize) !void { } } -// TODO: test, docs pub fn eraseChars(self: *Terminal, count: usize) void { const tracy = trace(@src()); defer tracy.end(); @@ -1330,13 +1329,39 @@ pub fn eraseChars(self: *Terminal, count: usize) void { // Our last index is at most the end of the number of chars we have // in the current line. - const end = @min(self.cols, self.screen.cursor.x + count); - - // Shift - 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); + const end = end: { + var end = @min(self.cols, self.screen.cursor.x + count); + + // If our last cell is a wide char then we need to also clear the + // cell beyond it since we can't just split a wide char. + if (end != self.cols) { + const last = row.getCellPtr(end - 1); + if (last.attrs.wide) end += 1; + } + + break :end end; + }; + + const pen: Screen.Cell = .{ + .bg = self.screen.cursor.pen.bg, + .attrs = .{ .has_bg = self.screen.cursor.pen.attrs.has_bg }, + }; + + // If we never had a protection mode, then we can assume no cells + // are protected and go with the fast path. If the last protection + // mode was not ISO we also always ignore protection attributes. + if (self.screen.protected_mode != .iso) { + row.fillSlice(pen, self.screen.cursor.x, end); + } + + // We had a protection mode at some point. We must go through each + // cell and check its protection attribute. + for (self.screen.cursor.x..end) |x| { + const cell = row.getCellPtr(x); + if (cell.attrs.protected) continue; + cell.* = pen; + } } /// Move the cursor to the left amount cells. If amount is 0, adjust it to 1. @@ -1874,15 +1899,20 @@ pub fn setProtectedMode(self: *Terminal, mode: ansi.ProtectedMode) void { switch (mode) { .off => { self.screen.cursor.pen.attrs.protected = false; + + // screen.protected_mode is NEVER reset to ".off" because + // logic such as eraseChars depends on knowing what the + // _most recent_ mode was. }, - // TODO: ISO/DEC have very subtle differences, so we should track that. .iso => { self.screen.cursor.pen.attrs.protected = true; + self.screen.protected_mode = .iso; }, .dec => { self.screen.cursor.pen.attrs.protected = true; + self.screen.protected_mode = .dec; }, } } @@ -1898,6 +1928,7 @@ pub fn fullReset(self: *Terminal, alloc: Allocator) void { self.screen.saved_cursor = .{}; self.screen.selection = null; self.screen.kitty_keyboard = .{}; + self.screen.protected_mode = .off; self.scrolling_region = .{ .top = 0, .bottom = self.rows - 1, @@ -3577,6 +3608,139 @@ test "Terminal: eraseChars resets wrap" { } } +test "Terminal: eraseChars simple operation" { + const alloc = testing.allocator; + var t = try init(alloc, 5, 5); + defer t.deinit(alloc); + + for ("ABC") |c| try t.print(c); + t.setCursorPos(1, 1); + t.eraseChars(2); + try t.print('X'); + + { + var str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("X C", str); + } +} + +test "Terminal: eraseChars beyond screen edge" { + const alloc = testing.allocator; + var t = try init(alloc, 5, 5); + defer t.deinit(alloc); + + for (" ABC") |c| try t.print(c); + t.setCursorPos(1, 4); + t.eraseChars(10); + + { + var str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings(" A", str); + } +} + +test "Terminal: eraseChars preserves background sgr" { + const alloc = testing.allocator; + var t = try init(alloc, 10, 10); + defer t.deinit(alloc); + + const pen: Screen.Cell = .{ + .bg = .{ .r = 0xFF, .g = 0x00, .b = 0x00 }, + .attrs = .{ .has_bg = true }, + }; + + for ("ABC") |c| try t.print(c); + t.setCursorPos(1, 1); + t.screen.cursor.pen = pen; + t.eraseChars(2); + + { + var str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings(" C", str); + { + const cell = t.screen.getCell(.active, 0, 0); + try testing.expectEqual(pen, cell); + } + { + const cell = t.screen.getCell(.active, 0, 1); + try testing.expectEqual(pen, cell); + } + } +} + +test "Terminal: eraseChars wide character" { + const alloc = testing.allocator; + var t = try init(alloc, 5, 5); + defer t.deinit(alloc); + + try t.print('橋'); + for ("BC") |c| try t.print(c); + t.setCursorPos(1, 1); + t.eraseChars(1); + try t.print('X'); + + { + var str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("X BC", str); + } +} + +test "Terminal: eraseChars protected attributes respected with iso" { + const alloc = testing.allocator; + var t = try init(alloc, 5, 5); + defer t.deinit(alloc); + + t.setProtectedMode(.iso); + for ("ABC") |c| try t.print(c); + t.setCursorPos(1, 1); + t.eraseChars(2); + + { + var str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("ABC", str); + } +} + +test "Terminal: eraseChars protected attributes ignored with dec most recent" { + const alloc = testing.allocator; + var t = try init(alloc, 5, 5); + defer t.deinit(alloc); + + t.setProtectedMode(.iso); + for ("ABC") |c| try t.print(c); + t.setProtectedMode(.dec); + t.setProtectedMode(.off); + t.setCursorPos(1, 1); + t.eraseChars(2); + + { + var str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings(" C", str); + } +} +test "Terminal: eraseChars protected attributes ignored with dec set" { + const alloc = testing.allocator; + var t = try init(alloc, 5, 5); + defer t.deinit(alloc); + + t.setProtectedMode(.dec); + for ("ABC") |c| try t.print(c); + t.setCursorPos(1, 1); + t.eraseChars(2); + + { + var str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings(" C", str); + } +} + // https://github.com/mitchellh/ghostty/issues/272 // This is also tested in depth in screen resize tests but I want to keep // this test around to ensure we don't regress at multiple layers. diff --git a/website/app/vt/ech/page.mdx b/website/app/vt/ech/page.mdx new file mode 100644 index 000000000..4d340b528 --- /dev/null +++ b/website/app/vt/ech/page.mdx @@ -0,0 +1,157 @@ +import VTSequence from "@/components/VTSequence"; + +# Erase Character (ECH) + + + +Blank `n` cells beginning with (including) and to the right of the cursor. + +The parameter `n` must be an integer greater than or equal to 1. If `n` is less than +or equal to 0, adjust `n` to be 1. If `n` is omitted, `n` defaults to 1. + +The rightmost column that can be erased is the rightmost column of the screen. +The [right margin](#) has no effect on this sequence. + +This sequence always unsets the pending wrap state. + +For `n` cells up to the rightmost column, blank the cell by replacing it +with an empty character with the background color colored according to the +current SGR state. No other SGR attributes are preserved. + +If a multi-cell character would be split, erase the full multi-cell +character. For example, if "橋" is printed and ECH `n = 1` is issued, +the full character should be erased even though it takes up two cells. +Both erased cells are colored with the current background color according +to the current SGR state. + +If [Select Character Selection Attribute (DECSCA)](#TODO) is enabled +or was the most recently enabled protection mode on the currently active screen, +protected attributes are ignored as if they were never set and the cells +with them are erased. It does not matter if DECSCA is currently disabled, +protected attributes are still ignored so long as DECSCA was the +_most recently enabled_ protection mode. + +If DECSCA is not currently enabled and was not the most recently enabled protection +mode on the currently active screen, cells with the protected attribute set are +respected and not erased but still count towards `n`. It does not matter if the +protection attribute for a cell was originally set from DECSCA. + +## Validation + +### ECH V-1: Simple Operation + +```bash +printf "ABC" +printf "\033[1G" +printf "\033[2X" +``` + +``` +|c_C_____| +``` + +### ECH V-2: Erasing Beyond Edge of Screen + +```bash +cols=$(tput cols) +printf "\033[${cols}G" +printf "\033[2D" +printf "ABC" +printf "\033[D" +printf "\033[10X" +``` + +``` +|_____Ac_| +``` + +### ECH V-3: Reset Pending Wrap State + +```bash +cols=$(tput cols) +printf "\033[${cols}G" # move to last column +printf "A" # set pending wrap state +printf "\033[X" +printf "X" +``` + +``` +|_______Xc +``` + +### ECH V-4: SGR State + +```bash +printf "ABC" +printf "\033[1G" +printf "\033[41m" +printf "\033[2X" +``` + +``` +|c_C_____| +``` + +The `c_` cells should both have a red background. All other cells +remain unchanged in style. + +### ECH V-5: Multi-cell Character + +```bash +printf "橋BC" +printf "\033[1G" +printf "\033[X" +printf "X" +``` + +``` +|XcBC____| +``` + +### ECH V-6: Left/Right Scroll Region Ignored + +```bash +printf "\033[1;1H" # move to top-left +printf "\033[0J" # clear screen +printf "\033[?69h" # enable left/right margins +printf "\033[1;3s" # scroll region left/right +printf "\033[4G" +printf "ABC" +printf "\033[1G" +printf "\033[4X" +``` + +``` +|c___BC____| +``` + +### ECH V-7: Protected Attributes Ignored with DECSCA + +```bash +printf "\033V" +printf "ABC" +printf "\033[1\"q" +printf "\033[0\"q" +printf "\033[1G" +printf "\033[2X" +``` + +``` +|c_C_______| +``` + +### ECH V-8: Protected Attributes Respected without DECSCA + +```bash +printf "\033[1\"q" +printf "ABC" +printf "\033V" +printf "\033[1G" +printf "\033[2X" +``` + +``` +|ABC_______| +``` + +The cursor remains at `A`.