From 1a127dbe01f32b0d6ab0f907424f02749eb6727d Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 7 Oct 2023 22:05:58 -0700 Subject: [PATCH 1/3] website: ech --- website/app/vt/ech/page.mdx | 157 ++++++++++++++++++++++++++++++++++++ 1 file changed, 157 insertions(+) create mode 100644 website/app/vt/ech/page.mdx diff --git a/website/app/vt/ech/page.mdx b/website/app/vt/ech/page.mdx new file mode 100644 index 000000000..c4eec2e97 --- /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, +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, 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`. From 514071dd8711f92c7f15f5e93a0a17d87213d9ea Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 7 Oct 2023 22:21:38 -0700 Subject: [PATCH 2/3] terminal: ECH tests with bg and multi-cell fix --- src/terminal/Terminal.zig | 104 +++++++++++++++++++++++++++++++++++--- 1 file changed, 98 insertions(+), 6 deletions(-) diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index 87e772e35..d9b27e122 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,25 @@ 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); + const row = self.screen.getRow(.{ .active = self.screen.cursor.y }); + 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; + }; // 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); + row.fillSlice(.{ + .bg = self.screen.cursor.pen.bg, + .attrs = .{ .has_bg = self.screen.cursor.pen.attrs.has_bg }, + }, self.screen.cursor.x, end); } /// Move the cursor to the left amount cells. If amount is 0, adjust it to 1. @@ -3577,6 +3588,87 @@ 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); + } +} + // 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. From fa73fa0de235211f5234678e00ab1845f958c4a8 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 7 Oct 2023 22:36:29 -0700 Subject: [PATCH 3/3] terminal: ECH handles protection attributes properly --- src/terminal/Screen.zig | 8 ++++ src/terminal/Terminal.zig | 80 +++++++++++++++++++++++++++++++++++-- website/app/vt/ech/page.mdx | 8 ++-- 3 files changed, 88 insertions(+), 8 deletions(-) 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 d9b27e122..c8e33c057 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -1343,11 +1343,25 @@ pub fn eraseChars(self: *Terminal, count: usize) void { break :end end; }; - // Shift - row.fillSlice(.{ + const pen: Screen.Cell = .{ .bg = self.screen.cursor.pen.bg, .attrs = .{ .has_bg = self.screen.cursor.pen.attrs.has_bg }, - }, self.screen.cursor.x, end); + }; + + // 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. @@ -1885,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; }, } } @@ -1909,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, @@ -3669,6 +3689,58 @@ test "Terminal: eraseChars wide character" { } } +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 index c4eec2e97..4d340b528 100644 --- a/website/app/vt/ech/page.mdx +++ b/website/app/vt/ech/page.mdx @@ -25,16 +25,16 @@ 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, +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, 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. +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