diff --git a/src/Surface.zig b/src/Surface.zig index e61977d53..815109dbc 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -156,7 +156,8 @@ const Mouse = struct { /// The point at which the left mouse click happened. This is in screen /// coordinates so that scrolling preserves the location. - left_click_point: terminal.point.ScreenPoint = .{}, + //TODO(paged-terminal) + //left_click_point: terminal.point.ScreenPoint = .{}, /// The starting xpos/ypos of the left click. Note that if scrolling occurs, /// these will point to different "cells", but the xpos/ypos will stay diff --git a/src/bench/stream.zig b/src/bench/stream.zig index 3e6262014..4d7586be4 100644 --- a/src/bench/stream.zig +++ b/src/bench/stream.zig @@ -14,8 +14,8 @@ const Allocator = std.mem.Allocator; const ArenaAllocator = std.heap.ArenaAllocator; const ziglyph = @import("ziglyph"); const cli = @import("../cli.zig"); -const terminal = @import("../terminal/main.zig"); -const terminalnew = @import("../terminal2/main.zig"); +const terminal = @import("../terminal-old/main.zig"); +const terminalnew = @import("../terminal/main.zig"); const Args = struct { mode: Mode = .noop, diff --git a/src/config/Config.zig b/src/config/Config.zig index 4a0ba75ec..eedd6932a 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -291,7 +291,7 @@ palette: Palette = .{}, /// a prompt, regardless of this configuration. You can disable that behavior /// by specifying `shell-integration-features = no-cursor` or disabling shell /// integration entirely. -@"cursor-style": terminal.Cursor.Style = .block, +@"cursor-style": terminal.CursorStyle = .block, /// Sets the default blinking state of the cursor. This is just the default /// state; running programs may override the cursor style using `DECSCUSR` (`CSI diff --git a/src/font/shaper/harfbuzz.zig b/src/font/shaper/harfbuzz.zig index 29a7e315f..04143c090 100644 --- a/src/font/shaper/harfbuzz.zig +++ b/src/font/shaper/harfbuzz.zig @@ -10,7 +10,7 @@ const GroupCache = font.GroupCache; const Library = font.Library; const Style = font.Style; const Presentation = font.Presentation; -const terminal = @import("../../terminal/main.zig").new; +const terminal = @import("../../terminal/main.zig"); const log = std.log.scoped(.font_shaper); diff --git a/src/font/shaper/run.zig b/src/font/shaper/run.zig index 7a6c4e554..c1f483778 100644 --- a/src/font/shaper/run.zig +++ b/src/font/shaper/run.zig @@ -3,7 +3,7 @@ const assert = std.debug.assert; const Allocator = std.mem.Allocator; const font = @import("../main.zig"); const shape = @import("../shape.zig"); -const terminal = @import("../../terminal/main.zig").new; +const terminal = @import("../../terminal/main.zig"); /// A single text run. A text run is only valid for one Shaper instance and /// until the next run is created. A text run never goes across multiple diff --git a/src/inspector/Inspector.zig b/src/inspector/Inspector.zig index 6a4235a7e..11ef18a06 100644 --- a/src/inspector/Inspector.zig +++ b/src/inspector/Inspector.zig @@ -35,8 +35,9 @@ mouse: struct { last_xpos: f64 = 0, last_ypos: f64 = 0, - /// Last hovered screen point - last_point: terminal.point.ScreenPoint = .{}, + // Last hovered screen point + // TODO(paged-terminal) + // last_point: terminal.point.ScreenPoint = .{}, } = .{}, /// A selected cell. @@ -63,7 +64,8 @@ const CellInspect = union(enum) { const Selected = struct { row: usize, col: usize, - cell: terminal.Screen.Cell, + // TODO(paged-terminal) + //cell: terminal.Screen.Cell, }; pub fn request(self: *CellInspect) void { diff --git a/src/main_ghostty.zig b/src/main_ghostty.zig index 4e99001c6..497631f31 100644 --- a/src/main_ghostty.zig +++ b/src/main_ghostty.zig @@ -309,7 +309,7 @@ test { _ = @import("segmented_pool.zig"); _ = @import("inspector/main.zig"); _ = @import("terminal/main.zig"); - _ = @import("terminal2/main.zig"); + _ = @import("terminal-old/main.zig"); _ = @import("terminfo/main.zig"); _ = @import("simd/main.zig"); _ = @import("unicode/main.zig"); diff --git a/src/renderer/cursor.zig b/src/renderer/cursor.zig index c4a74e05c..fd58257be 100644 --- a/src/renderer/cursor.zig +++ b/src/renderer/cursor.zig @@ -12,7 +12,7 @@ pub const CursorStyle = enum { underline, /// Create a cursor style from the terminal style request. - pub fn fromTerminal(style: terminal.Cursor.Style) ?CursorStyle { + pub fn fromTerminal(style: terminal.CursorStyle) ?CursorStyle { return switch (style) { .bar => .bar, .block => .block, @@ -57,7 +57,7 @@ pub fn cursorStyle( } // Otherwise, we use whatever style the terminal wants. - return CursorStyle.fromTerminal(state.terminal.screen.cursor.style); + return CursorStyle.fromTerminal(state.terminal.screen.cursor.cursor_style); } test "cursor: default uses configured style" { @@ -66,7 +66,7 @@ test "cursor: default uses configured style" { var term = try terminal.Terminal.init(alloc, 10, 10); defer term.deinit(alloc); - term.screen.cursor.style = .bar; + term.screen.cursor.cursor_style = .bar; term.modes.set(.cursor_blinking, true); var state: State = .{ @@ -87,7 +87,7 @@ test "cursor: blinking disabled" { var term = try terminal.Terminal.init(alloc, 10, 10); defer term.deinit(alloc); - term.screen.cursor.style = .bar; + term.screen.cursor.cursor_style = .bar; term.modes.set(.cursor_blinking, false); var state: State = .{ @@ -108,7 +108,7 @@ test "cursor: explictly not visible" { var term = try terminal.Terminal.init(alloc, 10, 10); defer term.deinit(alloc); - term.screen.cursor.style = .bar; + term.screen.cursor.cursor_style = .bar; term.modes.set(.cursor_visible, false); term.modes.set(.cursor_blinking, false); diff --git a/src/renderer/link.zig b/src/renderer/link.zig index ca1dc062a..4c16ed3a2 100644 --- a/src/renderer/link.zig +++ b/src/renderer/link.zig @@ -169,139 +169,140 @@ pub const MatchSet = struct { } }; -test "matchset" { - const testing = std.testing; - const alloc = testing.allocator; - - // Initialize our screen - var s = try Screen.init(alloc, 5, 5, 0); - defer s.deinit(); - const str = "1ABCD2EFGH\n3IJKL"; - try s.testWriteString(str); - - // Get a set - var set = try Set.fromConfig(alloc, &.{ - .{ - .regex = "AB", - .action = .{ .open = {} }, - .highlight = .{ .always = {} }, - }, - - .{ - .regex = "EF", - .action = .{ .open = {} }, - .highlight = .{ .always = {} }, - }, - }); - defer set.deinit(alloc); - - // Get our matches - var match = try set.matchSet(alloc, &s, .{}, .{}); - defer match.deinit(alloc); - try testing.expectEqual(@as(usize, 2), match.matches.len); - - // Test our matches - try testing.expect(!match.orderedContains(.{ .x = 0, .y = 0 })); - try testing.expect(match.orderedContains(.{ .x = 1, .y = 0 })); - try testing.expect(match.orderedContains(.{ .x = 2, .y = 0 })); - try testing.expect(!match.orderedContains(.{ .x = 3, .y = 0 })); - try testing.expect(match.orderedContains(.{ .x = 1, .y = 1 })); - try testing.expect(!match.orderedContains(.{ .x = 1, .y = 2 })); -} - -test "matchset hover links" { - const testing = std.testing; - const alloc = testing.allocator; - - // Initialize our screen - var s = try Screen.init(alloc, 5, 5, 0); - defer s.deinit(); - const str = "1ABCD2EFGH\n3IJKL"; - try s.testWriteString(str); - - // Get a set - var set = try Set.fromConfig(alloc, &.{ - .{ - .regex = "AB", - .action = .{ .open = {} }, - .highlight = .{ .hover = {} }, - }, - - .{ - .regex = "EF", - .action = .{ .open = {} }, - .highlight = .{ .always = {} }, - }, - }); - defer set.deinit(alloc); - - // Not hovering over the first link - { - var match = try set.matchSet(alloc, &s, .{}, .{}); - defer match.deinit(alloc); - try testing.expectEqual(@as(usize, 1), match.matches.len); - - // Test our matches - try testing.expect(!match.orderedContains(.{ .x = 0, .y = 0 })); - try testing.expect(!match.orderedContains(.{ .x = 1, .y = 0 })); - try testing.expect(!match.orderedContains(.{ .x = 2, .y = 0 })); - try testing.expect(!match.orderedContains(.{ .x = 3, .y = 0 })); - try testing.expect(match.orderedContains(.{ .x = 1, .y = 1 })); - try testing.expect(!match.orderedContains(.{ .x = 1, .y = 2 })); - } - - // Hovering over the first link - { - var match = try set.matchSet(alloc, &s, .{ .x = 1, .y = 0 }, .{}); - defer match.deinit(alloc); - try testing.expectEqual(@as(usize, 2), match.matches.len); - - // Test our matches - try testing.expect(!match.orderedContains(.{ .x = 0, .y = 0 })); - try testing.expect(match.orderedContains(.{ .x = 1, .y = 0 })); - try testing.expect(match.orderedContains(.{ .x = 2, .y = 0 })); - try testing.expect(!match.orderedContains(.{ .x = 3, .y = 0 })); - try testing.expect(match.orderedContains(.{ .x = 1, .y = 1 })); - try testing.expect(!match.orderedContains(.{ .x = 1, .y = 2 })); - } -} - -test "matchset mods no match" { - const testing = std.testing; - const alloc = testing.allocator; - - // Initialize our screen - var s = try Screen.init(alloc, 5, 5, 0); - defer s.deinit(); - const str = "1ABCD2EFGH\n3IJKL"; - try s.testWriteString(str); - - // Get a set - var set = try Set.fromConfig(alloc, &.{ - .{ - .regex = "AB", - .action = .{ .open = {} }, - .highlight = .{ .always = {} }, - }, - - .{ - .regex = "EF", - .action = .{ .open = {} }, - .highlight = .{ .always_mods = .{ .ctrl = true } }, - }, - }); - defer set.deinit(alloc); - - // Get our matches - var match = try set.matchSet(alloc, &s, .{}, .{}); - defer match.deinit(alloc); - try testing.expectEqual(@as(usize, 1), match.matches.len); - - // Test our matches - try testing.expect(!match.orderedContains(.{ .x = 0, .y = 0 })); - try testing.expect(match.orderedContains(.{ .x = 1, .y = 0 })); - try testing.expect(match.orderedContains(.{ .x = 2, .y = 0 })); - try testing.expect(!match.orderedContains(.{ .x = 3, .y = 0 })); - try testing.expect(!match.orderedContains(.{ .x = 1, .y = 1 })); - try testing.expect(!match.orderedContains(.{ .x = 1, .y = 2 })); -} +// TODO(paged-terminal) +// test "matchset" { +// const testing = std.testing; +// const alloc = testing.allocator; +// +// // Initialize our screen +// var s = try Screen.init(alloc, 5, 5, 0); +// defer s.deinit(); +// const str = "1ABCD2EFGH\n3IJKL"; +// try s.testWriteString(str); +// +// // Get a set +// var set = try Set.fromConfig(alloc, &.{ +// .{ +// .regex = "AB", +// .action = .{ .open = {} }, +// .highlight = .{ .always = {} }, +// }, +// +// .{ +// .regex = "EF", +// .action = .{ .open = {} }, +// .highlight = .{ .always = {} }, +// }, +// }); +// defer set.deinit(alloc); +// +// // Get our matches +// var match = try set.matchSet(alloc, &s, .{}, .{}); +// defer match.deinit(alloc); +// try testing.expectEqual(@as(usize, 2), match.matches.len); +// +// // Test our matches +// try testing.expect(!match.orderedContains(.{ .x = 0, .y = 0 })); +// try testing.expect(match.orderedContains(.{ .x = 1, .y = 0 })); +// try testing.expect(match.orderedContains(.{ .x = 2, .y = 0 })); +// try testing.expect(!match.orderedContains(.{ .x = 3, .y = 0 })); +// try testing.expect(match.orderedContains(.{ .x = 1, .y = 1 })); +// try testing.expect(!match.orderedContains(.{ .x = 1, .y = 2 })); +// } +// +// test "matchset hover links" { +// const testing = std.testing; +// const alloc = testing.allocator; +// +// // Initialize our screen +// var s = try Screen.init(alloc, 5, 5, 0); +// defer s.deinit(); +// const str = "1ABCD2EFGH\n3IJKL"; +// try s.testWriteString(str); +// +// // Get a set +// var set = try Set.fromConfig(alloc, &.{ +// .{ +// .regex = "AB", +// .action = .{ .open = {} }, +// .highlight = .{ .hover = {} }, +// }, +// +// .{ +// .regex = "EF", +// .action = .{ .open = {} }, +// .highlight = .{ .always = {} }, +// }, +// }); +// defer set.deinit(alloc); +// +// // Not hovering over the first link +// { +// var match = try set.matchSet(alloc, &s, .{}, .{}); +// defer match.deinit(alloc); +// try testing.expectEqual(@as(usize, 1), match.matches.len); +// +// // Test our matches +// try testing.expect(!match.orderedContains(.{ .x = 0, .y = 0 })); +// try testing.expect(!match.orderedContains(.{ .x = 1, .y = 0 })); +// try testing.expect(!match.orderedContains(.{ .x = 2, .y = 0 })); +// try testing.expect(!match.orderedContains(.{ .x = 3, .y = 0 })); +// try testing.expect(match.orderedContains(.{ .x = 1, .y = 1 })); +// try testing.expect(!match.orderedContains(.{ .x = 1, .y = 2 })); +// } +// +// // Hovering over the first link +// { +// var match = try set.matchSet(alloc, &s, .{ .x = 1, .y = 0 }, .{}); +// defer match.deinit(alloc); +// try testing.expectEqual(@as(usize, 2), match.matches.len); +// +// // Test our matches +// try testing.expect(!match.orderedContains(.{ .x = 0, .y = 0 })); +// try testing.expect(match.orderedContains(.{ .x = 1, .y = 0 })); +// try testing.expect(match.orderedContains(.{ .x = 2, .y = 0 })); +// try testing.expect(!match.orderedContains(.{ .x = 3, .y = 0 })); +// try testing.expect(match.orderedContains(.{ .x = 1, .y = 1 })); +// try testing.expect(!match.orderedContains(.{ .x = 1, .y = 2 })); +// } +// } +// +// test "matchset mods no match" { +// const testing = std.testing; +// const alloc = testing.allocator; +// +// // Initialize our screen +// var s = try Screen.init(alloc, 5, 5, 0); +// defer s.deinit(); +// const str = "1ABCD2EFGH\n3IJKL"; +// try s.testWriteString(str); +// +// // Get a set +// var set = try Set.fromConfig(alloc, &.{ +// .{ +// .regex = "AB", +// .action = .{ .open = {} }, +// .highlight = .{ .always = {} }, +// }, +// +// .{ +// .regex = "EF", +// .action = .{ .open = {} }, +// .highlight = .{ .always_mods = .{ .ctrl = true } }, +// }, +// }); +// defer set.deinit(alloc); +// +// // Get our matches +// var match = try set.matchSet(alloc, &s, .{}, .{}); +// defer match.deinit(alloc); +// try testing.expectEqual(@as(usize, 1), match.matches.len); +// +// // Test our matches +// try testing.expect(!match.orderedContains(.{ .x = 0, .y = 0 })); +// try testing.expect(match.orderedContains(.{ .x = 1, .y = 0 })); +// try testing.expect(match.orderedContains(.{ .x = 2, .y = 0 })); +// try testing.expect(!match.orderedContains(.{ .x = 3, .y = 0 })); +// try testing.expect(!match.orderedContains(.{ .x = 1, .y = 1 })); +// try testing.expect(!match.orderedContains(.{ .x = 1, .y = 2 })); +// } diff --git a/src/terminal2/Parser.zig b/src/terminal-old/Parser.zig similarity index 100% rename from src/terminal2/Parser.zig rename to src/terminal-old/Parser.zig diff --git a/src/terminal-old/Screen.zig b/src/terminal-old/Screen.zig new file mode 100644 index 000000000..385ce1eba --- /dev/null +++ b/src/terminal-old/Screen.zig @@ -0,0 +1,7920 @@ +//! 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. +//! * Row - A single visible row in the screen. +//! * Line - A single line of text. This may map to multiple rows if +//! the row is soft-wrapped. +//! +//! The internal storage of the screen is stored in a circular buffer +//! with roughly the following format: +//! +//! Storage (Circular Buffer) +//! ┌─────────────────────────────────────┐ +//! │ ┌─────┐┌─────┐┌─────┐ ┌─────┐ │ +//! │ │ Hdr ││Cell ││Cell │ ... │Cell │ │ +//! │ │ ││ 0 ││ 1 │ │ N-1 │ │ +//! │ └─────┘└─────┘└─────┘ └─────┘ │ +//! │ ┌─────┐┌─────┐┌─────┐ ┌─────┐ │ +//! │ │ Hdr ││Cell ││Cell │ ... │Cell │ │ +//! │ │ ││ 0 ││ 1 │ │ N-1 │ │ +//! │ └─────┘└─────┘└─────┘ └─────┘ │ +//! │ ┌─────┐┌─────┐┌─────┐ ┌─────┐ │ +//! │ │ Hdr ││Cell ││Cell │ ... │Cell │ │ +//! │ │ ││ 0 ││ 1 │ │ N-1 │ │ +//! │ └─────┘└─────┘└─────┘ └─────┘ │ +//! └─────────────────────────────────────┘ +//! +//! There are R rows with N columns. Each row has an extra "cell" which is +//! the row header. The row header is used to track metadata about the row. +//! Each cell itself is a union (see StorageCell) of either the header or +//! the cell. +//! +//! The storage is in a circular buffer so that scrollback can be handled +//! without copying rows. The circular buffer is implemented in circ_buf.zig. +//! The top of the circular buffer (index 0) is the top of the screen, +//! i.e. the scrollback if there is a lot of data. +//! +//! The top of the active area (or end of the history area, same thing) is +//! cached in `self.history` and is an offset in rows. This could always be +//! calculated but profiling showed that caching it saves a lot of time in +//! hot loops for minimal memory cost. +const Screen = @This(); + +const std = @import("std"); +const builtin = @import("builtin"); +const assert = std.debug.assert; +const Allocator = std.mem.Allocator; + +const ziglyph = @import("ziglyph"); +const ansi = @import("ansi.zig"); +const modes = @import("modes.zig"); +const sgr = @import("sgr.zig"); +const color = @import("color.zig"); +const kitty = @import("kitty.zig"); +const point = @import("point.zig"); +const CircBuf = @import("../circ_buf.zig").CircBuf; +const Selection = @import("Selection.zig"); +const StringMap = @import("StringMap.zig"); +const fastmem = @import("../fastmem.zig"); +const charsets = @import("charsets.zig"); + +const log = std.log.scoped(.screen); + +/// State required for all charset operations. +const CharsetState = struct { + /// The list of graphical charsets by slot + charsets: CharsetArray = CharsetArray.initFill(charsets.Charset.utf8), + + /// GL is the slot to use when using a 7-bit printable char (up to 127) + /// GR used for 8-bit printable chars. + gl: charsets.Slots = .G0, + gr: charsets.Slots = .G2, + + /// Single shift where a slot is used for exactly one char. + single_shift: ?charsets.Slots = null, + + /// An array to map a charset slot to a lookup table. + const CharsetArray = std.EnumArray(charsets.Slots, charsets.Charset); +}; + +/// 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, + + /// The visual style of the cursor. This defaults to block because + /// it has to default to something, but users of this struct are + /// encouraged to set their own default. + style: Style = .block, + + /// 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, + + /// The visual style of the cursor. Whether or not it blinks + /// is determined by mode 12 (modes.zig). This mode is synchronized + /// with CSI q, the same as xterm. + pub const Style = enum { bar, block, underline }; + + /// Saved cursor state. This contains more than just Cursor members + /// because additional state is stored. + pub const Saved = struct { + x: usize, + y: usize, + pen: Cell, + pending_wrap: bool, + origin: bool, + charset: CharsetState, + }; +}; + +/// 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 (!std.debug.runtime_safety) { + // 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 { + pub const Id = u32; + + /// The ID of this row, used to uniquely identify this row. The cells + /// are also ID'd by id + cell index (0-indexed). This will wrap around + /// when it reaches the maximum value for the type. For caching purposes, + /// when wrapping happens, all rows in the screen will be marked dirty. + id: Id = 0, + + // Packed flags + flags: packed struct { + /// If true, this row is soft-wrapped. The first cell of the next + /// row is a continuous of this row. + wrap: bool = false, + + /// True if this row has had changes. It is up to the caller to + /// set this to false. See the methods on Row to see what will set + /// this to true. + dirty: bool = false, + + /// True if any cell in this row has a grapheme associated with it. + grapheme: bool = false, + + /// True if this row is an active prompt (awaiting input). This is + /// set to false when the semantic prompt events (OSC 133) are received. + /// There are scenarios where the shell may never send this event, so + /// in order to reliably test prompt status, you need to iterate + /// backwards from the cursor to check the current line status going + /// back. + semantic_prompt: SemanticPrompt = .unknown, + } = .{}, + + /// Semantic prompt type. + pub const SemanticPrompt = enum(u3) { + /// Unknown, the running application didn't tell us for this line. + unknown = 0, + + /// This is a prompt line, meaning it only contains the shell prompt. + /// For poorly behaving shells, this may also be the input. + prompt = 1, + prompt_continuation = 2, + + /// This line contains the input area. We don't currently track + /// where this actually is in the line, so we just assume it is somewhere. + input = 3, + + /// This line is the start of command output. + command = 4, + + /// True if this is a prompt or input line. + pub fn promptOrInput(self: SemanticPrompt) bool { + return self == .prompt or self == .prompt_continuation or self == .input; + } + }; +}; + +/// The color associated with a single cell's foreground or background. +const CellColor = union(enum) { + none, + indexed: u8, + rgb: color.RGB, + + pub fn eql(self: CellColor, other: CellColor) bool { + return switch (self) { + .none => other == .none, + .indexed => |i| switch (other) { + .indexed => other.indexed == i, + else => false, + }, + .rgb => |rgb| switch (other) { + .rgb => other.rgb.eql(rgb), + else => 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. + fg: CellColor = .none, + bg: CellColor = .none, + + /// Underline color. + /// NOTE(mitchellh): This is very rarely set so ideally we wouldn't waste + /// cell space for this. For now its on this struct because it is convenient + /// but we should consider a lookaside table for this. + underline_fg: color.RGB = .{}, + + /// On/off attributes that can be set + attrs: packed struct { + bold: bool = false, + italic: bool = false, + faint: bool = false, + blink: bool = false, + inverse: bool = false, + invisible: bool = false, + strikethrough: bool = false, + underline: sgr.Attribute.Underline = .none, + underline_color: bool = false, + protected: 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 preceding + /// wide character (tail) or following (head). + wide_spacer_tail: bool = false, + wide_spacer_head: bool = false, + + /// True if this cell has additional codepoints to form a complete + /// grapheme cluster. If this is true, then the row grapheme flag must + /// also be true. The grapheme code points can be looked up in the + /// screen grapheme map. + grapheme: bool = false, + + /// Returns only the attributes related to style. + pub fn styleAttrs(self: @This()) @This() { + var copy = self; + copy.wide = false; + copy.wide_spacer_tail = false; + copy.wide_spacer_head = false; + copy.grapheme = false; + return copy; + } + } = .{}, + + /// True if the cell should be skipped for drawing + pub fn empty(self: Cell) bool { + // Get our backing integer for our packed struct of attributes + const AttrInt = @Type(.{ .Int = .{ + .signedness = .unsigned, + .bits = @bitSizeOf(@TypeOf(self.attrs)), + } }); + + // We're empty if we have no char AND we have no styling + return self.char == 0 and + self.fg == .none and + self.bg == .none and + @as(AttrInt, @bitCast(self.attrs)) == 0; + } + + /// The width of the cell. + /// + /// This uses the legacy calculation of a per-codepoint width calculation + /// to determine the width. This legacy calculation is incorrect because + /// it doesn't take into account multi-codepoint graphemes. + /// + /// The goal of this function is to match the expectation of shells + /// that aren't grapheme aware (at the time of writing this comment: none + /// are grapheme aware). This means it should match wcswidth. + pub fn widthLegacy(self: Cell) u8 { + // Wide is always 2 + if (self.attrs.wide) return 2; + + // Wide spacers are always 0 because their width is accounted for + // in the wide char. + if (self.attrs.wide_spacer_tail or self.attrs.wide_spacer_head) return 0; + + return 1; + } + + test "widthLegacy" { + const testing = std.testing; + + var c: Cell = .{}; + try testing.expectEqual(@as(u16, 1), c.widthLegacy()); + + c = .{ .attrs = .{ .wide = true } }; + try testing.expectEqual(@as(u16, 2), c.widthLegacy()); + + c = .{ .attrs = .{ .wide_spacer_tail = true } }; + try testing.expectEqual(@as(u16, 0), c.widthLegacy()); + } + + 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={} bits={} {}", .{ @sizeOf(Cell), @bitSizeOf(Cell), @alignOf(Cell) }); + try std.testing.expectEqual(20, @sizeOf(Cell)); + } +}; + +/// A row is a single row in the screen. +pub const Row = struct { + /// The screen this row is part of. + screen: *Screen, + + /// 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, + + /// Returns the ID for this row. You can turn this into a cell ID + /// by adding the cell offset plus 1 (so it is 1-indexed). + pub inline fn getId(self: Row) RowHeader.Id { + return self.storage[0].header.id; + } + + /// 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.flags.wrap = v; + } + + /// Set a row as dirty or not. Generally you only set a row as NOT dirty. + /// Various Row functions manage flagging dirty to true. + pub fn setDirty(self: Row, v: bool) void { + self.storage[0].header.flags.dirty = v; + } + + pub inline fn isDirty(self: Row) bool { + return self.storage[0].header.flags.dirty; + } + + pub inline fn isWrapped(self: Row) bool { + return self.storage[0].header.flags.wrap; + } + + /// Set the semantic prompt state for this row. + pub fn setSemanticPrompt(self: Row, p: RowHeader.SemanticPrompt) void { + self.storage[0].header.flags.semantic_prompt = p; + } + + /// Retrieve the semantic prompt state for this row. + pub fn getSemanticPrompt(self: Row) RowHeader.SemanticPrompt { + return self.storage[0].header.flags.semantic_prompt; + } + + /// 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; + } + + /// 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; + 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 { + self.fillSlice(cell, 0, self.storage.len - 1); + } + + /// Fill a slice of a row. + pub fn fillSlice(self: Row, cell: Cell, start: usize, len: usize) void { + assert(len <= self.storage.len - 1); + assert(!cell.attrs.grapheme); // you can't fill with graphemes + + // Always mark the row as dirty for this. + self.storage[0].header.flags.dirty = true; + + // If our row has no graphemes, then this is a fast copy + if (!self.storage[0].header.flags.grapheme) { + @memset(self.storage[start + 1 .. len + 1], .{ .cell = cell }); + return; + } + + // We have graphemes, so we have to clear those first. + for (self.storage[start + 1 .. len + 1], 0..) |*storage_cell, x| { + if (storage_cell.cell.attrs.grapheme) self.clearGraphemes(x); + storage_cell.* = .{ .cell = cell }; + } + + // We only reset the grapheme flag if we fill the whole row, for now. + // We can improve performance by more correctly setting this but I'm + // going to defer that until we can measure. + if (start == 0 and len == self.storage.len - 1) { + self.storage[0].header.flags.grapheme = false; + } + } + + /// Get a single immutable cell. + pub fn getCell(self: Row, x: usize) Cell { + 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(x < self.storage.len - 1); + + // Always mark the row as dirty for this. + self.storage[0].header.flags.dirty = true; + + return &self.storage[x + 1].cell; + } + + /// Attach a grapheme codepoint to the given cell. + pub fn attachGrapheme(self: Row, x: usize, cp: u21) !void { + assert(x < self.storage.len - 1); + + const cell = &self.storage[x + 1].cell; + const key = self.getId() + x + 1; + const gop = try self.screen.graphemes.getOrPut(self.screen.alloc, key); + errdefer if (!gop.found_existing) { + _ = self.screen.graphemes.remove(key); + }; + + // Our row now has a grapheme + self.storage[0].header.flags.grapheme = true; + + // Our row is now dirty + self.storage[0].header.flags.dirty = true; + + // If we weren't previously a grapheme and we found an existing value + // it means that it is old grapheme data. Just delete that. + if (!cell.attrs.grapheme and gop.found_existing) { + cell.attrs.grapheme = true; + gop.value_ptr.deinit(self.screen.alloc); + gop.value_ptr.* = .{ .one = cp }; + return; + } + + // If we didn't have a previous value, attach the single codepoint. + if (!gop.found_existing) { + cell.attrs.grapheme = true; + gop.value_ptr.* = .{ .one = cp }; + return; + } + + // We have an existing value, promote + assert(cell.attrs.grapheme); + try gop.value_ptr.append(self.screen.alloc, cp); + } + + /// Removes all graphemes associated with a cell. + pub fn clearGraphemes(self: Row, x: usize) void { + assert(x < self.storage.len - 1); + + // Our row is now dirty + self.storage[0].header.flags.dirty = true; + + const cell = &self.storage[x + 1].cell; + const key = self.getId() + x + 1; + cell.attrs.grapheme = false; + if (self.screen.graphemes.fetchRemove(key)) |kv| { + kv.value.deinit(self.screen.alloc); + } + } + + /// Copy a single cell from column x in src to column x in this row. + pub fn copyCell(self: Row, src: Row, x: usize) !void { + const dst_cell = self.getCellPtr(x); + const src_cell = src.getCellPtr(x); + + // If our destination has graphemes, we have to clear them. + if (dst_cell.attrs.grapheme) self.clearGraphemes(x); + dst_cell.* = src_cell.*; + + // If the source doesn't have any graphemes, then we can just copy. + if (!src_cell.attrs.grapheme) return; + + // Source cell has graphemes. Copy them. + const src_key = src.getId() + x + 1; + const src_data = src.screen.graphemes.get(src_key) orelse return; + const dst_key = self.getId() + x + 1; + const dst_gop = try self.screen.graphemes.getOrPut(self.screen.alloc, dst_key); + dst_gop.value_ptr.* = try src_data.copy(self.screen.alloc); + self.storage[0].header.flags.grapheme = true; + } + + /// Copy the row src into this row. The row can be from another screen. + pub fn copyRow(self: Row, src: Row) !void { + // 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; + + // If the source has no graphemes (likely) then this is fast. + const end = @min(src.storage.len, self.storage.len); + if (!src.storage[0].header.flags.grapheme) { + fastmem.copy(StorageCell, self.storage[1..], src.storage[1..end]); + return; + } + + // Source has graphemes, this is slow. + for (src.storage[1..end], 0..) |storage, x| { + self.storage[x + 1] = .{ .cell = storage.cell }; + + // Copy grapheme data if it exists + if (storage.cell.attrs.grapheme) { + const src_key = src.getId() + x + 1; + const src_data = src.screen.graphemes.get(src_key) orelse continue; + + const dst_key = self.getId() + x + 1; + const dst_gop = try self.screen.graphemes.getOrPut(self.screen.alloc, dst_key); + dst_gop.value_ptr.* = try src_data.copy(self.screen.alloc); + + self.storage[0].header.flags.grapheme = true; + } + } + } + + /// Read-only iterator for the cells in the row. + pub fn cellIterator(self: Row) CellIterator { + return .{ .row = self }; + } + + /// Returns the number of codepoints in the cell at column x, + /// including the primary codepoint. + pub fn codepointLen(self: Row, x: usize) usize { + var it = self.codepointIterator(x); + return it.len() + 1; + } + + /// Read-only iterator for the grapheme codepoints in a cell. This only + /// iterates over the EXTRA GRAPHEME codepoints and not the primary + /// codepoint in cell.char. + pub fn codepointIterator(self: Row, x: usize) CodepointIterator { + const cell = &self.storage[x + 1].cell; + if (!cell.attrs.grapheme) return .{ .data = .{ .zero = {} } }; + + const key = self.getId() + x + 1; + const data: GraphemeData = self.screen.graphemes.get(key) orelse data: { + // This is probably a bug somewhere in our internal state, + // but we don't want to just hard crash so its easier to just + // have zero codepoints. + log.debug("cell with grapheme flag but no grapheme data", .{}); + break :data .{ .zero = {} }; + }; + return .{ .data = data }; + } + + /// Returns true if this cell is the end of a grapheme cluster. + /// + /// NOTE: If/when "real" grapheme cluster support is in then + /// this will be removed because every cell will represent exactly + /// one grapheme cluster. + pub fn graphemeBreak(self: Row, x: usize) bool { + const cell = &self.storage[x + 1].cell; + + // Right now, if we are a grapheme, we only store ZWJs on + // the grapheme data so that means we can't be a break. + if (cell.attrs.grapheme) return false; + + // If we are a tail then we check our prior cell. + if (cell.attrs.wide_spacer_tail and x > 0) { + return self.graphemeBreak(x - 1); + } + + // If we are a wide char, then we have to check our prior cell. + if (cell.attrs.wide and x > 0) { + return self.graphemeBreak(x - 1); + } + + return true; + } +}; + +/// Used to iterate through the rows of a specific region. +pub const RowIterator = struct { + screen: *Screen, + tag: RowIndexTag, + max: usize, + value: usize = 0, + + pub fn next(self: *RowIterator) ?Row { + if (self.value >= self.max) 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; + } +}; + +/// Used to iterate through the codepoints of a cell. This only iterates +/// over the extra grapheme codepoints and not the primary codepoint. +pub const CodepointIterator = struct { + data: GraphemeData, + i: usize = 0, + + /// Returns the number of codepoints in the iterator. + pub fn len(self: CodepointIterator) usize { + switch (self.data) { + .zero => return 0, + .one => return 1, + .two => return 2, + .three => return 3, + .four => return 4, + .many => |v| return v.len, + } + } + + pub fn next(self: *CodepointIterator) ?u21 { + switch (self.data) { + .zero => return null, + + .one => |v| { + if (self.i >= 1) return null; + self.i += 1; + return v; + }, + + .two => |v| { + if (self.i >= v.len) return null; + defer self.i += 1; + return v[self.i]; + }, + + .three => |v| { + if (self.i >= v.len) return null; + defer self.i += 1; + return v[self.i]; + }, + + .four => |v| { + if (self.i >= v.len) return null; + defer self.i += 1; + return v[self.i]; + }, + + .many => |v| { + if (self.i >= v.len) return null; + defer self.i += 1; + return v[self.i]; + }, + } + } + + pub fn reset(self: *CodepointIterator) void { + self.i = 0; + } +}; + +/// 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: { + // NOTE for this and others below: Zig is supposed to optimize + // away assert in releasefast but for some reason these were + // not being optimized away. I don't know why. For these asserts + // only, I comptime gate them. + if (std.debug.runtime_safety) assert(y < RowIndexTag.screen.maxLen(screen)); + break :y y; + }, + + .viewport => |y| y: { + if (std.debug.runtime_safety) assert(y < RowIndexTag.viewport.maxLen(screen)); + break :y y + screen.viewport; + }, + + .active => |y| y: { + if (std.debug.runtime_safety) assert(y < RowIndexTag.active.maxLen(screen)); + break :y screen.history + y; + }, + + .history => |y| y: { + if (std.debug.runtime_safety) 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 inline fn maxLen(self: RowIndexTag, screen: *const Screen) usize { + return switch (self) { + // Screen can be any of the written rows + .screen => screen.rowsWritten(), + + // Viewport can be any of the written rows or the max size + // of a viewport. + .viewport => @max(1, @min(screen.rows, screen.rowsWritten())), + + // 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 => screen.history, + + // 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, + //TODO .active => @min(rows_written, 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 }, + }; + } +}; + +/// Stores the extra unicode codepoints that form a complete grapheme +/// cluster alongside a cell. We store this separately from a Cell because +/// grapheme clusters are relatively rare (depending on the language) and +/// we don't want to pay for the full cost all the time. +pub const GraphemeData = union(enum) { + // The named counts allow us to avoid allocators. We do this because + // []u21 is sizeof([4]u21) anyways so if we can store avoid small allocations + // we prefer it. Grapheme clusters are almost always <= 4 codepoints. + + zero: void, + one: u21, + two: [2]u21, + three: [3]u21, + four: [4]u21, + many: []u21, + + pub fn deinit(self: GraphemeData, alloc: Allocator) void { + switch (self) { + .many => |v| alloc.free(v), + else => {}, + } + } + + /// Append the codepoint cp to the grapheme data. + pub fn append(self: *GraphemeData, alloc: Allocator, cp: u21) !void { + switch (self.*) { + .zero => self.* = .{ .one = cp }, + .one => |v| self.* = .{ .two = .{ v, cp } }, + .two => |v| self.* = .{ .three = .{ v[0], v[1], cp } }, + .three => |v| self.* = .{ .four = .{ v[0], v[1], v[2], cp } }, + .four => |v| { + const many = try alloc.alloc(u21, 5); + fastmem.copy(u21, many, &v); + many[4] = cp; + self.* = .{ .many = many }; + }, + + .many => |v| { + // Note: this is super inefficient, we should use an arraylist + // or something so we have extra capacity. + const many = try alloc.realloc(v, v.len + 1); + many[v.len] = cp; + self.* = .{ .many = many }; + }, + } + } + + pub fn copy(self: GraphemeData, alloc: Allocator) !GraphemeData { + // If we're not many we're not allocated so just copy on stack. + if (self != .many) return self; + + // Heap allocated + return GraphemeData{ .many = try alloc.dupe(u21, self.many) }; + } + + test { + log.warn("Grapheme={}", .{@sizeOf(GraphemeData)}); + } + + test "append" { + const testing = std.testing; + const alloc = testing.allocator; + + var data: GraphemeData = .{ .one = 1 }; + defer data.deinit(alloc); + + try data.append(alloc, 2); + try testing.expectEqual(GraphemeData{ .two = .{ 1, 2 } }, data); + try data.append(alloc, 3); + try testing.expectEqual(GraphemeData{ .three = .{ 1, 2, 3 } }, data); + try data.append(alloc, 4); + try testing.expectEqual(GraphemeData{ .four = .{ 1, 2, 3, 4 } }, data); + try data.append(alloc, 5); + try testing.expect(data == .many); + try testing.expectEqualSlices(u21, &[_]u21{ 1, 2, 3, 4, 5 }, data.many); + try data.append(alloc, 6); + try testing.expect(data == .many); + try testing.expectEqualSlices(u21, &[_]u21{ 1, 2, 3, 4, 5, 6 }, data.many); + } + + comptime { + // We want to keep this at most the size of the tag + []u21 so that + // at most we're paying for the cost of a slice. + //assert(@sizeOf(GraphemeData) == 24); + } +}; + +/// A line represents a line of text, potentially across soft-wrapped +/// boundaries. This differs from row, which is a single physical row within +/// the terminal screen. +pub const Line = struct { + screen: *Screen, + tag: RowIndexTag, + start: usize, + len: usize, + + /// Return the string for this line. + pub fn string(self: *const Line, alloc: Allocator) ![:0]const u8 { + return try self.screen.selectionString(alloc, self.selection(), true); + } + + /// Receive the string for this line along with the byte-to-point mapping. + pub fn stringMap(self: *const Line, alloc: Allocator) !StringMap { + return try self.screen.selectionStringMap(alloc, self.selection()); + } + + /// Return a selection that covers the entire line. + pub fn selection(self: *const Line) Selection { + // Get the start and end screen point. + const start_idx = self.tag.index(self.start).toScreen(self.screen).screen; + const end_idx = self.tag.index(self.start + (self.len - 1)).toScreen(self.screen).screen; + + // Convert the start and end screen points into a selection across + // the entire rows. We then use selectionString because it handles + // unwrapping, graphemes, etc. + return .{ + .start = .{ .y = start_idx, .x = 0 }, + .end = .{ .y = end_idx, .x = self.screen.cols - 1 }, + }; + } +}; + +/// Iterator over textual lines within the terminal. This will unwrap +/// wrapped lines and consider them a single line. +pub const LineIterator = struct { + row_it: RowIterator, + + pub fn next(self: *LineIterator) ?Line { + const start = self.row_it.value; + + // Get our current row + var row = self.row_it.next() orelse return null; + var len: usize = 1; + + // While the row is wrapped we keep iterating over the rows + // and incrementing the length. + while (row.isWrapped()) { + // Note: this orelse shouldn't happen. A wrapped row should + // always have a next row. However, this isn't the place where + // we want to assert that. + row = self.row_it.next() orelse break; + len += 1; + } + + return .{ + .screen = self.row_it.screen, + .tag = self.row_it.tag, + .start = start, + .len = len, + }; + } +}; + +// 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 = .{} }); + +/// Stores a mapping of cell ID (row ID + cell offset + 1) to +/// graphemes associated with a cell. To know if a cell has graphemes, +/// check the "grapheme" flag of a cell. +const GraphemeMap = std.AutoHashMapUnmanaged(usize, GraphemeData); + +/// The allocator used for all the storage operations +alloc: Allocator, + +/// The full set of storage. +storage: StorageBuf, + +/// Graphemes associated with our current screen. +graphemes: GraphemeMap = .{}, + +/// The next ID to assign to a row. The value of this is NOT assigned. +next_row_id: RowHeader.Id = 1, + +/// 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, + +/// The amount of history (scrollback) that has been written so far. This +/// can be calculated dynamically using the storage buffer but its an +/// extremely hot piece of data so we cache it. Empirically this eliminates +/// millions of function calls and saves seconds under high scroll scenarios +/// (i.e. reading a large file). +history: usize, + +/// Each screen maintains its own cursor state. +cursor: Cursor = .{}, + +/// Saved cursor saved with DECSC (ESC 7). +saved_cursor: ?Cursor.Saved = null, + +/// The selection for this screen (if any). +selection: ?Selection = null, + +/// The kitty keyboard settings. +kitty_keyboard: kitty.KeyFlagStack = .{}, + +/// Kitty graphics protocol state. +kitty_images: kitty.graphics.ImageStorage = .{}, + +/// The charset state +charset: CharsetState = .{}, + +/// 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, + 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 + @min(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, + .history = 0, + }; +} + +pub fn deinit(self: *Screen) void { + self.kitty_images.deinit(self.alloc); + self.storage.deinit(self.alloc); + self.deinitGraphemes(); +} + +fn deinitGraphemes(self: *Screen) void { + var grapheme_it = self.graphemes.valueIterator(); + while (grapheme_it.next()) |data| data.deinit(self.alloc); + self.graphemes.deinit(self.alloc); +} + +/// Copy the screen portion given by top and bottom into a new screen instance. +/// This clone is meant for read-only access and hasn't been tested for +/// mutability. +pub fn clone(self: *Screen, alloc: Allocator, top: RowIndex, bottom: RowIndex) !Screen { + // Convert our top/bottom to screen coordinates + const top_y = top.toScreen(self).screen; + const bot_y = bottom.toScreen(self).screen; + assert(bot_y >= top_y); + const height = (bot_y - top_y) + 1; + + // We also figure out the "max y" we can have based on the number + // of rows written. This is used to prevent from reading out of the + // circular buffer where we might have no initialized data yet. + const max_y = max_y: { + const rows_written = self.rowsWritten(); + const index = RowIndex{ .active = @min(rows_written -| 1, self.rows - 1) }; + break :max_y index.toScreen(self).screen; + }; + + // The "real" Y value we use is whichever is smaller: the bottom + // requested or the max. This prevents from reading zero data. + // The "real" height is the amount of height of data we can actually + // 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 + // caller. + var result = try init(alloc, height, self.cols, 0); + errdefer result.deinit(); + + // Copy some data + result.cursor = self.cursor; + + // Get the pointer to our source buffer + const len = real_height * (self.cols + 1); + const src = self.storage.getPtrSlice(top_y * (self.cols + 1), len); + + // Get a direct pointer into our storage buffer. This should always be + // one slice because we created a perfectly fitting buffer. + const dst = result.storage.getPtrSlice(0, len); + assert(dst[1].len == 0); + + // Perform the copy + // std.log.warn("copy bytes={}", .{src[0].len + src[1].len}); + fastmem.copy(StorageCell, dst[0], src[0]); + fastmem.copy(StorageCell, dst[0][src[0].len..], src[1]); + + // If there are graphemes, we just copy them all + if (self.graphemes.count() > 0) { + // Clone the map + const graphemes = try self.graphemes.clone(alloc); + + // Go through all the values and clone the data because it MAY + // (rarely) be allocated. + var it = graphemes.iterator(); + while (it.next()) |kv| { + kv.value_ptr.* = try kv.value_ptr.copy(alloc); + } + + result.graphemes = graphemes; + } + + return result; +} + +/// Returns true if the viewport is scrolled to the bottom of the screen. +pub fn viewportIsBottom(self: Screen) bool { + return self.viewport == self.history; +} + +/// 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: *Screen, tag: RowIndexTag) RowIterator { + return .{ + .screen = self, + .tag = tag, + .max = tag.maxLen(self), + }; +} + +/// Returns an iterator that iterates over the lines of the screen. A line +/// is a single line of text which may wrap across multiple rows. A row +/// is a single physical row of the terminal. +pub fn lineIterator(self: *Screen, tag: RowIndexTag) LineIterator { + return .{ .row_it = self.rowIterator(tag) }; +} + +/// Returns the line that contains the given point. This may be null if the +/// point is outside the screen. +pub fn getLine(self: *Screen, pt: point.ScreenPoint) ?Line { + // If our y is outside of our written area, we have no line. + if (pt.y >= RowIndexTag.screen.maxLen(self)) return null; + if (pt.x >= self.cols) return null; + + // Find the starting y. We go back and as soon as we find a row that + // isn't wrapped, we know the NEXT line is the one we want. + const start_y: usize = if (pt.y == 0) 0 else start_y: { + for (1..pt.y) |y| { + const bot_y = pt.y - y; + const row = self.getRow(.{ .screen = bot_y }); + if (!row.isWrapped()) break :start_y bot_y + 1; + } + + break :start_y 0; + }; + + // Find the end y, which is the first row that isn't wrapped. + const end_y = end_y: { + for (pt.y..self.rowsWritten()) |y| { + const row = self.getRow(.{ .screen = y }); + if (!row.isWrapped()) break :end_y y; + } + + break :end_y self.rowsWritten() - 1; + }; + + return .{ + .screen = self, + .tag = .screen, + .start = start_y, + .len = (end_y - start_y) + 1, + }; +} + +/// 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 = .{ .screen = self, .storage = slices[0] }; + if (row.storage[0].header.id == 0) { + const Id = @TypeOf(self.next_row_id); + const id = self.next_row_id; + self.next_row_id +%= @as(Id, @intCast(self.cols)); + + // Store the header + row.storage[0].header.id = id; + + // We only set dirty and fill if its not dirty. If its dirty + // we assume this row has been written but just hasn't had + // an ID assigned yet. + if (!row.storage[0].header.flags.dirty) { + // Mark that we're dirty since we're a new row + row.storage[0].header.flags.dirty = true; + + // We only need to fill with runtime safety because unions are + // tag-checked. Otherwise, the default value of zero will be valid. + if (std.debug.runtime_safety) row.fill(.{}); + } + } + 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); + try dst_row.copyRow(src_row); +} + +/// Scroll rows in a region up. Rows that go beyond the region +/// top or bottom are deleted, and new rows inserted are blank according +/// to the current pen. +/// +/// This does NOT create any new scrollback. This modifies an existing +/// region within the screen (including possibly the scrollback if +/// the top/bottom are within it). +/// +/// This can be used to implement terminal scroll regions efficiently. +pub fn scrollRegionUp(self: *Screen, top: RowIndex, bottom: RowIndex, count_req: usize) void { + // Avoid a lot of work if we're doing nothing. + if (count_req == 0) return; + + // Convert our top/bottom to screen y values. This is the y offset + // in the entire screen buffer. + const top_y = top.toScreen(self).screen; + const bot_y = bottom.toScreen(self).screen; + + // If top is outside of the range of bot, we do nothing. + if (top_y >= bot_y) return; + + // We can only scroll up to the number of rows in the region. The "+ 1" + // is because our y values are 0-based and count is 1-based. + const count = @min(count_req, bot_y - top_y + 1); + + // Get the storage pointer for the full scroll region. We're going to + // be modifying the whole thing so we get it right away. + const height = (bot_y - top_y) + 1; + const len = height * (self.cols + 1); + const slices = self.storage.getPtrSlice(top_y * (self.cols + 1), len); + + // The total amount we're going to copy + const total_copy = (height - count) * (self.cols + 1); + + // The pen we'll use for new cells (only the BG attribute is applied to new + // cells) + const pen: Cell = switch (self.cursor.pen.bg) { + .none => .{}, + else => |bg| .{ .bg = bg }, + }; + + // Fast-path is that we have a contiguous buffer in our circular buffer. + // In this case we can do some memmoves. + if (slices[1].len == 0) { + const buf = slices[0]; + + { + // Our copy starts "count" rows below and is the length of + // the remainder of the data. Our destination is the top since + // we're scrolling up. + // + // Note we do NOT need to set any row headers to dirty because + // the row contents are not changing for the row ID. + const dst = buf; + const src_offset = count * (self.cols + 1); + const src = buf[src_offset..]; + assert(@intFromPtr(dst.ptr) < @intFromPtr(src.ptr)); + fastmem.move(StorageCell, dst, src); + } + + { + // Copy in our empties. The destination is the bottom + // count rows. We first fill with the pen values since there + // is a lot more of that. + const dst_offset = total_copy; + const dst = buf[dst_offset..]; + @memset(dst, .{ .cell = pen }); + + // Then we make sure our row headers are zeroed out. We set + // the value to a dirty row header so that the renderer re-draws. + // + // NOTE: we do NOT set a valid row ID here. The next time getRow + // is called it will be initialized. This should work fine as + // far as I can tell. It is important to set dirty so that the + // renderer knows to redraw this. + var i: usize = dst_offset; + while (i < buf.len) : (i += self.cols + 1) { + buf[i] = .{ .header = .{ + .flags = .{ .dirty = true }, + } }; + } + } + + return; + } + + // If we're split across two buffers this is a "slow" path. This shouldn't + // happen with the "active" area but it appears it does... in the future + // I plan on changing scroll region stuff to make it much faster so for + // now we just deal with this slow path. + + // This is the offset where we have to start copying. + const src_offset = count * (self.cols + 1); + + // Perform the copy and calculate where we need to start zero-ing. + const zero_offset: [2]usize = if (src_offset < slices[0].len) zero_offset: { + var remaining: usize = len; + + // Source starts in the top... so we can copy some from there. + const dst = slices[0]; + const src = slices[0][src_offset..]; + assert(@intFromPtr(dst.ptr) < @intFromPtr(src.ptr)); + fastmem.move(StorageCell, dst, src); + remaining = total_copy - src.len; + if (remaining == 0) break :zero_offset .{ src.len, 0 }; + + // We have data remaining, which means that we have to grab some + // from the bottom slice. + const dst2 = slices[0][src.len..]; + const src2_len = @min(dst2.len, remaining); + const src2 = slices[1][0..src2_len]; + fastmem.copy(StorageCell, dst2, src2); + remaining -= src2_len; + if (remaining == 0) break :zero_offset .{ src.len + src2.len, 0 }; + + // We still have data remaining, which means we copy into the bot. + const dst3 = slices[1]; + const src3 = slices[1][src2_len .. src2_len + remaining]; + fastmem.move(StorageCell, dst3, src3); + + break :zero_offset .{ slices[0].len, src3.len }; + } else zero_offset: { + var remaining: usize = len; + + // Source is in the bottom, so we copy from there into top. + const bot_src_offset = src_offset - slices[0].len; + const dst = slices[0]; + const src = slices[1][bot_src_offset..]; + const src_len = @min(dst.len, src.len); + fastmem.copy(StorageCell, dst, src[0..src_len]); + remaining = total_copy - src_len; + if (remaining == 0) break :zero_offset .{ src_len, 0 }; + + // We have data remaining, this has to go into the bottom. + const dst2 = slices[1]; + const src2_offset = bot_src_offset + src_len; + const src2 = slices[1][src2_offset..]; + const src2_len = remaining; + fastmem.move(StorageCell, dst2, src2[0..src2_len]); + break :zero_offset .{ src_len, src2_len }; + }; + + // Zero + for (zero_offset, 0..) |offset, i| { + if (offset >= slices[i].len) continue; + + const dst = slices[i][offset..]; + @memset(dst, .{ .cell = pen }); + + var j: usize = offset; + while (j < slices[i].len) : (j += self.cols + 1) { + slices[i][j] = .{ .header = .{ + .flags = .{ .dirty = true }, + } }; + } + } +} + +/// 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); +} + +pub const ClearMode = enum { + /// Delete all history. This will also move the viewport area to the top + /// so that the viewport area never contains history. This does NOT + /// change the active area. + history, + + /// Clear all the lines above the cursor in the active area. This does + /// not touch history. + above_cursor, +}; + +/// Clear the screen contents according to the given mode. +pub fn clear(self: *Screen, mode: ClearMode) !void { + switch (mode) { + .history => { + // If there is no history, do nothing. + if (self.history == 0) return; + + // Delete all our history + self.storage.deleteOldest(self.history * (self.cols + 1)); + self.history = 0; + + // Back to the top + self.viewport = 0; + }, + + .above_cursor => { + // First we copy all the rows from our cursor down to the top + // of the active area. + var y: usize = self.cursor.y; + const y_max = @min(self.rows, self.rowsWritten()) - 1; + const copy_n = (y_max - y) + 1; + while (y <= y_max) : (y += 1) { + const dst_y = y - self.cursor.y; + const dst = self.getRow(.{ .active = dst_y }); + const src = self.getRow(.{ .active = y }); + try dst.copyRow(src); + } + + // Next we want to clear all the rows below the copied amount. + y = copy_n; + while (y <= y_max) : (y += 1) { + const dst = self.getRow(.{ .active = y }); + dst.clear(.{}); + } + + // Move our cursor to the top + self.cursor.y = 0; + + // Scroll to the top of the viewport + self.viewport = self.history; + }, + } +} + +/// Return the selection for all contents on the screen. Surrounding +/// whitespace is omitted. If there is no selection, this returns null. +pub fn selectAll(self: *Screen) ?Selection { + const whitespace = &[_]u32{ 0, ' ', '\t' }; + const y_max = self.rowsWritten() - 1; + + const start: point.ScreenPoint = start: { + var y: usize = 0; + while (y <= y_max) : (y += 1) { + const current_row = self.getRow(.{ .screen = y }); + var x: usize = 0; + while (x < self.cols) : (x += 1) { + const cell = current_row.getCell(x); + + // Empty is whitespace + if (cell.empty()) continue; + + // Non-empty means we found it. + const this_whitespace = std.mem.indexOfAny( + u32, + whitespace, + &[_]u32{cell.char}, + ) != null; + if (this_whitespace) continue; + + break :start .{ .x = x, .y = y }; + } + } + + // There is no start point and therefore no line that can be selected. + return null; + }; + + const end: point.ScreenPoint = end: { + var y: usize = y_max; + while (true) { + const current_row = self.getRow(.{ .screen = y }); + + var x: usize = 0; + while (x < self.cols) : (x += 1) { + const real_x = self.cols - x - 1; + const cell = current_row.getCell(real_x); + + // Empty or whitespace, ignore. + if (cell.empty()) continue; + const this_whitespace = std.mem.indexOfAny( + u32, + whitespace, + &[_]u32{cell.char}, + ) != null; + if (this_whitespace) continue; + + // Got it + break :end .{ .x = real_x, .y = y }; + } + + if (y == 0) break; + y -= 1; + } + }; + + return Selection{ + .start = start, + .end = end, + }; +} + +/// Select the line under the given point. This will select across soft-wrapped +/// lines and will omit the leading and trailing whitespace. If the point is +/// over whitespace but the line has non-whitespace characters elsewhere, the +/// line will be selected. +pub fn selectLine(self: *Screen, pt: point.ScreenPoint) ?Selection { + // Whitespace characters for selection purposes + const whitespace = &[_]u32{ 0, ' ', '\t' }; + + // Impossible to select anything outside of the area we've written. + const y_max = self.rowsWritten() - 1; + if (pt.y > y_max or pt.x >= self.cols) return null; + + // Get the current point semantic prompt state since that determines + // boundary conditions too. This makes it so that line selection can + // only happen within the same prompt state. For example, if you triple + // click output, but the shell uses spaces to soft-wrap to the prompt + // then the selection will stop prior to the prompt. See issue #1329. + const semantic_prompt_state = self.getRow(.{ .screen = pt.y }) + .getSemanticPrompt() + .promptOrInput(); + + // The real start of the row is the first row in the soft-wrap. + const start_row: usize = start_row: { + if (pt.y == 0) break :start_row 0; + + var y: usize = pt.y - 1; + while (true) { + const current = self.getRow(.{ .screen = y }); + if (!current.header().flags.wrap) break :start_row y + 1; + + // See semantic_prompt_state comment for why + const current_prompt = current.getSemanticPrompt().promptOrInput(); + if (current_prompt != semantic_prompt_state) break :start_row y + 1; + + if (y == 0) break :start_row y; + y -= 1; + } + unreachable; + }; + + // The real end of the row is the final row in the soft-wrap. + const end_row: usize = end_row: { + var y: usize = pt.y; + while (y <= y_max) : (y += 1) { + const current = self.getRow(.{ .screen = y }); + + // See semantic_prompt_state comment for why + const current_prompt = current.getSemanticPrompt().promptOrInput(); + if (current_prompt != semantic_prompt_state) break :end_row y - 1; + + // End of the screen or not wrapped, we're done. + if (y == y_max or !current.header().flags.wrap) break :end_row y; + } + unreachable; + }; + + // Go forward from the start to find the first non-whitespace character. + const start: point.ScreenPoint = start: { + var y: usize = start_row; + while (y <= y_max) : (y += 1) { + const current_row = self.getRow(.{ .screen = y }); + var x: usize = 0; + while (x < self.cols) : (x += 1) { + const cell = current_row.getCell(x); + + // Empty is whitespace + if (cell.empty()) continue; + + // Non-empty means we found it. + const this_whitespace = std.mem.indexOfAny( + u32, + whitespace, + &[_]u32{cell.char}, + ) != null; + if (this_whitespace) continue; + + break :start .{ .x = x, .y = y }; + } + } + + // There is no start point and therefore no line that can be selected. + return null; + }; + + // Go backward from the end to find the first non-whitespace character. + const end: point.ScreenPoint = end: { + var y: usize = end_row; + while (true) { + const current_row = self.getRow(.{ .screen = y }); + + var x: usize = 0; + while (x < self.cols) : (x += 1) { + const real_x = self.cols - x - 1; + const cell = current_row.getCell(real_x); + + // Empty or whitespace, ignore. + if (cell.empty()) continue; + const this_whitespace = std.mem.indexOfAny( + u32, + whitespace, + &[_]u32{cell.char}, + ) != null; + if (this_whitespace) continue; + + // Got it + break :end .{ .x = real_x, .y = y }; + } + + if (y == 0) break; + y -= 1; + } + + // There is no start point and therefore no line that can be selected. + return null; + }; + + return Selection{ + .start = start, + .end = end, + }; +} + +/// Select the nearest word to start point that is between start_pt and +/// end_pt (inclusive). Because it selects "nearest" to start point, start +/// point can be before or after end point. +pub fn selectWordBetween( + self: *Screen, + start_pt: point.ScreenPoint, + end_pt: point.ScreenPoint, +) ?Selection { + const dir: point.Direction = if (start_pt.before(end_pt)) .right_down else .left_up; + var it = start_pt.iterator(self, dir); + while (it.next()) |pt| { + // Boundary conditions + switch (dir) { + .right_down => if (end_pt.before(pt)) return null, + .left_up => if (pt.before(end_pt)) return null, + } + + // If we found a word, then return it + if (self.selectWord(pt)) |sel| return sel; + } + + return null; +} + +/// Select the word under the given point. A word is any consecutive series +/// of characters that are exclusively whitespace or exclusively non-whitespace. +/// A selection can span multiple physical lines if they are soft-wrapped. +/// +/// This will return null if a selection is impossible. The only scenario +/// this happens is if the point pt is outside of the written screen space. +pub fn selectWord(self: *Screen, pt: point.ScreenPoint) ?Selection { + // Boundary characters for selection purposes + const boundary = &[_]u32{ + 0, + ' ', + '\t', + '\'', + '"', + '│', + '`', + '|', + ':', + ',', + '(', + ')', + '[', + ']', + '{', + '}', + '<', + '>', + }; + + // Impossible to select anything outside of the area we've written. + const y_max = self.rowsWritten() - 1; + if (pt.y > y_max) return null; + + // Get our row + const row = self.getRow(.{ .screen = pt.y }); + const start_cell = row.getCell(pt.x); + + // If our cell is empty we can't select a word, because we can't select + // areas where the screen is not yet written. + if (start_cell.empty()) return null; + + // Determine if we are a boundary or not to determine what our boundary is. + const expect_boundary = std.mem.indexOfAny(u32, boundary, &[_]u32{start_cell.char}) != null; + + // Go forwards to find our end boundary + const end: point.ScreenPoint = boundary: { + var prev: point.ScreenPoint = pt; + var y: usize = pt.y; + var x: usize = pt.x; + while (y <= y_max) : (y += 1) { + const current_row = self.getRow(.{ .screen = y }); + + // Go through all the remainining cells on this row until + // we reach a boundary condition. + while (x < self.cols) : (x += 1) { + const cell = current_row.getCell(x); + + // If we reached an empty cell its always a boundary + if (cell.empty()) break :boundary prev; + + // If we do not match our expected set, we hit a boundary + const this_boundary = std.mem.indexOfAny( + u32, + boundary, + &[_]u32{cell.char}, + ) != null; + if (this_boundary != expect_boundary) break :boundary prev; + + // Increase our prev + prev.x = x; + prev.y = y; + } + + // If we aren't wrapping, then we're done this is a boundary. + if (!current_row.header().flags.wrap) break :boundary prev; + + // If we are wrapping, reset some values and search the next line. + x = 0; + } + + break :boundary .{ .x = self.cols - 1, .y = y_max }; + }; + + // Go backwards to find our start boundary + const start: point.ScreenPoint = boundary: { + var current_row = row; + var prev: point.ScreenPoint = pt; + + var y: usize = pt.y; + var x: usize = pt.x; + while (true) { + // Go through all the remainining cells on this row until + // we reach a boundary condition. + while (x > 0) : (x -= 1) { + const cell = current_row.getCell(x - 1); + const this_boundary = std.mem.indexOfAny( + u32, + boundary, + &[_]u32{cell.char}, + ) != null; + if (this_boundary != expect_boundary) break :boundary prev; + + // Update our prev + prev.x = x - 1; + prev.y = y; + } + + // If we're at the start, we need to check if the previous line wrapped. + // If we are wrapped, we continue searching. If we are not wrapped, + // then we've hit a boundary. + assert(prev.x == 0); + + // If we're at the end, we're done! + if (y == 0) break; + + // If the previous row did not wrap, then we're done. Otherwise + // we keep searching. + y -= 1; + current_row = self.getRow(.{ .screen = y }); + if (!current_row.header().flags.wrap) break :boundary prev; + + // Set x to start at the first non-empty cell + x = self.cols; + while (x > 0) : (x -= 1) { + if (!current_row.getCell(x - 1).empty()) break; + } + } + + break :boundary .{ .x = 0, .y = 0 }; + }; + + return Selection{ + .start = start, + .end = end, + }; +} + +/// Select the command output under the given point. The limits of the output +/// are determined by semantic prompt information provided by shell integration. +/// A selection can span multiple physical lines if they are soft-wrapped. +/// +/// This will return null if a selection is impossible. The only scenarios +/// this happens is if: +/// - the point pt is outside of the written screen space. +/// - the point pt is on a prompt / input line. +pub fn selectOutput(self: *Screen, pt: point.ScreenPoint) ?Selection { + // Impossible to select anything outside of the area we've written. + const y_max = self.rowsWritten() - 1; + if (pt.y > y_max) return null; + const point_row = self.getRow(.{ .screen = pt.y }); + switch (point_row.getSemanticPrompt()) { + .input, .prompt_continuation, .prompt => { + // Cursor on a prompt line, selection impossible + return null; + }, + else => {}, + } + + // Go forwards to find our end boundary + // We are looking for input start / prompt markers + const end: point.ScreenPoint = boundary: { + for (pt.y..y_max + 1) |y| { + const row = self.getRow(.{ .screen = y }); + switch (row.getSemanticPrompt()) { + .input, .prompt_continuation, .prompt => { + const prev_row = self.getRow(.{ .screen = y - 1 }); + break :boundary .{ .x = prev_row.lenCells(), .y = y - 1 }; + }, + else => {}, + } + } + + break :boundary .{ .x = self.cols - 1, .y = y_max }; + }; + + // Go backwards to find our start boundary + // We are looking for output start markers + const start: point.ScreenPoint = boundary: { + var y: usize = pt.y; + while (y > 0) : (y -= 1) { + const row = self.getRow(.{ .screen = y }); + switch (row.getSemanticPrompt()) { + .command => break :boundary .{ .x = 0, .y = y }, + else => {}, + } + } + break :boundary .{ .x = 0, .y = 0 }; + }; + + return Selection{ + .start = start, + .end = end, + }; +} + +/// Returns the selection bounds for the prompt at the given point. If the +/// point is not on a prompt line, this returns null. Note that due to +/// the underlying protocol, this will only return the y-coordinates of +/// the prompt. The x-coordinates of the start will always be zero and +/// the x-coordinates of the end will always be the last column. +/// +/// Note that this feature requires shell integration. If shell integration +/// is not enabled, this will always return null. +pub fn selectPrompt(self: *Screen, pt: point.ScreenPoint) ?Selection { + // Ensure that the line the point is on is a prompt. + const pt_row = self.getRow(.{ .screen = pt.y }); + const is_known = switch (pt_row.getSemanticPrompt()) { + .prompt, .prompt_continuation, .input => true, + .command => return null, + + // We allow unknown to continue because not all shells output any + // semantic prompt information for continuation lines. This has the + // possibility of making this function VERY slow (we look at all + // scrollback) so we should try to avoid this in the future by + // setting a flag or something if we have EVER seen a semantic + // prompt sequence. + .unknown => false, + }; + + // Find the start of the prompt. + var saw_semantic_prompt = is_known; + const start: usize = start: for (0..pt.y) |offset| { + const y = pt.y - offset; + const row = self.getRow(.{ .screen = y - 1 }); + switch (row.getSemanticPrompt()) { + // A prompt, we continue searching. + .prompt, .prompt_continuation, .input => saw_semantic_prompt = true, + + // See comment about "unknown" a few lines above. If we have + // previously seen a semantic prompt then if we see an unknown + // we treat it as a boundary. + .unknown => if (saw_semantic_prompt) break :start y, + + // Command output or unknown, definitely not a prompt. + .command => break :start y, + } + } else 0; + + // If we never saw a semantic prompt flag, then we can't trust our + // start value and we return null. This scenario usually means that + // semantic prompts aren't enabled via the shell. + if (!saw_semantic_prompt) return null; + + // Find the end of the prompt. + const end: usize = end: for (pt.y..self.rowsWritten()) |y| { + const row = self.getRow(.{ .screen = y }); + switch (row.getSemanticPrompt()) { + // A prompt, we continue searching. + .prompt, .prompt_continuation, .input => {}, + + // Command output or unknown, definitely not a prompt. + .command, .unknown => break :end y - 1, + } + } else self.rowsWritten() - 1; + + return .{ + .start = .{ .x = 0, .y = start }, + .end = .{ .x = self.cols - 1, .y = end }, + }; +} + +/// Returns the change in x/y that is needed to reach "to" from "from" +/// within a prompt. If "to" is before or after the prompt bounds then +/// the result will be bounded to the prompt. +/// +/// This feature requires shell integration. If shell integration is not +/// enabled, this will always return zero for both x and y (no path). +pub fn promptPath( + self: *Screen, + from: point.ScreenPoint, + to: point.ScreenPoint, +) struct { + x: isize, + y: isize, +} { + // Get our prompt bounds assuming "from" is at a prompt. + const bounds = self.selectPrompt(from) orelse return .{ .x = 0, .y = 0 }; + + // Get our actual "to" point clamped to the bounds of the prompt. + const to_clamped = if (bounds.contains(to)) + to + else if (to.before(bounds.start)) + bounds.start + else + bounds.end; + + // Basic math to calculate our path. + const from_x: isize = @intCast(from.x); + const from_y: isize = @intCast(from.y); + const to_x: isize = @intCast(to_clamped.x); + const to_y: isize = @intCast(to_clamped.y); + return .{ .x = to_x - from_x, .y = to_y - from_y }; +} + +/// 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". This scrolls the + /// screen (potentially in addition to the viewport) and may therefore + /// create more rows if necessary. + screen: isize, + + /// This is the same as "screen" but only scrolls the viewport. The + /// delta will be clamped at the current size of the screen and will + /// never create new scrollback. + viewport: isize, + + /// Scroll so the given row is in view. If the row is in the viewport, + /// this will change nothing. If the row is outside the viewport, the + /// viewport will change so that this row is at the top of the viewport. + row: RowIndex, + + /// Scroll down and move all viewport contents into the scrollback + /// so that the screen is clear. This isn't eqiuivalent to "screen" with + /// the value set to the viewport size because this will handle the case + /// that the viewport is not full. + /// + /// This will ignore empty trailing rows. An empty row is a row that + /// has never been written to at all. A row with spaces is not empty. + clear: void, +}; + +/// 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) Allocator.Error!void { + // No matter what, scrolling marks our image state as dirty since + // it could move placements. If there are no placements or no images + // this is still a very cheap operation. + self.kitty_images.dirty = true; + + 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 = self.history, + + // TODO: deltas greater than the entire scrollback + .screen => |delta| try self.scrollDelta(delta, false), + .viewport => |delta| try self.scrollDelta(delta, true), + + // Scroll to a specific row + .row => |idx| self.scrollRow(idx), + + // Scroll until the viewport is clear by moving the viewport contents + // into the scrollback. + .clear => try self.scrollClear(), + } +} + +fn scrollClear(self: *Screen) Allocator.Error!void { + // The full amount of rows in the viewport + const full_amount = self.rowsWritten() - self.viewport; + + // Find the number of non-empty rows + const non_empty = for (0..full_amount) |i| { + const rev_i = full_amount - i - 1; + const row = self.getRow(.{ .viewport = rev_i }); + if (!row.isEmpty()) break rev_i + 1; + } else full_amount; + + try self.scroll(.{ .screen = @intCast(non_empty) }); +} + +fn scrollRow(self: *Screen, idx: RowIndex) void { + // Convert the given row to a screen point. + const screen_idx = idx.toScreen(self); + const screen_pt: point.ScreenPoint = .{ .y = screen_idx.screen }; + + // Move the viewport so that the screen point is in view. We do the + // @min here so that we don't scroll down below where our "bottom" + // viewport is. + self.viewport = @min(self.history, screen_pt.y); + assert(screen_pt.inViewport(self)); +} + +fn scrollDelta(self: *Screen, delta: isize, viewport_only: bool) Allocator.Error!void { + // Just in case, to avoid a bunch of stuff below. + if (delta == 0) return; + + // 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 -|= @as(usize, @intCast(-delta)); + return; + } + + // If we're scrolling only the viewport, then we just add to the viewport. + if (viewport_only) { + self.viewport = @min( + self.history, + self.viewport + @as(usize, @intCast(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. + const start_viewport_bottom = self.viewportIsBottom(); + const viewport = self.history + @as(usize, @intCast(delta)); + if (viewport <= self.history) return; + + // If our viewport is past the top of our history then we potentially need + // to write more blank rows. If our viewport is more than our rows written + // then we expand out to there. + const rows_written = self.rowsWritten(); + const viewport_bottom = viewport + self.rows; + if (viewport_bottom <= rows_written) return; + + // The number of new rows we need is the number of rows off our + // previous bottom we are growing. + const new_rows_needed = viewport_bottom - rows_written; + + // If we can't fit into our capacity but we have space, resize the + // buffer to allocate more scrollback. + 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 = @max( + rows_final * (self.cols + 1), + @min(self.storage.capacity() * 2, max_capacity), + ); + + // Allocate what we can. + try self.storage.resize( + self.alloc, + @min(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(); + + // Fast-path: we have no graphemes. + // Slow-path: we have graphemes, we have to check each row + // we're going to delete to see if they contain graphemes and + // clear the ones that do so we clear memory properly. + if (self.graphemes.count() > 0) { + var y: usize = 0; + while (y < rows_to_delete) : (y += 1) { + const row = self.getRow(.{ .screen = y }); + if (row.storage[0].header.flags.grapheme) row.clear(.{}); + } + } + + self.storage.deleteOldest(rows_to_delete * (self.cols + 1)); + break :deleted rows_to_delete; + } else 0; + + // If we are deleting rows and have a selection, then we need to offset + // the selection by the rows we're deleting. + if (self.selection) |*sel| { + // If we're deleting more rows than our Y values, we also move + // the X over to 0 because we're in the middle of the selection now. + if (rows_deleted > sel.start.y) sel.start.x = 0; + if (rows_deleted > sel.end.y) sel.end.x = 0; + + // Remove the deleted rows from both y values. We use saturating + // subtraction so that we can detect when we're at zero. + sel.start.y -|= rows_deleted; + sel.end.y -|= rows_deleted; + + // If the selection is now empty, just clear it. + if (sel.empty()) self.selection = null; + } + + // If we have more rows than what shows on our screen, we have a + // history boundary. + const rows_written_final = rows_final - rows_deleted; + if (rows_written_final > self.rows) { + self.history = rows_written_final - self.rows; + } + + // Ensure we have "written" our last row so that it shows up + const slices = self.storage.getPtrSlice( + (rows_written_final - 1) * (self.cols + 1), + self.cols + 1, + ); + // We should never be wrapped here + assert(slices[1].len == 0); + + // We only grabbed our new row(s), copy cells into the whole slice + const dst = slices[0]; + // The pen we'll use for new cells (only the BG attribute is applied to new + // cells) + const pen: Cell = switch (self.cursor.pen.bg) { + .none => .{}, + else => |bg| .{ .bg = bg }, + }; + @memset(dst, .{ .cell = pen }); + + // Then we make sure our row headers are zeroed out. We set + // the value to a dirty row header so that the renderer re-draws. + var i: usize = 0; + while (i < dst.len) : (i += self.cols + 1) { + dst[i] = .{ .header = .{ + .flags = .{ .dirty = true }, + } }; + } + + if (start_viewport_bottom) { + // If our viewport is on the bottom, we always update the viewport + // to the latest so that it remains in view. + self.viewport = self.history; + } else if (rows_deleted > 0) { + // If our viewport is NOT on the bottom, we want to keep our viewport + // where it was so that we don't jump around. However, we need to + // subtract the final rows written if we had to delete rows since + // that changes the viewport offset. + self.viewport -|= rows_deleted; + } +} + +/// The options for where you can jump to on the screen. +pub const JumpTarget = union(enum) { + /// Jump forwards (positive) or backwards (negative) a set number of + /// prompts. If the absolute value is greater than the number of prompts + /// in either direction, jump to the furthest prompt. + prompt_delta: isize, +}; + +/// Jump the viewport to specific location. +pub fn jump(self: *Screen, target: JumpTarget) bool { + return switch (target) { + .prompt_delta => |delta| self.jumpPrompt(delta), + }; +} + +/// Jump the viewport forwards (positive) or backwards (negative) a set number of +/// prompts (delta). Returns true if the viewport changed and false if no jump +/// occurred. +fn jumpPrompt(self: *Screen, delta: isize) bool { + // If we aren't jumping any prompts then we don't need to do anything. + if (delta == 0) return false; + + // The screen y value we start at + const start_y: isize = start_y: { + const idx: RowIndex = .{ .viewport = 0 }; + const screen = idx.toScreen(self); + break :start_y @intCast(screen.screen); + }; + + // The maximum y in the positive direction. Negative is always 0. + const max_y: isize = @intCast(self.rowsWritten() - 1); + + // Go line-by-line counting the number of prompts we see. + const step: isize = if (delta > 0) 1 else -1; + var y: isize = start_y + step; + const delta_start: usize = @intCast(if (delta > 0) delta else -delta); + var delta_rem: usize = delta_start; + while (y >= 0 and y <= max_y and delta_rem > 0) : (y += step) { + const row = self.getRow(.{ .screen = @intCast(y) }); + switch (row.getSemanticPrompt()) { + .prompt, .prompt_continuation, .input => delta_rem -= 1, + .command, .unknown => {}, + } + } + + //log.warn("delta={} delta_rem={} start_y={} y={}", .{ delta, delta_rem, start_y, y }); + + // If we didn't find any, do nothing. + if (delta_rem == delta_start) return false; + + // Done! We count the number of lines we changed and scroll. + const y_delta = (y - step) - start_y; + const new_y: usize = @intCast(start_y + y_delta); + const old_viewport = self.viewport; + self.scroll(.{ .row = .{ .screen = new_y } }) catch unreachable; + //log.warn("delta={} y_delta={} start_y={} new_y={}", .{ delta, y_delta, start_y, new_y }); + return self.viewport != old_viewport; +} + +/// 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, + trim: bool, +) ![:0]const u8 { + // Get the slices for the string + const slices = self.selectionSlices(sel); + + // Use an ArrayList so that we can grow the array as we go. We + // build an initial capacity of just our rows in our selection times + // columns. It can be more or less based on graphemes, newlines, etc. + var strbuilder = try std.ArrayList(u8).initCapacity(alloc, slices.rows * self.cols); + defer strbuilder.deinit(); + + // Get our string result. + try self.selectionSliceString(slices, &strbuilder, null); + + // Remove any trailing spaces on lines. We could do optimize this by + // doing this in the loop above but this isn't very hot path code and + // this is simple. + if (trim) { + var it = std.mem.tokenizeScalar(u8, strbuilder.items, '\n'); + + // Reset our items. We retain our capacity. Because we're only + // removing bytes, we know that the trimmed string must be no longer + // than the original string so we copy directly back into our + // allocated memory. + strbuilder.clearRetainingCapacity(); + while (it.next()) |line| { + const trimmed = std.mem.trimRight(u8, line, " \t"); + const i = strbuilder.items.len; + strbuilder.items.len += trimmed.len; + std.mem.copyForwards(u8, strbuilder.items[i..], trimmed); + strbuilder.appendAssumeCapacity('\n'); + } + + // Remove our trailing newline again + if (strbuilder.items.len > 0) strbuilder.items.len -= 1; + } + + // Get our final string + const string = try strbuilder.toOwnedSliceSentinel(0); + errdefer alloc.free(string); + + return string; +} + +/// Returns the row text associated with a selection along with the +/// mapping of each individual byte in the string to the point in the screen. +fn selectionStringMap( + self: *Screen, + alloc: Allocator, + sel: Selection, +) !StringMap { + // Get the slices for the string + const slices = self.selectionSlices(sel); + + // Use an ArrayList so that we can grow the array as we go. We + // build an initial capacity of just our rows in our selection times + // columns. It can be more or less based on graphemes, newlines, etc. + var strbuilder = try std.ArrayList(u8).initCapacity(alloc, slices.rows * self.cols); + defer strbuilder.deinit(); + var mapbuilder = try std.ArrayList(point.ScreenPoint).initCapacity(alloc, strbuilder.capacity); + defer mapbuilder.deinit(); + + // Get our results + try self.selectionSliceString(slices, &strbuilder, &mapbuilder); + + // Get our final string + const string = try strbuilder.toOwnedSliceSentinel(0); + errdefer alloc.free(string); + const map = try mapbuilder.toOwnedSlice(); + errdefer alloc.free(map); + return .{ .string = string, .map = map }; +} + +/// Takes a SelectionSlices value and builds the string and mapping for it. +fn selectionSliceString( + self: *Screen, + slices: SelectionSlices, + strbuilder: *std.ArrayList(u8), + mapbuilder: ?*std.ArrayList(point.ScreenPoint), +) !void { + // Connect the text from the two slices + const arr = [_][]StorageCell{ slices.top, slices.bot }; + var row_count: usize = 0; + for (arr) |slice| { + const 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; + + const end_idx = if (slices.sel.rectangle) + // Rectangle select: calculate end with bottom offset. + start_idx + slices.bot_offset + 2 // think "column count" + 1 + else + // Normal select: our end index is usually a full row, but if + // we're the final row then we just use the length. + @min(slice.len, start_idx + self.cols + 1); + + // We may have to skip some cells from the beginning if we're the + // first row, of if we're using rectangle select. + var skip: usize = if (row_count == 0 or slices.sel.rectangle) slices.top_offset else 0; + + // If we have runtime safety we need to initialize the row + // so that the proper union tag is set. In release modes we + // don't need to do this because we zero the memory. + if (std.debug.runtime_safety) { + _ = self.getRow(.{ .screen = slices.sel.start.y + row_i }); + } + + const row: Row = .{ .screen = self, .storage = slice[start_idx..end_idx] }; + var it = row.cellIterator(); + var x: usize = 0; + while (it.next()) |cell| { + defer x += 1; + + if (skip > 0) { + skip -= 1; + continue; + } + + // Skip spacers + if (cell.attrs.wide_spacer_head or + cell.attrs.wide_spacer_tail) continue; + + var buf: [4]u8 = undefined; + const char = if (cell.char > 0) cell.char else ' '; + { + const encode_len = try std.unicode.utf8Encode(@intCast(char), &buf); + try strbuilder.appendSlice(buf[0..encode_len]); + if (mapbuilder) |b| { + for (0..encode_len) |_| try b.append(.{ + .x = x, + .y = slices.sel.start.y + row_i, + }); + } + } + + var cp_it = row.codepointIterator(x); + while (cp_it.next()) |cp| { + const encode_len = try std.unicode.utf8Encode(cp, &buf); + try strbuilder.appendSlice(buf[0..encode_len]); + if (mapbuilder) |b| { + for (0..encode_len) |_| try b.append(.{ + .x = x, + .y = slices.sel.start.y + row_i, + }); + } + } + } + + // If this row is not soft-wrapped or if we're using rectangle + // select, add a newline + if (!row.header().flags.wrap or slices.sel.rectangle) { + try strbuilder.append('\n'); + if (mapbuilder) |b| { + try b.append(.{ + .x = self.cols - 1, + .y = slices.sel.start.y + row_i, + }); + } + } + } + } + + // Remove our trailing newline, its never correct. + if (strbuilder.items.len > 0 and + strbuilder.items[strbuilder.items.len - 1] == '\n') + { + strbuilder.items.len -= 1; + if (mapbuilder) |b| b.items.len -= 1; + } + + if (std.debug.runtime_safety) { + if (mapbuilder) |b| { + assert(strbuilder.items.len == b.items.len); + } + } +} + +const SelectionSlices = struct { + rows: usize, + + // The selection that the slices below represent. This may not + // be the same as the input selection since some normalization + // occurs. + sel: Selection, + + // 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, + + // Our bottom offset is used in rectangle select to always determine the + // maximum cell in a given row. + bot_offset: usize, + + // Our selection storage cell chunks. + top: []StorageCell, + bot: []StorageCell, +}; + +/// 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) SelectionSlices { + // Note: this function is tested via selectionString + + // If the selection starts beyond the end of the screen, then we return empty + if (sel_raw.start.y >= self.rowsWritten()) return .{ + .rows = 0, + .sel = sel_raw, + .top_offset = 0, + .bot_offset = 0, + .top = self.storage.storage[0..0], + .bot = self.storage.storage[0..0], + }; + + const sel = sel: { + var sel = sel_raw; + + // Clamp the selection to the screen + if (sel.end.y >= self.rowsWritten()) { + sel.end.y = self.rowsWritten() - 1; + sel.end.x = self.cols - 1; + } + + // 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.start.x -= 1; + } + } + + break :sel sel; + }; + + // Get the true "top" and "bottom" + const sel_top = sel.topLeft(); + const sel_bot = sel.bottomRight(); + const sel_isRect = sel.rectangle; + + // 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, + .sel = .{ .start = sel_top, .end = sel_bot, .rectangle = sel_isRect }, + .top_offset = sel_top.x, + .bot_offset = sel_bot.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 { + // 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; + + const blank = self.trailingBlankLines(); + + // If we are shrinking the number of rows, we don't want to trim + // off more blank rows than the number we're shrinking because it + // creates a jarring screen move experience. + if (self.rows > rows) break :blank @min(blank, self.rows - rows); + + break :blank blank; + }; + + // 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; + + // 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(end_of_screen_y, rows) * (cols + 1); + const new_max_capacity = self.maxCapacity(); + const buf_size = @min(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 and history resets to the top because we're going to + // rewrite the screen + self.viewport = 0; + self.history = 0; + + // Reset our grapheme map and ensure the old one is deallocated + // on success. + self.graphemes = .{}; + errdefer self.deinitGraphemes(); + defer old.deinitGraphemes(); + + // Rewrite all our rows + var y: usize = 0; + 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(.{ .screen = 1 }); + } + + // Get this row + const new_row = self.getRow(.{ .active = y }); + try 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 accommodate 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 = @min(old.cursor.x, self.cols - 1); + self.cursor.y = if (old_cursor_y_screen <= RowIndexTag.screen.maxLen(self)) + old_cursor_y_screen -| self.history + else + self.rows - 1; + + // If our rows increased and our cursor is NOT at the bottom, we want + // to try to preserve the y value of the old cursor. In other words, we + // don't want to "pull down" scrollback. This is purely a UX feature. + if (self.rows > old.rows and + old.cursor.y < old.rows - 1 and + self.cursor.y > old.cursor.y) + { + const delta = self.cursor.y - old.cursor.y; + if (self.scroll(.{ .screen = @intCast(delta) })) { + self.cursor.y -= delta; + } else |err| { + // If this scroll fails its not that big of a deal so we just + // log and ignore. + log.warn("failed to scroll for resize, cursor may be off err={}", .{err}); + } + } +} + +/// 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; + + // No matter what we mark our image state as dirty + self.kitty_images.dirty = true; + + // 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; + } + + // No matter what we mark our image state as dirty + self.kitty_images.dirty = true; + + // Keep track if our cursor is at the bottom + const cursor_bottom = self.cursor.y == self.rows - 1; + + // 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 old = self.*; + errdefer self.* = old; + + // Allocate enough to store our screen plus history. + const buf_size = (self.rows + @max(self.history, self.max_scrollback)) * (cols + 1); + self.storage = try StorageBuf.init(self.alloc, buf_size); + errdefer self.storage.deinit(self.alloc); + defer old.storage.deinit(self.alloc); + + // Copy grapheme map + self.graphemes = .{}; + errdefer self.deinitGraphemes(); + defer old.deinitGraphemes(); + + // 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.Active{ + .x = old.cursor.x, + .y = old.cursor.y, + }).toScreen(&old); + + // Whether we need to move the cursor or not + var new_cursor: ?point.ScreenPoint = null; + + // Reset our variables because we're going to reprint the screen. + self.cols = cols; + self.viewport = 0; + self.history = 0; + + // Iterate over the screen since we need to check for reflow. + var iter = old.rowIterator(.screen); + var y: usize = 0; + while (iter.next()) |old_row| { + // If we're past the end, scroll + if (y >= self.rows) { + try self.scroll(.{ .screen = 1 }); + y -= 1; + } + + // 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.history + y, .x = cursor_pos.x }; + } + + // 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) { + // We used to do have this behavior, but it broke some programs. + // I know I copied this behavior while observing some other + // terminal, but I can't remember which one. I'm leaving this + // here in case we want to bring this back (with probably + // slightly different behavior). + // + // 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; + } + + // 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.setWrapped(false); + + // x is the offset where we start copying into new_row. Its also + // used for cursor tracking. + var x: usize = old.cols; + + // Edge case: if the end of our old row is a wide spacer head, + // we want to overwrite it. + if (old_row.getCellPtr(x - 1).attrs.wide_spacer_head) x -= 1; + + wrapping: while (iter.next()) |wrapped_row| { + const wrapped_cells = trim: { + var i: usize = old.cols; + + // Trim the row from the right so that we ignore all trailing + // empty chars and don't wrap them. We only do this if the + // row is NOT wrapped again because the whitespace would be + // meaningful. + if (!wrapped_row.header().flags.wrap) { + while (i > 0) : (i -= 1) { + if (!wrapped_row.getCell(i - 1).empty()) break; + } + } else { + // If we are wrapped, then similar to above "edge case" + // we want to overwrite the wide spacer head if we end + // in one. + if (wrapped_row.getCellPtr(i - 1).attrs.wide_spacer_head) { + i -= 1; + } + } + + break :trim wrapped_row.storage[1 .. i + 1]; + }; + + var wrapped_i: usize = 0; + while (wrapped_i < wrapped_cells.len) { + // Remaining space in our new row + const new_row_rem = self.cols - x; + + // Remaining cells in our wrapped row + const wrapped_cells_rem = wrapped_cells.len - wrapped_i; + + // We copy as much as we can into our new row + const copy_len = if (new_row_rem <= wrapped_cells_rem) copy_len: { + // We are going to end up filling our new row. We need + // to check if the end of the row is a wide char and + // if so, we need to insert a wide char header and wrap + // there. + var proposed: usize = new_row_rem; + + // If the end of our copy is wide, we copy one less and + // set the wide spacer header now since we're not going + // to write over it anyways. + if (proposed > 0 and wrapped_cells[wrapped_i + proposed - 1].cell.attrs.wide) { + proposed -= 1; + new_row.getCellPtr(x + proposed).* = .{ + .char = ' ', + .attrs = .{ .wide_spacer_head = true }, + }; + } + + break :copy_len proposed; + } else wrapped_cells_rem; + + // The row doesn't fit, meaning we have to soft-wrap the + // new row but probably at a diff boundary. + fastmem.copy( + StorageCell, + new_row.storage[x + 1 ..], + wrapped_cells[wrapped_i .. wrapped_i + copy_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 < copy_len and + new_cursor == null) + { + 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) { + y += 1; + break :wrapping; + } + + // Wrapped again! + x += wrapped_cells_rem; + break; + } + + // We still need to copy the remainder + wrapped_i += copy_len; + + // Move to a new line in our new screen + new_row.setWrapped(true); + y += 1; + x = 0; + + // If we're past the end, scroll + if (y >= self.rows) { + y -= 1; + try self.scroll(.{ .screen = 1 }); + } + new_row = self.getRow(.{ .active = y }); + new_row.setSemanticPrompt(old_row.getSemanticPrompt()); + } + } + } + + // 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; + } + } + + // 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, self.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. + if (rows < self.rows) try self.resizeWithoutReflow(rows, self.cols); + + // If our cols got smaller, we have to reflow text. This is the worst + // possible case because we can't do any easy tricks to get reflow, + // we just have to iterate over the screen and "print", wrapping as + // needed. + if (cols < self.cols) { + var old = self.*; + errdefer self.* = old; + + // Allocate enough to store our screen plus history. + const buf_size = (self.rows + @max(self.history, self.max_scrollback)) * (cols + 1); + self.storage = try StorageBuf.init(self.alloc, buf_size); + errdefer self.storage.deinit(self.alloc); + defer old.storage.deinit(self.alloc); + + // Create empty grapheme map. Cell IDs change so we can't just copy it, + // we'll rebuild it. + self.graphemes = .{}; + errdefer self.deinitGraphemes(); + defer old.deinitGraphemes(); + + // Convert our cursor coordinates to screen coordinates because + // we may have to reflow the cursor if the line it is on is moved. + const cursor_pos = (point.Active{ + .x = old.cursor.x, + .y = old.cursor.y, + }).toScreen(&old); + + // Whether we need to move the cursor or not + var new_cursor: ?point.ScreenPoint = null; + var new_cursor_wrap: usize = 0; + + // Reset our variables because we're going to reprint the screen. + self.cols = cols; + self.viewport = 0; + self.history = 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 (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); + + // If our y is more than our rows, we need to scroll + if (y >= self.rows) { + try self.scroll(.{ .screen = 1 }); + y -= 1; + } + + // Fast path: our old row is not wrapped AND our old row fits + // into our new smaller size AND this row has no grapheme clusters. + // In this case, we just do a fast copy and move on. + if (!old_row_wrapped and + trimmed_row.len <= self.cols and + !old_row.header().flags.grapheme) + { + // 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 = cursor_pos.x, .y = self.history + y }; + } + + const row = self.getRow(.{ .active = y }); + row.setSemanticPrompt(old_row.getSemanticPrompt()); + + fastmem.copy( + StorageCell, + row.storage[1..], + trimmed_row, + ); + + y += 1; + 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 }); + row.setSemanticPrompt(old_row.getSemanticPrompt()); + 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..) |old_cell, old_x| { + var cell: StorageCell = old_cell; + + // This is a really wild edge case if we're resizing down + // to 1 column. In reality this is pretty broken for end + // users so downstream should prevent this. + if (self.cols == 1 and + (cell.cell.attrs.wide or + cell.cell.attrs.wide_spacer_head or + cell.cell.attrs.wide_spacer_tail)) + { + cell = .{ .cell = .{ .char = ' ' } }; + } + + // We need to wrap wide chars with a spacer head. + if (cell.cell.attrs.wide and x == self.cols - 1) { + row.getCellPtr(x).* = .{ + .char = ' ', + .attrs = .{ .wide_spacer_head = true }, + }; + x += 1; + } + + // 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(.{ .screen = 1 }); + y -= 1; + + // Clear if our current cell is a wide spacer tail + if (cell.cell.attrs.wide_spacer_tail) { + cell = .{ .cell = .{} }; + } + } + + if (cursor_pos.y == old_y) { + // If this original y is where our cursor is, we + // track the number of wraps we do so we can try to + // keep this whole line on the screen. + new_cursor_wrap += 1; + } + + row = self.getRow(.{ .active = y }); + row.setSemanticPrompt(cur_old_row.getSemanticPrompt()); + } + + // 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 + const new_cell = row.getCellPtr(x); + new_cell.* = cell.cell; + + // If the old cell is a multi-codepoint grapheme then we + // need to also attach the graphemes. + if (cell.cell.attrs.grapheme) { + var it = cur_old_row.codepointIterator(old_x); + while (it.next()) |cp| try row.attachGrapheme(x, cp); + } + + 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); + } + } + + // 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 = @min(viewport_pos.x, self.cols - 1); + self.cursor.y = @min(viewport_pos.y, self.rows - 1); + + // We want to keep our cursor y at the same place. To do so, we + // scroll the screen. This scrolls all of the content so the cell + // the cursor is over doesn't change. + if (!cursor_bottom and old.cursor.y < self.cursor.y) scroll: { + const delta: isize = delta: { + var delta: isize = @intCast(self.cursor.y - old.cursor.y); + + // new_cursor_wrap is the number of times the line that the + // cursor was on previously was wrapped to fit this new col + // width. We want to scroll that many times less so that + // the whole line the cursor was on attempts to remain + // in view. + delta -= @intCast(new_cursor_wrap); + + if (delta <= 0) break :scroll; + break :delta delta; + }; + + self.scroll(.{ .screen = delta }) catch |err| { + log.warn("failed to scroll for resize, cursor may be off err={}", .{err}); + break :scroll; + }; + + self.cursor.y -= @intCast(delta); + } + } else { + // TODO: why is this necessary? Without this, neovim will + // crash when we shrink the window to the smallest size. We + // never got a test case to cover this. + self.cursor.x = @min(self.cursor.x, self.cols - 1); + self.cursor.y = @min(self.cursor.y, self.rows - 1); + } + } +} + +/// 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. + // We also have to ignore wide spacers because they form + // a critical part of a wide character. + if (i > self.cols) { + if ((cell.char == 0 or cell.char == ' ') and + !cell.attrs.wide_spacer_tail and + !cell.attrs.wide_spacer_head) 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. +pub fn testWriteString(self: *Screen, text: []const u8) !void { + var y: usize = self.cursor.y; + var x: usize = self.cursor.x; + + var grapheme: struct { + x: usize = 0, + cell: ?*Cell = null, + } = .{}; + + 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; + grapheme = .{}; + continue; + } + + // If we're writing past the end of the active area, scroll. + if (y >= self.rows) { + y -= 1; + try self.scroll(.{ .screen = 1 }); + } + + // Get our row + var row = self.getRow(.{ .active = y }); + + // NOTE: graphemes are currently disabled + if (false) { + // If we have a previous cell, we check if we're part of a grapheme. + if (grapheme.cell) |prev_cell| { + const grapheme_break = brk: { + var state: u3 = 0; + var cp1 = @as(u21, @intCast(prev_cell.char)); + if (prev_cell.attrs.grapheme) { + var it = row.codepointIterator(grapheme.x); + while (it.next()) |cp2| { + assert(!ziglyph.graphemeBreak( + cp1, + cp2, + &state, + )); + + cp1 = cp2; + } + } + + break :brk ziglyph.graphemeBreak(cp1, c, &state); + }; + + if (!grapheme_break) { + try row.attachGrapheme(grapheme.x, c); + continue; + } + } + } + + const width: usize = @intCast(@max(0, ziglyph.display_width.codePointWidth(c, .half))); + //log.warn("c={x} width={}", .{ c, width }); + + // Zero-width are attached as grapheme data. + // NOTE: if/when grapheme clustering is ever enabled (above) this + // is not necessary + if (width == 0) { + if (grapheme.cell != null) { + try row.attachGrapheme(grapheme.x, c); + } + + continue; + } + + // 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(.{ .screen = 1 }); + } + row = self.getRow(.{ .active = y }); + } + + // If our character is double-width, handle it. + assert(width == 1 or width == 2); + switch (width) { + 1 => { + const cell = row.getCellPtr(x); + cell.* = self.cursor.pen; + cell.char = @intCast(c); + + grapheme.x = x; + grapheme.cell = cell; + }, + + 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(.{ .screen = 1 }); + } + row = self.getRow(.{ .active = y }); + } + + { + const cell = row.getCellPtr(x); + cell.* = self.cursor.pen; + cell.char = @intCast(c); + cell.attrs.wide = true; + + grapheme.x = x; + grapheme.cell = cell; + } + + { + x += 1; + const cell = row.getCellPtr(x); + cell.char = ' '; + cell.attrs.wide_spacer_tail = true; + } + }, + + else => unreachable, + } + + x += 1; + } + + // So the cursor doesn't go off screen + self.cursor.x = @min(x, self.cols - 1); + self.cursor.y = y; +} + +/// Options for dumping the screen to a string. +pub const Dump = struct { + /// The start and end rows. These don't have to be in order, the dump + /// function will automatically sort them. + start: RowIndex, + end: RowIndex, + + /// If true, this will unwrap soft-wrapped lines into a single line. + unwrap: bool = true, +}; + +/// Dump the screen to a string. The writer given should be buffered; +/// this function does not attempt to efficiently write and generally writes +/// one byte at a time. +/// +/// TODO: look at selectionString implementation for more efficiency +/// TODO: change selectionString to use this too after above todo +pub fn dumpString(self: *Screen, writer: anytype, opts: Dump) !void { + const start_screen = opts.start.toScreen(self); + const end_screen = opts.end.toScreen(self); + + // If we have no rows in our screen, do nothing. + const rows_written = self.rowsWritten(); + if (rows_written == 0) return; + + // Get the actual top and bottom y values. This handles situations + // where start/end are backwards. + const y_top = @min(start_screen.screen, end_screen.screen); + const y_bottom = @min( + @max(start_screen.screen, end_screen.screen), + rows_written - 1, + ); + + // This keeps track of the number of blank rows we see. We don't want + // to output blank rows unless they're followed by a non-blank row. + var blank_rows: usize = 0; + + // Iterate through the rows + var y: usize = y_top; + while (y <= y_bottom) : (y += 1) { + const row = self.getRow(.{ .screen = y }); + + // Handle blank rows + if (row.isEmpty()) { + blank_rows += 1; + continue; + } + if (blank_rows > 0) { + for (0..blank_rows) |_| try writer.writeByte('\n'); + blank_rows = 0; + } + + if (!row.header().flags.wrap) { + // If we're not wrapped, we always add a newline. + blank_rows += 1; + } else if (!opts.unwrap) { + // If we are wrapped, we only add a new line if we're unwrapping + // soft-wrapped lines. + blank_rows += 1; + } + + // Output each of the cells + var cells = row.cellIterator(); + var spacers: usize = 0; + while (cells.next()) |cell| { + // Skip spacers + if (cell.attrs.wide_spacer_head or cell.attrs.wide_spacer_tail) continue; + + // If we have a zero value, then we accumulate a counter. We + // only want to turn zero values into spaces if we have a non-zero + // char sometime later. + if (cell.char == 0) { + spacers += 1; + continue; + } + if (spacers > 0) { + for (0..spacers) |_| try writer.writeByte(' '); + spacers = 0; + } + + const codepoint: u21 = @intCast(cell.char); + try writer.print("{u}", .{codepoint}); + + var it = row.codepointIterator(cells.i - 1); + while (it.next()) |cp| { + try writer.print("{u}", .{cp}); + } + } + } +} + +/// 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 { + var builder = std.ArrayList(u8).init(alloc); + defer builder.deinit(); + try self.dumpString(builder.writer(), .{ + .start = tag.index(0), + .end = tag.index(tag.maxLen(self) - 1), + + // historically our testString wants to view the screen as-is without + // unwrapping soft-wrapped lines so turn this off. + .unwrap = false, + }); + return try builder.toOwnedSlice(); +} + +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 = .{ .rgb = .{ .r = 0xAA, .g = 0xBB, .b = 0xCC } }; + } + try testing.expect(row.isEmpty()); +} + +test "Row: clear with graphemes" { + 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.getId() > 0); + try testing.expectEqual(@as(usize, 5), row.lenCells()); + try testing.expect(!row.header().flags.grapheme); + + // Lets add a cell with a grapheme + { + const cell = row.getCellPtr(2); + cell.*.char = 'A'; + try row.attachGrapheme(2, 'B'); + try testing.expect(cell.attrs.grapheme); + try testing.expect(row.header().flags.grapheme); + try testing.expect(s.graphemes.count() == 1); + } + + // Clear the row + row.clear(.{}); + try testing.expect(!row.header().flags.grapheme); + try testing.expect(s.graphemes.count() == 0); +} + +test "Row: copy row with graphemes in destination" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 5, 5, 0); + defer s.deinit(); + + // Source row does NOT have graphemes + const row_src = s.getRow(.{ .active = 0 }); + { + const cell = row_src.getCellPtr(2); + cell.*.char = 'A'; + } + + // Destination has graphemes + const row = s.getRow(.{ .active = 1 }); + { + const cell = row.getCellPtr(1); + cell.*.char = 'B'; + try row.attachGrapheme(1, 'C'); + try testing.expect(cell.attrs.grapheme); + try testing.expect(row.header().flags.grapheme); + try testing.expect(s.graphemes.count() == 1); + } + + // Copy + try row.copyRow(row_src); + try testing.expect(!row.header().flags.grapheme); + try testing.expect(s.graphemes.count() == 0); +} + +test "Row: copy row with graphemes in source" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 5, 5, 0); + defer s.deinit(); + + // Source row does NOT have graphemes + const row_src = s.getRow(.{ .active = 0 }); + { + const cell = row_src.getCellPtr(2); + cell.*.char = 'A'; + try row_src.attachGrapheme(2, 'B'); + try testing.expect(cell.attrs.grapheme); + try testing.expect(row_src.header().flags.grapheme); + try testing.expect(s.graphemes.count() == 1); + } + + // Destination has no graphemes + const row = s.getRow(.{ .active = 1 }); + try row.copyRow(row_src); + try testing.expect(row.header().flags.grapheme); + try testing.expect(s.graphemes.count() == 2); + + row_src.clear(.{}); + try testing.expect(s.graphemes.count() == 1); +} + +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); + { + const 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' }); + const contents = try s.testString(alloc, .screen); + defer alloc.free(contents); + try testing.expectEqualStrings("AAAAA\nAAAAA\nAAAAA", contents); + } +} + +test "Screen: write graphemes" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 5, 5, 0); + defer s.deinit(); + + // Sanity check that our test helpers work + var buf: [32]u8 = undefined; + var buf_idx: usize = 0; + buf_idx += try std.unicode.utf8Encode(0x1F44D, buf[buf_idx..]); // Thumbs up plain + buf_idx += try std.unicode.utf8Encode(0x1F44D, buf[buf_idx..]); // Thumbs up plain + buf_idx += try std.unicode.utf8Encode(0x1F3FD, buf[buf_idx..]); // Medium skin tone + + // Note the assertions below are NOT the correct way to handle graphemes + // in general, but they're "correct" for historical purposes for terminals. + // For terminals, all double-wide codepoints are counted as part of the + // width. + + try s.testWriteString(buf[0..buf_idx]); + try testing.expect(s.rowsWritten() == 2); + try testing.expectEqual(@as(usize, 2), s.cursor.x); +} + +test "Screen: write long emoji" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 5, 30, 0); + defer s.deinit(); + + // Sanity check that our test helpers work + var buf: [32]u8 = undefined; + var buf_idx: usize = 0; + buf_idx += try std.unicode.utf8Encode(0x1F9D4, buf[buf_idx..]); // man: beard + buf_idx += try std.unicode.utf8Encode(0x1F3FB, buf[buf_idx..]); // light skin tone (Fitz 1-2) + buf_idx += try std.unicode.utf8Encode(0x200D, buf[buf_idx..]); // ZWJ + buf_idx += try std.unicode.utf8Encode(0x2642, buf[buf_idx..]); // male sign + buf_idx += try std.unicode.utf8Encode(0xFE0F, buf[buf_idx..]); // emoji representation + + // Note the assertions below are NOT the correct way to handle graphemes + // in general, but they're "correct" for historical purposes for terminals. + // For terminals, all double-wide codepoints are counted as part of the + // width. + + try s.testWriteString(buf[0..buf_idx]); + try testing.expect(s.rowsWritten() == 1); + try testing.expectEqual(@as(usize, 5), s.cursor.x); +} + +test "Screen: lineIterator" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 5, 5, 0); + defer s.deinit(); + + // Sanity check that our test helpers work + const str = "1ABCD\n2EFGH"; + try s.testWriteString(str); + + // Test the line iterator + var iter = s.lineIterator(.viewport); + { + const line = iter.next().?; + const actual = try line.string(alloc); + defer alloc.free(actual); + try testing.expectEqualStrings("1ABCD", actual); + } + { + const line = iter.next().?; + const actual = try line.string(alloc); + defer alloc.free(actual); + try testing.expectEqualStrings("2EFGH", actual); + } + try testing.expect(iter.next() == null); +} + +test "Screen: lineIterator soft wrap" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 5, 5, 0); + defer s.deinit(); + + // Sanity check that our test helpers work + const str = "1ABCD2EFGH\n3ABCD"; + try s.testWriteString(str); + + // Test the line iterator + var iter = s.lineIterator(.viewport); + { + const line = iter.next().?; + const actual = try line.string(alloc); + defer alloc.free(actual); + try testing.expectEqualStrings("1ABCD2EFGH", actual); + } + { + const line = iter.next().?; + const actual = try line.string(alloc); + defer alloc.free(actual); + try testing.expectEqualStrings("3ABCD", actual); + } + try testing.expect(iter.next() == null); +} + +test "Screen: getLine soft wrap" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 5, 5, 0); + defer s.deinit(); + + // Sanity check that our test helpers work + const str = "1ABCD2EFGH\n3ABCD"; + try s.testWriteString(str); + + // Test the line iterator + { + const line = s.getLine(.{ .x = 2, .y = 1 }).?; + const actual = try line.string(alloc); + defer alloc.free(actual); + try testing.expectEqualStrings("1ABCD2EFGH", actual); + } + { + const line = s.getLine(.{ .x = 2, .y = 2 }).?; + const actual = try line.string(alloc); + defer alloc.free(actual); + try testing.expectEqualStrings("3ABCD", actual); + } + + try testing.expect(s.getLine(.{ .x = 2, .y = 3 }) == null); + try testing.expect(s.getLine(.{ .x = 7, .y = 1 }) == null); +} + +// X +test "Screen: scrolling" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 3, 5, 0); + defer s.deinit(); + s.cursor.pen.bg = .{ .rgb = .{ .r = 155 } }; + try s.testWriteString("1ABCD\n2EFGH\n3IJKL"); + try testing.expect(s.viewportIsBottom()); + + // Scroll down, should still be bottom + try s.scroll(.{ .screen = 1 }); + try testing.expect(s.viewportIsBottom()); + + { + // Test our contents rotated + const contents = try s.testString(alloc, .viewport); + defer alloc.free(contents); + try testing.expectEqualStrings("2EFGH\n3IJKL", contents); + } + { + // Test that our new row has the correct background + const cell = s.getCell(.active, 2, 0); + try testing.expectEqual(@as(u8, 155), cell.bg.rgb.r); + } + + // Scrolling to the bottom does nothing + try s.scroll(.{ .bottom = {} }); + + { + // Test our contents rotated + const contents = try s.testString(alloc, .viewport); + defer alloc.free(contents); + try testing.expectEqualStrings("2EFGH\n3IJKL", contents); + } +} + +// X +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(.{ .screen = -1 }); + try testing.expect(s.viewportIsBottom()); + + { + // Test our contents rotated + const contents = try s.testString(alloc, .viewport); + defer alloc.free(contents); + try testing.expectEqualStrings("1ABCD\n2EFGH\n3IJKL", contents); + } +} + +// X +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(.{ .screen = 1 }); + + { + // Test our contents rotated + const 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 + const 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(.{ .screen = -1 }); + try testing.expect(!s.viewportIsBottom()); + + { + // Test our contents rotated + const 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(.{ .screen = -1 }); + + { + // Test our contents rotated + const 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 + const 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(.{ .viewport = 1 }); + + { + // Test our contents rotated + const 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 + const 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(.{}); + { + const 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 + const contents = try s.testString(alloc, .viewport); + defer alloc.free(contents); + try testing.expectEqualStrings("", contents); + } +} + +// X +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 + const contents = try s.testString(alloc, .viewport); + defer alloc.free(contents); + try testing.expectEqualStrings("1ABCD\n2EFGH\n3IJKL", contents); + } + + // Scroll down a ton + try s.scroll(.{ .viewport = 5 }); + try testing.expect(s.viewportIsBottom()); + { + // Test our contents rotated + const contents = try s.testString(alloc, .viewport); + defer alloc.free(contents); + try testing.expectEqualStrings("4ABCD\n5EFGH\n6IJKL", contents); + } +} + +// X +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(.{ .viewport = 1 }); + + { + // Test our contents + const contents = try s.testString(alloc, .viewport); + defer alloc.free(contents); + try testing.expectEqualStrings("1ABCD\n2EFGH\n3IJKL", contents); + } +} + +// X +test "Screen: scrollback doesn't move viewport if not at bottom" { + 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"); + + // First test: we scroll up by 1, so we're not at the bottom anymore. + try s.scroll(.{ .screen = -1 }); + try testing.expect(!s.viewportIsBottom()); + { + const contents = try s.testString(alloc, .viewport); + defer alloc.free(contents); + try testing.expectEqualStrings("2EFGH\n3IJKL\n4ABCD", contents); + } + + // Next, we scroll back down by 1, this grows the scrollback but we + // shouldn't move. + try s.scroll(.{ .screen = 1 }); + try testing.expect(!s.viewportIsBottom()); + { + const contents = try s.testString(alloc, .viewport); + defer alloc.free(contents); + try testing.expectEqualStrings("2EFGH\n3IJKL\n4ABCD", contents); + } + + // Scroll again, this clears scrollback so we should move viewports + // but still see the same thing since our original view fits. + try s.scroll(.{ .screen = 1 }); + try testing.expect(!s.viewportIsBottom()); + { + const contents = try s.testString(alloc, .viewport); + defer alloc.free(contents); + try testing.expectEqualStrings("2EFGH\n3IJKL\n4ABCD", contents); + } + + // Scroll again, this again goes into scrollback but is now deleting + // what we were looking at. We should see changes. + try s.scroll(.{ .screen = 1 }); + try testing.expect(!s.viewportIsBottom()); + { + const contents = try s.testString(alloc, .viewport); + defer alloc.free(contents); + try testing.expectEqualStrings("3IJKL\n4ABCD\n5EFGH", contents); + } +} + +test "Screen: scrolling moves selection" { + 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()); + + // Select a single line + s.selection = .{ + .start = .{ .x = 0, .y = 1 }, + .end = .{ .x = s.cols - 1, .y = 1 }, + }; + + // Scroll down, should still be bottom + try s.scroll(.{ .screen = 1 }); + try testing.expect(s.viewportIsBottom()); + + // Our selection should've moved up + try testing.expectEqual(Selection{ + .start = .{ .x = 0, .y = 0 }, + .end = .{ .x = s.cols - 1, .y = 0 }, + }, s.selection.?); + + { + // Test our contents rotated + const 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 = {} }); + + // Our selection should've stayed the same + try testing.expectEqual(Selection{ + .start = .{ .x = 0, .y = 0 }, + .end = .{ .x = s.cols - 1, .y = 0 }, + }, s.selection.?); + + { + // Test our contents rotated + const contents = try s.testString(alloc, .viewport); + defer alloc.free(contents); + try testing.expectEqualStrings("2EFGH\n3IJKL", contents); + } + + // Scroll up again + try s.scroll(.{ .screen = 1 }); + + // Our selection should be null because it left the screen. + try testing.expect(s.selection == null); +} + +test "Screen: scrolling with scrollback available doesn't move selection" { + 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 testing.expect(s.viewportIsBottom()); + + // Select a single line + s.selection = .{ + .start = .{ .x = 0, .y = 1 }, + .end = .{ .x = s.cols - 1, .y = 1 }, + }; + + // Scroll down, should still be bottom + try s.scroll(.{ .screen = 1 }); + try testing.expect(s.viewportIsBottom()); + + // Our selection should NOT move since we have scrollback + try testing.expectEqual(Selection{ + .start = .{ .x = 0, .y = 1 }, + .end = .{ .x = s.cols - 1, .y = 1 }, + }, s.selection.?); + + { + // Test our contents rotated + const 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(.{ .screen = -1 }); + try testing.expect(!s.viewportIsBottom()); + + // Our selection should NOT move since we have scrollback + try testing.expectEqual(Selection{ + .start = .{ .x = 0, .y = 1 }, + .end = .{ .x = s.cols - 1, .y = 1 }, + }, s.selection.?); + + { + // Test our contents rotated + const contents = try s.testString(alloc, .viewport); + defer alloc.free(contents); + try testing.expectEqualStrings("1ABCD\n2EFGH\n3IJKL", contents); + } + + // Scroll down, this sends us off the scrollback + try s.scroll(.{ .screen = 2 }); + + // Selection should be gone since we selected a line that went off. + try testing.expect(s.selection == null); + + { + // Test our contents rotated + const contents = try s.testString(alloc, .viewport); + defer alloc.free(contents); + try testing.expectEqualStrings("3IJKL", contents); + } +} + +// X +test "Screen: scroll and clear full screen" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 3, 5, 5); + defer s.deinit(); + try s.testWriteString("1ABCD\n2EFGH\n3IJKL"); + + { + const contents = try s.testString(alloc, .viewport); + defer alloc.free(contents); + try testing.expectEqualStrings("1ABCD\n2EFGH\n3IJKL", contents); + } + + try s.scroll(.{ .clear = {} }); + { + const contents = try s.testString(alloc, .viewport); + defer alloc.free(contents); + try testing.expectEqualStrings("", contents); + } + { + const contents = try s.testString(alloc, .screen); + defer alloc.free(contents); + try testing.expectEqualStrings("1ABCD\n2EFGH\n3IJKL", contents); + } +} + +// X +test "Screen: scroll and clear partial screen" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 3, 5, 5); + defer s.deinit(); + try s.testWriteString("1ABCD\n2EFGH"); + + { + const contents = try s.testString(alloc, .viewport); + defer alloc.free(contents); + try testing.expectEqualStrings("1ABCD\n2EFGH", contents); + } + + try s.scroll(.{ .clear = {} }); + { + const contents = try s.testString(alloc, .viewport); + defer alloc.free(contents); + try testing.expectEqualStrings("", contents); + } + { + const contents = try s.testString(alloc, .screen); + defer alloc.free(contents); + try testing.expectEqualStrings("1ABCD\n2EFGH", contents); + } +} + +// X +test "Screen: scroll and clear empty screen" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 3, 5, 5); + defer s.deinit(); + try s.scroll(.{ .clear = {} }); + try testing.expectEqual(@as(usize, 0), s.viewport); +} + +// X +test "Screen: scroll and clear ignore blank lines" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 3, 5, 10); + defer s.deinit(); + try s.testWriteString("1ABCD\n2EFGH"); + try s.scroll(.{ .clear = {} }); + { + const contents = try s.testString(alloc, .viewport); + defer alloc.free(contents); + try testing.expectEqualStrings("", contents); + } + + // Move back to top-left + s.cursor.x = 0; + s.cursor.y = 0; + + // Write and clear + try s.testWriteString("3ABCD\n"); + try s.scroll(.{ .clear = {} }); + { + const contents = try s.testString(alloc, .viewport); + defer alloc.free(contents); + try testing.expectEqualStrings("", contents); + } + + // Move back to top-left + s.cursor.x = 0; + s.cursor.y = 0; + try s.testWriteString("X"); + + { + const contents = try s.testString(alloc, .screen); + defer alloc.free(contents); + try testing.expectEqualStrings("1ABCD\n2EFGH\n3ABCD\nX", contents); + } +} + +// X - i don't think we need rowIterator +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); + { + const 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); +} + +// X - duplicated test above +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); + { + const contents = try s.testString(alloc, .viewport); + defer alloc.free(contents); + const expected = "3IJKL"; + try testing.expectEqualStrings(expected, contents); + } + { + // Test our contents + const contents = try s.testString(alloc, .screen); + defer alloc.free(contents); + try testing.expectEqualStrings("1ABCD\n2EFGH\n3IJKL", contents); + } + + { + const contents = try s.testString(alloc, .history); + defer alloc.free(contents); + const expected = "1ABCD\n2EFGH"; + try testing.expectEqualStrings(expected, contents); + } +} + +// X - don't need this, internal API +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(.{ .screen = 1 }); + try s.copyRow(.{ .active = 2 }, .{ .active = 0 }); + + // Test our contents + const contents = try s.testString(alloc, .viewport); + defer alloc.free(contents); + try testing.expectEqualStrings("2EFGH\n3IJKL\n2EFGH", contents); +} + +// X +test "Screen: clone" { + 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()); + + { + var s2 = try s.clone(alloc, .{ .active = 1 }, .{ .active = 1 }); + defer s2.deinit(); + + // Test our contents rotated + const contents = try s2.testString(alloc, .viewport); + defer alloc.free(contents); + try testing.expectEqualStrings("2EFGH", contents); + } + + { + var s2 = try s.clone(alloc, .{ .active = 1 }, .{ .active = 2 }); + defer s2.deinit(); + + // Test our contents rotated + const contents = try s2.testString(alloc, .viewport); + defer alloc.free(contents); + try testing.expectEqualStrings("2EFGH\n3IJKL", contents); + } +} + +// X +test "Screen: clone empty viewport" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 3, 5, 0); + defer s.deinit(); + + { + var s2 = try s.clone(alloc, .{ .viewport = 0 }, .{ .viewport = 0 }); + defer s2.deinit(); + + // Test our contents rotated + const contents = try s2.testString(alloc, .viewport); + defer alloc.free(contents); + try testing.expectEqualStrings("", contents); + } +} + +// X +test "Screen: clone one line viewport" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 3, 5, 0); + defer s.deinit(); + try s.testWriteString("1ABC"); + + { + var s2 = try s.clone(alloc, .{ .viewport = 0 }, .{ .viewport = 0 }); + defer s2.deinit(); + + // Test our contents + const contents = try s2.testString(alloc, .viewport); + defer alloc.free(contents); + try testing.expectEqualStrings("1ABC", contents); + } +} + +// X +test "Screen: clone empty active" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 3, 5, 0); + defer s.deinit(); + + { + var s2 = try s.clone(alloc, .{ .active = 0 }, .{ .active = 0 }); + defer s2.deinit(); + + // Test our contents rotated + const contents = try s2.testString(alloc, .active); + defer alloc.free(contents); + try testing.expectEqualStrings("", contents); + } +} + +// X +test "Screen: clone one line active with extra space" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 3, 5, 0); + defer s.deinit(); + try s.testWriteString("1ABC"); + + // Should have 1 line written + try testing.expectEqual(@as(usize, 1), s.rowsWritten()); + + { + var s2 = try s.clone(alloc, .{ .active = 0 }, .{ .active = s.rows - 1 }); + defer s2.deinit(); + + // Test our contents rotated + const contents = try s2.testString(alloc, .active); + defer alloc.free(contents); + try testing.expectEqualStrings("1ABC", contents); + } + + // Should still have no history. A bug was that we were generating history + // in this case which is not good! This was causing resizes to have all + // sorts of problems. + try testing.expectEqual(@as(usize, 1), s.rowsWritten()); +} + +// X +test "Screen: selectLine" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 10, 10, 0); + defer s.deinit(); + try s.testWriteString("ABC DEF\n 123\n456"); + + // Outside of active area + try testing.expect(s.selectLine(.{ .x = 13, .y = 0 }) == null); + try testing.expect(s.selectLine(.{ .x = 0, .y = 5 }) == null); + + // Going forward + { + const sel = s.selectLine(.{ .x = 0, .y = 0 }).?; + try testing.expectEqual(@as(usize, 0), sel.start.x); + try testing.expectEqual(@as(usize, 0), sel.start.y); + try testing.expectEqual(@as(usize, 7), sel.end.x); + try testing.expectEqual(@as(usize, 0), sel.end.y); + } + + // Going backward + { + const sel = s.selectLine(.{ .x = 7, .y = 0 }).?; + try testing.expectEqual(@as(usize, 0), sel.start.x); + try testing.expectEqual(@as(usize, 0), sel.start.y); + try testing.expectEqual(@as(usize, 7), sel.end.x); + try testing.expectEqual(@as(usize, 0), sel.end.y); + } + + // Going forward and backward + { + const sel = s.selectLine(.{ .x = 3, .y = 0 }).?; + try testing.expectEqual(@as(usize, 0), sel.start.x); + try testing.expectEqual(@as(usize, 0), sel.start.y); + try testing.expectEqual(@as(usize, 7), sel.end.x); + try testing.expectEqual(@as(usize, 0), sel.end.y); + } + + // Outside active area + { + const sel = s.selectLine(.{ .x = 9, .y = 0 }).?; + try testing.expectEqual(@as(usize, 0), sel.start.x); + try testing.expectEqual(@as(usize, 0), sel.start.y); + try testing.expectEqual(@as(usize, 7), sel.end.x); + try testing.expectEqual(@as(usize, 0), sel.end.y); + } +} + +// X +test "Screen: selectAll" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 10, 10, 0); + defer s.deinit(); + + { + try s.testWriteString("ABC DEF\n 123\n456"); + const sel = s.selectAll().?; + try testing.expectEqual(@as(usize, 0), sel.start.x); + try testing.expectEqual(@as(usize, 0), sel.start.y); + try testing.expectEqual(@as(usize, 2), sel.end.x); + try testing.expectEqual(@as(usize, 2), sel.end.y); + } + + { + try s.testWriteString("\nFOO\n BAR\n BAZ\n QWERTY\n 12345678"); + const sel = s.selectAll().?; + try testing.expectEqual(@as(usize, 0), sel.start.x); + try testing.expectEqual(@as(usize, 0), sel.start.y); + try testing.expectEqual(@as(usize, 8), sel.end.x); + try testing.expectEqual(@as(usize, 7), sel.end.y); + } +} + +// X +test "Screen: selectLine across soft-wrap" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 10, 5, 0); + defer s.deinit(); + try s.testWriteString(" 12 34012 \n 123"); + + // Going forward + { + const sel = s.selectLine(.{ .x = 1, .y = 0 }).?; + try testing.expectEqual(@as(usize, 1), sel.start.x); + try testing.expectEqual(@as(usize, 0), sel.start.y); + try testing.expectEqual(@as(usize, 3), sel.end.x); + try testing.expectEqual(@as(usize, 1), sel.end.y); + } +} + +// X +// https://github.com/mitchellh/ghostty/issues/1329 +test "Screen: selectLine semantic prompt boundary" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 10, 5, 0); + defer s.deinit(); + try s.testWriteString("ABCDE\nA > "); + + { + const contents = try s.testString(alloc, .screen); + defer alloc.free(contents); + try testing.expectEqualStrings("ABCDE\nA \n> ", contents); + } + + var row = s.getRow(.{ .screen = 2 }); + row.setSemanticPrompt(.prompt); + + // Selecting output stops at the prompt even if soft-wrapped + { + const sel = s.selectLine(.{ .x = 1, .y = 1 }).?; + try testing.expectEqual(@as(usize, 0), sel.start.x); + try testing.expectEqual(@as(usize, 1), sel.start.y); + try testing.expectEqual(@as(usize, 0), sel.end.x); + try testing.expectEqual(@as(usize, 1), sel.end.y); + } + { + const sel = s.selectLine(.{ .x = 1, .y = 2 }).?; + try testing.expectEqual(@as(usize, 0), sel.start.x); + try testing.expectEqual(@as(usize, 2), sel.start.y); + try testing.expectEqual(@as(usize, 0), sel.end.x); + try testing.expectEqual(@as(usize, 2), sel.end.y); + } +} + +// X +test "Screen: selectLine across soft-wrap ignores blank lines" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 10, 5, 0); + defer s.deinit(); + try s.testWriteString(" 12 34012 \n 123"); + + // Going forward + { + const sel = s.selectLine(.{ .x = 1, .y = 0 }).?; + try testing.expectEqual(@as(usize, 1), sel.start.x); + try testing.expectEqual(@as(usize, 0), sel.start.y); + try testing.expectEqual(@as(usize, 3), sel.end.x); + try testing.expectEqual(@as(usize, 1), sel.end.y); + } + + // Going backward + { + const sel = s.selectLine(.{ .x = 1, .y = 1 }).?; + try testing.expectEqual(@as(usize, 1), sel.start.x); + try testing.expectEqual(@as(usize, 0), sel.start.y); + try testing.expectEqual(@as(usize, 3), sel.end.x); + try testing.expectEqual(@as(usize, 1), sel.end.y); + } + + // Going forward and backward + { + const sel = s.selectLine(.{ .x = 3, .y = 0 }).?; + try testing.expectEqual(@as(usize, 1), sel.start.x); + try testing.expectEqual(@as(usize, 0), sel.start.y); + try testing.expectEqual(@as(usize, 3), sel.end.x); + try testing.expectEqual(@as(usize, 1), sel.end.y); + } +} + +// X +test "Screen: selectLine with scrollback" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 3, 2, 5); + defer s.deinit(); + try s.testWriteString("1A\n2B\n3C\n4D\n5E"); + + // Selecting first line + { + const sel = s.selectLine(.{ .x = 0, .y = 0 }).?; + try testing.expectEqual(@as(usize, 0), sel.start.x); + try testing.expectEqual(@as(usize, 0), sel.start.y); + try testing.expectEqual(@as(usize, 1), sel.end.x); + try testing.expectEqual(@as(usize, 0), sel.end.y); + } + + // Selecting last line + { + const sel = s.selectLine(.{ .x = 0, .y = 4 }).?; + try testing.expectEqual(@as(usize, 0), sel.start.x); + try testing.expectEqual(@as(usize, 4), sel.start.y); + try testing.expectEqual(@as(usize, 1), sel.end.x); + try testing.expectEqual(@as(usize, 4), sel.end.y); + } +} + +// X +test "Screen: selectWord" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 10, 10, 0); + defer s.deinit(); + try s.testWriteString("ABC DEF\n 123\n456"); + + // Outside of active area + try testing.expect(s.selectWord(.{ .x = 9, .y = 0 }) == null); + try testing.expect(s.selectWord(.{ .x = 0, .y = 5 }) == null); + + // Going forward + { + const sel = s.selectWord(.{ .x = 0, .y = 0 }).?; + try testing.expectEqual(@as(usize, 0), sel.start.x); + try testing.expectEqual(@as(usize, 0), sel.start.y); + try testing.expectEqual(@as(usize, 2), sel.end.x); + try testing.expectEqual(@as(usize, 0), sel.end.y); + } + + // Going backward + { + const sel = s.selectWord(.{ .x = 2, .y = 0 }).?; + try testing.expectEqual(@as(usize, 0), sel.start.x); + try testing.expectEqual(@as(usize, 0), sel.start.y); + try testing.expectEqual(@as(usize, 2), sel.end.x); + try testing.expectEqual(@as(usize, 0), sel.end.y); + } + + // Going forward and backward + { + const sel = s.selectWord(.{ .x = 1, .y = 0 }).?; + try testing.expectEqual(@as(usize, 0), sel.start.x); + try testing.expectEqual(@as(usize, 0), sel.start.y); + try testing.expectEqual(@as(usize, 2), sel.end.x); + try testing.expectEqual(@as(usize, 0), sel.end.y); + } + + // Whitespace + { + const sel = s.selectWord(.{ .x = 3, .y = 0 }).?; + try testing.expectEqual(@as(usize, 3), sel.start.x); + try testing.expectEqual(@as(usize, 0), sel.start.y); + try testing.expectEqual(@as(usize, 4), sel.end.x); + try testing.expectEqual(@as(usize, 0), sel.end.y); + } + + // Whitespace single char + { + const sel = s.selectWord(.{ .x = 0, .y = 1 }).?; + try testing.expectEqual(@as(usize, 0), sel.start.x); + try testing.expectEqual(@as(usize, 1), sel.start.y); + try testing.expectEqual(@as(usize, 0), sel.end.x); + try testing.expectEqual(@as(usize, 1), sel.end.y); + } + + // End of screen + { + const sel = s.selectWord(.{ .x = 1, .y = 2 }).?; + try testing.expectEqual(@as(usize, 0), sel.start.x); + try testing.expectEqual(@as(usize, 2), sel.start.y); + try testing.expectEqual(@as(usize, 2), sel.end.x); + try testing.expectEqual(@as(usize, 2), sel.end.y); + } +} + +// X +test "Screen: selectWord across soft-wrap" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 10, 5, 0); + defer s.deinit(); + try s.testWriteString(" 1234012\n 123"); + + // Going forward + { + const sel = s.selectWord(.{ .x = 1, .y = 0 }).?; + try testing.expectEqual(@as(usize, 1), sel.start.x); + try testing.expectEqual(@as(usize, 0), sel.start.y); + try testing.expectEqual(@as(usize, 2), sel.end.x); + try testing.expectEqual(@as(usize, 1), sel.end.y); + } + + // Going backward + { + const sel = s.selectWord(.{ .x = 1, .y = 1 }).?; + try testing.expectEqual(@as(usize, 1), sel.start.x); + try testing.expectEqual(@as(usize, 0), sel.start.y); + try testing.expectEqual(@as(usize, 2), sel.end.x); + try testing.expectEqual(@as(usize, 1), sel.end.y); + } + + // Going forward and backward + { + const sel = s.selectWord(.{ .x = 3, .y = 0 }).?; + try testing.expectEqual(@as(usize, 1), sel.start.x); + try testing.expectEqual(@as(usize, 0), sel.start.y); + try testing.expectEqual(@as(usize, 2), sel.end.x); + try testing.expectEqual(@as(usize, 1), sel.end.y); + } +} + +// X +test "Screen: selectWord whitespace across soft-wrap" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 10, 5, 0); + defer s.deinit(); + try s.testWriteString("1 1\n 123"); + + // Going forward + { + const sel = s.selectWord(.{ .x = 1, .y = 0 }).?; + try testing.expectEqual(@as(usize, 1), sel.start.x); + try testing.expectEqual(@as(usize, 0), sel.start.y); + try testing.expectEqual(@as(usize, 2), sel.end.x); + try testing.expectEqual(@as(usize, 1), sel.end.y); + } + + // Going backward + { + const sel = s.selectWord(.{ .x = 1, .y = 1 }).?; + try testing.expectEqual(@as(usize, 1), sel.start.x); + try testing.expectEqual(@as(usize, 0), sel.start.y); + try testing.expectEqual(@as(usize, 2), sel.end.x); + try testing.expectEqual(@as(usize, 1), sel.end.y); + } + + // Going forward and backward + { + const sel = s.selectWord(.{ .x = 3, .y = 0 }).?; + try testing.expectEqual(@as(usize, 1), sel.start.x); + try testing.expectEqual(@as(usize, 0), sel.start.y); + try testing.expectEqual(@as(usize, 2), sel.end.x); + try testing.expectEqual(@as(usize, 1), sel.end.y); + } +} + +// X +test "Screen: selectWord with character boundary" { + const testing = std.testing; + const alloc = testing.allocator; + + const cases = [_][]const u8{ + " 'abc' \n123", + " \"abc\" \n123", + " │abc│ \n123", + " `abc` \n123", + " |abc| \n123", + " :abc: \n123", + " ,abc, \n123", + " (abc( \n123", + " )abc) \n123", + " [abc[ \n123", + " ]abc] \n123", + " {abc{ \n123", + " }abc} \n123", + " abc> \n123", + }; + + for (cases) |case| { + var s = try init(alloc, 10, 20, 0); + defer s.deinit(); + try s.testWriteString(case); + + // Inside character forward + { + const sel = s.selectWord(.{ .x = 2, .y = 0 }).?; + try testing.expectEqual(@as(usize, 2), sel.start.x); + try testing.expectEqual(@as(usize, 0), sel.start.y); + try testing.expectEqual(@as(usize, 4), sel.end.x); + try testing.expectEqual(@as(usize, 0), sel.end.y); + } + + // Inside character backward + { + const sel = s.selectWord(.{ .x = 4, .y = 0 }).?; + try testing.expectEqual(@as(usize, 2), sel.start.x); + try testing.expectEqual(@as(usize, 0), sel.start.y); + try testing.expectEqual(@as(usize, 4), sel.end.x); + try testing.expectEqual(@as(usize, 0), sel.end.y); + } + + // Inside character bidirectional + { + const sel = s.selectWord(.{ .x = 3, .y = 0 }).?; + try testing.expectEqual(@as(usize, 2), sel.start.x); + try testing.expectEqual(@as(usize, 0), sel.start.y); + try testing.expectEqual(@as(usize, 4), sel.end.x); + try testing.expectEqual(@as(usize, 0), sel.end.y); + } + + // On quote + // NOTE: this behavior is not ideal, so we can change this one day, + // but I think its also not that important compared to the above. + { + const sel = s.selectWord(.{ .x = 1, .y = 0 }).?; + try testing.expectEqual(@as(usize, 0), sel.start.x); + try testing.expectEqual(@as(usize, 0), sel.start.y); + try testing.expectEqual(@as(usize, 1), sel.end.x); + try testing.expectEqual(@as(usize, 0), sel.end.y); + } + } +} + +// X +test "Screen: selectOutput" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 15, 10, 0); + defer s.deinit(); + + // zig fmt: off + { + // line number: + try s.testWriteString("output1\n"); // 0 + try s.testWriteString("output1\n"); // 1 + try s.testWriteString("prompt2\n"); // 2 + try s.testWriteString("input2\n"); // 3 + try s.testWriteString("output2\n"); // 4 + try s.testWriteString("output2\n"); // 5 + try s.testWriteString("prompt3$ input3\n"); // 6 + try s.testWriteString("output3\n"); // 7 + try s.testWriteString("output3\n"); // 8 + try s.testWriteString("output3"); // 9 + } + // zig fmt: on + + var row = s.getRow(.{ .screen = 2 }); + row.setSemanticPrompt(.prompt); + row = s.getRow(.{ .screen = 3 }); + row.setSemanticPrompt(.input); + row = s.getRow(.{ .screen = 4 }); + row.setSemanticPrompt(.command); + row = s.getRow(.{ .screen = 6 }); + row.setSemanticPrompt(.input); + row = s.getRow(.{ .screen = 7 }); + row.setSemanticPrompt(.command); + + // No start marker, should select from the beginning + { + const sel = s.selectOutput(.{ .x = 1, .y = 1 }).?; + try testing.expectEqual(@as(usize, 0), sel.start.x); + try testing.expectEqual(@as(usize, 0), sel.start.y); + try testing.expectEqual(@as(usize, 10), sel.end.x); + try testing.expectEqual(@as(usize, 1), sel.end.y); + } + // Both start and end markers, should select between them + { + const sel = s.selectOutput(.{ .x = 3, .y = 5 }).?; + try testing.expectEqual(@as(usize, 0), sel.start.x); + try testing.expectEqual(@as(usize, 4), sel.start.y); + try testing.expectEqual(@as(usize, 10), sel.end.x); + try testing.expectEqual(@as(usize, 5), sel.end.y); + } + // No end marker, should select till the end + { + const sel = s.selectOutput(.{ .x = 2, .y = 7 }).?; + try testing.expectEqual(@as(usize, 0), sel.start.x); + try testing.expectEqual(@as(usize, 7), sel.start.y); + try testing.expectEqual(@as(usize, 9), sel.end.x); + try testing.expectEqual(@as(usize, 10), sel.end.y); + } + // input / prompt at y = 0, pt.y = 0 + { + s.deinit(); + s = try init(alloc, 5, 10, 0); + try s.testWriteString("prompt1$ input1\n"); + try s.testWriteString("output1\n"); + try s.testWriteString("prompt2\n"); + row = s.getRow(.{ .screen = 0 }); + row.setSemanticPrompt(.input); + row = s.getRow(.{ .screen = 1 }); + row.setSemanticPrompt(.command); + try testing.expect(s.selectOutput(.{ .x = 2, .y = 0 }) == null); + } +} + +// X +test "Screen: selectPrompt basics" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 15, 10, 0); + defer s.deinit(); + + // zig fmt: off + { + // line number: + try s.testWriteString("output1\n"); // 0 + try s.testWriteString("output1\n"); // 1 + try s.testWriteString("prompt2\n"); // 2 + try s.testWriteString("input2\n"); // 3 + try s.testWriteString("output2\n"); // 4 + try s.testWriteString("output2\n"); // 5 + try s.testWriteString("prompt3$ input3\n"); // 6 + try s.testWriteString("output3\n"); // 7 + try s.testWriteString("output3\n"); // 8 + try s.testWriteString("output3"); // 9 + } + // zig fmt: on + + var row = s.getRow(.{ .screen = 2 }); + row.setSemanticPrompt(.prompt); + row = s.getRow(.{ .screen = 3 }); + row.setSemanticPrompt(.input); + row = s.getRow(.{ .screen = 4 }); + row.setSemanticPrompt(.command); + row = s.getRow(.{ .screen = 6 }); + row.setSemanticPrompt(.input); + row = s.getRow(.{ .screen = 7 }); + row.setSemanticPrompt(.command); + + // Not at a prompt + { + const sel = s.selectPrompt(.{ .x = 0, .y = 1 }); + try testing.expect(sel == null); + } + { + const sel = s.selectPrompt(.{ .x = 0, .y = 8 }); + try testing.expect(sel == null); + } + + // Single line prompt + { + const sel = s.selectPrompt(.{ .x = 1, .y = 6 }).?; + try testing.expectEqual(Selection{ + .start = .{ .x = 0, .y = 6 }, + .end = .{ .x = 9, .y = 6 }, + }, sel); + } + + // Multi line prompt + { + const sel = s.selectPrompt(.{ .x = 1, .y = 3 }).?; + try testing.expectEqual(Selection{ + .start = .{ .x = 0, .y = 2 }, + .end = .{ .x = 9, .y = 3 }, + }, sel); + } +} + +// X +test "Screen: selectPrompt prompt at start" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 15, 10, 0); + defer s.deinit(); + + // zig fmt: off + { + // line number: + try s.testWriteString("prompt1\n"); // 0 + try s.testWriteString("input1\n"); // 1 + try s.testWriteString("output2\n"); // 2 + try s.testWriteString("output2\n"); // 3 + } + // zig fmt: on + + var row = s.getRow(.{ .screen = 0 }); + row.setSemanticPrompt(.prompt); + row = s.getRow(.{ .screen = 1 }); + row.setSemanticPrompt(.input); + row = s.getRow(.{ .screen = 2 }); + row.setSemanticPrompt(.command); + + // Not at a prompt + { + const sel = s.selectPrompt(.{ .x = 0, .y = 3 }); + try testing.expect(sel == null); + } + + // Multi line prompt + { + const sel = s.selectPrompt(.{ .x = 1, .y = 1 }).?; + try testing.expectEqual(Selection{ + .start = .{ .x = 0, .y = 0 }, + .end = .{ .x = 9, .y = 1 }, + }, sel); + } +} + +// X +test "Screen: selectPrompt prompt at end" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 15, 10, 0); + defer s.deinit(); + + // zig fmt: off + { + // line number: + try s.testWriteString("output2\n"); // 0 + try s.testWriteString("output2\n"); // 1 + try s.testWriteString("prompt1\n"); // 2 + try s.testWriteString("input1\n"); // 3 + } + // zig fmt: on + + var row = s.getRow(.{ .screen = 2 }); + row.setSemanticPrompt(.prompt); + row = s.getRow(.{ .screen = 3 }); + row.setSemanticPrompt(.input); + + // Not at a prompt + { + const sel = s.selectPrompt(.{ .x = 0, .y = 1 }); + try testing.expect(sel == null); + } + + // Multi line prompt + { + const sel = s.selectPrompt(.{ .x = 1, .y = 2 }).?; + try testing.expectEqual(Selection{ + .start = .{ .x = 0, .y = 2 }, + .end = .{ .x = 9, .y = 3 }, + }, sel); + } +} + +// X +test "Screen: promptPath" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 15, 10, 0); + defer s.deinit(); + + // zig fmt: off + { + // line number: + try s.testWriteString("output1\n"); // 0 + try s.testWriteString("output1\n"); // 1 + try s.testWriteString("prompt2\n"); // 2 + try s.testWriteString("input2\n"); // 3 + try s.testWriteString("output2\n"); // 4 + try s.testWriteString("output2\n"); // 5 + try s.testWriteString("prompt3$ input3\n"); // 6 + try s.testWriteString("output3\n"); // 7 + try s.testWriteString("output3\n"); // 8 + try s.testWriteString("output3"); // 9 + } + // zig fmt: on + + var row = s.getRow(.{ .screen = 2 }); + row.setSemanticPrompt(.prompt); + row = s.getRow(.{ .screen = 3 }); + row.setSemanticPrompt(.input); + row = s.getRow(.{ .screen = 4 }); + row.setSemanticPrompt(.command); + row = s.getRow(.{ .screen = 6 }); + row.setSemanticPrompt(.input); + row = s.getRow(.{ .screen = 7 }); + row.setSemanticPrompt(.command); + + // From is not in the prompt + { + const path = s.promptPath( + .{ .x = 0, .y = 1 }, + .{ .x = 0, .y = 2 }, + ); + try testing.expectEqual(@as(isize, 0), path.x); + try testing.expectEqual(@as(isize, 0), path.y); + } + + // Same line + { + const path = s.promptPath( + .{ .x = 6, .y = 2 }, + .{ .x = 3, .y = 2 }, + ); + try testing.expectEqual(@as(isize, -3), path.x); + try testing.expectEqual(@as(isize, 0), path.y); + } + + // Different lines + { + const path = s.promptPath( + .{ .x = 6, .y = 2 }, + .{ .x = 3, .y = 3 }, + ); + try testing.expectEqual(@as(isize, -3), path.x); + try testing.expectEqual(@as(isize, 1), path.y); + } + + // To is out of bounds before + { + const path = s.promptPath( + .{ .x = 6, .y = 2 }, + .{ .x = 3, .y = 1 }, + ); + try testing.expectEqual(@as(isize, -6), path.x); + try testing.expectEqual(@as(isize, 0), path.y); + } + + // To is out of bounds after + { + const path = s.promptPath( + .{ .x = 6, .y = 2 }, + .{ .x = 3, .y = 9 }, + ); + try testing.expectEqual(@as(isize, 3), path.x); + try testing.expectEqual(@as(isize, 1), path.y); + } +} + +// X - we don't use this in new terminal +test "Screen: scrollRegionUp single" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 4, 5, 0); + defer s.deinit(); + try s.testWriteString("1ABCD\n2EFGH\n3IJKL\n4ABCD"); + + s.scrollRegionUp(.{ .active = 1 }, .{ .active = 2 }, 1); + { + // Test our contents rotated + const contents = try s.testString(alloc, .screen); + defer alloc.free(contents); + try testing.expectEqualStrings("1ABCD\n3IJKL\n\n4ABCD", contents); + } +} + +// X - we don't use this in new terminal +test "Screen: scrollRegionUp same line" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 4, 5, 0); + defer s.deinit(); + try s.testWriteString("1ABCD\n2EFGH\n3IJKL\n4ABCD"); + + s.scrollRegionUp(.{ .active = 1 }, .{ .active = 1 }, 1); + { + // Test our contents rotated + const contents = try s.testString(alloc, .screen); + defer alloc.free(contents); + try testing.expectEqualStrings("1ABCD\n2EFGH\n3IJKL\n4ABCD", contents); + } +} + +// X - we don't use this in new terminal +test "Screen: scrollRegionUp single with pen" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 4, 5, 0); + defer s.deinit(); + try s.testWriteString("1ABCD\n2EFGH\n3IJKL\n4ABCD"); + + s.cursor.pen = .{ .char = 'X' }; + s.cursor.pen.bg = .{ .rgb = .{ .r = 155 } }; + s.cursor.pen.attrs.bold = true; + s.scrollRegionUp(.{ .active = 1 }, .{ .active = 2 }, 1); + { + // Test our contents rotated + const contents = try s.testString(alloc, .screen); + defer alloc.free(contents); + try testing.expectEqualStrings("1ABCD\n3IJKL\n\n4ABCD", contents); + const cell = s.getCell(.active, 2, 0); + try testing.expectEqual(@as(u8, 155), cell.bg.rgb.r); + try testing.expect(!cell.attrs.bold); + try testing.expect(s.cursor.pen.attrs.bold); + } +} + +// X - we don't use this in new terminal +test "Screen: scrollRegionUp multiple" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 4, 5, 0); + defer s.deinit(); + try s.testWriteString("1ABCD\n2EFGH\n3IJKL\n4ABCD"); + + s.scrollRegionUp(.{ .active = 1 }, .{ .active = 3 }, 1); + { + // Test our contents rotated + const contents = try s.testString(alloc, .screen); + defer alloc.free(contents); + try testing.expectEqualStrings("1ABCD\n3IJKL\n4ABCD", contents); + } +} + +// X - we don't use this in new terminal +test "Screen: scrollRegionUp multiple count" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 4, 5, 0); + defer s.deinit(); + try s.testWriteString("1ABCD\n2EFGH\n3IJKL\n4ABCD"); + + s.scrollRegionUp(.{ .active = 1 }, .{ .active = 3 }, 2); + { + // Test our contents rotated + const contents = try s.testString(alloc, .screen); + defer alloc.free(contents); + try testing.expectEqualStrings("1ABCD\n4ABCD", contents); + } +} + +// X - we don't use this in new terminal +test "Screen: scrollRegionUp count greater than available lines" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 4, 5, 0); + defer s.deinit(); + try s.testWriteString("1ABCD\n2EFGH\n3IJKL\n4ABCD"); + + s.scrollRegionUp(.{ .active = 1 }, .{ .active = 2 }, 10); + { + // Test our contents rotated + const contents = try s.testString(alloc, .screen); + defer alloc.free(contents); + try testing.expectEqualStrings("1ABCD\n\n\n4ABCD", contents); + } +} +// X - we don't use this in new terminal +test "Screen: scrollRegionUp fills with pen" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 4, 5, 0); + defer s.deinit(); + try s.testWriteString("A\nB\nC\nD"); + + s.cursor.pen = .{ .char = 'X' }; + s.cursor.pen.bg = .{ .rgb = .{ .r = 155 } }; + s.cursor.pen.attrs.bold = true; + s.scrollRegionUp(.{ .active = 0 }, .{ .active = 2 }, 1); + { + // Test our contents rotated + const contents = try s.testString(alloc, .screen); + defer alloc.free(contents); + try testing.expectEqualStrings("B\nC\n\nD", contents); + const cell = s.getCell(.active, 2, 0); + try testing.expectEqual(@as(u8, 155), cell.bg.rgb.r); + try testing.expect(!cell.attrs.bold); + try testing.expect(s.cursor.pen.attrs.bold); + } +} + +// X - we don't use this in new terminal +test "Screen: scrollRegionUp buffer wrap" { + 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"); + + // Scroll down, should still be bottom, but should wrap because + // we're out of space. + try s.scroll(.{ .screen = 1 }); + s.cursor.x = 0; + try s.testWriteString("1ABCD\n2EFGH\n3IJKL\n4ABCD"); + + // Scroll + s.cursor.pen = .{ .char = 'X' }; + s.cursor.pen.bg = .{ .rgb = .{ .r = 155 } }; + s.cursor.pen.attrs.bold = true; + s.scrollRegionUp(.{ .screen = 0 }, .{ .screen = 2 }, 1); + + { + // Test our contents rotated + const contents = try s.testString(alloc, .screen); + defer alloc.free(contents); + try testing.expectEqualStrings("3IJKL\n4ABCD", contents); + const cell = s.getCell(.active, 2, 0); + try testing.expectEqual(@as(u8, 155), cell.bg.rgb.r); + try testing.expect(!cell.attrs.bold); + try testing.expect(s.cursor.pen.attrs.bold); + } +} + +// X - we don't use this in new terminal +test "Screen: scrollRegionUp buffer wrap alternate" { + 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"); + + // Scroll down, should still be bottom, but should wrap because + // we're out of space. + try s.scroll(.{ .screen = 1 }); + s.cursor.x = 0; + try s.testWriteString("1ABCD\n2EFGH\n3IJKL\n4ABCD"); + + // Scroll + s.cursor.pen = .{ .char = 'X' }; + s.cursor.pen.bg = .{ .rgb = .{ .r = 155 } }; + s.cursor.pen.attrs.bold = true; + s.scrollRegionUp(.{ .screen = 0 }, .{ .screen = 2 }, 2); + + { + // Test our contents rotated + const contents = try s.testString(alloc, .screen); + defer alloc.free(contents); + try testing.expectEqualStrings("4ABCD", contents); + const cell = s.getCell(.active, 2, 0); + try testing.expectEqual(@as(u8, 155), cell.bg.rgb.r); + try testing.expect(!cell.attrs.bold); + try testing.expect(s.cursor.pen.attrs.bold); + } +} + +// X - we don't use this in new terminal +test "Screen: scrollRegionUp buffer wrap alternative with extra lines" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 5, 5, 0); + defer s.deinit(); + + // We artificially mess with the circular buffer here. This was discovered + // when debugging https://github.com/mitchellh/ghostty/issues/315. I + // don't know how to "naturally" get the circular buffer into this state + // although it is obviously possible, verified through various + // asciinema casts. + // + // I think the proper way to recreate this state would be to fill + // the screen, scroll the correct number of times, clear the screen + // with a fill. I can try that later to ensure we're hitting the same + // code path. + s.storage.head = 24; + s.storage.tail = 24; + s.storage.full = true; + + // Scroll down, should still be bottom, but should wrap because + // we're out of space. + // try s.scroll(.{ .screen = 2 }); + // s.cursor.x = 0; + try s.testWriteString("1ABCD\n2EFGH\n3IJKL\n4ABCD\n5EFGH"); + + // Scroll + s.scrollRegionUp(.{ .screen = 0 }, .{ .screen = 3 }, 2); + + { + // Test our contents rotated + const contents = try s.testString(alloc, .screen); + defer alloc.free(contents); + try testing.expectEqualStrings("3IJKL\n4ABCD\n\n\n5EFGH", contents); + } +} + +// X +test "Screen: clear history with no history" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 3, 5, 3); + defer s.deinit(); + try s.testWriteString("4ABCD\n5EFGH\n6IJKL"); + try testing.expect(s.viewportIsBottom()); + try s.clear(.history); + try testing.expect(s.viewportIsBottom()); + { + // Test our contents rotated + const contents = try s.testString(alloc, .viewport); + defer alloc.free(contents); + try testing.expectEqualStrings("4ABCD\n5EFGH\n6IJKL", contents); + } + { + // Test our contents rotated + const contents = try s.testString(alloc, .screen); + defer alloc.free(contents); + try testing.expectEqualStrings("4ABCD\n5EFGH\n6IJKL", contents); + } +} + +// X +test "Screen: clear history" { + 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 + const contents = try s.testString(alloc, .viewport); + defer alloc.free(contents); + try testing.expectEqualStrings("1ABCD\n2EFGH\n3IJKL", contents); + } + + try s.clear(.history); + try testing.expect(s.viewportIsBottom()); + { + // Test our contents rotated + const contents = try s.testString(alloc, .viewport); + defer alloc.free(contents); + try testing.expectEqualStrings("4ABCD\n5EFGH\n6IJKL", contents); + } + { + // Test our contents rotated + const contents = try s.testString(alloc, .screen); + defer alloc.free(contents); + try testing.expectEqualStrings("4ABCD\n5EFGH\n6IJKL", contents); + } +} + +// X +test "Screen: clear above cursor" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 10, 10, 3); + defer s.deinit(); + try s.testWriteString("4ABCD\n5EFGH\n6IJKL"); + try testing.expect(s.viewportIsBottom()); + try s.clear(.above_cursor); + try testing.expect(s.viewportIsBottom()); + { + const contents = try s.testString(alloc, .viewport); + defer alloc.free(contents); + try testing.expectEqualStrings("6IJKL", contents); + } + { + const contents = try s.testString(alloc, .screen); + defer alloc.free(contents); + try testing.expectEqualStrings("6IJKL", contents); + } + + try testing.expectEqual(@as(usize, 5), s.cursor.x); + try testing.expectEqual(@as(usize, 0), s.cursor.y); +} + +// X +test "Screen: clear above cursor with history" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 3, 10, 3); + defer s.deinit(); + try s.testWriteString("1ABCD\n2EFGH\n3IJKL\n"); + try s.testWriteString("4ABCD\n5EFGH\n6IJKL"); + try testing.expect(s.viewportIsBottom()); + try s.clear(.above_cursor); + try testing.expect(s.viewportIsBottom()); + { + const contents = try s.testString(alloc, .viewport); + defer alloc.free(contents); + try testing.expectEqualStrings("6IJKL", contents); + } + { + const contents = try s.testString(alloc, .screen); + defer alloc.free(contents); + try testing.expectEqualStrings("1ABCD\n2EFGH\n3IJKL\n6IJKL", contents); + } + + try testing.expectEqual(@as(usize, 5), s.cursor.x); + try testing.expectEqual(@as(usize, 0), s.cursor.y); +} + +// X +test "Screen: selectionString basic" { + 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 contents = try s.selectionString(alloc, .{ + .start = .{ .x = 0, .y = 1 }, + .end = .{ .x = 2, .y = 2 }, + }, true); + defer alloc.free(contents); + const expected = "2EFGH\n3IJ"; + try testing.expectEqualStrings(expected, contents); + } +} + +// X +test "Screen: selectionString start outside of written area" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 10, 5, 0); + defer s.deinit(); + const str = "1ABCD\n2EFGH\n3IJKL"; + try s.testWriteString(str); + + { + const contents = try s.selectionString(alloc, .{ + .start = .{ .x = 0, .y = 5 }, + .end = .{ .x = 2, .y = 6 }, + }, true); + defer alloc.free(contents); + const expected = ""; + try testing.expectEqualStrings(expected, contents); + } +} + +// X +test "Screen: selectionString end outside of written area" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 10, 5, 0); + defer s.deinit(); + const str = "1ABCD\n2EFGH\n3IJKL"; + try s.testWriteString(str); + + { + const contents = try s.selectionString(alloc, .{ + .start = .{ .x = 0, .y = 2 }, + .end = .{ .x = 2, .y = 6 }, + }, true); + defer alloc.free(contents); + const expected = "3IJKL"; + try testing.expectEqualStrings(expected, contents); + } +} + +// X +test "Screen: selectionString trim space" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 3, 5, 0); + defer s.deinit(); + const str = "1AB \n2EFGH\n3IJKL"; + try s.testWriteString(str); + + { + const contents = try s.selectionString(alloc, .{ + .start = .{ .x = 0, .y = 0 }, + .end = .{ .x = 2, .y = 1 }, + }, true); + defer alloc.free(contents); + const expected = "1AB\n2EF"; + try testing.expectEqualStrings(expected, contents); + } + + // No trim + { + const contents = try s.selectionString(alloc, .{ + .start = .{ .x = 0, .y = 0 }, + .end = .{ .x = 2, .y = 1 }, + }, false); + defer alloc.free(contents); + const expected = "1AB \n2EF"; + try testing.expectEqualStrings(expected, contents); + } +} + +// X +test "Screen: selectionString trim empty line" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 5, 5, 0); + defer s.deinit(); + const str = "1AB \n\n2EFGH\n3IJKL"; + try s.testWriteString(str); + + { + const contents = try s.selectionString(alloc, .{ + .start = .{ .x = 0, .y = 0 }, + .end = .{ .x = 2, .y = 2 }, + }, true); + defer alloc.free(contents); + const expected = "1AB\n\n2EF"; + try testing.expectEqualStrings(expected, contents); + } + + // No trim + { + const contents = try s.selectionString(alloc, .{ + .start = .{ .x = 0, .y = 0 }, + .end = .{ .x = 2, .y = 2 }, + }, false); + defer alloc.free(contents); + const expected = "1AB \n \n2EF"; + try testing.expectEqualStrings(expected, contents); + } +} + +// X +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); + + { + const contents = try s.selectionString(alloc, .{ + .start = .{ .x = 0, .y = 1 }, + .end = .{ .x = 2, .y = 2 }, + }, true); + defer alloc.free(contents); + const expected = "2EFGH3IJ"; + try testing.expectEqualStrings(expected, contents); + } +} + +// X - can't happen in new terminal +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(.{ .screen = 1 }); + try testing.expect(s.viewportIsBottom()); + try s.testWriteString("1ABCD\n2EFGH\n3IJKL"); + + { + const contents = try s.selectionString(alloc, .{ + .start = .{ .x = 0, .y = 1 }, + .end = .{ .x = 2, .y = 2 }, + }, true); + defer alloc.free(contents); + const expected = "2EFGH\n3IJ"; + try testing.expectEqualStrings(expected, contents); + } +} + +// X +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); + + { + const contents = try s.selectionString(alloc, .{ + .start = .{ .x = 0, .y = 0 }, + .end = .{ .x = 3, .y = 0 }, + }, true); + defer alloc.free(contents); + const expected = str; + try testing.expectEqualStrings(expected, contents); + } + + { + const contents = try s.selectionString(alloc, .{ + .start = .{ .x = 0, .y = 0 }, + .end = .{ .x = 2, .y = 0 }, + }, true); + defer alloc.free(contents); + const expected = str; + try testing.expectEqualStrings(expected, contents); + } + + { + const contents = try s.selectionString(alloc, .{ + .start = .{ .x = 3, .y = 0 }, + .end = .{ .x = 3, .y = 0 }, + }, true); + defer alloc.free(contents); + const expected = "⚡"; + try testing.expectEqualStrings(expected, contents); + } +} + +// X +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); + + { + const contents = try s.selectionString(alloc, .{ + .start = .{ .x = 0, .y = 0 }, + .end = .{ .x = 4, .y = 0 }, + }, true); + defer alloc.free(contents); + const expected = str; + try testing.expectEqualStrings(expected, contents); + } +} + +// X +// https://github.com/mitchellh/ghostty/issues/289 +test "Screen: selectionString empty with soft wrap" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 2, 5, 0); + defer s.deinit(); + + // Let me describe the situation that caused this because this + // test is not obvious. By writing an emoji below, we introduce + // one cell with the emoji and one cell as a "wide char spacer". + // We then soft wrap the line by writing spaces. + // + // By selecting only the tail, we'd select nothing and we had + // a logic error that would cause a crash. + try s.testWriteString("👨"); + try s.testWriteString(" "); + + { + const contents = try s.selectionString(alloc, .{ + .start = .{ .x = 1, .y = 0 }, + .end = .{ .x = 2, .y = 0 }, + }, true); + defer alloc.free(contents); + const expected = "👨"; + try testing.expectEqualStrings(expected, contents); + } +} + +// X +test "Screen: selectionString with zero width joiner" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 1, 10, 0); + defer s.deinit(); + const str = "👨‍"; // this has a ZWJ + try s.testWriteString(str); + + // Integrity check + const row = s.getRow(.{ .screen = 0 }); + { + const cell = row.getCell(0); + try testing.expectEqual(@as(u32, 0x1F468), cell.char); + try testing.expect(cell.attrs.wide); + try testing.expectEqual(@as(usize, 2), row.codepointLen(0)); + } + { + const cell = row.getCell(1); + try testing.expectEqual(@as(u32, ' '), cell.char); + try testing.expect(cell.attrs.wide_spacer_tail); + try testing.expectEqual(@as(usize, 1), row.codepointLen(1)); + } + + // The real test + { + const contents = try s.selectionString(alloc, .{ + .start = .{ .x = 0, .y = 0 }, + .end = .{ .x = 1, .y = 0 }, + }, true); + defer alloc.free(contents); + const expected = "👨‍"; + try testing.expectEqualStrings(expected, contents); + } +} + +// X +test "Screen: selectionString, rectangle, basic" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 5, 30, 0); + defer s.deinit(); + const str = + \\Lorem ipsum dolor + \\sit amet, consectetur + \\adipiscing elit, sed do + \\eiusmod tempor incididunt + \\ut labore et dolore + ; + const sel = Selection{ + .start = .{ .x = 2, .y = 1 }, + .end = .{ .x = 6, .y = 3 }, + .rectangle = true, + }; + const expected = + \\t ame + \\ipisc + \\usmod + ; + try s.testWriteString(str); + + const contents = try s.selectionString(alloc, sel, true); + defer alloc.free(contents); + try testing.expectEqualStrings(expected, contents); +} + +// X +test "Screen: selectionString, rectangle, w/EOL" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 5, 30, 0); + defer s.deinit(); + const str = + \\Lorem ipsum dolor + \\sit amet, consectetur + \\adipiscing elit, sed do + \\eiusmod tempor incididunt + \\ut labore et dolore + ; + const sel = Selection{ + .start = .{ .x = 12, .y = 0 }, + .end = .{ .x = 26, .y = 4 }, + .rectangle = true, + }; + const expected = + \\dolor + \\nsectetur + \\lit, sed do + \\or incididunt + \\ dolore + ; + try s.testWriteString(str); + + const contents = try s.selectionString(alloc, sel, true); + defer alloc.free(contents); + try testing.expectEqualStrings(expected, contents); +} + +// X +test "Screen: selectionString, rectangle, more complex w/breaks" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 8, 30, 0); + defer s.deinit(); + const str = + \\Lorem ipsum dolor + \\sit amet, consectetur + \\adipiscing elit, sed do + \\eiusmod tempor incididunt + \\ut labore et dolore + \\ + \\magna aliqua. Ut enim + \\ad minim veniam, quis + ; + const sel = Selection{ + .start = .{ .x = 11, .y = 2 }, + .end = .{ .x = 26, .y = 7 }, + .rectangle = true, + }; + const expected = + \\elit, sed do + \\por incididunt + \\t dolore + \\ + \\a. Ut enim + \\niam, quis + ; + try s.testWriteString(str); + + const contents = try s.selectionString(alloc, sel, true); + defer alloc.free(contents); + try testing.expectEqualStrings(expected, contents); +} + +test "Screen: dirty with getCellPtr" { + 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()); + + // Ensure all are dirty. Clear em. + var iter = s.rowIterator(.viewport); + while (iter.next()) |row| { + try testing.expect(row.isDirty()); + row.setDirty(false); + } + + // Reset our cursor onto the second row. + s.cursor.x = 0; + s.cursor.y = 1; + + try s.testWriteString("foo"); + { + const row = s.getRow(.{ .active = 0 }); + try testing.expect(!row.isDirty()); + } + { + const row = s.getRow(.{ .active = 1 }); + try testing.expect(row.isDirty()); + } + { + const row = s.getRow(.{ .active = 2 }); + try testing.expect(!row.isDirty()); + + _ = row.getCell(0); + try testing.expect(!row.isDirty()); + } +} + +test "Screen: dirty with clear, fill, fillSlice, copyRow" { + 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()); + + // Ensure all are dirty. Clear em. + var iter = s.rowIterator(.viewport); + while (iter.next()) |row| { + try testing.expect(row.isDirty()); + row.setDirty(false); + } + + { + const row = s.getRow(.{ .active = 0 }); + try testing.expect(!row.isDirty()); + row.clear(.{}); + try testing.expect(row.isDirty()); + row.setDirty(false); + } + + { + const row = s.getRow(.{ .active = 0 }); + try testing.expect(!row.isDirty()); + row.fill(.{ .char = 'A' }); + try testing.expect(row.isDirty()); + row.setDirty(false); + } + + { + const row = s.getRow(.{ .active = 0 }); + try testing.expect(!row.isDirty()); + row.fillSlice(.{ .char = 'A' }, 0, 2); + try testing.expect(row.isDirty()); + row.setDirty(false); + } + + { + const src = s.getRow(.{ .active = 0 }); + const row = s.getRow(.{ .active = 1 }); + try testing.expect(!row.isDirty()); + try row.copyRow(src); + try testing.expect(!src.isDirty()); + try testing.expect(row.isDirty()); + row.setDirty(false); + } +} + +test "Screen: dirty with graphemes" { + 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()); + + // Ensure all are dirty. Clear em. + var iter = s.rowIterator(.viewport); + while (iter.next()) |row| { + try testing.expect(row.isDirty()); + row.setDirty(false); + } + + { + const row = s.getRow(.{ .active = 0 }); + try testing.expect(!row.isDirty()); + try row.attachGrapheme(0, 0xFE0F); + try testing.expect(row.isDirty()); + row.setDirty(false); + row.clearGraphemes(0); + try testing.expect(row.isDirty()); + row.setDirty(false); + } +} + +// X +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); + + // Clear dirty rows + var iter = s.rowIterator(.viewport); + while (iter.next()) |row| row.setDirty(false); + + // Resize + try s.resizeWithoutReflow(10, 5); + { + const contents = try s.testString(alloc, .viewport); + defer alloc.free(contents); + try testing.expectEqualStrings(str, contents); + } + + // Everything should be dirty + iter = s.rowIterator(.viewport); + while (iter.next()) |row| try testing.expect(row.isDirty()); +} + +// X +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); + + { + const contents = try s.testString(alloc, .viewport); + defer alloc.free(contents); + try testing.expectEqualStrings("2EFGH\n3IJKL", contents); + } +} + +// X +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 = .{ .rgb = .{ .r = 0xFF, .g = 0, .b = 0 } }; + } + } + + // 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); + + { + const contents = try s.testString(alloc, .viewport); + defer alloc.free(contents); + try testing.expectEqualStrings("1ABCD", contents); + } +} + +// X +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 = .{ .rgb = .{ .r = 0xFF, .g = 0, .b = 0 } }; + } + } + + // 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); + + { + const contents = try s.testString(alloc, .viewport); + defer alloc.free(contents); + try testing.expectEqualStrings("1ABCD", contents); + } +} + +// X +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); + + { + const contents = try s.testString(alloc, .viewport); + defer alloc.free(contents); + try testing.expectEqualStrings(str, contents); + } +} + +// X +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); + + { + const contents = try s.testString(alloc, .viewport); + defer alloc.free(contents); + const expected = "1ABC\n2EFG\n3IJK"; + try testing.expectEqualStrings(expected, contents); + } +} + +// X +test "Screen: resize (no reflow) more rows with scrollback cursor end" { + 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); + + { + const contents = try s.testString(alloc, .viewport); + defer alloc.free(contents); + try testing.expectEqualStrings(str, contents); + } +} + +// X +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); + + { + const contents = try s.testString(alloc, .screen); + defer alloc.free(contents); + const expected = "2EFGH\n3IJKL\n4ABCD\n5EFGH"; + try testing.expectEqualStrings(expected, contents); + } +} + +// X +// https://github.com/mitchellh/ghostty/issues/1030 +test "Screen: resize (no reflow) less rows with empty trailing" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 3, 5, 5); + defer s.deinit(); + const str = "1\n2\n3\n4\n5\n6\n7\n8"; + try s.testWriteString(str); + try s.scroll(.{ .clear = {} }); + s.cursor.x = 0; + s.cursor.y = 0; + try s.testWriteString("A\nB"); + + const cursor = s.cursor; + try s.resizeWithoutReflow(2, 5); + try testing.expectEqual(cursor, s.cursor); + + { + const contents = try s.testString(alloc, .viewport); + defer alloc.free(contents); + try testing.expectEqualStrings("A\nB", contents); + } +} + +// X +test "Screen: resize (no reflow) empty 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); + try testing.expectEqual(@as(usize, 5), s.rowsCapacity()); + + try s.resizeWithoutReflow(10, 10); + try testing.expect(s.rowsWritten() == 0); + + // This is the primary test for this test, we want to ensure we + // always have at least enough capacity for our rows. + try testing.expectEqual(@as(usize, 10), s.rowsCapacity()); +} + +// X +test "Screen: resize (no reflow) grapheme copy" { + 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); + + // Attach graphemes to all the columns + { + var iter = s.rowIterator(.viewport); + while (iter.next()) |row| { + var col: usize = 0; + while (col < s.cols) : (col += 1) { + try row.attachGrapheme(col, 0xFE0F); + } + } + } + + // Clear dirty rows + { + var iter = s.rowIterator(.viewport); + while (iter.next()) |row| row.setDirty(false); + } + + // Resize + try s.resizeWithoutReflow(10, 5); + { + const expected = "1️A️B️C️D️\n2️E️F️G️H️\n3️I️J️K️L️"; + const contents = try s.testString(alloc, .viewport); + defer alloc.free(contents); + try testing.expectEqualStrings(expected, contents); + } + + // Everything should be dirty + { + var iter = s.rowIterator(.viewport); + while (iter.next()) |row| try testing.expect(row.isDirty()); + } +} + +// X +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); + { + const 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); + } + } +} + +// X +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); + + { + const contents = try s.testString(alloc, .viewport); + defer alloc.free(contents); + try testing.expectEqualStrings(str, contents); + } + { + const contents = try s.testString(alloc, .screen); + defer alloc.free(contents); + try testing.expectEqualStrings(str, contents); + } +} + +// X +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); + + { + const contents = try s.testString(alloc, .viewport); + defer alloc.free(contents); + try testing.expectEqualStrings(str, contents); + } + { + const contents = try s.testString(alloc, .screen); + defer alloc.free(contents); + try testing.expectEqualStrings(str, contents); + } +} + +// X +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); + { + const 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); + + { + const contents = try s.testString(alloc, .viewport); + defer alloc.free(contents); + const expected = "3IJKL\n4ABCD\n5EFGH"; + try testing.expectEqualStrings(expected, contents); + } +} + +// X +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); + { + const 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); + + { + const contents = try s.testString(alloc, .viewport); + defer alloc.free(contents); + try testing.expectEqualStrings(str, contents); + } + { + const contents = try s.testString(alloc, .screen); + defer alloc.free(contents); + try testing.expectEqualStrings(str, contents); + } +} + +// X +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); + + { + const contents = try s.testString(alloc, .viewport); + defer alloc.free(contents); + try testing.expectEqualStrings(str, contents); + } + { + const contents = try s.testString(alloc, .screen); + defer alloc.free(contents); + try testing.expectEqualStrings(str, contents); + } +} + +// X +// https://github.com/mitchellh/ghostty/issues/272#issuecomment-1676038963 +test "Screen: resize more cols perfect split" { + 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); + try s.resize(3, 10); + + { + const contents = try s.testString(alloc, .screen); + defer alloc.free(contents); + try testing.expectEqualStrings("1ABCD2EFGH\n3IJKL", contents); + } +} + +// X +// https://github.com/mitchellh/ghostty/issues/1159 +test "Screen: resize (no reflow) more cols with scrollback scrolled up" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 3, 5, 5); + defer s.deinit(); + const str = "1\n2\n3\n4\n5\n6\n7\n8"; + try s.testWriteString(str); + + // Cursor at bottom + try testing.expectEqual(@as(usize, 1), s.cursor.x); + try testing.expectEqual(@as(usize, 2), s.cursor.y); + + try s.scroll(.{ .viewport = -4 }); + { + const contents = try s.testString(alloc, .viewport); + defer alloc.free(contents); + try testing.expectEqualStrings("2\n3\n4", contents); + } + + try s.resize(3, 8); + { + const contents = try s.testString(alloc, .screen); + defer alloc.free(contents); + try testing.expectEqualStrings(str, contents); + } + + // Cursor remains at bottom + try testing.expectEqual(@as(usize, 1), s.cursor.x); + try testing.expectEqual(@as(usize, 2), s.cursor.y); +} + +// X +// https://github.com/mitchellh/ghostty/issues/1159 +test "Screen: resize (no reflow) less cols with scrollback scrolled up" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 3, 5, 5); + defer s.deinit(); + const str = "1\n2\n3\n4\n5\n6\n7\n8"; + try s.testWriteString(str); + + // Cursor at bottom + try testing.expectEqual(@as(usize, 1), s.cursor.x); + try testing.expectEqual(@as(usize, 2), s.cursor.y); + + try s.scroll(.{ .viewport = -4 }); + { + const contents = try s.testString(alloc, .viewport); + defer alloc.free(contents); + try testing.expectEqualStrings("2\n3\n4", contents); + } + + try s.resize(3, 4); + { + const contents = try s.testString(alloc, .screen); + defer alloc.free(contents); + try testing.expectEqualStrings(str, contents); + } + { + const contents = try s.testString(alloc, .active); + defer alloc.free(contents); + try testing.expectEqualStrings("6\n7\n8", contents); + } + + // Cursor remains at bottom + try testing.expectEqual(@as(usize, 1), s.cursor.x); + try testing.expectEqual(@as(usize, 2), s.cursor.y); +} + +// X +test "Screen: resize more cols no reflow preserves semantic prompt" { + 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); + + // Set one of the rows to be a prompt + { + const row = s.getRow(.{ .active = 1 }); + row.setSemanticPrompt(.prompt); + } + + const cursor = s.cursor; + try s.resize(3, 10); + + // Cursor should not move + try testing.expectEqual(cursor, s.cursor); + + { + const contents = try s.testString(alloc, .viewport); + defer alloc.free(contents); + try testing.expectEqualStrings(str, contents); + } + { + const contents = try s.testString(alloc, .screen); + defer alloc.free(contents); + try testing.expectEqualStrings(str, contents); + } + + // Our one row should still be a semantic prompt, the others should not. + { + const row = s.getRow(.{ .active = 0 }); + try testing.expect(row.getSemanticPrompt() == .unknown); + } + { + const row = s.getRow(.{ .active = 1 }); + try testing.expect(row.getSemanticPrompt() == .prompt); + } + { + const row = s.getRow(.{ .active = 2 }); + try testing.expect(row.getSemanticPrompt() == .unknown); + } +} + +// X +test "Screen: resize more cols grapheme map" { + 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); + + // Attach graphemes to all the columns + { + var iter = s.rowIterator(.viewport); + while (iter.next()) |row| { + var col: usize = 0; + while (col < s.cols) : (col += 1) { + try row.attachGrapheme(col, 0xFE0F); + } + } + } + + const cursor = s.cursor; + try s.resize(3, 10); + + // Cursor should not move + try testing.expectEqual(cursor, s.cursor); + + { + const expected = "1️A️B️C️D️\n2️E️F️G️H️\n3️I️J️K️L️"; + const contents = try s.testString(alloc, .viewport); + defer alloc.free(contents); + try testing.expectEqualStrings(expected, contents); + } + { + const expected = "1️A️B️C️D️\n2️E️F️G️H️\n3️I️J️K️L️"; + const contents = try s.testString(alloc, .screen); + defer alloc.free(contents); + try testing.expectEqualStrings(expected, contents); + } +} + +// X +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(); + const str = "1ABCD2EFGH\n3IJKL"; + try s.testWriteString(str); + + // Verify we soft wrapped + { + const 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(.active, s.cursor.y, s.cursor.x).char); + + // Resize and verify we undid the soft wrap because we have space now + try s.resize(3, 10); + { + const 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); +} + +// X +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(); + const str = "1ABCD2EFGH\n3IJKL"; + try s.testWriteString(str); + + // Verify we soft wrapped + { + const 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(.active, s.cursor.y, s.cursor.x).char); + + // Resize and verify we undid the soft wrap because we have space now + try s.resize(3, 10); + { + const 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(.active, s.cursor.y, s.cursor.x).char); +} + +// X +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(); + const str = "1ABCD2EFGH\n3IJKL"; + try 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(.active, s.cursor.y, s.cursor.x).char); + + // Verify we soft wrapped + { + const 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(3, 7); + { + const 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); +} + +// X +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(); + const str = "1ABCD2EFGH3IJKL"; + 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, '3'), s.getCell(.active, s.cursor.y, s.cursor.x).char); + + // Verify we soft wrapped + { + const 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(3, 15); + { + const 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); +} + +// X +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(); + const str = "1ABCD\n2EFGH\n3IJKL\n4ABCD5EFGH"; + try s.testWriteString(str); + { + const 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(.active, s.cursor.y, s.cursor.x).char); + + // Resize + try s.resize(3, 10); + + // Cursor should still be on the "5" + try testing.expectEqual(@as(u32, '5'), s.getCell(.active, s.cursor.y, s.cursor.x).char); + + { + const contents = try s.testString(alloc, .viewport); + defer alloc.free(contents); + const expected = "2EFGH\n3IJKL\n4ABCD5EFGH"; + try testing.expectEqualStrings(expected, contents); + } +} + +// X +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 + { + const 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); + + { + const 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); +} + +// X +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(); + const str = "1ABCD\n2EFGH\n3IJKL"; + try s.testWriteString(str); + s.cursor.x = 0; + s.cursor.y = 0; + const cursor = s.cursor; + try s.resize(1, 5); + + // Cursor should not move + try testing.expectEqual(cursor, s.cursor); + + { + const contents = try s.testString(alloc, .viewport); + defer alloc.free(contents); + const expected = "3IJKL"; + try testing.expectEqualStrings(expected, contents); + } + { + const contents = try s.testString(alloc, .screen); + defer alloc.free(contents); + const expected = "3IJKL"; + try testing.expectEqualStrings(expected, contents); + } +} + +// X +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(); + const str = "1ABCD\n2EFGH\n3IJKL"; + try 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(.active, s.cursor.y, s.cursor.x).char); + + // Resize + try s.resize(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); + + { + const contents = try s.testString(alloc, .viewport); + defer alloc.free(contents); + const expected = "3IJKL"; + try testing.expectEqualStrings(expected, contents); + } + { + const contents = try s.testString(alloc, .screen); + defer alloc.free(contents); + const expected = "3IJKL"; + try testing.expectEqualStrings(expected, contents); + } +} + +// X +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(); + const str = "1ABCD\n2EFGH\n3IJKL"; + try s.testWriteString(str); + try s.resize(1, 5); + + { + const contents = try s.testString(alloc, .screen); + defer alloc.free(contents); + try testing.expectEqualStrings(str, contents); + } + { + const contents = try s.testString(alloc, .viewport); + defer alloc.free(contents); + const expected = "3IJKL"; + try testing.expectEqualStrings(expected, contents); + } +} + +// X +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(); + const str = "1ABCD\n2EFGH\n3IJKL\n4ABCD\n5EFGH"; + try s.testWriteString(str); + { + const contents = try s.testString(alloc, .viewport); + defer alloc.free(contents); + const expected = "3IJKL\n4ABCD\n5EFGH"; + try testing.expectEqualStrings(expected, contents); + } + + // Resize + try s.resize(1, 5); + + { + const contents = try s.testString(alloc, .screen); + defer alloc.free(contents); + try testing.expectEqualStrings(str, contents); + } + { + const contents = try s.testString(alloc, .viewport); + defer alloc.free(contents); + const expected = "5EFGH"; + try testing.expectEqualStrings(expected, contents); + } +} + +// X +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); + { + const 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); + + { + const contents = try s.testString(alloc, .screen); + defer alloc.free(contents); + const expected = "1ABCD\n2EFGH\n3IJKL\n4ABCD\n5EFGH"; + try testing.expectEqualStrings(expected, contents); + } + { + const contents = try s.testString(alloc, .viewport); + defer alloc.free(contents); + const expected = "4ABCD\n5EFGH"; + try testing.expectEqualStrings(expected, contents); + } +} + +// X +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(); + const str = "1AB\n2EF\n3IJ"; + try s.testWriteString(str); + s.cursor.x = 0; + s.cursor.y = 0; + const cursor = s.cursor; + try s.resize(3, 3); + + // Cursor should not move + try testing.expectEqual(cursor, s.cursor); + + { + const contents = try s.testString(alloc, .viewport); + defer alloc.free(contents); + try testing.expectEqualStrings(str, contents); + } + { + const contents = try s.testString(alloc, .screen); + defer alloc.free(contents); + try testing.expectEqualStrings(str, contents); + } +} + +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 = .{ .rgb = .{ .r = 0xFF } } }; + 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); + + { + const contents = try s.testString(alloc, .viewport); + defer alloc.free(contents); + try testing.expectEqualStrings(str, contents); + } + { + const 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.*); + } +} + +// X +test "Screen: resize less cols with graphemes" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 3, 5, 0); + defer s.deinit(); + const str = "1AB\n2EF\n3IJ"; + try s.testWriteString(str); + + // Attach graphemes to all the columns + { + var iter = s.rowIterator(.viewport); + while (iter.next()) |row| { + var col: usize = 0; + while (col < 3) : (col += 1) { + try row.attachGrapheme(col, 0xFE0F); + } + } + } + + s.cursor.x = 0; + s.cursor.y = 0; + const cursor = s.cursor; + try s.resize(3, 3); + + // Cursor should not move + try testing.expectEqual(cursor, s.cursor); + + { + const expected = "1️A️B️\n2️E️F️\n3️I️J️"; + const contents = try s.testString(alloc, .viewport); + defer alloc.free(contents); + try testing.expectEqualStrings(expected, contents); + } + { + const expected = "1️A️B️\n2️E️F️\n3️I️J️"; + const contents = try s.testString(alloc, .screen); + defer alloc.free(contents); + try testing.expectEqualStrings(expected, contents); + } +} + +// X +test "Screen: resize less cols no reflow preserves semantic prompt" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 3, 5, 0); + defer s.deinit(); + const str = "1AB\n2EF\n3IJ"; + try s.testWriteString(str); + + // Set one of the rows to be a prompt + { + const row = s.getRow(.{ .active = 1 }); + row.setSemanticPrompt(.prompt); + } + + s.cursor.x = 0; + s.cursor.y = 0; + const cursor = s.cursor; + try s.resize(3, 3); + + // Cursor should not move + try testing.expectEqual(cursor, s.cursor); + + { + const contents = try s.testString(alloc, .viewport); + defer alloc.free(contents); + try testing.expectEqualStrings(str, contents); + } + { + const contents = try s.testString(alloc, .screen); + defer alloc.free(contents); + try testing.expectEqualStrings(str, contents); + } + + // Our one row should still be a semantic prompt, the others should not. + { + const row = s.getRow(.{ .active = 0 }); + try testing.expect(row.getSemanticPrompt() == .unknown); + } + { + const row = s.getRow(.{ .active = 1 }); + try testing.expect(row.getSemanticPrompt() == .prompt); + } + { + const row = s.getRow(.{ .active = 2 }); + try testing.expect(row.getSemanticPrompt() == .unknown); + } +} + +// X +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(); + const str = "1ABCD"; + try 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(.active, s.cursor.y, s.cursor.x).char); + + try s.resize(3, 3); + { + const contents = try s.testString(alloc, .viewport); + defer alloc.free(contents); + const expected = "1AB\nCD"; + try testing.expectEqualStrings(expected, contents); + } + { + const 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); +} + +// X +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(); + const str = "3IJKL\n4ABCD\n5EFGH"; + try s.testWriteString(str); + try s.resize(3, 3); + + { + const contents = try s.testString(alloc, .viewport); + defer alloc.free(contents); + const expected = "CD\n5EF\nGH"; + try testing.expectEqualStrings(expected, contents); + } + { + const contents = try s.testString(alloc, .screen); + defer alloc.free(contents); + const expected = "CD\n5EF\nGH"; + try testing.expectEqualStrings(expected, contents); + } +} + +// X +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(); + const str = "3IJKL\n4ABCD\n5EFGH"; + try s.testWriteString(str); + try s.resize(3, 3); + + { + const contents = try s.testString(alloc, .viewport); + defer alloc.free(contents); + const expected = "CD\n5EF\nGH"; + try testing.expectEqualStrings(expected, contents); + } + { + const contents = try s.testString(alloc, .screen); + defer alloc.free(contents); + const expected = "4AB\nCD\n5EF\nGH"; + try testing.expectEqualStrings(expected, contents); + } +} + +// X +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 + { + const 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); + + // { + // const contents = try s.testString(alloc, .viewport); + // defer alloc.free(contents); + // const expected = "CD\n5EF\nGH"; + // try testing.expectEqualStrings(expected, contents); + // } + { + const contents = try s.testString(alloc, .screen); + defer alloc.free(contents); + const expected = "ABC\nD5E\nFGH"; + try testing.expectEqualStrings(expected, contents); + } +} + +// X +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); + + { + const 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); +} + +// X +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 + { + const 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); + + { + const contents = try s.testString(alloc, .viewport); + defer alloc.free(contents); + const expected = "CD5\nEFG\nH"; + try testing.expectEqualStrings(expected, contents); + } + { + const 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); +} + +// X +test "Screen: resize less cols with scrollback keeps cursor row" { + 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); + + // Lets do a scroll and clear operation + try s.scroll(.{ .clear = {} }); + + // Move our cursor to the beginning + s.cursor.x = 0; + s.cursor.y = 0; + + try s.resize(3, 3); + + { + const contents = try s.testString(alloc, .viewport); + defer alloc.free(contents); + const expected = ""; + try testing.expectEqualStrings(expected, contents); + } + + // Cursor should be on the last line + try testing.expectEqual(@as(usize, 0), s.cursor.x); + try testing.expectEqual(@as(usize, 0), s.cursor.y); +} + +// X +test "Screen: resize more rows, less cols with reflow with scrollback" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 3, 5, 3); + defer s.deinit(); + const str = "1ABCD\n2EFGH3IJKL\n4MNOP"; + try s.testWriteString(str); + + { + const contents = try s.testString(alloc, .screen); + defer alloc.free(contents); + const expected = "1ABCD\n2EFGH\n3IJKL\n4MNOP"; + try testing.expectEqualStrings(expected, contents); + } + { + const contents = try s.testString(alloc, .viewport); + defer alloc.free(contents); + const expected = "2EFGH\n3IJKL\n4MNOP"; + try testing.expectEqualStrings(expected, contents); + } + + try s.resize(10, 2); + + { + const contents = try s.testString(alloc, .viewport); + defer alloc.free(contents); + const expected = "BC\nD\n2E\nFG\nH3\nIJ\nKL\n4M\nNO\nP"; + try testing.expectEqualStrings(expected, contents); + } + { + const contents = try s.testString(alloc, .screen); + defer alloc.free(contents); + const expected = "1A\nBC\nD\n2E\nFG\nH3\nIJ\nKL\n4M\nNO\nP"; + try testing.expectEqualStrings(expected, contents); + } +} + +// X +// 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(); + const str = "1ABC"; + try s.testWriteString(str); + + // Grow + try s.resize(10, 5); + { + const contents = try s.testString(alloc, .viewport); + defer alloc.free(contents); + try testing.expectEqualStrings(str, contents); + } + { + const contents = try s.testString(alloc, .screen); + defer alloc.free(contents); + try testing.expectEqualStrings(str, contents); + } + + // Shrink + try s.resize(3, 5); + { + const contents = try s.testString(alloc, .screen); + defer alloc.free(contents); + try testing.expectEqualStrings(str, contents); + } + { + const contents = try s.testString(alloc, .viewport); + defer alloc.free(contents); + try testing.expectEqualStrings(str, contents); + } + + // Grow again + try s.resize(10, 5); + { + const contents = try s.testString(alloc, .viewport); + defer alloc.free(contents); + try testing.expectEqualStrings(str, contents); + } + { + const contents = try s.testString(alloc, .screen); + defer alloc.free(contents); + try testing.expectEqualStrings(str, contents); + } +} + +// X +test "Screen: resize less cols to eliminate wide char" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 1, 2, 0); + defer s.deinit(); + const str = "😀"; + try s.testWriteString(str); + { + const contents = try s.testString(alloc, .screen); + defer alloc.free(contents); + try testing.expectEqualStrings(str, contents); + } + { + const cell = s.getCell(.screen, 0, 0); + try testing.expectEqual(@as(u32, '😀'), cell.char); + try testing.expect(cell.attrs.wide); + } + + // Resize to 1 column can't fit a wide char. So it should be deleted. + try s.resize(1, 1); + { + const contents = try s.testString(alloc, .screen); + defer alloc.free(contents); + try testing.expectEqualStrings(" ", contents); + } + + const cell = s.getCell(.screen, 0, 0); + try testing.expectEqual(@as(u32, ' '), cell.char); + try testing.expect(!cell.attrs.wide); + try testing.expect(!cell.attrs.wide_spacer_tail); + try testing.expect(!cell.attrs.wide_spacer_head); +} + +// X +test "Screen: resize less cols to wrap wide char" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 3, 3, 0); + defer s.deinit(); + const str = "x😀"; + try s.testWriteString(str); + { + const contents = try s.testString(alloc, .screen); + defer alloc.free(contents); + try testing.expectEqualStrings(str, contents); + } + { + const cell = s.getCell(.screen, 0, 1); + try testing.expectEqual(@as(u32, '😀'), cell.char); + try testing.expect(cell.attrs.wide); + try testing.expect(s.getCell(.screen, 0, 2).attrs.wide_spacer_tail); + } + + try s.resize(3, 2); + { + const contents = try s.testString(alloc, .screen); + defer alloc.free(contents); + try testing.expectEqualStrings("x\n😀", contents); + } + { + const cell = s.getCell(.screen, 0, 1); + try testing.expectEqual(@as(u32, ' '), cell.char); + try testing.expect(!cell.attrs.wide); + try testing.expect(!cell.attrs.wide_spacer_tail); + try testing.expect(cell.attrs.wide_spacer_head); + } +} + +// X +test "Screen: resize less cols to eliminate wide char with row space" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 2, 2, 0); + defer s.deinit(); + const str = "😀"; + try s.testWriteString(str); + { + const contents = try s.testString(alloc, .screen); + defer alloc.free(contents); + try testing.expectEqualStrings(str, contents); + } + { + const cell = s.getCell(.screen, 0, 0); + try testing.expectEqual(@as(u32, '😀'), cell.char); + try testing.expect(cell.attrs.wide); + try testing.expect(s.getCell(.screen, 0, 1).attrs.wide_spacer_tail); + } + + try s.resize(2, 1); + { + const contents = try s.testString(alloc, .screen); + defer alloc.free(contents); + try testing.expectEqualStrings(" \n ", contents); + } + { + const cell = s.getCell(.screen, 0, 0); + try testing.expectEqual(@as(u32, ' '), cell.char); + try testing.expect(!cell.attrs.wide); + try testing.expect(!cell.attrs.wide_spacer_tail); + try testing.expect(!cell.attrs.wide_spacer_head); + } +} + +// X +test "Screen: resize more cols with wide spacer head" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 2, 3, 0); + defer s.deinit(); + const str = " 😀"; + try s.testWriteString(str); + { + const contents = try s.testString(alloc, .screen); + defer alloc.free(contents); + try testing.expectEqualStrings(" \n😀", contents); + } + + // So this is the key point: we end up with a wide spacer head at + // the end of row 1, then the emoji, then a wide spacer tail on row 2. + // We should expect that if we resize to more cols, the wide spacer + // head is replaced with the emoji. + { + const cell = s.getCell(.screen, 0, 2); + try testing.expectEqual(@as(u32, ' '), cell.char); + try testing.expect(cell.attrs.wide_spacer_head); + try testing.expect(s.getCell(.screen, 1, 0).attrs.wide); + try testing.expect(s.getCell(.screen, 1, 1).attrs.wide_spacer_tail); + } + + try s.resize(2, 4); + { + const contents = try s.testString(alloc, .screen); + defer alloc.free(contents); + try testing.expectEqualStrings(str, contents); + } + { + const cell = s.getCell(.screen, 0, 2); + try testing.expectEqual(@as(u32, '😀'), cell.char); + try testing.expect(!cell.attrs.wide_spacer_head); + try testing.expect(cell.attrs.wide); + try testing.expect(s.getCell(.screen, 0, 3).attrs.wide_spacer_tail); + } +} + +// X +test "Screen: resize less cols preserves grapheme cluster" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 1, 5, 0); + defer s.deinit(); + const str: []const u8 = &.{ 0x43, 0xE2, 0x83, 0x90 }; // C⃐ (C with combining left arrow) + try s.testWriteString(str); + + // We should have a single cell with all the codepoints + { + const row = s.getRow(.{ .screen = 0 }); + try testing.expectEqual(@as(usize, 2), row.codepointLen(0)); + } + { + const contents = try s.testString(alloc, .screen); + defer alloc.free(contents); + try testing.expectEqualStrings(str, contents); + } + + // Resize to less columns. No wrapping, but we should still have + // the same grapheme cluster. + try s.resize(1, 4); + { + const contents = try s.testString(alloc, .screen); + defer alloc.free(contents); + try testing.expectEqualStrings(str, contents); + } +} + +// X +test "Screen: resize more cols with wide spacer head multiple lines" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 3, 3, 0); + defer s.deinit(); + const str = "xxxyy😀"; + try s.testWriteString(str); + { + const contents = try s.testString(alloc, .screen); + defer alloc.free(contents); + try testing.expectEqualStrings("xxx\nyy\n😀", contents); + } + + // Similar to the "wide spacer head" test, but this time we'er going + // to increase our columns such that multiple rows are unwrapped. + { + const cell = s.getCell(.screen, 1, 2); + try testing.expectEqual(@as(u32, ' '), cell.char); + try testing.expect(cell.attrs.wide_spacer_head); + try testing.expect(s.getCell(.screen, 2, 0).attrs.wide); + try testing.expect(s.getCell(.screen, 2, 1).attrs.wide_spacer_tail); + } + + try s.resize(2, 8); + { + const contents = try s.testString(alloc, .screen); + defer alloc.free(contents); + try testing.expectEqualStrings(str, contents); + } + { + const cell = s.getCell(.screen, 0, 5); + try testing.expect(!cell.attrs.wide_spacer_head); + try testing.expectEqual(@as(u32, '😀'), cell.char); + try testing.expect(cell.attrs.wide); + try testing.expect(s.getCell(.screen, 0, 6).attrs.wide_spacer_tail); + } +} + +// X +test "Screen: resize more cols requiring a wide spacer head" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 2, 2, 0); + defer s.deinit(); + const str = "xx😀"; + try s.testWriteString(str); + { + const contents = try s.testString(alloc, .screen); + defer alloc.free(contents); + try testing.expectEqualStrings("xx\n😀", contents); + } + { + try testing.expect(s.getCell(.screen, 1, 0).attrs.wide); + try testing.expect(s.getCell(.screen, 1, 1).attrs.wide_spacer_tail); + } + + // This resizes to 3 columns, which isn't enough space for our wide + // char to enter row 1. But we need to mark the wide spacer head on the + // end of the first row since we're wrapping to the next row. + try s.resize(2, 3); + { + const contents = try s.testString(alloc, .screen); + defer alloc.free(contents); + try testing.expectEqualStrings("xx\n😀", contents); + } + { + const cell = s.getCell(.screen, 0, 2); + try testing.expectEqual(@as(u32, ' '), cell.char); + try testing.expect(cell.attrs.wide_spacer_head); + try testing.expect(s.getCell(.screen, 1, 0).attrs.wide); + try testing.expect(s.getCell(.screen, 1, 1).attrs.wide_spacer_tail); + } + { + const cell = s.getCell(.screen, 1, 0); + try testing.expectEqual(@as(u32, '😀'), cell.char); + try testing.expect(cell.attrs.wide); + try testing.expect(s.getCell(.screen, 1, 1).attrs.wide_spacer_tail); + } +} + +test "Screen: jump zero" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 3, 5, 10); + defer s.deinit(); + try s.testWriteString("1ABCD\n2EFGH\n3IJKL\n"); + try s.testWriteString("4ABCD\n5EFGH\n6IJKL"); + try testing.expect(s.viewportIsBottom()); + + // Set semantic prompts + { + const row = s.getRow(.{ .screen = 1 }); + row.setSemanticPrompt(.prompt); + } + { + const row = s.getRow(.{ .screen = 5 }); + row.setSemanticPrompt(.prompt); + } + + try testing.expect(!s.jump(.{ .prompt_delta = 0 })); + try testing.expectEqual(@as(usize, 3), s.viewport); +} + +test "Screen: jump to prompt" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 3, 5, 10); + defer s.deinit(); + try s.testWriteString("1ABCD\n2EFGH\n3IJKL\n"); + try s.testWriteString("4ABCD\n5EFGH\n6IJKL"); + try testing.expect(s.viewportIsBottom()); + + // Set semantic prompts + { + const row = s.getRow(.{ .screen = 1 }); + row.setSemanticPrompt(.prompt); + } + { + const row = s.getRow(.{ .screen = 5 }); + row.setSemanticPrompt(.prompt); + } + + // Jump back + try testing.expect(s.jump(.{ .prompt_delta = -1 })); + try testing.expectEqual(@as(usize, 1), s.viewport); + + // Jump back + try testing.expect(!s.jump(.{ .prompt_delta = -1 })); + try testing.expectEqual(@as(usize, 1), s.viewport); + + // Jump forward + try testing.expect(s.jump(.{ .prompt_delta = 1 })); + try testing.expectEqual(@as(usize, 3), s.viewport); + + // Jump forward + try testing.expect(!s.jump(.{ .prompt_delta = 1 })); + try testing.expectEqual(@as(usize, 3), s.viewport); +} + +test "Screen: row graphemeBreak" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 1, 10, 0); + defer s.deinit(); + try s.testWriteString("x"); + try s.testWriteString("👨‍A"); + + const row = s.getRow(.{ .screen = 0 }); + + // Normal char is a break + try testing.expect(row.graphemeBreak(0)); + + // Emoji with ZWJ is not + try testing.expect(!row.graphemeBreak(1)); +} diff --git a/src/terminal-old/Selection.zig b/src/terminal-old/Selection.zig new file mode 100644 index 000000000..d29513d73 --- /dev/null +++ b/src/terminal-old/Selection.zig @@ -0,0 +1,1165 @@ +/// Represents a single selection within the terminal +/// (i.e. a highlight region). +const Selection = @This(); + +const std = @import("std"); +const assert = std.debug.assert; +const point = @import("point.zig"); +const Screen = @import("Screen.zig"); +const ScreenPoint = point.ScreenPoint; + +/// Start and end of the selection. There is no guarantee that +/// start is before end or vice versa. If a user selects backwards, +/// start will be after end, and vice versa. Use the struct functions +/// to not have to worry about this. +start: ScreenPoint, +end: ScreenPoint, + +/// Whether or not this selection refers to a rectangle, rather than whole +/// lines of a buffer. In this mode, start and end refer to the top left and +/// bottom right of the rectangle, or vice versa if the selection is backwards. +rectangle: bool = false, + +/// Converts a selection screen points to viewport points (still typed +/// as ScreenPoints) if the selection is present within the viewport +/// of the screen. +pub fn toViewport(self: Selection, screen: *const Screen) ?Selection { + const top = (point.Viewport{ .x = 0, .y = 0 }).toScreen(screen); + const bot = (point.Viewport{ .x = screen.cols - 1, .y = screen.rows - 1 }).toScreen(screen); + + // If our selection isn't within the viewport, do nothing. + if (!self.within(top, bot)) return null; + + // Convert + const start = self.start.toViewport(screen); + const end = self.end.toViewport(screen); + return Selection{ + .start = .{ .x = if (self.rectangle) self.start.x else start.x, .y = start.y }, + .end = .{ .x = if (self.rectangle) self.end.x else end.x, .y = end.y }, + .rectangle = self.rectangle, + }; +} + +/// Returns true if the selection is empty. +pub fn empty(self: Selection) bool { + return self.start.x == self.end.x and self.start.y == self.end.y; +} + +/// Returns true if the selection contains the given point. +/// +/// This recalculates top left and bottom right each call. If you have +/// many points to check, it is cheaper to do the containment logic +/// yourself and cache the topleft/bottomright. +pub fn contains(self: Selection, p: ScreenPoint) bool { + const tl = self.topLeft(); + const br = self.bottomRight(); + + // Honestly there is probably way more efficient boolean logic here. + // Look back at this in the future... + + // If we're in rectangle select, we can short-circuit with an easy check + // here + if (self.rectangle) + return p.y >= tl.y and p.y <= br.y and p.x >= tl.x and p.x <= br.x; + + // If tl/br are same line + if (tl.y == br.y) return p.y == tl.y and + p.x >= tl.x and + p.x <= br.x; + + // If on top line, just has to be left of X + if (p.y == tl.y) return p.x >= tl.x; + + // If on bottom line, just has to be right of X + if (p.y == br.y) return p.x <= br.x; + + // If between the top/bottom, always good. + return p.y > tl.y and p.y < br.y; +} + +/// Returns true if the selection contains any of the points between +/// (and including) the start and end. The x values are ignored this is +/// just a section match +pub fn within(self: Selection, start: ScreenPoint, end: ScreenPoint) bool { + const tl = self.topLeft(); + const br = self.bottomRight(); + + // Bottom right is before start, no way we are in it. + if (br.y < start.y) return false; + // Bottom right is the first line, only if our x is in it. + if (br.y == start.y) return br.x >= start.x; + + // If top left is beyond the end, we're not in it. + if (tl.y > end.y) return false; + // If top left is on the end, only if our x is in it. + if (tl.y == end.y) return tl.x <= end.x; + + return true; +} + +/// Returns true if the selection contains the row of the given point, +/// regardless of the x value. +pub fn containsRow(self: Selection, p: ScreenPoint) bool { + const tl = self.topLeft(); + const br = self.bottomRight(); + return p.y >= tl.y and p.y <= br.y; +} + +/// Get a selection for a single row in the screen. This will return null +/// if the row is not included in the selection. +pub fn containedRow(self: Selection, screen: *const Screen, p: ScreenPoint) ?Selection { + const tl = self.topLeft(); + const br = self.bottomRight(); + if (p.y < tl.y or p.y > br.y) return null; + + // Rectangle case: we can return early as the x range will always be the + // same. We've already validated that the row is in the selection. + if (self.rectangle) return .{ + .start = .{ .y = p.y, .x = tl.x }, + .end = .{ .y = p.y, .x = br.x }, + .rectangle = true, + }; + + if (p.y == tl.y) { + // If the selection is JUST this line, return it as-is. + if (p.y == br.y) { + return self; + } + + // Selection top-left line matches only. + return .{ + .start = tl, + .end = .{ .y = tl.y, .x = screen.cols - 1 }, + }; + } + + // Row is our bottom selection, so we return the selection from the + // beginning of the line to the br. We know our selection is more than + // one line (due to conditionals above) + if (p.y == br.y) { + assert(p.y != tl.y); + return .{ + .start = .{ .y = br.y, .x = 0 }, + .end = br, + }; + } + + // Row is somewhere between our selection lines so we return the full line. + return .{ + .start = .{ .y = p.y, .x = 0 }, + .end = .{ .y = p.y, .x = screen.cols - 1 }, + }; +} + +/// Returns the top left point of the selection. +pub fn topLeft(self: Selection) ScreenPoint { + return switch (self.order()) { + .forward => self.start, + .reverse => self.end, + .mirrored_forward => .{ .x = self.end.x, .y = self.start.y }, + .mirrored_reverse => .{ .x = self.start.x, .y = self.end.y }, + }; +} + +/// Returns the bottom right point of the selection. +pub fn bottomRight(self: Selection) ScreenPoint { + return switch (self.order()) { + .forward => self.end, + .reverse => self.start, + .mirrored_forward => .{ .x = self.start.x, .y = self.end.y }, + .mirrored_reverse => .{ .x = self.end.x, .y = self.start.y }, + }; +} + +/// Returns the selection in the given order. +/// +/// Note that only forward and reverse are useful desired orders for this +/// function. All other orders act as if forward order was desired. +pub fn ordered(self: Selection, desired: Order) Selection { + if (self.order() == desired) return self; + const tl = self.topLeft(); + const br = self.bottomRight(); + return switch (desired) { + .forward => .{ .start = tl, .end = br, .rectangle = self.rectangle }, + .reverse => .{ .start = br, .end = tl, .rectangle = self.rectangle }, + else => .{ .start = tl, .end = br, .rectangle = self.rectangle }, + }; +} + +/// The order of the selection: +/// +/// * forward: start(x, y) is before end(x, y) (top-left to bottom-right). +/// * reverse: end(x, y) is before start(x, y) (bottom-right to top-left). +/// * mirrored_[forward|reverse]: special, rectangle selections only (see below). +/// +/// For regular selections, the above also holds for top-right to bottom-left +/// (forward) and bottom-left to top-right (reverse). However, for rectangle +/// selections, both of these selections are *mirrored* as orientation +/// operations only flip the x or y axis, not both. Depending on the y axis +/// direction, this is either mirrored_forward or mirrored_reverse. +/// +pub const Order = enum { forward, reverse, mirrored_forward, mirrored_reverse }; + +pub fn order(self: Selection) Order { + if (self.rectangle) { + // Reverse (also handles single-column) + if (self.start.y > self.end.y and self.start.x >= self.end.x) return .reverse; + if (self.start.y >= self.end.y and self.start.x > self.end.x) return .reverse; + + // Mirror, bottom-left to top-right + if (self.start.y > self.end.y and self.start.x < self.end.x) return .mirrored_reverse; + + // Mirror, top-right to bottom-left + if (self.start.y < self.end.y and self.start.x > self.end.x) return .mirrored_forward; + + // Forward + return .forward; + } + + if (self.start.y < self.end.y) return .forward; + if (self.start.y > self.end.y) return .reverse; + if (self.start.x <= self.end.x) return .forward; + return .reverse; +} + +/// Possible adjustments to the selection. +pub const Adjustment = enum { + left, + right, + up, + down, + home, + end, + page_up, + page_down, +}; + +/// Adjust the selection by some given adjustment. An adjustment allows +/// a selection to be expanded slightly left, right, up, down, etc. +pub fn adjust(self: Selection, screen: *Screen, adjustment: Adjustment) Selection { + const screen_end = Screen.RowIndexTag.screen.maxLen(screen) - 1; + + // Make an editable one because its so much easier to use modification + // logic below than it is to reconstruct the selection every time. + var result = self; + + // Note that we always adjusts "end" because end always represents + // the last point of the selection by mouse, not necessarilly the + // top/bottom visually. So this results in the right behavior + // whether the user drags up or down. + switch (adjustment) { + .up => if (result.end.y == 0) { + result.end.x = 0; + } else { + result.end.y -= 1; + }, + + .down => if (result.end.y >= screen_end) { + result.end.y = screen_end; + result.end.x = screen.cols - 1; + } else { + result.end.y += 1; + }, + + .left => { + // Step left, wrapping to the next row up at the start of each new line, + // until we find a non-empty cell. + // + // This iterator emits the start point first, throw it out. + var iterator = result.end.iterator(screen, .left_up); + _ = iterator.next(); + while (iterator.next()) |next| { + if (screen.getCell( + .screen, + next.y, + next.x, + ).char != 0) { + result.end = next; + break; + } + } + }, + + .right => { + // Step right, wrapping to the next row down at the start of each new line, + // until we find a non-empty cell. + var iterator = result.end.iterator(screen, .right_down); + _ = iterator.next(); + while (iterator.next()) |next| { + if (next.y > screen_end) break; + if (screen.getCell( + .screen, + next.y, + next.x, + ).char != 0) { + if (next.y > screen_end) { + result.end.y = screen_end; + } else { + result.end = next; + } + break; + } + } + }, + + .page_up => if (screen.rows > result.end.y) { + result.end.y = 0; + result.end.x = 0; + } else { + result.end.y -= screen.rows; + }, + + .page_down => if (screen.rows > screen_end - result.end.y) { + result.end.y = screen_end; + result.end.x = screen.cols - 1; + } else { + result.end.y += screen.rows; + }, + + .home => { + result.end.y = 0; + result.end.x = 0; + }, + + .end => { + result.end.y = screen_end; + result.end.x = screen.cols - 1; + }, + } + + return result; +} + +// X +test "Selection: adjust right" { + const testing = std.testing; + var screen = try Screen.init(testing.allocator, 5, 10, 0); + defer screen.deinit(); + try screen.testWriteString("A1234\nB5678\nC1234\nD5678"); + + // Simple movement right + { + const sel = (Selection{ + .start = .{ .x = 5, .y = 1 }, + .end = .{ .x = 3, .y = 3 }, + }).adjust(&screen, .right); + + try testing.expectEqual(Selection{ + .start = .{ .x = 5, .y = 1 }, + .end = .{ .x = 4, .y = 3 }, + }, sel); + } + + // Already at end of the line. + { + const sel = (Selection{ + .start = .{ .x = 5, .y = 1 }, + .end = .{ .x = 4, .y = 2 }, + }).adjust(&screen, .right); + + try testing.expectEqual(Selection{ + .start = .{ .x = 5, .y = 1 }, + .end = .{ .x = 0, .y = 3 }, + }, sel); + } + + // Already at end of the screen + { + const sel = (Selection{ + .start = .{ .x = 5, .y = 1 }, + .end = .{ .x = 4, .y = 3 }, + }).adjust(&screen, .right); + + try testing.expectEqual(Selection{ + .start = .{ .x = 5, .y = 1 }, + .end = .{ .x = 4, .y = 3 }, + }, sel); + } +} + +// X +test "Selection: adjust left" { + const testing = std.testing; + var screen = try Screen.init(testing.allocator, 5, 10, 0); + defer screen.deinit(); + try screen.testWriteString("A1234\nB5678\nC1234\nD5678"); + + // Simple movement left + { + const sel = (Selection{ + .start = .{ .x = 5, .y = 1 }, + .end = .{ .x = 3, .y = 3 }, + }).adjust(&screen, .left); + + // Start line + try testing.expectEqual(Selection{ + .start = .{ .x = 5, .y = 1 }, + .end = .{ .x = 2, .y = 3 }, + }, sel); + } + + // Already at beginning of the line. + { + const sel = (Selection{ + .start = .{ .x = 5, .y = 1 }, + .end = .{ .x = 0, .y = 3 }, + }).adjust(&screen, .left); + + // Start line + try testing.expectEqual(Selection{ + .start = .{ .x = 5, .y = 1 }, + .end = .{ .x = 4, .y = 2 }, + }, sel); + } +} + +// X +test "Selection: adjust left skips blanks" { + const testing = std.testing; + var screen = try Screen.init(testing.allocator, 5, 10, 0); + defer screen.deinit(); + try screen.testWriteString("A1234\nB5678\nC12\nD56"); + + // Same line + { + const sel = (Selection{ + .start = .{ .x = 5, .y = 1 }, + .end = .{ .x = 4, .y = 3 }, + }).adjust(&screen, .left); + + // Start line + try testing.expectEqual(Selection{ + .start = .{ .x = 5, .y = 1 }, + .end = .{ .x = 2, .y = 3 }, + }, sel); + } + + // Edge + { + const sel = (Selection{ + .start = .{ .x = 5, .y = 1 }, + .end = .{ .x = 0, .y = 3 }, + }).adjust(&screen, .left); + + // Start line + try testing.expectEqual(Selection{ + .start = .{ .x = 5, .y = 1 }, + .end = .{ .x = 2, .y = 2 }, + }, sel); + } +} + +// X +test "Selection: adjust up" { + const testing = std.testing; + var screen = try Screen.init(testing.allocator, 5, 10, 0); + defer screen.deinit(); + try screen.testWriteString("A\nB\nC\nD\nE"); + + // Not on the first line + { + const sel = (Selection{ + .start = .{ .x = 5, .y = 1 }, + .end = .{ .x = 3, .y = 3 }, + }).adjust(&screen, .up); + + // Start line + try testing.expectEqual(Selection{ + .start = .{ .x = 5, .y = 1 }, + .end = .{ .x = 3, .y = 2 }, + }, sel); + } + + // On the first line + { + const sel = (Selection{ + .start = .{ .x = 5, .y = 1 }, + .end = .{ .x = 3, .y = 0 }, + }).adjust(&screen, .up); + + // Start line + try testing.expectEqual(Selection{ + .start = .{ .x = 5, .y = 1 }, + .end = .{ .x = 0, .y = 0 }, + }, sel); + } +} + +// X +test "Selection: adjust down" { + const testing = std.testing; + var screen = try Screen.init(testing.allocator, 5, 10, 0); + defer screen.deinit(); + try screen.testWriteString("A\nB\nC\nD\nE"); + + // Not on the first line + { + const sel = (Selection{ + .start = .{ .x = 5, .y = 1 }, + .end = .{ .x = 3, .y = 3 }, + }).adjust(&screen, .down); + + // Start line + try testing.expectEqual(Selection{ + .start = .{ .x = 5, .y = 1 }, + .end = .{ .x = 3, .y = 4 }, + }, sel); + } + + // On the last line + { + const sel = (Selection{ + .start = .{ .x = 5, .y = 1 }, + .end = .{ .x = 3, .y = 4 }, + }).adjust(&screen, .down); + + // Start line + try testing.expectEqual(Selection{ + .start = .{ .x = 5, .y = 1 }, + .end = .{ .x = 9, .y = 4 }, + }, sel); + } +} + +// X +test "Selection: adjust down with not full screen" { + const testing = std.testing; + var screen = try Screen.init(testing.allocator, 5, 10, 0); + defer screen.deinit(); + try screen.testWriteString("A\nB\nC"); + + // On the last line + { + const sel = (Selection{ + .start = .{ .x = 5, .y = 1 }, + .end = .{ .x = 3, .y = 2 }, + }).adjust(&screen, .down); + + // Start line + try testing.expectEqual(Selection{ + .start = .{ .x = 5, .y = 1 }, + .end = .{ .x = 9, .y = 2 }, + }, sel); + } +} + +// X +test "Selection: contains" { + const testing = std.testing; + { + const sel: Selection = .{ + .start = .{ .x = 5, .y = 1 }, + .end = .{ .x = 3, .y = 2 }, + }; + + try testing.expect(sel.contains(.{ .x = 6, .y = 1 })); + try testing.expect(sel.contains(.{ .x = 1, .y = 2 })); + try testing.expect(!sel.contains(.{ .x = 1, .y = 1 })); + try testing.expect(!sel.contains(.{ .x = 5, .y = 2 })); + try testing.expect(!sel.containsRow(.{ .x = 1, .y = 3 })); + try testing.expect(sel.containsRow(.{ .x = 1, .y = 1 })); + try testing.expect(sel.containsRow(.{ .x = 5, .y = 2 })); + } + + // Reverse + { + const sel: Selection = .{ + .start = .{ .x = 3, .y = 2 }, + .end = .{ .x = 5, .y = 1 }, + }; + + try testing.expect(sel.contains(.{ .x = 6, .y = 1 })); + try testing.expect(sel.contains(.{ .x = 1, .y = 2 })); + try testing.expect(!sel.contains(.{ .x = 1, .y = 1 })); + try testing.expect(!sel.contains(.{ .x = 5, .y = 2 })); + } + + // Single line + { + const sel: Selection = .{ + .start = .{ .x = 5, .y = 1 }, + .end = .{ .x = 10, .y = 1 }, + }; + + try testing.expect(sel.contains(.{ .x = 6, .y = 1 })); + try testing.expect(!sel.contains(.{ .x = 2, .y = 1 })); + try testing.expect(!sel.contains(.{ .x = 12, .y = 1 })); + } +} + +// X +test "Selection: contains, rectangle" { + const testing = std.testing; + { + const sel: Selection = .{ + .start = .{ .x = 3, .y = 3 }, + .end = .{ .x = 7, .y = 9 }, + .rectangle = true, + }; + + try testing.expect(sel.contains(.{ .x = 5, .y = 6 })); // Center + try testing.expect(sel.contains(.{ .x = 3, .y = 6 })); // Left border + try testing.expect(sel.contains(.{ .x = 7, .y = 6 })); // Right border + try testing.expect(sel.contains(.{ .x = 5, .y = 3 })); // Top border + try testing.expect(sel.contains(.{ .x = 5, .y = 9 })); // Bottom border + + try testing.expect(!sel.contains(.{ .x = 5, .y = 2 })); // Above center + try testing.expect(!sel.contains(.{ .x = 5, .y = 10 })); // Below center + try testing.expect(!sel.contains(.{ .x = 2, .y = 6 })); // Left center + try testing.expect(!sel.contains(.{ .x = 8, .y = 6 })); // Right center + try testing.expect(!sel.contains(.{ .x = 8, .y = 3 })); // Just right of top right + try testing.expect(!sel.contains(.{ .x = 2, .y = 9 })); // Just left of bottom left + + try testing.expect(!sel.containsRow(.{ .x = 1, .y = 1 })); + try testing.expect(sel.containsRow(.{ .x = 1, .y = 3 })); // x does not matter + try testing.expect(sel.containsRow(.{ .x = 1, .y = 6 })); + try testing.expect(sel.containsRow(.{ .x = 5, .y = 9 })); + try testing.expect(!sel.containsRow(.{ .x = 5, .y = 10 })); + } + + // Reverse + { + const sel: Selection = .{ + .start = .{ .x = 7, .y = 9 }, + .end = .{ .x = 3, .y = 3 }, + .rectangle = true, + }; + + try testing.expect(sel.contains(.{ .x = 5, .y = 6 })); // Center + try testing.expect(sel.contains(.{ .x = 3, .y = 6 })); // Left border + try testing.expect(sel.contains(.{ .x = 7, .y = 6 })); // Right border + try testing.expect(sel.contains(.{ .x = 5, .y = 3 })); // Top border + try testing.expect(sel.contains(.{ .x = 5, .y = 9 })); // Bottom border + + try testing.expect(!sel.contains(.{ .x = 5, .y = 2 })); // Above center + try testing.expect(!sel.contains(.{ .x = 5, .y = 10 })); // Below center + try testing.expect(!sel.contains(.{ .x = 2, .y = 6 })); // Left center + try testing.expect(!sel.contains(.{ .x = 8, .y = 6 })); // Right center + try testing.expect(!sel.contains(.{ .x = 8, .y = 3 })); // Just right of top right + try testing.expect(!sel.contains(.{ .x = 2, .y = 9 })); // Just left of bottom left + + try testing.expect(!sel.containsRow(.{ .x = 1, .y = 1 })); + try testing.expect(sel.containsRow(.{ .x = 1, .y = 3 })); // x does not matter + try testing.expect(sel.containsRow(.{ .x = 1, .y = 6 })); + try testing.expect(sel.containsRow(.{ .x = 5, .y = 9 })); + try testing.expect(!sel.containsRow(.{ .x = 5, .y = 10 })); + } + + // Single line + // NOTE: This is the same as normal selection but we just do it for brevity + { + const sel: Selection = .{ + .start = .{ .x = 5, .y = 1 }, + .end = .{ .x = 10, .y = 1 }, + .rectangle = true, + }; + + try testing.expect(sel.contains(.{ .x = 6, .y = 1 })); + try testing.expect(!sel.contains(.{ .x = 2, .y = 1 })); + try testing.expect(!sel.contains(.{ .x = 12, .y = 1 })); + } +} + +test "Selection: containedRow" { + const testing = std.testing; + var screen = try Screen.init(testing.allocator, 5, 10, 0); + defer screen.deinit(); + + { + const sel: Selection = .{ + .start = .{ .x = 5, .y = 1 }, + .end = .{ .x = 3, .y = 3 }, + }; + + // Not contained + try testing.expect(sel.containedRow(&screen, .{ .x = 1, .y = 4 }) == null); + + // Start line + try testing.expectEqual(Selection{ + .start = sel.start, + .end = .{ .x = screen.cols - 1, .y = 1 }, + }, sel.containedRow(&screen, .{ .x = 1, .y = 1 }).?); + + // End line + try testing.expectEqual(Selection{ + .start = .{ .x = 0, .y = 3 }, + .end = sel.end, + }, sel.containedRow(&screen, .{ .x = 2, .y = 3 }).?); + + // Middle line + try testing.expectEqual(Selection{ + .start = .{ .x = 0, .y = 2 }, + .end = .{ .x = screen.cols - 1, .y = 2 }, + }, sel.containedRow(&screen, .{ .x = 2, .y = 2 }).?); + } + + // Rectangle + { + const sel: Selection = .{ + .start = .{ .x = 3, .y = 1 }, + .end = .{ .x = 6, .y = 3 }, + .rectangle = true, + }; + + // Not contained + try testing.expect(sel.containedRow(&screen, .{ .x = 1, .y = 4 }) == null); + + // Start line + try testing.expectEqual(Selection{ + .start = .{ .x = 3, .y = 1 }, + .end = .{ .x = 6, .y = 1 }, + .rectangle = true, + }, sel.containedRow(&screen, .{ .x = 1, .y = 1 }).?); + + // End line + try testing.expectEqual(Selection{ + .start = .{ .x = 3, .y = 3 }, + .end = .{ .x = 6, .y = 3 }, + .rectangle = true, + }, sel.containedRow(&screen, .{ .x = 2, .y = 3 }).?); + + // Middle line + try testing.expectEqual(Selection{ + .start = .{ .x = 3, .y = 2 }, + .end = .{ .x = 6, .y = 2 }, + .rectangle = true, + }, sel.containedRow(&screen, .{ .x = 2, .y = 2 }).?); + } + + // Single-line selection + { + const sel: Selection = .{ + .start = .{ .x = 2, .y = 1 }, + .end = .{ .x = 6, .y = 1 }, + }; + + // Not contained + try testing.expect(sel.containedRow(&screen, .{ .x = 1, .y = 0 }) == null); + try testing.expect(sel.containedRow(&screen, .{ .x = 1, .y = 2 }) == null); + + // Contained + try testing.expectEqual(Selection{ + .start = sel.start, + .end = sel.end, + }, sel.containedRow(&screen, .{ .x = 1, .y = 1 }).?); + } +} + +test "Selection: within" { + const testing = std.testing; + { + const sel: Selection = .{ + .start = .{ .x = 5, .y = 1 }, + .end = .{ .x = 3, .y = 2 }, + }; + + // Fully within + try testing.expect(sel.within(.{ .x = 6, .y = 0 }, .{ .x = 6, .y = 3 })); + try testing.expect(sel.within(.{ .x = 3, .y = 1 }, .{ .x = 6, .y = 3 })); + try testing.expect(sel.within(.{ .x = 3, .y = 0 }, .{ .x = 6, .y = 2 })); + + // Partially within + try testing.expect(sel.within(.{ .x = 1, .y = 2 }, .{ .x = 6, .y = 3 })); + try testing.expect(sel.within(.{ .x = 1, .y = 0 }, .{ .x = 6, .y = 1 })); + + // Not within at all + try testing.expect(!sel.within(.{ .x = 0, .y = 0 }, .{ .x = 4, .y = 1 })); + } +} + +// X +test "Selection: order, standard" { + const testing = std.testing; + { + // forward, multi-line + const sel: Selection = .{ + .start = .{ .x = 2, .y = 1 }, + .end = .{ .x = 2, .y = 2 }, + }; + + try testing.expect(sel.order() == .forward); + } + { + // reverse, multi-line + const sel: Selection = .{ + .start = .{ .x = 2, .y = 2 }, + .end = .{ .x = 2, .y = 1 }, + }; + + try testing.expect(sel.order() == .reverse); + } + { + // forward, same-line + const sel: Selection = .{ + .start = .{ .x = 2, .y = 1 }, + .end = .{ .x = 3, .y = 1 }, + }; + + try testing.expect(sel.order() == .forward); + } + { + // forward, single char + const sel: Selection = .{ + .start = .{ .x = 2, .y = 1 }, + .end = .{ .x = 2, .y = 1 }, + }; + + try testing.expect(sel.order() == .forward); + } + { + // reverse, single line + const sel: Selection = .{ + .start = .{ .x = 2, .y = 1 }, + .end = .{ .x = 1, .y = 1 }, + }; + + try testing.expect(sel.order() == .reverse); + } +} + +// X +test "Selection: order, rectangle" { + const testing = std.testing; + // Conventions: + // TL - top left + // BL - bottom left + // TR - top right + // BR - bottom right + { + // forward (TL -> BR) + const sel: Selection = .{ + .start = .{ .x = 1, .y = 1 }, + .end = .{ .x = 2, .y = 2 }, + .rectangle = true, + }; + + try testing.expect(sel.order() == .forward); + } + { + // reverse (BR -> TL) + const sel: Selection = .{ + .start = .{ .x = 2, .y = 2 }, + .end = .{ .x = 1, .y = 1 }, + .rectangle = true, + }; + + try testing.expect(sel.order() == .reverse); + } + { + // mirrored_forward (TR -> BL) + const sel: Selection = .{ + .start = .{ .x = 3, .y = 1 }, + .end = .{ .x = 1, .y = 3 }, + .rectangle = true, + }; + + try testing.expect(sel.order() == .mirrored_forward); + } + { + // mirrored_reverse (BL -> TR) + const sel: Selection = .{ + .start = .{ .x = 1, .y = 3 }, + .end = .{ .x = 3, .y = 1 }, + .rectangle = true, + }; + + try testing.expect(sel.order() == .mirrored_reverse); + } + { + // forward, single line (left -> right ) + const sel: Selection = .{ + .start = .{ .x = 1, .y = 1 }, + .end = .{ .x = 3, .y = 1 }, + .rectangle = true, + }; + + try testing.expect(sel.order() == .forward); + } + { + // reverse, single line (right -> left) + const sel: Selection = .{ + .start = .{ .x = 3, .y = 1 }, + .end = .{ .x = 1, .y = 1 }, + .rectangle = true, + }; + + try testing.expect(sel.order() == .reverse); + } + { + // forward, single column (top -> bottom) + const sel: Selection = .{ + .start = .{ .x = 2, .y = 1 }, + .end = .{ .x = 2, .y = 3 }, + .rectangle = true, + }; + + try testing.expect(sel.order() == .forward); + } + { + // reverse, single column (bottom -> top) + const sel: Selection = .{ + .start = .{ .x = 2, .y = 3 }, + .end = .{ .x = 2, .y = 1 }, + .rectangle = true, + }; + + try testing.expect(sel.order() == .reverse); + } + { + // forward, single cell + const sel: Selection = .{ + .start = .{ .x = 1, .y = 1 }, + .end = .{ .x = 1, .y = 1 }, + .rectangle = true, + }; + + try testing.expect(sel.order() == .forward); + } +} + +// X +test "topLeft" { + const testing = std.testing; + { + // forward + const sel: Selection = .{ + .start = .{ .x = 1, .y = 1 }, + .end = .{ .x = 3, .y = 1 }, + }; + const expected: ScreenPoint = .{ .x = 1, .y = 1 }; + try testing.expectEqual(sel.topLeft(), expected); + } + { + // reverse + const sel: Selection = .{ + .start = .{ .x = 3, .y = 1 }, + .end = .{ .x = 1, .y = 1 }, + }; + const expected: ScreenPoint = .{ .x = 1, .y = 1 }; + try testing.expectEqual(sel.topLeft(), expected); + } + { + // mirrored_forward + const sel: Selection = .{ + .start = .{ .x = 3, .y = 1 }, + .end = .{ .x = 1, .y = 3 }, + .rectangle = true, + }; + const expected: ScreenPoint = .{ .x = 1, .y = 1 }; + try testing.expectEqual(sel.topLeft(), expected); + } + { + // mirrored_reverse + const sel: Selection = .{ + .start = .{ .x = 1, .y = 3 }, + .end = .{ .x = 3, .y = 1 }, + .rectangle = true, + }; + const expected: ScreenPoint = .{ .x = 1, .y = 1 }; + try testing.expectEqual(sel.topLeft(), expected); + } +} + +// X +test "bottomRight" { + const testing = std.testing; + { + // forward + const sel: Selection = .{ + .start = .{ .x = 1, .y = 1 }, + .end = .{ .x = 3, .y = 1 }, + }; + const expected: ScreenPoint = .{ .x = 3, .y = 1 }; + try testing.expectEqual(sel.bottomRight(), expected); + } + { + // reverse + const sel: Selection = .{ + .start = .{ .x = 3, .y = 1 }, + .end = .{ .x = 1, .y = 1 }, + }; + const expected: ScreenPoint = .{ .x = 3, .y = 1 }; + try testing.expectEqual(sel.bottomRight(), expected); + } + { + // mirrored_forward + const sel: Selection = .{ + .start = .{ .x = 3, .y = 1 }, + .end = .{ .x = 1, .y = 3 }, + .rectangle = true, + }; + const expected: ScreenPoint = .{ .x = 3, .y = 3 }; + try testing.expectEqual(sel.bottomRight(), expected); + } + { + // mirrored_reverse + const sel: Selection = .{ + .start = .{ .x = 1, .y = 3 }, + .end = .{ .x = 3, .y = 1 }, + .rectangle = true, + }; + const expected: ScreenPoint = .{ .x = 3, .y = 3 }; + try testing.expectEqual(sel.bottomRight(), expected); + } +} + +// X +test "ordered" { + const testing = std.testing; + { + // forward + const sel: Selection = .{ + .start = .{ .x = 1, .y = 1 }, + .end = .{ .x = 3, .y = 1 }, + }; + const sel_reverse: Selection = .{ + .start = .{ .x = 3, .y = 1 }, + .end = .{ .x = 1, .y = 1 }, + }; + try testing.expectEqual(sel.ordered(.forward), sel); + try testing.expectEqual(sel.ordered(.reverse), sel_reverse); + try testing.expectEqual(sel.ordered(.mirrored_reverse), sel); + } + { + // reverse + const sel: Selection = .{ + .start = .{ .x = 3, .y = 1 }, + .end = .{ .x = 1, .y = 1 }, + }; + const sel_forward: Selection = .{ + .start = .{ .x = 1, .y = 1 }, + .end = .{ .x = 3, .y = 1 }, + }; + try testing.expectEqual(sel.ordered(.forward), sel_forward); + try testing.expectEqual(sel.ordered(.reverse), sel); + try testing.expectEqual(sel.ordered(.mirrored_forward), sel_forward); + } + { + // mirrored_forward + const sel: Selection = .{ + .start = .{ .x = 3, .y = 1 }, + .end = .{ .x = 1, .y = 3 }, + .rectangle = true, + }; + const sel_forward: Selection = .{ + .start = .{ .x = 1, .y = 1 }, + .end = .{ .x = 3, .y = 3 }, + .rectangle = true, + }; + const sel_reverse: Selection = .{ + .start = .{ .x = 3, .y = 3 }, + .end = .{ .x = 1, .y = 1 }, + .rectangle = true, + }; + try testing.expectEqual(sel.ordered(.forward), sel_forward); + try testing.expectEqual(sel.ordered(.reverse), sel_reverse); + try testing.expectEqual(sel.ordered(.mirrored_reverse), sel_forward); + } + { + // mirrored_reverse + const sel: Selection = .{ + .start = .{ .x = 1, .y = 3 }, + .end = .{ .x = 3, .y = 1 }, + .rectangle = true, + }; + const sel_forward: Selection = .{ + .start = .{ .x = 1, .y = 1 }, + .end = .{ .x = 3, .y = 3 }, + .rectangle = true, + }; + const sel_reverse: Selection = .{ + .start = .{ .x = 3, .y = 3 }, + .end = .{ .x = 1, .y = 1 }, + .rectangle = true, + }; + try testing.expectEqual(sel.ordered(.forward), sel_forward); + try testing.expectEqual(sel.ordered(.reverse), sel_reverse); + try testing.expectEqual(sel.ordered(.mirrored_forward), sel_forward); + } +} + +test "toViewport" { + const testing = std.testing; + var screen = try Screen.init(testing.allocator, 24, 80, 0); + defer screen.deinit(); + screen.viewport = 11; // Scroll us down a bit + { + // Not in viewport (null) + const sel: Selection = .{ + .start = .{ .x = 10, .y = 1 }, + .end = .{ .x = 3, .y = 3 }, + .rectangle = false, + }; + try testing.expectEqual(null, sel.toViewport(&screen)); + } + { + // In viewport + const sel: Selection = .{ + .start = .{ .x = 10, .y = 11 }, + .end = .{ .x = 3, .y = 13 }, + .rectangle = false, + }; + const want: Selection = .{ + .start = .{ .x = 10, .y = 0 }, + .end = .{ .x = 3, .y = 2 }, + .rectangle = false, + }; + try testing.expectEqual(want, sel.toViewport(&screen)); + } + { + // Top off viewport + const sel: Selection = .{ + .start = .{ .x = 10, .y = 1 }, + .end = .{ .x = 3, .y = 13 }, + .rectangle = false, + }; + const want: Selection = .{ + .start = .{ .x = 0, .y = 0 }, + .end = .{ .x = 3, .y = 2 }, + .rectangle = false, + }; + try testing.expectEqual(want, sel.toViewport(&screen)); + } + { + // Bottom off viewport + const sel: Selection = .{ + .start = .{ .x = 10, .y = 11 }, + .end = .{ .x = 3, .y = 40 }, + .rectangle = false, + }; + const want: Selection = .{ + .start = .{ .x = 10, .y = 0 }, + .end = .{ .x = 79, .y = 23 }, + .rectangle = false, + }; + try testing.expectEqual(want, sel.toViewport(&screen)); + } + { + // Both off viewport + const sel: Selection = .{ + .start = .{ .x = 10, .y = 1 }, + .end = .{ .x = 3, .y = 40 }, + .rectangle = false, + }; + const want: Selection = .{ + .start = .{ .x = 0, .y = 0 }, + .end = .{ .x = 79, .y = 23 }, + .rectangle = false, + }; + try testing.expectEqual(want, sel.toViewport(&screen)); + } + { + // Both off viewport (rectangle) + const sel: Selection = .{ + .start = .{ .x = 10, .y = 1 }, + .end = .{ .x = 3, .y = 40 }, + .rectangle = true, + }; + const want: Selection = .{ + .start = .{ .x = 10, .y = 0 }, + .end = .{ .x = 3, .y = 23 }, + .rectangle = true, + }; + try testing.expectEqual(want, sel.toViewport(&screen)); + } +} diff --git a/src/terminal/StringMap.zig b/src/terminal-old/StringMap.zig similarity index 100% rename from src/terminal/StringMap.zig rename to src/terminal-old/StringMap.zig diff --git a/src/terminal2/Tabstops.zig b/src/terminal-old/Tabstops.zig similarity index 100% rename from src/terminal2/Tabstops.zig rename to src/terminal-old/Tabstops.zig diff --git a/src/terminal2/Terminal.zig b/src/terminal-old/Terminal.zig similarity index 73% rename from src/terminal2/Terminal.zig rename to src/terminal-old/Terminal.zig index 94d33f734..5ff2591cb 100644 --- a/src/terminal2/Terminal.zig +++ b/src/terminal-old/Terminal.zig @@ -1,17 +1,15 @@ //! The primary terminal emulation structure. This represents a single +//! //! "terminal" containing a grid of characters and exposes various operations //! on that grid. This also maintains the scrollback buffer. const Terminal = @This(); -// TODO on new terminal branch: -// - page splitting -// - resize tests when multiple pages are required - const std = @import("std"); const builtin = @import("builtin"); -const assert = std.debug.assert; const testing = std.testing; +const assert = std.debug.assert; const Allocator = std.mem.Allocator; +const simd = @import("../simd/main.zig"); const unicode = @import("../unicode/main.zig"); const ansi = @import("ansi.zig"); @@ -22,15 +20,8 @@ const kitty = @import("kitty.zig"); const sgr = @import("sgr.zig"); const Tabstops = @import("Tabstops.zig"); const color = @import("color.zig"); -const mouse_shape = @import("mouse_shape.zig"); - -const size = @import("size.zig"); -const pagepkg = @import("page.zig"); -const style = @import("style.zig"); const Screen = @import("Screen.zig"); -const Page = pagepkg.Page; -const Cell = pagepkg.Cell; -const Row = pagepkg.Row; +const mouse_shape = @import("mouse_shape.zig"); const log = std.log.scoped(.terminal); @@ -43,6 +34,18 @@ pub const ScreenType = enum { alternate, }; +/// The semantic prompt type. This is used when tracking a line type and +/// requires integration with the shell. By default, we mark a line as "none" +/// meaning we don't know what type it is. +/// +/// See: https://gitlab.freedesktop.org/Per_Bothner/specifications/blob/master/proposals/semantic-prompts.md +pub const SemanticPrompt = enum { + prompt, + prompt_continuation, + input, + command, +}; + /// Screen is the current screen state. The "active_screen" field says what /// the current screen is. The backup screen is the opposite of the active /// screen. @@ -59,8 +62,8 @@ status_display: ansi.StatusDisplay = .main, tabstops: Tabstops, /// The size of the terminal. -rows: size.CellCountInt, -cols: size.CellCountInt, +rows: usize, +cols: usize, /// The size of the screen in pixels. This is used for pty events and images width_px: u32 = 0, @@ -149,26 +152,26 @@ pub const MouseFormat = enum(u3) { pub const ScrollingRegion = struct { // Top and bottom of the scroll region (0-indexed) // Precondition: top < bottom - top: size.CellCountInt, - bottom: size.CellCountInt, + top: usize, + bottom: usize, // Left/right scroll regions. // Precondition: right > left // Precondition: right <= cols - 1 - left: size.CellCountInt, - right: size.CellCountInt, + left: usize, + right: usize, }; /// Initialize a new terminal. -pub fn init(alloc: Allocator, cols: size.CellCountInt, rows: size.CellCountInt) !Terminal { +pub fn init(alloc: Allocator, cols: usize, rows: usize) !Terminal { return Terminal{ .cols = cols, .rows = rows, .active_screen = .primary, // TODO: configurable scrollback - .screen = try Screen.init(alloc, cols, rows, 10000), + .screen = try Screen.init(alloc, rows, cols, 10000), // No scrollback for the alternate screen - .secondary_screen = try Screen.init(alloc, cols, rows, 0), + .secondary_screen = try Screen.init(alloc, rows, cols, 0), .tabstops = try Tabstops.init(alloc, cols, TABSTOP_INTERVAL), .scrolling_region = .{ .top = 0, @@ -188,1806 +191,86 @@ pub fn deinit(self: *Terminal, alloc: Allocator) void { self.* = undefined; } -/// Print UTF-8 encoded string to the terminal. -pub fn printString(self: *Terminal, str: []const u8) !void { - const view = try std.unicode.Utf8View.init(str); - var it = view.iterator(); - while (it.nextCodepoint()) |cp| { - switch (cp) { - '\n' => { - self.carriageReturn(); - try self.linefeed(); - }, - - else => try self.print(cp), - } - } -} - -/// Print the previous printed character a repeated amount of times. -pub fn printRepeat(self: *Terminal, count_req: usize) !void { - if (self.previous_char) |c| { - const count = @max(count_req, 1); - for (0..count) |_| try self.print(c); - } -} - -pub fn print(self: *Terminal, c: u21) !void { - // log.debug("print={x} y={} x={}", .{ c, self.screen.cursor.y, self.screen.cursor.x }); - - // If we're not on the main display, do nothing for now - if (self.status_display != .main) return; - - // Our right margin depends where our cursor is now. - const right_limit = if (self.screen.cursor.x > self.scrolling_region.right) - self.cols - else - self.scrolling_region.right + 1; - - // Perform grapheme clustering if grapheme support is enabled (mode 2027). - // This is MUCH slower than the normal path so the conditional below is - // purposely ordered in least-likely to most-likely so we can drop out - // as quickly as possible. - if (c > 255 and - self.modes.get(.grapheme_cluster) and - self.screen.cursor.x > 0) - grapheme: { - // We need the previous cell to determine if we're at a grapheme - // break or not. If we are NOT, then we are still combining the - // same grapheme. Otherwise, we can stay in this cell. - const Prev = struct { cell: *Cell, left: size.CellCountInt }; - const prev: Prev = prev: { - const left: size.CellCountInt = left: { - // If we have wraparound, then we always use the prev col - if (self.modes.get(.wraparound)) break :left 1; - - // If we do not have wraparound, the logic is trickier. If - // we're not on the last column, then we just use the previous - // column. Otherwise, we need to check if there is text to - // figure out if we're attaching to the prev or current. - if (self.screen.cursor.x != right_limit - 1) break :left 1; - break :left @intFromBool(!self.screen.cursor.page_cell.hasText()); - }; - - // If the previous cell is a wide spacer tail, then we actually - // want to use the cell before that because that has the actual - // content. - const immediate = self.screen.cursorCellLeft(left); - break :prev switch (immediate.wide) { - else => .{ .cell = immediate, .left = left }, - .spacer_tail => .{ - .cell = self.screen.cursorCellLeft(left + 1), - .left = left + 1, - }, - }; - }; - - // If our cell has no content, then this is a new cell and - // necessarily a grapheme break. - if (!prev.cell.hasText()) break :grapheme; - - const grapheme_break = brk: { - var state: unicode.GraphemeBreakState = .{}; - var cp1: u21 = prev.cell.content.codepoint; - if (prev.cell.hasGrapheme()) { - const cps = self.screen.cursor.page_pin.page.data.lookupGrapheme(prev.cell).?; - for (cps) |cp2| { - // log.debug("cp1={x} cp2={x}", .{ cp1, cp2 }); - assert(!unicode.graphemeBreak(cp1, cp2, &state)); - cp1 = cp2; - } - } - - // log.debug("cp1={x} cp2={x} end", .{ cp1, c }); - break :brk unicode.graphemeBreak(cp1, c, &state); - }; - - // If we can NOT break, this means that "c" is part of a grapheme - // with the previous char. - if (!grapheme_break) { - // If this is an emoji variation selector then we need to modify - // the cell width accordingly. VS16 makes the character wide and - // VS15 makes it narrow. - if (c == 0xFE0F or c == 0xFE0E) { - // This only applies to emoji - const prev_props = unicode.getProperties(prev.cell.content.codepoint); - const emoji = prev_props.grapheme_boundary_class == .extended_pictographic; - if (!emoji) return; - - switch (c) { - 0xFE0F => wide: { - if (prev.cell.wide == .wide) break :wide; - - // Move our cursor back to the previous. We'll move - // the cursor within this block to the proper location. - self.screen.cursorLeft(prev.left); - - // If we don't have space for the wide char, we need - // to insert spacers and wrap. Then we just print the wide - // char as normal. - if (self.screen.cursor.x == right_limit - 1) { - if (!self.modes.get(.wraparound)) return; - self.printCell(' ', .spacer_head); - try self.printWrap(); - } - - self.printCell(prev.cell.content.codepoint, .wide); - - // Write our spacer - self.screen.cursorRight(1); - self.printCell(' ', .spacer_tail); - - // Move the cursor again so we're beyond our spacer - if (self.screen.cursor.x == right_limit - 1) { - self.screen.cursor.pending_wrap = true; - } else { - self.screen.cursorRight(1); - } - }, - - 0xFE0E => narrow: { - // Prev cell is no longer wide - if (prev.cell.wide != .wide) break :narrow; - prev.cell.wide = .narrow; - - // Remove the wide spacer tail - const cell = self.screen.cursorCellLeft(prev.left - 1); - cell.wide = .narrow; - - break :narrow; - }, - - else => unreachable, - } - } - - log.debug("c={x} grapheme attach to left={}", .{ c, prev.left }); - try self.screen.cursor.page_pin.page.data.appendGrapheme( - self.screen.cursor.page_row, - prev.cell, - c, - ); - return; - } - } - - // Determine the width of this character so we can handle - // non-single-width characters properly. We have a fast-path for - // byte-sized characters since they're so common. We can ignore - // control characters because they're always filtered prior. - const width: usize = if (c <= 0xFF) 1 else @intCast(unicode.table.get(c).width); - - // Note: it is possible to have a width of "3" and a width of "-1" - // from ziglyph. We should look into those cases and handle them - // appropriately. - assert(width <= 2); - // log.debug("c={x} width={}", .{ c, width }); - - // Attach zero-width characters to our cell as grapheme data. - if (width == 0) { - // If we have grapheme clustering enabled, we don't blindly attach - // any zero width character to our cells and we instead just ignore - // it. - if (self.modes.get(.grapheme_cluster)) return; - - // If we're at cell zero, then this is malformed data and we don't - // print anything or even store this. Zero-width characters are ALWAYS - // attached to some other non-zero-width character at the time of - // writing. - if (self.screen.cursor.x == 0) { - log.warn("zero-width character with no prior character, ignoring", .{}); - return; - } - - // Find our previous cell - const prev = prev: { - const immediate = self.screen.cursorCellLeft(1); - if (immediate.wide != .spacer_tail) break :prev immediate; - break :prev self.screen.cursorCellLeft(2); - }; - - // If our previous cell has no text, just ignore the zero-width character - if (!prev.hasText()) { - log.warn("zero-width character with no prior character, ignoring", .{}); - return; - } - - // If this is a emoji variation selector, prev must be an emoji - if (c == 0xFE0F or c == 0xFE0E) { - const prev_props = unicode.getProperties(prev.content.codepoint); - const emoji = prev_props.grapheme_boundary_class == .extended_pictographic; - if (!emoji) return; - } - - try self.screen.cursor.page_pin.page.data.appendGrapheme( - self.screen.cursor.page_row, - prev, - c, - ); - return; - } - - // We have a printable character, save it - self.previous_char = c; - - // If we're soft-wrapping, then handle that first. - if (self.screen.cursor.pending_wrap and self.modes.get(.wraparound)) { - try self.printWrap(); - } - - // If we have insert mode enabled then we need to handle that. We - // only do insert mode if we're not at the end of the line. - if (self.modes.get(.insert) and - self.screen.cursor.x + width < self.cols) - { - self.insertBlanks(width); - } - - switch (width) { - // Single cell is very easy: just write in the cell - 1 => @call(.always_inline, printCell, .{ self, c, .narrow }), - - // Wide character requires a spacer. We print this by - // using two cells: the first is flagged "wide" and has the - // wide char. The second is guaranteed to be a spacer if - // we're not at the end of the line. - 2 => if ((right_limit - self.scrolling_region.left) > 1) { - // If we don't have space for the wide char, we need - // to insert spacers and wrap. Then we just print the wide - // char as normal. - if (self.screen.cursor.x == right_limit - 1) { - // If we don't have wraparound enabled then we don't print - // this character at all and don't move the cursor. This is - // how xterm behaves. - if (!self.modes.get(.wraparound)) return; - - self.printCell(' ', .spacer_head); - try self.printWrap(); - } - - self.printCell(c, .wide); - self.screen.cursorRight(1); - self.printCell(' ', .spacer_tail); - } else { - // This is pretty broken, terminals should never be only 1-wide. - // We sould prevent this downstream. - self.printCell(' ', .narrow); - }, - - else => unreachable, - } - - // If we're at the column limit, then we need to wrap the next time. - // In this case, we don't move the cursor. - if (self.screen.cursor.x == right_limit - 1) { - self.screen.cursor.pending_wrap = true; - return; - } - - // Move the cursor - self.screen.cursorRight(1); -} - -fn printCell( - self: *Terminal, - unmapped_c: u21, - wide: Cell.Wide, -) void { - // TODO: spacers should use a bgcolor only cell - - const c: u21 = c: { - // TODO: non-utf8 handling, gr - - // If we're single shifting, then we use the key exactly once. - const key = if (self.screen.charset.single_shift) |key_once| blk: { - self.screen.charset.single_shift = null; - break :blk key_once; - } else self.screen.charset.gl; - const set = self.screen.charset.charsets.get(key); - - // UTF-8 or ASCII is used as-is - if (set == .utf8 or set == .ascii) break :c unmapped_c; - - // If we're outside of ASCII range this is an invalid value in - // this table so we just return space. - if (unmapped_c > std.math.maxInt(u8)) break :c ' '; - - // Get our lookup table and map it - const table = set.table(); - break :c @intCast(table[@intCast(unmapped_c)]); - }; - - const cell = self.screen.cursor.page_cell; - - // If the wide property of this cell is the same, then we don't - // need to do the special handling here because the structure will - // be the same. If it is NOT the same, then we may need to clear some - // cells. - if (cell.wide != wide) { - switch (cell.wide) { - // Previous cell was narrow. Do nothing. - .narrow => {}, - - // Previous cell was wide. We need to clear the tail and head. - .wide => wide: { - if (self.screen.cursor.x >= self.cols - 1) break :wide; - - const spacer_cell = self.screen.cursorCellRight(1); - spacer_cell.* = .{ .style_id = self.screen.cursor.style_id }; - if (self.screen.cursor.y > 0 and self.screen.cursor.x <= 1) { - const head_cell = self.screen.cursorCellEndOfPrev(); - head_cell.wide = .narrow; - } - }, - - .spacer_tail => { - assert(self.screen.cursor.x > 0); - - const wide_cell = self.screen.cursorCellLeft(1); - wide_cell.* = .{ .style_id = self.screen.cursor.style_id }; - if (self.screen.cursor.y > 0 and self.screen.cursor.x <= 1) { - const head_cell = self.screen.cursorCellEndOfPrev(); - head_cell.wide = .narrow; - } - }, - - // TODO: this case was not handled in the old terminal implementation - // but it feels like we should do something. investigate other - // terminals (xterm mainly) and see whats up. - .spacer_head => {}, - } - } - - // If the prior value had graphemes, clear those - if (cell.hasGrapheme()) { - self.screen.cursor.page_pin.page.data.clearGrapheme( - self.screen.cursor.page_row, - cell, - ); - } - - // Keep track of the previous style so we can decrement the ref count - const prev_style_id = cell.style_id; - - // Write - cell.* = .{ - .content_tag = .codepoint, - .content = .{ .codepoint = c }, - .style_id = self.screen.cursor.style_id, - .wide = wide, - .protected = self.screen.cursor.protected, - }; - - // Handle the style ref count handling - style_ref: { - if (prev_style_id != style.default_id) { - const row = self.screen.cursor.page_row; - assert(row.styled); - - // If our previous cell had the same style ID as us currently, - // then we don't bother with any ref counts because we're the same. - if (prev_style_id == self.screen.cursor.style_id) break :style_ref; - - // Slow path: we need to lookup this style so we can decrement - // the ref count. Since we've already loaded everything, we also - // just go ahead and GC it if it reaches zero, too. - var page = &self.screen.cursor.page_pin.page.data; - if (page.styles.lookupId(page.memory, prev_style_id)) |prev_style| { - // Below upsert can't fail because it should already be present - const md = page.styles.upsert(page.memory, prev_style.*) catch unreachable; - assert(md.ref > 0); - md.ref -= 1; - if (md.ref == 0) page.styles.remove(page.memory, prev_style_id); - } - } - - // If we have a ref-counted style, increase. - if (self.screen.cursor.style_ref) |ref| { - ref.* += 1; - self.screen.cursor.page_row.styled = true; - } - } -} - -fn printWrap(self: *Terminal) !void { - self.screen.cursor.page_row.wrap = true; - - // Get the old semantic prompt so we can extend it to the next - // line. We need to do this before we index() because we may - // modify memory. - const old_prompt = self.screen.cursor.page_row.semantic_prompt; - - // Move to the next line - try self.index(); - self.screen.cursorHorizontalAbsolute(self.scrolling_region.left); - - // New line must inherit semantic prompt of the old line - self.screen.cursor.page_row.semantic_prompt = old_prompt; - self.screen.cursor.page_row.wrap_continuation = true; -} - -/// Set the charset into the given slot. -pub fn configureCharset(self: *Terminal, slot: charsets.Slots, set: charsets.Charset) void { - self.screen.charset.charsets.set(slot, set); -} - -/// Invoke the charset in slot into the active slot. If single is true, -/// then this will only be invoked for a single character. -pub fn invokeCharset( - self: *Terminal, - active: charsets.ActiveSlot, - slot: charsets.Slots, - single: bool, -) void { - if (single) { - assert(active == .GL); - self.screen.charset.single_shift = slot; - return; - } - - switch (active) { - .GL => self.screen.charset.gl = slot, - .GR => self.screen.charset.gr = slot, - } -} - -/// Carriage return moves the cursor to the first column. -pub fn carriageReturn(self: *Terminal) void { - // Always reset pending wrap state - self.screen.cursor.pending_wrap = false; - - // In origin mode we always move to the left margin - self.screen.cursorHorizontalAbsolute(if (self.modes.get(.origin)) - self.scrolling_region.left - else if (self.screen.cursor.x >= self.scrolling_region.left) - self.scrolling_region.left - else - 0); -} - -/// Linefeed moves the cursor to the next line. -pub fn linefeed(self: *Terminal) !void { - try self.index(); - if (self.modes.get(.linefeed)) self.carriageReturn(); -} - -/// Backspace moves the cursor back a column (but not less than 0). -pub fn backspace(self: *Terminal) void { - self.cursorLeft(1); -} - -/// Move the cursor up amount lines. If amount is greater than the maximum -/// move distance then it is internally adjusted to the maximum. If amount is -/// 0, adjust it to 1. -pub fn cursorUp(self: *Terminal, count_req: usize) void { - // Always resets pending wrap - self.screen.cursor.pending_wrap = false; - - // The maximum amount the cursor can move up depends on scrolling regions - const max = if (self.screen.cursor.y >= self.scrolling_region.top) - self.screen.cursor.y - self.scrolling_region.top - else - self.screen.cursor.y; - const count = @min(max, @max(count_req, 1)); - - // We can safely intCast below because of the min/max clamping we did above. - self.screen.cursorUp(@intCast(count)); -} - -/// Move the cursor down amount lines. If amount is greater than the maximum -/// move distance then it is internally adjusted to the maximum. This sequence -/// will not scroll the screen or scroll region. If amount is 0, adjust it to 1. -pub fn cursorDown(self: *Terminal, count_req: usize) void { - // Always resets pending wrap - self.screen.cursor.pending_wrap = false; - - // The max the cursor can move to depends where the cursor currently is - const max = if (self.screen.cursor.y <= self.scrolling_region.bottom) - self.scrolling_region.bottom - self.screen.cursor.y - else - self.rows - self.screen.cursor.y - 1; - const count = @min(max, @max(count_req, 1)); - self.screen.cursorDown(@intCast(count)); -} - -/// Move the cursor right amount columns. If amount is greater than the -/// maximum move distance then it is internally adjusted to the maximum. -/// This sequence will not scroll the screen or scroll region. If amount is -/// 0, adjust it to 1. -pub fn cursorRight(self: *Terminal, count_req: usize) void { - // Always resets pending wrap - self.screen.cursor.pending_wrap = false; - - // The max the cursor can move to depends where the cursor currently is - const max = if (self.screen.cursor.x <= self.scrolling_region.right) - self.scrolling_region.right - self.screen.cursor.x - else - self.cols - self.screen.cursor.x - 1; - const count = @min(max, @max(count_req, 1)); - self.screen.cursorRight(@intCast(count)); -} - -/// Move the cursor to the left amount cells. If amount is 0, adjust it to 1. -pub fn cursorLeft(self: *Terminal, count_req: usize) void { - // Wrapping behavior depends on various terminal modes - const WrapMode = enum { none, reverse, reverse_extended }; - const wrap_mode: WrapMode = wrap_mode: { - if (!self.modes.get(.wraparound)) break :wrap_mode .none; - if (self.modes.get(.reverse_wrap_extended)) break :wrap_mode .reverse_extended; - if (self.modes.get(.reverse_wrap)) break :wrap_mode .reverse; - break :wrap_mode .none; - }; - - var count = @max(count_req, 1); - - // If we are in no wrap mode, then we move the cursor left and exit - // since this is the fastest and most typical path. - if (wrap_mode == .none) { - self.screen.cursorLeft(@min(count, self.screen.cursor.x)); - self.screen.cursor.pending_wrap = false; - return; - } - - // If we have a pending wrap state and we are in either reverse wrap - // modes then we decrement the amount we move by one to match xterm. - if (self.screen.cursor.pending_wrap) { - count -= 1; - self.screen.cursor.pending_wrap = false; - } - - // The margins we can move to. - const top = self.scrolling_region.top; - const bottom = self.scrolling_region.bottom; - const right_margin = self.scrolling_region.right; - const left_margin = if (self.screen.cursor.x < self.scrolling_region.left) - 0 - else - self.scrolling_region.left; - - // Handle some edge cases when our cursor is already on the left margin. - if (self.screen.cursor.x == left_margin) { - switch (wrap_mode) { - // In reverse mode, if we're already before the top margin - // then we just set our cursor to the top-left and we're done. - .reverse => if (self.screen.cursor.y <= top) { - self.screen.cursorAbsolute(left_margin, top); - return; - }, - - // Handled in while loop - .reverse_extended => {}, - - // Handled above - .none => unreachable, - } - } - - while (true) { - // We can move at most to the left margin. - const max = self.screen.cursor.x - left_margin; - - // We want to move at most the number of columns we have left - // or our remaining count. Do the move. - const amount = @min(max, count); - count -= amount; - self.screen.cursorLeft(amount); - - // If we have no more to move, then we're done. - if (count == 0) break; - - // If we are at the top, then we are done. - if (self.screen.cursor.y == top) { - if (wrap_mode != .reverse_extended) break; - - self.screen.cursorAbsolute(right_margin, bottom); - count -= 1; - continue; - } - - // UNDEFINED TERMINAL BEHAVIOR. This situation is not handled in xterm - // and currently results in a crash in xterm. Given no other known - // terminal [to me] implements XTREVWRAP2, I decided to just mimick - // the behavior of xterm up and not including the crash by wrapping - // up to the (0, 0) and stopping there. My reasoning is that for an - // appropriately sized value of "count" this is the behavior that xterm - // would have. This is unit tested. - if (self.screen.cursor.y == 0) { - assert(self.screen.cursor.x == left_margin); - break; - } - - // If our previous line is not wrapped then we are done. - if (wrap_mode != .reverse_extended) { - const prev_row = self.screen.cursorRowUp(1); - if (!prev_row.wrap) break; - } - - self.screen.cursorAbsolute(right_margin, self.screen.cursor.y - 1); - count -= 1; - } -} - -/// Save cursor position and further state. -/// -/// The primary and alternate screen have distinct save state. One saved state -/// is kept per screen (main / alternative). If for the current screen state -/// was already saved it is overwritten. -pub fn saveCursor(self: *Terminal) void { - self.screen.saved_cursor = .{ - .x = self.screen.cursor.x, - .y = self.screen.cursor.y, - .style = self.screen.cursor.style, - .protected = self.screen.cursor.protected, - .pending_wrap = self.screen.cursor.pending_wrap, - .origin = self.modes.get(.origin), - .charset = self.screen.charset, - }; -} - -/// Restore cursor position and other state. -/// -/// The primary and alternate screen have distinct save state. -/// If no save was done before values are reset to their initial values. -pub fn restoreCursor(self: *Terminal) !void { - const saved: Screen.SavedCursor = self.screen.saved_cursor orelse .{ - .x = 0, - .y = 0, - .style = .{}, - .protected = false, - .pending_wrap = false, - .origin = false, - .charset = .{}, - }; - - // Set the style first because it can fail - const old_style = self.screen.cursor.style; - self.screen.cursor.style = saved.style; - errdefer self.screen.cursor.style = old_style; - try self.screen.manualStyleUpdate(); - - self.screen.charset = saved.charset; - self.modes.set(.origin, saved.origin); - self.screen.cursor.pending_wrap = saved.pending_wrap; - self.screen.cursor.protected = saved.protected; - self.screen.cursorAbsolute( - @min(saved.x, self.cols - 1), - @min(saved.y, self.rows - 1), - ); -} - -/// Set the character protection mode for the terminal. -pub fn setProtectedMode(self: *Terminal, mode: ansi.ProtectedMode) void { - switch (mode) { - .off => { - self.screen.cursor.protected = false; - - // screen.protected_mode is NEVER reset to ".off" because - // logic such as eraseChars depends on knowing what the - // _most recent_ mode was. - }, - - .iso => { - self.screen.cursor.protected = true; - self.screen.protected_mode = .iso; - }, - - .dec => { - self.screen.cursor.protected = true; - self.screen.protected_mode = .dec; - }, - } -} - -/// The semantic prompt type. This is used when tracking a line type and -/// requires integration with the shell. By default, we mark a line as "none" -/// meaning we don't know what type it is. -/// -/// See: https://gitlab.freedesktop.org/Per_Bothner/specifications/blob/master/proposals/semantic-prompts.md -pub const SemanticPrompt = enum { - prompt, - prompt_continuation, - input, - command, +/// Options for switching to the alternate screen. +pub const AlternateScreenOptions = struct { + cursor_save: bool = false, + clear_on_enter: bool = false, + clear_on_exit: bool = false, }; -/// Mark the current semantic prompt information. Current escape sequences -/// (OSC 133) only allow setting this for wherever the current active cursor -/// is located. -pub fn markSemanticPrompt(self: *Terminal, p: SemanticPrompt) void { - //log.debug("semantic_prompt y={} p={}", .{ self.screen.cursor.y, p }); - self.screen.cursor.page_row.semantic_prompt = switch (p) { - .prompt => .prompt, - .prompt_continuation => .prompt_continuation, - .input => .input, - .command => .command, - }; -} - -/// Returns true if the cursor is currently at a prompt. Another way to look -/// at this is it returns false if the shell is currently outputting something. -/// This requires shell integration (semantic prompt integration). +/// Switch to the alternate screen buffer. /// -/// If the shell integration doesn't exist, this will always return false. -pub fn cursorIsAtPrompt(self: *Terminal) bool { - // If we're on the secondary screen, we're never at a prompt. - if (self.active_screen == .alternate) return false; - - // Reverse through the active - const start_x, const start_y = .{ self.screen.cursor.x, self.screen.cursor.y }; - defer self.screen.cursorAbsolute(start_x, start_y); - - for (0..start_y + 1) |i| { - if (i > 0) self.screen.cursorUp(1); - switch (self.screen.cursor.page_row.semantic_prompt) { - // If we're at a prompt or input area, then we are at a prompt. - .prompt, - .prompt_continuation, - .input, - => return true, - - // If we have command output, then we're most certainly not - // at a prompt. - .command => return false, - - // If we don't know, we keep searching. - .unknown => {}, - } - } - - return false; -} - -/// Horizontal tab moves the cursor to the next tabstop, clearing -/// the screen to the left the tabstop. -pub fn horizontalTab(self: *Terminal) !void { - while (self.screen.cursor.x < self.scrolling_region.right) { - // Move the cursor right - self.screen.cursorRight(1); - - // If the last cursor position was a tabstop we return. We do - // "last cursor position" because we want a space to be written - // at the tabstop unless we're at the end (the while condition). - if (self.tabstops.get(self.screen.cursor.x)) return; - } -} - -// Same as horizontalTab but moves to the previous tabstop instead of the next. -pub fn horizontalTabBack(self: *Terminal) !void { - // With origin mode enabled, our leftmost limit is the left margin. - const left_limit = if (self.modes.get(.origin)) self.scrolling_region.left else 0; - - while (true) { - // If we're already at the edge of the screen, then we're done. - if (self.screen.cursor.x <= left_limit) return; - - // Move the cursor left - self.screen.cursorLeft(1); - if (self.tabstops.get(self.screen.cursor.x)) return; - } -} - -/// Clear tab stops. -pub fn tabClear(self: *Terminal, cmd: csi.TabClear) void { - switch (cmd) { - .current => self.tabstops.unset(self.screen.cursor.x), - .all => self.tabstops.reset(0), - else => log.warn("invalid or unknown tab clear setting: {}", .{cmd}), - } -} - -/// Set a tab stop on the current cursor. -/// TODO: test -pub fn tabSet(self: *Terminal) void { - self.tabstops.set(self.screen.cursor.x); -} - -/// TODO: test -pub fn tabReset(self: *Terminal) void { - self.tabstops.reset(TABSTOP_INTERVAL); -} - -/// Move the cursor to the next line in the scrolling region, possibly scrolling. +/// The alternate screen buffer: +/// * has its own grid +/// * has its own cursor state (included saved cursor) +/// * does not support scrollback /// -/// If the cursor is outside of the scrolling region: move the cursor one line -/// down if it is not on the bottom-most line of the screen. -/// -/// If the cursor is inside the scrolling region: -/// If the cursor is on the bottom-most line of the scrolling region: -/// invoke scroll up with amount=1 -/// If the cursor is not on the bottom-most line of the scrolling region: -/// move the cursor one line down -/// -/// This unsets the pending wrap state without wrapping. -pub fn index(self: *Terminal) !void { - // Unset pending wrap state - self.screen.cursor.pending_wrap = false; - - // Outside of the scroll region we move the cursor one line down. - if (self.screen.cursor.y < self.scrolling_region.top or - self.screen.cursor.y > self.scrolling_region.bottom) - { - // We only move down if we're not already at the bottom of - // the screen. - if (self.screen.cursor.y < self.rows - 1) { - self.screen.cursorDown(1); - } - - return; - } - - // If the cursor is inside the scrolling region and on the bottom-most - // line, then we scroll up. If our scrolling region is the full screen - // we create scrollback. - if (self.screen.cursor.y == self.scrolling_region.bottom and - self.screen.cursor.x >= self.scrolling_region.left and - self.screen.cursor.x <= self.scrolling_region.right) - { - // If our scrolling region is the full screen, we create scrollback. - // Otherwise, we simply scroll the region. - if (self.scrolling_region.top == 0 and - self.scrolling_region.bottom == self.rows - 1 and - self.scrolling_region.left == 0 and - self.scrolling_region.right == self.cols - 1) - { - try self.screen.cursorDownScroll(); - } else { - self.scrollUp(1); - } - - return; - } - - // Increase cursor by 1, maximum to bottom of scroll region - if (self.screen.cursor.y < self.scrolling_region.bottom) { - self.screen.cursorDown(1); - } -} - -/// Move the cursor to the previous line in the scrolling region, possibly -/// scrolling. -/// -/// If the cursor is outside of the scrolling region, move the cursor one -/// line up if it is not on the top-most line of the screen. -/// -/// If the cursor is inside the scrolling region: -/// -/// * If the cursor is on the top-most line of the scrolling region: -/// invoke scroll down with amount=1 -/// * If the cursor is not on the top-most line of the scrolling region: -/// move the cursor one line up -pub fn reverseIndex(self: *Terminal) void { - if (self.screen.cursor.y != self.scrolling_region.top or - self.screen.cursor.x < self.scrolling_region.left or - self.screen.cursor.x > self.scrolling_region.right) - { - self.cursorUp(1); - return; - } - - self.scrollDown(1); -} - -// Set Cursor Position. Move cursor to the position indicated -// by row and column (1-indexed). If column is 0, it is adjusted to 1. -// If column is greater than the right-most column it is adjusted to -// the right-most column. If row is 0, it is adjusted to 1. If row is -// greater than the bottom-most row it is adjusted to the bottom-most -// row. -pub fn setCursorPos(self: *Terminal, row_req: usize, col_req: usize) void { - // If cursor origin mode is set the cursor row will be moved relative to - // the top margin row and adjusted to be above or at bottom-most row in - // the current scroll region. - // - // If origin mode is set and left and right margin mode is set the cursor - // will be moved relative to the left margin column and adjusted to be on - // or left of the right margin column. - const params: struct { - x_offset: size.CellCountInt = 0, - y_offset: size.CellCountInt = 0, - x_max: size.CellCountInt, - y_max: size.CellCountInt, - } = if (self.modes.get(.origin)) .{ - .x_offset = self.scrolling_region.left, - .y_offset = self.scrolling_region.top, - .x_max = self.scrolling_region.right + 1, // We need this 1-indexed - .y_max = self.scrolling_region.bottom + 1, // We need this 1-indexed - } else .{ - .x_max = self.cols, - .y_max = self.rows, - }; - - // Unset pending wrap state - self.screen.cursor.pending_wrap = false; - - // Calculate our new x/y - const row = if (row_req == 0) 1 else row_req; - const col = if (col_req == 0) 1 else col_req; - const x = @min(params.x_max, col + params.x_offset) -| 1; - const y = @min(params.y_max, row + params.y_offset) -| 1; - - // If the y is unchanged then this is fast pointer math - if (y == self.screen.cursor.y) { - if (x > self.screen.cursor.x) { - self.screen.cursorRight(x - self.screen.cursor.x); - } else { - self.screen.cursorLeft(self.screen.cursor.x - x); - } - - return; - } - - // If everything changed we do an absolute change which is slightly slower - self.screen.cursorAbsolute(x, y); - // log.info("set cursor position: col={} row={}", .{ self.screen.cursor.x, self.screen.cursor.y }); -} - -/// Set Top and Bottom Margins If bottom is not specified, 0 or bigger than -/// the number of the bottom-most row, it is adjusted to the number of the -/// bottom most row. -/// -/// If top < bottom set the top and bottom row of the scroll region according -/// to top and bottom and move the cursor to the top-left cell of the display -/// (when in cursor origin mode is set to the top-left cell of the scroll region). -/// -/// Otherwise: Set the top and bottom row of the scroll region to the top-most -/// and bottom-most line of the screen. -/// -/// Top and bottom are 1-indexed. -pub fn setTopAndBottomMargin(self: *Terminal, top_req: usize, bottom_req: usize) void { - const top = @max(1, top_req); - const bottom = @min(self.rows, if (bottom_req == 0) self.rows else bottom_req); - if (top >= bottom) return; - - self.scrolling_region.top = @intCast(top - 1); - self.scrolling_region.bottom = @intCast(bottom - 1); - self.setCursorPos(1, 1); -} - -/// DECSLRM -pub fn setLeftAndRightMargin(self: *Terminal, left_req: usize, right_req: usize) void { - // We must have this mode enabled to do anything - if (!self.modes.get(.enable_left_and_right_margin)) return; - - const left = @max(1, left_req); - const right = @min(self.cols, if (right_req == 0) self.cols else right_req); - if (left >= right) return; - - self.scrolling_region.left = @intCast(left - 1); - self.scrolling_region.right = @intCast(right - 1); - self.setCursorPos(1, 1); -} - -/// Scroll the text down by one row. -pub fn scrollDown(self: *Terminal, count: usize) void { - // Preserve our x/y to restore. - const old_x = self.screen.cursor.x; - const old_y = self.screen.cursor.y; - const old_wrap = self.screen.cursor.pending_wrap; - defer { - self.screen.cursorAbsolute(old_x, old_y); - self.screen.cursor.pending_wrap = old_wrap; - } - - // Move to the top of the scroll region - self.screen.cursorAbsolute(self.scrolling_region.left, self.scrolling_region.top); - self.insertLines(count); -} - -/// Removes amount lines from the top of the scroll region. The remaining lines -/// to the bottom margin are shifted up and space from the bottom margin up -/// is filled with empty lines. -/// -/// The new lines are created according to the current SGR state. -/// -/// Does not change the (absolute) cursor position. -pub fn scrollUp(self: *Terminal, count: usize) void { - // Preserve our x/y to restore. - const old_x = self.screen.cursor.x; - const old_y = self.screen.cursor.y; - const old_wrap = self.screen.cursor.pending_wrap; - defer { - self.screen.cursorAbsolute(old_x, old_y); - self.screen.cursor.pending_wrap = old_wrap; - } - - // Move to the top of the scroll region - self.screen.cursorAbsolute(self.scrolling_region.left, self.scrolling_region.top); - self.deleteLines(count); -} - -/// Options for scrolling the viewport of the terminal grid. -pub const ScrollViewport = union(enum) { - /// Scroll to the top of the scrollback - top: void, - - /// Scroll to the bottom, i.e. the top of the active area - bottom: void, - - /// Scroll by some delta amount, up is negative. - delta: isize, -}; - -/// Scroll the viewport of the terminal grid. -pub fn scrollViewport(self: *Terminal, behavior: ScrollViewport) !void { - self.screen.scroll(switch (behavior) { - .top => .{ .top = {} }, - .bottom => .{ .active = {} }, - .delta => |delta| .{ .delta_row = delta }, - }); -} - -/// Insert amount lines at the current cursor row. The contents of the line -/// at the current cursor row and below (to the bottom-most line in the -/// scrolling region) are shifted down by amount lines. The contents of the -/// amount bottom-most lines in the scroll region are lost. -/// -/// This unsets the pending wrap state without wrapping. If the current cursor -/// position is outside of the current scroll region it does nothing. -/// -/// If amount is greater than the remaining number of lines in the scrolling -/// region it is adjusted down (still allowing for scrolling out every remaining -/// line in the scrolling region) -/// -/// In left and right margin mode the margins are respected; lines are only -/// scrolled in the scroll region. -/// -/// All cleared space is colored according to the current SGR state. -/// -/// Moves the cursor to the left margin. -pub fn insertLines(self: *Terminal, count: usize) void { - // Rare, but happens - if (count == 0) return; - - // If the cursor is outside the scroll region we do nothing. - if (self.screen.cursor.y < self.scrolling_region.top or - self.screen.cursor.y > self.scrolling_region.bottom or - self.screen.cursor.x < self.scrolling_region.left or - self.screen.cursor.x > self.scrolling_region.right) return; - - // Remaining rows from our cursor to the bottom of the scroll region. - const rem = self.scrolling_region.bottom - self.screen.cursor.y + 1; - - // We can only insert lines up to our remaining lines in the scroll - // region. So we take whichever is smaller. - const adjusted_count = @min(count, rem); - - // top is just the cursor position. insertLines starts at the cursor - // so this is our top. We want to shift lines down, down to the bottom - // of the scroll region. - const top: [*]Row = @ptrCast(self.screen.cursor.page_row); - - // This is the amount of space at the bottom of the scroll region - // that will NOT be blank, so we need to shift the correct lines down. - // "scroll_amount" is the number of such lines. - const scroll_amount = rem - adjusted_count; - if (scroll_amount > 0) { - var y: [*]Row = top + (scroll_amount - 1); - - // TODO: detect active area split across multiple pages - - // If we have left/right scroll margins we have a slower path. - const left_right = self.scrolling_region.left > 0 or - self.scrolling_region.right < self.cols - 1; - - // We work backwards so we don't overwrite data. - while (@intFromPtr(y) >= @intFromPtr(top)) : (y -= 1) { - const src: *Row = @ptrCast(y); - const dst: *Row = @ptrCast(y + adjusted_count); - - if (!left_right) { - // Swap the src/dst cells. This ensures that our dst gets the proper - // shifted rows and src gets non-garbage cell data that we can clear. - const dst_row = dst.*; - dst.* = src.*; - src.* = dst_row; - continue; - } - - // Left/right scroll margins we have to copy cells, which is much slower... - var page = &self.screen.cursor.page_pin.page.data; - page.moveCells( - src, - self.scrolling_region.left, - dst, - self.scrolling_region.left, - (self.scrolling_region.right - self.scrolling_region.left) + 1, - ); - } - } - - // Inserted lines should keep our bg color - for (0..adjusted_count) |i| { - const row: *Row = @ptrCast(top + i); - - // Clear the src row. - var page = &self.screen.cursor.page_pin.page.data; - const cells = page.getCells(row); - const cells_write = cells[self.scrolling_region.left .. self.scrolling_region.right + 1]; - self.screen.clearCells(page, row, cells_write); - } - - // Move the cursor to the left margin. But importantly this also - // forces screen.cursor.page_cell to reload because the rows above - // shifted cell ofsets so this will ensure the cursor is pointing - // to the correct cell. - self.screen.cursorAbsolute( - self.scrolling_region.left, - self.screen.cursor.y, - ); - - // Always unset pending wrap - self.screen.cursor.pending_wrap = false; -} - -/// Removes amount lines from the current cursor row down. The remaining lines -/// to the bottom margin are shifted up and space from the bottom margin up is -/// filled with empty lines. -/// -/// If the current cursor position is outside of the current scroll region it -/// does nothing. If amount is greater than the remaining number of lines in the -/// scrolling region it is adjusted down. -/// -/// In left and right margin mode the margins are respected; lines are only -/// scrolled in the scroll region. -/// -/// If the cell movement splits a multi cell character that character cleared, -/// by replacing it by spaces, keeping its current attributes. All other -/// cleared space is colored according to the current SGR state. -/// -/// Moves the cursor to the left margin. -pub fn deleteLines(self: *Terminal, count_req: usize) void { - // If the cursor is outside the scroll region we do nothing. - if (self.screen.cursor.y < self.scrolling_region.top or - self.screen.cursor.y > self.scrolling_region.bottom or - self.screen.cursor.x < self.scrolling_region.left or - self.screen.cursor.x > self.scrolling_region.right) return; - - // top is just the cursor position. insertLines starts at the cursor - // so this is our top. We want to shift lines down, down to the bottom - // of the scroll region. - const top: [*]Row = @ptrCast(self.screen.cursor.page_row); - var y: [*]Row = top; - - // Remaining rows from our cursor to the bottom of the scroll region. - const rem = self.scrolling_region.bottom - self.screen.cursor.y + 1; - - // The maximum we can delete is the remaining lines in the scroll region. - const count = @min(count_req, rem); - - // This is the amount of space at the bottom of the scroll region - // that will NOT be blank, so we need to shift the correct lines down. - // "scroll_amount" is the number of such lines. - const scroll_amount = rem - count; - if (scroll_amount > 0) { - // If we have left/right scroll margins we have a slower path. - const left_right = self.scrolling_region.left > 0 or - self.scrolling_region.right < self.cols - 1; - - const bottom: [*]Row = top + (scroll_amount - 1); - while (@intFromPtr(y) <= @intFromPtr(bottom)) : (y += 1) { - const src: *Row = @ptrCast(y + count); - const dst: *Row = @ptrCast(y); - - if (!left_right) { - // Swap the src/dst cells. This ensures that our dst gets the proper - // shifted rows and src gets non-garbage cell data that we can clear. - const dst_row = dst.*; - dst.* = src.*; - src.* = dst_row; - continue; - } - - // Left/right scroll margins we have to copy cells, which is much slower... - var page = &self.screen.cursor.page_pin.page.data; - page.moveCells( - src, - self.scrolling_region.left, - dst, - self.scrolling_region.left, - (self.scrolling_region.right - self.scrolling_region.left) + 1, - ); - } - } - - const bottom: [*]Row = top + (rem - 1); - while (@intFromPtr(y) <= @intFromPtr(bottom)) : (y += 1) { - const row: *Row = @ptrCast(y); - - // Clear the src row. - var page = &self.screen.cursor.page_pin.page.data; - const cells = page.getCells(row); - const cells_write = cells[self.scrolling_region.left .. self.scrolling_region.right + 1]; - self.screen.clearCells(page, row, cells_write); - } - - // Move the cursor to the left margin. But importantly this also - // forces screen.cursor.page_cell to reload because the rows above - // shifted cell ofsets so this will ensure the cursor is pointing - // to the correct cell. - self.screen.cursorAbsolute( - self.scrolling_region.left, - self.screen.cursor.y, - ); - - // Always unset pending wrap - self.screen.cursor.pending_wrap = false; -} - -/// Inserts spaces at current cursor position moving existing cell contents -/// to the right. The contents of the count right-most columns in the scroll -/// region are lost. The cursor position is not changed. -/// -/// This unsets the pending wrap state without wrapping. -/// -/// The inserted cells are colored according to the current SGR state. -pub fn insertBlanks(self: *Terminal, count: usize) void { - // Unset pending wrap state without wrapping. Note: this purposely - // happens BEFORE the scroll region check below, because that's what - // xterm does. - self.screen.cursor.pending_wrap = false; - - // If our cursor is outside the margins then do nothing. We DO reset - // wrap state still so this must remain below the above logic. - if (self.screen.cursor.x < self.scrolling_region.left or - self.screen.cursor.x > self.scrolling_region.right) return; - - // If our count is larger than the remaining amount, we just erase right. - // We only do this if we can erase the entire line (no right margin). - // if (right_limit == self.cols and - // count > right_limit - self.screen.cursor.x) - // { - // self.eraseLine(.right, false); - // return; - // } - - // left is just the cursor position but as a multi-pointer - const left: [*]Cell = @ptrCast(self.screen.cursor.page_cell); - var page = &self.screen.cursor.page_pin.page.data; - - // Remaining cols from our cursor to the right margin. - const rem = self.scrolling_region.right - self.screen.cursor.x + 1; - - // We can only insert blanks up to our remaining cols - const adjusted_count = @min(count, rem); - - // This is the amount of space at the right of the scroll region - // that will NOT be blank, so we need to shift the correct cols right. - // "scroll_amount" is the number of such cols. - const scroll_amount = rem - adjusted_count; - if (scroll_amount > 0) { - var x: [*]Cell = left + (scroll_amount - 1); - - // If our last cell we're shifting is wide, then we need to clear - // it to be empty so we don't split the multi-cell char. - const end: *Cell = @ptrCast(x); - if (end.wide == .wide) { - self.screen.clearCells(page, self.screen.cursor.page_row, end[0..1]); - } - - // We work backwards so we don't overwrite data. - while (@intFromPtr(x) >= @intFromPtr(left)) : (x -= 1) { - const src: *Cell = @ptrCast(x); - const dst: *Cell = @ptrCast(x + adjusted_count); - - // If the destination has graphemes we need to delete them. - // Graphemes are stored by cell offset so we have to do this - // now before we move. - if (dst.hasGrapheme()) { - page.clearGrapheme(self.screen.cursor.page_row, dst); - } - - // Copy our src to our dst - const old_dst = dst.*; - dst.* = src.*; - src.* = old_dst; - - // If the original source (now copied to dst) had graphemes, - // we have to move them since they're stored by cell offset. - if (dst.hasGrapheme()) { - assert(!src.hasGrapheme()); - page.moveGraphemeWithinRow(src, dst); - } - } - } - - // Insert blanks. The blanks preserve the background color. - self.screen.clearCells(page, self.screen.cursor.page_row, left[0..adjusted_count]); -} - -/// Removes amount characters from the current cursor position to the right. -/// The remaining characters are shifted to the left and space from the right -/// margin is filled with spaces. -/// -/// If amount is greater than the remaining number of characters in the -/// scrolling region, it is adjusted down. -/// -/// Does not change the cursor position. -pub fn deleteChars(self: *Terminal, count: usize) void { - if (count == 0) return; - - // If our cursor is outside the margins then do nothing. We DO reset - // wrap state still so this must remain below the above logic. - if (self.screen.cursor.x < self.scrolling_region.left or - self.screen.cursor.x > self.scrolling_region.right) return; - - // This resets the pending wrap state - self.screen.cursor.pending_wrap = false; - - // left is just the cursor position but as a multi-pointer - const left: [*]Cell = @ptrCast(self.screen.cursor.page_cell); - var page = &self.screen.cursor.page_pin.page.data; - - // If our X is a wide spacer tail then we need to erase the - // previous cell too so we don't split a multi-cell character. - if (self.screen.cursor.page_cell.wide == .spacer_tail) { - assert(self.screen.cursor.x > 0); - self.screen.clearCells(page, self.screen.cursor.page_row, (left - 1)[0..2]); - } - - // Remaining cols from our cursor to the right margin. - const rem = self.scrolling_region.right - self.screen.cursor.x + 1; - - // We can only insert blanks up to our remaining cols - const adjusted_count = @min(count, rem); - - // This is the amount of space at the right of the scroll region - // that will NOT be blank, so we need to shift the correct cols right. - // "scroll_amount" is the number of such cols. - const scroll_amount = rem - adjusted_count; - var x: [*]Cell = left; - if (scroll_amount > 0) { - const right: [*]Cell = left + (scroll_amount - 1); - - // If our last cell we're shifting is wide, then we need to clear - // it to be empty so we don't split the multi-cell char. - const end: *Cell = @ptrCast(right + count); - if (end.wide == .spacer_tail) { - const wide: [*]Cell = right + count - 1; - assert(wide[0].wide == .wide); - self.screen.clearCells(page, self.screen.cursor.page_row, wide[0..2]); - } - - while (@intFromPtr(x) <= @intFromPtr(right)) : (x += 1) { - const src: *Cell = @ptrCast(x + count); - const dst: *Cell = @ptrCast(x); - - // If the destination has graphemes we need to delete them. - // Graphemes are stored by cell offset so we have to do this - // now before we move. - if (dst.hasGrapheme()) { - page.clearGrapheme(self.screen.cursor.page_row, dst); - } - - // Copy our src to our dst - const old_dst = dst.*; - dst.* = src.*; - src.* = old_dst; - - // If the original source (now copied to dst) had graphemes, - // we have to move them since they're stored by cell offset. - if (dst.hasGrapheme()) { - assert(!src.hasGrapheme()); - page.moveGraphemeWithinRow(src, dst); - } - } - } - - // Insert blanks. The blanks preserve the background color. - self.screen.clearCells(page, self.screen.cursor.page_row, x[0 .. rem - scroll_amount]); -} - -pub fn eraseChars(self: *Terminal, count_req: usize) void { - const count = @max(count_req, 1); - - // This resets the soft-wrap of this line - self.screen.cursor.page_row.wrap = false; - - // This resets the pending wrap state - self.screen.cursor.pending_wrap = false; - - // Our last index is at most the end of the number of chars we have - // in the current line. - const end = end: { - const remaining = self.cols - self.screen.cursor.x; - var end = @min(remaining, 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 != remaining) { - const last = self.screen.cursorCellRight(end - 1); - if (last.wide == .wide) end += 1; - } - - break :end end; - }; - - // Clear the cells - const cells: [*]Cell = @ptrCast(self.screen.cursor.page_cell); - - // 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) { - self.screen.clearCells( - &self.screen.cursor.page_pin.page.data, - self.screen.cursor.page_row, - cells[0..end], - ); - return; - } - - // SLOW PATH - // We had a protection mode at some point. We must go through each - // cell and check its protection attribute. - for (0..end) |x| { - const cell_multi: [*]Cell = @ptrCast(cells + x); - const cell: *Cell = @ptrCast(&cell_multi[0]); - if (cell.protected) continue; - self.screen.clearCells( - &self.screen.cursor.page_pin.page.data, - self.screen.cursor.page_row, - cell_multi[0..1], - ); - } -} - -/// Erase the line. -pub fn eraseLine( - self: *Terminal, - mode: csi.EraseLine, - protected_req: bool, -) void { - // Get our start/end positions depending on mode. - const start, const end = switch (mode) { - .right => right: { - var x = self.screen.cursor.x; - - // If our X is a wide spacer tail then we need to erase the - // previous cell too so we don't split a multi-cell character. - if (x > 0 and self.screen.cursor.page_cell.wide == .spacer_tail) { - x -= 1; - } - - // This resets the soft-wrap of this line - self.screen.cursor.page_row.wrap = false; - - break :right .{ x, self.cols }; - }, - - .left => left: { - var x = self.screen.cursor.x; - - // If our x is a wide char we need to delete the tail too. - if (self.screen.cursor.page_cell.wide == .wide) { - x += 1; - } - - break :left .{ 0, x + 1 }; - }, - - // Note that it seems like complete should reset the soft-wrap - // state of the line but in xterm it does not. - .complete => .{ 0, self.cols }, - - else => { - log.err("unimplemented erase line mode: {}", .{mode}); - return; - }, - }; - - // All modes will clear the pending wrap state and we know we have - // a valid mode at this point. - self.screen.cursor.pending_wrap = false; - - // Start of our cells - const cells: [*]Cell = cells: { - const cells: [*]Cell = @ptrCast(self.screen.cursor.page_cell); - break :cells cells - self.screen.cursor.x; - }; - - // We respect protected attributes if explicitly requested (probably - // a DECSEL sequence) or if our last protected mode was ISO even if its - // not currently set. - const protected = self.screen.protected_mode == .iso or protected_req; - - // If we're not respecting protected attributes, we can use a fast-path - // to fill the entire line. - if (!protected) { - self.screen.clearCells( - &self.screen.cursor.page_pin.page.data, - self.screen.cursor.page_row, - cells[start..end], - ); - return; - } - - for (start..end) |x| { - const cell_multi: [*]Cell = @ptrCast(cells + x); - const cell: *Cell = @ptrCast(&cell_multi[0]); - if (cell.protected) continue; - self.screen.clearCells( - &self.screen.cursor.page_pin.page.data, - self.screen.cursor.page_row, - cell_multi[0..1], - ); - } -} - -/// Erase the display. -pub fn eraseDisplay( - self: *Terminal, - mode: csi.EraseDisplay, - protected_req: bool, -) void { - // We respect protected attributes if explicitly requested (probably - // a DECSEL sequence) or if our last protected mode was ISO even if its - // not currently set. - const protected = self.screen.protected_mode == .iso or protected_req; - - switch (mode) { - .scroll_complete => { - self.screen.scrollClear() catch |err| { - log.warn("scroll clear failed, doing a normal clear err={}", .{err}); - self.eraseDisplay(.complete, protected_req); - return; - }; - - // Unsets pending wrap state - self.screen.cursor.pending_wrap = false; - - // Clear all Kitty graphics state for this screen - // TODO - // self.screen.kitty_images.delete(alloc, self, .{ .all = true }); - }, - - .complete => { - // If we're on the primary screen and our last non-empty row is - // a prompt, then we do a scroll_complete instead. This is a - // heuristic to get the generally desirable behavior that ^L - // at a prompt scrolls the screen contents prior to clearing. - // Most shells send `ESC [ H ESC [ 2 J` so we can't just check - // our current cursor position. See #905 - // if (self.active_screen == .primary) at_prompt: { - // // Go from the bottom of the viewport up and see if we're - // // at a prompt. - // const viewport_max = Screen.RowIndexTag.viewport.maxLen(&self.screen); - // for (0..viewport_max) |y| { - // const bottom_y = viewport_max - y - 1; - // const row = self.screen.getRow(.{ .viewport = bottom_y }); - // if (row.isEmpty()) continue; - // switch (row.getSemanticPrompt()) { - // // If we're at a prompt or input area, then we are at a prompt. - // .prompt, - // .prompt_continuation, - // .input, - // => break, - // - // // If we have command output, then we're most certainly not - // // at a prompt. - // .command => break :at_prompt, - // - // // If we don't know, we keep searching. - // .unknown => {}, - // } - // } else break :at_prompt; - // - // self.screen.scroll(.{ .clear = {} }) catch { - // // If we fail, we just fall back to doing a normal clear - // // so we don't worry about the error. - // }; - // } - - // All active area - self.screen.clearRows( - .{ .active = .{} }, - null, - protected, - ); - - // Unsets pending wrap state - self.screen.cursor.pending_wrap = false; - - // Clear all Kitty graphics state for this screen - // TODO - //self.screen.kitty_images.delete(alloc, self, .{ .all = true }); - }, - - .below => { - // All lines to the right (including the cursor) - self.eraseLine(.right, protected_req); - - // All lines below - if (self.screen.cursor.y + 1 < self.rows) { - self.screen.clearRows( - .{ .active = .{ .y = self.screen.cursor.y + 1 } }, - null, - protected, - ); - } - - // Unsets pending wrap state. Should be done by eraseLine. - assert(!self.screen.cursor.pending_wrap); - }, - - .above => { - // Erase to the left (including the cursor) - self.eraseLine(.left, protected_req); - - // All lines above - if (self.screen.cursor.y > 0) { - self.screen.clearRows( - .{ .active = .{ .y = 0 } }, - .{ .active = .{ .y = self.screen.cursor.y - 1 } }, - protected, - ); - } - - // Unsets pending wrap state - assert(!self.screen.cursor.pending_wrap); - }, - - .scrollback => self.screen.eraseRows(.{ .history = .{} }, null), - } -} - -/// Resets all margins and fills the whole screen with the character 'E' -/// -/// Sets the cursor to the top left corner. -pub fn decaln(self: *Terminal) !void { - // Clear our stylistic attributes. This is the only thing that can - // fail so we do it first so we can undo it. - const old_style = self.screen.cursor.style; - self.screen.cursor.style = .{ - .bg_color = self.screen.cursor.style.bg_color, - .fg_color = self.screen.cursor.style.fg_color, - // TODO: protected attribute - // .protected = self.screen.cursor.pen.attrs.protected, - }; - errdefer self.screen.cursor.style = old_style; - try self.screen.manualStyleUpdate(); - - // Reset margins, also sets cursor to top-left - self.scrolling_region = .{ - .top = 0, - .bottom = self.rows - 1, - .left = 0, - .right = self.cols - 1, - }; - - // Origin mode is disabled - self.modes.set(.origin, false); - - // Move our cursor to the top-left - self.setCursorPos(1, 1); - - // Erase the display which will deallocate graphames, styles, etc. - self.eraseDisplay(.complete, false); - - // Fill with Es, does not move cursor. - var it = self.screen.pages.pageIterator(.right_down, .{ .active = .{} }, null); - while (it.next()) |chunk| { - for (chunk.rows()) |*row| { - const cells_multi: [*]Cell = row.cells.ptr(chunk.page.data.memory); - const cells = cells_multi[0..self.cols]; - @memset(cells, .{ - .content_tag = .codepoint, - .content = .{ .codepoint = 'E' }, - .style_id = self.screen.cursor.style_id, - .protected = self.screen.cursor.protected, - }); - - // If we have a ref-counted style, increase - if (self.screen.cursor.style_ref) |ref| { - ref.* += @intCast(cells.len); - row.styled = true; - } - } - } -} - -/// Execute a kitty graphics command. The buf is used to populate with -/// the response that should be sent as an APC sequence. The response will -/// be a full, valid APC sequence. -/// -/// If an error occurs, the caller should response to the pty that a -/// an error occurred otherwise the behavior of the graphics protocol is -/// undefined. -pub fn kittyGraphics( +pub fn alternateScreen( self: *Terminal, alloc: Allocator, - cmd: *kitty.graphics.Command, -) ?kitty.graphics.Response { - return kitty.graphics.execute(alloc, self, cmd); + options: AlternateScreenOptions, +) void { + //log.info("alt screen active={} options={} cursor={}", .{ self.active_screen, options, self.screen.cursor }); + + // TODO: test + // TODO(mitchellh): what happens if we enter alternate screen multiple times? + // for now, we ignore... + if (self.active_screen == .alternate) return; + + // If we requested cursor save, we save the cursor in the primary screen + if (options.cursor_save) self.saveCursor(); + + // Switch the screens + const old = self.screen; + self.screen = self.secondary_screen; + self.secondary_screen = old; + self.active_screen = .alternate; + + // Bring our pen with us + self.screen.cursor = old.cursor; + + // Bring our charset state with us + self.screen.charset = old.charset; + + // Clear our selection + self.screen.selection = null; + + // Mark kitty images as dirty so they redraw + self.screen.kitty_images.dirty = true; + + if (options.clear_on_enter) { + self.eraseDisplay(alloc, .complete, false); + } } -/// Set a style attribute. -pub fn setAttribute(self: *Terminal, attr: sgr.Attribute) !void { - try self.screen.setAttribute(attr); -} +/// Switch back to the primary screen (reset alternate screen mode). +pub fn primaryScreen( + self: *Terminal, + alloc: Allocator, + options: AlternateScreenOptions, +) void { + //log.info("primary screen active={} options={}", .{ self.active_screen, options }); -/// Print the active attributes as a string. This is used to respond to DECRQSS -/// requests. -/// -/// Boolean attributes are printed first, followed by foreground color, then -/// background color. Each attribute is separated by a semicolon. -pub fn printAttributes(self: *Terminal, buf: []u8) ![]const u8 { - var stream = std.io.fixedBufferStream(buf); - const writer = stream.writer(); + // TODO: test + // TODO(mitchellh): what happens if we enter alternate screen multiple times? + if (self.active_screen == .primary) return; - // The SGR response always starts with a 0. See https://vt100.net/docs/vt510-rm/DECRPSS - try writer.writeByte('0'); + if (options.clear_on_exit) self.eraseDisplay(alloc, .complete, false); - const pen = self.screen.cursor.style; - var attrs = [_]u8{0} ** 8; - var i: usize = 0; + // Switch the screens + const old = self.screen; + self.screen = self.secondary_screen; + self.secondary_screen = old; + self.active_screen = .primary; - if (pen.flags.bold) { - attrs[i] = '1'; - i += 1; - } + // Clear our selection + self.screen.selection = null; - if (pen.flags.faint) { - attrs[i] = '2'; - i += 1; - } + // Mark kitty images as dirty so they redraw + self.screen.kitty_images.dirty = true; - if (pen.flags.italic) { - attrs[i] = '3'; - i += 1; - } - - if (pen.flags.underline != .none) { - attrs[i] = '4'; - i += 1; - } - - if (pen.flags.blink) { - attrs[i] = '5'; - i += 1; - } - - if (pen.flags.inverse) { - attrs[i] = '7'; - i += 1; - } - - if (pen.flags.invisible) { - attrs[i] = '8'; - i += 1; - } - - if (pen.flags.strikethrough) { - attrs[i] = '9'; - i += 1; - } - - for (attrs[0..i]) |c| { - try writer.print(";{c}", .{c}); - } - - switch (pen.fg_color) { - .none => {}, - .palette => |idx| if (idx >= 16) - try writer.print(";38:5:{}", .{idx}) - else if (idx >= 8) - try writer.print(";9{}", .{idx - 8}) - else - try writer.print(";3{}", .{idx}), - .rgb => |rgb| try writer.print(";38:2::{[r]}:{[g]}:{[b]}", rgb), - } - - switch (pen.bg_color) { - .none => {}, - .palette => |idx| if (idx >= 16) - try writer.print(";48:5:{}", .{idx}) - else if (idx >= 8) - try writer.print(";10{}", .{idx - 8}) - else - try writer.print(";4{}", .{idx}), - .rgb => |rgb| try writer.print(";48:2::{[r]}:{[g]}:{[b]}", rgb), - } - - return stream.getWritten(); + // Restore the cursor from the primary screen + if (options.cursor_save) self.restoreCursor(); } /// The modes for DECCOLM. @@ -2026,21 +309,17 @@ pub fn deccolm(self: *Terminal, alloc: Allocator, mode: DeccolmMode) !void { ); // Erase our display and move our cursor. - self.eraseDisplay(.complete, false); + self.eraseDisplay(alloc, .complete, false); self.setCursorPos(1, 1); } /// Resize the underlying terminal. -pub fn resize( - self: *Terminal, - alloc: Allocator, - cols: size.CellCountInt, - rows: size.CellCountInt, -) !void { +pub fn resize(self: *Terminal, alloc: Allocator, cols: usize, rows: usize) !void { // If our cols/rows didn't change then we're done if (self.cols == cols and self.rows == rows) return; // Resize our tabstops + // TODO: use resize, but it doesn't set new tabstops if (self.cols != cols) { self.tabstops.deinit(alloc); self.tabstops = try Tabstops.init(alloc, cols, 8); @@ -2081,8 +360,1764 @@ pub fn resize( /// then this will clear the screen from the cursor down if the cursor is /// on a prompt in order to allow the shell to redraw the prompt. fn clearPromptForResize(self: *Terminal) void { - // TODO - _ = self; + assert(self.active_screen == .primary); + + if (!self.flags.shell_redraws_prompt) return; + + // We need to find the first y that is a prompt. If we find any line + // that is NOT a prompt (or input -- which is part of a prompt) then + // we are not at a prompt and we can exit this function. + const prompt_y: usize = prompt_y: { + // Keep track of the found value, because we want to find the START + var found: ?usize = null; + + // Search from the cursor up + var y: usize = 0; + while (y <= self.screen.cursor.y) : (y += 1) { + const real_y = self.screen.cursor.y - y; + const row = self.screen.getRow(.{ .active = real_y }); + switch (row.getSemanticPrompt()) { + // We are at a prompt but we're not at the start of the prompt. + // We mark our found value and continue because the prompt + // may be multi-line. + .input => found = real_y, + + // If we find the prompt then we're done. We are also done + // if we find any prompt continuation, because the shells + // that send this currently (zsh) cannot redraw every line. + .prompt, .prompt_continuation => { + found = real_y; + break; + }, + + // If we have command output, then we're most certainly not + // at a prompt. Break out of the loop. + .command => break, + + // If we don't know, we keep searching. + .unknown => {}, + } + } + + if (found) |found_y| break :prompt_y found_y; + return; + }; + assert(prompt_y < self.rows); + + // We want to clear all the lines from prompt_y downwards because + // the shell will redraw the prompt. + for (prompt_y..self.rows) |y| { + const row = self.screen.getRow(.{ .active = y }); + row.setWrapped(false); + row.setDirty(true); + row.clear(.{}); + } +} + +/// Return the current string value of the terminal. Newlines are +/// 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 { + return try self.screen.testString(alloc, .viewport); +} + +/// Save cursor position and further state. +/// +/// The primary and alternate screen have distinct save state. One saved state +/// is kept per screen (main / alternative). If for the current screen state +/// was already saved it is overwritten. +pub fn saveCursor(self: *Terminal) void { + self.screen.saved_cursor = .{ + .x = self.screen.cursor.x, + .y = self.screen.cursor.y, + .pen = self.screen.cursor.pen, + .pending_wrap = self.screen.cursor.pending_wrap, + .origin = self.modes.get(.origin), + .charset = self.screen.charset, + }; +} + +/// Restore cursor position and other state. +/// +/// The primary and alternate screen have distinct save state. +/// If no save was done before values are reset to their initial values. +pub fn restoreCursor(self: *Terminal) void { + const saved: Screen.Cursor.Saved = self.screen.saved_cursor orelse .{ + .x = 0, + .y = 0, + .pen = .{}, + .pending_wrap = false, + .origin = false, + .charset = .{}, + }; + + self.screen.cursor.pen = saved.pen; + self.screen.charset = saved.charset; + self.modes.set(.origin, saved.origin); + self.screen.cursor.x = @min(saved.x, self.cols - 1); + self.screen.cursor.y = @min(saved.y, self.rows - 1); + self.screen.cursor.pending_wrap = saved.pending_wrap; +} + +/// TODO: test +pub fn setAttribute(self: *Terminal, attr: sgr.Attribute) !void { + switch (attr) { + .unset => { + self.screen.cursor.pen.fg = .none; + self.screen.cursor.pen.bg = .none; + self.screen.cursor.pen.attrs = .{}; + }, + + .bold => { + self.screen.cursor.pen.attrs.bold = true; + }, + + .reset_bold => { + // Bold and faint share the same SGR code for this + self.screen.cursor.pen.attrs.bold = false; + self.screen.cursor.pen.attrs.faint = false; + }, + + .italic => { + self.screen.cursor.pen.attrs.italic = true; + }, + + .reset_italic => { + self.screen.cursor.pen.attrs.italic = false; + }, + + .faint => { + self.screen.cursor.pen.attrs.faint = true; + }, + + .underline => |v| { + self.screen.cursor.pen.attrs.underline = v; + }, + + .reset_underline => { + self.screen.cursor.pen.attrs.underline = .none; + }, + + .underline_color => |rgb| { + self.screen.cursor.pen.attrs.underline_color = true; + self.screen.cursor.pen.underline_fg = .{ + .r = rgb.r, + .g = rgb.g, + .b = rgb.b, + }; + }, + + .@"256_underline_color" => |idx| { + self.screen.cursor.pen.attrs.underline_color = true; + self.screen.cursor.pen.underline_fg = self.color_palette.colors[idx]; + }, + + .reset_underline_color => { + self.screen.cursor.pen.attrs.underline_color = false; + }, + + .blink => { + log.warn("blink requested, but not implemented", .{}); + self.screen.cursor.pen.attrs.blink = true; + }, + + .reset_blink => { + self.screen.cursor.pen.attrs.blink = false; + }, + + .inverse => { + self.screen.cursor.pen.attrs.inverse = true; + }, + + .reset_inverse => { + self.screen.cursor.pen.attrs.inverse = false; + }, + + .invisible => { + self.screen.cursor.pen.attrs.invisible = true; + }, + + .reset_invisible => { + self.screen.cursor.pen.attrs.invisible = false; + }, + + .strikethrough => { + self.screen.cursor.pen.attrs.strikethrough = true; + }, + + .reset_strikethrough => { + self.screen.cursor.pen.attrs.strikethrough = false; + }, + + .direct_color_fg => |rgb| { + self.screen.cursor.pen.fg = .{ + .rgb = .{ + .r = rgb.r, + .g = rgb.g, + .b = rgb.b, + }, + }; + }, + + .direct_color_bg => |rgb| { + self.screen.cursor.pen.bg = .{ + .rgb = .{ + .r = rgb.r, + .g = rgb.g, + .b = rgb.b, + }, + }; + }, + + .@"8_fg" => |n| { + self.screen.cursor.pen.fg = .{ .indexed = @intFromEnum(n) }; + }, + + .@"8_bg" => |n| { + self.screen.cursor.pen.bg = .{ .indexed = @intFromEnum(n) }; + }, + + .reset_fg => self.screen.cursor.pen.fg = .none, + + .reset_bg => self.screen.cursor.pen.bg = .none, + + .@"8_bright_fg" => |n| { + self.screen.cursor.pen.fg = .{ .indexed = @intFromEnum(n) }; + }, + + .@"8_bright_bg" => |n| { + self.screen.cursor.pen.bg = .{ .indexed = @intFromEnum(n) }; + }, + + .@"256_fg" => |idx| { + self.screen.cursor.pen.fg = .{ .indexed = idx }; + }, + + .@"256_bg" => |idx| { + self.screen.cursor.pen.bg = .{ .indexed = idx }; + }, + + .unknown => return error.InvalidAttribute, + } +} + +/// Print the active attributes as a string. This is used to respond to DECRQSS +/// requests. +/// +/// Boolean attributes are printed first, followed by foreground color, then +/// background color. Each attribute is separated by a semicolon. +pub fn printAttributes(self: *Terminal, buf: []u8) ![]const u8 { + var stream = std.io.fixedBufferStream(buf); + const writer = stream.writer(); + + // The SGR response always starts with a 0. See https://vt100.net/docs/vt510-rm/DECRPSS + try writer.writeByte('0'); + + const pen = self.screen.cursor.pen; + var attrs = [_]u8{0} ** 8; + var i: usize = 0; + + if (pen.attrs.bold) { + attrs[i] = '1'; + i += 1; + } + + if (pen.attrs.faint) { + attrs[i] = '2'; + i += 1; + } + + if (pen.attrs.italic) { + attrs[i] = '3'; + i += 1; + } + + if (pen.attrs.underline != .none) { + attrs[i] = '4'; + i += 1; + } + + if (pen.attrs.blink) { + attrs[i] = '5'; + i += 1; + } + + if (pen.attrs.inverse) { + attrs[i] = '7'; + i += 1; + } + + if (pen.attrs.invisible) { + attrs[i] = '8'; + i += 1; + } + + if (pen.attrs.strikethrough) { + attrs[i] = '9'; + i += 1; + } + + for (attrs[0..i]) |c| { + try writer.print(";{c}", .{c}); + } + + switch (pen.fg) { + .none => {}, + .indexed => |idx| if (idx >= 16) + try writer.print(";38:5:{}", .{idx}) + else if (idx >= 8) + try writer.print(";9{}", .{idx - 8}) + else + try writer.print(";3{}", .{idx}), + .rgb => |rgb| try writer.print(";38:2::{[r]}:{[g]}:{[b]}", rgb), + } + + switch (pen.bg) { + .none => {}, + .indexed => |idx| if (idx >= 16) + try writer.print(";48:5:{}", .{idx}) + else if (idx >= 8) + try writer.print(";10{}", .{idx - 8}) + else + try writer.print(";4{}", .{idx}), + .rgb => |rgb| try writer.print(";48:2::{[r]}:{[g]}:{[b]}", rgb), + } + + return stream.getWritten(); +} + +/// Set the charset into the given slot. +pub fn configureCharset(self: *Terminal, slot: charsets.Slots, set: charsets.Charset) void { + self.screen.charset.charsets.set(slot, set); +} + +/// Invoke the charset in slot into the active slot. If single is true, +/// then this will only be invoked for a single character. +pub fn invokeCharset( + self: *Terminal, + active: charsets.ActiveSlot, + slot: charsets.Slots, + single: bool, +) void { + if (single) { + assert(active == .GL); + self.screen.charset.single_shift = slot; + return; + } + + switch (active) { + .GL => self.screen.charset.gl = slot, + .GR => self.screen.charset.gr = slot, + } +} + +/// Print UTF-8 encoded string to the terminal. +pub fn printString(self: *Terminal, str: []const u8) !void { + const view = try std.unicode.Utf8View.init(str); + var it = view.iterator(); + while (it.nextCodepoint()) |cp| { + switch (cp) { + '\n' => { + self.carriageReturn(); + try self.linefeed(); + }, + + else => try self.print(cp), + } + } +} + +pub fn print(self: *Terminal, c: u21) !void { + // log.debug("print={x} y={} x={}", .{ c, self.screen.cursor.y, self.screen.cursor.x }); + + // If we're not on the main display, do nothing for now + if (self.status_display != .main) return; + + // Our right margin depends where our cursor is now. + const right_limit = if (self.screen.cursor.x > self.scrolling_region.right) + self.cols + else + self.scrolling_region.right + 1; + + // Perform grapheme clustering if grapheme support is enabled (mode 2027). + // This is MUCH slower than the normal path so the conditional below is + // purposely ordered in least-likely to most-likely so we can drop out + // as quickly as possible. + if (c > 255 and self.modes.get(.grapheme_cluster) and self.screen.cursor.x > 0) grapheme: { + const row = self.screen.getRow(.{ .active = self.screen.cursor.y }); + + // We need the previous cell to determine if we're at a grapheme + // break or not. If we are NOT, then we are still combining the + // same grapheme. Otherwise, we can stay in this cell. + const Prev = struct { cell: *Screen.Cell, x: usize }; + const prev: Prev = prev: { + const x = x: { + // If we have wraparound, then we always use the prev col + if (self.modes.get(.wraparound)) break :x self.screen.cursor.x - 1; + + // If we do not have wraparound, the logic is trickier. If + // we're not on the last column, then we just use the previous + // column. Otherwise, we need to check if there is text to + // figure out if we're attaching to the prev or current. + if (self.screen.cursor.x != right_limit - 1) break :x self.screen.cursor.x - 1; + const current = row.getCellPtr(self.screen.cursor.x); + break :x self.screen.cursor.x - @intFromBool(current.char == 0); + }; + const immediate = row.getCellPtr(x); + + // If the previous cell is a wide spacer tail, then we actually + // want to use the cell before that because that has the actual + // content. + if (!immediate.attrs.wide_spacer_tail) break :prev .{ + .cell = immediate, + .x = x, + }; + + break :prev .{ + .cell = row.getCellPtr(x - 1), + .x = x - 1, + }; + }; + + // If our cell has no content, then this is a new cell and + // necessarily a grapheme break. + if (prev.cell.char == 0) break :grapheme; + + const grapheme_break = brk: { + var state: unicode.GraphemeBreakState = .{}; + var cp1: u21 = @intCast(prev.cell.char); + if (prev.cell.attrs.grapheme) { + var it = row.codepointIterator(prev.x); + while (it.next()) |cp2| { + // log.debug("cp1={x} cp2={x}", .{ cp1, cp2 }); + assert(!unicode.graphemeBreak(cp1, cp2, &state)); + cp1 = cp2; + } + } + + // log.debug("cp1={x} cp2={x} end", .{ cp1, c }); + break :brk unicode.graphemeBreak(cp1, c, &state); + }; + + // If we can NOT break, this means that "c" is part of a grapheme + // with the previous char. + if (!grapheme_break) { + // If this is an emoji variation selector then we need to modify + // the cell width accordingly. VS16 makes the character wide and + // VS15 makes it narrow. + if (c == 0xFE0F or c == 0xFE0E) { + // This only applies to emoji + const prev_props = unicode.getProperties(@intCast(prev.cell.char)); + const emoji = prev_props.grapheme_boundary_class == .extended_pictographic; + if (!emoji) return; + + switch (c) { + 0xFE0F => wide: { + if (prev.cell.attrs.wide) break :wide; + + // Move our cursor back to the previous. We'll move + // the cursor within this block to the proper location. + self.screen.cursor.x = prev.x; + + // If we don't have space for the wide char, we need + // to insert spacers and wrap. Then we just print the wide + // char as normal. + if (prev.x == right_limit - 1) { + if (!self.modes.get(.wraparound)) return; + const spacer_head = self.printCell(' '); + spacer_head.attrs.wide_spacer_head = true; + try self.printWrap(); + } + + const wide_cell = self.printCell(@intCast(prev.cell.char)); + wide_cell.attrs.wide = true; + + // Write our spacer + self.screen.cursor.x += 1; + const spacer = self.printCell(' '); + spacer.attrs.wide_spacer_tail = true; + + // Move the cursor again so we're beyond our spacer + self.screen.cursor.x += 1; + if (self.screen.cursor.x == right_limit) { + self.screen.cursor.x -= 1; + self.screen.cursor.pending_wrap = true; + } + }, + + 0xFE0E => narrow: { + // Prev cell is no longer wide + if (!prev.cell.attrs.wide) break :narrow; + prev.cell.attrs.wide = false; + + // Remove the wide spacer tail + const cell = row.getCellPtr(prev.x + 1); + cell.attrs.wide_spacer_tail = false; + + break :narrow; + }, + + else => unreachable, + } + } + + log.debug("c={x} grapheme attach to x={}", .{ c, prev.x }); + try row.attachGrapheme(prev.x, c); + return; + } + } + + // Determine the width of this character so we can handle + // non-single-width characters properly. We have a fast-path for + // byte-sized characters since they're so common. We can ignore + // control characters because they're always filtered prior. + const width: usize = if (c <= 0xFF) 1 else @intCast(unicode.table.get(c).width); + + // Note: it is possible to have a width of "3" and a width of "-1" + // from ziglyph. We should look into those cases and handle them + // appropriately. + assert(width <= 2); + // log.debug("c={x} width={}", .{ c, width }); + + // Attach zero-width characters to our cell as grapheme data. + if (width == 0) { + // If we have grapheme clustering enabled, we don't blindly attach + // any zero width character to our cells and we instead just ignore + // it. + if (self.modes.get(.grapheme_cluster)) return; + + // If we're at cell zero, then this is malformed data and we don't + // print anything or even store this. Zero-width characters are ALWAYS + // attached to some other non-zero-width character at the time of + // writing. + if (self.screen.cursor.x == 0) { + log.warn("zero-width character with no prior character, ignoring", .{}); + return; + } + + // Find our previous cell + const row = self.screen.getRow(.{ .active = self.screen.cursor.y }); + const prev: usize = prev: { + const x = self.screen.cursor.x - 1; + const immediate = row.getCellPtr(x); + if (!immediate.attrs.wide_spacer_tail) break :prev x; + break :prev x - 1; + }; + + // If this is a emoji variation selector, prev must be an emoji + if (c == 0xFE0F or c == 0xFE0E) { + const prev_cell = row.getCellPtr(prev); + const prev_props = unicode.getProperties(@intCast(prev_cell.char)); + const emoji = prev_props.grapheme_boundary_class == .extended_pictographic; + if (!emoji) return; + } + + try row.attachGrapheme(prev, c); + return; + } + + // We have a printable character, save it + self.previous_char = c; + + // If we're soft-wrapping, then handle that first. + if (self.screen.cursor.pending_wrap and self.modes.get(.wraparound)) + try self.printWrap(); + + // If we have insert mode enabled then we need to handle that. We + // only do insert mode if we're not at the end of the line. + if (self.modes.get(.insert) and + self.screen.cursor.x + width < self.cols) + { + self.insertBlanks(width); + } + + switch (width) { + // Single cell is very easy: just write in the cell + 1 => _ = @call(.always_inline, printCell, .{ self, c }), + + // Wide character requires a spacer. We print this by + // using two cells: the first is flagged "wide" and has the + // wide char. The second is guaranteed to be a spacer if + // we're not at the end of the line. + 2 => if ((right_limit - self.scrolling_region.left) > 1) { + // If we don't have space for the wide char, we need + // to insert spacers and wrap. Then we just print the wide + // char as normal. + if (self.screen.cursor.x == right_limit - 1) { + // If we don't have wraparound enabled then we don't print + // this character at all and don't move the cursor. This is + // how xterm behaves. + if (!self.modes.get(.wraparound)) return; + + const spacer_head = self.printCell(' '); + spacer_head.attrs.wide_spacer_head = true; + try self.printWrap(); + } + + const wide_cell = self.printCell(c); + wide_cell.attrs.wide = true; + + // Write our spacer + self.screen.cursor.x += 1; + const spacer = self.printCell(' '); + spacer.attrs.wide_spacer_tail = true; + } else { + // This is pretty broken, terminals should never be only 1-wide. + // We sould prevent this downstream. + _ = self.printCell(' '); + }, + + else => unreachable, + } + + // Move the cursor + self.screen.cursor.x += 1; + + // If we're at the column limit, then we need to wrap the next time. + // This is unlikely so we do the increment above and decrement here + // if we need to rather than check once. + if (self.screen.cursor.x == right_limit) { + self.screen.cursor.x -= 1; + self.screen.cursor.pending_wrap = true; + } +} + +fn printCell(self: *Terminal, unmapped_c: u21) *Screen.Cell { + const c: u21 = c: { + // TODO: non-utf8 handling, gr + + // If we're single shifting, then we use the key exactly once. + const key = if (self.screen.charset.single_shift) |key_once| blk: { + self.screen.charset.single_shift = null; + break :blk key_once; + } else self.screen.charset.gl; + const set = self.screen.charset.charsets.get(key); + + // UTF-8 or ASCII is used as-is + if (set == .utf8 or set == .ascii) break :c unmapped_c; + + // If we're outside of ASCII range this is an invalid value in + // this table so we just return space. + if (unmapped_c > std.math.maxInt(u8)) break :c ' '; + + // Get our lookup table and map it + const table = set.table(); + break :c @intCast(table[@intCast(unmapped_c)]); + }; + + 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 + // single-width characters into that. + if (cell.attrs.wide) { + const x = self.screen.cursor.x + 1; + if (x < self.cols) { + const spacer_cell = row.getCellPtr(x); + spacer_cell.* = self.screen.cursor.pen; + } + + if (self.screen.cursor.y > 0 and self.screen.cursor.x <= 1) { + self.clearWideSpacerHead(); + } + } else if (cell.attrs.wide_spacer_tail) { + assert(self.screen.cursor.x > 0); + const x = self.screen.cursor.x - 1; + + const wide_cell = row.getCellPtr(x); + wide_cell.* = self.screen.cursor.pen; + + if (self.screen.cursor.y > 0 and self.screen.cursor.x <= 1) { + self.clearWideSpacerHead(); + } + } + + // If the prior value had graphemes, clear those + if (cell.attrs.grapheme) row.clearGraphemes(self.screen.cursor.x); + + // Write + cell.* = self.screen.cursor.pen; + cell.char = @intCast(c); + return cell; +} + +fn printWrap(self: *Terminal) !void { + const row = self.screen.getRow(.{ .active = self.screen.cursor.y }); + row.setWrapped(true); + + // Get the old semantic prompt so we can extend it to the next + // line. We need to do this before we index() because we may + // modify memory. + const old_prompt = row.getSemanticPrompt(); + + // Move to the next line + try self.index(); + self.screen.cursor.x = self.scrolling_region.left; + + // New line must inherit semantic prompt of the old line + const new_row = self.screen.getRow(.{ .active = self.screen.cursor.y }); + new_row.setSemanticPrompt(old_prompt); +} + +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.getCellPtr( + .active, + self.screen.cursor.y - 1, + self.cols - 1, + ); + cell.attrs.wide_spacer_head = false; +} + +/// Print the previous printed character a repeated amount of times. +pub fn printRepeat(self: *Terminal, count_req: usize) !void { + if (self.previous_char) |c| { + const count = @max(count_req, 1); + for (0..count) |_| try self.print(c); + } +} + +/// Resets all margins and fills the whole screen with the character 'E' +/// +/// Sets the cursor to the top left corner. +pub fn decaln(self: *Terminal) !void { + // Reset margins, also sets cursor to top-left + self.scrolling_region = .{ + .top = 0, + .bottom = self.rows - 1, + .left = 0, + .right = self.cols - 1, + }; + + // Origin mode is disabled + self.modes.set(.origin, false); + + // Move our cursor to the top-left + self.setCursorPos(1, 1); + + // Clear our stylistic attributes + self.screen.cursor.pen = .{ + .bg = self.screen.cursor.pen.bg, + .fg = self.screen.cursor.pen.fg, + .attrs = .{ + .protected = self.screen.cursor.pen.attrs.protected, + }, + }; + + // Our pen has the letter E + const pen: Screen.Cell = .{ .char = 'E' }; + + // Fill with Es, does not move cursor. + for (0..self.rows) |y| { + const filled = self.screen.getRow(.{ .active = y }); + filled.fill(pen); + } +} + +/// Move the cursor to the next line in the scrolling region, possibly scrolling. +/// +/// If the cursor is outside of the scrolling region: move the cursor one line +/// down if it is not on the bottom-most line of the screen. +/// +/// If the cursor is inside the scrolling region: +/// If the cursor is on the bottom-most line of the scrolling region: +/// invoke scroll up with amount=1 +/// If the cursor is not on the bottom-most line of the scrolling region: +/// move the cursor one line down +/// +/// This unsets the pending wrap state without wrapping. +pub fn index(self: *Terminal) !void { + // Unset pending wrap state + self.screen.cursor.pending_wrap = false; + + // Outside of the scroll region we move the cursor one line down. + if (self.screen.cursor.y < self.scrolling_region.top or + self.screen.cursor.y > self.scrolling_region.bottom) + { + self.screen.cursor.y = @min(self.screen.cursor.y + 1, self.rows - 1); + return; + } + + // If the cursor is inside the scrolling region and on the bottom-most + // line, then we scroll up. If our scrolling region is the full screen + // we create scrollback. + if (self.screen.cursor.y == self.scrolling_region.bottom and + self.screen.cursor.x >= self.scrolling_region.left and + self.screen.cursor.x <= self.scrolling_region.right) + { + // If our scrolling region is the full screen, we create scrollback. + // Otherwise, we simply scroll the region. + if (self.scrolling_region.top == 0 and + self.scrolling_region.bottom == self.rows - 1 and + self.scrolling_region.left == 0 and + self.scrolling_region.right == self.cols - 1) + { + try self.screen.scroll(.{ .screen = 1 }); + } else { + try self.scrollUp(1); + } + + return; + } + + // Increase cursor by 1, maximum to bottom of scroll region + self.screen.cursor.y = @min(self.screen.cursor.y + 1, self.scrolling_region.bottom); +} + +/// Move the cursor to the previous line in the scrolling region, possibly +/// scrolling. +/// +/// If the cursor is outside of the scrolling region, move the cursor one +/// line up if it is not on the top-most line of the screen. +/// +/// If the cursor is inside the scrolling region: +/// +/// * If the cursor is on the top-most line of the scrolling region: +/// invoke scroll down with amount=1 +/// * If the cursor is not on the top-most line of the scrolling region: +/// move the cursor one line up +pub fn reverseIndex(self: *Terminal) !void { + if (self.screen.cursor.y != self.scrolling_region.top or + self.screen.cursor.x < self.scrolling_region.left or + self.screen.cursor.x > self.scrolling_region.right) + { + self.cursorUp(1); + return; + } + + try self.scrollDown(1); +} + +// Set Cursor Position. Move cursor to the position indicated +// by row and column (1-indexed). If column is 0, it is adjusted to 1. +// If column is greater than the right-most column it is adjusted to +// the right-most column. If row is 0, it is adjusted to 1. If row is +// greater than the bottom-most row it is adjusted to the bottom-most +// row. +pub fn setCursorPos(self: *Terminal, row_req: usize, col_req: usize) void { + // If cursor origin mode is set the cursor row will be moved relative to + // the top margin row and adjusted to be above or at bottom-most row in + // the current scroll region. + // + // If origin mode is set and left and right margin mode is set the cursor + // will be moved relative to the left margin column and adjusted to be on + // or left of the right margin column. + const params: struct { + x_offset: usize = 0, + y_offset: usize = 0, + x_max: usize, + y_max: usize, + } = if (self.modes.get(.origin)) .{ + .x_offset = self.scrolling_region.left, + .y_offset = self.scrolling_region.top, + .x_max = self.scrolling_region.right + 1, // We need this 1-indexed + .y_max = self.scrolling_region.bottom + 1, // We need this 1-indexed + } else .{ + .x_max = self.cols, + .y_max = self.rows, + }; + + const row = if (row_req == 0) 1 else row_req; + const col = if (col_req == 0) 1 else col_req; + self.screen.cursor.x = @min(params.x_max, col + params.x_offset) -| 1; + self.screen.cursor.y = @min(params.y_max, row + params.y_offset) -| 1; + // log.info("set cursor position: col={} row={}", .{ self.screen.cursor.x, self.screen.cursor.y }); + + // Unset pending wrap state + self.screen.cursor.pending_wrap = false; +} + +/// Erase the display. +pub fn eraseDisplay( + self: *Terminal, + alloc: Allocator, + mode: csi.EraseDisplay, + protected_req: bool, +) void { + // Erasing clears all attributes / colors _except_ the background + const pen: Screen.Cell = switch (self.screen.cursor.pen.bg) { + .none => .{}, + else => |bg| .{ .bg = bg }, + }; + + // We respect protected attributes if explicitly requested (probably + // a DECSEL sequence) or if our last protected mode was ISO even if its + // not currently set. + const protected = self.screen.protected_mode == .iso or protected_req; + + switch (mode) { + .scroll_complete => { + self.screen.scroll(.{ .clear = {} }) catch |err| { + log.warn("scroll clear failed, doing a normal clear err={}", .{err}); + self.eraseDisplay(alloc, .complete, protected_req); + return; + }; + + // Unsets pending wrap state + self.screen.cursor.pending_wrap = false; + + // Clear all Kitty graphics state for this screen + self.screen.kitty_images.delete(alloc, self, .{ .all = true }); + }, + + .complete => { + // If we're on the primary screen and our last non-empty row is + // a prompt, then we do a scroll_complete instead. This is a + // heuristic to get the generally desirable behavior that ^L + // at a prompt scrolls the screen contents prior to clearing. + // Most shells send `ESC [ H ESC [ 2 J` so we can't just check + // our current cursor position. See #905 + if (self.active_screen == .primary) at_prompt: { + // Go from the bottom of the viewport up and see if we're + // at a prompt. + const viewport_max = Screen.RowIndexTag.viewport.maxLen(&self.screen); + for (0..viewport_max) |y| { + const bottom_y = viewport_max - y - 1; + const row = self.screen.getRow(.{ .viewport = bottom_y }); + if (row.isEmpty()) continue; + switch (row.getSemanticPrompt()) { + // If we're at a prompt or input area, then we are at a prompt. + .prompt, + .prompt_continuation, + .input, + => break, + + // If we have command output, then we're most certainly not + // at a prompt. + .command => break :at_prompt, + + // If we don't know, we keep searching. + .unknown => {}, + } + } else break :at_prompt; + + self.screen.scroll(.{ .clear = {} }) catch { + // If we fail, we just fall back to doing a normal clear + // so we don't worry about the error. + }; + } + + var it = self.screen.rowIterator(.active); + while (it.next()) |row| { + row.setWrapped(false); + row.setDirty(true); + + if (!protected) { + row.clear(pen); + continue; + } + + // Protected mode erase + for (0..row.lenCells()) |x| { + const cell = row.getCellPtr(x); + if (cell.attrs.protected) continue; + cell.* = pen; + } + } + + // Unsets pending wrap state + self.screen.cursor.pending_wrap = false; + + // Clear all Kitty graphics state for this screen + self.screen.kitty_images.delete(alloc, self, .{ .all = true }); + }, + + .below => { + // All lines to the right (including the cursor) + { + self.eraseLine(.right, protected_req); + const row = self.screen.getRow(.{ .active = self.screen.cursor.y }); + row.setWrapped(false); + row.setDirty(true); + } + + // All lines below + 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); + if (protected and cell.attrs.protected) continue; + cell.* = pen; + cell.char = 0; + } + } + + // Unsets pending wrap state + self.screen.cursor.pending_wrap = false; + }, + + .above => { + // Erase to the left (including the cursor) + self.eraseLine(.left, protected_req); + + // All lines above + var y: usize = 0; + while (y < self.screen.cursor.y) : (y += 1) { + var x: usize = 0; + while (x < self.cols) : (x += 1) { + const cell = self.screen.getCellPtr(.active, y, x); + if (protected and cell.attrs.protected) continue; + cell.* = pen; + cell.char = 0; + } + } + + // Unsets pending wrap state + self.screen.cursor.pending_wrap = false; + }, + + .scrollback => self.screen.clear(.history) catch |err| { + // This isn't a huge issue, so just log it. + log.err("failed to clear scrollback: {}", .{err}); + }, + } +} + +/// Erase the line. +pub fn eraseLine( + self: *Terminal, + mode: csi.EraseLine, + protected_req: bool, +) void { + // We always fill with the background + const pen: Screen.Cell = switch (self.screen.cursor.pen.bg) { + .none => .{}, + else => |bg| .{ .bg = bg }, + }; + + // Get our start/end positions depending on mode. + const row = self.screen.getRow(.{ .active = self.screen.cursor.y }); + const start, const end = switch (mode) { + .right => right: { + var x = self.screen.cursor.x; + + // If our X is a wide spacer tail then we need to erase the + // previous cell too so we don't split a multi-cell character. + if (x > 0) { + const cell = row.getCellPtr(x); + if (cell.attrs.wide_spacer_tail) x -= 1; + } + + // This resets the soft-wrap of this line + row.setWrapped(false); + + break :right .{ x, row.lenCells() }; + }, + + .left => left: { + var x = self.screen.cursor.x; + + // If our x is a wide char we need to delete the tail too. + const cell = row.getCellPtr(x); + if (cell.attrs.wide) { + if (row.getCellPtr(x + 1).attrs.wide_spacer_tail) { + x += 1; + } + } + + break :left .{ 0, x + 1 }; + }, + + // Note that it seems like complete should reset the soft-wrap + // state of the line but in xterm it does not. + .complete => .{ 0, row.lenCells() }, + + else => { + log.err("unimplemented erase line mode: {}", .{mode}); + return; + }, + }; + + // All modes will clear the pending wrap state and we know we have + // a valid mode at this point. + self.screen.cursor.pending_wrap = false; + + // We respect protected attributes if explicitly requested (probably + // a DECSEL sequence) or if our last protected mode was ISO even if its + // not currently set. + const protected = self.screen.protected_mode == .iso or protected_req; + + // If we're not respecting protected attributes, we can use a fast-path + // to fill the entire line. + if (!protected) { + row.fillSlice(self.screen.cursor.pen, start, end); + return; + } + + for (start..end) |x| { + const cell = row.getCellPtr(x); + if (cell.attrs.protected) continue; + cell.* = pen; + } +} + +/// Removes amount characters from the current cursor position to the right. +/// The remaining characters are shifted to the left and space from the right +/// margin is filled with spaces. +/// +/// If amount is greater than the remaining number of characters in the +/// scrolling region, it is adjusted down. +/// +/// Does not change the cursor position. +pub fn deleteChars(self: *Terminal, count: usize) !void { + if (count == 0) return; + + // If our cursor is outside the margins then do nothing. We DO reset + // wrap state still so this must remain below the above logic. + if (self.screen.cursor.x < self.scrolling_region.left or + self.screen.cursor.x > self.scrolling_region.right) return; + + // This resets the pending wrap state + self.screen.cursor.pending_wrap = false; + + const pen: Screen.Cell = .{ + .bg = self.screen.cursor.pen.bg, + }; + + // If our X is a wide spacer tail then we need to erase the + // previous cell too so we don't split a multi-cell character. + const line = self.screen.getRow(.{ .active = self.screen.cursor.y }); + if (self.screen.cursor.x > 0) { + const cell = line.getCellPtr(self.screen.cursor.x); + if (cell.attrs.wide_spacer_tail) { + line.getCellPtr(self.screen.cursor.x - 1).* = pen; + } + } + + // We go from our cursor right to the end and either copy the cell + // "count" away or clear it. + for (self.screen.cursor.x..self.scrolling_region.right + 1) |x| { + const copy_x = x + count; + if (copy_x >= self.scrolling_region.right + 1) { + line.getCellPtr(x).* = pen; + continue; + } + + const copy_cell = line.getCellPtr(copy_x); + if (x == 0 and copy_cell.attrs.wide_spacer_tail) { + line.getCellPtr(x).* = pen; + continue; + } + line.getCellPtr(x).* = copy_cell.*; + copy_cell.char = 0; + } +} + +pub fn eraseChars(self: *Terminal, count_req: usize) void { + const count = @max(count_req, 1); + + // This resets the pending wrap state + self.screen.cursor.pending_wrap = false; + + // Our last index is at most the end of the number of chars we have + // in the current line. + 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; + }; + + // This resets the soft-wrap of this line + row.setWrapped(false); + + const pen: Screen.Cell = .{ + .bg = self.screen.cursor.pen.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. +pub fn cursorLeft(self: *Terminal, count_req: usize) void { + // Wrapping behavior depends on various terminal modes + const WrapMode = enum { none, reverse, reverse_extended }; + const wrap_mode: WrapMode = wrap_mode: { + if (!self.modes.get(.wraparound)) break :wrap_mode .none; + if (self.modes.get(.reverse_wrap_extended)) break :wrap_mode .reverse_extended; + if (self.modes.get(.reverse_wrap)) break :wrap_mode .reverse; + break :wrap_mode .none; + }; + + var count: usize = @max(count_req, 1); + + // If we are in no wrap mode, then we move the cursor left and exit + // since this is the fastest and most typical path. + if (wrap_mode == .none) { + self.screen.cursor.x -|= count; + self.screen.cursor.pending_wrap = false; + return; + } + + // If we have a pending wrap state and we are in either reverse wrap + // modes then we decrement the amount we move by one to match xterm. + if (self.screen.cursor.pending_wrap) { + count -= 1; + self.screen.cursor.pending_wrap = false; + } + + // The margins we can move to. + const top = self.scrolling_region.top; + const bottom = self.scrolling_region.bottom; + const right_margin = self.scrolling_region.right; + const left_margin = if (self.screen.cursor.x < self.scrolling_region.left) + 0 + else + self.scrolling_region.left; + + // Handle some edge cases when our cursor is already on the left margin. + if (self.screen.cursor.x == left_margin) { + switch (wrap_mode) { + // In reverse mode, if we're already before the top margin + // then we just set our cursor to the top-left and we're done. + .reverse => if (self.screen.cursor.y <= top) { + self.screen.cursor.x = left_margin; + self.screen.cursor.y = top; + return; + }, + + // Handled in while loop + .reverse_extended => {}, + + // Handled above + .none => unreachable, + } + } + + while (true) { + // We can move at most to the left margin. + const max = self.screen.cursor.x - left_margin; + + // We want to move at most the number of columns we have left + // or our remaining count. Do the move. + const amount = @min(max, count); + count -= amount; + self.screen.cursor.x -= amount; + + // If we have no more to move, then we're done. + if (count == 0) break; + + // If we are at the top, then we are done. + if (self.screen.cursor.y == top) { + if (wrap_mode != .reverse_extended) break; + + self.screen.cursor.y = bottom; + self.screen.cursor.x = right_margin; + count -= 1; + continue; + } + + // UNDEFINED TERMINAL BEHAVIOR. This situation is not handled in xterm + // and currently results in a crash in xterm. Given no other known + // terminal [to me] implements XTREVWRAP2, I decided to just mimick + // the behavior of xterm up and not including the crash by wrapping + // up to the (0, 0) and stopping there. My reasoning is that for an + // appropriately sized value of "count" this is the behavior that xterm + // would have. This is unit tested. + if (self.screen.cursor.y == 0) { + assert(self.screen.cursor.x == left_margin); + break; + } + + // If our previous line is not wrapped then we are done. + if (wrap_mode != .reverse_extended) { + const row = self.screen.getRow(.{ .active = self.screen.cursor.y - 1 }); + if (!row.isWrapped()) break; + } + + self.screen.cursor.y -= 1; + self.screen.cursor.x = right_margin; + count -= 1; + } +} + +/// Move the cursor right amount columns. If amount is greater than the +/// maximum move distance then it is internally adjusted to the maximum. +/// This sequence will not scroll the screen or scroll region. If amount is +/// 0, adjust it to 1. +pub fn cursorRight(self: *Terminal, count_req: usize) void { + // Always resets pending wrap + self.screen.cursor.pending_wrap = false; + + // The max the cursor can move to depends where the cursor currently is + const max = if (self.screen.cursor.x <= self.scrolling_region.right) + self.scrolling_region.right + else + self.cols - 1; + + const count = @max(count_req, 1); + self.screen.cursor.x = @min(max, self.screen.cursor.x +| count); +} + +/// Move the cursor down amount lines. If amount is greater than the maximum +/// move distance then it is internally adjusted to the maximum. This sequence +/// will not scroll the screen or scroll region. If amount is 0, adjust it to 1. +pub fn cursorDown(self: *Terminal, count_req: usize) void { + // Always resets pending wrap + self.screen.cursor.pending_wrap = false; + + // The max the cursor can move to depends where the cursor currently is + const max = if (self.screen.cursor.y <= self.scrolling_region.bottom) + self.scrolling_region.bottom + else + self.rows - 1; + + const count = @max(count_req, 1); + self.screen.cursor.y = @min(max, self.screen.cursor.y +| count); +} + +/// Move the cursor up amount lines. If amount is greater than the maximum +/// move distance then it is internally adjusted to the maximum. If amount is +/// 0, adjust it to 1. +pub fn cursorUp(self: *Terminal, count_req: usize) void { + // Always resets pending wrap + self.screen.cursor.pending_wrap = false; + + // The min the cursor can move to depends where the cursor currently is + const min = if (self.screen.cursor.y >= self.scrolling_region.top) + self.scrolling_region.top + else + 0; + + const count = @max(count_req, 1); + self.screen.cursor.y = @max(min, self.screen.cursor.y -| count); +} + +/// Backspace moves the cursor back a column (but not less than 0). +pub fn backspace(self: *Terminal) void { + self.cursorLeft(1); +} + +/// Horizontal tab moves the cursor to the next tabstop, clearing +/// the screen to the left the tabstop. +pub fn horizontalTab(self: *Terminal) !void { + while (self.screen.cursor.x < self.scrolling_region.right) { + // Move the cursor right + self.screen.cursor.x += 1; + + // If the last cursor position was a tabstop we return. We do + // "last cursor position" because we want a space to be written + // at the tabstop unless we're at the end (the while condition). + if (self.tabstops.get(self.screen.cursor.x)) return; + } +} + +// Same as horizontalTab but moves to the previous tabstop instead of the next. +pub fn horizontalTabBack(self: *Terminal) !void { + // With origin mode enabled, our leftmost limit is the left margin. + const left_limit = if (self.modes.get(.origin)) self.scrolling_region.left else 0; + + while (true) { + // If we're already at the edge of the screen, then we're done. + if (self.screen.cursor.x <= left_limit) return; + + // Move the cursor left + self.screen.cursor.x -= 1; + if (self.tabstops.get(self.screen.cursor.x)) return; + } +} + +/// Clear tab stops. +pub fn tabClear(self: *Terminal, cmd: csi.TabClear) void { + switch (cmd) { + .current => self.tabstops.unset(self.screen.cursor.x), + .all => self.tabstops.reset(0), + else => log.warn("invalid or unknown tab clear setting: {}", .{cmd}), + } +} + +/// Set a tab stop on the current cursor. +/// TODO: test +pub fn tabSet(self: *Terminal) void { + self.tabstops.set(self.screen.cursor.x); +} + +/// TODO: test +pub fn tabReset(self: *Terminal) void { + self.tabstops.reset(TABSTOP_INTERVAL); +} + +/// Carriage return moves the cursor to the first column. +pub fn carriageReturn(self: *Terminal) void { + // Always reset pending wrap state + self.screen.cursor.pending_wrap = false; + + // In origin mode we always move to the left margin + self.screen.cursor.x = if (self.modes.get(.origin)) + self.scrolling_region.left + else if (self.screen.cursor.x >= self.scrolling_region.left) + self.scrolling_region.left + else + 0; +} + +/// Linefeed moves the cursor to the next line. +pub fn linefeed(self: *Terminal) !void { + try self.index(); + if (self.modes.get(.linefeed)) self.carriageReturn(); +} + +/// Inserts spaces at current cursor position moving existing cell contents +/// to the right. The contents of the count right-most columns in the scroll +/// region are lost. The cursor position is not changed. +/// +/// This unsets the pending wrap state without wrapping. +/// +/// The inserted cells are colored according to the current SGR state. +pub fn insertBlanks(self: *Terminal, count: usize) void { + // Unset pending wrap state without wrapping. Note: this purposely + // happens BEFORE the scroll region check below, because that's what + // xterm does. + self.screen.cursor.pending_wrap = false; + + // If our cursor is outside the margins then do nothing. We DO reset + // wrap state still so this must remain below the above logic. + if (self.screen.cursor.x < self.scrolling_region.left or + self.screen.cursor.x > self.scrolling_region.right) return; + + // The limit we can shift to is our right margin. We add 1 since the + // math around this is 1-indexed. + const right_limit = self.scrolling_region.right + 1; + + // If our count is larger than the remaining amount, we just erase right. + // We only do this if we can erase the entire line (no right margin). + if (right_limit == self.cols and + count > right_limit - self.screen.cursor.x) + { + self.eraseLine(.right, false); + return; + } + + // Get the current row + const row = self.screen.getRow(.{ .active = self.screen.cursor.y }); + + // Determine our indexes. + const start = self.screen.cursor.x; + const pivot = @min(self.screen.cursor.x + count, right_limit); + + // 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 = right_limit - pivot; + if (copyable > 0) { + // This is the index of the final copyable value that we need to copy. + const copyable_end = start + copyable - 1; + + // If our last cell we're shifting is wide, then we need to clear + // it to be empty so we don't split the multi-cell char. + const cell = row.getCellPtr(copyable_end); + if (cell.attrs.wide) cell.char = 0; + + // Shift count cells. We have to do this backwards since we're not + // allocated new space, otherwise we'll copy duplicates. + var i: usize = 0; + while (i < copyable) : (i += 1) { + const to = right_limit - 1 - i; + const from = copyable_end - i; + const src = row.getCell(from); + const dst = row.getCellPtr(to); + dst.* = src; + } + } + + // Insert blanks. The blanks preserve the background color. + row.fillSlice(.{ + .bg = self.screen.cursor.pen.bg, + }, start, pivot); +} + +/// Insert amount lines at the current cursor row. The contents of the line +/// at the current cursor row and below (to the bottom-most line in the +/// scrolling region) are shifted down by amount lines. The contents of the +/// amount bottom-most lines in the scroll region are lost. +/// +/// This unsets the pending wrap state without wrapping. If the current cursor +/// position is outside of the current scroll region it does nothing. +/// +/// If amount is greater than the remaining number of lines in the scrolling +/// region it is adjusted down (still allowing for scrolling out every remaining +/// line in the scrolling region) +/// +/// In left and right margin mode the margins are respected; lines are only +/// scrolled in the scroll region. +/// +/// All cleared space is colored according to the current SGR state. +/// +/// Moves the cursor to the left margin. +pub fn insertLines(self: *Terminal, count: usize) !void { + // Rare, but happens + if (count == 0) return; + + // If the cursor is outside the scroll region we do nothing. + if (self.screen.cursor.y < self.scrolling_region.top or + self.screen.cursor.y > self.scrolling_region.bottom or + self.screen.cursor.x < self.scrolling_region.left or + self.screen.cursor.x > self.scrolling_region.right) return; + + // Move the cursor to the left margin + self.screen.cursor.x = self.scrolling_region.left; + self.screen.cursor.pending_wrap = false; + + // Remaining rows from our cursor + const rem = self.scrolling_region.bottom - self.screen.cursor.y + 1; + + // If count is greater than the amount of rows, adjust down. + const adjusted_count = @min(count, rem); + + // The the top `scroll_amount` lines need to move to the bottom + // scroll area. We may have nothing to scroll if we're clearing. + const scroll_amount = rem - adjusted_count; + var y: usize = self.scrolling_region.bottom; + const top = y - scroll_amount; + + // Ensure we have the lines populated to the end + while (y > top) : (y -= 1) { + const src = self.screen.getRow(.{ .active = y - adjusted_count }); + const dst = self.screen.getRow(.{ .active = y }); + for (self.scrolling_region.left..self.scrolling_region.right + 1) |x| { + try dst.copyCell(src, x); + } + } + + // Insert count blank lines + y = self.screen.cursor.y; + while (y < self.screen.cursor.y + adjusted_count) : (y += 1) { + const row = self.screen.getRow(.{ .active = y }); + row.fillSlice(.{ + .bg = self.screen.cursor.pen.bg, + }, self.scrolling_region.left, self.scrolling_region.right + 1); + } +} + +/// Removes amount lines from the current cursor row down. The remaining lines +/// to the bottom margin are shifted up and space from the bottom margin up is +/// filled with empty lines. +/// +/// If the current cursor position is outside of the current scroll region it +/// does nothing. If amount is greater than the remaining number of lines in the +/// scrolling region it is adjusted down. +/// +/// In left and right margin mode the margins are respected; lines are only +/// scrolled in the scroll region. +/// +/// If the cell movement splits a multi cell character that character cleared, +/// by replacing it by spaces, keeping its current attributes. All other +/// cleared space is colored according to the current SGR state. +/// +/// Moves the cursor to the left margin. +pub fn deleteLines(self: *Terminal, count: usize) !void { + // If the cursor is outside the scroll region we do nothing. + if (self.screen.cursor.y < self.scrolling_region.top or + self.screen.cursor.y > self.scrolling_region.bottom or + self.screen.cursor.x < self.scrolling_region.left or + self.screen.cursor.x > self.scrolling_region.right) return; + + // Move the cursor to the left margin + self.screen.cursor.x = self.scrolling_region.left; + self.screen.cursor.pending_wrap = false; + + // If this is a full line margin then we can do a faster scroll. + if (self.scrolling_region.left == 0 and + self.scrolling_region.right == self.cols - 1) + { + self.screen.scrollRegionUp( + .{ .active = self.screen.cursor.y }, + .{ .active = self.scrolling_region.bottom }, + @min(count, (self.scrolling_region.bottom - self.screen.cursor.y) + 1), + ); + return; + } + + // Left/right margin is set, we need to do a slower scroll. + // Remaining rows from our cursor in the region, 1-indexed. + const rem = self.scrolling_region.bottom - self.screen.cursor.y + 1; + + // If our count is greater than the remaining amount, we can just + // clear the region using insertLines. + if (count >= rem) { + try self.insertLines(count); + return; + } + + // The amount of lines we need to scroll up. + const scroll_amount = rem - count; + const scroll_end_y = self.screen.cursor.y + scroll_amount; + for (self.screen.cursor.y..scroll_end_y) |y| { + const src = self.screen.getRow(.{ .active = y + count }); + const dst = self.screen.getRow(.{ .active = y }); + for (self.scrolling_region.left..self.scrolling_region.right + 1) |x| { + try dst.copyCell(src, x); + } + } + + // Insert blank lines + for (scroll_end_y..self.scrolling_region.bottom + 1) |y| { + const row = self.screen.getRow(.{ .active = y }); + row.setWrapped(false); + row.fillSlice(.{ + .bg = self.screen.cursor.pen.bg, + }, self.scrolling_region.left, self.scrolling_region.right + 1); + } +} + +/// Scroll the text down by one row. +pub fn scrollDown(self: *Terminal, count: usize) !void { + // Preserve the cursor + const cursor = self.screen.cursor; + defer self.screen.cursor = cursor; + + // Move to the top of the scroll region + self.screen.cursor.y = self.scrolling_region.top; + self.screen.cursor.x = self.scrolling_region.left; + try self.insertLines(count); +} + +/// Removes amount lines from the top of the scroll region. The remaining lines +/// to the bottom margin are shifted up and space from the bottom margin up +/// is filled with empty lines. +/// +/// The new lines are created according to the current SGR state. +/// +/// Does not change the (absolute) cursor position. +pub fn scrollUp(self: *Terminal, count: usize) !void { + // Preserve the cursor + const cursor = self.screen.cursor; + defer self.screen.cursor = cursor; + + // Move to the top of the scroll region + self.screen.cursor.y = self.scrolling_region.top; + self.screen.cursor.x = self.scrolling_region.left; + try self.deleteLines(count); +} + +/// Options for scrolling the viewport of the terminal grid. +pub const ScrollViewport = union(enum) { + /// Scroll to the top of the scrollback + top: void, + + /// Scroll to the bottom, i.e. the top of the active area + bottom: void, + + /// Scroll by some delta amount, up is negative. + delta: isize, +}; + +/// Scroll the viewport of the terminal grid. +pub fn scrollViewport(self: *Terminal, behavior: ScrollViewport) !void { + try self.screen.scroll(switch (behavior) { + .top => .{ .top = {} }, + .bottom => .{ .bottom = {} }, + .delta => |delta| .{ .viewport = delta }, + }); +} + +/// Set Top and Bottom Margins If bottom is not specified, 0 or bigger than +/// the number of the bottom-most row, it is adjusted to the number of the +/// bottom most row. +/// +/// If top < bottom set the top and bottom row of the scroll region according +/// to top and bottom and move the cursor to the top-left cell of the display +/// (when in cursor origin mode is set to the top-left cell of the scroll region). +/// +/// Otherwise: Set the top and bottom row of the scroll region to the top-most +/// and bottom-most line of the screen. +/// +/// Top and bottom are 1-indexed. +pub fn setTopAndBottomMargin(self: *Terminal, top_req: usize, bottom_req: usize) void { + const top = @max(1, top_req); + const bottom = @min(self.rows, if (bottom_req == 0) self.rows else bottom_req); + if (top >= bottom) return; + + self.scrolling_region.top = top - 1; + self.scrolling_region.bottom = bottom - 1; + self.setCursorPos(1, 1); +} + +/// DECSLRM +pub fn setLeftAndRightMargin(self: *Terminal, left_req: usize, right_req: usize) void { + // We must have this mode enabled to do anything + if (!self.modes.get(.enable_left_and_right_margin)) return; + + const left = @max(1, left_req); + const right = @min(self.cols, if (right_req == 0) self.cols else right_req); + if (left >= right) return; + + self.scrolling_region.left = left - 1; + self.scrolling_region.right = right - 1; + self.setCursorPos(1, 1); +} + +/// Mark the current semantic prompt information. Current escape sequences +/// (OSC 133) only allow setting this for wherever the current active cursor +/// is located. +pub fn markSemanticPrompt(self: *Terminal, p: SemanticPrompt) void { + //log.debug("semantic_prompt y={} p={}", .{ self.screen.cursor.y, p }); + const row = self.screen.getRow(.{ .active = self.screen.cursor.y }); + row.setSemanticPrompt(switch (p) { + .prompt => .prompt, + .prompt_continuation => .prompt_continuation, + .input => .input, + .command => .command, + }); +} + +/// Returns true if the cursor is currently at a prompt. Another way to look +/// at this is it returns false if the shell is currently outputting something. +/// This requires shell integration (semantic prompt integration). +/// +/// If the shell integration doesn't exist, this will always return false. +pub fn cursorIsAtPrompt(self: *Terminal) bool { + // If we're on the secondary screen, we're never at a prompt. + if (self.active_screen == .alternate) return false; + + var y: usize = 0; + while (y <= self.screen.cursor.y) : (y += 1) { + // We want to go bottom up + const bottom_y = self.screen.cursor.y - y; + const row = self.screen.getRow(.{ .active = bottom_y }); + switch (row.getSemanticPrompt()) { + // If we're at a prompt or input area, then we are at a prompt. + .prompt, + .prompt_continuation, + .input, + => return true, + + // If we have command output, then we're most certainly not + // at a prompt. + .command => return false, + + // If we don't know, we keep searching. + .unknown => {}, + } + } + + return false; } /// Set the pwd for the terminal. @@ -2098,113 +2133,52 @@ pub fn getPwd(self: *const Terminal) ?[]const u8 { return self.pwd.items; } -/// Options for switching to the alternate screen. -pub const AlternateScreenOptions = struct { - cursor_save: bool = false, - clear_on_enter: bool = false, - clear_on_exit: bool = false, -}; - -/// Switch to the alternate screen buffer. +/// Execute a kitty graphics command. The buf is used to populate with +/// the response that should be sent as an APC sequence. The response will +/// be a full, valid APC sequence. /// -/// The alternate screen buffer: -/// * has its own grid -/// * has its own cursor state (included saved cursor) -/// * does not support scrollback -/// -pub fn alternateScreen( +/// If an error occurs, the caller should response to the pty that a +/// an error occurred otherwise the behavior of the graphics protocol is +/// undefined. +pub fn kittyGraphics( self: *Terminal, - options: AlternateScreenOptions, -) void { - //log.info("alt screen active={} options={} cursor={}", .{ self.active_screen, options, self.screen.cursor }); + alloc: Allocator, + cmd: *kitty.graphics.Command, +) ?kitty.graphics.Response { + return kitty.graphics.execute(alloc, self, cmd); +} - // TODO: test - // TODO(mitchellh): what happens if we enter alternate screen multiple times? - // for now, we ignore... - if (self.active_screen == .alternate) return; +/// Set the character protection mode for the terminal. +pub fn setProtectedMode(self: *Terminal, mode: ansi.ProtectedMode) void { + switch (mode) { + .off => { + self.screen.cursor.pen.attrs.protected = false; - // If we requested cursor save, we save the cursor in the primary screen - if (options.cursor_save) self.saveCursor(); + // screen.protected_mode is NEVER reset to ".off" because + // logic such as eraseChars depends on knowing what the + // _most recent_ mode was. + }, - // Switch the screens - const old = self.screen; - self.screen = self.secondary_screen; - self.secondary_screen = old; - self.active_screen = .alternate; + .iso => { + self.screen.cursor.pen.attrs.protected = true; + self.screen.protected_mode = .iso; + }, - // Bring our charset state with us - self.screen.charset = old.charset; - - // Clear our selection - self.screen.selection = null; - - // Mark kitty images as dirty so they redraw - self.screen.kitty_images.dirty = true; - - // Bring our pen with us - self.screen.cursor = old.cursor; - self.screen.cursor.style_id = 0; - self.screen.cursor.style_ref = null; - self.screen.cursorAbsolute(old.cursor.x, old.cursor.y); - - if (options.clear_on_enter) { - self.eraseDisplay(.complete, false); + .dec => { + self.screen.cursor.pen.attrs.protected = true; + self.screen.protected_mode = .dec; + }, } - - // Update any style ref after we erase the display so we definitely have space - self.screen.manualStyleUpdate() catch |err| { - log.warn("style update failed entering alt screen err={}", .{err}); - }; -} - -/// Switch back to the primary screen (reset alternate screen mode). -pub fn primaryScreen( - self: *Terminal, - options: AlternateScreenOptions, -) void { - //log.info("primary screen active={} options={}", .{ self.active_screen, options }); - - // TODO: test - // TODO(mitchellh): what happens if we enter alternate screen multiple times? - if (self.active_screen == .primary) return; - - if (options.clear_on_exit) self.eraseDisplay(.complete, false); - - // Switch the screens - const old = self.screen; - self.screen = self.secondary_screen; - self.secondary_screen = old; - self.active_screen = .primary; - - // Clear our selection - self.screen.selection = null; - - // Mark kitty images as dirty so they redraw - self.screen.kitty_images.dirty = true; - - // Restore the cursor from the primary screen. This should not - // fail because we should not have to allocate memory since swapping - // screens does not create new cursors. - if (options.cursor_save) self.restoreCursor() catch |err| { - log.warn("restore cursor on primary screen failed err={}", .{err}); - }; -} - -/// Return the current string value of the terminal. Newlines are -/// 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 { - return try self.screen.dumpStringAlloc(alloc, .{ .viewport = .{} }); } /// Full reset -pub fn fullReset(self: *Terminal) void { - self.primaryScreen(.{ .clear_on_exit = true, .cursor_save = true }); +pub fn fullReset(self: *Terminal, alloc: Allocator) void { + self.primaryScreen(alloc, .{ .clear_on_exit = true, .cursor_save = true }); self.screen.charset = .{}; self.modes = .{}; self.flags = .{}; self.tabstops.reset(TABSTOP_INTERVAL); + self.screen.cursor = .{}; self.screen.saved_cursor = null; self.screen.selection = null; self.screen.kitty_keyboard = .{}; @@ -2216,62 +2190,68 @@ pub fn fullReset(self: *Terminal) void { .right = self.cols - 1, }; self.previous_char = null; - self.eraseDisplay(.scrollback, false); - self.eraseDisplay(.complete, false); - self.screen.cursorAbsolute(0, 0); + self.eraseDisplay(alloc, .scrollback, false); + self.eraseDisplay(alloc, .complete, false); self.pwd.clearRetainingCapacity(); self.status_display = .main; } +// X +test "Terminal: fullReset with a non-empty pen" { + var t = try init(testing.allocator, 80, 80); + defer t.deinit(testing.allocator); + + t.screen.cursor.pen.bg = .{ .rgb = .{ .r = 0xFF, .g = 0x00, .b = 0x7F } }; + t.screen.cursor.pen.fg = .{ .rgb = .{ .r = 0xFF, .g = 0x00, .b = 0x7F } }; + t.fullReset(testing.allocator); + + const cell = t.screen.getCell(.active, t.screen.cursor.y, t.screen.cursor.x); + try testing.expect(cell.bg == .none); + try testing.expect(cell.fg == .none); +} + +// X +test "Terminal: fullReset origin mode" { + var t = try init(testing.allocator, 10, 10); + defer t.deinit(testing.allocator); + + t.setCursorPos(3, 5); + t.modes.set(.origin, true); + t.fullReset(testing.allocator); + + // Origin mode should be reset and the cursor should be moved + try testing.expectEqual(@as(usize, 0), t.screen.cursor.y); + try testing.expectEqual(@as(usize, 0), t.screen.cursor.x); + try testing.expect(!t.modes.get(.origin)); +} + +// X +test "Terminal: fullReset status display" { + var t = try init(testing.allocator, 10, 10); + defer t.deinit(testing.allocator); + + t.status_display = .status_line; + t.fullReset(testing.allocator); + try testing.expect(t.status_display == .main); +} + +// X test "Terminal: input with no control characters" { - const alloc = testing.allocator; - var t = try init(alloc, 40, 40); - defer t.deinit(alloc); + var t = try init(testing.allocator, 80, 80); + defer t.deinit(testing.allocator); // Basic grid writing for ("hello") |c| try t.print(c); try testing.expectEqual(@as(usize, 0), t.screen.cursor.y); try testing.expectEqual(@as(usize, 5), t.screen.cursor.x); { - const str = try t.plainString(alloc); - defer alloc.free(str); + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); try testing.expectEqualStrings("hello", str); } } -test "Terminal: input with basic wraparound" { - const alloc = testing.allocator; - var t = try init(alloc, 5, 40); - defer t.deinit(alloc); - - // Basic grid writing - for ("helloworldabc12") |c| try t.print(c); - try testing.expectEqual(@as(usize, 2), t.screen.cursor.y); - try testing.expectEqual(@as(usize, 4), t.screen.cursor.x); - try testing.expect(t.screen.cursor.pending_wrap); - { - const str = try t.plainString(alloc); - defer alloc.free(str); - try testing.expectEqualStrings("hello\nworld\nabc12", str); - } -} - -test "Terminal: input that forces scroll" { - const alloc = testing.allocator; - var t = try init(alloc, 1, 5); - defer t.deinit(alloc); - - // Basic grid writing - for ("abcdef") |c| try t.print(c); - try testing.expectEqual(@as(usize, 4), t.screen.cursor.y); - try testing.expectEqual(@as(usize, 0), t.screen.cursor.x); - { - const str = try t.plainString(alloc); - defer alloc.free(str); - try testing.expectEqualStrings("b\nc\nd\ne\nf", str); - } -} - +// X test "Terminal: zero-width character at start" { var t = try init(testing.allocator, 80, 80); defer t.deinit(testing.allocator); @@ -2285,61 +2265,17 @@ test "Terminal: zero-width character at start" { } // https://github.com/mitchellh/ghostty/issues/1400 +// X test "Terminal: print single very long line" { var t = try init(testing.allocator, 5, 5); defer t.deinit(testing.allocator); // This would crash for issue 1400. So the assertion here is // that we simply do not crash. - for (0..1000) |_| try t.print('x'); -} - -test "Terminal: print wide char" { - var t = try init(testing.allocator, 80, 80); - defer t.deinit(testing.allocator); - - try t.print(0x1F600); // Smiley face - try testing.expectEqual(@as(usize, 0), t.screen.cursor.y); - try testing.expectEqual(@as(usize, 2), t.screen.cursor.x); - - { - const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; - const cell = list_cell.cell; - try testing.expectEqual(@as(u21, 0x1F600), cell.content.codepoint); - try testing.expectEqual(Cell.Wide.wide, cell.wide); - } - { - const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 1, .y = 0 } }).?; - const cell = list_cell.cell; - try testing.expectEqual(Cell.Wide.spacer_tail, cell.wide); - } -} - -test "Terminal: print wide char with 1-column width" { - const alloc = testing.allocator; - var t = try init(alloc, 1, 2); - defer t.deinit(alloc); - - try t.print('😀'); // 0x1F600 -} - -test "Terminal: print wide char in single-width terminal" { - var t = try init(testing.allocator, 1, 80); - defer t.deinit(testing.allocator); - - try t.print(0x1F600); // Smiley face - try testing.expectEqual(@as(usize, 0), t.screen.cursor.y); - try testing.expectEqual(@as(usize, 0), t.screen.cursor.x); - try testing.expect(t.screen.cursor.pending_wrap); - - { - const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; - const cell = list_cell.cell; - try testing.expectEqual(@as(u21, ' '), cell.content.codepoint); - try testing.expectEqual(Cell.Wide.narrow, cell.wide); - } + for (0..500) |_| try t.print('x'); } +// X test "Terminal: print over wide char at 0,0" { var t = try init(testing.allocator, 80, 80); defer t.deinit(testing.allocator); @@ -2351,20 +2287,20 @@ test "Terminal: print over wide char at 0,0" { try testing.expectEqual(@as(usize, 0), t.screen.cursor.y); try testing.expectEqual(@as(usize, 1), t.screen.cursor.x); + const row = t.screen.getRow(.{ .screen = 0 }); { - const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; - const cell = list_cell.cell; - try testing.expectEqual(@as(u21, 'A'), cell.content.codepoint); - try testing.expectEqual(Cell.Wide.narrow, cell.wide); + const cell = row.getCell(0); + try testing.expectEqual(@as(u32, 'A'), cell.char); + try testing.expect(!cell.attrs.wide); + try testing.expectEqual(@as(usize, 1), row.codepointLen(0)); } { - const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 1, .y = 0 } }).?; - const cell = list_cell.cell; - try testing.expectEqual(@as(u21, 0), cell.content.codepoint); - try testing.expectEqual(Cell.Wide.narrow, cell.wide); + const cell = row.getCell(1); + try testing.expect(!cell.attrs.wide_spacer_tail); } } +// X test "Terminal: print over wide spacer tail" { var t = try init(testing.allocator, 5, 5); defer t.deinit(testing.allocator); @@ -2373,26 +2309,140 @@ test "Terminal: print over wide spacer tail" { t.setCursorPos(1, 2); try t.print('X'); - { - const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; - const cell = list_cell.cell; - try testing.expectEqual(@as(u21, 0), cell.content.codepoint); - try testing.expectEqual(Cell.Wide.narrow, cell.wide); - } - { - const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 1, .y = 0 } }).?; - const cell = list_cell.cell; - try testing.expectEqual(@as(u21, 'X'), cell.content.codepoint); - try testing.expectEqual(Cell.Wide.narrow, cell.wide); - } - { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); try testing.expectEqualStrings(" X", str); } + + const row = t.screen.getRow(.{ .screen = 0 }); + { + const cell = row.getCell(0); + try testing.expectEqual(@as(u32, 0), cell.char); + try testing.expect(!cell.attrs.wide); + try testing.expectEqual(@as(usize, 1), row.codepointLen(0)); + } + { + const cell = row.getCell(1); + try testing.expectEqual(@as(u32, 'X'), cell.char); + try testing.expect(!cell.attrs.wide_spacer_tail); + try testing.expectEqual(@as(usize, 1), row.codepointLen(1)); + } } +// X +test "Terminal: VS15 to make narrow character" { + var t = try init(testing.allocator, 5, 5); + defer t.deinit(testing.allocator); + + // Enable grapheme clustering + t.modes.set(.grapheme_cluster, true); + + try t.print(0x26C8); // Thunder cloud and rain + try t.print(0xFE0E); // VS15 to make narrow + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("⛈︎", str); + } + + const row = t.screen.getRow(.{ .screen = 0 }); + { + const cell = row.getCell(0); + try testing.expectEqual(@as(u32, 0x26C8), cell.char); + try testing.expect(!cell.attrs.wide); + try testing.expectEqual(@as(usize, 2), row.codepointLen(0)); + } +} + +// X +test "Terminal: VS16 to make wide character with mode 2027" { + var t = try init(testing.allocator, 5, 5); + defer t.deinit(testing.allocator); + + // Enable grapheme clustering + t.modes.set(.grapheme_cluster, true); + + try t.print(0x2764); // Heart + try t.print(0xFE0F); // VS16 to make wide + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("❤️", str); + } + + const row = t.screen.getRow(.{ .screen = 0 }); + { + const cell = row.getCell(0); + try testing.expectEqual(@as(u32, 0x2764), cell.char); + try testing.expect(cell.attrs.wide); + try testing.expectEqual(@as(usize, 2), row.codepointLen(0)); + } +} + +// X +test "Terminal: VS16 repeated with mode 2027" { + var t = try init(testing.allocator, 5, 5); + defer t.deinit(testing.allocator); + + // Enable grapheme clustering + t.modes.set(.grapheme_cluster, true); + + try t.print(0x2764); // Heart + try t.print(0xFE0F); // VS16 to make wide + try t.print(0x2764); // Heart + try t.print(0xFE0F); // VS16 to make wide + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("❤️❤️", str); + } + + const row = t.screen.getRow(.{ .screen = 0 }); + { + const cell = row.getCell(0); + try testing.expectEqual(@as(u32, 0x2764), cell.char); + try testing.expect(cell.attrs.wide); + try testing.expectEqual(@as(usize, 2), row.codepointLen(0)); + } + { + const cell = row.getCell(2); + try testing.expectEqual(@as(u32, 0x2764), cell.char); + try testing.expect(cell.attrs.wide); + try testing.expectEqual(@as(usize, 2), row.codepointLen(2)); + } +} + +// X +test "Terminal: VS16 doesn't make character with 2027 disabled" { + var t = try init(testing.allocator, 5, 5); + defer t.deinit(testing.allocator); + + // Disable grapheme clustering + t.modes.set(.grapheme_cluster, false); + + try t.print(0x2764); // Heart + try t.print(0xFE0F); // VS16 to make wide + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("❤️", str); + } + + const row = t.screen.getRow(.{ .screen = 0 }); + { + const cell = row.getCell(0); + try testing.expectEqual(@as(u32, 0x2764), cell.char); + try testing.expect(!cell.attrs.wide); + try testing.expectEqual(@as(usize, 2), row.codepointLen(0)); + } +} + +// X test "Terminal: print multicodepoint grapheme, disabled mode 2027" { var t = try init(testing.allocator, 80, 80); defer t.deinit(testing.allocator); @@ -2411,113 +2461,44 @@ test "Terminal: print multicodepoint grapheme, disabled mode 2027" { // Assert various properties about our screen to verify // we have all expected cells. + const row = t.screen.getRow(.{ .screen = 0 }); { - const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; - const cell = list_cell.cell; - try testing.expectEqual(@as(u21, 0x1F468), cell.content.codepoint); - try testing.expect(cell.hasGrapheme()); - try testing.expectEqual(Cell.Wide.wide, cell.wide); - const cps = list_cell.page.data.lookupGrapheme(cell).?; - try testing.expectEqual(@as(usize, 1), cps.len); + const cell = row.getCell(0); + try testing.expectEqual(@as(u32, 0x1F468), cell.char); + try testing.expect(cell.attrs.wide); + try testing.expectEqual(@as(usize, 2), row.codepointLen(0)); } { - const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 1, .y = 0 } }).?; - const cell = list_cell.cell; - try testing.expectEqual(@as(u21, ' '), cell.content.codepoint); - try testing.expect(!cell.hasGrapheme()); - try testing.expectEqual(Cell.Wide.spacer_tail, cell.wide); - try testing.expect(list_cell.page.data.lookupGrapheme(cell) == null); + const cell = row.getCell(1); + try testing.expectEqual(@as(u32, ' '), cell.char); + try testing.expect(cell.attrs.wide_spacer_tail); + try testing.expectEqual(@as(usize, 1), row.codepointLen(1)); } { - const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 2, .y = 0 } }).?; - const cell = list_cell.cell; - try testing.expectEqual(@as(u21, 0x1F469), cell.content.codepoint); - try testing.expect(cell.hasGrapheme()); - try testing.expectEqual(Cell.Wide.wide, cell.wide); - const cps = list_cell.page.data.lookupGrapheme(cell).?; - try testing.expectEqual(@as(usize, 1), cps.len); + const cell = row.getCell(2); + try testing.expectEqual(@as(u32, 0x1F469), cell.char); + try testing.expect(cell.attrs.wide); + try testing.expectEqual(@as(usize, 2), row.codepointLen(2)); } { - const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 3, .y = 0 } }).?; - const cell = list_cell.cell; - try testing.expectEqual(@as(u21, ' '), cell.content.codepoint); - try testing.expect(!cell.hasGrapheme()); - try testing.expectEqual(Cell.Wide.spacer_tail, cell.wide); - try testing.expect(list_cell.page.data.lookupGrapheme(cell) == null); + const cell = row.getCell(3); + try testing.expectEqual(@as(u32, ' '), cell.char); + try testing.expect(cell.attrs.wide_spacer_tail); } { - const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 4, .y = 0 } }).?; - const cell = list_cell.cell; - try testing.expectEqual(@as(u21, 0x1F467), cell.content.codepoint); - try testing.expect(!cell.hasGrapheme()); - try testing.expectEqual(Cell.Wide.wide, cell.wide); - try testing.expect(list_cell.page.data.lookupGrapheme(cell) == null); + const cell = row.getCell(4); + try testing.expectEqual(@as(u32, 0x1F467), cell.char); + try testing.expect(cell.attrs.wide); + try testing.expectEqual(@as(usize, 1), row.codepointLen(4)); } { - const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 5, .y = 0 } }).?; - const cell = list_cell.cell; - try testing.expectEqual(@as(u21, ' '), cell.content.codepoint); - try testing.expect(!cell.hasGrapheme()); - try testing.expectEqual(Cell.Wide.spacer_tail, cell.wide); - try testing.expect(list_cell.page.data.lookupGrapheme(cell) == null); - } -} - -test "Terminal: VS16 doesn't make character with 2027 disabled" { - var t = try init(testing.allocator, 5, 5); - defer t.deinit(testing.allocator); - - // Disable grapheme clustering - t.modes.set(.grapheme_cluster, false); - - try t.print(0x2764); // Heart - try t.print(0xFE0F); // VS16 to make wide - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("❤️", str); - } - - { - const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; - const cell = list_cell.cell; - try testing.expectEqual(@as(u21, 0x2764), cell.content.codepoint); - try testing.expect(cell.hasGrapheme()); - try testing.expectEqual(Cell.Wide.narrow, cell.wide); - const cps = list_cell.page.data.lookupGrapheme(cell).?; - try testing.expectEqual(@as(usize, 1), cps.len); - } -} - -test "Terminal: print invalid VS16 non-grapheme" { - var t = try init(testing.allocator, 80, 80); - defer t.deinit(testing.allocator); - - // https://github.com/mitchellh/ghostty/issues/1482 - try t.print('x'); - try t.print(0xFE0F); - - // We should have 2 cells taken up. It is one character but "wide". - try testing.expectEqual(@as(usize, 0), t.screen.cursor.y); - try testing.expectEqual(@as(usize, 1), t.screen.cursor.x); - - // Assert various properties about our screen to verify - // we have all expected cells. - { - const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; - const cell = list_cell.cell; - try testing.expectEqual(@as(u21, 'x'), cell.content.codepoint); - try testing.expect(!cell.hasGrapheme()); - try testing.expectEqual(Cell.Wide.narrow, cell.wide); - } - { - const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 1, .y = 0 } }).?; - const cell = list_cell.cell; - try testing.expectEqual(@as(u21, 0), cell.content.codepoint); + const cell = row.getCell(5); + try testing.expectEqual(@as(u32, ' '), cell.char); + try testing.expect(cell.attrs.wide_spacer_tail); } } +// X test "Terminal: print multicodepoint grapheme, mode 2027" { var t = try init(testing.allocator, 80, 80); defer t.deinit(testing.allocator); @@ -2539,116 +2520,50 @@ test "Terminal: print multicodepoint grapheme, mode 2027" { // Assert various properties about our screen to verify // we have all expected cells. + const row = t.screen.getRow(.{ .screen = 0 }); { - const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; - const cell = list_cell.cell; - try testing.expectEqual(@as(u21, 0x1F468), cell.content.codepoint); - try testing.expect(cell.hasGrapheme()); - try testing.expectEqual(Cell.Wide.wide, cell.wide); - const cps = list_cell.page.data.lookupGrapheme(cell).?; - try testing.expectEqual(@as(usize, 4), cps.len); + const cell = row.getCell(0); + try testing.expectEqual(@as(u32, 0x1F468), cell.char); + try testing.expect(cell.attrs.wide); + try testing.expectEqual(@as(usize, 5), row.codepointLen(0)); } { - const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 1, .y = 0 } }).?; - const cell = list_cell.cell; - try testing.expectEqual(@as(u21, ' '), cell.content.codepoint); - try testing.expect(!cell.hasGrapheme()); - try testing.expectEqual(Cell.Wide.spacer_tail, cell.wide); + const cell = row.getCell(1); + try testing.expectEqual(@as(u32, ' '), cell.char); + try testing.expect(cell.attrs.wide_spacer_tail); + try testing.expectEqual(@as(usize, 1), row.codepointLen(1)); } } -test "Terminal: VS15 to make narrow character" { - var t = try init(testing.allocator, 5, 5); +// X +test "Terminal: print invalid VS16 non-grapheme" { + var t = try init(testing.allocator, 80, 80); defer t.deinit(testing.allocator); - // Enable grapheme clustering - t.modes.set(.grapheme_cluster, true); + // https://github.com/mitchellh/ghostty/issues/1482 + try t.print('x'); + try t.print(0xFE0F); - try t.print(0x26C8); // Thunder cloud and rain - try t.print(0xFE0E); // VS15 to make narrow + // We should have 2 cells taken up. It is one character but "wide". + try testing.expectEqual(@as(usize, 0), t.screen.cursor.y); + try testing.expectEqual(@as(usize, 1), t.screen.cursor.x); + // Assert various properties about our screen to verify + // we have all expected cells. + const row = t.screen.getRow(.{ .screen = 0 }); { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("⛈︎", str); + const cell = row.getCell(0); + try testing.expectEqual(@as(u32, 'x'), cell.char); + try testing.expect(!cell.attrs.wide); + try testing.expectEqual(@as(usize, 1), row.codepointLen(0)); } - { - const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; - const cell = list_cell.cell; - try testing.expectEqual(@as(u21, 0x26C8), cell.content.codepoint); - try testing.expect(cell.hasGrapheme()); - try testing.expectEqual(Cell.Wide.narrow, cell.wide); - const cps = list_cell.page.data.lookupGrapheme(cell).?; - try testing.expectEqual(@as(usize, 1), cps.len); - } -} - -test "Terminal: VS16 to make wide character with mode 2027" { - var t = try init(testing.allocator, 5, 5); - defer t.deinit(testing.allocator); - - // Enable grapheme clustering - t.modes.set(.grapheme_cluster, true); - - try t.print(0x2764); // Heart - try t.print(0xFE0F); // VS16 to make wide - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("❤️", str); - } - - { - const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; - const cell = list_cell.cell; - try testing.expectEqual(@as(u21, 0x2764), cell.content.codepoint); - try testing.expect(cell.hasGrapheme()); - try testing.expectEqual(Cell.Wide.wide, cell.wide); - const cps = list_cell.page.data.lookupGrapheme(cell).?; - try testing.expectEqual(@as(usize, 1), cps.len); - } -} - -test "Terminal: VS16 repeated with mode 2027" { - var t = try init(testing.allocator, 5, 5); - defer t.deinit(testing.allocator); - - // Enable grapheme clustering - t.modes.set(.grapheme_cluster, true); - - try t.print(0x2764); // Heart - try t.print(0xFE0F); // VS16 to make wide - try t.print(0x2764); // Heart - try t.print(0xFE0F); // VS16 to make wide - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("❤️❤️", str); - } - - { - const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; - const cell = list_cell.cell; - try testing.expectEqual(@as(u21, 0x2764), cell.content.codepoint); - try testing.expect(cell.hasGrapheme()); - try testing.expectEqual(Cell.Wide.wide, cell.wide); - const cps = list_cell.page.data.lookupGrapheme(cell).?; - try testing.expectEqual(@as(usize, 1), cps.len); - } - { - const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 2, .y = 0 } }).?; - const cell = list_cell.cell; - try testing.expectEqual(@as(u21, 0x2764), cell.content.codepoint); - try testing.expect(cell.hasGrapheme()); - try testing.expectEqual(Cell.Wide.wide, cell.wide); - const cps = list_cell.page.data.lookupGrapheme(cell).?; - try testing.expectEqual(@as(usize, 1), cps.len); + const cell = row.getCell(1); + try testing.expectEqual(@as(u32, 0), cell.char); } } +// X test "Terminal: print invalid VS16 grapheme" { var t = try init(testing.allocator, 80, 80); defer t.deinit(testing.allocator); @@ -2666,21 +2581,20 @@ test "Terminal: print invalid VS16 grapheme" { // Assert various properties about our screen to verify // we have all expected cells. + const row = t.screen.getRow(.{ .screen = 0 }); { - const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; - const cell = list_cell.cell; - try testing.expectEqual(@as(u21, 'x'), cell.content.codepoint); - try testing.expect(!cell.hasGrapheme()); - try testing.expectEqual(Cell.Wide.narrow, cell.wide); + const cell = row.getCell(0); + try testing.expectEqual(@as(u32, 'x'), cell.char); + try testing.expect(!cell.attrs.wide); + try testing.expectEqual(@as(usize, 1), row.codepointLen(0)); } { - const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 1, .y = 0 } }).?; - const cell = list_cell.cell; - try testing.expectEqual(@as(u21, 0), cell.content.codepoint); - try testing.expectEqual(Cell.Wide.narrow, cell.wide); + const cell = row.getCell(1); + try testing.expectEqual(@as(u32, 0), cell.char); } } +// X test "Terminal: print invalid VS16 with second char" { var t = try init(testing.allocator, 80, 80); defer t.deinit(testing.allocator); @@ -2699,50 +2613,145 @@ test "Terminal: print invalid VS16 with second char" { // Assert various properties about our screen to verify // we have all expected cells. + const row = t.screen.getRow(.{ .screen = 0 }); { - const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; - const cell = list_cell.cell; - try testing.expectEqual(@as(u21, 'x'), cell.content.codepoint); - try testing.expect(!cell.hasGrapheme()); - try testing.expectEqual(Cell.Wide.narrow, cell.wide); + const cell = row.getCell(0); + try testing.expectEqual(@as(u32, 'x'), cell.char); + try testing.expect(!cell.attrs.wide); + try testing.expectEqual(@as(usize, 1), row.codepointLen(0)); } - { - const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 1, .y = 0 } }).?; - const cell = list_cell.cell; - try testing.expectEqual(@as(u21, 'y'), cell.content.codepoint); - try testing.expect(!cell.hasGrapheme()); - try testing.expectEqual(Cell.Wide.narrow, cell.wide); + const cell = row.getCell(1); + try testing.expectEqual(@as(u32, 'y'), cell.char); + try testing.expect(!cell.attrs.wide); + try testing.expectEqual(@as(usize, 1), row.codepointLen(0)); } } -test "Terminal: overwrite grapheme should clear grapheme data" { +// X +test "Terminal: soft wrap" { + var t = try init(testing.allocator, 3, 80); + defer t.deinit(testing.allocator); + + // Basic grid writing + for ("hello") |c| try t.print(c); + try testing.expectEqual(@as(usize, 1), t.screen.cursor.y); + try testing.expectEqual(@as(usize, 2), t.screen.cursor.x); + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("hel\nlo", str); + } +} + +// X +test "Terminal: soft wrap with semantic prompt" { + var t = try init(testing.allocator, 3, 80); + defer t.deinit(testing.allocator); + + t.markSemanticPrompt(.prompt); + for ("hello") |c| try t.print(c); + + { + const row = t.screen.getRow(.{ .active = 0 }); + try testing.expect(row.getSemanticPrompt() == .prompt); + } + { + const row = t.screen.getRow(.{ .active = 1 }); + try testing.expect(row.getSemanticPrompt() == .prompt); + } +} + +// X +test "Terminal: disabled wraparound with wide char and one space" { var t = try init(testing.allocator, 5, 5); defer t.deinit(testing.allocator); - // Enable grapheme clustering - t.modes.set(.grapheme_cluster, true); + t.modes.set(.wraparound, false); - try t.print(0x26C8); // Thunder cloud and rain - try t.print(0xFE0E); // VS15 to make narrow - t.setCursorPos(1, 1); - try t.print('A'); + // This puts our cursor at the end and there is NO SPACE for a + // wide character. + try t.printString("AAAA"); + try t.print(0x1F6A8); // Police car light + try testing.expectEqual(@as(usize, 0), t.screen.cursor.y); + try testing.expectEqual(@as(usize, 4), t.screen.cursor.x); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("A", str); + try testing.expectEqualStrings("AAAA", str); } + // Make sure we printed nothing + const row = t.screen.getRow(.{ .screen = 0 }); { - const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; - const cell = list_cell.cell; - try testing.expectEqual(@as(u21, 'A'), cell.content.codepoint); - try testing.expect(!cell.hasGrapheme()); - try testing.expectEqual(Cell.Wide.narrow, cell.wide); + const cell = row.getCell(4); + try testing.expectEqual(@as(u32, 0), cell.char); + try testing.expect(!cell.attrs.wide); } } +// X +test "Terminal: disabled wraparound with wide char and no space" { + var t = try init(testing.allocator, 5, 5); + defer t.deinit(testing.allocator); + + t.modes.set(.wraparound, false); + + // This puts our cursor at the end and there is NO SPACE for a + // wide character. + try t.printString("AAAAA"); + try t.print(0x1F6A8); // Police car light + try testing.expectEqual(@as(usize, 0), t.screen.cursor.y); + try testing.expectEqual(@as(usize, 4), t.screen.cursor.x); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("AAAAA", str); + } + + // Make sure we printed nothing + const row = t.screen.getRow(.{ .screen = 0 }); + { + const cell = row.getCell(4); + try testing.expectEqual(@as(u32, 'A'), cell.char); + try testing.expect(!cell.attrs.wide); + } +} + +// X +test "Terminal: disabled wraparound with wide grapheme and half space" { + var t = try init(testing.allocator, 5, 5); + defer t.deinit(testing.allocator); + + t.modes.set(.grapheme_cluster, true); + t.modes.set(.wraparound, false); + + // This puts our cursor at the end and there is NO SPACE for a + // wide character. + try t.printString("AAAA"); + try t.print(0x2764); // Heart + try t.print(0xFE0F); // VS16 to make wide + try testing.expectEqual(@as(usize, 0), t.screen.cursor.y); + try testing.expectEqual(@as(usize, 4), t.screen.cursor.x); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("AAAA❤", str); + } + + // Make sure we printed nothing + const row = t.screen.getRow(.{ .screen = 0 }); + { + const cell = row.getCell(4); + try testing.expectEqual(@as(u32, '❤'), cell.char); + try testing.expect(!cell.attrs.wide); + } +} + +// X test "Terminal: print writes to bottom if scrolled" { var t = try init(testing.allocator, 5, 2); defer t.deinit(testing.allocator); @@ -2763,7 +2772,7 @@ test "Terminal: print writes to bottom if scrolled" { } // Scroll to the top - t.screen.scroll(.{ .top = {} }); + try t.scrollViewport(.{ .top = {} }); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); @@ -2772,7 +2781,7 @@ test "Terminal: print writes to bottom if scrolled" { // Type try t.print('A'); - t.screen.scroll(.{ .active = {} }); + try t.scrollViewport(.{ .bottom = {} }); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); @@ -2780,6 +2789,7 @@ test "Terminal: print writes to bottom if scrolled" { } } +// X test "Terminal: print charset" { var t = try init(testing.allocator, 80, 80); defer t.deinit(testing.allocator); @@ -2804,6 +2814,7 @@ test "Terminal: print charset" { } } +// X test "Terminal: print charset outside of ASCII" { var t = try init(testing.allocator, 80, 80); defer t.deinit(testing.allocator); @@ -2824,6 +2835,7 @@ test "Terminal: print charset outside of ASCII" { } } +// X test "Terminal: print invoke charset" { var t = try init(testing.allocator, 80, 80); defer t.deinit(testing.allocator); @@ -2844,6 +2856,7 @@ test "Terminal: print invoke charset" { } } +// X test "Terminal: print invoke charset single" { var t = try init(testing.allocator, 80, 80); defer t.deinit(testing.allocator); @@ -2862,122 +2875,7 @@ test "Terminal: print invoke charset single" { } } -test "Terminal: soft wrap" { - var t = try init(testing.allocator, 3, 80); - defer t.deinit(testing.allocator); - - // Basic grid writing - for ("hello") |c| try t.print(c); - try testing.expectEqual(@as(usize, 1), t.screen.cursor.y); - try testing.expectEqual(@as(usize, 2), t.screen.cursor.x); - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("hel\nlo", str); - } -} - -test "Terminal: soft wrap with semantic prompt" { - var t = try init(testing.allocator, 3, 80); - defer t.deinit(testing.allocator); - - t.markSemanticPrompt(.prompt); - for ("hello") |c| try t.print(c); - - { - const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; - try testing.expectEqual(Row.SemanticPrompt.prompt, list_cell.row.semantic_prompt); - } - { - const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 0, .y = 1 } }).?; - try testing.expectEqual(Row.SemanticPrompt.prompt, list_cell.row.semantic_prompt); - } -} - -test "Terminal: disabled wraparound with wide char and one space" { - var t = try init(testing.allocator, 5, 5); - defer t.deinit(testing.allocator); - - t.modes.set(.wraparound, false); - - // This puts our cursor at the end and there is NO SPACE for a - // wide character. - try t.printString("AAAA"); - try t.print(0x1F6A8); // Police car light - try testing.expectEqual(@as(usize, 0), t.screen.cursor.y); - try testing.expectEqual(@as(usize, 4), t.screen.cursor.x); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("AAAA", str); - } - - // Make sure we printed nothing - { - const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 4, .y = 0 } }).?; - const cell = list_cell.cell; - try testing.expectEqual(@as(u21, 0), cell.content.codepoint); - try testing.expectEqual(Cell.Wide.narrow, cell.wide); - } -} - -test "Terminal: disabled wraparound with wide char and no space" { - var t = try init(testing.allocator, 5, 5); - defer t.deinit(testing.allocator); - - t.modes.set(.wraparound, false); - - // This puts our cursor at the end and there is NO SPACE for a - // wide character. - try t.printString("AAAAA"); - try t.print(0x1F6A8); // Police car light - try testing.expectEqual(@as(usize, 0), t.screen.cursor.y); - try testing.expectEqual(@as(usize, 4), t.screen.cursor.x); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("AAAAA", str); - } - - { - const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 4, .y = 0 } }).?; - const cell = list_cell.cell; - try testing.expectEqual(@as(u21, 'A'), cell.content.codepoint); - try testing.expectEqual(Cell.Wide.narrow, cell.wide); - } -} - -test "Terminal: disabled wraparound with wide grapheme and half space" { - var t = try init(testing.allocator, 5, 5); - defer t.deinit(testing.allocator); - - t.modes.set(.grapheme_cluster, true); - t.modes.set(.wraparound, false); - - // This puts our cursor at the end and there is NO SPACE for a - // wide character. - try t.printString("AAAA"); - try t.print(0x2764); // Heart - try t.print(0xFE0F); // VS16 to make wide - try testing.expectEqual(@as(usize, 0), t.screen.cursor.y); - try testing.expectEqual(@as(usize, 4), t.screen.cursor.x); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("AAAA❤", str); - } - - { - const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 4, .y = 0 } }).?; - const cell = list_cell.cell; - try testing.expectEqual(@as(u21, '❤'), cell.content.codepoint); - try testing.expectEqual(Cell.Wide.narrow, cell.wide); - } -} - +// X test "Terminal: print right margin wrap" { var t = try init(testing.allocator, 10, 5); defer t.deinit(testing.allocator); @@ -2995,6 +2893,7 @@ test "Terminal: print right margin wrap" { } } +// X test "Terminal: print right margin outside" { var t = try init(testing.allocator, 10, 5); defer t.deinit(testing.allocator); @@ -3012,6 +2911,7 @@ test "Terminal: print right margin outside" { } } +// X test "Terminal: print right margin outside wrap" { var t = try init(testing.allocator, 10, 5); defer t.deinit(testing.allocator); @@ -3029,6 +2929,7 @@ test "Terminal: print right margin outside wrap" { } } +// X test "Terminal: linefeed and carriage return" { var t = try init(testing.allocator, 80, 80); defer t.deinit(testing.allocator); @@ -3047,6 +2948,7 @@ test "Terminal: linefeed and carriage return" { } } +// X test "Terminal: linefeed unsets pending wrap" { var t = try init(testing.allocator, 5, 80); defer t.deinit(testing.allocator); @@ -3058,6 +2960,7 @@ test "Terminal: linefeed unsets pending wrap" { try testing.expect(t.screen.cursor.pending_wrap == false); } +// X test "Terminal: linefeed mode automatic carriage return" { var t = try init(testing.allocator, 10, 10); defer t.deinit(testing.allocator); @@ -3074,6 +2977,7 @@ test "Terminal: linefeed mode automatic carriage return" { } } +// X test "Terminal: carriage return unsets pending wrap" { var t = try init(testing.allocator, 5, 80); defer t.deinit(testing.allocator); @@ -3085,6 +2989,7 @@ test "Terminal: carriage return unsets pending wrap" { try testing.expect(t.screen.cursor.pending_wrap == false); } +// X test "Terminal: carriage return origin mode moves to left margin" { var t = try init(testing.allocator, 5, 80); defer t.deinit(testing.allocator); @@ -3096,6 +3001,7 @@ test "Terminal: carriage return origin mode moves to left margin" { try testing.expectEqual(@as(usize, 2), t.screen.cursor.x); } +// X test "Terminal: carriage return left of left margin moves to zero" { var t = try init(testing.allocator, 5, 80); defer t.deinit(testing.allocator); @@ -3106,6 +3012,7 @@ test "Terminal: carriage return left of left margin moves to zero" { try testing.expectEqual(@as(usize, 0), t.screen.cursor.x); } +// X test "Terminal: carriage return right of left margin moves to left margin" { var t = try init(testing.allocator, 5, 80); defer t.deinit(testing.allocator); @@ -3116,6 +3023,7 @@ test "Terminal: carriage return right of left margin moves to left margin" { try testing.expectEqual(@as(usize, 2), t.screen.cursor.x); } +// X test "Terminal: backspace" { var t = try init(testing.allocator, 80, 80); defer t.deinit(testing.allocator); @@ -3133,6 +3041,7 @@ test "Terminal: backspace" { } } +// X test "Terminal: horizontal tabs" { const alloc = testing.allocator; var t = try init(alloc, 20, 5); @@ -3154,14 +3063,15 @@ test "Terminal: horizontal tabs" { try testing.expectEqual(@as(usize, 19), t.screen.cursor.x); } +// X test "Terminal: horizontal tabs starting on tabstop" { const alloc = testing.allocator; var t = try init(alloc, 20, 5); defer t.deinit(alloc); - t.setCursorPos(t.screen.cursor.y, 9); + t.screen.cursor.x = 8; try t.print('X'); - t.setCursorPos(t.screen.cursor.y, 9); + t.screen.cursor.x = 8; try t.horizontalTab(); try t.print('A'); @@ -3172,6 +3082,7 @@ test "Terminal: horizontal tabs starting on tabstop" { } } +// X test "Terminal: horizontal tabs with right margin" { const alloc = testing.allocator; var t = try init(alloc, 20, 5); @@ -3179,7 +3090,7 @@ test "Terminal: horizontal tabs with right margin" { t.scrolling_region.left = 2; t.scrolling_region.right = 5; - t.setCursorPos(t.screen.cursor.y, 1); + t.screen.cursor.x = 0; try t.print('X'); try t.horizontalTab(); try t.print('A'); @@ -3191,13 +3102,14 @@ test "Terminal: horizontal tabs with right margin" { } } +// X test "Terminal: horizontal tabs back" { const alloc = testing.allocator; var t = try init(alloc, 20, 5); defer t.deinit(alloc); // Edge of screen - t.setCursorPos(t.screen.cursor.y, 20); + t.screen.cursor.x = 19; // HT try t.horizontalTabBack(); @@ -3214,14 +3126,15 @@ test "Terminal: horizontal tabs back" { try testing.expectEqual(@as(usize, 0), t.screen.cursor.x); } +// X test "Terminal: horizontal tabs back starting on tabstop" { const alloc = testing.allocator; var t = try init(alloc, 20, 5); defer t.deinit(alloc); - t.setCursorPos(t.screen.cursor.y, 9); + t.screen.cursor.x = 8; try t.print('X'); - t.setCursorPos(t.screen.cursor.y, 9); + t.screen.cursor.x = 8; try t.horizontalTabBack(); try t.print('A'); @@ -3232,6 +3145,7 @@ test "Terminal: horizontal tabs back starting on tabstop" { } } +// X test "Terminal: horizontal tabs with left margin in origin mode" { const alloc = testing.allocator; var t = try init(alloc, 20, 5); @@ -3240,7 +3154,7 @@ test "Terminal: horizontal tabs with left margin in origin mode" { t.modes.set(.origin, true); t.scrolling_region.left = 2; t.scrolling_region.right = 5; - t.setCursorPos(1, 2); + t.screen.cursor.x = 3; try t.print('X'); try t.horizontalTabBack(); try t.print('A'); @@ -3252,6 +3166,7 @@ test "Terminal: horizontal tabs with left margin in origin mode" { } } +// X test "Terminal: horizontal tab back with cursor before left margin" { const alloc = testing.allocator; var t = try init(alloc, 20, 5); @@ -3261,7 +3176,7 @@ test "Terminal: horizontal tab back with cursor before left margin" { t.saveCursor(); t.modes.set(.enable_left_and_right_margin, true); t.setLeftAndRightMargin(5, 0); - try t.restoreCursor(); + t.restoreCursor(); try t.horizontalTabBack(); try t.print('X'); @@ -3272,6 +3187,7 @@ test "Terminal: horizontal tab back with cursor before left margin" { } } +// X test "Terminal: cursorPos resets wrap" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); @@ -3290,6 +3206,7 @@ test "Terminal: cursorPos resets wrap" { } } +// X test "Terminal: cursorPos off the screen" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); @@ -3305,6 +3222,7 @@ test "Terminal: cursorPos off the screen" { } } +// X test "Terminal: cursorPos relative to origin" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); @@ -3323,6 +3241,7 @@ test "Terminal: cursorPos relative to origin" { } } +// X test "Terminal: cursorPos relative to origin with left/right" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); @@ -3343,6 +3262,7 @@ test "Terminal: cursorPos relative to origin with left/right" { } } +// X test "Terminal: cursorPos limits with full scroll region" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); @@ -3363,7 +3283,7 @@ test "Terminal: cursorPos limits with full scroll region" { } } -// Probably outdated, but dates back to the original terminal implementation. +// X test "Terminal: setCursorPos (original test)" { var t = try init(testing.allocator, 80, 80); defer t.deinit(testing.allocator); @@ -3416,6 +3336,7 @@ test "Terminal: setCursorPos (original test)" { try testing.expectEqual(@as(usize, 10), t.screen.cursor.y); } +// X test "Terminal: setTopAndBottomMargin simple" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); @@ -3429,7 +3350,7 @@ test "Terminal: setTopAndBottomMargin simple" { try t.linefeed(); try t.printString("GHI"); t.setTopAndBottomMargin(0, 0); - t.scrollDown(1); + try t.scrollDown(1); { const str = try t.plainString(testing.allocator); @@ -3438,6 +3359,7 @@ test "Terminal: setTopAndBottomMargin simple" { } } +// X test "Terminal: setTopAndBottomMargin top only" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); @@ -3451,7 +3373,7 @@ test "Terminal: setTopAndBottomMargin top only" { try t.linefeed(); try t.printString("GHI"); t.setTopAndBottomMargin(2, 0); - t.scrollDown(1); + try t.scrollDown(1); { const str = try t.plainString(testing.allocator); @@ -3460,6 +3382,7 @@ test "Terminal: setTopAndBottomMargin top only" { } } +// X test "Terminal: setTopAndBottomMargin top and bottom" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); @@ -3473,7 +3396,7 @@ test "Terminal: setTopAndBottomMargin top and bottom" { try t.linefeed(); try t.printString("GHI"); t.setTopAndBottomMargin(1, 2); - t.scrollDown(1); + try t.scrollDown(1); { const str = try t.plainString(testing.allocator); @@ -3482,6 +3405,7 @@ test "Terminal: setTopAndBottomMargin top and bottom" { } } +// X test "Terminal: setTopAndBottomMargin top equal to bottom" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); @@ -3495,7 +3419,7 @@ test "Terminal: setTopAndBottomMargin top equal to bottom" { try t.linefeed(); try t.printString("GHI"); t.setTopAndBottomMargin(2, 2); - t.scrollDown(1); + try t.scrollDown(1); { const str = try t.plainString(testing.allocator); @@ -3504,6 +3428,7 @@ test "Terminal: setTopAndBottomMargin top equal to bottom" { } } +// X test "Terminal: setLeftAndRightMargin simple" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); @@ -3527,6 +3452,7 @@ test "Terminal: setLeftAndRightMargin simple" { } } +// X test "Terminal: setLeftAndRightMargin left only" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); @@ -3544,7 +3470,7 @@ test "Terminal: setLeftAndRightMargin left only" { try testing.expectEqual(@as(usize, 1), t.scrolling_region.left); try testing.expectEqual(@as(usize, t.cols - 1), t.scrolling_region.right); t.setCursorPos(1, 2); - t.insertLines(1); + try t.insertLines(1); { const str = try t.plainString(testing.allocator); @@ -3553,6 +3479,7 @@ test "Terminal: setLeftAndRightMargin left only" { } } +// X test "Terminal: setLeftAndRightMargin left and right" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); @@ -3568,7 +3495,7 @@ test "Terminal: setLeftAndRightMargin left and right" { t.modes.set(.enable_left_and_right_margin, true); t.setLeftAndRightMargin(1, 2); t.setCursorPos(1, 2); - t.insertLines(1); + try t.insertLines(1); { const str = try t.plainString(testing.allocator); @@ -3577,6 +3504,7 @@ test "Terminal: setLeftAndRightMargin left and right" { } } +// X test "Terminal: setLeftAndRightMargin left equal right" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); @@ -3592,7 +3520,7 @@ test "Terminal: setLeftAndRightMargin left equal right" { t.modes.set(.enable_left_and_right_margin, true); t.setLeftAndRightMargin(2, 2); t.setCursorPos(1, 2); - t.insertLines(1); + try t.insertLines(1); { const str = try t.plainString(testing.allocator); @@ -3601,6 +3529,7 @@ test "Terminal: setLeftAndRightMargin left equal right" { } } +// X test "Terminal: setLeftAndRightMargin mode 69 unset" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); @@ -3616,7 +3545,7 @@ test "Terminal: setLeftAndRightMargin mode 69 unset" { t.modes.set(.enable_left_and_right_margin, false); t.setLeftAndRightMargin(1, 2); t.setCursorPos(1, 2); - t.insertLines(1); + try t.insertLines(1); { const str = try t.plainString(testing.allocator); @@ -3625,1799 +3554,8 @@ test "Terminal: setLeftAndRightMargin mode 69 unset" { } } -test "Terminal: insertLines simple" { - const alloc = testing.allocator; - var t = try init(alloc, 5, 5); - defer t.deinit(alloc); - - try t.printString("ABC"); - t.carriageReturn(); - try t.linefeed(); - try t.printString("DEF"); - t.carriageReturn(); - try t.linefeed(); - try t.printString("GHI"); - t.setCursorPos(2, 2); - t.insertLines(1); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("ABC\n\nDEF\nGHI", str); - } -} - -test "Terminal: insertLines colors with bg color" { - const alloc = testing.allocator; - var t = try init(alloc, 5, 5); - defer t.deinit(alloc); - - try t.printString("ABC"); - t.carriageReturn(); - try t.linefeed(); - try t.printString("DEF"); - t.carriageReturn(); - try t.linefeed(); - try t.printString("GHI"); - t.setCursorPos(2, 2); - - try t.setAttribute(.{ .direct_color_bg = .{ - .r = 0xFF, - .g = 0, - .b = 0, - } }); - t.insertLines(1); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("ABC\n\nDEF\nGHI", str); - } - - for (0..t.cols) |x| { - const list_cell = t.screen.pages.getCell(.{ .active = .{ .x = x, .y = 1 } }).?; - try testing.expect(list_cell.cell.content_tag == .bg_color_rgb); - try testing.expectEqual(Cell.RGB{ - .r = 0xFF, - .g = 0, - .b = 0, - }, list_cell.cell.content.color_rgb); - } -} - -test "Terminal: insertLines handles style refs" { - const alloc = testing.allocator; - var t = try init(alloc, 5, 3); - defer t.deinit(alloc); - - try t.printString("ABC"); - t.carriageReturn(); - try t.linefeed(); - try t.printString("DEF"); - t.carriageReturn(); - try t.linefeed(); - - // For the line being deleted, create a refcounted style - try t.setAttribute(.{ .bold = {} }); - try t.printString("GHI"); - try t.setAttribute(.{ .unset = {} }); - - // verify we have styles in our style map - const page = t.screen.cursor.page_pin.page.data; - try testing.expectEqual(@as(usize, 1), page.styles.count(page.memory)); - - t.setCursorPos(2, 2); - t.insertLines(1); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("ABC\n\nDEF", str); - } - - // verify we have no styles in our style map - try testing.expectEqual(@as(usize, 0), page.styles.count(page.memory)); -} - -test "Terminal: insertLines outside of scroll region" { - const alloc = testing.allocator; - var t = try init(alloc, 5, 5); - defer t.deinit(alloc); - - try t.printString("ABC"); - t.carriageReturn(); - try t.linefeed(); - try t.printString("DEF"); - t.carriageReturn(); - try t.linefeed(); - try t.printString("GHI"); - t.setTopAndBottomMargin(3, 4); - t.setCursorPos(2, 2); - t.insertLines(1); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("ABC\nDEF\nGHI", str); - } -} - -test "Terminal: insertLines top/bottom scroll region" { - const alloc = testing.allocator; - var t = try init(alloc, 5, 5); - defer t.deinit(alloc); - - try t.printString("ABC"); - t.carriageReturn(); - try t.linefeed(); - try t.printString("DEF"); - t.carriageReturn(); - try t.linefeed(); - try t.printString("GHI"); - t.carriageReturn(); - try t.linefeed(); - try t.printString("123"); - t.setTopAndBottomMargin(1, 3); - t.setCursorPos(2, 2); - t.insertLines(1); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("ABC\n\nDEF\n123", str); - } -} - -test "Terminal: insertLines (legacy test)" { - const alloc = testing.allocator; - var t = try init(alloc, 2, 5); - defer t.deinit(alloc); - - // Initial value - try t.print('A'); - t.carriageReturn(); - try t.linefeed(); - try t.print('B'); - t.carriageReturn(); - try t.linefeed(); - try t.print('C'); - t.carriageReturn(); - try t.linefeed(); - try t.print('D'); - t.carriageReturn(); - try t.linefeed(); - try t.print('E'); - - // Move to row 2 - t.setCursorPos(2, 1); - - // Insert two lines - t.insertLines(2); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("A\n\n\nB\nC", str); - } -} - -test "Terminal: insertLines zero" { - const alloc = testing.allocator; - var t = try init(alloc, 2, 5); - defer t.deinit(alloc); - - // This should do nothing - t.setCursorPos(1, 1); - t.insertLines(0); -} - -test "Terminal: insertLines with scroll region" { - const alloc = testing.allocator; - var t = try init(alloc, 2, 6); - defer t.deinit(alloc); - - // Initial value - try t.print('A'); - t.carriageReturn(); - try t.linefeed(); - try t.print('B'); - t.carriageReturn(); - try t.linefeed(); - try t.print('C'); - t.carriageReturn(); - try t.linefeed(); - try t.print('D'); - t.carriageReturn(); - try t.linefeed(); - try t.print('E'); - - t.setTopAndBottomMargin(1, 2); - t.setCursorPos(1, 1); - t.insertLines(1); - - try t.print('X'); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("X\nA\nC\nD\nE", str); - } -} - -test "Terminal: insertLines more than remaining" { - const alloc = testing.allocator; - var t = try init(alloc, 2, 5); - defer t.deinit(alloc); - - // Initial value - try t.print('A'); - t.carriageReturn(); - try t.linefeed(); - try t.print('B'); - t.carriageReturn(); - try t.linefeed(); - try t.print('C'); - t.carriageReturn(); - try t.linefeed(); - try t.print('D'); - t.carriageReturn(); - try t.linefeed(); - try t.print('E'); - - // Move to row 2 - t.setCursorPos(2, 1); - - // Insert a bunch of lines - t.insertLines(20); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("A", str); - } -} - -test "Terminal: insertLines resets wrap" { - const alloc = testing.allocator; - var t = try init(alloc, 5, 5); - defer t.deinit(alloc); - - for ("ABCDE") |c| try t.print(c); - try testing.expect(t.screen.cursor.pending_wrap); - t.insertLines(1); - try testing.expect(!t.screen.cursor.pending_wrap); - try t.print('B'); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("B\nABCDE", str); - } -} - -test "Terminal: insertLines multi-codepoint graphemes" { - const alloc = testing.allocator; - var t = try init(alloc, 5, 5); - defer t.deinit(alloc); - - // Disable grapheme clustering - t.modes.set(.grapheme_cluster, true); - - try t.printString("ABC"); - t.carriageReturn(); - try t.linefeed(); - - // This is: 👨‍👩‍👧 (which may or may not render correctly) - try t.print(0x1F468); - try t.print(0x200D); - try t.print(0x1F469); - try t.print(0x200D); - try t.print(0x1F467); - - t.carriageReturn(); - try t.linefeed(); - try t.printString("GHI"); - t.setCursorPos(2, 2); - t.insertLines(1); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("ABC\n\n👨‍👩‍👧\nGHI", str); - } -} - -test "Terminal: insertLines left/right scroll region" { - const alloc = testing.allocator; - var t = try init(alloc, 10, 10); - defer t.deinit(alloc); - - try t.printString("ABC123"); - t.carriageReturn(); - try t.linefeed(); - try t.printString("DEF456"); - t.carriageReturn(); - try t.linefeed(); - try t.printString("GHI789"); - t.scrolling_region.left = 1; - t.scrolling_region.right = 3; - t.setCursorPos(2, 2); - t.insertLines(1); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("ABC123\nD 56\nGEF489\n HI7", str); - } -} - -test "Terminal: scrollUp simple" { - const alloc = testing.allocator; - var t = try init(alloc, 5, 5); - defer t.deinit(alloc); - - try t.printString("ABC"); - t.carriageReturn(); - try t.linefeed(); - try t.printString("DEF"); - t.carriageReturn(); - try t.linefeed(); - try t.printString("GHI"); - t.setCursorPos(2, 2); - const cursor = t.screen.cursor; - t.scrollUp(1); - try testing.expectEqual(cursor.x, t.screen.cursor.x); - try testing.expectEqual(cursor.y, t.screen.cursor.y); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("DEF\nGHI", str); - } -} - -test "Terminal: scrollUp top/bottom scroll region" { - const alloc = testing.allocator; - var t = try init(alloc, 5, 5); - defer t.deinit(alloc); - - try t.printString("ABC"); - t.carriageReturn(); - try t.linefeed(); - try t.printString("DEF"); - t.carriageReturn(); - try t.linefeed(); - try t.printString("GHI"); - t.setTopAndBottomMargin(2, 3); - t.setCursorPos(1, 1); - t.scrollUp(1); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("ABC\nGHI", str); - } -} - -test "Terminal: scrollUp left/right scroll region" { - const alloc = testing.allocator; - var t = try init(alloc, 10, 10); - defer t.deinit(alloc); - - try t.printString("ABC123"); - t.carriageReturn(); - try t.linefeed(); - try t.printString("DEF456"); - t.carriageReturn(); - try t.linefeed(); - try t.printString("GHI789"); - t.scrolling_region.left = 1; - t.scrolling_region.right = 3; - t.setCursorPos(2, 2); - const cursor = t.screen.cursor; - t.scrollUp(1); - try testing.expectEqual(cursor.x, t.screen.cursor.x); - try testing.expectEqual(cursor.y, t.screen.cursor.y); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("AEF423\nDHI756\nG 89", str); - } -} - -test "Terminal: scrollUp preserves pending wrap" { - const alloc = testing.allocator; - var t = try init(alloc, 5, 5); - defer t.deinit(alloc); - - t.setCursorPos(1, 5); - try t.print('A'); - t.setCursorPos(2, 5); - try t.print('B'); - t.setCursorPos(3, 5); - try t.print('C'); - t.scrollUp(1); - try t.print('X'); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings(" B\n C\n\nX", str); - } -} - -test "Terminal: scrollUp full top/bottom region" { - const alloc = testing.allocator; - var t = try init(alloc, 5, 5); - defer t.deinit(alloc); - - try t.printString("top"); - t.setCursorPos(5, 1); - try t.printString("ABCDE"); - t.setTopAndBottomMargin(2, 5); - t.scrollUp(4); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("top", str); - } -} - -test "Terminal: scrollUp full top/bottomleft/right scroll region" { - const alloc = testing.allocator; - var t = try init(alloc, 5, 5); - defer t.deinit(alloc); - - try t.printString("top"); - t.setCursorPos(5, 1); - try t.printString("ABCDE"); - t.modes.set(.enable_left_and_right_margin, true); - t.setTopAndBottomMargin(2, 5); - t.setLeftAndRightMargin(2, 4); - t.scrollUp(4); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("top\n\n\n\nA E", str); - } -} - -test "Terminal: scrollDown simple" { - const alloc = testing.allocator; - var t = try init(alloc, 5, 5); - defer t.deinit(alloc); - - try t.printString("ABC"); - t.carriageReturn(); - try t.linefeed(); - try t.printString("DEF"); - t.carriageReturn(); - try t.linefeed(); - try t.printString("GHI"); - t.setCursorPos(2, 2); - const cursor = t.screen.cursor; - t.scrollDown(1); - try testing.expectEqual(cursor.x, t.screen.cursor.x); - try testing.expectEqual(cursor.y, t.screen.cursor.y); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("\nABC\nDEF\nGHI", str); - } -} - -test "Terminal: scrollDown outside of scroll region" { - const alloc = testing.allocator; - var t = try init(alloc, 5, 5); - defer t.deinit(alloc); - - try t.printString("ABC"); - t.carriageReturn(); - try t.linefeed(); - try t.printString("DEF"); - t.carriageReturn(); - try t.linefeed(); - try t.printString("GHI"); - t.setTopAndBottomMargin(3, 4); - t.setCursorPos(2, 2); - const cursor = t.screen.cursor; - t.scrollDown(1); - try testing.expectEqual(cursor.x, t.screen.cursor.x); - try testing.expectEqual(cursor.y, t.screen.cursor.y); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("ABC\nDEF\n\nGHI", str); - } -} - -test "Terminal: scrollDown left/right scroll region" { - const alloc = testing.allocator; - var t = try init(alloc, 10, 10); - defer t.deinit(alloc); - - try t.printString("ABC123"); - t.carriageReturn(); - try t.linefeed(); - try t.printString("DEF456"); - t.carriageReturn(); - try t.linefeed(); - try t.printString("GHI789"); - t.scrolling_region.left = 1; - t.scrolling_region.right = 3; - t.setCursorPos(2, 2); - const cursor = t.screen.cursor; - t.scrollDown(1); - try testing.expectEqual(cursor.x, t.screen.cursor.x); - try testing.expectEqual(cursor.y, t.screen.cursor.y); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("A 23\nDBC156\nGEF489\n HI7", str); - } -} - -test "Terminal: scrollDown outside of left/right scroll region" { - const alloc = testing.allocator; - var t = try init(alloc, 10, 10); - defer t.deinit(alloc); - - try t.printString("ABC123"); - t.carriageReturn(); - try t.linefeed(); - try t.printString("DEF456"); - t.carriageReturn(); - try t.linefeed(); - try t.printString("GHI789"); - t.scrolling_region.left = 1; - t.scrolling_region.right = 3; - t.setCursorPos(1, 1); - const cursor = t.screen.cursor; - t.scrollDown(1); - try testing.expectEqual(cursor.x, t.screen.cursor.x); - try testing.expectEqual(cursor.y, t.screen.cursor.y); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("A 23\nDBC156\nGEF489\n HI7", str); - } -} - -test "Terminal: scrollDown preserves pending wrap" { - const alloc = testing.allocator; - var t = try init(alloc, 5, 10); - defer t.deinit(alloc); - - t.setCursorPos(1, 5); - try t.print('A'); - t.setCursorPos(2, 5); - try t.print('B'); - t.setCursorPos(3, 5); - try t.print('C'); - t.scrollDown(1); - try t.print('X'); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("\n A\n B\nX C", str); - } -} - -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'); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("X C", str); - } -} - -test "Terminal: eraseChars minimum one" { - 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(0); - try t.print('X'); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("XBC", 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); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings(" A", str); - } -} - -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'); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("X BC", str); - } -} - -test "Terminal: eraseChars resets pending wrap" { - const alloc = testing.allocator; - var t = try init(alloc, 5, 5); - defer t.deinit(alloc); - - for ("ABCDE") |c| try t.print(c); - try testing.expect(t.screen.cursor.pending_wrap); - t.eraseChars(1); - try testing.expect(!t.screen.cursor.pending_wrap); - try t.print('X'); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("ABCDX", str); - } -} - -test "Terminal: eraseChars resets wrap" { - const alloc = testing.allocator; - var t = try init(alloc, 5, 5); - defer t.deinit(alloc); - - for ("ABCDE123") |c| try t.print(c); - { - const list_cell = t.screen.pages.getCell(.{ .active = .{ .x = 0, .y = 0 } }).?; - const row = list_cell.row; - try testing.expect(row.wrap); - } - - t.setCursorPos(1, 1); - t.eraseChars(1); - - { - const list_cell = t.screen.pages.getCell(.{ .active = .{ .x = 0, .y = 0 } }).?; - const row = list_cell.row; - try testing.expect(!row.wrap); - } - - try t.print('X'); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("XBCDE\n123", str); - } -} - -test "Terminal: eraseChars preserves background sgr" { - const alloc = testing.allocator; - var t = try init(alloc, 10, 10); - defer t.deinit(alloc); - - for ("ABC") |c| try t.print(c); - t.setCursorPos(1, 1); - try t.setAttribute(.{ .direct_color_bg = .{ - .r = 0xFF, - .g = 0, - .b = 0, - } }); - t.eraseChars(2); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings(" C", str); - { - const list_cell = t.screen.pages.getCell(.{ .active = .{ .x = 0, .y = 0 } }).?; - try testing.expect(list_cell.cell.content_tag == .bg_color_rgb); - try testing.expectEqual(Cell.RGB{ - .r = 0xFF, - .g = 0, - .b = 0, - }, list_cell.cell.content.color_rgb); - } - { - const list_cell = t.screen.pages.getCell(.{ .active = .{ .x = 1, .y = 0 } }).?; - try testing.expect(list_cell.cell.content_tag == .bg_color_rgb); - try testing.expectEqual(Cell.RGB{ - .r = 0xFF, - .g = 0, - .b = 0, - }, list_cell.cell.content.color_rgb); - } - } -} - -test "Terminal: eraseChars handles refcounted styles" { - const alloc = testing.allocator; - var t = try init(alloc, 10, 10); - defer t.deinit(alloc); - - try t.setAttribute(.{ .bold = {} }); - try t.print('A'); - try t.print('B'); - try t.setAttribute(.{ .unset = {} }); - try t.print('C'); - - // verify we have styles in our style map - const page = t.screen.cursor.page_pin.page.data; - try testing.expectEqual(@as(usize, 1), page.styles.count(page.memory)); - - t.setCursorPos(1, 1); - t.eraseChars(2); - - // verify we have no styles in our style map - try testing.expectEqual(@as(usize, 0), page.styles.count(page.memory)); -} - -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); - - { - const 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); - - { - const 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); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings(" C", str); - } -} - -test "Terminal: reverseIndex" { - const alloc = testing.allocator; - var t = try init(alloc, 2, 5); - defer t.deinit(alloc); - - // Initial value - try t.print('A'); - t.carriageReturn(); - try t.linefeed(); - try t.print('B'); - t.carriageReturn(); - try t.linefeed(); - try t.print('C'); - t.reverseIndex(); - try t.print('D'); - t.carriageReturn(); - try t.linefeed(); - t.carriageReturn(); - try t.linefeed(); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("A\nBD\nC", str); - } -} - -test "Terminal: reverseIndex from the top" { - const alloc = testing.allocator; - var t = try init(alloc, 2, 5); - defer t.deinit(alloc); - - try t.print('A'); - t.carriageReturn(); - try t.linefeed(); - try t.print('B'); - t.carriageReturn(); - try t.linefeed(); - t.carriageReturn(); - try t.linefeed(); - - t.setCursorPos(1, 1); - t.reverseIndex(); - try t.print('D'); - - t.carriageReturn(); - try t.linefeed(); - t.setCursorPos(1, 1); - t.reverseIndex(); - try t.print('E'); - t.carriageReturn(); - try t.linefeed(); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("E\nD\nA\nB", str); - } -} - -test "Terminal: reverseIndex top of scrolling region" { - const alloc = testing.allocator; - var t = try init(alloc, 2, 10); - defer t.deinit(alloc); - - // Initial value - t.setCursorPos(2, 1); - try t.print('A'); - t.carriageReturn(); - try t.linefeed(); - try t.print('B'); - t.carriageReturn(); - try t.linefeed(); - try t.print('C'); - t.carriageReturn(); - try t.linefeed(); - try t.print('D'); - t.carriageReturn(); - try t.linefeed(); - - // Set our scroll region - t.setTopAndBottomMargin(2, 5); - t.setCursorPos(2, 1); - t.reverseIndex(); - try t.print('X'); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("\nX\nA\nB\nC", str); - } -} - -test "Terminal: reverseIndex top of screen" { - const alloc = testing.allocator; - var t = try init(alloc, 5, 5); - defer t.deinit(alloc); - - try t.print('A'); - t.setCursorPos(2, 1); - try t.print('B'); - t.setCursorPos(3, 1); - try t.print('C'); - t.setCursorPos(1, 1); - t.reverseIndex(); - try t.print('X'); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("X\nA\nB\nC", str); - } -} - -test "Terminal: reverseIndex not top of screen" { - const alloc = testing.allocator; - var t = try init(alloc, 5, 5); - defer t.deinit(alloc); - - try t.print('A'); - t.setCursorPos(2, 1); - try t.print('B'); - t.setCursorPos(3, 1); - try t.print('C'); - t.setCursorPos(2, 1); - t.reverseIndex(); - try t.print('X'); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("X\nB\nC", str); - } -} - -test "Terminal: reverseIndex top/bottom margins" { - const alloc = testing.allocator; - var t = try init(alloc, 5, 5); - defer t.deinit(alloc); - - try t.print('A'); - t.setCursorPos(2, 1); - try t.print('B'); - t.setCursorPos(3, 1); - try t.print('C'); - t.setTopAndBottomMargin(2, 3); - t.setCursorPos(2, 1); - t.reverseIndex(); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("A\n\nB", str); - } -} - -test "Terminal: reverseIndex outside top/bottom margins" { - const alloc = testing.allocator; - var t = try init(alloc, 5, 5); - defer t.deinit(alloc); - - try t.print('A'); - t.setCursorPos(2, 1); - try t.print('B'); - t.setCursorPos(3, 1); - try t.print('C'); - t.setTopAndBottomMargin(2, 3); - t.setCursorPos(1, 1); - t.reverseIndex(); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("A\nB\nC", str); - } -} - -test "Terminal: reverseIndex left/right margins" { - const alloc = testing.allocator; - var t = try init(alloc, 5, 5); - defer t.deinit(alloc); - - try t.printString("ABC"); - t.setCursorPos(2, 1); - try t.printString("DEF"); - t.setCursorPos(3, 1); - try t.printString("GHI"); - t.modes.set(.enable_left_and_right_margin, true); - t.setLeftAndRightMargin(2, 3); - t.setCursorPos(1, 2); - t.reverseIndex(); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("A\nDBC\nGEF\n HI", str); - } -} - -test "Terminal: reverseIndex outside left/right margins" { - const alloc = testing.allocator; - var t = try init(alloc, 5, 5); - defer t.deinit(alloc); - - try t.printString("ABC"); - t.setCursorPos(2, 1); - try t.printString("DEF"); - t.setCursorPos(3, 1); - try t.printString("GHI"); - t.modes.set(.enable_left_and_right_margin, true); - t.setLeftAndRightMargin(2, 3); - t.setCursorPos(1, 1); - t.reverseIndex(); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("ABC\nDEF\nGHI", str); - } -} - -test "Terminal: index" { - const alloc = testing.allocator; - var t = try init(alloc, 2, 5); - defer t.deinit(alloc); - - try t.index(); - try t.print('A'); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("\nA", str); - } -} - -test "Terminal: index from the bottom" { - const alloc = testing.allocator; - var t = try init(alloc, 2, 5); - defer t.deinit(alloc); - - t.setCursorPos(5, 1); - try t.print('A'); - t.cursorLeft(1); // undo moving right from 'A' - try t.index(); - - try t.print('B'); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("\n\n\nA\nB", str); - } -} - -test "Terminal: index outside of scrolling region" { - const alloc = testing.allocator; - var t = try init(alloc, 2, 5); - defer t.deinit(alloc); - - try testing.expectEqual(@as(usize, 0), t.screen.cursor.y); - t.setTopAndBottomMargin(2, 5); - try t.index(); - try testing.expectEqual(@as(usize, 1), t.screen.cursor.y); -} - -test "Terminal: index from the bottom outside of scroll region" { - const alloc = testing.allocator; - var t = try init(alloc, 2, 5); - defer t.deinit(alloc); - - t.setTopAndBottomMargin(1, 2); - t.setCursorPos(5, 1); - try t.print('A'); - try t.index(); - try t.print('B'); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("\n\n\n\nAB", str); - } -} - -test "Terminal: index no scroll region, top of screen" { - const alloc = testing.allocator; - var t = try init(alloc, 5, 5); - defer t.deinit(alloc); - - try t.print('A'); - try t.index(); - try t.print('X'); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("A\n X", str); - } -} - -test "Terminal: index bottom of primary screen" { - const alloc = testing.allocator; - var t = try init(alloc, 5, 5); - defer t.deinit(alloc); - - t.setCursorPos(5, 1); - try t.print('A'); - try t.index(); - try t.print('X'); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("\n\n\nA\n X", str); - } -} - -test "Terminal: index bottom of primary screen background sgr" { - const alloc = testing.allocator; - var t = try init(alloc, 5, 5); - defer t.deinit(alloc); - - t.setCursorPos(5, 1); - try t.print('A'); - try t.setAttribute(.{ .direct_color_bg = .{ - .r = 0xFF, - .g = 0, - .b = 0, - } }); - try t.index(); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("\n\n\nA", str); - for (0..5) |x| { - const list_cell = t.screen.pages.getCell(.{ .active = .{ .x = x, .y = 4 } }).?; - try testing.expect(list_cell.cell.content_tag == .bg_color_rgb); - try testing.expectEqual(Cell.RGB{ - .r = 0xFF, - .g = 0, - .b = 0, - }, list_cell.cell.content.color_rgb); - } - } -} - -test "Terminal: index inside scroll region" { - const alloc = testing.allocator; - var t = try init(alloc, 5, 5); - defer t.deinit(alloc); - - t.setTopAndBottomMargin(1, 3); - try t.print('A'); - try t.index(); - try t.print('X'); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("A\n X", str); - } -} - -test "Terminal: index bottom of primary screen with scroll region" { - const alloc = testing.allocator; - var t = try init(alloc, 5, 5); - defer t.deinit(alloc); - - t.setTopAndBottomMargin(1, 3); - t.setCursorPos(3, 1); - try t.print('A'); - t.setCursorPos(5, 1); - try t.index(); - try t.index(); - try t.index(); - try t.print('X'); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("\n\nA\n\nX", str); - } -} - -test "Terminal: index outside left/right margin" { - const alloc = testing.allocator; - var t = try init(alloc, 10, 5); - defer t.deinit(alloc); - - t.setTopAndBottomMargin(1, 3); - t.scrolling_region.left = 3; - t.scrolling_region.right = 5; - t.setCursorPos(3, 3); - try t.print('A'); - t.setCursorPos(3, 1); - try t.index(); - try t.print('X'); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("\n\nX A", str); - } -} - -test "Terminal: index inside left/right margin" { - const alloc = testing.allocator; - var t = try init(alloc, 10, 5); - defer t.deinit(alloc); - - try t.printString("AAAAAA"); - t.carriageReturn(); - try t.linefeed(); - try t.printString("AAAAAA"); - t.carriageReturn(); - try t.linefeed(); - try t.printString("AAAAAA"); - t.modes.set(.enable_left_and_right_margin, true); - t.setTopAndBottomMargin(1, 3); - t.setLeftAndRightMargin(1, 3); - t.setCursorPos(3, 1); - try t.index(); - - try testing.expectEqual(@as(usize, 2), t.screen.cursor.y); - try testing.expectEqual(@as(usize, 0), t.screen.cursor.x); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("AAAAAA\nAAAAAA\n AAA", str); - } -} - -test "Terminal: index bottom of scroll region" { - const alloc = testing.allocator; - var t = try init(alloc, 5, 5); - defer t.deinit(alloc); - - t.setTopAndBottomMargin(1, 3); - t.setCursorPos(4, 1); - try t.print('B'); - t.setCursorPos(3, 1); - try t.print('A'); - try t.index(); - try t.print('X'); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("\nA\n X\nB", str); - } -} - -test "Terminal: cursorUp basic" { - const alloc = testing.allocator; - var t = try init(alloc, 5, 5); - defer t.deinit(alloc); - - t.setCursorPos(3, 1); - try t.print('A'); - t.cursorUp(10); - try t.print('X'); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings(" X\n\nA", str); - } -} - -test "Terminal: cursorUp below top scroll margin" { - const alloc = testing.allocator; - var t = try init(alloc, 5, 5); - defer t.deinit(alloc); - - t.setTopAndBottomMargin(2, 4); - t.setCursorPos(3, 1); - try t.print('A'); - t.cursorUp(5); - try t.print('X'); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("\n X\nA", str); - } -} - -test "Terminal: cursorUp above top scroll margin" { - const alloc = testing.allocator; - var t = try init(alloc, 5, 5); - defer t.deinit(alloc); - - t.setTopAndBottomMargin(3, 5); - t.setCursorPos(3, 1); - try t.print('A'); - t.setCursorPos(2, 1); - t.cursorUp(10); - try t.print('X'); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("X\n\nA", str); - } -} - -test "Terminal: cursorUp resets wrap" { - const alloc = testing.allocator; - var t = try init(alloc, 5, 5); - defer t.deinit(alloc); - - for ("ABCDE") |c| try t.print(c); - try testing.expect(t.screen.cursor.pending_wrap); - t.cursorUp(1); - try testing.expect(!t.screen.cursor.pending_wrap); - try t.print('X'); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("ABCDX", str); - } -} - -test "Terminal: cursorLeft no wrap" { - const alloc = testing.allocator; - var t = try init(alloc, 10, 5); - defer t.deinit(alloc); - - try t.print('A'); - t.carriageReturn(); - try t.linefeed(); - try t.print('B'); - t.cursorLeft(10); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("A\nB", str); - } -} - -test "Terminal: cursorLeft unsets pending wrap state" { - const alloc = testing.allocator; - var t = try init(alloc, 5, 5); - defer t.deinit(alloc); - - for ("ABCDE") |c| try t.print(c); - try testing.expect(t.screen.cursor.pending_wrap); - t.cursorLeft(1); - try testing.expect(!t.screen.cursor.pending_wrap); - try t.print('X'); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("ABCXE", str); - } -} - -test "Terminal: cursorLeft unsets pending wrap state with longer jump" { - const alloc = testing.allocator; - var t = try init(alloc, 5, 5); - defer t.deinit(alloc); - - for ("ABCDE") |c| try t.print(c); - try testing.expect(t.screen.cursor.pending_wrap); - t.cursorLeft(3); - try testing.expect(!t.screen.cursor.pending_wrap); - try t.print('X'); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("AXCDE", str); - } -} - -test "Terminal: cursorLeft reverse wrap with pending wrap state" { - const alloc = testing.allocator; - var t = try init(alloc, 5, 5); - defer t.deinit(alloc); - - t.modes.set(.wraparound, true); - t.modes.set(.reverse_wrap, true); - - for ("ABCDE") |c| try t.print(c); - try testing.expect(t.screen.cursor.pending_wrap); - t.cursorLeft(1); - try testing.expect(!t.screen.cursor.pending_wrap); - try t.print('X'); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("ABCDX", str); - } -} - -test "Terminal: cursorLeft reverse wrap extended with pending wrap state" { - const alloc = testing.allocator; - var t = try init(alloc, 5, 5); - defer t.deinit(alloc); - - t.modes.set(.wraparound, true); - t.modes.set(.reverse_wrap_extended, true); - - for ("ABCDE") |c| try t.print(c); - try testing.expect(t.screen.cursor.pending_wrap); - t.cursorLeft(1); - try testing.expect(!t.screen.cursor.pending_wrap); - try t.print('X'); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("ABCDX", str); - } -} - -test "Terminal: cursorLeft reverse wrap" { - const alloc = testing.allocator; - var t = try init(alloc, 5, 5); - defer t.deinit(alloc); - - t.modes.set(.wraparound, true); - t.modes.set(.reverse_wrap, true); - - for ("ABCDE1") |c| try t.print(c); - t.cursorLeft(2); - try t.print('X'); - try testing.expect(t.screen.cursor.pending_wrap); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("ABCDX\n1", str); - } -} - -test "Terminal: cursorLeft reverse wrap with no soft wrap" { - const alloc = testing.allocator; - var t = try init(alloc, 5, 5); - defer t.deinit(alloc); - - t.modes.set(.wraparound, true); - t.modes.set(.reverse_wrap, true); - - for ("ABCDE") |c| try t.print(c); - t.carriageReturn(); - try t.linefeed(); - try t.print('1'); - t.cursorLeft(2); - try t.print('X'); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("ABCDE\nX", str); - } -} - -test "Terminal: cursorLeft reverse wrap before left margin" { - const alloc = testing.allocator; - var t = try init(alloc, 5, 5); - defer t.deinit(alloc); - - t.modes.set(.wraparound, true); - t.modes.set(.reverse_wrap, true); - t.setTopAndBottomMargin(3, 0); - t.cursorLeft(1); - try t.print('X'); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("\n\nX", str); - } -} - -test "Terminal: cursorLeft extended reverse wrap" { - const alloc = testing.allocator; - var t = try init(alloc, 5, 5); - defer t.deinit(alloc); - - t.modes.set(.wraparound, true); - t.modes.set(.reverse_wrap_extended, true); - - for ("ABCDE") |c| try t.print(c); - t.carriageReturn(); - try t.linefeed(); - try t.print('1'); - t.cursorLeft(2); - try t.print('X'); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("ABCDX\n1", str); - } -} - -test "Terminal: cursorLeft extended reverse wrap bottom wraparound" { - const alloc = testing.allocator; - var t = try init(alloc, 5, 3); - defer t.deinit(alloc); - - t.modes.set(.wraparound, true); - t.modes.set(.reverse_wrap_extended, true); - - for ("ABCDE") |c| try t.print(c); - t.carriageReturn(); - try t.linefeed(); - try t.print('1'); - t.cursorLeft(1 + t.cols + 1); - try t.print('X'); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("ABCDE\n1\n X", str); - } -} - -test "Terminal: cursorLeft extended reverse wrap is priority if both set" { - const alloc = testing.allocator; - var t = try init(alloc, 5, 3); - defer t.deinit(alloc); - - t.modes.set(.wraparound, true); - t.modes.set(.reverse_wrap, true); - t.modes.set(.reverse_wrap_extended, true); - - for ("ABCDE") |c| try t.print(c); - t.carriageReturn(); - try t.linefeed(); - try t.print('1'); - t.cursorLeft(1 + t.cols + 1); - try t.print('X'); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("ABCDE\n1\n X", str); - } -} - -test "Terminal: cursorLeft extended reverse wrap above top scroll region" { - const alloc = testing.allocator; - var t = try init(alloc, 5, 5); - defer t.deinit(alloc); - - t.modes.set(.wraparound, true); - t.modes.set(.reverse_wrap_extended, true); - - t.setTopAndBottomMargin(3, 0); - t.setCursorPos(2, 1); - t.cursorLeft(1000); - - try testing.expectEqual(@as(usize, 0), t.screen.cursor.x); - try testing.expectEqual(@as(usize, 0), t.screen.cursor.y); -} - -test "Terminal: cursorLeft reverse wrap on first row" { - const alloc = testing.allocator; - var t = try init(alloc, 5, 5); - defer t.deinit(alloc); - - t.modes.set(.wraparound, true); - t.modes.set(.reverse_wrap, true); - - t.setTopAndBottomMargin(3, 0); - t.setCursorPos(1, 2); - t.cursorLeft(1000); - - try testing.expectEqual(@as(usize, 0), t.screen.cursor.x); - try testing.expectEqual(@as(usize, 0), t.screen.cursor.y); -} - -test "Terminal: cursorDown basic" { - const alloc = testing.allocator; - var t = try init(alloc, 5, 5); - defer t.deinit(alloc); - - try t.print('A'); - t.cursorDown(10); - try t.print('X'); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("A\n\n\n\n X", str); - } -} - -test "Terminal: cursorDown above bottom scroll margin" { - const alloc = testing.allocator; - var t = try init(alloc, 5, 5); - defer t.deinit(alloc); - - t.setTopAndBottomMargin(1, 3); - try t.print('A'); - t.cursorDown(10); - try t.print('X'); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("A\n\n X", str); - } -} - -test "Terminal: cursorDown below bottom scroll margin" { - const alloc = testing.allocator; - var t = try init(alloc, 5, 5); - defer t.deinit(alloc); - - t.setTopAndBottomMargin(1, 3); - try t.print('A'); - t.setCursorPos(4, 1); - t.cursorDown(10); - try t.print('X'); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("A\n\n\n\nX", str); - } -} - -test "Terminal: cursorDown resets wrap" { - const alloc = testing.allocator; - var t = try init(alloc, 5, 5); - defer t.deinit(alloc); - - for ("ABCDE") |c| try t.print(c); - try testing.expect(t.screen.cursor.pending_wrap); - t.cursorDown(1); - try testing.expect(!t.screen.cursor.pending_wrap); - try t.print('X'); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("ABCDE\n X", str); - } -} - -test "Terminal: cursorRight resets wrap" { - const alloc = testing.allocator; - var t = try init(alloc, 5, 5); - defer t.deinit(alloc); - - for ("ABCDE") |c| try t.print(c); - try testing.expect(t.screen.cursor.pending_wrap); - t.cursorRight(1); - try testing.expect(!t.screen.cursor.pending_wrap); - try t.print('X'); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("ABCDX", str); - } -} - -test "Terminal: cursorRight to the edge of screen" { - const alloc = testing.allocator; - var t = try init(alloc, 5, 5); - defer t.deinit(alloc); - - t.cursorRight(100); - try t.print('X'); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings(" X", str); - } -} - -test "Terminal: cursorRight left of right margin" { - const alloc = testing.allocator; - var t = try init(alloc, 5, 5); - defer t.deinit(alloc); - - t.scrolling_region.right = 2; - t.cursorRight(100); - try t.print('X'); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings(" X", str); - } -} - -test "Terminal: cursorRight right of right margin" { - const alloc = testing.allocator; - var t = try init(alloc, 5, 5); - defer t.deinit(alloc); - - t.scrolling_region.right = 2; - t.setCursorPos(1, 4); - t.cursorRight(100); - try t.print('X'); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings(" X", str); - } -} - -test "Terminal: deleteLines simple" { - const alloc = testing.allocator; - var t = try init(alloc, 5, 5); - defer t.deinit(alloc); - - try t.printString("ABC"); - t.carriageReturn(); - try t.linefeed(); - try t.printString("DEF"); - t.carriageReturn(); - try t.linefeed(); - try t.printString("GHI"); - t.setCursorPos(2, 2); - t.deleteLines(1); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("ABC\nGHI", str); - } -} - -test "Terminal: deleteLines colors with bg color" { - const alloc = testing.allocator; - var t = try init(alloc, 5, 5); - defer t.deinit(alloc); - - try t.printString("ABC"); - t.carriageReturn(); - try t.linefeed(); - try t.printString("DEF"); - t.carriageReturn(); - try t.linefeed(); - try t.printString("GHI"); - t.setCursorPos(2, 2); - - try t.setAttribute(.{ .direct_color_bg = .{ - .r = 0xFF, - .g = 0, - .b = 0, - } }); - t.deleteLines(1); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("ABC\nGHI", str); - } - - for (0..t.cols) |x| { - const list_cell = t.screen.pages.getCell(.{ .active = .{ .x = x, .y = 4 } }).?; - try testing.expect(list_cell.cell.content_tag == .bg_color_rgb); - try testing.expectEqual(Cell.RGB{ - .r = 0xFF, - .g = 0, - .b = 0, - }, list_cell.cell.content.color_rgb); - } -} - -test "Terminal: deleteLines (legacy)" { +// X +test "Terminal: deleteLines" { const alloc = testing.allocator; var t = try init(alloc, 80, 80); defer t.deinit(alloc); @@ -5435,7 +3573,7 @@ test "Terminal: deleteLines (legacy)" { try t.print('D'); t.cursorUp(2); - t.deleteLines(1); + try t.deleteLines(1); try t.print('E'); t.carriageReturn(); @@ -5452,6 +3590,7 @@ test "Terminal: deleteLines (legacy)" { } } +// X test "Terminal: deleteLines with scroll region" { const alloc = testing.allocator; var t = try init(alloc, 80, 80); @@ -5471,7 +3610,7 @@ test "Terminal: deleteLines with scroll region" { t.setTopAndBottomMargin(1, 3); t.setCursorPos(1, 1); - t.deleteLines(1); + try t.deleteLines(1); try t.print('E'); t.carriageReturn(); @@ -5508,7 +3647,7 @@ test "Terminal: deleteLines with scroll region, large count" { t.setTopAndBottomMargin(1, 3); t.setCursorPos(1, 1); - t.deleteLines(5); + try t.deleteLines(5); try t.print('E'); t.carriageReturn(); @@ -5545,7 +3684,7 @@ test "Terminal: deleteLines with scroll region, cursor outside of region" { t.setTopAndBottomMargin(1, 3); t.setCursorPos(4, 1); - t.deleteLines(1); + try t.deleteLines(1); { const str = try t.plainString(testing.allocator); @@ -5554,6 +3693,7 @@ test "Terminal: deleteLines with scroll region, cursor outside of region" { } } +// X test "Terminal: deleteLines resets wrap" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); @@ -5561,7 +3701,7 @@ test "Terminal: deleteLines resets wrap" { for ("ABCDE") |c| try t.print(c); try testing.expect(t.screen.cursor.pending_wrap); - t.deleteLines(1); + try t.deleteLines(1); try testing.expect(!t.screen.cursor.pending_wrap); try t.print('B'); @@ -5572,6 +3712,30 @@ test "Terminal: deleteLines resets wrap" { } } +// X +test "Terminal: deleteLines simple" { + const alloc = testing.allocator; + var t = try init(alloc, 5, 5); + defer t.deinit(alloc); + + try t.printString("ABC"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("DEF"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("GHI"); + t.setCursorPos(2, 2); + try t.deleteLines(1); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("ABC\nGHI", str); + } +} + +// X test "Terminal: deleteLines left/right scroll region" { const alloc = testing.allocator; var t = try init(alloc, 10, 10); @@ -5587,7 +3751,7 @@ test "Terminal: deleteLines left/right scroll region" { t.scrolling_region.left = 1; t.scrolling_region.right = 3; t.setCursorPos(2, 2); - t.deleteLines(1); + try t.deleteLines(1); { const str = try t.plainString(testing.allocator); @@ -5596,6 +3760,26 @@ test "Terminal: deleteLines left/right scroll region" { } } +test "Terminal: deleteLines left/right scroll region clears row wrap" { + const alloc = testing.allocator; + var t = try init(alloc, 5, 5); + defer t.deinit(alloc); + + try t.print('0'); + t.modes.set(.enable_left_and_right_margin, true); + t.setLeftAndRightMargin(2, 3); + try t.printRepeat(1000); + for (0..t.rows - 1) |y| { + const row = t.screen.getRow(.{ .active = y }); + try testing.expect(row.isWrapped()); + } + { + const row = t.screen.getRow(.{ .active = t.rows - 1 }); + try testing.expect(!row.isWrapped()); + } +} + +// X test "Terminal: deleteLines left/right scroll region from top" { const alloc = testing.allocator; var t = try init(alloc, 10, 10); @@ -5611,7 +3795,7 @@ test "Terminal: deleteLines left/right scroll region from top" { t.scrolling_region.left = 1; t.scrolling_region.right = 3; t.setCursorPos(1, 2); - t.deleteLines(1); + try t.deleteLines(1); { const str = try t.plainString(testing.allocator); @@ -5620,6 +3804,7 @@ test "Terminal: deleteLines left/right scroll region from top" { } } +// X test "Terminal: deleteLines left/right scroll region high count" { const alloc = testing.allocator; var t = try init(alloc, 10, 10); @@ -5635,7 +3820,7 @@ test "Terminal: deleteLines left/right scroll region high count" { t.scrolling_region.left = 1; t.scrolling_region.right = 3; t.setCursorPos(2, 2); - t.deleteLines(100); + try t.deleteLines(100); { const str = try t.plainString(testing.allocator); @@ -5644,99 +3829,708 @@ test "Terminal: deleteLines left/right scroll region high count" { } } -test "Terminal: default style is empty" { +// X +test "Terminal: insertLines simple" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); defer t.deinit(alloc); - try t.print('A'); + try t.printString("ABC"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("DEF"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("GHI"); + t.setCursorPos(2, 2); + try t.insertLines(1); { - const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; - const cell = list_cell.cell; - try testing.expectEqual(@as(u21, 'A'), cell.content.codepoint); - try testing.expectEqual(@as(style.Id, 0), cell.style_id); + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("ABC\n\nDEF\nGHI", str); } } -test "Terminal: bold style" { +// X +test "Terminal: insertLines outside of scroll region" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); defer t.deinit(alloc); - try t.setAttribute(.{ .bold = {} }); - try t.print('A'); + try t.printString("ABC"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("DEF"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("GHI"); + t.setTopAndBottomMargin(3, 4); + t.setCursorPos(2, 2); + try t.insertLines(1); { - const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; - const cell = list_cell.cell; - try testing.expectEqual(@as(u21, 'A'), cell.content.codepoint); - try testing.expect(cell.style_id != 0); - try testing.expect(t.screen.cursor.style_ref.?.* > 0); + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("ABC\nDEF\nGHI", str); } } -test "Terminal: garbage collect overwritten" { +// X +test "Terminal: insertLines top/bottom scroll region" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); defer t.deinit(alloc); - try t.setAttribute(.{ .bold = {} }); + try t.printString("ABC"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("DEF"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("GHI"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("123"); + t.setTopAndBottomMargin(1, 3); + t.setCursorPos(2, 2); + try t.insertLines(1); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("ABC\n\nDEF\n123", str); + } +} + +// X +test "Terminal: insertLines left/right scroll region" { + const alloc = testing.allocator; + var t = try init(alloc, 10, 10); + defer t.deinit(alloc); + + try t.printString("ABC123"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("DEF456"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("GHI789"); + t.scrolling_region.left = 1; + t.scrolling_region.right = 3; + t.setCursorPos(2, 2); + try t.insertLines(1); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("ABC123\nD 56\nGEF489\n HI7", str); + } +} + +// X +test "Terminal: insertLines" { + const alloc = testing.allocator; + var t = try init(alloc, 2, 5); + defer t.deinit(alloc); + + // Initial value try t.print('A'); + t.carriageReturn(); + try t.linefeed(); + try t.print('B'); + t.carriageReturn(); + try t.linefeed(); + try t.print('C'); + t.carriageReturn(); + try t.linefeed(); + try t.print('D'); + t.carriageReturn(); + try t.linefeed(); + try t.print('E'); + + // Move to row 2 + t.setCursorPos(2, 1); + + // Insert two lines + try t.insertLines(2); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("A\n\n\nB\nC", str); + } +} + +// X +test "Terminal: insertLines zero" { + const alloc = testing.allocator; + var t = try init(alloc, 2, 5); + defer t.deinit(alloc); + + // This should do nothing t.setCursorPos(1, 1); - try t.setAttribute(.{ .unset = {} }); - try t.print('B'); - - { - const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; - const cell = list_cell.cell; - try testing.expectEqual(@as(u21, 'B'), cell.content.codepoint); - try testing.expect(cell.style_id == 0); - } - - // verify we have no styles in our style map - const page = t.screen.cursor.page_pin.page.data; - try testing.expectEqual(@as(usize, 0), page.styles.count(page.memory)); + try t.insertLines(0); } -test "Terminal: do not garbage collect old styles in use" { +// X +test "Terminal: insertLines with scroll region" { + const alloc = testing.allocator; + var t = try init(alloc, 2, 6); + defer t.deinit(alloc); + + // Initial value + try t.print('A'); + t.carriageReturn(); + try t.linefeed(); + try t.print('B'); + t.carriageReturn(); + try t.linefeed(); + try t.print('C'); + t.carriageReturn(); + try t.linefeed(); + try t.print('D'); + t.carriageReturn(); + try t.linefeed(); + try t.print('E'); + + t.setTopAndBottomMargin(1, 2); + t.setCursorPos(1, 1); + try t.insertLines(1); + + try t.print('X'); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("X\nA\nC\nD\nE", str); + } +} + +// X +test "Terminal: insertLines more than remaining" { + const alloc = testing.allocator; + var t = try init(alloc, 2, 5); + defer t.deinit(alloc); + + // Initial value + try t.print('A'); + t.carriageReturn(); + try t.linefeed(); + try t.print('B'); + t.carriageReturn(); + try t.linefeed(); + try t.print('C'); + t.carriageReturn(); + try t.linefeed(); + try t.print('D'); + t.carriageReturn(); + try t.linefeed(); + try t.print('E'); + + // Move to row 2 + t.setCursorPos(2, 1); + + // Insert a bunch of lines + try t.insertLines(20); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("A", str); + } +} + +// X +test "Terminal: insertLines resets wrap" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); defer t.deinit(alloc); - try t.setAttribute(.{ .bold = {} }); - try t.print('A'); - try t.setAttribute(.{ .unset = {} }); + for ("ABCDE") |c| try t.print(c); + try testing.expect(t.screen.cursor.pending_wrap); + try t.insertLines(1); + try testing.expect(!t.screen.cursor.pending_wrap); try t.print('B'); { - const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 1, .y = 0 } }).?; - const cell = list_cell.cell; - try testing.expectEqual(@as(u21, 'B'), cell.content.codepoint); - try testing.expect(cell.style_id == 0); + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("B\nABCDE", str); } - - // verify we have no styles in our style map - const page = t.screen.cursor.page_pin.page.data; - try testing.expectEqual(@as(usize, 1), page.styles.count(page.memory)); } -test "Terminal: print with style marks the row as styled" { +// X +test "Terminal: reverseIndex" { + const alloc = testing.allocator; + var t = try init(alloc, 2, 5); + defer t.deinit(alloc); + + // Initial value + try t.print('A'); + t.carriageReturn(); + try t.linefeed(); + try t.print('B'); + t.carriageReturn(); + try t.linefeed(); + try t.print('C'); + try t.reverseIndex(); + try t.print('D'); + t.carriageReturn(); + try t.linefeed(); + t.carriageReturn(); + try t.linefeed(); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("A\nBD\nC", str); + } +} + +// X +test "Terminal: reverseIndex from the top" { + const alloc = testing.allocator; + var t = try init(alloc, 2, 5); + defer t.deinit(alloc); + + try t.print('A'); + t.carriageReturn(); + try t.linefeed(); + try t.print('B'); + t.carriageReturn(); + try t.linefeed(); + t.carriageReturn(); + try t.linefeed(); + + t.setCursorPos(1, 1); + try t.reverseIndex(); + try t.print('D'); + + t.carriageReturn(); + try t.linefeed(); + t.setCursorPos(1, 1); + try t.reverseIndex(); + try t.print('E'); + t.carriageReturn(); + try t.linefeed(); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("E\nD\nA\nB", str); + } +} + +// X +test "Terminal: reverseIndex top of scrolling region" { + const alloc = testing.allocator; + var t = try init(alloc, 2, 10); + defer t.deinit(alloc); + + // Initial value + t.setCursorPos(2, 1); + try t.print('A'); + t.carriageReturn(); + try t.linefeed(); + try t.print('B'); + t.carriageReturn(); + try t.linefeed(); + try t.print('C'); + t.carriageReturn(); + try t.linefeed(); + try t.print('D'); + t.carriageReturn(); + try t.linefeed(); + + // Set our scroll region + t.setTopAndBottomMargin(2, 5); + t.setCursorPos(2, 1); + try t.reverseIndex(); + try t.print('X'); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("\nX\nA\nB\nC", str); + } +} + +// X +test "Terminal: reverseIndex top of screen" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); defer t.deinit(alloc); - try t.setAttribute(.{ .bold = {} }); try t.print('A'); - try t.setAttribute(.{ .unset = {} }); + t.setCursorPos(2, 1); try t.print('B'); + t.setCursorPos(3, 1); + try t.print('C'); + t.setCursorPos(1, 1); + try t.reverseIndex(); + try t.print('X'); { - const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; - try testing.expect(list_cell.row.styled); + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("X\nA\nB\nC", str); } } +// X +test "Terminal: reverseIndex not top of screen" { + const alloc = testing.allocator; + var t = try init(alloc, 5, 5); + defer t.deinit(alloc); + + try t.print('A'); + t.setCursorPos(2, 1); + try t.print('B'); + t.setCursorPos(3, 1); + try t.print('C'); + t.setCursorPos(2, 1); + try t.reverseIndex(); + try t.print('X'); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("X\nB\nC", str); + } +} + +// X +test "Terminal: reverseIndex top/bottom margins" { + const alloc = testing.allocator; + var t = try init(alloc, 5, 5); + defer t.deinit(alloc); + + try t.print('A'); + t.setCursorPos(2, 1); + try t.print('B'); + t.setCursorPos(3, 1); + try t.print('C'); + t.setTopAndBottomMargin(2, 3); + t.setCursorPos(2, 1); + try t.reverseIndex(); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("A\n\nB", str); + } +} + +// X +test "Terminal: reverseIndex outside top/bottom margins" { + const alloc = testing.allocator; + var t = try init(alloc, 5, 5); + defer t.deinit(alloc); + + try t.print('A'); + t.setCursorPos(2, 1); + try t.print('B'); + t.setCursorPos(3, 1); + try t.print('C'); + t.setTopAndBottomMargin(2, 3); + t.setCursorPos(1, 1); + try t.reverseIndex(); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("A\nB\nC", str); + } +} + +// X +test "Terminal: reverseIndex left/right margins" { + const alloc = testing.allocator; + var t = try init(alloc, 5, 5); + defer t.deinit(alloc); + + try t.printString("ABC"); + t.setCursorPos(2, 1); + try t.printString("DEF"); + t.setCursorPos(3, 1); + try t.printString("GHI"); + t.modes.set(.enable_left_and_right_margin, true); + t.setLeftAndRightMargin(2, 3); + t.setCursorPos(1, 2); + try t.reverseIndex(); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("A\nDBC\nGEF\n HI", str); + } +} + +// X +test "Terminal: reverseIndex outside left/right margins" { + const alloc = testing.allocator; + var t = try init(alloc, 5, 5); + defer t.deinit(alloc); + + try t.printString("ABC"); + t.setCursorPos(2, 1); + try t.printString("DEF"); + t.setCursorPos(3, 1); + try t.printString("GHI"); + t.modes.set(.enable_left_and_right_margin, true); + t.setLeftAndRightMargin(2, 3); + t.setCursorPos(1, 1); + try t.reverseIndex(); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("ABC\nDEF\nGHI", str); + } +} + +// X +test "Terminal: index" { + const alloc = testing.allocator; + var t = try init(alloc, 2, 5); + defer t.deinit(alloc); + + try t.index(); + try t.print('A'); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("\nA", str); + } +} + +// X +test "Terminal: index from the bottom" { + const alloc = testing.allocator; + var t = try init(alloc, 2, 5); + defer t.deinit(alloc); + + t.setCursorPos(5, 1); + try t.print('A'); + t.cursorLeft(1); // undo moving right from 'A' + try t.index(); + + try t.print('B'); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("\n\n\nA\nB", str); + } +} + +// X +test "Terminal: index outside of scrolling region" { + const alloc = testing.allocator; + var t = try init(alloc, 2, 5); + defer t.deinit(alloc); + + try testing.expectEqual(@as(usize, 0), t.screen.cursor.y); + t.setTopAndBottomMargin(2, 5); + try t.index(); + try testing.expectEqual(@as(usize, 1), t.screen.cursor.y); +} + +// X +test "Terminal: index from the bottom outside of scroll region" { + const alloc = testing.allocator; + var t = try init(alloc, 2, 5); + defer t.deinit(alloc); + + t.setTopAndBottomMargin(1, 2); + t.setCursorPos(5, 1); + try t.print('A'); + try t.index(); + try t.print('B'); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("\n\n\n\nAB", str); + } +} + +// X +test "Terminal: index no scroll region, top of screen" { + const alloc = testing.allocator; + var t = try init(alloc, 5, 5); + defer t.deinit(alloc); + + try t.print('A'); + try t.index(); + try t.print('X'); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("A\n X", str); + } +} + +// X +test "Terminal: index bottom of primary screen" { + const alloc = testing.allocator; + var t = try init(alloc, 5, 5); + defer t.deinit(alloc); + + t.setCursorPos(5, 1); + try t.print('A'); + try t.index(); + try t.print('X'); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("\n\n\nA\n X", str); + } +} + +// X +test "Terminal: index bottom of primary screen background sgr" { + const alloc = testing.allocator; + var t = try init(alloc, 5, 5); + defer t.deinit(alloc); + + const pen: Screen.Cell = .{ + .bg = .{ .rgb = .{ .r = 0xFF, .g = 0x00, .b = 0x00 } }, + }; + + t.setCursorPos(5, 1); + try t.print('A'); + t.screen.cursor.pen = pen; + try t.index(); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("\n\n\nA", str); + for (0..5) |x| { + const cell = t.screen.getCell(.active, 4, x); + try testing.expectEqual(pen, cell); + } + } +} + +// X +test "Terminal: index inside scroll region" { + const alloc = testing.allocator; + var t = try init(alloc, 5, 5); + defer t.deinit(alloc); + + t.setTopAndBottomMargin(1, 3); + try t.print('A'); + try t.index(); + try t.print('X'); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("A\n X", str); + } +} + +// X +test "Terminal: index bottom of scroll region" { + const alloc = testing.allocator; + var t = try init(alloc, 5, 5); + defer t.deinit(alloc); + + t.setTopAndBottomMargin(1, 3); + t.setCursorPos(4, 1); + try t.print('B'); + t.setCursorPos(3, 1); + try t.print('A'); + try t.index(); + try t.print('X'); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("\nA\n X\nB", str); + } +} + +// X +test "Terminal: index bottom of primary screen with scroll region" { + const alloc = testing.allocator; + var t = try init(alloc, 5, 5); + defer t.deinit(alloc); + + t.setTopAndBottomMargin(1, 3); + t.setCursorPos(3, 1); + try t.print('A'); + t.setCursorPos(5, 1); + try t.index(); + try t.index(); + try t.index(); + try t.print('X'); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("\n\nA\n\nX", str); + } +} + +// X +test "Terminal: index outside left/right margin" { + const alloc = testing.allocator; + var t = try init(alloc, 10, 5); + defer t.deinit(alloc); + + t.setTopAndBottomMargin(1, 3); + t.scrolling_region.left = 3; + t.scrolling_region.right = 5; + t.setCursorPos(3, 3); + try t.print('A'); + t.setCursorPos(3, 1); + try t.index(); + try t.print('X'); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("\n\nX A", str); + } +} + +// X +test "Terminal: index inside left/right margin" { + const alloc = testing.allocator; + var t = try init(alloc, 10, 5); + defer t.deinit(alloc); + + try t.printString("AAAAAA"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("AAAAAA"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("AAAAAA"); + t.modes.set(.enable_left_and_right_margin, true); + t.setTopAndBottomMargin(1, 3); + t.setLeftAndRightMargin(1, 3); + t.setCursorPos(3, 1); + try t.index(); + + try testing.expectEqual(@as(usize, 2), t.screen.cursor.y); + try testing.expectEqual(@as(usize, 0), t.screen.cursor.x); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("AAAAAA\nAAAAAA\n AAA", str); + } +} + +// X test "Terminal: DECALN" { const alloc = testing.allocator; var t = try init(alloc, 2, 2); @@ -5759,6 +4553,7 @@ test "Terminal: DECALN" { } } +// X test "Terminal: decaln reset margins" { const alloc = testing.allocator; var t = try init(alloc, 3, 3); @@ -5768,7 +4563,7 @@ test "Terminal: decaln reset margins" { t.modes.set(.origin, true); t.setTopAndBottomMargin(2, 3); try t.decaln(); - t.scrollDown(1); + try t.scrollDown(1); { const str = try t.plainString(testing.allocator); @@ -5777,35 +4572,33 @@ test "Terminal: decaln reset margins" { } } +// X test "Terminal: decaln preserves color" { const alloc = testing.allocator; var t = try init(alloc, 3, 3); defer t.deinit(alloc); + const pen: Screen.Cell = .{ + .bg = .{ .rgb = .{ .r = 0xFF, .g = 0x00, .b = 0x00 } }, + }; + // Initial value - try t.setAttribute(.{ .direct_color_bg = .{ .r = 0xFF, .g = 0, .b = 0 } }); + t.screen.cursor.pen = pen; t.modes.set(.origin, true); t.setTopAndBottomMargin(2, 3); try t.decaln(); - t.scrollDown(1); + try t.scrollDown(1); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); try testing.expectEqualStrings("\nEEE\nEEE", str); - } - - { - const list_cell = t.screen.pages.getCell(.{ .active = .{ .x = 0, .y = 0 } }).?; - try testing.expect(list_cell.cell.content_tag == .bg_color_rgb); - try testing.expectEqual(Cell.RGB{ - .r = 0xFF, - .g = 0, - .b = 0, - }, list_cell.cell.content.color_rgb); + const cell = t.screen.getCell(.active, 0, 0); + try testing.expectEqual(pen, cell); } } +// X test "Terminal: insertBlanks" { // NOTE: this is not verified with conformance tests, so these // tests might actually be verifying wrong behavior. @@ -5816,6 +4609,7 @@ test "Terminal: insertBlanks" { try t.print('A'); try t.print('B'); try t.print('C'); + t.screen.cursor.pen.attrs.bold = true; t.setCursorPos(1, 1); t.insertBlanks(2); @@ -5823,9 +4617,12 @@ test "Terminal: insertBlanks" { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); try testing.expectEqualStrings(" ABC", str); + const cell = t.screen.getCell(.active, 0, 0); + try testing.expect(!cell.attrs.bold); } } +// X test "Terminal: insertBlanks pushes off end" { // NOTE: this is not verified with conformance tests, so these // tests might actually be verifying wrong behavior. @@ -5846,6 +4643,7 @@ test "Terminal: insertBlanks pushes off end" { } } +// X test "Terminal: insertBlanks more than size" { // NOTE: this is not verified with conformance tests, so these // tests might actually be verifying wrong behavior. @@ -5866,6 +4664,7 @@ test "Terminal: insertBlanks more than size" { } } +// X test "Terminal: insertBlanks no scroll region, fits" { const alloc = testing.allocator; var t = try init(alloc, 10, 10); @@ -5882,36 +4681,31 @@ test "Terminal: insertBlanks no scroll region, fits" { } } +// X test "Terminal: insertBlanks preserves background sgr" { const alloc = testing.allocator; var t = try init(alloc, 10, 10); defer t.deinit(alloc); + const pen: Screen.Cell = .{ + .bg = .{ .rgb = .{ .r = 0xFF, .g = 0x00, .b = 0x00 } }, + }; + for ("ABC") |c| try t.print(c); t.setCursorPos(1, 1); - try t.setAttribute(.{ .direct_color_bg = .{ - .r = 0xFF, - .g = 0, - .b = 0, - } }); + t.screen.cursor.pen = pen; t.insertBlanks(2); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); try testing.expectEqualStrings(" ABC", str); - } - { - const list_cell = t.screen.pages.getCell(.{ .active = .{ .x = 0, .y = 0 } }).?; - try testing.expect(list_cell.cell.content_tag == .bg_color_rgb); - try testing.expectEqual(Cell.RGB{ - .r = 0xFF, - .g = 0, - .b = 0, - }, list_cell.cell.content.color_rgb); + const cell = t.screen.getCell(.active, 0, 0); + try testing.expectEqual(pen, cell); } } +// X test "Terminal: insertBlanks shift off screen" { const alloc = testing.allocator; var t = try init(alloc, 5, 10); @@ -5929,6 +4723,7 @@ test "Terminal: insertBlanks shift off screen" { } } +// X test "Terminal: insertBlanks split multi-cell character" { const alloc = testing.allocator; var t = try init(alloc, 5, 10); @@ -5946,6 +4741,7 @@ test "Terminal: insertBlanks split multi-cell character" { } } +// X test "Terminal: insertBlanks inside left/right scroll region" { const alloc = testing.allocator; var t = try init(alloc, 10, 10); @@ -5966,6 +4762,7 @@ test "Terminal: insertBlanks inside left/right scroll region" { } } +// X test "Terminal: insertBlanks outside left/right scroll region" { const alloc = testing.allocator; var t = try init(alloc, 6, 10); @@ -5987,6 +4784,7 @@ test "Terminal: insertBlanks outside left/right scroll region" { } } +// X test "Terminal: insertBlanks left/right scroll region large count" { const alloc = testing.allocator; var t = try init(alloc, 10, 10); @@ -6006,74 +4804,7 @@ test "Terminal: insertBlanks left/right scroll region large count" { } } -test "Terminal: insertBlanks deleting graphemes" { - const alloc = testing.allocator; - var t = try init(alloc, 5, 5); - defer t.deinit(alloc); - - // Disable grapheme clustering - t.modes.set(.grapheme_cluster, true); - - try t.printString("ABC"); - - // This is: 👨‍👩‍👧 (which may or may not render correctly) - try t.print(0x1F468); - try t.print(0x200D); - try t.print(0x1F469); - try t.print(0x200D); - try t.print(0x1F467); - - // We should have one cell with graphemes - const page = t.screen.cursor.page_pin.page.data; - try testing.expectEqual(@as(usize, 1), page.graphemeCount()); - - t.setCursorPos(1, 1); - t.insertBlanks(4); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings(" A", str); - } - - // We should have no graphemes - try testing.expectEqual(@as(usize, 0), page.graphemeCount()); -} - -test "Terminal: insertBlanks shift graphemes" { - const alloc = testing.allocator; - var t = try init(alloc, 5, 5); - defer t.deinit(alloc); - - // Disable grapheme clustering - t.modes.set(.grapheme_cluster, true); - - try t.printString("A"); - - // This is: 👨‍👩‍👧 (which may or may not render correctly) - try t.print(0x1F468); - try t.print(0x200D); - try t.print(0x1F469); - try t.print(0x200D); - try t.print(0x1F467); - - // We should have one cell with graphemes - const page = t.screen.cursor.page_pin.page.data; - try testing.expectEqual(@as(usize, 1), page.graphemeCount()); - - t.setCursorPos(1, 1); - t.insertBlanks(1); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings(" A👨‍👩‍👧", str); - } - - // We should have no graphemes - try testing.expectEqual(@as(usize, 1), page.graphemeCount()); -} - +// X test "Terminal: insert mode with space" { const alloc = testing.allocator; var t = try init(alloc, 10, 2); @@ -6091,6 +4822,7 @@ test "Terminal: insert mode with space" { } } +// X test "Terminal: insert mode doesn't wrap pushed characters" { const alloc = testing.allocator; var t = try init(alloc, 5, 2); @@ -6108,6 +4840,7 @@ test "Terminal: insert mode doesn't wrap pushed characters" { } } +// X test "Terminal: insert mode does nothing at the end of the line" { const alloc = testing.allocator; var t = try init(alloc, 5, 2); @@ -6124,6 +4857,7 @@ test "Terminal: insert mode does nothing at the end of the line" { } } +// X test "Terminal: insert mode with wide characters" { const alloc = testing.allocator; var t = try init(alloc, 5, 2); @@ -6141,6 +4875,7 @@ test "Terminal: insert mode with wide characters" { } } +// X test "Terminal: insert mode with wide characters at end" { const alloc = testing.allocator; var t = try init(alloc, 5, 2); @@ -6157,6 +4892,7 @@ test "Terminal: insert mode with wide characters at end" { } } +// X test "Terminal: insert mode pushing off wide character" { const alloc = testing.allocator; var t = try init(alloc, 5, 2); @@ -6175,6 +4911,64 @@ test "Terminal: insert mode pushing off wide character" { } } +// X +test "Terminal: cursorIsAtPrompt" { + const alloc = testing.allocator; + var t = try init(alloc, 3, 2); + defer t.deinit(alloc); + + try testing.expect(!t.cursorIsAtPrompt()); + t.markSemanticPrompt(.prompt); + try testing.expect(t.cursorIsAtPrompt()); + + // Input is also a prompt + t.markSemanticPrompt(.input); + try testing.expect(t.cursorIsAtPrompt()); + + // Newline -- we expect we're still at a prompt if we received + // prompt stuff before. + try t.linefeed(); + try testing.expect(t.cursorIsAtPrompt()); + + // But once we say we're starting output, we're not a prompt + t.markSemanticPrompt(.command); + try testing.expect(!t.cursorIsAtPrompt()); + try t.linefeed(); + try testing.expect(!t.cursorIsAtPrompt()); + + // Until we know we're at a prompt again + try t.linefeed(); + t.markSemanticPrompt(.prompt); + try testing.expect(t.cursorIsAtPrompt()); +} + +// X +test "Terminal: cursorIsAtPrompt alternate screen" { + const alloc = testing.allocator; + var t = try init(alloc, 3, 2); + defer t.deinit(alloc); + + try testing.expect(!t.cursorIsAtPrompt()); + t.markSemanticPrompt(.prompt); + try testing.expect(t.cursorIsAtPrompt()); + + // Secondary screen is never a prompt + t.alternateScreen(alloc, .{}); + try testing.expect(!t.cursorIsAtPrompt()); + t.markSemanticPrompt(.prompt); + try testing.expect(!t.cursorIsAtPrompt()); +} + +// X +test "Terminal: print wide char with 1-column width" { + const alloc = testing.allocator; + var t = try init(alloc, 1, 2); + defer t.deinit(alloc); + + try t.print('😀'); // 0x1F600 +} + +// X test "Terminal: deleteChars" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); @@ -6183,14 +4977,21 @@ test "Terminal: deleteChars" { for ("ABCDE") |c| try t.print(c); t.setCursorPos(1, 2); - t.deleteChars(2); + // the cells that shifted in should not have this attribute set + t.screen.cursor.pen = .{ .attrs = .{ .bold = true } }; + + try t.deleteChars(2); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); try testing.expectEqualStrings("ADE", str); + + const cell = t.screen.getCell(.active, 0, 4); + try testing.expect(!cell.attrs.bold); } } +// X test "Terminal: deleteChars zero count" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); @@ -6199,7 +5000,7 @@ test "Terminal: deleteChars zero count" { for ("ABCDE") |c| try t.print(c); t.setCursorPos(1, 2); - t.deleteChars(0); + try t.deleteChars(0); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); @@ -6207,6 +5008,7 @@ test "Terminal: deleteChars zero count" { } } +// X test "Terminal: deleteChars more than half" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); @@ -6215,7 +5017,7 @@ test "Terminal: deleteChars more than half" { for ("ABCDE") |c| try t.print(c); t.setCursorPos(1, 2); - t.deleteChars(3); + try t.deleteChars(3); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); @@ -6223,6 +5025,7 @@ test "Terminal: deleteChars more than half" { } } +// X test "Terminal: deleteChars more than line width" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); @@ -6231,7 +5034,7 @@ test "Terminal: deleteChars more than line width" { for ("ABCDE") |c| try t.print(c); t.setCursorPos(1, 2); - t.deleteChars(10); + try t.deleteChars(10); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); @@ -6239,6 +5042,7 @@ test "Terminal: deleteChars more than line width" { } } +// X test "Terminal: deleteChars should shift left" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); @@ -6247,7 +5051,7 @@ test "Terminal: deleteChars should shift left" { for ("ABCDE") |c| try t.print(c); t.setCursorPos(1, 2); - t.deleteChars(1); + try t.deleteChars(1); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); @@ -6255,6 +5059,7 @@ test "Terminal: deleteChars should shift left" { } } +// X test "Terminal: deleteChars resets wrap" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); @@ -6262,7 +5067,7 @@ test "Terminal: deleteChars resets wrap" { for ("ABCDE") |c| try t.print(c); try testing.expect(t.screen.cursor.pending_wrap); - t.deleteChars(1); + try t.deleteChars(1); try testing.expect(!t.screen.cursor.pending_wrap); try t.print('X'); @@ -6273,6 +5078,7 @@ test "Terminal: deleteChars resets wrap" { } } +// X test "Terminal: deleteChars simple operation" { const alloc = testing.allocator; var t = try init(alloc, 10, 10); @@ -6280,7 +5086,7 @@ test "Terminal: deleteChars simple operation" { try t.printString("ABC123"); t.setCursorPos(1, 3); - t.deleteChars(2); + try t.deleteChars(2); { const str = try t.plainString(testing.allocator); @@ -6289,36 +5095,33 @@ test "Terminal: deleteChars simple operation" { } } -test "Terminal: deleteChars preserves background sgr" { +// X +test "Terminal: deleteChars background sgr" { const alloc = testing.allocator; var t = try init(alloc, 10, 10); defer t.deinit(alloc); - for ("ABC123") |c| try t.print(c); + const pen: Screen.Cell = .{ + .bg = .{ .rgb = .{ .r = 0xFF, .g = 0x00, .b = 0x00 } }, + }; + + try t.printString("ABC123"); t.setCursorPos(1, 3); - try t.setAttribute(.{ .direct_color_bg = .{ - .r = 0xFF, - .g = 0, - .b = 0, - } }); - t.deleteChars(2); + t.screen.cursor.pen = pen; + try t.deleteChars(2); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); try testing.expectEqualStrings("AB23", str); - } - for (t.cols - 2..t.cols) |x| { - const list_cell = t.screen.pages.getCell(.{ .active = .{ .x = x, .y = 0 } }).?; - try testing.expect(list_cell.cell.content_tag == .bg_color_rgb); - try testing.expectEqual(Cell.RGB{ - .r = 0xFF, - .g = 0, - .b = 0, - }, list_cell.cell.content.color_rgb); + for (t.cols - 2..t.cols) |x| { + const cell = t.screen.getCell(.active, 0, x); + try testing.expectEqual(pen, cell); + } } } +// X test "Terminal: deleteChars outside scroll region" { const alloc = testing.allocator; var t = try init(alloc, 6, 10); @@ -6328,7 +5131,7 @@ test "Terminal: deleteChars outside scroll region" { t.scrolling_region.left = 2; t.scrolling_region.right = 4; try testing.expect(t.screen.cursor.pending_wrap); - t.deleteChars(2); + try t.deleteChars(2); try testing.expect(t.screen.cursor.pending_wrap); { @@ -6338,6 +5141,7 @@ test "Terminal: deleteChars outside scroll region" { } } +// X test "Terminal: deleteChars inside scroll region" { const alloc = testing.allocator; var t = try init(alloc, 6, 10); @@ -6347,7 +5151,7 @@ test "Terminal: deleteChars inside scroll region" { t.scrolling_region.left = 2; t.scrolling_region.right = 4; t.setCursorPos(1, 4); - t.deleteChars(1); + try t.deleteChars(1); { const str = try t.plainString(testing.allocator); @@ -6356,6 +5160,7 @@ test "Terminal: deleteChars inside scroll region" { } } +// X test "Terminal: deleteChars split wide character" { const alloc = testing.allocator; var t = try init(alloc, 6, 10); @@ -6363,7 +5168,7 @@ test "Terminal: deleteChars split wide character" { try t.printString("A橋123"); t.setCursorPos(1, 3); - t.deleteChars(1); + try t.deleteChars(1); { const str = try t.plainString(testing.allocator); @@ -6372,6 +5177,7 @@ test "Terminal: deleteChars split wide character" { } } +// X test "Terminal: deleteChars split wide character tail" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); @@ -6380,7 +5186,7 @@ test "Terminal: deleteChars split wide character tail" { t.setCursorPos(1, t.cols - 1); try t.print(0x6A4B); // 橋 t.carriageReturn(); - t.deleteChars(t.cols - 1); + try t.deleteChars(t.cols - 1); try t.print('0'); { @@ -6390,54 +5196,341 @@ test "Terminal: deleteChars split wide character tail" { } } +// X +test "Terminal: eraseChars resets pending wrap" { + const alloc = testing.allocator; + var t = try init(alloc, 5, 5); + defer t.deinit(alloc); + + for ("ABCDE") |c| try t.print(c); + try testing.expect(t.screen.cursor.pending_wrap); + t.eraseChars(1); + try testing.expect(!t.screen.cursor.pending_wrap); + try t.print('X'); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("ABCDX", str); + } +} + +// X +test "Terminal: eraseChars resets wrap" { + const alloc = testing.allocator; + var t = try init(alloc, 5, 5); + defer t.deinit(alloc); + + for ("ABCDE123") |c| try t.print(c); + { + const row = t.screen.getRow(.{ .active = 0 }); + try testing.expect(row.isWrapped()); + } + + t.setCursorPos(1, 1); + t.eraseChars(1); + + { + const row = t.screen.getRow(.{ .active = 0 }); + try testing.expect(!row.isWrapped()); + } + try t.print('X'); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("XBCDE\n123", str); + } +} + +// X +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'); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("X C", str); + } +} + +// X +test "Terminal: eraseChars minimum one" { + 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(0); + try t.print('X'); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("XBC", str); + } +} + +// X +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); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings(" A", str); + } +} + +// X +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 = .{ .rgb = .{ .r = 0xFF, .g = 0x00, .b = 0x00 } }, + }; + + for ("ABC") |c| try t.print(c); + t.setCursorPos(1, 1); + t.screen.cursor.pen = pen; + t.eraseChars(2); + + { + const 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); + } + } +} + +// X +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'); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("X BC", str); + } +} + +// X +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); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("ABC", str); + } +} + +// X +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); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings(" C", str); + } +} + +// X +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); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings(" C", str); + } +} + +// X +// 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. +test "Terminal: resize less cols with wide char then print" { + const alloc = testing.allocator; + var t = try init(alloc, 3, 3); + defer t.deinit(alloc); + + try t.print('x'); + try t.print('😀'); // 0x1F600 + try t.resize(alloc, 2, 3); + t.setCursorPos(1, 2); + try t.print('😀'); // 0x1F600 +} + +// X +// https://github.com/mitchellh/ghostty/issues/723 +// This was found via fuzzing so its highly specific. +test "Terminal: resize with left and right margin set" { + const alloc = testing.allocator; + const cols = 70; + const rows = 23; + var t = try init(alloc, cols, rows); + defer t.deinit(alloc); + + t.modes.set(.enable_left_and_right_margin, true); + try t.print('0'); + t.modes.set(.enable_mode_3, true); + try t.resize(alloc, cols, rows); + t.setLeftAndRightMargin(2, 0); + try t.printRepeat(1850); + _ = t.modes.restore(.enable_mode_3); + try t.resize(alloc, cols, rows); +} + +// X +// https://github.com/mitchellh/ghostty/issues/1343 +test "Terminal: resize with wraparound off" { + const alloc = testing.allocator; + const cols = 4; + const rows = 2; + var t = try init(alloc, cols, rows); + defer t.deinit(alloc); + + t.modes.set(.wraparound, false); + try t.print('0'); + try t.print('1'); + try t.print('2'); + try t.print('3'); + const new_cols = 2; + try t.resize(alloc, new_cols, rows); + + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("01", str); +} + +// X +test "Terminal: resize with wraparound on" { + const alloc = testing.allocator; + const cols = 4; + const rows = 2; + var t = try init(alloc, cols, rows); + defer t.deinit(alloc); + + t.modes.set(.wraparound, true); + try t.print('0'); + try t.print('1'); + try t.print('2'); + try t.print('3'); + const new_cols = 2; + try t.resize(alloc, new_cols, rows); + + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("01\n23", str); +} + +// X test "Terminal: saveCursor" { const alloc = testing.allocator; var t = try init(alloc, 3, 3); defer t.deinit(alloc); - try t.setAttribute(.{ .bold = {} }); + t.screen.cursor.pen.attrs.bold = true; t.screen.charset.gr = .G3; t.modes.set(.origin, true); t.saveCursor(); t.screen.charset.gr = .G0; - try t.setAttribute(.{ .unset = {} }); + t.screen.cursor.pen.attrs.bold = false; t.modes.set(.origin, false); - try t.restoreCursor(); - try testing.expect(t.screen.cursor.style.flags.bold); + t.restoreCursor(); + try testing.expect(t.screen.cursor.pen.attrs.bold); try testing.expect(t.screen.charset.gr == .G3); try testing.expect(t.modes.get(.origin)); } +// X test "Terminal: saveCursor with screen change" { const alloc = testing.allocator; var t = try init(alloc, 3, 3); defer t.deinit(alloc); - try t.setAttribute(.{ .bold = {} }); + t.screen.cursor.pen.attrs.bold = true; t.screen.cursor.x = 2; t.screen.charset.gr = .G3; t.modes.set(.origin, true); - t.alternateScreen(.{ + t.alternateScreen(alloc, .{ .cursor_save = true, .clear_on_enter = true, }); // make sure our cursor and charset have come with us - try testing.expect(t.screen.cursor.style.flags.bold); + try testing.expect(t.screen.cursor.pen.attrs.bold); try testing.expect(t.screen.cursor.x == 2); try testing.expect(t.screen.charset.gr == .G3); try testing.expect(t.modes.get(.origin)); t.screen.charset.gr = .G0; - try t.setAttribute(.{ .reset_bold = {} }); + t.screen.cursor.pen.attrs.bold = false; t.modes.set(.origin, false); - t.primaryScreen(.{ + t.primaryScreen(alloc, .{ .cursor_save = true, .clear_on_enter = true, }); - try testing.expect(t.screen.cursor.style.flags.bold); + try testing.expect(t.screen.cursor.pen.attrs.bold); try testing.expect(t.screen.charset.gr == .G3); try testing.expect(t.modes.get(.origin)); } +// X test "Terminal: saveCursor position" { const alloc = testing.allocator; var t = try init(alloc, 10, 5); @@ -6448,7 +5541,7 @@ test "Terminal: saveCursor position" { t.saveCursor(); t.setCursorPos(1, 1); try t.print('B'); - try t.restoreCursor(); + t.restoreCursor(); try t.print('X'); { @@ -6458,6 +5551,7 @@ test "Terminal: saveCursor position" { } } +// X test "Terminal: saveCursor pending wrap state" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); @@ -6468,7 +5562,7 @@ test "Terminal: saveCursor pending wrap state" { t.saveCursor(); t.setCursorPos(1, 1); try t.print('B'); - try t.restoreCursor(); + t.restoreCursor(); try t.print('X'); { @@ -6478,6 +5572,7 @@ test "Terminal: saveCursor pending wrap state" { } } +// X test "Terminal: saveCursor origin mode" { const alloc = testing.allocator; var t = try init(alloc, 10, 5); @@ -6488,7 +5583,7 @@ test "Terminal: saveCursor origin mode" { t.modes.set(.enable_left_and_right_margin, true); t.setLeftAndRightMargin(3, 5); t.setTopAndBottomMargin(2, 4); - try t.restoreCursor(); + t.restoreCursor(); try t.print('X'); { @@ -6498,6 +5593,7 @@ test "Terminal: saveCursor origin mode" { } } +// X test "Terminal: saveCursor resize" { const alloc = testing.allocator; var t = try init(alloc, 10, 5); @@ -6506,7 +5602,7 @@ test "Terminal: saveCursor resize" { t.setCursorPos(1, 10); t.saveCursor(); try t.resize(alloc, 5, 5); - try t.restoreCursor(); + t.restoreCursor(); try t.print('X'); { @@ -6516,37 +5612,40 @@ test "Terminal: saveCursor resize" { } } +// X test "Terminal: saveCursor protected pen" { const alloc = testing.allocator; var t = try init(alloc, 10, 5); defer t.deinit(alloc); t.setProtectedMode(.iso); - try testing.expect(t.screen.cursor.protected); + try testing.expect(t.screen.cursor.pen.attrs.protected); t.setCursorPos(1, 10); t.saveCursor(); t.setProtectedMode(.off); - try testing.expect(!t.screen.cursor.protected); - try t.restoreCursor(); - try testing.expect(t.screen.cursor.protected); + try testing.expect(!t.screen.cursor.pen.attrs.protected); + t.restoreCursor(); + try testing.expect(t.screen.cursor.pen.attrs.protected); } +// X test "Terminal: setProtectedMode" { const alloc = testing.allocator; var t = try init(alloc, 3, 3); defer t.deinit(alloc); - try testing.expect(!t.screen.cursor.protected); + try testing.expect(!t.screen.cursor.pen.attrs.protected); t.setProtectedMode(.off); - try testing.expect(!t.screen.cursor.protected); + try testing.expect(!t.screen.cursor.pen.attrs.protected); t.setProtectedMode(.iso); - try testing.expect(t.screen.cursor.protected); + try testing.expect(t.screen.cursor.pen.attrs.protected); t.setProtectedMode(.dec); - try testing.expect(t.screen.cursor.protected); + try testing.expect(t.screen.cursor.pen.attrs.protected); t.setProtectedMode(.off); - try testing.expect(!t.screen.cursor.protected); + try testing.expect(!t.screen.cursor.pen.attrs.protected); } +// X test "Terminal: eraseLine simple erase right" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); @@ -6563,6 +5662,7 @@ test "Terminal: eraseLine simple erase right" { } } +// X test "Terminal: eraseLine resets pending wrap" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); @@ -6581,6 +5681,7 @@ test "Terminal: eraseLine resets pending wrap" { } } +// X test "Terminal: eraseLine resets wrap" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); @@ -6588,16 +5689,16 @@ test "Terminal: eraseLine resets wrap" { for ("ABCDE123") |c| try t.print(c); { - const list_cell = t.screen.pages.getCell(.{ .active = .{ .x = 0, .y = 0 } }).?; - try testing.expect(list_cell.row.wrap); + const row = t.screen.getRow(.{ .active = 0 }); + try testing.expect(row.isWrapped()); } t.setCursorPos(1, 1); t.eraseLine(.right, false); { - const list_cell = t.screen.pages.getCell(.{ .active = .{ .x = 0, .y = 0 } }).?; - try testing.expect(!list_cell.row.wrap); + const row = t.screen.getRow(.{ .active = 0 }); + try testing.expect(!row.isWrapped()); } try t.print('X'); @@ -6608,18 +5709,19 @@ test "Terminal: eraseLine resets wrap" { } } +// X test "Terminal: eraseLine right preserves background sgr" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); defer t.deinit(alloc); + const pen: Screen.Cell = .{ + .bg = .{ .rgb = .{ .r = 0xFF, .g = 0x00, .b = 0x00 } }, + }; + for ("ABCDE") |c| try t.print(c); t.setCursorPos(1, 2); - try t.setAttribute(.{ .direct_color_bg = .{ - .r = 0xFF, - .g = 0, - .b = 0, - } }); + t.screen.cursor.pen = pen; t.eraseLine(.right, false); { @@ -6627,17 +5729,13 @@ test "Terminal: eraseLine right preserves background sgr" { defer testing.allocator.free(str); try testing.expectEqualStrings("A", str); for (1..5) |x| { - const list_cell = t.screen.pages.getCell(.{ .active = .{ .x = x, .y = 0 } }).?; - try testing.expect(list_cell.cell.content_tag == .bg_color_rgb); - try testing.expectEqual(Cell.RGB{ - .r = 0xFF, - .g = 0, - .b = 0, - }, list_cell.cell.content.color_rgb); + const cell = t.screen.getCell(.active, 0, x); + try testing.expectEqual(pen, cell); } } } +// X test "Terminal: eraseLine right wide character" { const alloc = testing.allocator; var t = try init(alloc, 10, 5); @@ -6656,6 +5754,7 @@ test "Terminal: eraseLine right wide character" { } } +// X test "Terminal: eraseLine right protected attributes respected with iso" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); @@ -6673,6 +5772,7 @@ test "Terminal: eraseLine right protected attributes respected with iso" { } } +// X test "Terminal: eraseLine right protected attributes ignored with dec most recent" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); @@ -6692,6 +5792,7 @@ test "Terminal: eraseLine right protected attributes ignored with dec most recen } } +// X test "Terminal: eraseLine right protected attributes ignored with dec set" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); @@ -6709,6 +5810,7 @@ test "Terminal: eraseLine right protected attributes ignored with dec set" { } } +// X test "Terminal: eraseLine right protected requested" { const alloc = testing.allocator; var t = try init(alloc, 10, 5); @@ -6728,6 +5830,7 @@ test "Terminal: eraseLine right protected requested" { } } +// X test "Terminal: eraseLine simple erase left" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); @@ -6744,6 +5847,7 @@ test "Terminal: eraseLine simple erase left" { } } +// X test "Terminal: eraseLine left resets wrap" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); @@ -6762,18 +5866,19 @@ test "Terminal: eraseLine left resets wrap" { } } +// X test "Terminal: eraseLine left preserves background sgr" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); defer t.deinit(alloc); + const pen: Screen.Cell = .{ + .bg = .{ .rgb = .{ .r = 0xFF, .g = 0x00, .b = 0x00 } }, + }; + for ("ABCDE") |c| try t.print(c); t.setCursorPos(1, 2); - try t.setAttribute(.{ .direct_color_bg = .{ - .r = 0xFF, - .g = 0, - .b = 0, - } }); + t.screen.cursor.pen = pen; t.eraseLine(.left, false); { @@ -6781,17 +5886,13 @@ test "Terminal: eraseLine left preserves background sgr" { defer testing.allocator.free(str); try testing.expectEqualStrings(" CDE", str); for (0..2) |x| { - const list_cell = t.screen.pages.getCell(.{ .active = .{ .x = x, .y = 0 } }).?; - try testing.expect(list_cell.cell.content_tag == .bg_color_rgb); - try testing.expectEqual(Cell.RGB{ - .r = 0xFF, - .g = 0, - .b = 0, - }, list_cell.cell.content.color_rgb); + const cell = t.screen.getCell(.active, 0, x); + try testing.expectEqual(pen, cell); } } } +// X test "Terminal: eraseLine left wide character" { const alloc = testing.allocator; var t = try init(alloc, 10, 5); @@ -6810,6 +5911,7 @@ test "Terminal: eraseLine left wide character" { } } +// X test "Terminal: eraseLine left protected attributes respected with iso" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); @@ -6827,6 +5929,7 @@ test "Terminal: eraseLine left protected attributes respected with iso" { } } +// X test "Terminal: eraseLine left protected attributes ignored with dec most recent" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); @@ -6846,6 +5949,7 @@ test "Terminal: eraseLine left protected attributes ignored with dec most recent } } +// X test "Terminal: eraseLine left protected attributes ignored with dec set" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); @@ -6863,6 +5967,7 @@ test "Terminal: eraseLine left protected attributes ignored with dec set" { } } +// X test "Terminal: eraseLine left protected requested" { const alloc = testing.allocator; var t = try init(alloc, 10, 5); @@ -6882,18 +5987,19 @@ test "Terminal: eraseLine left protected requested" { } } +// X test "Terminal: eraseLine complete preserves background sgr" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); defer t.deinit(alloc); + const pen: Screen.Cell = .{ + .bg = .{ .rgb = .{ .r = 0xFF, .g = 0x00, .b = 0x00 } }, + }; + for ("ABCDE") |c| try t.print(c); t.setCursorPos(1, 2); - try t.setAttribute(.{ .direct_color_bg = .{ - .r = 0xFF, - .g = 0, - .b = 0, - } }); + t.screen.cursor.pen = pen; t.eraseLine(.complete, false); { @@ -6901,17 +6007,13 @@ test "Terminal: eraseLine complete preserves background sgr" { defer testing.allocator.free(str); try testing.expectEqualStrings("", str); for (0..5) |x| { - const list_cell = t.screen.pages.getCell(.{ .active = .{ .x = x, .y = 0 } }).?; - try testing.expect(list_cell.cell.content_tag == .bg_color_rgb); - try testing.expectEqual(Cell.RGB{ - .r = 0xFF, - .g = 0, - .b = 0, - }, list_cell.cell.content.color_rgb); + const cell = t.screen.getCell(.active, 0, x); + try testing.expectEqual(pen, cell); } } } +// X test "Terminal: eraseLine complete protected attributes respected with iso" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); @@ -6929,6 +6031,7 @@ test "Terminal: eraseLine complete protected attributes respected with iso" { } } +// X test "Terminal: eraseLine complete protected attributes ignored with dec most recent" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); @@ -6948,6 +6051,7 @@ test "Terminal: eraseLine complete protected attributes ignored with dec most re } } +// X test "Terminal: eraseLine complete protected attributes ignored with dec set" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); @@ -6965,6 +6069,7 @@ test "Terminal: eraseLine complete protected attributes ignored with dec set" { } } +// X test "Terminal: eraseLine complete protected requested" { const alloc = testing.allocator; var t = try init(alloc, 10, 5); @@ -6984,6 +6089,1319 @@ test "Terminal: eraseLine complete protected requested" { } } +// X +test "Terminal: eraseDisplay simple erase below" { + const alloc = testing.allocator; + var t = try init(alloc, 5, 5); + defer t.deinit(alloc); + + for ("ABC") |c| try t.print(c); + t.carriageReturn(); + try t.linefeed(); + for ("DEF") |c| try t.print(c); + t.carriageReturn(); + try t.linefeed(); + for ("GHI") |c| try t.print(c); + t.setCursorPos(2, 2); + t.eraseDisplay(alloc, .below, false); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("ABC\nD", str); + } +} + +// X +test "Terminal: eraseDisplay erase below preserves SGR bg" { + const alloc = testing.allocator; + var t = try init(alloc, 5, 5); + defer t.deinit(alloc); + + for ("ABC") |c| try t.print(c); + t.carriageReturn(); + try t.linefeed(); + for ("DEF") |c| try t.print(c); + t.carriageReturn(); + try t.linefeed(); + for ("GHI") |c| try t.print(c); + t.setCursorPos(2, 2); + + const pen: Screen.Cell = .{ + .bg = .{ .rgb = .{ .r = 0xFF, .g = 0x00, .b = 0x00 } }, + }; + + t.screen.cursor.pen = pen; + t.eraseDisplay(alloc, .below, false); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("ABC\nD", str); + for (1..5) |x| { + const cell = t.screen.getCell(.active, 1, x); + try testing.expectEqual(pen, cell); + } + } +} + +// X +test "Terminal: eraseDisplay below split multi-cell" { + const alloc = testing.allocator; + var t = try init(alloc, 5, 5); + defer t.deinit(alloc); + + try t.printString("AB橋C"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("DE橋F"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("GH橋I"); + t.setCursorPos(2, 4); + t.eraseDisplay(alloc, .below, false); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("AB橋C\nDE", str); + } +} + +// X +test "Terminal: eraseDisplay below 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.carriageReturn(); + try t.linefeed(); + for ("DEF") |c| try t.print(c); + t.carriageReturn(); + try t.linefeed(); + for ("GHI") |c| try t.print(c); + t.setCursorPos(2, 2); + t.eraseDisplay(alloc, .below, false); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("ABC\nDEF\nGHI", str); + } +} + +// X +test "Terminal: eraseDisplay below 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.carriageReturn(); + try t.linefeed(); + for ("DEF") |c| try t.print(c); + t.carriageReturn(); + try t.linefeed(); + for ("GHI") |c| try t.print(c); + t.setProtectedMode(.dec); + t.setProtectedMode(.off); + t.setCursorPos(2, 2); + t.eraseDisplay(alloc, .below, false); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("ABC\nD", str); + } +} + +// X +test "Terminal: eraseDisplay below 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.carriageReturn(); + try t.linefeed(); + for ("DEF") |c| try t.print(c); + t.carriageReturn(); + try t.linefeed(); + for ("GHI") |c| try t.print(c); + t.setCursorPos(2, 2); + t.eraseDisplay(alloc, .below, false); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("ABC\nD", str); + } +} + +// X +test "Terminal: eraseDisplay simple erase above" { + const alloc = testing.allocator; + var t = try init(alloc, 5, 5); + defer t.deinit(alloc); + + for ("ABC") |c| try t.print(c); + t.carriageReturn(); + try t.linefeed(); + for ("DEF") |c| try t.print(c); + t.carriageReturn(); + try t.linefeed(); + for ("GHI") |c| try t.print(c); + t.setCursorPos(2, 2); + t.eraseDisplay(alloc, .above, false); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("\n F\nGHI", str); + } +} + +// X +test "Terminal: eraseDisplay below protected attributes respected with force" { + 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.carriageReturn(); + try t.linefeed(); + for ("DEF") |c| try t.print(c); + t.carriageReturn(); + try t.linefeed(); + for ("GHI") |c| try t.print(c); + t.setCursorPos(2, 2); + t.eraseDisplay(alloc, .below, true); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("ABC\nDEF\nGHI", str); + } +} + +// X +test "Terminal: eraseDisplay erase above preserves SGR bg" { + const alloc = testing.allocator; + var t = try init(alloc, 5, 5); + defer t.deinit(alloc); + + for ("ABC") |c| try t.print(c); + t.carriageReturn(); + try t.linefeed(); + for ("DEF") |c| try t.print(c); + t.carriageReturn(); + try t.linefeed(); + for ("GHI") |c| try t.print(c); + t.setCursorPos(2, 2); + + const pen: Screen.Cell = .{ + .bg = .{ .rgb = .{ .r = 0xFF, .g = 0x00, .b = 0x00 } }, + }; + + t.screen.cursor.pen = pen; + t.eraseDisplay(alloc, .above, false); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("\n F\nGHI", str); + for (0..2) |x| { + const cell = t.screen.getCell(.active, 1, x); + try testing.expectEqual(pen, cell); + } + } +} + +// X +test "Terminal: eraseDisplay above split multi-cell" { + const alloc = testing.allocator; + var t = try init(alloc, 5, 5); + defer t.deinit(alloc); + + try t.printString("AB橋C"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("DE橋F"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("GH橋I"); + t.setCursorPos(2, 3); + t.eraseDisplay(alloc, .above, false); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("\n F\nGH橋I", str); + } +} + +// X +test "Terminal: eraseDisplay above 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.carriageReturn(); + try t.linefeed(); + for ("DEF") |c| try t.print(c); + t.carriageReturn(); + try t.linefeed(); + for ("GHI") |c| try t.print(c); + t.setCursorPos(2, 2); + t.eraseDisplay(alloc, .above, false); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("ABC\nDEF\nGHI", str); + } +} + +// X +test "Terminal: eraseDisplay above 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.carriageReturn(); + try t.linefeed(); + for ("DEF") |c| try t.print(c); + t.carriageReturn(); + try t.linefeed(); + for ("GHI") |c| try t.print(c); + t.setProtectedMode(.dec); + t.setProtectedMode(.off); + t.setCursorPos(2, 2); + t.eraseDisplay(alloc, .above, false); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("\n F\nGHI", str); + } +} + +// X +test "Terminal: eraseDisplay above 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.carriageReturn(); + try t.linefeed(); + for ("DEF") |c| try t.print(c); + t.carriageReturn(); + try t.linefeed(); + for ("GHI") |c| try t.print(c); + t.setCursorPos(2, 2); + t.eraseDisplay(alloc, .above, false); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("\n F\nGHI", str); + } +} + +// X +test "Terminal: eraseDisplay above protected attributes respected with force" { + 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.carriageReturn(); + try t.linefeed(); + for ("DEF") |c| try t.print(c); + t.carriageReturn(); + try t.linefeed(); + for ("GHI") |c| try t.print(c); + t.setCursorPos(2, 2); + t.eraseDisplay(alloc, .above, true); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("ABC\nDEF\nGHI", str); + } +} + +// X +test "Terminal: eraseDisplay above" { + var t = try init(testing.allocator, 80, 80); + defer t.deinit(testing.allocator); + + const pink = color.RGB{ .r = 0xFF, .g = 0x00, .b = 0x7F }; + t.screen.cursor.pen = Screen.Cell{ + .char = 'a', + .bg = .{ .rgb = pink }, + .fg = .{ .rgb = pink }, + .attrs = .{ .bold = true }, + }; + const cell_ptr = t.screen.getCellPtr(.active, 0, 0); + cell_ptr.* = t.screen.cursor.pen; + // verify the cell was set + var cell = t.screen.getCell(.active, 0, 0); + try testing.expect(cell.bg.rgb.eql(pink)); + try testing.expect(cell.fg.rgb.eql(pink)); + try testing.expect(cell.char == 'a'); + try testing.expect(cell.attrs.bold); + // move the cursor below it + t.screen.cursor.y = 40; + t.screen.cursor.x = 40; + // erase above the cursor + t.eraseDisplay(testing.allocator, .above, false); + // check it was erased + cell = t.screen.getCell(.active, 0, 0); + try testing.expect(cell.bg.rgb.eql(pink)); + try testing.expect(cell.fg == .none); + try testing.expect(cell.char == 0); + try testing.expect(!cell.attrs.bold); + + // Check that our pen hasn't changed + try testing.expect(t.screen.cursor.pen.attrs.bold); + + // check that another cell got the correct bg + cell = t.screen.getCell(.active, 0, 1); + try testing.expect(cell.bg.rgb.eql(pink)); +} + +// X +test "Terminal: eraseDisplay below" { + var t = try init(testing.allocator, 80, 80); + defer t.deinit(testing.allocator); + + const pink = color.RGB{ .r = 0xFF, .g = 0x00, .b = 0x7F }; + t.screen.cursor.pen = Screen.Cell{ + .char = 'a', + .bg = .{ .rgb = pink }, + .fg = .{ .rgb = pink }, + .attrs = .{ .bold = true }, + }; + const cell_ptr = t.screen.getCellPtr(.active, 60, 60); + cell_ptr.* = t.screen.cursor.pen; + // verify the cell was set + var cell = t.screen.getCell(.active, 60, 60); + try testing.expect(cell.bg.rgb.eql(pink)); + try testing.expect(cell.fg.rgb.eql(pink)); + try testing.expect(cell.char == 'a'); + try testing.expect(cell.attrs.bold); + // erase below the cursor + t.eraseDisplay(testing.allocator, .below, false); + // check it was erased + cell = t.screen.getCell(.active, 60, 60); + try testing.expect(cell.bg.rgb.eql(pink)); + try testing.expect(cell.fg == .none); + try testing.expect(cell.char == 0); + try testing.expect(!cell.attrs.bold); + + // check that another cell got the correct bg + cell = t.screen.getCell(.active, 0, 1); + try testing.expect(cell.bg.rgb.eql(pink)); +} + +// X +test "Terminal: eraseDisplay complete" { + var t = try init(testing.allocator, 80, 80); + defer t.deinit(testing.allocator); + + const pink = color.RGB{ .r = 0xFF, .g = 0x00, .b = 0x7F }; + t.screen.cursor.pen = Screen.Cell{ + .char = 'a', + .bg = .{ .rgb = pink }, + .fg = .{ .rgb = pink }, + .attrs = .{ .bold = true }, + }; + var cell_ptr = t.screen.getCellPtr(.active, 60, 60); + cell_ptr.* = t.screen.cursor.pen; + cell_ptr = t.screen.getCellPtr(.active, 0, 0); + cell_ptr.* = t.screen.cursor.pen; + // verify the cell was set + var cell = t.screen.getCell(.active, 60, 60); + try testing.expect(cell.bg.rgb.eql(pink)); + try testing.expect(cell.fg.rgb.eql(pink)); + try testing.expect(cell.char == 'a'); + try testing.expect(cell.attrs.bold); + // verify the cell was set + cell = t.screen.getCell(.active, 0, 0); + try testing.expect(cell.bg.rgb.eql(pink)); + try testing.expect(cell.fg.rgb.eql(pink)); + try testing.expect(cell.char == 'a'); + try testing.expect(cell.attrs.bold); + // position our cursor between the cells + t.screen.cursor.y = 30; + // erase everything + t.eraseDisplay(testing.allocator, .complete, false); + // check they were erased + cell = t.screen.getCell(.active, 60, 60); + try testing.expect(cell.bg.rgb.eql(pink)); + try testing.expect(cell.fg == .none); + try testing.expect(cell.char == 0); + try testing.expect(!cell.attrs.bold); + cell = t.screen.getCell(.active, 0, 0); + try testing.expect(cell.bg.rgb.eql(pink)); + try testing.expect(cell.fg == .none); + try testing.expect(cell.char == 0); + try testing.expect(!cell.attrs.bold); +} + +// X +test "Terminal: eraseDisplay protected complete" { + const alloc = testing.allocator; + var t = try init(alloc, 10, 5); + defer t.deinit(alloc); + + try t.print('A'); + t.carriageReturn(); + try t.linefeed(); + for ("123456789") |c| try t.print(c); + t.setCursorPos(t.screen.cursor.y + 1, 6); + t.setProtectedMode(.dec); + try t.print('X'); + t.setCursorPos(t.screen.cursor.y + 1, 4); + t.eraseDisplay(alloc, .complete, true); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("\n X", str); + } +} + +// X +test "Terminal: eraseDisplay protected below" { + const alloc = testing.allocator; + var t = try init(alloc, 10, 5); + defer t.deinit(alloc); + + try t.print('A'); + t.carriageReturn(); + try t.linefeed(); + for ("123456789") |c| try t.print(c); + t.setCursorPos(t.screen.cursor.y + 1, 6); + t.setProtectedMode(.dec); + try t.print('X'); + t.setCursorPos(t.screen.cursor.y + 1, 4); + t.eraseDisplay(alloc, .below, true); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("A\n123 X", str); + } +} + +// X +test "Terminal: eraseDisplay protected above" { + const alloc = testing.allocator; + var t = try init(alloc, 10, 5); + defer t.deinit(alloc); + + try t.print('A'); + t.carriageReturn(); + try t.linefeed(); + t.eraseDisplay(alloc, .scroll_complete, false); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("", str); + } +} + +// X +test "Terminal: eraseDisplay scroll complete" { + const alloc = testing.allocator; + var t = try init(alloc, 10, 3); + defer t.deinit(alloc); + + try t.print('A'); + t.carriageReturn(); + try t.linefeed(); + for ("123456789") |c| try t.print(c); + t.setCursorPos(t.screen.cursor.y + 1, 6); + t.setProtectedMode(.dec); + try t.print('X'); + t.setCursorPos(t.screen.cursor.y + 1, 8); + t.eraseDisplay(alloc, .above, true); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("\n X 9", str); + } +} + +// X +test "Terminal: cursorLeft no wrap" { + const alloc = testing.allocator; + var t = try init(alloc, 10, 5); + defer t.deinit(alloc); + + try t.print('A'); + t.carriageReturn(); + try t.linefeed(); + try t.print('B'); + t.cursorLeft(10); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("A\nB", str); + } +} + +// X +test "Terminal: cursorLeft unsets pending wrap state" { + const alloc = testing.allocator; + var t = try init(alloc, 5, 5); + defer t.deinit(alloc); + + for ("ABCDE") |c| try t.print(c); + try testing.expect(t.screen.cursor.pending_wrap); + t.cursorLeft(1); + try testing.expect(!t.screen.cursor.pending_wrap); + try t.print('X'); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("ABCXE", str); + } +} + +// X +test "Terminal: cursorLeft unsets pending wrap state with longer jump" { + const alloc = testing.allocator; + var t = try init(alloc, 5, 5); + defer t.deinit(alloc); + + for ("ABCDE") |c| try t.print(c); + try testing.expect(t.screen.cursor.pending_wrap); + t.cursorLeft(3); + try testing.expect(!t.screen.cursor.pending_wrap); + try t.print('X'); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("AXCDE", str); + } +} + +// X +test "Terminal: cursorLeft reverse wrap with pending wrap state" { + const alloc = testing.allocator; + var t = try init(alloc, 5, 5); + defer t.deinit(alloc); + + t.modes.set(.wraparound, true); + t.modes.set(.reverse_wrap, true); + + for ("ABCDE") |c| try t.print(c); + try testing.expect(t.screen.cursor.pending_wrap); + t.cursorLeft(1); + try testing.expect(!t.screen.cursor.pending_wrap); + try t.print('X'); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("ABCDX", str); + } +} + +// X +test "Terminal: cursorLeft reverse wrap extended with pending wrap state" { + const alloc = testing.allocator; + var t = try init(alloc, 5, 5); + defer t.deinit(alloc); + + t.modes.set(.wraparound, true); + t.modes.set(.reverse_wrap_extended, true); + + for ("ABCDE") |c| try t.print(c); + try testing.expect(t.screen.cursor.pending_wrap); + t.cursorLeft(1); + try testing.expect(!t.screen.cursor.pending_wrap); + try t.print('X'); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("ABCDX", str); + } +} + +// X +test "Terminal: cursorLeft reverse wrap" { + const alloc = testing.allocator; + var t = try init(alloc, 5, 5); + defer t.deinit(alloc); + + t.modes.set(.wraparound, true); + t.modes.set(.reverse_wrap, true); + + for ("ABCDE1") |c| try t.print(c); + t.cursorLeft(2); + try t.print('X'); + try testing.expect(t.screen.cursor.pending_wrap); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("ABCDX\n1", str); + } +} + +// X +test "Terminal: cursorLeft reverse wrap with no soft wrap" { + const alloc = testing.allocator; + var t = try init(alloc, 5, 5); + defer t.deinit(alloc); + + t.modes.set(.wraparound, true); + t.modes.set(.reverse_wrap, true); + + for ("ABCDE") |c| try t.print(c); + t.carriageReturn(); + try t.linefeed(); + try t.print('1'); + t.cursorLeft(2); + try t.print('X'); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("ABCDE\nX", str); + } +} + +// X +test "Terminal: cursorLeft reverse wrap before left margin" { + const alloc = testing.allocator; + var t = try init(alloc, 5, 5); + defer t.deinit(alloc); + + t.modes.set(.wraparound, true); + t.modes.set(.reverse_wrap, true); + t.setTopAndBottomMargin(3, 0); + t.cursorLeft(1); + try t.print('X'); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("\n\nX", str); + } +} + +// X +test "Terminal: cursorLeft extended reverse wrap" { + const alloc = testing.allocator; + var t = try init(alloc, 5, 5); + defer t.deinit(alloc); + + t.modes.set(.wraparound, true); + t.modes.set(.reverse_wrap_extended, true); + + for ("ABCDE") |c| try t.print(c); + t.carriageReturn(); + try t.linefeed(); + try t.print('1'); + t.cursorLeft(2); + try t.print('X'); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("ABCDX\n1", str); + } +} + +// X +test "Terminal: cursorLeft extended reverse wrap bottom wraparound" { + const alloc = testing.allocator; + var t = try init(alloc, 5, 3); + defer t.deinit(alloc); + + t.modes.set(.wraparound, true); + t.modes.set(.reverse_wrap_extended, true); + + for ("ABCDE") |c| try t.print(c); + t.carriageReturn(); + try t.linefeed(); + try t.print('1'); + t.cursorLeft(1 + t.cols + 1); + try t.print('X'); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("ABCDE\n1\n X", str); + } +} + +// X +test "Terminal: cursorLeft extended reverse wrap is priority if both set" { + const alloc = testing.allocator; + var t = try init(alloc, 5, 3); + defer t.deinit(alloc); + + t.modes.set(.wraparound, true); + t.modes.set(.reverse_wrap, true); + t.modes.set(.reverse_wrap_extended, true); + + for ("ABCDE") |c| try t.print(c); + t.carriageReturn(); + try t.linefeed(); + try t.print('1'); + t.cursorLeft(1 + t.cols + 1); + try t.print('X'); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("ABCDE\n1\n X", str); + } +} + +// X +test "Terminal: cursorLeft extended reverse wrap above top scroll region" { + const alloc = testing.allocator; + var t = try init(alloc, 5, 5); + defer t.deinit(alloc); + + t.modes.set(.wraparound, true); + t.modes.set(.reverse_wrap_extended, true); + + t.setTopAndBottomMargin(3, 0); + t.setCursorPos(2, 1); + t.cursorLeft(1000); + + try testing.expectEqual(@as(usize, 0), t.screen.cursor.x); + try testing.expectEqual(@as(usize, 0), t.screen.cursor.y); +} + +// X +test "Terminal: cursorLeft reverse wrap on first row" { + const alloc = testing.allocator; + var t = try init(alloc, 5, 5); + defer t.deinit(alloc); + + t.modes.set(.wraparound, true); + t.modes.set(.reverse_wrap, true); + + t.setTopAndBottomMargin(3, 0); + t.setCursorPos(1, 2); + t.cursorLeft(1000); + + try testing.expectEqual(@as(usize, 0), t.screen.cursor.x); + try testing.expectEqual(@as(usize, 0), t.screen.cursor.y); +} + +// X +test "Terminal: cursorDown basic" { + const alloc = testing.allocator; + var t = try init(alloc, 5, 5); + defer t.deinit(alloc); + + try t.print('A'); + t.cursorDown(10); + try t.print('X'); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("A\n\n\n\n X", str); + } +} + +// X +test "Terminal: cursorDown above bottom scroll margin" { + const alloc = testing.allocator; + var t = try init(alloc, 5, 5); + defer t.deinit(alloc); + + t.setTopAndBottomMargin(1, 3); + try t.print('A'); + t.cursorDown(10); + try t.print('X'); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("A\n\n X", str); + } +} + +// X +test "Terminal: cursorDown below bottom scroll margin" { + const alloc = testing.allocator; + var t = try init(alloc, 5, 5); + defer t.deinit(alloc); + + t.setTopAndBottomMargin(1, 3); + try t.print('A'); + t.setCursorPos(4, 1); + t.cursorDown(10); + try t.print('X'); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("A\n\n\n\nX", str); + } +} + +// X +test "Terminal: cursorDown resets wrap" { + const alloc = testing.allocator; + var t = try init(alloc, 5, 5); + defer t.deinit(alloc); + + for ("ABCDE") |c| try t.print(c); + try testing.expect(t.screen.cursor.pending_wrap); + t.cursorDown(1); + try testing.expect(!t.screen.cursor.pending_wrap); + try t.print('X'); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("ABCDE\n X", str); + } +} + +// X +test "Terminal: cursorUp basic" { + const alloc = testing.allocator; + var t = try init(alloc, 5, 5); + defer t.deinit(alloc); + + t.setCursorPos(3, 1); + try t.print('A'); + t.cursorUp(10); + try t.print('X'); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings(" X\n\nA", str); + } +} + +// X +test "Terminal: cursorUp below top scroll margin" { + const alloc = testing.allocator; + var t = try init(alloc, 5, 5); + defer t.deinit(alloc); + + t.setTopAndBottomMargin(2, 4); + t.setCursorPos(3, 1); + try t.print('A'); + t.cursorUp(5); + try t.print('X'); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("\n X\nA", str); + } +} + +// X +test "Terminal: cursorUp above top scroll margin" { + const alloc = testing.allocator; + var t = try init(alloc, 5, 5); + defer t.deinit(alloc); + + t.setTopAndBottomMargin(3, 5); + t.setCursorPos(3, 1); + try t.print('A'); + t.setCursorPos(2, 1); + t.cursorUp(10); + try t.print('X'); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("X\n\nA", str); + } +} + +// X +test "Terminal: cursorUp resets wrap" { + const alloc = testing.allocator; + var t = try init(alloc, 5, 5); + defer t.deinit(alloc); + + for ("ABCDE") |c| try t.print(c); + try testing.expect(t.screen.cursor.pending_wrap); + t.cursorUp(1); + try testing.expect(!t.screen.cursor.pending_wrap); + try t.print('X'); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("ABCDX", str); + } +} + +// X +test "Terminal: cursorRight resets wrap" { + const alloc = testing.allocator; + var t = try init(alloc, 5, 5); + defer t.deinit(alloc); + + for ("ABCDE") |c| try t.print(c); + try testing.expect(t.screen.cursor.pending_wrap); + t.cursorRight(1); + try testing.expect(!t.screen.cursor.pending_wrap); + try t.print('X'); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("ABCDX", str); + } +} + +// X +test "Terminal: cursorRight to the edge of screen" { + const alloc = testing.allocator; + var t = try init(alloc, 5, 5); + defer t.deinit(alloc); + + t.cursorRight(100); + try t.print('X'); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings(" X", str); + } +} + +// X +test "Terminal: cursorRight left of right margin" { + const alloc = testing.allocator; + var t = try init(alloc, 5, 5); + defer t.deinit(alloc); + + t.scrolling_region.right = 2; + t.cursorRight(100); + try t.print('X'); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings(" X", str); + } +} + +// X +test "Terminal: cursorRight right of right margin" { + const alloc = testing.allocator; + var t = try init(alloc, 5, 5); + defer t.deinit(alloc); + + t.scrolling_region.right = 2; + t.screen.cursor.x = 3; + t.cursorRight(100); + try t.print('X'); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings(" X", str); + } +} + +// X +test "Terminal: scrollDown simple" { + const alloc = testing.allocator; + var t = try init(alloc, 5, 5); + defer t.deinit(alloc); + + try t.printString("ABC"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("DEF"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("GHI"); + t.setCursorPos(2, 2); + const cursor = t.screen.cursor; + try t.scrollDown(1); + try testing.expectEqual(cursor, t.screen.cursor); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("\nABC\nDEF\nGHI", str); + } +} + +// X +test "Terminal: scrollDown outside of scroll region" { + const alloc = testing.allocator; + var t = try init(alloc, 5, 5); + defer t.deinit(alloc); + + try t.printString("ABC"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("DEF"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("GHI"); + t.setTopAndBottomMargin(3, 4); + t.setCursorPos(2, 2); + const cursor = t.screen.cursor; + try t.scrollDown(1); + try testing.expectEqual(cursor, t.screen.cursor); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("ABC\nDEF\n\nGHI", str); + } +} + +// X +test "Terminal: scrollDown left/right scroll region" { + const alloc = testing.allocator; + var t = try init(alloc, 10, 10); + defer t.deinit(alloc); + + try t.printString("ABC123"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("DEF456"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("GHI789"); + t.scrolling_region.left = 1; + t.scrolling_region.right = 3; + t.setCursorPos(2, 2); + const cursor = t.screen.cursor; + try t.scrollDown(1); + try testing.expectEqual(cursor, t.screen.cursor); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("A 23\nDBC156\nGEF489\n HI7", str); + } +} + +// X +test "Terminal: scrollDown outside of left/right scroll region" { + const alloc = testing.allocator; + var t = try init(alloc, 10, 10); + defer t.deinit(alloc); + + try t.printString("ABC123"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("DEF456"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("GHI789"); + t.scrolling_region.left = 1; + t.scrolling_region.right = 3; + t.setCursorPos(1, 1); + const cursor = t.screen.cursor; + try t.scrollDown(1); + try testing.expectEqual(cursor, t.screen.cursor); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("A 23\nDBC156\nGEF489\n HI7", str); + } +} + +// X +test "Terminal: scrollDown preserves pending wrap" { + const alloc = testing.allocator; + var t = try init(alloc, 5, 10); + defer t.deinit(alloc); + + t.setCursorPos(1, 5); + try t.print('A'); + t.setCursorPos(2, 5); + try t.print('B'); + t.setCursorPos(3, 5); + try t.print('C'); + try t.scrollDown(1); + try t.print('X'); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("\n A\n B\nX C", str); + } +} + +// X +test "Terminal: scrollUp simple" { + const alloc = testing.allocator; + var t = try init(alloc, 5, 5); + defer t.deinit(alloc); + + try t.printString("ABC"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("DEF"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("GHI"); + t.setCursorPos(2, 2); + const cursor = t.screen.cursor; + try t.scrollUp(1); + try testing.expectEqual(cursor, t.screen.cursor); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("DEF\nGHI", str); + } +} + +// X +test "Terminal: scrollUp top/bottom scroll region" { + const alloc = testing.allocator; + var t = try init(alloc, 5, 5); + defer t.deinit(alloc); + + try t.printString("ABC"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("DEF"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("GHI"); + t.setTopAndBottomMargin(2, 3); + t.setCursorPos(1, 1); + try t.scrollUp(1); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("ABC\nGHI", str); + } +} + +// X +test "Terminal: scrollUp left/right scroll region" { + const alloc = testing.allocator; + var t = try init(alloc, 10, 10); + defer t.deinit(alloc); + + try t.printString("ABC123"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("DEF456"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("GHI789"); + t.scrolling_region.left = 1; + t.scrolling_region.right = 3; + t.setCursorPos(2, 2); + const cursor = t.screen.cursor; + try t.scrollUp(1); + try testing.expectEqual(cursor, t.screen.cursor); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("AEF423\nDHI756\nG 89", str); + } +} + +// X +test "Terminal: scrollUp preserves pending wrap" { + const alloc = testing.allocator; + var t = try init(alloc, 5, 5); + defer t.deinit(alloc); + + t.setCursorPos(1, 5); + try t.print('A'); + t.setCursorPos(2, 5); + try t.print('B'); + t.setCursorPos(3, 5); + try t.print('C'); + try t.scrollUp(1); + try t.print('X'); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings(" B\n C\n\nX", str); + } +} + +// X +test "Terminal: scrollUp full top/bottom region" { + const alloc = testing.allocator; + var t = try init(alloc, 5, 5); + defer t.deinit(alloc); + + try t.printString("top"); + t.setCursorPos(5, 1); + try t.printString("ABCDE"); + t.setTopAndBottomMargin(2, 5); + try t.scrollUp(4); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("top", str); + } +} + +// X +test "Terminal: scrollUp full top/bottomleft/right scroll region" { + const alloc = testing.allocator; + var t = try init(alloc, 5, 5); + defer t.deinit(alloc); + + try t.printString("top"); + t.setCursorPos(5, 1); + try t.printString("ABCDE"); + t.modes.set(.enable_left_and_right_margin, true); + t.setTopAndBottomMargin(2, 5); + t.setLeftAndRightMargin(2, 4); + try t.scrollUp(4); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("top\n\n\n\nA E", str); + } +} + +// X test "Terminal: tabClear single" { const alloc = testing.allocator; var t = try init(alloc, 30, 5); @@ -6996,6 +7414,7 @@ test "Terminal: tabClear single" { try testing.expectEqual(@as(usize, 16), t.screen.cursor.x); } +// X test "Terminal: tabClear all" { const alloc = testing.allocator; var t = try init(alloc, 30, 5); @@ -7007,6 +7426,7 @@ test "Terminal: tabClear all" { try testing.expectEqual(@as(usize, 29), t.screen.cursor.x); } +// X test "Terminal: printRepeat simple" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); @@ -7022,6 +7442,7 @@ test "Terminal: printRepeat simple" { } } +// X test "Terminal: printRepeat wrap" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); @@ -7037,6 +7458,7 @@ test "Terminal: printRepeat wrap" { } } +// X test "Terminal: printRepeat no previous character" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); @@ -7051,6 +7473,88 @@ test "Terminal: printRepeat no previous character" { } } +// X +test "Terminal: DECCOLM without DEC mode 40" { + const alloc = testing.allocator; + var t = try init(alloc, 5, 5); + defer t.deinit(alloc); + + t.modes.set(.@"132_column", true); + try t.deccolm(alloc, .@"132_cols"); + try testing.expectEqual(@as(usize, 5), t.cols); + try testing.expectEqual(@as(usize, 5), t.rows); + try testing.expect(!t.modes.get(.@"132_column")); +} + +// X +test "Terminal: DECCOLM unset" { + const alloc = testing.allocator; + var t = try init(alloc, 5, 5); + defer t.deinit(alloc); + + t.modes.set(.enable_mode_3, true); + try t.deccolm(alloc, .@"80_cols"); + try testing.expectEqual(@as(usize, 80), t.cols); + try testing.expectEqual(@as(usize, 5), t.rows); +} + +// X +test "Terminal: DECCOLM resets pending wrap" { + const alloc = testing.allocator; + var t = try init(alloc, 5, 5); + defer t.deinit(alloc); + + for ("ABCDE") |c| try t.print(c); + try testing.expect(t.screen.cursor.pending_wrap); + + t.modes.set(.enable_mode_3, true); + try t.deccolm(alloc, .@"80_cols"); + try testing.expectEqual(@as(usize, 80), t.cols); + try testing.expectEqual(@as(usize, 5), t.rows); + try testing.expect(!t.screen.cursor.pending_wrap); +} + +// X +test "Terminal: DECCOLM preserves SGR bg" { + const alloc = testing.allocator; + var t = try init(alloc, 5, 5); + defer t.deinit(alloc); + + const pen: Screen.Cell = .{ + .bg = .{ .rgb = .{ .r = 0xFF, .g = 0x00, .b = 0x00 } }, + }; + + t.screen.cursor.pen = pen; + t.modes.set(.enable_mode_3, true); + try t.deccolm(alloc, .@"80_cols"); + + { + const cell = t.screen.getCell(.active, 0, 0); + try testing.expectEqual(pen, cell); + } +} + +// X +test "Terminal: DECCOLM resets scroll region" { + const alloc = testing.allocator; + var t = try init(alloc, 5, 5); + defer t.deinit(alloc); + + t.modes.set(.enable_left_and_right_margin, true); + t.setTopAndBottomMargin(2, 3); + t.setLeftAndRightMargin(3, 5); + + t.modes.set(.enable_mode_3, true); + try t.deccolm(alloc, .@"80_cols"); + + try testing.expect(t.modes.get(.enable_left_and_right_margin)); + try testing.expectEqual(@as(usize, 0), t.scrolling_region.top); + try testing.expectEqual(@as(usize, 4), t.scrolling_region.bottom); + try testing.expectEqual(@as(usize, 0), t.scrolling_region.left); + try testing.expectEqual(@as(usize, 79), t.scrolling_region.right); +} + +// X test "Terminal: printAttributes" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); @@ -7102,678 +7606,27 @@ test "Terminal: printAttributes" { } } -test "Terminal: eraseDisplay simple erase below" { +test "Terminal: preserve grapheme cluster on large scrollback" { const alloc = testing.allocator; - var t = try init(alloc, 5, 5); + var t = try init(alloc, 5, 3); defer t.deinit(alloc); - for ("ABC") |c| try t.print(c); - t.carriageReturn(); - try t.linefeed(); - for ("DEF") |c| try t.print(c); - t.carriageReturn(); - try t.linefeed(); - for ("GHI") |c| try t.print(c); - t.setCursorPos(2, 2); - t.eraseDisplay(.below, false); + // This is the label emoji + the VS16 variant selector + const label = "\u{1F3F7}\u{FE0F}"; + // This bug required a certain behavior around scrollback interacting + // with the circular buffer that we use at the time of writing this test. + // Mainly, we want to verify that in certain scroll scenarios we preserve + // grapheme clusters. This test is admittedly somewhat brittle but we + // should keep it around to prevent this regression. + for (0..t.screen.max_scrollback * 2) |_| { + try t.printString(label ++ "\n"); + } + + try t.scrollViewport(.{ .delta = -1 }); { - const str = try t.plainString(testing.allocator); + const str = try t.screen.testString(alloc, .viewport); defer testing.allocator.free(str); - try testing.expectEqualStrings("ABC\nD", str); + try testing.expectEqualStrings("🏷️\n🏷️\n🏷️", str); } } - -test "Terminal: eraseDisplay erase below preserves SGR bg" { - const alloc = testing.allocator; - var t = try init(alloc, 5, 5); - defer t.deinit(alloc); - - for ("ABC") |c| try t.print(c); - t.carriageReturn(); - try t.linefeed(); - for ("DEF") |c| try t.print(c); - t.carriageReturn(); - try t.linefeed(); - for ("GHI") |c| try t.print(c); - t.setCursorPos(2, 2); - - try t.setAttribute(.{ .direct_color_bg = .{ - .r = 0xFF, - .g = 0, - .b = 0, - } }); - t.eraseDisplay(.below, false); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("ABC\nD", str); - for (1..5) |x| { - const list_cell = t.screen.pages.getCell(.{ .active = .{ .x = x, .y = 1 } }).?; - try testing.expect(list_cell.cell.content_tag == .bg_color_rgb); - try testing.expectEqual(Cell.RGB{ - .r = 0xFF, - .g = 0, - .b = 0, - }, list_cell.cell.content.color_rgb); - } - } -} - -test "Terminal: eraseDisplay below split multi-cell" { - const alloc = testing.allocator; - var t = try init(alloc, 5, 5); - defer t.deinit(alloc); - - try t.printString("AB橋C"); - t.carriageReturn(); - try t.linefeed(); - try t.printString("DE橋F"); - t.carriageReturn(); - try t.linefeed(); - try t.printString("GH橋I"); - t.setCursorPos(2, 4); - t.eraseDisplay(.below, false); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("AB橋C\nDE", str); - } -} - -test "Terminal: eraseDisplay below 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.carriageReturn(); - try t.linefeed(); - for ("DEF") |c| try t.print(c); - t.carriageReturn(); - try t.linefeed(); - for ("GHI") |c| try t.print(c); - t.setCursorPos(2, 2); - t.eraseDisplay(.below, false); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("ABC\nDEF\nGHI", str); - } -} - -test "Terminal: eraseDisplay below 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.carriageReturn(); - try t.linefeed(); - for ("DEF") |c| try t.print(c); - t.carriageReturn(); - try t.linefeed(); - for ("GHI") |c| try t.print(c); - t.setProtectedMode(.dec); - t.setProtectedMode(.off); - t.setCursorPos(2, 2); - t.eraseDisplay(.below, false); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("ABC\nD", str); - } -} - -test "Terminal: eraseDisplay below 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.carriageReturn(); - try t.linefeed(); - for ("DEF") |c| try t.print(c); - t.carriageReturn(); - try t.linefeed(); - for ("GHI") |c| try t.print(c); - t.setCursorPos(2, 2); - t.eraseDisplay(.below, false); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("ABC\nD", str); - } -} - -test "Terminal: eraseDisplay below protected attributes respected with force" { - 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.carriageReturn(); - try t.linefeed(); - for ("DEF") |c| try t.print(c); - t.carriageReturn(); - try t.linefeed(); - for ("GHI") |c| try t.print(c); - t.setCursorPos(2, 2); - t.eraseDisplay(.below, true); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("ABC\nDEF\nGHI", str); - } -} - -test "Terminal: eraseDisplay simple erase above" { - const alloc = testing.allocator; - var t = try init(alloc, 5, 5); - defer t.deinit(alloc); - - for ("ABC") |c| try t.print(c); - t.carriageReturn(); - try t.linefeed(); - for ("DEF") |c| try t.print(c); - t.carriageReturn(); - try t.linefeed(); - for ("GHI") |c| try t.print(c); - t.setCursorPos(2, 2); - t.eraseDisplay(.above, false); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("\n F\nGHI", str); - } -} - -test "Terminal: eraseDisplay erase above preserves SGR bg" { - const alloc = testing.allocator; - var t = try init(alloc, 5, 5); - defer t.deinit(alloc); - - for ("ABC") |c| try t.print(c); - t.carriageReturn(); - try t.linefeed(); - for ("DEF") |c| try t.print(c); - t.carriageReturn(); - try t.linefeed(); - for ("GHI") |c| try t.print(c); - t.setCursorPos(2, 2); - - try t.setAttribute(.{ .direct_color_bg = .{ - .r = 0xFF, - .g = 0, - .b = 0, - } }); - t.eraseDisplay(.above, false); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("\n F\nGHI", str); - for (0..2) |x| { - const list_cell = t.screen.pages.getCell(.{ .active = .{ .x = x, .y = 1 } }).?; - try testing.expect(list_cell.cell.content_tag == .bg_color_rgb); - try testing.expectEqual(Cell.RGB{ - .r = 0xFF, - .g = 0, - .b = 0, - }, list_cell.cell.content.color_rgb); - } - } -} - -test "Terminal: eraseDisplay above split multi-cell" { - const alloc = testing.allocator; - var t = try init(alloc, 5, 5); - defer t.deinit(alloc); - - try t.printString("AB橋C"); - t.carriageReturn(); - try t.linefeed(); - try t.printString("DE橋F"); - t.carriageReturn(); - try t.linefeed(); - try t.printString("GH橋I"); - t.setCursorPos(2, 3); - t.eraseDisplay(.above, false); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("\n F\nGH橋I", str); - } -} - -test "Terminal: eraseDisplay above 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.carriageReturn(); - try t.linefeed(); - for ("DEF") |c| try t.print(c); - t.carriageReturn(); - try t.linefeed(); - for ("GHI") |c| try t.print(c); - t.setCursorPos(2, 2); - t.eraseDisplay(.above, false); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("ABC\nDEF\nGHI", str); - } -} - -test "Terminal: eraseDisplay above 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.carriageReturn(); - try t.linefeed(); - for ("DEF") |c| try t.print(c); - t.carriageReturn(); - try t.linefeed(); - for ("GHI") |c| try t.print(c); - t.setProtectedMode(.dec); - t.setProtectedMode(.off); - t.setCursorPos(2, 2); - t.eraseDisplay(.above, false); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("\n F\nGHI", str); - } -} - -test "Terminal: eraseDisplay above 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.carriageReturn(); - try t.linefeed(); - for ("DEF") |c| try t.print(c); - t.carriageReturn(); - try t.linefeed(); - for ("GHI") |c| try t.print(c); - t.setCursorPos(2, 2); - t.eraseDisplay(.above, false); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("\n F\nGHI", str); - } -} - -test "Terminal: eraseDisplay above protected attributes respected with force" { - 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.carriageReturn(); - try t.linefeed(); - for ("DEF") |c| try t.print(c); - t.carriageReturn(); - try t.linefeed(); - for ("GHI") |c| try t.print(c); - t.setCursorPos(2, 2); - t.eraseDisplay(.above, true); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("ABC\nDEF\nGHI", str); - } -} - -test "Terminal: eraseDisplay protected complete" { - const alloc = testing.allocator; - var t = try init(alloc, 10, 5); - defer t.deinit(alloc); - - try t.print('A'); - t.carriageReturn(); - try t.linefeed(); - for ("123456789") |c| try t.print(c); - t.setCursorPos(t.screen.cursor.y + 1, 6); - t.setProtectedMode(.dec); - try t.print('X'); - t.setCursorPos(t.screen.cursor.y + 1, 4); - t.eraseDisplay(.complete, true); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("\n X", str); - } -} - -test "Terminal: eraseDisplay protected below" { - const alloc = testing.allocator; - var t = try init(alloc, 10, 5); - defer t.deinit(alloc); - - try t.print('A'); - t.carriageReturn(); - try t.linefeed(); - for ("123456789") |c| try t.print(c); - t.setCursorPos(t.screen.cursor.y + 1, 6); - t.setProtectedMode(.dec); - try t.print('X'); - t.setCursorPos(t.screen.cursor.y + 1, 4); - t.eraseDisplay(.below, true); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("A\n123 X", str); - } -} - -test "Terminal: eraseDisplay scroll complete" { - const alloc = testing.allocator; - var t = try init(alloc, 10, 5); - defer t.deinit(alloc); - - try t.print('A'); - t.carriageReturn(); - try t.linefeed(); - t.eraseDisplay(.scroll_complete, false); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("", str); - } -} - -test "Terminal: eraseDisplay protected above" { - const alloc = testing.allocator; - var t = try init(alloc, 10, 3); - defer t.deinit(alloc); - - try t.print('A'); - t.carriageReturn(); - try t.linefeed(); - for ("123456789") |c| try t.print(c); - t.setCursorPos(t.screen.cursor.y + 1, 6); - t.setProtectedMode(.dec); - try t.print('X'); - t.setCursorPos(t.screen.cursor.y + 1, 8); - t.eraseDisplay(.above, true); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("\n X 9", str); - } -} - -test "Terminal: cursorIsAtPrompt" { - const alloc = testing.allocator; - var t = try init(alloc, 3, 2); - defer t.deinit(alloc); - - try testing.expect(!t.cursorIsAtPrompt()); - t.markSemanticPrompt(.prompt); - try testing.expect(t.cursorIsAtPrompt()); - - // Input is also a prompt - t.markSemanticPrompt(.input); - try testing.expect(t.cursorIsAtPrompt()); - - // Newline -- we expect we're still at a prompt if we received - // prompt stuff before. - try t.linefeed(); - try testing.expect(t.cursorIsAtPrompt()); - - // But once we say we're starting output, we're not a prompt - t.markSemanticPrompt(.command); - try testing.expect(!t.cursorIsAtPrompt()); - try t.linefeed(); - try testing.expect(!t.cursorIsAtPrompt()); - - // Until we know we're at a prompt again - try t.linefeed(); - t.markSemanticPrompt(.prompt); - try testing.expect(t.cursorIsAtPrompt()); -} - -test "Terminal: cursorIsAtPrompt alternate screen" { - const alloc = testing.allocator; - var t = try init(alloc, 3, 2); - defer t.deinit(alloc); - - try testing.expect(!t.cursorIsAtPrompt()); - t.markSemanticPrompt(.prompt); - try testing.expect(t.cursorIsAtPrompt()); - - // Secondary screen is never a prompt - t.alternateScreen(.{}); - try testing.expect(!t.cursorIsAtPrompt()); - t.markSemanticPrompt(.prompt); - try testing.expect(!t.cursorIsAtPrompt()); -} - -test "Terminal: fullReset with a non-empty pen" { - var t = try init(testing.allocator, 80, 80); - defer t.deinit(testing.allocator); - - try t.setAttribute(.{ .direct_color_fg = .{ .r = 0xFF, .g = 0, .b = 0x7F } }); - try t.setAttribute(.{ .direct_color_bg = .{ .r = 0xFF, .g = 0, .b = 0x7F } }); - t.fullReset(); - - { - const list_cell = t.screen.pages.getCell(.{ .active = .{ - .x = t.screen.cursor.x, - .y = t.screen.cursor.y, - } }).?; - const cell = list_cell.cell; - try testing.expect(cell.style_id == 0); - } -} - -test "Terminal: fullReset origin mode" { - var t = try init(testing.allocator, 10, 10); - defer t.deinit(testing.allocator); - - t.setCursorPos(3, 5); - t.modes.set(.origin, true); - t.fullReset(); - - // Origin mode should be reset and the cursor should be moved - try testing.expectEqual(@as(usize, 0), t.screen.cursor.y); - try testing.expectEqual(@as(usize, 0), t.screen.cursor.x); - try testing.expect(!t.modes.get(.origin)); -} - -test "Terminal: fullReset status display" { - var t = try init(testing.allocator, 10, 10); - defer t.deinit(testing.allocator); - - t.status_display = .status_line; - t.fullReset(); - try testing.expect(t.status_display == .main); -} - -// 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. -test "Terminal: resize less cols with wide char then print" { - const alloc = testing.allocator; - var t = try init(alloc, 3, 3); - defer t.deinit(alloc); - - try t.print('x'); - try t.print('😀'); // 0x1F600 - try t.resize(alloc, 2, 3); - t.setCursorPos(1, 2); - try t.print('😀'); // 0x1F600 -} - -// https://github.com/mitchellh/ghostty/issues/723 -// This was found via fuzzing so its highly specific. -test "Terminal: resize with left and right margin set" { - const alloc = testing.allocator; - const cols = 70; - const rows = 23; - var t = try init(alloc, cols, rows); - defer t.deinit(alloc); - - t.modes.set(.enable_left_and_right_margin, true); - try t.print('0'); - t.modes.set(.enable_mode_3, true); - try t.resize(alloc, cols, rows); - t.setLeftAndRightMargin(2, 0); - try t.printRepeat(1850); - _ = t.modes.restore(.enable_mode_3); - try t.resize(alloc, cols, rows); -} - -// https://github.com/mitchellh/ghostty/issues/1343 -test "Terminal: resize with wraparound off" { - const alloc = testing.allocator; - const cols = 4; - const rows = 2; - var t = try init(alloc, cols, rows); - defer t.deinit(alloc); - - t.modes.set(.wraparound, false); - try t.print('0'); - try t.print('1'); - try t.print('2'); - try t.print('3'); - const new_cols = 2; - try t.resize(alloc, new_cols, rows); - - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("01", str); -} - -test "Terminal: resize with wraparound on" { - const alloc = testing.allocator; - const cols = 4; - const rows = 2; - var t = try init(alloc, cols, rows); - defer t.deinit(alloc); - - t.modes.set(.wraparound, true); - try t.print('0'); - try t.print('1'); - try t.print('2'); - try t.print('3'); - const new_cols = 2; - try t.resize(alloc, new_cols, rows); - - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("01\n23", str); -} - -test "Terminal: DECCOLM without DEC mode 40" { - const alloc = testing.allocator; - var t = try init(alloc, 5, 5); - defer t.deinit(alloc); - - t.modes.set(.@"132_column", true); - try t.deccolm(alloc, .@"132_cols"); - try testing.expectEqual(@as(usize, 5), t.cols); - try testing.expectEqual(@as(usize, 5), t.rows); - try testing.expect(!t.modes.get(.@"132_column")); -} - -test "Terminal: DECCOLM unset" { - const alloc = testing.allocator; - var t = try init(alloc, 5, 5); - defer t.deinit(alloc); - - t.modes.set(.enable_mode_3, true); - try t.deccolm(alloc, .@"80_cols"); - try testing.expectEqual(@as(usize, 80), t.cols); - try testing.expectEqual(@as(usize, 5), t.rows); -} - -test "Terminal: DECCOLM resets pending wrap" { - const alloc = testing.allocator; - var t = try init(alloc, 5, 5); - defer t.deinit(alloc); - - for ("ABCDE") |c| try t.print(c); - try testing.expect(t.screen.cursor.pending_wrap); - - t.modes.set(.enable_mode_3, true); - try t.deccolm(alloc, .@"80_cols"); - try testing.expectEqual(@as(usize, 80), t.cols); - try testing.expectEqual(@as(usize, 5), t.rows); - try testing.expect(!t.screen.cursor.pending_wrap); -} - -test "Terminal: DECCOLM preserves SGR bg" { - const alloc = testing.allocator; - var t = try init(alloc, 5, 5); - defer t.deinit(alloc); - - try t.setAttribute(.{ .direct_color_bg = .{ - .r = 0xFF, - .g = 0, - .b = 0, - } }); - t.modes.set(.enable_mode_3, true); - try t.deccolm(alloc, .@"80_cols"); - - { - const list_cell = t.screen.pages.getCell(.{ .active = .{ .x = 0, .y = 0 } }).?; - try testing.expect(list_cell.cell.content_tag == .bg_color_rgb); - try testing.expectEqual(Cell.RGB{ - .r = 0xFF, - .g = 0, - .b = 0, - }, list_cell.cell.content.color_rgb); - } -} - -test "Terminal: DECCOLM resets scroll region" { - const alloc = testing.allocator; - var t = try init(alloc, 5, 5); - defer t.deinit(alloc); - - t.modes.set(.enable_left_and_right_margin, true); - t.setTopAndBottomMargin(2, 3); - t.setLeftAndRightMargin(3, 5); - - t.modes.set(.enable_mode_3, true); - try t.deccolm(alloc, .@"80_cols"); - - try testing.expect(t.modes.get(.enable_left_and_right_margin)); - try testing.expectEqual(@as(usize, 0), t.scrolling_region.top); - try testing.expectEqual(@as(usize, 4), t.scrolling_region.bottom); - try testing.expectEqual(@as(usize, 0), t.scrolling_region.left); - try testing.expectEqual(@as(usize, 79), t.scrolling_region.right); -} diff --git a/src/terminal2/UTF8Decoder.zig b/src/terminal-old/UTF8Decoder.zig similarity index 100% rename from src/terminal2/UTF8Decoder.zig rename to src/terminal-old/UTF8Decoder.zig diff --git a/src/terminal2/ansi.zig b/src/terminal-old/ansi.zig similarity index 100% rename from src/terminal2/ansi.zig rename to src/terminal-old/ansi.zig diff --git a/src/terminal2/apc.zig b/src/terminal-old/apc.zig similarity index 100% rename from src/terminal2/apc.zig rename to src/terminal-old/apc.zig diff --git a/src/terminal2/charsets.zig b/src/terminal-old/charsets.zig similarity index 100% rename from src/terminal2/charsets.zig rename to src/terminal-old/charsets.zig diff --git a/src/terminal2/color.zig b/src/terminal-old/color.zig similarity index 100% rename from src/terminal2/color.zig rename to src/terminal-old/color.zig diff --git a/src/terminal2/csi.zig b/src/terminal-old/csi.zig similarity index 100% rename from src/terminal2/csi.zig rename to src/terminal-old/csi.zig diff --git a/src/terminal2/dcs.zig b/src/terminal-old/dcs.zig similarity index 100% rename from src/terminal2/dcs.zig rename to src/terminal-old/dcs.zig diff --git a/src/terminal2/device_status.zig b/src/terminal-old/device_status.zig similarity index 100% rename from src/terminal2/device_status.zig rename to src/terminal-old/device_status.zig diff --git a/src/terminal2/kitty.zig b/src/terminal-old/kitty.zig similarity index 100% rename from src/terminal2/kitty.zig rename to src/terminal-old/kitty.zig diff --git a/src/terminal2/kitty/graphics.zig b/src/terminal-old/kitty/graphics.zig similarity index 100% rename from src/terminal2/kitty/graphics.zig rename to src/terminal-old/kitty/graphics.zig diff --git a/src/terminal2/kitty/graphics_command.zig b/src/terminal-old/kitty/graphics_command.zig similarity index 100% rename from src/terminal2/kitty/graphics_command.zig rename to src/terminal-old/kitty/graphics_command.zig diff --git a/src/terminal2/kitty/graphics_exec.zig b/src/terminal-old/kitty/graphics_exec.zig similarity index 100% rename from src/terminal2/kitty/graphics_exec.zig rename to src/terminal-old/kitty/graphics_exec.zig diff --git a/src/terminal2/kitty/graphics_image.zig b/src/terminal-old/kitty/graphics_image.zig similarity index 98% rename from src/terminal2/kitty/graphics_image.zig rename to src/terminal-old/kitty/graphics_image.zig index 8f9a1b666..d84ea91d6 100644 --- a/src/terminal2/kitty/graphics_image.zig +++ b/src/terminal-old/kitty/graphics_image.zig @@ -6,7 +6,6 @@ const ArenaAllocator = std.heap.ArenaAllocator; const command = @import("graphics_command.zig"); const point = @import("../point.zig"); -const PageList = @import("../PageList.zig"); const internal_os = @import("../../os/main.zig"); const stb = @import("../../stb/main.zig"); @@ -452,8 +451,16 @@ pub const Image = struct { /// be rounded up to the nearest grid cell since we can't place images /// in partial grid cells. pub const Rect = struct { - top_left: PageList.Pin, - bottom_right: PageList.Pin, + top_left: point.ScreenPoint = .{}, + bottom_right: point.ScreenPoint = .{}, + + /// True if the rect contains a given screen point. + pub fn contains(self: Rect, p: point.ScreenPoint) bool { + return p.y >= self.top_left.y and + p.y <= self.bottom_right.y and + p.x >= self.top_left.x and + p.x <= self.bottom_right.x; + } }; /// Easy base64 encoding function. diff --git a/src/terminal2/kitty/graphics_storage.zig b/src/terminal-old/kitty/graphics_storage.zig similarity index 75% rename from src/terminal2/kitty/graphics_storage.zig rename to src/terminal-old/kitty/graphics_storage.zig index bde44074b..6e4efc55b 100644 --- a/src/terminal2/kitty/graphics_storage.zig +++ b/src/terminal-old/kitty/graphics_storage.zig @@ -6,12 +6,12 @@ const ArenaAllocator = std.heap.ArenaAllocator; const terminal = @import("../main.zig"); const point = @import("../point.zig"); const command = @import("graphics_command.zig"); -const PageList = @import("../PageList.zig"); const Screen = @import("../Screen.zig"); const LoadingImage = @import("graphics_image.zig").LoadingImage; const Image = @import("graphics_image.zig").Image; const Rect = @import("graphics_image.zig").Rect; const Command = command.Command; +const ScreenPoint = point.ScreenPoint; const log = std.log.scoped(.kitty_gfx); @@ -53,18 +53,13 @@ pub const ImageStorage = struct { total_bytes: usize = 0, total_limit: usize = 320 * 1000 * 1000, // 320MB - pub fn deinit( - self: *ImageStorage, - alloc: Allocator, - s: *terminal.Screen, - ) void { + pub fn deinit(self: *ImageStorage, alloc: Allocator) void { if (self.loading) |loading| loading.destroy(alloc); var it = self.images.iterator(); while (it.next()) |kv| kv.value_ptr.deinit(alloc); self.images.deinit(alloc); - self.clearPlacements(s); self.placements.deinit(alloc); } @@ -175,12 +170,6 @@ pub const ImageStorage = struct { self.dirty = true; } - fn clearPlacements(self: *ImageStorage, s: *terminal.Screen) void { - var it = self.placements.iterator(); - while (it.next()) |entry| entry.value_ptr.deinit(s); - self.placements.clearRetainingCapacity(); - } - /// Get an image by its ID. If the image doesn't exist, null is returned. pub fn imageById(self: *const ImageStorage, image_id: u32) ?Image { return self.images.get(image_id); @@ -208,20 +197,19 @@ pub const ImageStorage = struct { pub fn delete( self: *ImageStorage, alloc: Allocator, - t: *terminal.Terminal, + t: *const terminal.Terminal, cmd: command.Delete, ) void { switch (cmd) { .all => |delete_images| if (delete_images) { // We just reset our entire state. - self.deinit(alloc, &t.screen); + self.deinit(alloc); self.* = .{ .dirty = true, .total_limit = self.total_limit, }; } else { // Delete all our placements - self.clearPlacements(&t.screen); self.placements.deinit(alloc); self.placements = .{}; self.dirty = true; @@ -229,7 +217,6 @@ pub const ImageStorage = struct { .id => |v| self.deleteById( alloc, - &t.screen, v.image_id, v.placement_id, v.delete, @@ -237,59 +224,29 @@ pub const ImageStorage = struct { .newest => |v| newest: { const img = self.imageByNumber(v.image_number) orelse break :newest; - self.deleteById( - alloc, - &t.screen, - img.id, - v.placement_id, - v.delete, - ); + self.deleteById(alloc, img.id, v.placement_id, v.delete); }, .intersect_cursor => |delete_images| { - self.deleteIntersecting( - alloc, - t, - .{ .active = .{ - .x = t.screen.cursor.x, - .y = t.screen.cursor.y, - } }, - delete_images, - {}, - null, - ); + const target = (point.Viewport{ + .x = t.screen.cursor.x, + .y = t.screen.cursor.y, + }).toScreen(&t.screen); + self.deleteIntersecting(alloc, t, target, delete_images, {}, null); }, .intersect_cell => |v| { - self.deleteIntersecting( - alloc, - t, - .{ .active = .{ - .x = v.x, - .y = v.y, - } }, - v.delete, - {}, - null, - ); + const target = (point.Viewport{ .x = v.x, .y = v.y }).toScreen(&t.screen); + self.deleteIntersecting(alloc, t, target, v.delete, {}, null); }, .intersect_cell_z => |v| { - self.deleteIntersecting( - alloc, - t, - .{ .active = .{ - .x = v.x, - .y = v.y, - } }, - v.delete, - v.z, - struct { - fn filter(ctx: i32, p: Placement) bool { - return p.z == ctx; - } - }.filter, - ); + const target = (point.Viewport{ .x = v.x, .y = v.y }).toScreen(&t.screen); + self.deleteIntersecting(alloc, t, target, v.delete, v.z, struct { + fn filter(ctx: i32, p: Placement) bool { + return p.z == ctx; + } + }.filter); }, .column => |v| { @@ -298,7 +255,6 @@ pub const ImageStorage = struct { const img = self.imageById(entry.key_ptr.image_id) orelse continue; const rect = entry.value_ptr.rect(img, t); if (rect.top_left.x <= v.x and rect.bottom_right.x >= v.x) { - entry.value_ptr.deinit(&t.screen); self.placements.removeByPtr(entry.key_ptr); if (v.delete) self.deleteIfUnused(alloc, img.id); } @@ -308,24 +264,15 @@ pub const ImageStorage = struct { self.dirty = true; }, - .row => |v| row: { - // v.y is in active coords so we want to convert it to a pin - // so we can compare by page offsets. - const target_pin = t.screen.pages.pin(.{ .active = .{ - .y = v.y, - } }) orelse break :row; + .row => |v| { + // Get the screenpoint y + const y = (point.Viewport{ .x = 0, .y = v.y }).toScreen(&t.screen).y; var it = self.placements.iterator(); while (it.next()) |entry| { const img = self.imageById(entry.key_ptr.image_id) orelse continue; const rect = entry.value_ptr.rect(img, t); - - // We need to copy our pin to ensure we are at least at - // the top-left x. - var target_pin_copy = target_pin; - target_pin_copy.x = rect.top_left.x; - if (target_pin_copy.isBetween(rect.top_left, rect.bottom_right)) { - entry.value_ptr.deinit(&t.screen); + if (rect.top_left.y <= y and rect.bottom_right.y >= y) { self.placements.removeByPtr(entry.key_ptr); if (v.delete) self.deleteIfUnused(alloc, img.id); } @@ -340,7 +287,6 @@ pub const ImageStorage = struct { while (it.next()) |entry| { if (entry.value_ptr.z == v.z) { const image_id = entry.key_ptr.image_id; - entry.value_ptr.deinit(&t.screen); self.placements.removeByPtr(entry.key_ptr); if (v.delete) self.deleteIfUnused(alloc, image_id); } @@ -359,7 +305,6 @@ pub const ImageStorage = struct { fn deleteById( self: *ImageStorage, alloc: Allocator, - s: *terminal.Screen, image_id: u32, placement_id: u32, delete_unused: bool, @@ -369,18 +314,14 @@ pub const ImageStorage = struct { var it = self.placements.iterator(); while (it.next()) |entry| { if (entry.key_ptr.image_id == image_id) { - entry.value_ptr.deinit(s); self.placements.removeByPtr(entry.key_ptr); } } } else { - if (self.placements.getEntry(.{ + _ = self.placements.remove(.{ .image_id = image_id, .placement_id = .{ .tag = .external, .id = placement_id }, - })) |entry| { - entry.value_ptr.deinit(s); - self.placements.removeByPtr(entry.key_ptr); - } + }); } // If this is specified, then we also delete the image @@ -412,22 +353,18 @@ pub const ImageStorage = struct { fn deleteIntersecting( self: *ImageStorage, alloc: Allocator, - t: *terminal.Terminal, - p: point.Point, + t: *const terminal.Terminal, + p: point.ScreenPoint, delete_unused: bool, filter_ctx: anytype, comptime filter: ?fn (@TypeOf(filter_ctx), Placement) bool, ) void { - // Convert our target point to a pin for comparison. - const target_pin = t.screen.pages.pin(p) orelse return; - var it = self.placements.iterator(); while (it.next()) |entry| { const img = self.imageById(entry.key_ptr.image_id) orelse continue; const rect = entry.value_ptr.rect(img, t); - if (target_pin.isBetween(rect.top_left, rect.bottom_right)) { + if (rect.contains(p)) { if (filter) |f| if (!f(filter_ctx, entry.value_ptr.*)) continue; - entry.value_ptr.deinit(&t.screen); self.placements.removeByPtr(entry.key_ptr); if (delete_unused) self.deleteIfUnused(alloc, img.id); } @@ -549,8 +486,8 @@ pub const ImageStorage = struct { }; pub const Placement = struct { - /// The tracked pin for this placement. - pin: *PageList.Pin, + /// The location of the image on the screen. + point: ScreenPoint, /// Offset of the x/y from the top-left of the cell. x_offset: u32 = 0, @@ -569,13 +506,6 @@ pub const ImageStorage = struct { /// The z-index for this placement. z: i32 = 0, - pub fn deinit( - self: *const Placement, - s: *terminal.Screen, - ) void { - s.pages.untrackPin(self.pin); - } - /// Returns a selection of the entire rectangle this placement /// occupies within the screen. pub fn rect( @@ -585,13 +515,13 @@ pub const ImageStorage = struct { ) Rect { // If we have columns/rows specified we can simplify this whole thing. if (self.columns > 0 and self.rows > 0) { - var br = switch (self.pin.downOverflow(self.rows)) { - .offset => |v| v, - .overflow => |v| v.end, + return .{ + .top_left = self.point, + .bottom_right = .{ + .x = @min(self.point.x + self.columns, t.cols - 1), + .y = self.point.y + self.rows, + }, }; - br.x = @min(self.pin.x + self.columns, t.cols - 1); - - return .{ .top_left = self.pin.*, .bottom_right = br }; } // Calculate our cell size. @@ -612,31 +542,17 @@ pub const ImageStorage = struct { const width_cells: u32 = @intFromFloat(@ceil(width_f64 / cell_width_f64)); const height_cells: u32 = @intFromFloat(@ceil(height_f64 / cell_height_f64)); - // TODO(paged-terminal): clean this logic up above - var br = switch (self.pin.downOverflow(height_cells)) { - .offset => |v| v, - .overflow => |v| v.end, - }; - br.x = @min(self.pin.x + width_cells, t.cols - 1); - return .{ - .top_left = self.pin.*, - .bottom_right = br, + .top_left = self.point, + .bottom_right = .{ + .x = @min(self.point.x + width_cells, t.cols - 1), + .y = self.point.y + height_cells, + }, }; } }; }; -// Our pin for the placement -fn trackPin( - t: *terminal.Terminal, - pt: point.Point.Coordinate, -) !*PageList.Pin { - return try t.screen.pages.trackPin(t.screen.pages.pin(.{ - .active = pt, - }).?); -} - test "storage: add placement with zero placement id" { const testing = std.testing; const alloc = testing.allocator; @@ -646,11 +562,11 @@ test "storage: add placement with zero placement id" { t.height_px = 100; var s: ImageStorage = .{}; - defer s.deinit(alloc, &t.screen); + defer s.deinit(alloc); try s.addImage(alloc, .{ .id = 1, .width = 50, .height = 50 }); try s.addImage(alloc, .{ .id = 2, .width = 25, .height = 25 }); - try s.addPlacement(alloc, 1, 0, .{ .pin = try trackPin(&t, .{ .x = 25, .y = 25 }) }); - try s.addPlacement(alloc, 1, 0, .{ .pin = try trackPin(&t, .{ .x = 25, .y = 25 }) }); + try s.addPlacement(alloc, 1, 0, .{ .point = .{ .x = 25, .y = 25 } }); + try s.addPlacement(alloc, 1, 0, .{ .point = .{ .x = 25, .y = 25 } }); try testing.expectEqual(@as(usize, 2), s.placements.count()); try testing.expectEqual(@as(usize, 2), s.images.count()); @@ -671,22 +587,20 @@ test "storage: delete all placements and images" { const alloc = testing.allocator; var t = try terminal.Terminal.init(alloc, 3, 3); defer t.deinit(alloc); - const tracked = t.screen.pages.countTrackedPins(); var s: ImageStorage = .{}; - defer s.deinit(alloc, &t.screen); + defer s.deinit(alloc); try s.addImage(alloc, .{ .id = 1 }); try s.addImage(alloc, .{ .id = 2 }); try s.addImage(alloc, .{ .id = 3 }); - try s.addPlacement(alloc, 1, 1, .{ .pin = try trackPin(&t, .{ .x = 1, .y = 1 }) }); - try s.addPlacement(alloc, 2, 1, .{ .pin = try trackPin(&t, .{ .x = 1, .y = 1 }) }); + try s.addPlacement(alloc, 1, 1, .{ .point = .{ .x = 1, .y = 1 } }); + try s.addPlacement(alloc, 2, 1, .{ .point = .{ .x = 1, .y = 1 } }); s.dirty = false; s.delete(alloc, &t, .{ .all = true }); try testing.expect(s.dirty); try testing.expectEqual(@as(usize, 0), s.images.count()); try testing.expectEqual(@as(usize, 0), s.placements.count()); - try testing.expectEqual(tracked, t.screen.pages.countTrackedPins()); } test "storage: delete all placements and images preserves limit" { @@ -694,16 +608,15 @@ test "storage: delete all placements and images preserves limit" { const alloc = testing.allocator; var t = try terminal.Terminal.init(alloc, 3, 3); defer t.deinit(alloc); - const tracked = t.screen.pages.countTrackedPins(); var s: ImageStorage = .{}; - defer s.deinit(alloc, &t.screen); + defer s.deinit(alloc); s.total_limit = 5000; try s.addImage(alloc, .{ .id = 1 }); try s.addImage(alloc, .{ .id = 2 }); try s.addImage(alloc, .{ .id = 3 }); - try s.addPlacement(alloc, 1, 1, .{ .pin = try trackPin(&t, .{ .x = 1, .y = 1 }) }); - try s.addPlacement(alloc, 2, 1, .{ .pin = try trackPin(&t, .{ .x = 1, .y = 1 }) }); + try s.addPlacement(alloc, 1, 1, .{ .point = .{ .x = 1, .y = 1 } }); + try s.addPlacement(alloc, 2, 1, .{ .point = .{ .x = 1, .y = 1 } }); s.dirty = false; s.delete(alloc, &t, .{ .all = true }); @@ -711,7 +624,6 @@ test "storage: delete all placements and images preserves limit" { try testing.expectEqual(@as(usize, 0), s.images.count()); try testing.expectEqual(@as(usize, 0), s.placements.count()); try testing.expectEqual(@as(usize, 5000), s.total_limit); - try testing.expectEqual(tracked, t.screen.pages.countTrackedPins()); } test "storage: delete all placements" { @@ -719,22 +631,20 @@ test "storage: delete all placements" { const alloc = testing.allocator; var t = try terminal.Terminal.init(alloc, 3, 3); defer t.deinit(alloc); - const tracked = t.screen.pages.countTrackedPins(); var s: ImageStorage = .{}; - defer s.deinit(alloc, &t.screen); + defer s.deinit(alloc); try s.addImage(alloc, .{ .id = 1 }); try s.addImage(alloc, .{ .id = 2 }); try s.addImage(alloc, .{ .id = 3 }); - try s.addPlacement(alloc, 1, 1, .{ .pin = try trackPin(&t, .{ .x = 1, .y = 1 }) }); - try s.addPlacement(alloc, 2, 1, .{ .pin = try trackPin(&t, .{ .x = 1, .y = 1 }) }); + try s.addPlacement(alloc, 1, 1, .{ .point = .{ .x = 1, .y = 1 } }); + try s.addPlacement(alloc, 2, 1, .{ .point = .{ .x = 1, .y = 1 } }); s.dirty = false; s.delete(alloc, &t, .{ .all = false }); try testing.expect(s.dirty); try testing.expectEqual(@as(usize, 0), s.placements.count()); try testing.expectEqual(@as(usize, 3), s.images.count()); - try testing.expectEqual(tracked, t.screen.pages.countTrackedPins()); } test "storage: delete all placements by image id" { @@ -742,22 +652,20 @@ test "storage: delete all placements by image id" { const alloc = testing.allocator; var t = try terminal.Terminal.init(alloc, 3, 3); defer t.deinit(alloc); - const tracked = t.screen.pages.countTrackedPins(); var s: ImageStorage = .{}; - defer s.deinit(alloc, &t.screen); + defer s.deinit(alloc); try s.addImage(alloc, .{ .id = 1 }); try s.addImage(alloc, .{ .id = 2 }); try s.addImage(alloc, .{ .id = 3 }); - try s.addPlacement(alloc, 1, 1, .{ .pin = try trackPin(&t, .{ .x = 1, .y = 1 }) }); - try s.addPlacement(alloc, 2, 1, .{ .pin = try trackPin(&t, .{ .x = 1, .y = 1 }) }); + try s.addPlacement(alloc, 1, 1, .{ .point = .{ .x = 1, .y = 1 } }); + try s.addPlacement(alloc, 2, 1, .{ .point = .{ .x = 1, .y = 1 } }); s.dirty = false; s.delete(alloc, &t, .{ .id = .{ .image_id = 2 } }); try testing.expect(s.dirty); try testing.expectEqual(@as(usize, 1), s.placements.count()); try testing.expectEqual(@as(usize, 3), s.images.count()); - try testing.expectEqual(tracked + 1, t.screen.pages.countTrackedPins()); } test "storage: delete all placements by image id and unused images" { @@ -765,22 +673,20 @@ test "storage: delete all placements by image id and unused images" { const alloc = testing.allocator; var t = try terminal.Terminal.init(alloc, 3, 3); defer t.deinit(alloc); - const tracked = t.screen.pages.countTrackedPins(); var s: ImageStorage = .{}; - defer s.deinit(alloc, &t.screen); + defer s.deinit(alloc); try s.addImage(alloc, .{ .id = 1 }); try s.addImage(alloc, .{ .id = 2 }); try s.addImage(alloc, .{ .id = 3 }); - try s.addPlacement(alloc, 1, 1, .{ .pin = try trackPin(&t, .{ .x = 1, .y = 1 }) }); - try s.addPlacement(alloc, 2, 1, .{ .pin = try trackPin(&t, .{ .x = 1, .y = 1 }) }); + try s.addPlacement(alloc, 1, 1, .{ .point = .{ .x = 1, .y = 1 } }); + try s.addPlacement(alloc, 2, 1, .{ .point = .{ .x = 1, .y = 1 } }); s.dirty = false; s.delete(alloc, &t, .{ .id = .{ .delete = true, .image_id = 2 } }); try testing.expect(s.dirty); try testing.expectEqual(@as(usize, 1), s.placements.count()); try testing.expectEqual(@as(usize, 2), s.images.count()); - try testing.expectEqual(tracked + 1, t.screen.pages.countTrackedPins()); } test "storage: delete placement by specific id" { @@ -788,16 +694,15 @@ test "storage: delete placement by specific id" { const alloc = testing.allocator; var t = try terminal.Terminal.init(alloc, 3, 3); defer t.deinit(alloc); - const tracked = t.screen.pages.countTrackedPins(); var s: ImageStorage = .{}; - defer s.deinit(alloc, &t.screen); + defer s.deinit(alloc); try s.addImage(alloc, .{ .id = 1 }); try s.addImage(alloc, .{ .id = 2 }); try s.addImage(alloc, .{ .id = 3 }); - try s.addPlacement(alloc, 1, 1, .{ .pin = try trackPin(&t, .{ .x = 1, .y = 1 }) }); - try s.addPlacement(alloc, 1, 2, .{ .pin = try trackPin(&t, .{ .x = 1, .y = 1 }) }); - try s.addPlacement(alloc, 2, 1, .{ .pin = try trackPin(&t, .{ .x = 1, .y = 1 }) }); + try s.addPlacement(alloc, 1, 1, .{ .point = .{ .x = 1, .y = 1 } }); + try s.addPlacement(alloc, 1, 2, .{ .point = .{ .x = 1, .y = 1 } }); + try s.addPlacement(alloc, 2, 1, .{ .point = .{ .x = 1, .y = 1 } }); s.dirty = false; s.delete(alloc, &t, .{ .id = .{ @@ -808,7 +713,6 @@ test "storage: delete placement by specific id" { try testing.expect(s.dirty); try testing.expectEqual(@as(usize, 2), s.placements.count()); try testing.expectEqual(@as(usize, 3), s.images.count()); - try testing.expectEqual(tracked + 2, t.screen.pages.countTrackedPins()); } test "storage: delete intersecting cursor" { @@ -818,23 +722,22 @@ test "storage: delete intersecting cursor" { defer t.deinit(alloc); t.width_px = 100; t.height_px = 100; - const tracked = t.screen.pages.countTrackedPins(); var s: ImageStorage = .{}; - defer s.deinit(alloc, &t.screen); + defer s.deinit(alloc); try s.addImage(alloc, .{ .id = 1, .width = 50, .height = 50 }); try s.addImage(alloc, .{ .id = 2, .width = 25, .height = 25 }); - try s.addPlacement(alloc, 1, 1, .{ .pin = try trackPin(&t, .{ .x = 0, .y = 0 }) }); - try s.addPlacement(alloc, 1, 2, .{ .pin = try trackPin(&t, .{ .x = 25, .y = 25 }) }); + try s.addPlacement(alloc, 1, 1, .{ .point = .{ .x = 0, .y = 0 } }); + try s.addPlacement(alloc, 1, 2, .{ .point = .{ .x = 25, .y = 25 } }); - t.screen.cursorAbsolute(12, 12); + t.screen.cursor.x = 12; + t.screen.cursor.y = 12; s.dirty = false; s.delete(alloc, &t, .{ .intersect_cursor = false }); try testing.expect(s.dirty); try testing.expectEqual(@as(usize, 1), s.placements.count()); try testing.expectEqual(@as(usize, 2), s.images.count()); - try testing.expectEqual(tracked + 1, t.screen.pages.countTrackedPins()); // verify the placement is what we expect try testing.expect(s.placements.get(.{ @@ -850,23 +753,22 @@ test "storage: delete intersecting cursor plus unused" { defer t.deinit(alloc); t.width_px = 100; t.height_px = 100; - const tracked = t.screen.pages.countTrackedPins(); var s: ImageStorage = .{}; - defer s.deinit(alloc, &t.screen); + defer s.deinit(alloc); try s.addImage(alloc, .{ .id = 1, .width = 50, .height = 50 }); try s.addImage(alloc, .{ .id = 2, .width = 25, .height = 25 }); - try s.addPlacement(alloc, 1, 1, .{ .pin = try trackPin(&t, .{ .x = 0, .y = 0 }) }); - try s.addPlacement(alloc, 1, 2, .{ .pin = try trackPin(&t, .{ .x = 25, .y = 25 }) }); + try s.addPlacement(alloc, 1, 1, .{ .point = .{ .x = 0, .y = 0 } }); + try s.addPlacement(alloc, 1, 2, .{ .point = .{ .x = 25, .y = 25 } }); - t.screen.cursorAbsolute(12, 12); + t.screen.cursor.x = 12; + t.screen.cursor.y = 12; s.dirty = false; s.delete(alloc, &t, .{ .intersect_cursor = true }); try testing.expect(s.dirty); try testing.expectEqual(@as(usize, 1), s.placements.count()); try testing.expectEqual(@as(usize, 2), s.images.count()); - try testing.expectEqual(tracked + 1, t.screen.pages.countTrackedPins()); // verify the placement is what we expect try testing.expect(s.placements.get(.{ @@ -882,23 +784,22 @@ test "storage: delete intersecting cursor hits multiple" { defer t.deinit(alloc); t.width_px = 100; t.height_px = 100; - const tracked = t.screen.pages.countTrackedPins(); var s: ImageStorage = .{}; - defer s.deinit(alloc, &t.screen); + defer s.deinit(alloc); try s.addImage(alloc, .{ .id = 1, .width = 50, .height = 50 }); try s.addImage(alloc, .{ .id = 2, .width = 25, .height = 25 }); - try s.addPlacement(alloc, 1, 1, .{ .pin = try trackPin(&t, .{ .x = 0, .y = 0 }) }); - try s.addPlacement(alloc, 1, 2, .{ .pin = try trackPin(&t, .{ .x = 25, .y = 25 }) }); + try s.addPlacement(alloc, 1, 1, .{ .point = .{ .x = 0, .y = 0 } }); + try s.addPlacement(alloc, 1, 2, .{ .point = .{ .x = 25, .y = 25 } }); - t.screen.cursorAbsolute(26, 26); + t.screen.cursor.x = 26; + t.screen.cursor.y = 26; s.dirty = false; s.delete(alloc, &t, .{ .intersect_cursor = true }); try testing.expect(s.dirty); try testing.expectEqual(@as(usize, 0), s.placements.count()); try testing.expectEqual(@as(usize, 1), s.images.count()); - try testing.expectEqual(tracked, t.screen.pages.countTrackedPins()); } test "storage: delete by column" { @@ -908,14 +809,13 @@ test "storage: delete by column" { defer t.deinit(alloc); t.width_px = 100; t.height_px = 100; - const tracked = t.screen.pages.countTrackedPins(); var s: ImageStorage = .{}; - defer s.deinit(alloc, &t.screen); + defer s.deinit(alloc); try s.addImage(alloc, .{ .id = 1, .width = 50, .height = 50 }); try s.addImage(alloc, .{ .id = 2, .width = 25, .height = 25 }); - try s.addPlacement(alloc, 1, 1, .{ .pin = try trackPin(&t, .{ .x = 0, .y = 0 }) }); - try s.addPlacement(alloc, 1, 2, .{ .pin = try trackPin(&t, .{ .x = 25, .y = 25 }) }); + try s.addPlacement(alloc, 1, 1, .{ .point = .{ .x = 0, .y = 0 } }); + try s.addPlacement(alloc, 1, 2, .{ .point = .{ .x = 25, .y = 25 } }); s.dirty = false; s.delete(alloc, &t, .{ .column = .{ @@ -925,7 +825,6 @@ test "storage: delete by column" { try testing.expect(s.dirty); try testing.expectEqual(@as(usize, 1), s.placements.count()); try testing.expectEqual(@as(usize, 2), s.images.count()); - try testing.expectEqual(tracked + 1, t.screen.pages.countTrackedPins()); // verify the placement is what we expect try testing.expect(s.placements.get(.{ @@ -941,14 +840,13 @@ test "storage: delete by row" { defer t.deinit(alloc); t.width_px = 100; t.height_px = 100; - const tracked = t.screen.pages.countTrackedPins(); var s: ImageStorage = .{}; - defer s.deinit(alloc, &t.screen); + defer s.deinit(alloc); try s.addImage(alloc, .{ .id = 1, .width = 50, .height = 50 }); try s.addImage(alloc, .{ .id = 2, .width = 25, .height = 25 }); - try s.addPlacement(alloc, 1, 1, .{ .pin = try trackPin(&t, .{ .x = 0, .y = 0 }) }); - try s.addPlacement(alloc, 1, 2, .{ .pin = try trackPin(&t, .{ .x = 25, .y = 25 }) }); + try s.addPlacement(alloc, 1, 1, .{ .point = .{ .x = 0, .y = 0 } }); + try s.addPlacement(alloc, 1, 2, .{ .point = .{ .x = 25, .y = 25 } }); s.dirty = false; s.delete(alloc, &t, .{ .row = .{ @@ -958,7 +856,6 @@ test "storage: delete by row" { try testing.expect(s.dirty); try testing.expectEqual(@as(usize, 1), s.placements.count()); try testing.expectEqual(@as(usize, 2), s.images.count()); - try testing.expectEqual(tracked + 1, t.screen.pages.countTrackedPins()); // verify the placement is what we expect try testing.expect(s.placements.get(.{ diff --git a/src/terminal2/kitty/key.zig b/src/terminal-old/kitty/key.zig similarity index 100% rename from src/terminal2/kitty/key.zig rename to src/terminal-old/kitty/key.zig diff --git a/src/terminal2/kitty/testdata/image-png-none-50x76-2147483647-raw.data b/src/terminal-old/kitty/testdata/image-png-none-50x76-2147483647-raw.data similarity index 100% rename from src/terminal2/kitty/testdata/image-png-none-50x76-2147483647-raw.data rename to src/terminal-old/kitty/testdata/image-png-none-50x76-2147483647-raw.data diff --git a/src/terminal2/kitty/testdata/image-rgb-none-20x15-2147483647.data b/src/terminal-old/kitty/testdata/image-rgb-none-20x15-2147483647.data similarity index 100% rename from src/terminal2/kitty/testdata/image-rgb-none-20x15-2147483647.data rename to src/terminal-old/kitty/testdata/image-rgb-none-20x15-2147483647.data diff --git a/src/terminal2/kitty/testdata/image-rgb-zlib_deflate-128x96-2147483647.data b/src/terminal-old/kitty/testdata/image-rgb-zlib_deflate-128x96-2147483647.data similarity index 100% rename from src/terminal2/kitty/testdata/image-rgb-zlib_deflate-128x96-2147483647.data rename to src/terminal-old/kitty/testdata/image-rgb-zlib_deflate-128x96-2147483647.data diff --git a/src/terminal2/main.zig b/src/terminal-old/main.zig similarity index 81% rename from src/terminal2/main.zig rename to src/terminal-old/main.zig index 25a97cb2e..e886d97c1 100644 --- a/src/terminal2/main.zig +++ b/src/terminal-old/main.zig @@ -15,27 +15,21 @@ pub const color = @import("color.zig"); pub const device_status = @import("device_status.zig"); pub const kitty = @import("kitty.zig"); pub const modes = @import("modes.zig"); -pub const page = @import("page.zig"); pub const parse_table = @import("parse_table.zig"); pub const x11_color = @import("x11_color.zig"); pub const Charset = charsets.Charset; pub const CharsetSlot = charsets.Slots; pub const CharsetActiveSlot = charsets.ActiveSlot; -pub const Cell = page.Cell; pub const CSI = Parser.Action.CSI; pub const DCS = Parser.Action.DCS; pub const MouseShape = @import("mouse_shape.zig").MouseShape; -pub const Page = page.Page; -pub const PageList = @import("PageList.zig"); pub const Parser = @import("Parser.zig"); -pub const Pin = PageList.Pin; -pub const Screen = @import("Screen.zig"); pub const Selection = @import("Selection.zig"); +pub const Screen = @import("Screen.zig"); pub const Terminal = @import("Terminal.zig"); pub const Stream = stream.Stream; pub const Cursor = Screen.Cursor; -pub const CursorStyle = Screen.CursorStyle; pub const CursorStyleReq = ansi.CursorStyle; pub const DeviceAttributeReq = ansi.DeviceAttributeReq; pub const Mode = modes.Mode; @@ -48,12 +42,17 @@ pub const EraseLine = csi.EraseLine; pub const TabClear = csi.TabClear; pub const Attribute = sgr.Attribute; +// TODO(paged-terminal) +pub const StringMap = @import("StringMap.zig"); + +/// If we're targeting wasm then we export some wasm APIs. +pub usingnamespace if (builtin.target.isWasm()) struct { + pub usingnamespace @import("wasm.zig"); +} else struct {}; + +// TODO(paged-terminal) remove before merge +pub const new = @import("../terminal/main.zig"); + test { @import("std").testing.refAllDecls(@This()); - - // todo: make top-level imports - _ = @import("bitmap_allocator.zig"); - _ = @import("hash_map.zig"); - _ = @import("size.zig"); - _ = @import("style.zig"); } diff --git a/src/terminal2/modes.zig b/src/terminal-old/modes.zig similarity index 100% rename from src/terminal2/modes.zig rename to src/terminal-old/modes.zig diff --git a/src/terminal2/mouse_shape.zig b/src/terminal-old/mouse_shape.zig similarity index 100% rename from src/terminal2/mouse_shape.zig rename to src/terminal-old/mouse_shape.zig diff --git a/src/terminal2/osc.zig b/src/terminal-old/osc.zig similarity index 100% rename from src/terminal2/osc.zig rename to src/terminal-old/osc.zig diff --git a/src/terminal2/parse_table.zig b/src/terminal-old/parse_table.zig similarity index 100% rename from src/terminal2/parse_table.zig rename to src/terminal-old/parse_table.zig diff --git a/src/terminal-old/point.zig b/src/terminal-old/point.zig new file mode 100644 index 000000000..8c694f992 --- /dev/null +++ b/src/terminal-old/point.zig @@ -0,0 +1,254 @@ +const std = @import("std"); +const terminal = @import("main.zig"); +const Screen = terminal.Screen; + +// This file contains various types to represent x/y coordinates. We +// use different types so that we can lean on type-safety to get the +// exact expected type of point. + +/// Active is a point within the active part of the screen. +pub const Active = struct { + x: usize = 0, + y: usize = 0, + + pub fn toScreen(self: Active, screen: *const Screen) ScreenPoint { + return .{ + .x = self.x, + .y = screen.history + self.y, + }; + } + + test "toScreen with scrollback" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try Screen.init(alloc, 3, 5, 3); + defer s.deinit(); + const str = "1\n2\n3\n4\n5\n6\n7\n8"; + try s.testWriteString(str); + + try testing.expectEqual(ScreenPoint{ + .x = 1, + .y = 5, + }, (Active{ .x = 1, .y = 2 }).toScreen(&s)); + } +}; + +/// Viewport is a point within the viewport of the screen. +pub const Viewport = struct { + x: usize = 0, + y: usize = 0, + + pub fn toScreen(self: Viewport, screen: *const Screen) ScreenPoint { + // x is unchanged, y we have to add the visible offset to + // get the full offset from the top. + return .{ + .x = self.x, + .y = screen.viewport + self.y, + }; + } + + pub fn eql(self: Viewport, other: Viewport) bool { + return self.x == other.x and self.y == other.y; + } + + test "toScreen with no scrollback" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try Screen.init(alloc, 3, 5, 0); + defer s.deinit(); + + try testing.expectEqual(ScreenPoint{ + .x = 1, + .y = 1, + }, (Viewport{ .x = 1, .y = 1 }).toScreen(&s)); + } + + test "toScreen with scrollback" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try Screen.init(alloc, 3, 5, 3); + defer s.deinit(); + + // At the bottom + try s.scroll(.{ .screen = 6 }); + try testing.expectEqual(ScreenPoint{ + .x = 0, + .y = 3, + }, (Viewport{ .x = 0, .y = 0 }).toScreen(&s)); + + // Move the viewport a bit up + try s.scroll(.{ .screen = -1 }); + try testing.expectEqual(ScreenPoint{ + .x = 0, + .y = 2, + }, (Viewport{ .x = 0, .y = 0 }).toScreen(&s)); + + // Move the viewport to top + try s.scroll(.{ .top = {} }); + try testing.expectEqual(ScreenPoint{ + .x = 0, + .y = 0, + }, (Viewport{ .x = 0, .y = 0 }).toScreen(&s)); + } +}; + +/// A screen point. This is offset from the top of the scrollback +/// buffer. If the screen is scrolled or resized, this will have to +/// be recomputed. +pub const ScreenPoint = struct { + x: usize = 0, + y: usize = 0, + + /// Returns if this point is before another point. + pub fn before(self: ScreenPoint, other: ScreenPoint) bool { + return self.y < other.y or + (self.y == other.y and self.x < other.x); + } + + /// Returns if two points are equal. + pub fn eql(self: ScreenPoint, other: ScreenPoint) bool { + return self.x == other.x and self.y == other.y; + } + + /// Returns true if this screen point is currently in the active viewport. + pub fn inViewport(self: ScreenPoint, screen: *const Screen) bool { + return self.y >= screen.viewport and + self.y < screen.viewport + screen.rows; + } + + /// Converts this to a viewport point. If the point is above the + /// viewport this will move the point to (0, 0) and if it is below + /// the viewport it'll move it to (cols - 1, rows - 1). + pub fn toViewport(self: ScreenPoint, screen: *const Screen) Viewport { + // TODO: test + + // Before viewport + if (self.y < screen.viewport) return .{ .x = 0, .y = 0 }; + + // After viewport + if (self.y > screen.viewport + screen.rows) return .{ + .x = screen.cols - 1, + .y = screen.rows - 1, + }; + + return .{ .x = self.x, .y = self.y - screen.viewport }; + } + + /// Returns a screen point iterator. This will iterate over all of + /// of the points in a screen in a given direction one by one. + /// + /// The iterator is only valid as long as the screen is not resized. + pub fn iterator( + self: ScreenPoint, + screen: *const Screen, + dir: Direction, + ) Iterator { + return .{ .screen = screen, .current = self, .direction = dir }; + } + + pub const Iterator = struct { + screen: *const Screen, + current: ?ScreenPoint, + direction: Direction, + + pub fn next(self: *Iterator) ?ScreenPoint { + const current = self.current orelse return null; + self.current = switch (self.direction) { + .left_up => left_up: { + if (current.x == 0) { + if (current.y == 0) break :left_up null; + break :left_up .{ + .x = self.screen.cols - 1, + .y = current.y - 1, + }; + } + + break :left_up .{ + .x = current.x - 1, + .y = current.y, + }; + }, + + .right_down => right_down: { + if (current.x == self.screen.cols - 1) { + const max = self.screen.rows + self.screen.max_scrollback; + if (current.y == max - 1) break :right_down null; + break :right_down .{ + .x = 0, + .y = current.y + 1, + }; + } + + break :right_down .{ + .x = current.x + 1, + .y = current.y, + }; + }, + }; + + return current; + } + }; + + test "before" { + const testing = std.testing; + + const p: ScreenPoint = .{ .x = 5, .y = 2 }; + try testing.expect(p.before(.{ .x = 6, .y = 2 })); + try testing.expect(p.before(.{ .x = 3, .y = 3 })); + } + + test "iterator" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try Screen.init(alloc, 5, 5, 0); + defer s.deinit(); + + // Back from the first line + { + var pt: ScreenPoint = .{ .x = 1, .y = 0 }; + var it = pt.iterator(&s, .left_up); + try testing.expectEqual(ScreenPoint{ .x = 1, .y = 0 }, it.next().?); + try testing.expectEqual(ScreenPoint{ .x = 0, .y = 0 }, it.next().?); + try testing.expect(it.next() == null); + } + + // Back from second line + { + var pt: ScreenPoint = .{ .x = 1, .y = 1 }; + var it = pt.iterator(&s, .left_up); + try testing.expectEqual(ScreenPoint{ .x = 1, .y = 1 }, it.next().?); + try testing.expectEqual(ScreenPoint{ .x = 0, .y = 1 }, it.next().?); + try testing.expectEqual(ScreenPoint{ .x = 4, .y = 0 }, it.next().?); + } + + // Forward last line + { + var pt: ScreenPoint = .{ .x = 3, .y = 4 }; + var it = pt.iterator(&s, .right_down); + try testing.expectEqual(ScreenPoint{ .x = 3, .y = 4 }, it.next().?); + try testing.expectEqual(ScreenPoint{ .x = 4, .y = 4 }, it.next().?); + try testing.expect(it.next() == null); + } + + // Forward not last line + { + var pt: ScreenPoint = .{ .x = 3, .y = 3 }; + var it = pt.iterator(&s, .right_down); + try testing.expectEqual(ScreenPoint{ .x = 3, .y = 3 }, it.next().?); + try testing.expectEqual(ScreenPoint{ .x = 4, .y = 3 }, it.next().?); + try testing.expectEqual(ScreenPoint{ .x = 0, .y = 4 }, it.next().?); + } + } +}; + +/// Direction that points can go. +pub const Direction = enum { left_up, right_down }; + +test { + std.testing.refAllDecls(@This()); +} diff --git a/src/terminal2/res/rgb.txt b/src/terminal-old/res/rgb.txt similarity index 100% rename from src/terminal2/res/rgb.txt rename to src/terminal-old/res/rgb.txt diff --git a/src/terminal2/sanitize.zig b/src/terminal-old/sanitize.zig similarity index 100% rename from src/terminal2/sanitize.zig rename to src/terminal-old/sanitize.zig diff --git a/src/terminal2/sgr.zig b/src/terminal-old/sgr.zig similarity index 100% rename from src/terminal2/sgr.zig rename to src/terminal-old/sgr.zig diff --git a/src/terminal/simdvt.zig b/src/terminal-old/simdvt.zig similarity index 100% rename from src/terminal/simdvt.zig rename to src/terminal-old/simdvt.zig diff --git a/src/terminal2/stream.zig b/src/terminal-old/stream.zig similarity index 100% rename from src/terminal2/stream.zig rename to src/terminal-old/stream.zig diff --git a/src/terminal/wasm.zig b/src/terminal-old/wasm.zig similarity index 100% rename from src/terminal/wasm.zig rename to src/terminal-old/wasm.zig diff --git a/src/terminal2/x11_color.zig b/src/terminal-old/x11_color.zig similarity index 100% rename from src/terminal2/x11_color.zig rename to src/terminal-old/x11_color.zig diff --git a/src/terminal2/PageList.zig b/src/terminal/PageList.zig similarity index 100% rename from src/terminal2/PageList.zig rename to src/terminal/PageList.zig diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index 385ce1eba..694d5dfc0 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -1,79 +1,117 @@ -//! 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. -//! * Row - A single visible row in the screen. -//! * Line - A single line of text. This may map to multiple rows if -//! the row is soft-wrapped. -//! -//! The internal storage of the screen is stored in a circular buffer -//! with roughly the following format: -//! -//! Storage (Circular Buffer) -//! ┌─────────────────────────────────────┐ -//! │ ┌─────┐┌─────┐┌─────┐ ┌─────┐ │ -//! │ │ Hdr ││Cell ││Cell │ ... │Cell │ │ -//! │ │ ││ 0 ││ 1 │ │ N-1 │ │ -//! │ └─────┘└─────┘└─────┘ └─────┘ │ -//! │ ┌─────┐┌─────┐┌─────┐ ┌─────┐ │ -//! │ │ Hdr ││Cell ││Cell │ ... │Cell │ │ -//! │ │ ││ 0 ││ 1 │ │ N-1 │ │ -//! │ └─────┘└─────┘└─────┘ └─────┘ │ -//! │ ┌─────┐┌─────┐┌─────┐ ┌─────┐ │ -//! │ │ Hdr ││Cell ││Cell │ ... │Cell │ │ -//! │ │ ││ 0 ││ 1 │ │ N-1 │ │ -//! │ └─────┘└─────┘└─────┘ └─────┘ │ -//! └─────────────────────────────────────┘ -//! -//! There are R rows with N columns. Each row has an extra "cell" which is -//! the row header. The row header is used to track metadata about the row. -//! Each cell itself is a union (see StorageCell) of either the header or -//! the cell. -//! -//! The storage is in a circular buffer so that scrollback can be handled -//! without copying rows. The circular buffer is implemented in circ_buf.zig. -//! The top of the circular buffer (index 0) is the top of the screen, -//! i.e. the scrollback if there is a lot of data. -//! -//! The top of the active area (or end of the history area, same thing) is -//! cached in `self.history` and is an offset in rows. This could always be -//! calculated but profiling showed that caching it saves a lot of time in -//! hot loops for minimal memory cost. const Screen = @This(); const std = @import("std"); -const builtin = @import("builtin"); -const assert = std.debug.assert; const Allocator = std.mem.Allocator; - -const ziglyph = @import("ziglyph"); +const assert = std.debug.assert; const ansi = @import("ansi.zig"); -const modes = @import("modes.zig"); -const sgr = @import("sgr.zig"); -const color = @import("color.zig"); -const kitty = @import("kitty.zig"); -const point = @import("point.zig"); -const CircBuf = @import("../circ_buf.zig").CircBuf; -const Selection = @import("Selection.zig"); -const StringMap = @import("StringMap.zig"); -const fastmem = @import("../fastmem.zig"); const charsets = @import("charsets.zig"); +const kitty = @import("kitty.zig"); +const sgr = @import("sgr.zig"); +const unicode = @import("../unicode/main.zig"); +const Selection = @import("Selection.zig"); +const PageList = @import("PageList.zig"); +const pagepkg = @import("page.zig"); +const point = @import("point.zig"); +const size = @import("size.zig"); +const style = @import("style.zig"); +const Page = pagepkg.Page; +const Row = pagepkg.Row; +const Cell = pagepkg.Cell; +const Pin = PageList.Pin; -const log = std.log.scoped(.screen); +/// The general purpose allocator to use for all memory allocations. +/// Unfortunately some screen operations do require allocation. +alloc: Allocator, + +/// The list of pages in the screen. +pages: PageList, + +/// Special-case where we want no scrollback whatsoever. We have to flag +/// this because max_size 0 in PageList gets rounded up to two pages so +/// we can always have an active screen. +no_scrollback: bool = false, + +/// The current cursor position +cursor: Cursor, + +/// The saved cursor +saved_cursor: ?SavedCursor = null, + +/// The selection for this screen (if any). +//selection: ?Selection = null, +selection: ?void = null, + +/// The charset state +charset: CharsetState = .{}, + +/// 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, + +/// The kitty keyboard settings. +kitty_keyboard: kitty.KeyFlagStack = .{}, + +/// Kitty graphics protocol state. +kitty_images: kitty.graphics.ImageStorage = .{}, + +/// The cursor position. +pub const Cursor = struct { + // The x/y position within the viewport. + x: size.CellCountInt, + y: size.CellCountInt, + + /// The visual style of the cursor. This defaults to block because + /// it has to default to something, but users of this struct are + /// encouraged to set their own default. + cursor_style: CursorStyle = .block, + + /// The "last column flag (LCF)" as its called. If this is set then the + /// next character print will force a soft-wrap. + pending_wrap: bool = false, + + /// The protected mode state of the cursor. If this is true then + /// all new characters printed will have the protected state set. + protected: bool = false, + + /// The currently active style. This is the concrete style value + /// that should be kept up to date. The style ID to use for cell writing + /// is below. + style: style.Style = .{}, + + /// The currently active style ID. The style is page-specific so when + /// we change pages we need to ensure that we update that page with + /// our style when used. + style_id: style.Id = style.default_id, + style_ref: ?*size.CellCountInt = null, + + /// The pointers into the page list where the cursor is currently + /// located. This makes it faster to move the cursor. + page_pin: *PageList.Pin, + page_row: *pagepkg.Row, + page_cell: *pagepkg.Cell, +}; + +/// The visual style of the cursor. Whether or not it blinks +/// is determined by mode 12 (modes.zig). This mode is synchronized +/// with CSI q, the same as xterm. +pub const CursorStyle = enum { bar, block, underline }; + +/// Saved cursor state. +pub const SavedCursor = struct { + x: size.CellCountInt, + y: size.CellCountInt, + style: style.Style, + protected: bool, + pending_wrap: bool, + origin: bool, + charset: CharsetState, +}; /// State required for all charset operations. -const CharsetState = struct { +pub const CharsetState = struct { /// The list of graphical charsets by slot charsets: CharsetArray = CharsetArray.initFill(charsets.Charset.utf8), @@ -89,1647 +127,1001 @@ const CharsetState = struct { const CharsetArray = std.EnumArray(charsets.Slots, charsets.Charset); }; -/// 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, - - /// The visual style of the cursor. This defaults to block because - /// it has to default to something, but users of this struct are - /// encouraged to set their own default. - style: Style = .block, - - /// 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, - - /// The visual style of the cursor. Whether or not it blinks - /// is determined by mode 12 (modes.zig). This mode is synchronized - /// with CSI q, the same as xterm. - pub const Style = enum { bar, block, underline }; - - /// Saved cursor state. This contains more than just Cursor members - /// because additional state is stored. - pub const Saved = struct { - x: usize, - y: usize, - pen: Cell, - pending_wrap: bool, - origin: bool, - charset: CharsetState, - }; -}; - -/// 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 (!std.debug.runtime_safety) { - // 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 { - pub const Id = u32; - - /// The ID of this row, used to uniquely identify this row. The cells - /// are also ID'd by id + cell index (0-indexed). This will wrap around - /// when it reaches the maximum value for the type. For caching purposes, - /// when wrapping happens, all rows in the screen will be marked dirty. - id: Id = 0, - - // Packed flags - flags: packed struct { - /// If true, this row is soft-wrapped. The first cell of the next - /// row is a continuous of this row. - wrap: bool = false, - - /// True if this row has had changes. It is up to the caller to - /// set this to false. See the methods on Row to see what will set - /// this to true. - dirty: bool = false, - - /// True if any cell in this row has a grapheme associated with it. - grapheme: bool = false, - - /// True if this row is an active prompt (awaiting input). This is - /// set to false when the semantic prompt events (OSC 133) are received. - /// There are scenarios where the shell may never send this event, so - /// in order to reliably test prompt status, you need to iterate - /// backwards from the cursor to check the current line status going - /// back. - semantic_prompt: SemanticPrompt = .unknown, - } = .{}, - - /// Semantic prompt type. - pub const SemanticPrompt = enum(u3) { - /// Unknown, the running application didn't tell us for this line. - unknown = 0, - - /// This is a prompt line, meaning it only contains the shell prompt. - /// For poorly behaving shells, this may also be the input. - prompt = 1, - prompt_continuation = 2, - - /// This line contains the input area. We don't currently track - /// where this actually is in the line, so we just assume it is somewhere. - input = 3, - - /// This line is the start of command output. - command = 4, - - /// True if this is a prompt or input line. - pub fn promptOrInput(self: SemanticPrompt) bool { - return self == .prompt or self == .prompt_continuation or self == .input; - } - }; -}; - -/// The color associated with a single cell's foreground or background. -const CellColor = union(enum) { - none, - indexed: u8, - rgb: color.RGB, - - pub fn eql(self: CellColor, other: CellColor) bool { - return switch (self) { - .none => other == .none, - .indexed => |i| switch (other) { - .indexed => other.indexed == i, - else => false, - }, - .rgb => |rgb| switch (other) { - .rgb => other.rgb.eql(rgb), - else => 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. - fg: CellColor = .none, - bg: CellColor = .none, - - /// Underline color. - /// NOTE(mitchellh): This is very rarely set so ideally we wouldn't waste - /// cell space for this. For now its on this struct because it is convenient - /// but we should consider a lookaside table for this. - underline_fg: color.RGB = .{}, - - /// On/off attributes that can be set - attrs: packed struct { - bold: bool = false, - italic: bool = false, - faint: bool = false, - blink: bool = false, - inverse: bool = false, - invisible: bool = false, - strikethrough: bool = false, - underline: sgr.Attribute.Underline = .none, - underline_color: bool = false, - protected: 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 preceding - /// wide character (tail) or following (head). - wide_spacer_tail: bool = false, - wide_spacer_head: bool = false, - - /// True if this cell has additional codepoints to form a complete - /// grapheme cluster. If this is true, then the row grapheme flag must - /// also be true. The grapheme code points can be looked up in the - /// screen grapheme map. - grapheme: bool = false, - - /// Returns only the attributes related to style. - pub fn styleAttrs(self: @This()) @This() { - var copy = self; - copy.wide = false; - copy.wide_spacer_tail = false; - copy.wide_spacer_head = false; - copy.grapheme = false; - return copy; - } - } = .{}, - - /// True if the cell should be skipped for drawing - pub fn empty(self: Cell) bool { - // Get our backing integer for our packed struct of attributes - const AttrInt = @Type(.{ .Int = .{ - .signedness = .unsigned, - .bits = @bitSizeOf(@TypeOf(self.attrs)), - } }); - - // We're empty if we have no char AND we have no styling - return self.char == 0 and - self.fg == .none and - self.bg == .none and - @as(AttrInt, @bitCast(self.attrs)) == 0; - } - - /// The width of the cell. - /// - /// This uses the legacy calculation of a per-codepoint width calculation - /// to determine the width. This legacy calculation is incorrect because - /// it doesn't take into account multi-codepoint graphemes. - /// - /// The goal of this function is to match the expectation of shells - /// that aren't grapheme aware (at the time of writing this comment: none - /// are grapheme aware). This means it should match wcswidth. - pub fn widthLegacy(self: Cell) u8 { - // Wide is always 2 - if (self.attrs.wide) return 2; - - // Wide spacers are always 0 because their width is accounted for - // in the wide char. - if (self.attrs.wide_spacer_tail or self.attrs.wide_spacer_head) return 0; - - return 1; - } - - test "widthLegacy" { - const testing = std.testing; - - var c: Cell = .{}; - try testing.expectEqual(@as(u16, 1), c.widthLegacy()); - - c = .{ .attrs = .{ .wide = true } }; - try testing.expectEqual(@as(u16, 2), c.widthLegacy()); - - c = .{ .attrs = .{ .wide_spacer_tail = true } }; - try testing.expectEqual(@as(u16, 0), c.widthLegacy()); - } - - 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={} bits={} {}", .{ @sizeOf(Cell), @bitSizeOf(Cell), @alignOf(Cell) }); - try std.testing.expectEqual(20, @sizeOf(Cell)); - } -}; - -/// A row is a single row in the screen. -pub const Row = struct { - /// The screen this row is part of. - screen: *Screen, - - /// 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, - - /// Returns the ID for this row. You can turn this into a cell ID - /// by adding the cell offset plus 1 (so it is 1-indexed). - pub inline fn getId(self: Row) RowHeader.Id { - return self.storage[0].header.id; - } - - /// 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.flags.wrap = v; - } - - /// Set a row as dirty or not. Generally you only set a row as NOT dirty. - /// Various Row functions manage flagging dirty to true. - pub fn setDirty(self: Row, v: bool) void { - self.storage[0].header.flags.dirty = v; - } - - pub inline fn isDirty(self: Row) bool { - return self.storage[0].header.flags.dirty; - } - - pub inline fn isWrapped(self: Row) bool { - return self.storage[0].header.flags.wrap; - } - - /// Set the semantic prompt state for this row. - pub fn setSemanticPrompt(self: Row, p: RowHeader.SemanticPrompt) void { - self.storage[0].header.flags.semantic_prompt = p; - } - - /// Retrieve the semantic prompt state for this row. - pub fn getSemanticPrompt(self: Row) RowHeader.SemanticPrompt { - return self.storage[0].header.flags.semantic_prompt; - } - - /// 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; - } - - /// 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; - 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 { - self.fillSlice(cell, 0, self.storage.len - 1); - } - - /// Fill a slice of a row. - pub fn fillSlice(self: Row, cell: Cell, start: usize, len: usize) void { - assert(len <= self.storage.len - 1); - assert(!cell.attrs.grapheme); // you can't fill with graphemes - - // Always mark the row as dirty for this. - self.storage[0].header.flags.dirty = true; - - // If our row has no graphemes, then this is a fast copy - if (!self.storage[0].header.flags.grapheme) { - @memset(self.storage[start + 1 .. len + 1], .{ .cell = cell }); - return; - } - - // We have graphemes, so we have to clear those first. - for (self.storage[start + 1 .. len + 1], 0..) |*storage_cell, x| { - if (storage_cell.cell.attrs.grapheme) self.clearGraphemes(x); - storage_cell.* = .{ .cell = cell }; - } - - // We only reset the grapheme flag if we fill the whole row, for now. - // We can improve performance by more correctly setting this but I'm - // going to defer that until we can measure. - if (start == 0 and len == self.storage.len - 1) { - self.storage[0].header.flags.grapheme = false; - } - } - - /// Get a single immutable cell. - pub fn getCell(self: Row, x: usize) Cell { - 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(x < self.storage.len - 1); - - // Always mark the row as dirty for this. - self.storage[0].header.flags.dirty = true; - - return &self.storage[x + 1].cell; - } - - /// Attach a grapheme codepoint to the given cell. - pub fn attachGrapheme(self: Row, x: usize, cp: u21) !void { - assert(x < self.storage.len - 1); - - const cell = &self.storage[x + 1].cell; - const key = self.getId() + x + 1; - const gop = try self.screen.graphemes.getOrPut(self.screen.alloc, key); - errdefer if (!gop.found_existing) { - _ = self.screen.graphemes.remove(key); - }; - - // Our row now has a grapheme - self.storage[0].header.flags.grapheme = true; - - // Our row is now dirty - self.storage[0].header.flags.dirty = true; - - // If we weren't previously a grapheme and we found an existing value - // it means that it is old grapheme data. Just delete that. - if (!cell.attrs.grapheme and gop.found_existing) { - cell.attrs.grapheme = true; - gop.value_ptr.deinit(self.screen.alloc); - gop.value_ptr.* = .{ .one = cp }; - return; - } - - // If we didn't have a previous value, attach the single codepoint. - if (!gop.found_existing) { - cell.attrs.grapheme = true; - gop.value_ptr.* = .{ .one = cp }; - return; - } - - // We have an existing value, promote - assert(cell.attrs.grapheme); - try gop.value_ptr.append(self.screen.alloc, cp); - } - - /// Removes all graphemes associated with a cell. - pub fn clearGraphemes(self: Row, x: usize) void { - assert(x < self.storage.len - 1); - - // Our row is now dirty - self.storage[0].header.flags.dirty = true; - - const cell = &self.storage[x + 1].cell; - const key = self.getId() + x + 1; - cell.attrs.grapheme = false; - if (self.screen.graphemes.fetchRemove(key)) |kv| { - kv.value.deinit(self.screen.alloc); - } - } - - /// Copy a single cell from column x in src to column x in this row. - pub fn copyCell(self: Row, src: Row, x: usize) !void { - const dst_cell = self.getCellPtr(x); - const src_cell = src.getCellPtr(x); - - // If our destination has graphemes, we have to clear them. - if (dst_cell.attrs.grapheme) self.clearGraphemes(x); - dst_cell.* = src_cell.*; - - // If the source doesn't have any graphemes, then we can just copy. - if (!src_cell.attrs.grapheme) return; - - // Source cell has graphemes. Copy them. - const src_key = src.getId() + x + 1; - const src_data = src.screen.graphemes.get(src_key) orelse return; - const dst_key = self.getId() + x + 1; - const dst_gop = try self.screen.graphemes.getOrPut(self.screen.alloc, dst_key); - dst_gop.value_ptr.* = try src_data.copy(self.screen.alloc); - self.storage[0].header.flags.grapheme = true; - } - - /// Copy the row src into this row. The row can be from another screen. - pub fn copyRow(self: Row, src: Row) !void { - // 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; - - // If the source has no graphemes (likely) then this is fast. - const end = @min(src.storage.len, self.storage.len); - if (!src.storage[0].header.flags.grapheme) { - fastmem.copy(StorageCell, self.storage[1..], src.storage[1..end]); - return; - } - - // Source has graphemes, this is slow. - for (src.storage[1..end], 0..) |storage, x| { - self.storage[x + 1] = .{ .cell = storage.cell }; - - // Copy grapheme data if it exists - if (storage.cell.attrs.grapheme) { - const src_key = src.getId() + x + 1; - const src_data = src.screen.graphemes.get(src_key) orelse continue; - - const dst_key = self.getId() + x + 1; - const dst_gop = try self.screen.graphemes.getOrPut(self.screen.alloc, dst_key); - dst_gop.value_ptr.* = try src_data.copy(self.screen.alloc); - - self.storage[0].header.flags.grapheme = true; - } - } - } - - /// Read-only iterator for the cells in the row. - pub fn cellIterator(self: Row) CellIterator { - return .{ .row = self }; - } - - /// Returns the number of codepoints in the cell at column x, - /// including the primary codepoint. - pub fn codepointLen(self: Row, x: usize) usize { - var it = self.codepointIterator(x); - return it.len() + 1; - } - - /// Read-only iterator for the grapheme codepoints in a cell. This only - /// iterates over the EXTRA GRAPHEME codepoints and not the primary - /// codepoint in cell.char. - pub fn codepointIterator(self: Row, x: usize) CodepointIterator { - const cell = &self.storage[x + 1].cell; - if (!cell.attrs.grapheme) return .{ .data = .{ .zero = {} } }; - - const key = self.getId() + x + 1; - const data: GraphemeData = self.screen.graphemes.get(key) orelse data: { - // This is probably a bug somewhere in our internal state, - // but we don't want to just hard crash so its easier to just - // have zero codepoints. - log.debug("cell with grapheme flag but no grapheme data", .{}); - break :data .{ .zero = {} }; - }; - return .{ .data = data }; - } - - /// Returns true if this cell is the end of a grapheme cluster. - /// - /// NOTE: If/when "real" grapheme cluster support is in then - /// this will be removed because every cell will represent exactly - /// one grapheme cluster. - pub fn graphemeBreak(self: Row, x: usize) bool { - const cell = &self.storage[x + 1].cell; - - // Right now, if we are a grapheme, we only store ZWJs on - // the grapheme data so that means we can't be a break. - if (cell.attrs.grapheme) return false; - - // If we are a tail then we check our prior cell. - if (cell.attrs.wide_spacer_tail and x > 0) { - return self.graphemeBreak(x - 1); - } - - // If we are a wide char, then we have to check our prior cell. - if (cell.attrs.wide and x > 0) { - return self.graphemeBreak(x - 1); - } - - return true; - } -}; - -/// Used to iterate through the rows of a specific region. -pub const RowIterator = struct { - screen: *Screen, - tag: RowIndexTag, - max: usize, - value: usize = 0, - - pub fn next(self: *RowIterator) ?Row { - if (self.value >= self.max) 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; - } -}; - -/// Used to iterate through the codepoints of a cell. This only iterates -/// over the extra grapheme codepoints and not the primary codepoint. -pub const CodepointIterator = struct { - data: GraphemeData, - i: usize = 0, - - /// Returns the number of codepoints in the iterator. - pub fn len(self: CodepointIterator) usize { - switch (self.data) { - .zero => return 0, - .one => return 1, - .two => return 2, - .three => return 3, - .four => return 4, - .many => |v| return v.len, - } - } - - pub fn next(self: *CodepointIterator) ?u21 { - switch (self.data) { - .zero => return null, - - .one => |v| { - if (self.i >= 1) return null; - self.i += 1; - return v; - }, - - .two => |v| { - if (self.i >= v.len) return null; - defer self.i += 1; - return v[self.i]; - }, - - .three => |v| { - if (self.i >= v.len) return null; - defer self.i += 1; - return v[self.i]; - }, - - .four => |v| { - if (self.i >= v.len) return null; - defer self.i += 1; - return v[self.i]; - }, - - .many => |v| { - if (self.i >= v.len) return null; - defer self.i += 1; - return v[self.i]; - }, - } - } - - pub fn reset(self: *CodepointIterator) void { - self.i = 0; - } -}; - -/// 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: { - // NOTE for this and others below: Zig is supposed to optimize - // away assert in releasefast but for some reason these were - // not being optimized away. I don't know why. For these asserts - // only, I comptime gate them. - if (std.debug.runtime_safety) assert(y < RowIndexTag.screen.maxLen(screen)); - break :y y; - }, - - .viewport => |y| y: { - if (std.debug.runtime_safety) assert(y < RowIndexTag.viewport.maxLen(screen)); - break :y y + screen.viewport; - }, - - .active => |y| y: { - if (std.debug.runtime_safety) assert(y < RowIndexTag.active.maxLen(screen)); - break :y screen.history + y; - }, - - .history => |y| y: { - if (std.debug.runtime_safety) 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 inline fn maxLen(self: RowIndexTag, screen: *const Screen) usize { - return switch (self) { - // Screen can be any of the written rows - .screen => screen.rowsWritten(), - - // Viewport can be any of the written rows or the max size - // of a viewport. - .viewport => @max(1, @min(screen.rows, screen.rowsWritten())), - - // 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 => screen.history, - - // 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, - //TODO .active => @min(rows_written, 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 }, - }; - } -}; - -/// Stores the extra unicode codepoints that form a complete grapheme -/// cluster alongside a cell. We store this separately from a Cell because -/// grapheme clusters are relatively rare (depending on the language) and -/// we don't want to pay for the full cost all the time. -pub const GraphemeData = union(enum) { - // The named counts allow us to avoid allocators. We do this because - // []u21 is sizeof([4]u21) anyways so if we can store avoid small allocations - // we prefer it. Grapheme clusters are almost always <= 4 codepoints. - - zero: void, - one: u21, - two: [2]u21, - three: [3]u21, - four: [4]u21, - many: []u21, - - pub fn deinit(self: GraphemeData, alloc: Allocator) void { - switch (self) { - .many => |v| alloc.free(v), - else => {}, - } - } - - /// Append the codepoint cp to the grapheme data. - pub fn append(self: *GraphemeData, alloc: Allocator, cp: u21) !void { - switch (self.*) { - .zero => self.* = .{ .one = cp }, - .one => |v| self.* = .{ .two = .{ v, cp } }, - .two => |v| self.* = .{ .three = .{ v[0], v[1], cp } }, - .three => |v| self.* = .{ .four = .{ v[0], v[1], v[2], cp } }, - .four => |v| { - const many = try alloc.alloc(u21, 5); - fastmem.copy(u21, many, &v); - many[4] = cp; - self.* = .{ .many = many }; - }, - - .many => |v| { - // Note: this is super inefficient, we should use an arraylist - // or something so we have extra capacity. - const many = try alloc.realloc(v, v.len + 1); - many[v.len] = cp; - self.* = .{ .many = many }; - }, - } - } - - pub fn copy(self: GraphemeData, alloc: Allocator) !GraphemeData { - // If we're not many we're not allocated so just copy on stack. - if (self != .many) return self; - - // Heap allocated - return GraphemeData{ .many = try alloc.dupe(u21, self.many) }; - } - - test { - log.warn("Grapheme={}", .{@sizeOf(GraphemeData)}); - } - - test "append" { - const testing = std.testing; - const alloc = testing.allocator; - - var data: GraphemeData = .{ .one = 1 }; - defer data.deinit(alloc); - - try data.append(alloc, 2); - try testing.expectEqual(GraphemeData{ .two = .{ 1, 2 } }, data); - try data.append(alloc, 3); - try testing.expectEqual(GraphemeData{ .three = .{ 1, 2, 3 } }, data); - try data.append(alloc, 4); - try testing.expectEqual(GraphemeData{ .four = .{ 1, 2, 3, 4 } }, data); - try data.append(alloc, 5); - try testing.expect(data == .many); - try testing.expectEqualSlices(u21, &[_]u21{ 1, 2, 3, 4, 5 }, data.many); - try data.append(alloc, 6); - try testing.expect(data == .many); - try testing.expectEqualSlices(u21, &[_]u21{ 1, 2, 3, 4, 5, 6 }, data.many); - } - - comptime { - // We want to keep this at most the size of the tag + []u21 so that - // at most we're paying for the cost of a slice. - //assert(@sizeOf(GraphemeData) == 24); - } -}; - -/// A line represents a line of text, potentially across soft-wrapped -/// boundaries. This differs from row, which is a single physical row within -/// the terminal screen. -pub const Line = struct { - screen: *Screen, - tag: RowIndexTag, - start: usize, - len: usize, - - /// Return the string for this line. - pub fn string(self: *const Line, alloc: Allocator) ![:0]const u8 { - return try self.screen.selectionString(alloc, self.selection(), true); - } - - /// Receive the string for this line along with the byte-to-point mapping. - pub fn stringMap(self: *const Line, alloc: Allocator) !StringMap { - return try self.screen.selectionStringMap(alloc, self.selection()); - } - - /// Return a selection that covers the entire line. - pub fn selection(self: *const Line) Selection { - // Get the start and end screen point. - const start_idx = self.tag.index(self.start).toScreen(self.screen).screen; - const end_idx = self.tag.index(self.start + (self.len - 1)).toScreen(self.screen).screen; - - // Convert the start and end screen points into a selection across - // the entire rows. We then use selectionString because it handles - // unwrapping, graphemes, etc. - return .{ - .start = .{ .y = start_idx, .x = 0 }, - .end = .{ .y = end_idx, .x = self.screen.cols - 1 }, - }; - } -}; - -/// Iterator over textual lines within the terminal. This will unwrap -/// wrapped lines and consider them a single line. -pub const LineIterator = struct { - row_it: RowIterator, - - pub fn next(self: *LineIterator) ?Line { - const start = self.row_it.value; - - // Get our current row - var row = self.row_it.next() orelse return null; - var len: usize = 1; - - // While the row is wrapped we keep iterating over the rows - // and incrementing the length. - while (row.isWrapped()) { - // Note: this orelse shouldn't happen. A wrapped row should - // always have a next row. However, this isn't the place where - // we want to assert that. - row = self.row_it.next() orelse break; - len += 1; - } - - return .{ - .screen = self.row_it.screen, - .tag = self.row_it.tag, - .start = start, - .len = len, - }; - } -}; - -// 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 = .{} }); - -/// Stores a mapping of cell ID (row ID + cell offset + 1) to -/// graphemes associated with a cell. To know if a cell has graphemes, -/// check the "grapheme" flag of a cell. -const GraphemeMap = std.AutoHashMapUnmanaged(usize, GraphemeData); - -/// The allocator used for all the storage operations -alloc: Allocator, - -/// The full set of storage. -storage: StorageBuf, - -/// Graphemes associated with our current screen. -graphemes: GraphemeMap = .{}, - -/// The next ID to assign to a row. The value of this is NOT assigned. -next_row_id: RowHeader.Id = 1, - -/// 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, - -/// The amount of history (scrollback) that has been written so far. This -/// can be calculated dynamically using the storage buffer but its an -/// extremely hot piece of data so we cache it. Empirically this eliminates -/// millions of function calls and saves seconds under high scroll scenarios -/// (i.e. reading a large file). -history: usize, - -/// Each screen maintains its own cursor state. -cursor: Cursor = .{}, - -/// Saved cursor saved with DECSC (ESC 7). -saved_cursor: ?Cursor.Saved = null, - -/// The selection for this screen (if any). -selection: ?Selection = null, - -/// The kitty keyboard settings. -kitty_keyboard: kitty.KeyFlagStack = .{}, - -/// Kitty graphics protocol state. -kitty_images: kitty.graphics.ImageStorage = .{}, - -/// The charset state -charset: CharsetState = .{}, - -/// 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. +/// +/// max_scrollback is the amount of scrollback to keep in bytes. This +/// will be rounded UP to the nearest page size because our minimum allocation +/// size is that anyways. +/// +/// If max scrollback is 0, then no scrollback is kept at all. pub fn init( alloc: Allocator, - rows: usize, - cols: usize, + cols: size.CellCountInt, + rows: size.CellCountInt, 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 + @min(max_scrollback, rows)) * (cols + 1); + // Initialize our backing pages. + var pages = try PageList.init(alloc, cols, rows, max_scrollback); + errdefer pages.deinit(); - return Screen{ + // Create our tracked pin for the cursor. + const page_pin = try pages.trackPin(.{ .page = pages.pages.first.? }); + errdefer pages.untrackPin(page_pin); + const page_rac = page_pin.rowAndCell(); + + return .{ .alloc = alloc, - .storage = try StorageBuf.init(alloc, buf_size), - .rows = rows, - .cols = cols, - .max_scrollback = max_scrollback, - .viewport = 0, - .history = 0, + .pages = pages, + .no_scrollback = max_scrollback == 0, + .cursor = .{ + .x = 0, + .y = 0, + .page_pin = page_pin, + .page_row = page_rac.row, + .page_cell = page_rac.cell, + }, }; } pub fn deinit(self: *Screen) void { - self.kitty_images.deinit(self.alloc); - self.storage.deinit(self.alloc); - self.deinitGraphemes(); + self.kitty_images.deinit(self.alloc, self); + self.pages.deinit(); } -fn deinitGraphemes(self: *Screen) void { - var grapheme_it = self.graphemes.valueIterator(); - while (grapheme_it.next()) |data| data.deinit(self.alloc); - self.graphemes.deinit(self.alloc); +/// Clone the screen. +/// +/// This will copy: +/// +/// - Screen dimensions +/// - Screen data (cell state, etc.) for the region +/// +/// Anything not mentioned above is NOT copied. Some of this is for +/// very good reason: +/// +/// - Kitty images have a LOT of data. This is not efficient to copy. +/// Use a lock and access the image data. The dirty bit is there for +/// a reason. +/// - Cursor location can be expensive to calculate with respect to the +/// specified region. It is faster to grab the cursor from the old +/// screen and then move it to the new screen. +/// +/// If not mentioned above, then there isn't a specific reason right now +/// to not copy some data other than we probably didn't need it and it +/// isn't necessary for screen coherency. +/// +/// Other notes: +/// +/// - The viewport will always be set to the active area of the new +/// screen. This is the bottom "rows" rows. +/// - If the clone region is smaller than a viewport area, blanks will +/// be filled in at the bottom. +/// +pub fn clone( + self: *const Screen, + alloc: Allocator, + top: point.Point, + bot: ?point.Point, +) !Screen { + return try self.clonePool(alloc, null, top, bot); } -/// Copy the screen portion given by top and bottom into a new screen instance. -/// This clone is meant for read-only access and hasn't been tested for -/// mutability. -pub fn clone(self: *Screen, alloc: Allocator, top: RowIndex, bottom: RowIndex) !Screen { - // Convert our top/bottom to screen coordinates - const top_y = top.toScreen(self).screen; - const bot_y = bottom.toScreen(self).screen; - assert(bot_y >= top_y); - const height = (bot_y - top_y) + 1; +/// Same as clone but you can specify a custom memory pool to use for +/// the screen. +pub fn clonePool( + self: *const Screen, + alloc: Allocator, + pool: ?*PageList.MemoryPool, + top: point.Point, + bot: ?point.Point, +) !Screen { + var pages = if (pool) |p| + try self.pages.clonePool(p, top, bot) + else + try self.pages.clone(alloc, top, bot); + errdefer pages.deinit(); - // We also figure out the "max y" we can have based on the number - // of rows written. This is used to prevent from reading out of the - // circular buffer where we might have no initialized data yet. - const max_y = max_y: { - const rows_written = self.rowsWritten(); - const index = RowIndex{ .active = @min(rows_written -| 1, self.rows - 1) }; - break :max_y index.toScreen(self).screen; + return .{ + .alloc = alloc, + .pages = pages, + .no_scrollback = self.no_scrollback, + + // TODO: let's make this reasonble + .cursor = undefined, + }; +} + +pub fn cursorCellRight(self: *Screen, n: size.CellCountInt) *pagepkg.Cell { + assert(self.cursor.x + n < self.pages.cols); + const cell: [*]pagepkg.Cell = @ptrCast(self.cursor.page_cell); + return @ptrCast(cell + n); +} + +pub fn cursorCellLeft(self: *Screen, n: size.CellCountInt) *pagepkg.Cell { + assert(self.cursor.x >= n); + const cell: [*]pagepkg.Cell = @ptrCast(self.cursor.page_cell); + return @ptrCast(cell - n); +} + +pub fn cursorCellEndOfPrev(self: *Screen) *pagepkg.Cell { + assert(self.cursor.y > 0); + + var page_pin = self.cursor.page_pin.up(1).?; + page_pin.x = self.pages.cols - 1; + const page_rac = page_pin.rowAndCell(); + return page_rac.cell; +} + +/// Move the cursor right. This is a specialized function that is very fast +/// if the caller can guarantee we have space to move right (no wrapping). +pub fn cursorRight(self: *Screen, n: size.CellCountInt) void { + assert(self.cursor.x + n < self.pages.cols); + + const cell: [*]pagepkg.Cell = @ptrCast(self.cursor.page_cell); + self.cursor.page_cell = @ptrCast(cell + n); + self.cursor.page_pin.x += n; + self.cursor.x += n; +} + +/// Move the cursor left. +pub fn cursorLeft(self: *Screen, n: size.CellCountInt) void { + assert(self.cursor.x >= n); + + const cell: [*]pagepkg.Cell = @ptrCast(self.cursor.page_cell); + self.cursor.page_cell = @ptrCast(cell - n); + self.cursor.page_pin.x -= n; + self.cursor.x -= n; +} + +/// Move the cursor up. +/// +/// Precondition: The cursor is not at the top of the screen. +pub fn cursorUp(self: *Screen, n: size.CellCountInt) void { + assert(self.cursor.y >= n); + + const page_pin = self.cursor.page_pin.up(n).?; + const page_rac = page_pin.rowAndCell(); + self.cursor.page_pin.* = page_pin; + self.cursor.page_row = page_rac.row; + self.cursor.page_cell = page_rac.cell; + self.cursor.y -= n; +} + +pub fn cursorRowUp(self: *Screen, n: size.CellCountInt) *pagepkg.Row { + assert(self.cursor.y >= n); + + const page_pin = self.cursor.page_pin.up(n).?; + const page_rac = page_pin.rowAndCell(); + return page_rac.row; +} + +/// Move the cursor down. +/// +/// Precondition: The cursor is not at the bottom of the screen. +pub fn cursorDown(self: *Screen, n: size.CellCountInt) void { + assert(self.cursor.y + n < self.pages.rows); + + // We move the offset into our page list to the next row and then + // get the pointers to the row/cell and set all the cursor state up. + const page_pin = self.cursor.page_pin.down(n).?; + const page_rac = page_pin.rowAndCell(); + self.cursor.page_pin.* = page_pin; + self.cursor.page_row = page_rac.row; + self.cursor.page_cell = page_rac.cell; + + // Y of course increases + self.cursor.y += n; +} + +/// Move the cursor to some absolute horizontal position. +pub fn cursorHorizontalAbsolute(self: *Screen, x: size.CellCountInt) void { + assert(x < self.pages.cols); + + self.cursor.page_pin.x = x; + const page_rac = self.cursor.page_pin.rowAndCell(); + self.cursor.page_cell = page_rac.cell; + self.cursor.x = x; +} + +/// Move the cursor to some absolute position. +pub fn cursorAbsolute(self: *Screen, x: size.CellCountInt, y: size.CellCountInt) void { + assert(x < self.pages.cols); + assert(y < self.pages.rows); + + var page_pin = if (y < self.cursor.y) + self.cursor.page_pin.up(self.cursor.y - y).? + else if (y > self.cursor.y) + self.cursor.page_pin.down(y - self.cursor.y).? + else + self.cursor.page_pin.*; + page_pin.x = x; + const page_rac = page_pin.rowAndCell(); + self.cursor.page_pin.* = page_pin; + self.cursor.page_row = page_rac.row; + self.cursor.page_cell = page_rac.cell; + self.cursor.x = x; + self.cursor.y = y; +} + +/// Reloads the cursor pointer information into the screen. This is expensive +/// so it should only be done in cases where the pointers are invalidated +/// in such a way that its difficult to recover otherwise. +pub fn cursorReload(self: *Screen) void { + // Our tracked pin is ALWAYS accurate, so we derive the active + // point from the pin. If this returns null it means our pin + // points outside the active area. In that case, we update the + // pin to be the top-left. + const pt: point.Point = self.pages.pointFromPin( + .active, + self.cursor.page_pin.*, + ) orelse reset: { + const pin = self.pages.pin(.{ .active = .{} }).?; + self.cursor.page_pin.* = pin; + break :reset self.pages.pointFromPin(.active, pin).?; }; - // The "real" Y value we use is whichever is smaller: the bottom - // requested or the max. This prevents from reading zero data. - // The "real" height is the amount of height of data we can actually - // 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 }); + self.cursor.x = @intCast(pt.active.x); + self.cursor.y = @intCast(pt.active.y); + const page_rac = self.cursor.page_pin.rowAndCell(); + self.cursor.page_row = page_rac.row; + self.cursor.page_cell = page_rac.cell; +} - // 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 - // caller. - var result = try init(alloc, height, self.cols, 0); - errdefer result.deinit(); +/// Scroll the active area and keep the cursor at the bottom of the screen. +/// This is a very specialized function but it keeps it fast. +pub fn cursorDownScroll(self: *Screen) !void { + assert(self.cursor.y == self.pages.rows - 1); - // Copy some data - result.cursor = self.cursor; + // If we have no scrollback, then we shift all our rows instead. + if (self.no_scrollback) { + // Erase rows will shift our rows up + self.pages.eraseRows(.{ .active = .{} }, .{ .active = .{} }); - // Get the pointer to our source buffer - const len = real_height * (self.cols + 1); - const src = self.storage.getPtrSlice(top_y * (self.cols + 1), len); + // We need to move our cursor down one because eraseRows will + // preserve our pin directly and we're erasing one row. + const page_pin = self.cursor.page_pin.down(1).?; + const page_rac = page_pin.rowAndCell(); + self.cursor.page_pin.* = page_pin; + self.cursor.page_row = page_rac.row; + self.cursor.page_cell = page_rac.cell; - // Get a direct pointer into our storage buffer. This should always be - // one slice because we created a perfectly fitting buffer. - const dst = result.storage.getPtrSlice(0, len); - assert(dst[1].len == 0); + // Erase rows does NOT clear the cells because in all other cases + // we never write those rows again. Active erasing is a bit + // different so we manually clear our one row. + self.clearCells( + &page_pin.page.data, + self.cursor.page_row, + page_pin.page.data.getCells(self.cursor.page_row), + ); + } else { + // Grow our pages by one row. The PageList will handle if we need to + // allocate, prune scrollback, whatever. + _ = try self.pages.grow(); + const page_pin = self.cursor.page_pin.down(1).?; + const page_rac = page_pin.rowAndCell(); + self.cursor.page_pin.* = page_pin; + self.cursor.page_row = page_rac.row; + self.cursor.page_cell = page_rac.cell; - // Perform the copy - // std.log.warn("copy bytes={}", .{src[0].len + src[1].len}); - fastmem.copy(StorageCell, dst[0], src[0]); - fastmem.copy(StorageCell, dst[0][src[0].len..], src[1]); - - // If there are graphemes, we just copy them all - if (self.graphemes.count() > 0) { - // Clone the map - const graphemes = try self.graphemes.clone(alloc); - - // Go through all the values and clone the data because it MAY - // (rarely) be allocated. - var it = graphemes.iterator(); - while (it.next()) |kv| { - kv.value_ptr.* = try kv.value_ptr.copy(alloc); + // Clear the new row so it gets our bg color. We only do this + // if we have a bg color at all. + if (self.cursor.style.bg_color != .none) { + self.clearCells( + &page_pin.page.data, + self.cursor.page_row, + page_pin.page.data.getCells(self.cursor.page_row), + ); } - - result.graphemes = graphemes; } - return result; + // The newly created line needs to be styled according to the bg color + // if it is set. + if (self.cursor.style_id != style.default_id) { + if (self.cursor.style.bgCell()) |blank_cell| { + const cell_current: [*]pagepkg.Cell = @ptrCast(self.cursor.page_cell); + const cells = cell_current - self.cursor.x; + @memset(cells[0..self.pages.cols], blank_cell); + } + } +} + +/// Move the cursor down if we're not at the bottom of the screen. Otherwise +/// scroll. Currently only used for testing. +fn cursorDownOrScroll(self: *Screen) !void { + if (self.cursor.y + 1 < self.pages.rows) { + self.cursorDown(1); + } else { + try self.cursorDownScroll(); + } +} + +/// Options for scrolling the viewport of the terminal grid. The reason +/// we have this in addition to PageList.Scroll is because we have additional +/// scroll behaviors that are not part of the PageList.Scroll enum. +pub const Scroll = union(enum) { + /// For all of these, see PageList.Scroll. + active, + top, + delta_row: isize, +}; + +/// Scroll the viewport of the terminal grid. +pub fn scroll(self: *Screen, behavior: Scroll) void { + // No matter what, scrolling marks our image state as dirty since + // it could move placements. If there are no placements or no images + // this is still a very cheap operation. + self.kitty_images.dirty = true; + + switch (behavior) { + .active => self.pages.scroll(.{ .active = {} }), + .top => self.pages.scroll(.{ .top = {} }), + .delta_row => |v| self.pages.scroll(.{ .delta_row = v }), + } +} + +/// See PageList.scrollClear. In addition to that, we reset the cursor +/// to be on top. +pub fn scrollClear(self: *Screen) !void { + try self.pages.scrollClear(); + self.cursorReload(); + + // No matter what, scrolling marks our image state as dirty since + // it could move placements. If there are no placements or no images + // this is still a very cheap operation. + self.kitty_images.dirty = true; } /// Returns true if the viewport is scrolled to the bottom of the screen. pub fn viewportIsBottom(self: Screen) bool { - return self.viewport == self.history; + return self.pages.viewport == .active; } -/// 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); +/// Erase the region specified by tl and br, inclusive. This will physically +/// erase the rows meaning the memory will be reclaimed (if the underlying +/// page is empty) and other rows will be shifted up. +pub fn eraseRows( + self: *Screen, + tl: point.Point, + bl: ?point.Point, +) void { + // Erase the rows + self.pages.eraseRows(tl, bl); + + // Just to be safe, reset our cursor since it is possible depending + // on the points that our active area shifted so our pointers are + // invalid. + self.cursorReload(); } -/// 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); -} +// Clear the region specified by tl and bl, inclusive. Cleared cells are +// colored with the current style background color. This will clear all +// cells in the rows. +// +// If protected is true, the protected flag will be respected and only +// unprotected cells will be cleared. Otherwise, all cells will be cleared. +pub fn clearRows( + self: *Screen, + tl: point.Point, + bl: ?point.Point, + protected: bool, +) void { + var it = self.pages.pageIterator(.right_down, tl, bl); + while (it.next()) |chunk| { + for (chunk.rows()) |*row| { + const cells_offset = row.cells; + const cells_multi: [*]Cell = row.cells.ptr(chunk.page.data.memory); + const cells = cells_multi[0..self.pages.cols]; -/// 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, - .max = tag.maxLen(self), - }; -} + // Clear all cells + if (protected) { + self.clearUnprotectedCells(&chunk.page.data, row, cells); + } else { + self.clearCells(&chunk.page.data, row, cells); + } -/// Returns an iterator that iterates over the lines of the screen. A line -/// is a single line of text which may wrap across multiple rows. A row -/// is a single physical row of the terminal. -pub fn lineIterator(self: *Screen, tag: RowIndexTag) LineIterator { - return .{ .row_it = self.rowIterator(tag) }; -} - -/// Returns the line that contains the given point. This may be null if the -/// point is outside the screen. -pub fn getLine(self: *Screen, pt: point.ScreenPoint) ?Line { - // If our y is outside of our written area, we have no line. - if (pt.y >= RowIndexTag.screen.maxLen(self)) return null; - if (pt.x >= self.cols) return null; - - // Find the starting y. We go back and as soon as we find a row that - // isn't wrapped, we know the NEXT line is the one we want. - const start_y: usize = if (pt.y == 0) 0 else start_y: { - for (1..pt.y) |y| { - const bot_y = pt.y - y; - const row = self.getRow(.{ .screen = bot_y }); - if (!row.isWrapped()) break :start_y bot_y + 1; - } - - break :start_y 0; - }; - - // Find the end y, which is the first row that isn't wrapped. - const end_y = end_y: { - for (pt.y..self.rowsWritten()) |y| { - const row = self.getRow(.{ .screen = y }); - if (!row.isWrapped()) break :end_y y; - } - - break :end_y self.rowsWritten() - 1; - }; - - return .{ - .screen = self, - .tag = .screen, - .start = start_y, - .len = (end_y - start_y) + 1, - }; -} - -/// 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 = .{ .screen = self, .storage = slices[0] }; - if (row.storage[0].header.id == 0) { - const Id = @TypeOf(self.next_row_id); - const id = self.next_row_id; - self.next_row_id +%= @as(Id, @intCast(self.cols)); - - // Store the header - row.storage[0].header.id = id; - - // We only set dirty and fill if its not dirty. If its dirty - // we assume this row has been written but just hasn't had - // an ID assigned yet. - if (!row.storage[0].header.flags.dirty) { - // Mark that we're dirty since we're a new row - row.storage[0].header.flags.dirty = true; - - // We only need to fill with runtime safety because unions are - // tag-checked. Otherwise, the default value of zero will be valid. - if (std.debug.runtime_safety) row.fill(.{}); + // Reset our row to point to the proper memory but everything + // else is zeroed. + row.* = .{ .cells = cells_offset }; } } - 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); - try dst_row.copyRow(src_row); -} - -/// Scroll rows in a region up. Rows that go beyond the region -/// top or bottom are deleted, and new rows inserted are blank according -/// to the current pen. -/// -/// This does NOT create any new scrollback. This modifies an existing -/// region within the screen (including possibly the scrollback if -/// the top/bottom are within it). -/// -/// This can be used to implement terminal scroll regions efficiently. -pub fn scrollRegionUp(self: *Screen, top: RowIndex, bottom: RowIndex, count_req: usize) void { - // Avoid a lot of work if we're doing nothing. - if (count_req == 0) return; - - // Convert our top/bottom to screen y values. This is the y offset - // in the entire screen buffer. - const top_y = top.toScreen(self).screen; - const bot_y = bottom.toScreen(self).screen; - - // If top is outside of the range of bot, we do nothing. - if (top_y >= bot_y) return; - - // We can only scroll up to the number of rows in the region. The "+ 1" - // is because our y values are 0-based and count is 1-based. - const count = @min(count_req, bot_y - top_y + 1); - - // Get the storage pointer for the full scroll region. We're going to - // be modifying the whole thing so we get it right away. - const height = (bot_y - top_y) + 1; - const len = height * (self.cols + 1); - const slices = self.storage.getPtrSlice(top_y * (self.cols + 1), len); - - // The total amount we're going to copy - const total_copy = (height - count) * (self.cols + 1); - - // The pen we'll use for new cells (only the BG attribute is applied to new - // cells) - const pen: Cell = switch (self.cursor.pen.bg) { - .none => .{}, - else => |bg| .{ .bg = bg }, - }; - - // Fast-path is that we have a contiguous buffer in our circular buffer. - // In this case we can do some memmoves. - if (slices[1].len == 0) { - const buf = slices[0]; - - { - // Our copy starts "count" rows below and is the length of - // the remainder of the data. Our destination is the top since - // we're scrolling up. - // - // Note we do NOT need to set any row headers to dirty because - // the row contents are not changing for the row ID. - const dst = buf; - const src_offset = count * (self.cols + 1); - const src = buf[src_offset..]; - assert(@intFromPtr(dst.ptr) < @intFromPtr(src.ptr)); - fastmem.move(StorageCell, dst, src); +/// Clear the cells with the blank cell. This takes care to handle +/// cleaning up graphemes and styles. +pub fn clearCells( + self: *Screen, + page: *Page, + row: *Row, + cells: []Cell, +) void { + // If this row has graphemes, then we need go through a slow path + // and delete the cell graphemes. + if (row.grapheme) { + for (cells) |*cell| { + if (cell.hasGrapheme()) page.clearGrapheme(row, cell); } + } - { - // Copy in our empties. The destination is the bottom - // count rows. We first fill with the pen values since there - // is a lot more of that. - const dst_offset = total_copy; - const dst = buf[dst_offset..]; - @memset(dst, .{ .cell = pen }); + if (row.styled) { + for (cells) |*cell| { + if (cell.style_id == style.default_id) continue; - // Then we make sure our row headers are zeroed out. We set - // the value to a dirty row header so that the renderer re-draws. - // - // NOTE: we do NOT set a valid row ID here. The next time getRow - // is called it will be initialized. This should work fine as - // far as I can tell. It is important to set dirty so that the - // renderer knows to redraw this. - var i: usize = dst_offset; - while (i < buf.len) : (i += self.cols + 1) { - buf[i] = .{ .header = .{ - .flags = .{ .dirty = true }, - } }; + // Fast-path, the style ID matches, in this case we just update + // our own ref and continue. We never delete because our style + // is still active. + if (cell.style_id == self.cursor.style_id) { + self.cursor.style_ref.?.* -= 1; + continue; + } + + // Slow path: we need to lookup this style so we can decrement + // the ref count. Since we've already loaded everything, we also + // just go ahead and GC it if it reaches zero, too. + if (page.styles.lookupId(page.memory, cell.style_id)) |prev_style| { + // Below upsert can't fail because it should already be present + const md = page.styles.upsert(page.memory, prev_style.*) catch unreachable; + assert(md.ref > 0); + md.ref -= 1; + if (md.ref == 0) page.styles.remove(page.memory, cell.style_id); } } + // If we have no left/right scroll region we can be sure that + // the row is no longer styled. + if (cells.len == self.pages.cols) row.styled = false; + } + + @memset(cells, self.blankCell()); +} + +/// Clear cells but only if they are not protected. +pub fn clearUnprotectedCells( + self: *Screen, + page: *Page, + row: *Row, + cells: []Cell, +) void { + for (cells) |*cell| { + if (cell.protected) continue; + const cell_multi: [*]Cell = @ptrCast(cell); + self.clearCells(page, row, cell_multi[0..1]); + } +} + +/// Returns the blank cell to use when doing terminal operations that +/// require preserving the bg color. +fn blankCell(self: *const Screen) Cell { + if (self.cursor.style_id == style.default_id) return .{}; + return self.cursor.style.bgCell() orelse .{}; +} + +/// Resize the screen. The rows or cols can be bigger or smaller. +/// +/// This will reflow soft-wrapped text. If the screen size is getting +/// smaller and the maximum scrollback size is exceeded, data will be +/// lost from the top of the scrollback. +/// +/// If this returns an error, the screen is left in a likely garbage state. +/// It is very hard to undo this operation without blowing up our memory +/// usage. The only way to recover is to reset the screen. The only way +/// this really fails is if page allocation is required and fails, which +/// probably means the system is in trouble anyways. I'd like to improve this +/// in the future but it is not a priority particularly because this scenario +/// (resize) is difficult. +pub fn resize( + self: *Screen, + cols: size.CellCountInt, + rows: size.CellCountInt, +) !void { + try self.resizeInternal(cols, rows, true); +} + +/// 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, + cols: size.CellCountInt, + rows: size.CellCountInt, +) !void { + try self.resizeInternal(cols, rows, false); +} + +/// Resize the screen. +// TODO: replace resize and resizeWithoutReflow with this. +fn resizeInternal( + self: *Screen, + cols: size.CellCountInt, + rows: size.CellCountInt, + reflow: bool, +) !void { + // No matter what we mark our image state as dirty + self.kitty_images.dirty = true; + + // Perform the resize operation. This will update cursor by reference. + try self.pages.resize(.{ + .rows = rows, + .cols = cols, + .reflow = reflow, + .cursor = .{ .x = self.cursor.x, .y = self.cursor.y }, + }); + + // If we have no scrollback and we shrunk our rows, we must explicitly + // erase our history. This is beacuse PageList always keeps at least + // a page size of history. + if (self.no_scrollback) { + self.pages.eraseRows(.{ .history = .{} }, null); + } + + // If our cursor was updated, we do a full reload so all our cursor + // state is correct. + self.cursorReload(); +} + +/// Set a style attribute for the current cursor. +/// +/// This can cause a page split if the current page cannot fit this style. +/// This is the only scenario an error return is possible. +pub fn setAttribute(self: *Screen, attr: sgr.Attribute) !void { + switch (attr) { + .unset => { + self.cursor.style = .{}; + }, + + .bold => { + self.cursor.style.flags.bold = true; + }, + + .reset_bold => { + // Bold and faint share the same SGR code for this + self.cursor.style.flags.bold = false; + self.cursor.style.flags.faint = false; + }, + + .italic => { + self.cursor.style.flags.italic = true; + }, + + .reset_italic => { + self.cursor.style.flags.italic = false; + }, + + .faint => { + self.cursor.style.flags.faint = true; + }, + + .underline => |v| { + self.cursor.style.flags.underline = v; + }, + + .reset_underline => { + self.cursor.style.flags.underline = .none; + }, + + .underline_color => |rgb| { + self.cursor.style.underline_color = .{ .rgb = .{ + .r = rgb.r, + .g = rgb.g, + .b = rgb.b, + } }; + }, + + .@"256_underline_color" => |idx| { + self.cursor.style.underline_color = .{ .palette = idx }; + }, + + .reset_underline_color => { + self.cursor.style.underline_color = .none; + }, + + .blink => { + self.cursor.style.flags.blink = true; + }, + + .reset_blink => { + self.cursor.style.flags.blink = false; + }, + + .inverse => { + self.cursor.style.flags.inverse = true; + }, + + .reset_inverse => { + self.cursor.style.flags.inverse = false; + }, + + .invisible => { + self.cursor.style.flags.invisible = true; + }, + + .reset_invisible => { + self.cursor.style.flags.invisible = false; + }, + + .strikethrough => { + self.cursor.style.flags.strikethrough = true; + }, + + .reset_strikethrough => { + self.cursor.style.flags.strikethrough = false; + }, + + .direct_color_fg => |rgb| { + self.cursor.style.fg_color = .{ + .rgb = .{ + .r = rgb.r, + .g = rgb.g, + .b = rgb.b, + }, + }; + }, + + .direct_color_bg => |rgb| { + self.cursor.style.bg_color = .{ + .rgb = .{ + .r = rgb.r, + .g = rgb.g, + .b = rgb.b, + }, + }; + }, + + .@"8_fg" => |n| { + self.cursor.style.fg_color = .{ .palette = @intFromEnum(n) }; + }, + + .@"8_bg" => |n| { + self.cursor.style.bg_color = .{ .palette = @intFromEnum(n) }; + }, + + .reset_fg => self.cursor.style.fg_color = .none, + + .reset_bg => self.cursor.style.bg_color = .none, + + .@"8_bright_fg" => |n| { + self.cursor.style.fg_color = .{ .palette = @intFromEnum(n) }; + }, + + .@"8_bright_bg" => |n| { + self.cursor.style.bg_color = .{ .palette = @intFromEnum(n) }; + }, + + .@"256_fg" => |idx| { + self.cursor.style.fg_color = .{ .palette = idx }; + }, + + .@"256_bg" => |idx| { + self.cursor.style.bg_color = .{ .palette = idx }; + }, + + .unknown => return, + } + + try self.manualStyleUpdate(); +} + +/// Call this whenever you manually change the cursor style. +pub fn manualStyleUpdate(self: *Screen) !void { + var page = &self.cursor.page_pin.page.data; + + // Remove our previous style if is unused. + if (self.cursor.style_ref) |ref| { + if (ref.* == 0) { + page.styles.remove(page.memory, self.cursor.style_id); + } + } + + // If our new style is the default, just reset to that + if (self.cursor.style.default()) { + self.cursor.style_id = 0; + self.cursor.style_ref = null; return; } - // If we're split across two buffers this is a "slow" path. This shouldn't - // happen with the "active" area but it appears it does... in the future - // I plan on changing scroll region stuff to make it much faster so for - // now we just deal with this slow path. + // After setting the style, we need to update our style map. + // Note that we COULD lazily do this in print. We should look into + // if that makes a meaningful difference. Our priority is to keep print + // fast because setting a ton of styles that do nothing is uncommon + // and weird. + const md = try page.styles.upsert(page.memory, self.cursor.style); + self.cursor.style_id = md.id; + self.cursor.style_ref = &md.ref; +} - // This is the offset where we have to start copying. - const src_offset = count * (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, + trim: bool, +) ![:0]const u8 { + // Use an ArrayList so that we can grow the array as we go. We + // build an initial capacity of just our rows in our selection times + // columns. It can be more or less based on graphemes, newlines, etc. + var strbuilder = std.ArrayList(u8).init(alloc); + defer strbuilder.deinit(); - // Perform the copy and calculate where we need to start zero-ing. - const zero_offset: [2]usize = if (src_offset < slices[0].len) zero_offset: { - var remaining: usize = len; + const sel_ordered = sel.ordered(self, .forward); + const sel_start = start: { + var start = sel.start(); + const cell = start.rowAndCell().cell; + if (cell.wide == .spacer_tail) start.x -= 1; + break :start start; + }; + const sel_end = end: { + var end = sel.end(); + const cell = end.rowAndCell().cell; + switch (cell.wide) { + .narrow, .wide => {}, - // Source starts in the top... so we can copy some from there. - const dst = slices[0]; - const src = slices[0][src_offset..]; - assert(@intFromPtr(dst.ptr) < @intFromPtr(src.ptr)); - fastmem.move(StorageCell, dst, src); - remaining = total_copy - src.len; - if (remaining == 0) break :zero_offset .{ src.len, 0 }; + // We can omit the tail + .spacer_tail => end.x -= 1, - // We have data remaining, which means that we have to grab some - // from the bottom slice. - const dst2 = slices[0][src.len..]; - const src2_len = @min(dst2.len, remaining); - const src2 = slices[1][0..src2_len]; - fastmem.copy(StorageCell, dst2, src2); - remaining -= src2_len; - if (remaining == 0) break :zero_offset .{ src.len + src2.len, 0 }; - - // We still have data remaining, which means we copy into the bot. - const dst3 = slices[1]; - const src3 = slices[1][src2_len .. src2_len + remaining]; - fastmem.move(StorageCell, dst3, src3); - - break :zero_offset .{ slices[0].len, src3.len }; - } else zero_offset: { - var remaining: usize = len; - - // Source is in the bottom, so we copy from there into top. - const bot_src_offset = src_offset - slices[0].len; - const dst = slices[0]; - const src = slices[1][bot_src_offset..]; - const src_len = @min(dst.len, src.len); - fastmem.copy(StorageCell, dst, src[0..src_len]); - remaining = total_copy - src_len; - if (remaining == 0) break :zero_offset .{ src_len, 0 }; - - // We have data remaining, this has to go into the bottom. - const dst2 = slices[1]; - const src2_offset = bot_src_offset + src_len; - const src2 = slices[1][src2_offset..]; - const src2_len = remaining; - fastmem.move(StorageCell, dst2, src2[0..src2_len]); - break :zero_offset .{ src_len, src2_len }; + // With the head we want to include the wrapped wide character. + .spacer_head => if (end.down(1)) |p| { + end = p; + end.x = 0; + }, + } + break :end end; }; - // Zero - for (zero_offset, 0..) |offset, i| { - if (offset >= slices[i].len) continue; + var page_it = sel_start.pageIterator(.right_down, sel_end); + var row_count: usize = 0; + while (page_it.next()) |chunk| { + const rows = chunk.rows(); + for (rows) |row| { + const cells_ptr = row.cells.ptr(chunk.page.data.memory); - const dst = slices[i][offset..]; - @memset(dst, .{ .cell = pen }); + const start_x = if (row_count == 0 or sel_ordered.rectangle) + sel_start.x + else + 0; + const end_x = if (row_count == rows.len - 1 or sel_ordered.rectangle) + sel_end.x + 1 + else + self.pages.cols; - var j: usize = offset; - while (j < slices[i].len) : (j += self.cols + 1) { - slices[i][j] = .{ .header = .{ - .flags = .{ .dirty = true }, - } }; + const cells = cells_ptr[start_x..end_x]; + for (cells) |*cell| { + // Skip wide spacers + switch (cell.wide) { + .narrow, .wide => {}, + .spacer_head, .spacer_tail => continue, + } + + var buf: [4]u8 = undefined; + { + const raw: u21 = if (cell.hasText()) cell.content.codepoint else 0; + const char = if (raw > 0) raw else ' '; + const encode_len = try std.unicode.utf8Encode(char, &buf); + try strbuilder.appendSlice(buf[0..encode_len]); + } + if (cell.hasGrapheme()) { + const cps = chunk.page.data.lookupGrapheme(cell).?; + for (cps) |cp| { + const encode_len = try std.unicode.utf8Encode(cp, &buf); + try strbuilder.appendSlice(buf[0..encode_len]); + } + } + } + + if (row_count < rows.len - 1 and + (!row.wrap or sel_ordered.rectangle)) + { + try strbuilder.append('\n'); + } + + row_count += 1; } } -} -/// 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); -} + // Remove any trailing spaces on lines. We could do optimize this by + // doing this in the loop above but this isn't very hot path code and + // this is simple. + if (trim) { + var it = std.mem.tokenizeScalar(u8, strbuilder.items, '\n'); -/// 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); -} + // Reset our items. We retain our capacity. Because we're only + // removing bytes, we know that the trimmed string must be no longer + // than the original string so we copy directly back into our + // allocated memory. + strbuilder.clearRetainingCapacity(); + while (it.next()) |line| { + const trimmed = std.mem.trimRight(u8, line, " \t"); + const i = strbuilder.items.len; + strbuilder.items.len += trimmed.len; + std.mem.copyForwards(u8, strbuilder.items[i..], trimmed); + try strbuilder.append('\n'); + } -/// 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); -} - -pub const ClearMode = enum { - /// Delete all history. This will also move the viewport area to the top - /// so that the viewport area never contains history. This does NOT - /// change the active area. - history, - - /// Clear all the lines above the cursor in the active area. This does - /// not touch history. - above_cursor, -}; - -/// Clear the screen contents according to the given mode. -pub fn clear(self: *Screen, mode: ClearMode) !void { - switch (mode) { - .history => { - // If there is no history, do nothing. - if (self.history == 0) return; - - // Delete all our history - self.storage.deleteOldest(self.history * (self.cols + 1)); - self.history = 0; - - // Back to the top - self.viewport = 0; - }, - - .above_cursor => { - // First we copy all the rows from our cursor down to the top - // of the active area. - var y: usize = self.cursor.y; - const y_max = @min(self.rows, self.rowsWritten()) - 1; - const copy_n = (y_max - y) + 1; - while (y <= y_max) : (y += 1) { - const dst_y = y - self.cursor.y; - const dst = self.getRow(.{ .active = dst_y }); - const src = self.getRow(.{ .active = y }); - try dst.copyRow(src); - } - - // Next we want to clear all the rows below the copied amount. - y = copy_n; - while (y <= y_max) : (y += 1) { - const dst = self.getRow(.{ .active = y }); - dst.clear(.{}); - } - - // Move our cursor to the top - self.cursor.y = 0; - - // Scroll to the top of the viewport - self.viewport = self.history; - }, + // Remove all trailing newlines + for (0..strbuilder.items.len) |_| { + if (strbuilder.items[strbuilder.items.len - 1] != '\n') break; + strbuilder.items.len -= 1; + } } -} -/// Return the selection for all contents on the screen. Surrounding -/// whitespace is omitted. If there is no selection, this returns null. -pub fn selectAll(self: *Screen) ?Selection { - const whitespace = &[_]u32{ 0, ' ', '\t' }; - const y_max = self.rowsWritten() - 1; + // Get our final string + const string = try strbuilder.toOwnedSliceSentinel(0); + errdefer alloc.free(string); - const start: point.ScreenPoint = start: { - var y: usize = 0; - while (y <= y_max) : (y += 1) { - const current_row = self.getRow(.{ .screen = y }); - var x: usize = 0; - while (x < self.cols) : (x += 1) { - const cell = current_row.getCell(x); - - // Empty is whitespace - if (cell.empty()) continue; - - // Non-empty means we found it. - const this_whitespace = std.mem.indexOfAny( - u32, - whitespace, - &[_]u32{cell.char}, - ) != null; - if (this_whitespace) continue; - - break :start .{ .x = x, .y = y }; - } - } - - // There is no start point and therefore no line that can be selected. - return null; - }; - - const end: point.ScreenPoint = end: { - var y: usize = y_max; - while (true) { - const current_row = self.getRow(.{ .screen = y }); - - var x: usize = 0; - while (x < self.cols) : (x += 1) { - const real_x = self.cols - x - 1; - const cell = current_row.getCell(real_x); - - // Empty or whitespace, ignore. - if (cell.empty()) continue; - const this_whitespace = std.mem.indexOfAny( - u32, - whitespace, - &[_]u32{cell.char}, - ) != null; - if (this_whitespace) continue; - - // Got it - break :end .{ .x = real_x, .y = y }; - } - - if (y == 0) break; - y -= 1; - } - }; - - return Selection{ - .start = start, - .end = end, - }; + return string; } /// Select the line under the given point. This will select across soft-wrapped /// lines and will omit the leading and trailing whitespace. If the point is /// over whitespace but the line has non-whitespace characters elsewhere, the /// line will be selected. -pub fn selectLine(self: *Screen, pt: point.ScreenPoint) ?Selection { +pub fn selectLine(self: *Screen, pin: Pin) ?Selection { + _ = self; + // Whitespace characters for selection purposes const whitespace = &[_]u32{ 0, ' ', '\t' }; - // Impossible to select anything outside of the area we've written. - const y_max = self.rowsWritten() - 1; - if (pt.y > y_max or pt.x >= self.cols) return null; - // Get the current point semantic prompt state since that determines // boundary conditions too. This makes it so that line selection can // only happen within the same prompt state. For example, if you triple // click output, but the shell uses spaces to soft-wrap to the prompt // then the selection will stop prior to the prompt. See issue #1329. - const semantic_prompt_state = self.getRow(.{ .screen = pt.y }) - .getSemanticPrompt() - .promptOrInput(); + const semantic_prompt_state = state: { + const rac = pin.rowAndCell(); + break :state rac.row.semantic_prompt.promptOrInput(); + }; // The real start of the row is the first row in the soft-wrap. - const start_row: usize = start_row: { - if (pt.y == 0) break :start_row 0; + const start_pin: Pin = start_pin: { + var it = pin.rowIterator(.left_up, null); + var it_prev: Pin = pin; + while (it.next()) |p| { + const row = p.rowAndCell().row; - var y: usize = pt.y - 1; - while (true) { - const current = self.getRow(.{ .screen = y }); - if (!current.header().flags.wrap) break :start_row y + 1; + if (!row.wrap) { + var copy = it_prev; + copy.x = 0; + break :start_pin copy; + } // See semantic_prompt_state comment for why - const current_prompt = current.getSemanticPrompt().promptOrInput(); - if (current_prompt != semantic_prompt_state) break :start_row y + 1; + const current_prompt = row.semantic_prompt.promptOrInput(); + if (current_prompt != semantic_prompt_state) { + var copy = it_prev; + copy.x = 0; + break :start_pin copy; + } - if (y == 0) break :start_row y; - y -= 1; + it_prev = p; + } else { + var copy = it_prev; + copy.x = 0; + break :start_pin copy; } - unreachable; }; // The real end of the row is the final row in the soft-wrap. - const end_row: usize = end_row: { - var y: usize = pt.y; - while (y <= y_max) : (y += 1) { - const current = self.getRow(.{ .screen = y }); + const end_pin: Pin = end_pin: { + var it = pin.rowIterator(.right_down, null); + while (it.next()) |p| { + const row = p.rowAndCell().row; // See semantic_prompt_state comment for why - const current_prompt = current.getSemanticPrompt().promptOrInput(); - if (current_prompt != semantic_prompt_state) break :end_row y - 1; + const current_prompt = row.semantic_prompt.promptOrInput(); + if (current_prompt != semantic_prompt_state) { + var prev = p.up(1).?; + prev.x = p.page.data.size.cols - 1; + break :end_pin prev; + } - // End of the screen or not wrapped, we're done. - if (y == y_max or !current.header().flags.wrap) break :end_row y; - } - unreachable; - }; - - // Go forward from the start to find the first non-whitespace character. - const start: point.ScreenPoint = start: { - var y: usize = start_row; - while (y <= y_max) : (y += 1) { - const current_row = self.getRow(.{ .screen = y }); - var x: usize = 0; - while (x < self.cols) : (x += 1) { - const cell = current_row.getCell(x); - - // Empty is whitespace - if (cell.empty()) continue; - - // Non-empty means we found it. - const this_whitespace = std.mem.indexOfAny( - u32, - whitespace, - &[_]u32{cell.char}, - ) != null; - if (this_whitespace) continue; - - break :start .{ .x = x, .y = y }; + if (!row.wrap) { + var copy = p; + copy.x = p.page.data.size.cols - 1; + break :end_pin copy; } } - // There is no start point and therefore no line that can be selected. + return null; + }; + + // Go forward from the start to find the first non-whitespace character. + const start: Pin = start: { + var it = start_pin.cellIterator(.right_down, end_pin); + while (it.next()) |p| { + const cell = p.rowAndCell().cell; + if (!cell.hasText()) continue; + + // Non-empty means we found it. + const this_whitespace = std.mem.indexOfAny( + u32, + whitespace, + &[_]u32{cell.content.codepoint}, + ) != null; + if (this_whitespace) continue; + + break :start p; + } + return null; }; // Go backward from the end to find the first non-whitespace character. - const end: point.ScreenPoint = end: { - var y: usize = end_row; - while (true) { - const current_row = self.getRow(.{ .screen = y }); + const end: Pin = end: { + var it = end_pin.cellIterator(.left_up, start_pin); + while (it.next()) |p| { + const cell = p.rowAndCell().cell; + if (!cell.hasText()) continue; - var x: usize = 0; - while (x < self.cols) : (x += 1) { - const real_x = self.cols - x - 1; - const cell = current_row.getCell(real_x); + // Non-empty means we found it. + const this_whitespace = std.mem.indexOfAny( + u32, + whitespace, + &[_]u32{cell.content.codepoint}, + ) != null; + if (this_whitespace) continue; - // Empty or whitespace, ignore. - if (cell.empty()) continue; - const this_whitespace = std.mem.indexOfAny( - u32, - whitespace, - &[_]u32{cell.char}, - ) != null; - if (this_whitespace) continue; - - // Got it - break :end .{ .x = real_x, .y = y }; - } - - if (y == 0) break; - y -= 1; + break :end p; } - // There is no start point and therefore no line that can be selected. return null; }; - return Selection{ - .start = start, - .end = end, - }; + return Selection.init(start, end, false); } -/// Select the nearest word to start point that is between start_pt and -/// end_pt (inclusive). Because it selects "nearest" to start point, start -/// point can be before or after end point. -pub fn selectWordBetween( - self: *Screen, - start_pt: point.ScreenPoint, - end_pt: point.ScreenPoint, -) ?Selection { - const dir: point.Direction = if (start_pt.before(end_pt)) .right_down else .left_up; - var it = start_pt.iterator(self, dir); - while (it.next()) |pt| { - // Boundary conditions - switch (dir) { - .right_down => if (end_pt.before(pt)) return null, - .left_up => if (pt.before(end_pt)) return null, +/// Return the selection for all contents on the screen. Surrounding +/// whitespace is omitted. If there is no selection, this returns null. +pub fn selectAll(self: *Screen) ?Selection { + const whitespace = &[_]u32{ 0, ' ', '\t' }; + + const start: Pin = start: { + var it = self.pages.cellIterator( + .right_down, + .{ .screen = .{} }, + null, + ); + while (it.next()) |p| { + const cell = p.rowAndCell().cell; + if (!cell.hasText()) continue; + + // Non-empty means we found it. + const this_whitespace = std.mem.indexOfAny( + u32, + whitespace, + &[_]u32{cell.content.codepoint}, + ) != null; + if (this_whitespace) continue; + + break :start p; } - // If we found a word, then return it - if (self.selectWord(pt)) |sel| return sel; - } + return null; + }; - return null; + const end: Pin = end: { + var it = self.pages.cellIterator( + .left_up, + .{ .screen = .{} }, + null, + ); + while (it.next()) |p| { + const cell = p.rowAndCell().cell; + if (!cell.hasText()) continue; + + // Non-empty means we found it. + const this_whitespace = std.mem.indexOfAny( + u32, + whitespace, + &[_]u32{cell.content.codepoint}, + ) != null; + if (this_whitespace) continue; + + break :end p; + } + + return null; + }; + + return Selection.init(start, end, false); } /// Select the word under the given point. A word is any consecutive series @@ -1738,7 +1130,9 @@ pub fn selectWordBetween( /// /// This will return null if a selection is impossible. The only scenario /// this happens is if the point pt is outside of the written screen space. -pub fn selectWord(self: *Screen, pt: point.ScreenPoint) ?Selection { +pub fn selectWord(self: *Screen, pin: Pin) ?Selection { + _ = self; + // Boundary characters for selection purposes const boundary = &[_]u32{ 0, @@ -1761,112 +1155,81 @@ pub fn selectWord(self: *Screen, pt: point.ScreenPoint) ?Selection { '>', }; - // Impossible to select anything outside of the area we've written. - const y_max = self.rowsWritten() - 1; - if (pt.y > y_max) return null; - - // Get our row - const row = self.getRow(.{ .screen = pt.y }); - const start_cell = row.getCell(pt.x); - // If our cell is empty we can't select a word, because we can't select // areas where the screen is not yet written. - if (start_cell.empty()) return null; + const start_cell = pin.rowAndCell().cell; + if (!start_cell.hasText()) return null; // Determine if we are a boundary or not to determine what our boundary is. - const expect_boundary = std.mem.indexOfAny(u32, boundary, &[_]u32{start_cell.char}) != null; + const expect_boundary = std.mem.indexOfAny( + u32, + boundary, + &[_]u32{start_cell.content.codepoint}, + ) != null; // Go forwards to find our end boundary - const end: point.ScreenPoint = boundary: { - var prev: point.ScreenPoint = pt; - var y: usize = pt.y; - var x: usize = pt.x; - while (y <= y_max) : (y += 1) { - const current_row = self.getRow(.{ .screen = y }); + const end: Pin = end: { + var it = pin.cellIterator(.right_down, null); + var prev = it.next().?; // Consume one, our start + while (it.next()) |p| { + const rac = p.rowAndCell(); + const cell = rac.cell; - // Go through all the remainining cells on this row until - // we reach a boundary condition. - while (x < self.cols) : (x += 1) { - const cell = current_row.getCell(x); + // If we reached an empty cell its always a boundary + if (!cell.hasText()) break :end prev; - // If we reached an empty cell its always a boundary - if (cell.empty()) break :boundary prev; + // If we do not match our expected set, we hit a boundary + const this_boundary = std.mem.indexOfAny( + u32, + boundary, + &[_]u32{cell.content.codepoint}, + ) != null; + if (this_boundary != expect_boundary) break :end prev; - // If we do not match our expected set, we hit a boundary - const this_boundary = std.mem.indexOfAny( - u32, - boundary, - &[_]u32{cell.char}, - ) != null; - if (this_boundary != expect_boundary) break :boundary prev; - - // Increase our prev - prev.x = x; - prev.y = y; + // If we are going to the next row and it isn't wrapped, we + // return the previous. + if (p.x == p.page.data.size.cols - 1 and !rac.row.wrap) { + break :end p; } - // If we aren't wrapping, then we're done this is a boundary. - if (!current_row.header().flags.wrap) break :boundary prev; - - // If we are wrapping, reset some values and search the next line. - x = 0; + prev = p; } - break :boundary .{ .x = self.cols - 1, .y = y_max }; + break :end prev; }; // Go backwards to find our start boundary - const start: point.ScreenPoint = boundary: { - var current_row = row; - var prev: point.ScreenPoint = pt; + const start: Pin = start: { + var it = pin.cellIterator(.left_up, null); + var prev = it.next().?; // Consume one, our start + while (it.next()) |p| { + const rac = p.rowAndCell(); + const cell = rac.cell; - var y: usize = pt.y; - var x: usize = pt.x; - while (true) { - // Go through all the remainining cells on this row until - // we reach a boundary condition. - while (x > 0) : (x -= 1) { - const cell = current_row.getCell(x - 1); - const this_boundary = std.mem.indexOfAny( - u32, - boundary, - &[_]u32{cell.char}, - ) != null; - if (this_boundary != expect_boundary) break :boundary prev; - - // Update our prev - prev.x = x - 1; - prev.y = y; + // If we are going to the next row and it isn't wrapped, we + // return the previous. + if (p.x == p.page.data.size.cols - 1 and !rac.row.wrap) { + break :start prev; } - // If we're at the start, we need to check if the previous line wrapped. - // If we are wrapped, we continue searching. If we are not wrapped, - // then we've hit a boundary. - assert(prev.x == 0); + // If we reached an empty cell its always a boundary + if (!cell.hasText()) break :start prev; - // If we're at the end, we're done! - if (y == 0) break; + // If we do not match our expected set, we hit a boundary + const this_boundary = std.mem.indexOfAny( + u32, + boundary, + &[_]u32{cell.content.codepoint}, + ) != null; + if (this_boundary != expect_boundary) break :start prev; - // If the previous row did not wrap, then we're done. Otherwise - // we keep searching. - y -= 1; - current_row = self.getRow(.{ .screen = y }); - if (!current_row.header().flags.wrap) break :boundary prev; - - // Set x to start at the first non-empty cell - x = self.cols; - while (x > 0) : (x -= 1) { - if (!current_row.getCell(x - 1).empty()) break; - } + prev = p; } - break :boundary .{ .x = 0, .y = 0 }; + break :start prev; }; - return Selection{ - .start = start, - .end = end, - }; + return Selection.init(start, end, false); } /// Select the command output under the given point. The limits of the output @@ -1877,54 +1240,73 @@ pub fn selectWord(self: *Screen, pt: point.ScreenPoint) ?Selection { /// this happens is if: /// - the point pt is outside of the written screen space. /// - the point pt is on a prompt / input line. -pub fn selectOutput(self: *Screen, pt: point.ScreenPoint) ?Selection { - // Impossible to select anything outside of the area we've written. - const y_max = self.rowsWritten() - 1; - if (pt.y > y_max) return null; - const point_row = self.getRow(.{ .screen = pt.y }); - switch (point_row.getSemanticPrompt()) { +pub fn selectOutput(self: *Screen, pin: Pin) ?Selection { + _ = self; + + switch (pin.rowAndCell().row.semantic_prompt) { .input, .prompt_continuation, .prompt => { // Cursor on a prompt line, selection impossible return null; }, + else => {}, } // Go forwards to find our end boundary // We are looking for input start / prompt markers - const end: point.ScreenPoint = boundary: { - for (pt.y..y_max + 1) |y| { - const row = self.getRow(.{ .screen = y }); - switch (row.getSemanticPrompt()) { + const end: Pin = boundary: { + var it = pin.rowIterator(.right_down, null); + var it_prev = pin; + while (it.next()) |p| { + const row = p.rowAndCell().row; + switch (row.semantic_prompt) { .input, .prompt_continuation, .prompt => { - const prev_row = self.getRow(.{ .screen = y - 1 }); - break :boundary .{ .x = prev_row.lenCells(), .y = y - 1 }; + var copy = it_prev; + copy.x = it_prev.page.data.size.cols - 1; + break :boundary copy; }, else => {}, } + + it_prev = p; } - break :boundary .{ .x = self.cols - 1, .y = y_max }; + // Find the last non-blank row + it = it_prev.rowIterator(.left_up, null); + while (it.next()) |p| { + const row = p.rowAndCell().row; + const cells = p.page.data.getCells(row); + if (Cell.hasTextAny(cells)) { + var copy = p; + copy.x = p.page.data.size.cols - 1; + break :boundary copy; + } + } + + // In this case it means that all our rows are blank. Let's + // just return no selection, this is a weird case. + return null; }; // Go backwards to find our start boundary // We are looking for output start markers - const start: point.ScreenPoint = boundary: { - var y: usize = pt.y; - while (y > 0) : (y -= 1) { - const row = self.getRow(.{ .screen = y }); - switch (row.getSemanticPrompt()) { - .command => break :boundary .{ .x = 0, .y = y }, + const start: Pin = boundary: { + var it = pin.rowIterator(.left_up, null); + var it_prev = pin; + while (it.next()) |p| { + const row = p.rowAndCell().row; + switch (row.semantic_prompt) { + .command => break :boundary p, else => {}, } + + it_prev = p; } - break :boundary .{ .x = 0, .y = 0 }; + + break :boundary it_prev; }; - return Selection{ - .start = start, - .end = end, - }; + return Selection.init(start, end, false); } /// Returns the selection bounds for the prompt at the given point. If the @@ -1935,10 +1317,11 @@ pub fn selectOutput(self: *Screen, pt: point.ScreenPoint) ?Selection { /// /// Note that this feature requires shell integration. If shell integration /// is not enabled, this will always return null. -pub fn selectPrompt(self: *Screen, pt: point.ScreenPoint) ?Selection { +pub fn selectPrompt(self: *Screen, pin: Pin) ?Selection { + _ = self; + // Ensure that the line the point is on is a prompt. - const pt_row = self.getRow(.{ .screen = pt.y }); - const is_known = switch (pt_row.getSemanticPrompt()) { + const is_known = switch (pin.rowAndCell().row.semantic_prompt) { .prompt, .prompt_continuation, .input => true, .command => return null, @@ -1953,22 +1336,29 @@ pub fn selectPrompt(self: *Screen, pt: point.ScreenPoint) ?Selection { // Find the start of the prompt. var saw_semantic_prompt = is_known; - const start: usize = start: for (0..pt.y) |offset| { - const y = pt.y - offset; - const row = self.getRow(.{ .screen = y - 1 }); - switch (row.getSemanticPrompt()) { - // A prompt, we continue searching. - .prompt, .prompt_continuation, .input => saw_semantic_prompt = true, + const start: Pin = start: { + var it = pin.rowIterator(.left_up, null); + var it_prev = it.next().?; + while (it.next()) |p| { + const row = p.rowAndCell().row; + switch (row.semantic_prompt) { + // A prompt, we continue searching. + .prompt, .prompt_continuation, .input => saw_semantic_prompt = true, - // See comment about "unknown" a few lines above. If we have - // previously seen a semantic prompt then if we see an unknown - // we treat it as a boundary. - .unknown => if (saw_semantic_prompt) break :start y, + // See comment about "unknown" a few lines above. If we have + // previously seen a semantic prompt then if we see an unknown + // we treat it as a boundary. + .unknown => if (saw_semantic_prompt) break :start it_prev, - // Command output or unknown, definitely not a prompt. - .command => break :start y, + // Command output or unknown, definitely not a prompt. + .command => break :start it_prev, + } + + it_prev = p; } - } else 0; + + break :start it_prev; + }; // If we never saw a semantic prompt flag, then we can't trust our // start value and we return null. This scenario usually means that @@ -1976,21 +1366,28 @@ pub fn selectPrompt(self: *Screen, pt: point.ScreenPoint) ?Selection { if (!saw_semantic_prompt) return null; // Find the end of the prompt. - const end: usize = end: for (pt.y..self.rowsWritten()) |y| { - const row = self.getRow(.{ .screen = y }); - switch (row.getSemanticPrompt()) { - // A prompt, we continue searching. - .prompt, .prompt_continuation, .input => {}, + const end: Pin = end: { + var it = pin.rowIterator(.right_down, null); + var it_prev = it.next().?; + it_prev.x = it_prev.page.data.size.cols - 1; + while (it.next()) |p| { + const row = p.rowAndCell().row; + switch (row.semantic_prompt) { + // A prompt, we continue searching. + .prompt, .prompt_continuation, .input => {}, - // Command output or unknown, definitely not a prompt. - .command, .unknown => break :end y - 1, + // Command output or unknown, definitely not a prompt. + .command, .unknown => break :end it_prev, + } + + it_prev = p; + it_prev.x = it_prev.page.data.size.cols - 1; } - } else self.rowsWritten() - 1; - return .{ - .start = .{ .x = 0, .y = start }, - .end = .{ .x = self.cols - 1, .y = end }, + break :end it_prev; }; + + return Selection.init(start, end, false); } /// Returns the change in x/y that is needed to reach "to" from "from" @@ -2001,8 +1398,8 @@ pub fn selectPrompt(self: *Screen, pt: point.ScreenPoint) ?Selection { /// enabled, this will always return zero for both x and y (no path). pub fn promptPath( self: *Screen, - from: point.ScreenPoint, - to: point.ScreenPoint, + from: Pin, + to: Pin, ) struct { x: isize, y: isize, @@ -2011,1429 +1408,44 @@ pub fn promptPath( const bounds = self.selectPrompt(from) orelse return .{ .x = 0, .y = 0 }; // Get our actual "to" point clamped to the bounds of the prompt. - const to_clamped = if (bounds.contains(to)) + const to_clamped = if (bounds.contains(self, to)) to - else if (to.before(bounds.start)) - bounds.start + else if (to.before(bounds.start())) + bounds.start() else - bounds.end; + bounds.end(); + + // Convert to points + const from_pt = self.pages.pointFromPin(.screen, from).?.screen; + const to_pt = self.pages.pointFromPin(.screen, to_clamped).?.screen; // Basic math to calculate our path. - const from_x: isize = @intCast(from.x); - const from_y: isize = @intCast(from.y); - const to_x: isize = @intCast(to_clamped.x); - const to_y: isize = @intCast(to_clamped.y); + const from_x: isize = @intCast(from_pt.x); + const from_y: isize = @intCast(from_pt.y); + const to_x: isize = @intCast(to_pt.x); + const to_y: isize = @intCast(to_pt.y); return .{ .x = to_x - from_x, .y = to_y - from_y }; } -/// 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". This scrolls the - /// screen (potentially in addition to the viewport) and may therefore - /// create more rows if necessary. - screen: isize, - - /// This is the same as "screen" but only scrolls the viewport. The - /// delta will be clamped at the current size of the screen and will - /// never create new scrollback. - viewport: isize, - - /// Scroll so the given row is in view. If the row is in the viewport, - /// this will change nothing. If the row is outside the viewport, the - /// viewport will change so that this row is at the top of the viewport. - row: RowIndex, - - /// Scroll down and move all viewport contents into the scrollback - /// so that the screen is clear. This isn't eqiuivalent to "screen" with - /// the value set to the viewport size because this will handle the case - /// that the viewport is not full. - /// - /// This will ignore empty trailing rows. An empty row is a row that - /// has never been written to at all. A row with spaces is not empty. - clear: void, -}; - -/// 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) Allocator.Error!void { - // No matter what, scrolling marks our image state as dirty since - // it could move placements. If there are no placements or no images - // this is still a very cheap operation. - self.kitty_images.dirty = true; - - 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 = self.history, - - // TODO: deltas greater than the entire scrollback - .screen => |delta| try self.scrollDelta(delta, false), - .viewport => |delta| try self.scrollDelta(delta, true), - - // Scroll to a specific row - .row => |idx| self.scrollRow(idx), - - // Scroll until the viewport is clear by moving the viewport contents - // into the scrollback. - .clear => try self.scrollClear(), - } -} - -fn scrollClear(self: *Screen) Allocator.Error!void { - // The full amount of rows in the viewport - const full_amount = self.rowsWritten() - self.viewport; - - // Find the number of non-empty rows - const non_empty = for (0..full_amount) |i| { - const rev_i = full_amount - i - 1; - const row = self.getRow(.{ .viewport = rev_i }); - if (!row.isEmpty()) break rev_i + 1; - } else full_amount; - - try self.scroll(.{ .screen = @intCast(non_empty) }); -} - -fn scrollRow(self: *Screen, idx: RowIndex) void { - // Convert the given row to a screen point. - const screen_idx = idx.toScreen(self); - const screen_pt: point.ScreenPoint = .{ .y = screen_idx.screen }; - - // Move the viewport so that the screen point is in view. We do the - // @min here so that we don't scroll down below where our "bottom" - // viewport is. - self.viewport = @min(self.history, screen_pt.y); - assert(screen_pt.inViewport(self)); -} - -fn scrollDelta(self: *Screen, delta: isize, viewport_only: bool) Allocator.Error!void { - // Just in case, to avoid a bunch of stuff below. - if (delta == 0) return; - - // 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 -|= @as(usize, @intCast(-delta)); - return; - } - - // If we're scrolling only the viewport, then we just add to the viewport. - if (viewport_only) { - self.viewport = @min( - self.history, - self.viewport + @as(usize, @intCast(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. - const start_viewport_bottom = self.viewportIsBottom(); - const viewport = self.history + @as(usize, @intCast(delta)); - if (viewport <= self.history) return; - - // If our viewport is past the top of our history then we potentially need - // to write more blank rows. If our viewport is more than our rows written - // then we expand out to there. - const rows_written = self.rowsWritten(); - const viewport_bottom = viewport + self.rows; - if (viewport_bottom <= rows_written) return; - - // The number of new rows we need is the number of rows off our - // previous bottom we are growing. - const new_rows_needed = viewport_bottom - rows_written; - - // If we can't fit into our capacity but we have space, resize the - // buffer to allocate more scrollback. - 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 = @max( - rows_final * (self.cols + 1), - @min(self.storage.capacity() * 2, max_capacity), - ); - - // Allocate what we can. - try self.storage.resize( - self.alloc, - @min(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(); - - // Fast-path: we have no graphemes. - // Slow-path: we have graphemes, we have to check each row - // we're going to delete to see if they contain graphemes and - // clear the ones that do so we clear memory properly. - if (self.graphemes.count() > 0) { - var y: usize = 0; - while (y < rows_to_delete) : (y += 1) { - const row = self.getRow(.{ .screen = y }); - if (row.storage[0].header.flags.grapheme) row.clear(.{}); - } - } - - self.storage.deleteOldest(rows_to_delete * (self.cols + 1)); - break :deleted rows_to_delete; - } else 0; - - // If we are deleting rows and have a selection, then we need to offset - // the selection by the rows we're deleting. - if (self.selection) |*sel| { - // If we're deleting more rows than our Y values, we also move - // the X over to 0 because we're in the middle of the selection now. - if (rows_deleted > sel.start.y) sel.start.x = 0; - if (rows_deleted > sel.end.y) sel.end.x = 0; - - // Remove the deleted rows from both y values. We use saturating - // subtraction so that we can detect when we're at zero. - sel.start.y -|= rows_deleted; - sel.end.y -|= rows_deleted; - - // If the selection is now empty, just clear it. - if (sel.empty()) self.selection = null; - } - - // If we have more rows than what shows on our screen, we have a - // history boundary. - const rows_written_final = rows_final - rows_deleted; - if (rows_written_final > self.rows) { - self.history = rows_written_final - self.rows; - } - - // Ensure we have "written" our last row so that it shows up - const slices = self.storage.getPtrSlice( - (rows_written_final - 1) * (self.cols + 1), - self.cols + 1, - ); - // We should never be wrapped here - assert(slices[1].len == 0); - - // We only grabbed our new row(s), copy cells into the whole slice - const dst = slices[0]; - // The pen we'll use for new cells (only the BG attribute is applied to new - // cells) - const pen: Cell = switch (self.cursor.pen.bg) { - .none => .{}, - else => |bg| .{ .bg = bg }, - }; - @memset(dst, .{ .cell = pen }); - - // Then we make sure our row headers are zeroed out. We set - // the value to a dirty row header so that the renderer re-draws. - var i: usize = 0; - while (i < dst.len) : (i += self.cols + 1) { - dst[i] = .{ .header = .{ - .flags = .{ .dirty = true }, - } }; - } - - if (start_viewport_bottom) { - // If our viewport is on the bottom, we always update the viewport - // to the latest so that it remains in view. - self.viewport = self.history; - } else if (rows_deleted > 0) { - // If our viewport is NOT on the bottom, we want to keep our viewport - // where it was so that we don't jump around. However, we need to - // subtract the final rows written if we had to delete rows since - // that changes the viewport offset. - self.viewport -|= rows_deleted; - } -} - -/// The options for where you can jump to on the screen. -pub const JumpTarget = union(enum) { - /// Jump forwards (positive) or backwards (negative) a set number of - /// prompts. If the absolute value is greater than the number of prompts - /// in either direction, jump to the furthest prompt. - prompt_delta: isize, -}; - -/// Jump the viewport to specific location. -pub fn jump(self: *Screen, target: JumpTarget) bool { - return switch (target) { - .prompt_delta => |delta| self.jumpPrompt(delta), - }; -} - -/// Jump the viewport forwards (positive) or backwards (negative) a set number of -/// prompts (delta). Returns true if the viewport changed and false if no jump -/// occurred. -fn jumpPrompt(self: *Screen, delta: isize) bool { - // If we aren't jumping any prompts then we don't need to do anything. - if (delta == 0) return false; - - // The screen y value we start at - const start_y: isize = start_y: { - const idx: RowIndex = .{ .viewport = 0 }; - const screen = idx.toScreen(self); - break :start_y @intCast(screen.screen); - }; - - // The maximum y in the positive direction. Negative is always 0. - const max_y: isize = @intCast(self.rowsWritten() - 1); - - // Go line-by-line counting the number of prompts we see. - const step: isize = if (delta > 0) 1 else -1; - var y: isize = start_y + step; - const delta_start: usize = @intCast(if (delta > 0) delta else -delta); - var delta_rem: usize = delta_start; - while (y >= 0 and y <= max_y and delta_rem > 0) : (y += step) { - const row = self.getRow(.{ .screen = @intCast(y) }); - switch (row.getSemanticPrompt()) { - .prompt, .prompt_continuation, .input => delta_rem -= 1, - .command, .unknown => {}, - } - } - - //log.warn("delta={} delta_rem={} start_y={} y={}", .{ delta, delta_rem, start_y, y }); - - // If we didn't find any, do nothing. - if (delta_rem == delta_start) return false; - - // Done! We count the number of lines we changed and scroll. - const y_delta = (y - step) - start_y; - const new_y: usize = @intCast(start_y + y_delta); - const old_viewport = self.viewport; - self.scroll(.{ .row = .{ .screen = new_y } }) catch unreachable; - //log.warn("delta={} y_delta={} start_y={} new_y={}", .{ delta, y_delta, start_y, new_y }); - return self.viewport != old_viewport; -} - -/// 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, - trim: bool, -) ![:0]const u8 { - // Get the slices for the string - const slices = self.selectionSlices(sel); - - // Use an ArrayList so that we can grow the array as we go. We - // build an initial capacity of just our rows in our selection times - // columns. It can be more or less based on graphemes, newlines, etc. - var strbuilder = try std.ArrayList(u8).initCapacity(alloc, slices.rows * self.cols); - defer strbuilder.deinit(); - - // Get our string result. - try self.selectionSliceString(slices, &strbuilder, null); - - // Remove any trailing spaces on lines. We could do optimize this by - // doing this in the loop above but this isn't very hot path code and - // this is simple. - if (trim) { - var it = std.mem.tokenizeScalar(u8, strbuilder.items, '\n'); - - // Reset our items. We retain our capacity. Because we're only - // removing bytes, we know that the trimmed string must be no longer - // than the original string so we copy directly back into our - // allocated memory. - strbuilder.clearRetainingCapacity(); - while (it.next()) |line| { - const trimmed = std.mem.trimRight(u8, line, " \t"); - const i = strbuilder.items.len; - strbuilder.items.len += trimmed.len; - std.mem.copyForwards(u8, strbuilder.items[i..], trimmed); - strbuilder.appendAssumeCapacity('\n'); - } - - // Remove our trailing newline again - if (strbuilder.items.len > 0) strbuilder.items.len -= 1; - } - - // Get our final string - const string = try strbuilder.toOwnedSliceSentinel(0); - errdefer alloc.free(string); - - return string; -} - -/// Returns the row text associated with a selection along with the -/// mapping of each individual byte in the string to the point in the screen. -fn selectionStringMap( - self: *Screen, - alloc: Allocator, - sel: Selection, -) !StringMap { - // Get the slices for the string - const slices = self.selectionSlices(sel); - - // Use an ArrayList so that we can grow the array as we go. We - // build an initial capacity of just our rows in our selection times - // columns. It can be more or less based on graphemes, newlines, etc. - var strbuilder = try std.ArrayList(u8).initCapacity(alloc, slices.rows * self.cols); - defer strbuilder.deinit(); - var mapbuilder = try std.ArrayList(point.ScreenPoint).initCapacity(alloc, strbuilder.capacity); - defer mapbuilder.deinit(); - - // Get our results - try self.selectionSliceString(slices, &strbuilder, &mapbuilder); - - // Get our final string - const string = try strbuilder.toOwnedSliceSentinel(0); - errdefer alloc.free(string); - const map = try mapbuilder.toOwnedSlice(); - errdefer alloc.free(map); - return .{ .string = string, .map = map }; -} - -/// Takes a SelectionSlices value and builds the string and mapping for it. -fn selectionSliceString( - self: *Screen, - slices: SelectionSlices, - strbuilder: *std.ArrayList(u8), - mapbuilder: ?*std.ArrayList(point.ScreenPoint), -) !void { - // Connect the text from the two slices - const arr = [_][]StorageCell{ slices.top, slices.bot }; - var row_count: usize = 0; - for (arr) |slice| { - const 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; - - const end_idx = if (slices.sel.rectangle) - // Rectangle select: calculate end with bottom offset. - start_idx + slices.bot_offset + 2 // think "column count" + 1 - else - // Normal select: our end index is usually a full row, but if - // we're the final row then we just use the length. - @min(slice.len, start_idx + self.cols + 1); - - // We may have to skip some cells from the beginning if we're the - // first row, of if we're using rectangle select. - var skip: usize = if (row_count == 0 or slices.sel.rectangle) slices.top_offset else 0; - - // If we have runtime safety we need to initialize the row - // so that the proper union tag is set. In release modes we - // don't need to do this because we zero the memory. - if (std.debug.runtime_safety) { - _ = self.getRow(.{ .screen = slices.sel.start.y + row_i }); - } - - const row: Row = .{ .screen = self, .storage = slice[start_idx..end_idx] }; - var it = row.cellIterator(); - var x: usize = 0; - while (it.next()) |cell| { - defer x += 1; - - if (skip > 0) { - skip -= 1; - continue; - } - - // Skip spacers - if (cell.attrs.wide_spacer_head or - cell.attrs.wide_spacer_tail) continue; - - var buf: [4]u8 = undefined; - const char = if (cell.char > 0) cell.char else ' '; - { - const encode_len = try std.unicode.utf8Encode(@intCast(char), &buf); - try strbuilder.appendSlice(buf[0..encode_len]); - if (mapbuilder) |b| { - for (0..encode_len) |_| try b.append(.{ - .x = x, - .y = slices.sel.start.y + row_i, - }); - } - } - - var cp_it = row.codepointIterator(x); - while (cp_it.next()) |cp| { - const encode_len = try std.unicode.utf8Encode(cp, &buf); - try strbuilder.appendSlice(buf[0..encode_len]); - if (mapbuilder) |b| { - for (0..encode_len) |_| try b.append(.{ - .x = x, - .y = slices.sel.start.y + row_i, - }); - } - } - } - - // If this row is not soft-wrapped or if we're using rectangle - // select, add a newline - if (!row.header().flags.wrap or slices.sel.rectangle) { - try strbuilder.append('\n'); - if (mapbuilder) |b| { - try b.append(.{ - .x = self.cols - 1, - .y = slices.sel.start.y + row_i, - }); - } - } - } - } - - // Remove our trailing newline, its never correct. - if (strbuilder.items.len > 0 and - strbuilder.items[strbuilder.items.len - 1] == '\n') - { - strbuilder.items.len -= 1; - if (mapbuilder) |b| b.items.len -= 1; - } - - if (std.debug.runtime_safety) { - if (mapbuilder) |b| { - assert(strbuilder.items.len == b.items.len); - } - } -} - -const SelectionSlices = struct { - rows: usize, - - // The selection that the slices below represent. This may not - // be the same as the input selection since some normalization - // occurs. - sel: Selection, - - // 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, - - // Our bottom offset is used in rectangle select to always determine the - // maximum cell in a given row. - bot_offset: usize, - - // Our selection storage cell chunks. - top: []StorageCell, - bot: []StorageCell, -}; - -/// 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) SelectionSlices { - // Note: this function is tested via selectionString - - // If the selection starts beyond the end of the screen, then we return empty - if (sel_raw.start.y >= self.rowsWritten()) return .{ - .rows = 0, - .sel = sel_raw, - .top_offset = 0, - .bot_offset = 0, - .top = self.storage.storage[0..0], - .bot = self.storage.storage[0..0], - }; - - const sel = sel: { - var sel = sel_raw; - - // Clamp the selection to the screen - if (sel.end.y >= self.rowsWritten()) { - sel.end.y = self.rowsWritten() - 1; - sel.end.x = self.cols - 1; - } - - // 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.start.x -= 1; - } - } - - break :sel sel; - }; - - // Get the true "top" and "bottom" - const sel_top = sel.topLeft(); - const sel_bot = sel.bottomRight(); - const sel_isRect = sel.rectangle; - - // 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, - .sel = .{ .start = sel_top, .end = sel_bot, .rectangle = sel_isRect }, - .top_offset = sel_top.x, - .bot_offset = sel_bot.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 { - // 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; - - const blank = self.trailingBlankLines(); - - // If we are shrinking the number of rows, we don't want to trim - // off more blank rows than the number we're shrinking because it - // creates a jarring screen move experience. - if (self.rows > rows) break :blank @min(blank, self.rows - rows); - - break :blank blank; - }; - - // 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; - - // 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(end_of_screen_y, rows) * (cols + 1); - const new_max_capacity = self.maxCapacity(); - const buf_size = @min(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 and history resets to the top because we're going to - // rewrite the screen - self.viewport = 0; - self.history = 0; - - // Reset our grapheme map and ensure the old one is deallocated - // on success. - self.graphemes = .{}; - errdefer self.deinitGraphemes(); - defer old.deinitGraphemes(); - - // Rewrite all our rows - var y: usize = 0; - 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(.{ .screen = 1 }); - } - - // Get this row - const new_row = self.getRow(.{ .active = y }); - try 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 accommodate 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 = @min(old.cursor.x, self.cols - 1); - self.cursor.y = if (old_cursor_y_screen <= RowIndexTag.screen.maxLen(self)) - old_cursor_y_screen -| self.history - else - self.rows - 1; - - // If our rows increased and our cursor is NOT at the bottom, we want - // to try to preserve the y value of the old cursor. In other words, we - // don't want to "pull down" scrollback. This is purely a UX feature. - if (self.rows > old.rows and - old.cursor.y < old.rows - 1 and - self.cursor.y > old.cursor.y) - { - const delta = self.cursor.y - old.cursor.y; - if (self.scroll(.{ .screen = @intCast(delta) })) { - self.cursor.y -= delta; - } else |err| { - // If this scroll fails its not that big of a deal so we just - // log and ignore. - log.warn("failed to scroll for resize, cursor may be off err={}", .{err}); - } - } -} - -/// 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; - - // No matter what we mark our image state as dirty - self.kitty_images.dirty = true; - - // 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; - } - - // No matter what we mark our image state as dirty - self.kitty_images.dirty = true; - - // Keep track if our cursor is at the bottom - const cursor_bottom = self.cursor.y == self.rows - 1; - - // 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 old = self.*; - errdefer self.* = old; - - // Allocate enough to store our screen plus history. - const buf_size = (self.rows + @max(self.history, self.max_scrollback)) * (cols + 1); - self.storage = try StorageBuf.init(self.alloc, buf_size); - errdefer self.storage.deinit(self.alloc); - defer old.storage.deinit(self.alloc); - - // Copy grapheme map - self.graphemes = .{}; - errdefer self.deinitGraphemes(); - defer old.deinitGraphemes(); - - // 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.Active{ - .x = old.cursor.x, - .y = old.cursor.y, - }).toScreen(&old); - - // Whether we need to move the cursor or not - var new_cursor: ?point.ScreenPoint = null; - - // Reset our variables because we're going to reprint the screen. - self.cols = cols; - self.viewport = 0; - self.history = 0; - - // Iterate over the screen since we need to check for reflow. - var iter = old.rowIterator(.screen); - var y: usize = 0; - while (iter.next()) |old_row| { - // If we're past the end, scroll - if (y >= self.rows) { - try self.scroll(.{ .screen = 1 }); - y -= 1; - } - - // 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.history + y, .x = cursor_pos.x }; - } - - // 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) { - // We used to do have this behavior, but it broke some programs. - // I know I copied this behavior while observing some other - // terminal, but I can't remember which one. I'm leaving this - // here in case we want to bring this back (with probably - // slightly different behavior). - // - // 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; - } - - // 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.setWrapped(false); - - // x is the offset where we start copying into new_row. Its also - // used for cursor tracking. - var x: usize = old.cols; - - // Edge case: if the end of our old row is a wide spacer head, - // we want to overwrite it. - if (old_row.getCellPtr(x - 1).attrs.wide_spacer_head) x -= 1; - - wrapping: while (iter.next()) |wrapped_row| { - const wrapped_cells = trim: { - var i: usize = old.cols; - - // Trim the row from the right so that we ignore all trailing - // empty chars and don't wrap them. We only do this if the - // row is NOT wrapped again because the whitespace would be - // meaningful. - if (!wrapped_row.header().flags.wrap) { - while (i > 0) : (i -= 1) { - if (!wrapped_row.getCell(i - 1).empty()) break; - } - } else { - // If we are wrapped, then similar to above "edge case" - // we want to overwrite the wide spacer head if we end - // in one. - if (wrapped_row.getCellPtr(i - 1).attrs.wide_spacer_head) { - i -= 1; - } - } - - break :trim wrapped_row.storage[1 .. i + 1]; - }; - - var wrapped_i: usize = 0; - while (wrapped_i < wrapped_cells.len) { - // Remaining space in our new row - const new_row_rem = self.cols - x; - - // Remaining cells in our wrapped row - const wrapped_cells_rem = wrapped_cells.len - wrapped_i; - - // We copy as much as we can into our new row - const copy_len = if (new_row_rem <= wrapped_cells_rem) copy_len: { - // We are going to end up filling our new row. We need - // to check if the end of the row is a wide char and - // if so, we need to insert a wide char header and wrap - // there. - var proposed: usize = new_row_rem; - - // If the end of our copy is wide, we copy one less and - // set the wide spacer header now since we're not going - // to write over it anyways. - if (proposed > 0 and wrapped_cells[wrapped_i + proposed - 1].cell.attrs.wide) { - proposed -= 1; - new_row.getCellPtr(x + proposed).* = .{ - .char = ' ', - .attrs = .{ .wide_spacer_head = true }, - }; - } - - break :copy_len proposed; - } else wrapped_cells_rem; - - // The row doesn't fit, meaning we have to soft-wrap the - // new row but probably at a diff boundary. - fastmem.copy( - StorageCell, - new_row.storage[x + 1 ..], - wrapped_cells[wrapped_i .. wrapped_i + copy_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 < copy_len and - new_cursor == null) - { - 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) { - y += 1; - break :wrapping; - } - - // Wrapped again! - x += wrapped_cells_rem; - break; - } - - // We still need to copy the remainder - wrapped_i += copy_len; - - // Move to a new line in our new screen - new_row.setWrapped(true); - y += 1; - x = 0; - - // If we're past the end, scroll - if (y >= self.rows) { - y -= 1; - try self.scroll(.{ .screen = 1 }); - } - new_row = self.getRow(.{ .active = y }); - new_row.setSemanticPrompt(old_row.getSemanticPrompt()); - } - } - } - - // 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; - } - } - - // 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, self.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. - if (rows < self.rows) try self.resizeWithoutReflow(rows, self.cols); - - // If our cols got smaller, we have to reflow text. This is the worst - // possible case because we can't do any easy tricks to get reflow, - // we just have to iterate over the screen and "print", wrapping as - // needed. - if (cols < self.cols) { - var old = self.*; - errdefer self.* = old; - - // Allocate enough to store our screen plus history. - const buf_size = (self.rows + @max(self.history, self.max_scrollback)) * (cols + 1); - self.storage = try StorageBuf.init(self.alloc, buf_size); - errdefer self.storage.deinit(self.alloc); - defer old.storage.deinit(self.alloc); - - // Create empty grapheme map. Cell IDs change so we can't just copy it, - // we'll rebuild it. - self.graphemes = .{}; - errdefer self.deinitGraphemes(); - defer old.deinitGraphemes(); - - // Convert our cursor coordinates to screen coordinates because - // we may have to reflow the cursor if the line it is on is moved. - const cursor_pos = (point.Active{ - .x = old.cursor.x, - .y = old.cursor.y, - }).toScreen(&old); - - // Whether we need to move the cursor or not - var new_cursor: ?point.ScreenPoint = null; - var new_cursor_wrap: usize = 0; - - // Reset our variables because we're going to reprint the screen. - self.cols = cols; - self.viewport = 0; - self.history = 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 (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); - - // If our y is more than our rows, we need to scroll - if (y >= self.rows) { - try self.scroll(.{ .screen = 1 }); - y -= 1; - } - - // Fast path: our old row is not wrapped AND our old row fits - // into our new smaller size AND this row has no grapheme clusters. - // In this case, we just do a fast copy and move on. - if (!old_row_wrapped and - trimmed_row.len <= self.cols and - !old_row.header().flags.grapheme) - { - // 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 = cursor_pos.x, .y = self.history + y }; - } - - const row = self.getRow(.{ .active = y }); - row.setSemanticPrompt(old_row.getSemanticPrompt()); - - fastmem.copy( - StorageCell, - row.storage[1..], - trimmed_row, - ); - - y += 1; - 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 }); - row.setSemanticPrompt(old_row.getSemanticPrompt()); - 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..) |old_cell, old_x| { - var cell: StorageCell = old_cell; - - // This is a really wild edge case if we're resizing down - // to 1 column. In reality this is pretty broken for end - // users so downstream should prevent this. - if (self.cols == 1 and - (cell.cell.attrs.wide or - cell.cell.attrs.wide_spacer_head or - cell.cell.attrs.wide_spacer_tail)) - { - cell = .{ .cell = .{ .char = ' ' } }; - } - - // We need to wrap wide chars with a spacer head. - if (cell.cell.attrs.wide and x == self.cols - 1) { - row.getCellPtr(x).* = .{ - .char = ' ', - .attrs = .{ .wide_spacer_head = true }, - }; - x += 1; - } - - // 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(.{ .screen = 1 }); - y -= 1; - - // Clear if our current cell is a wide spacer tail - if (cell.cell.attrs.wide_spacer_tail) { - cell = .{ .cell = .{} }; - } - } - - if (cursor_pos.y == old_y) { - // If this original y is where our cursor is, we - // track the number of wraps we do so we can try to - // keep this whole line on the screen. - new_cursor_wrap += 1; - } - - row = self.getRow(.{ .active = y }); - row.setSemanticPrompt(cur_old_row.getSemanticPrompt()); - } - - // 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 - const new_cell = row.getCellPtr(x); - new_cell.* = cell.cell; - - // If the old cell is a multi-codepoint grapheme then we - // need to also attach the graphemes. - if (cell.cell.attrs.grapheme) { - var it = cur_old_row.codepointIterator(old_x); - while (it.next()) |cp| try row.attachGrapheme(x, cp); - } - - 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); - } - } - - // 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 = @min(viewport_pos.x, self.cols - 1); - self.cursor.y = @min(viewport_pos.y, self.rows - 1); - - // We want to keep our cursor y at the same place. To do so, we - // scroll the screen. This scrolls all of the content so the cell - // the cursor is over doesn't change. - if (!cursor_bottom and old.cursor.y < self.cursor.y) scroll: { - const delta: isize = delta: { - var delta: isize = @intCast(self.cursor.y - old.cursor.y); - - // new_cursor_wrap is the number of times the line that the - // cursor was on previously was wrapped to fit this new col - // width. We want to scroll that many times less so that - // the whole line the cursor was on attempts to remain - // in view. - delta -= @intCast(new_cursor_wrap); - - if (delta <= 0) break :scroll; - break :delta delta; - }; - - self.scroll(.{ .screen = delta }) catch |err| { - log.warn("failed to scroll for resize, cursor may be off err={}", .{err}); - break :scroll; - }; - - self.cursor.y -= @intCast(delta); - } - } else { - // TODO: why is this necessary? Without this, neovim will - // crash when we shrink the window to the smallest size. We - // never got a test case to cover this. - self.cursor.x = @min(self.cursor.x, self.cols - 1); - self.cursor.y = @min(self.cursor.y, self.rows - 1); - } - } -} - -/// 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. - // We also have to ignore wide spacers because they form - // a critical part of a wide character. - if (i > self.cols) { - if ((cell.char == 0 or cell.char == ' ') and - !cell.attrs.wide_spacer_tail and - !cell.attrs.wide_spacer_head) 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. -pub fn testWriteString(self: *Screen, text: []const u8) !void { - var y: usize = self.cursor.y; - var x: usize = self.cursor.x; - - var grapheme: struct { - x: usize = 0, - cell: ?*Cell = null, - } = .{}; - - 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; - grapheme = .{}; - continue; - } - - // If we're writing past the end of the active area, scroll. - if (y >= self.rows) { - y -= 1; - try self.scroll(.{ .screen = 1 }); - } - - // Get our row - var row = self.getRow(.{ .active = y }); - - // NOTE: graphemes are currently disabled - if (false) { - // If we have a previous cell, we check if we're part of a grapheme. - if (grapheme.cell) |prev_cell| { - const grapheme_break = brk: { - var state: u3 = 0; - var cp1 = @as(u21, @intCast(prev_cell.char)); - if (prev_cell.attrs.grapheme) { - var it = row.codepointIterator(grapheme.x); - while (it.next()) |cp2| { - assert(!ziglyph.graphemeBreak( - cp1, - cp2, - &state, - )); - - cp1 = cp2; - } - } - - break :brk ziglyph.graphemeBreak(cp1, c, &state); - }; - - if (!grapheme_break) { - try row.attachGrapheme(grapheme.x, c); - continue; - } - } - } - - const width: usize = @intCast(@max(0, ziglyph.display_width.codePointWidth(c, .half))); - //log.warn("c={x} width={}", .{ c, width }); - - // Zero-width are attached as grapheme data. - // NOTE: if/when grapheme clustering is ever enabled (above) this - // is not necessary - if (width == 0) { - if (grapheme.cell != null) { - try row.attachGrapheme(grapheme.x, c); - } - - continue; - } - - // 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(.{ .screen = 1 }); - } - row = self.getRow(.{ .active = y }); - } - - // If our character is double-width, handle it. - assert(width == 1 or width == 2); - switch (width) { - 1 => { - const cell = row.getCellPtr(x); - cell.* = self.cursor.pen; - cell.char = @intCast(c); - - grapheme.x = x; - grapheme.cell = cell; - }, - - 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(.{ .screen = 1 }); - } - row = self.getRow(.{ .active = y }); - } - - { - const cell = row.getCellPtr(x); - cell.* = self.cursor.pen; - cell.char = @intCast(c); - cell.attrs.wide = true; - - grapheme.x = x; - grapheme.cell = cell; - } - - { - x += 1; - const cell = row.getCellPtr(x); - cell.char = ' '; - cell.attrs.wide_spacer_tail = true; - } - }, - - else => unreachable, - } - - x += 1; - } - - // So the cursor doesn't go off screen - self.cursor.x = @min(x, self.cols - 1); - self.cursor.y = y; -} - -/// Options for dumping the screen to a string. -pub const Dump = struct { - /// The start and end rows. These don't have to be in order, the dump - /// function will automatically sort them. - start: RowIndex, - end: RowIndex, - - /// If true, this will unwrap soft-wrapped lines into a single line. - unwrap: bool = true, -}; - /// Dump the screen to a string. The writer given should be buffered; /// this function does not attempt to efficiently write and generally writes /// one byte at a time. -/// -/// TODO: look at selectionString implementation for more efficiency -/// TODO: change selectionString to use this too after above todo -pub fn dumpString(self: *Screen, writer: anytype, opts: Dump) !void { - const start_screen = opts.start.toScreen(self); - const end_screen = opts.end.toScreen(self); - - // If we have no rows in our screen, do nothing. - const rows_written = self.rowsWritten(); - if (rows_written == 0) return; - - // Get the actual top and bottom y values. This handles situations - // where start/end are backwards. - const y_top = @min(start_screen.screen, end_screen.screen); - const y_bottom = @min( - @max(start_screen.screen, end_screen.screen), - rows_written - 1, - ); - - // This keeps track of the number of blank rows we see. We don't want - // to output blank rows unless they're followed by a non-blank row. +pub fn dumpString( + self: *const Screen, + writer: anytype, + tl: point.Point, +) !void { var blank_rows: usize = 0; - // Iterate through the rows - var y: usize = y_top; - while (y <= y_bottom) : (y += 1) { - const row = self.getRow(.{ .screen = y }); + var iter = self.pages.rowIterator(.right_down, tl, null); + while (iter.next()) |row_offset| { + const rac = row_offset.rowAndCell(); + const cells = cells: { + const cells: [*]pagepkg.Cell = @ptrCast(rac.cell); + break :cells cells[0..self.pages.cols]; + }; - // Handle blank rows - if (row.isEmpty()) { + if (!pagepkg.Cell.hasTextAny(cells)) { blank_rows += 1; continue; } @@ -3442,1090 +1454,2631 @@ pub fn dumpString(self: *Screen, writer: anytype, opts: Dump) !void { blank_rows = 0; } - if (!row.header().flags.wrap) { - // If we're not wrapped, we always add a newline. - blank_rows += 1; - } else if (!opts.unwrap) { - // If we are wrapped, we only add a new line if we're unwrapping - // soft-wrapped lines. - blank_rows += 1; - } + // TODO: handle wrap + blank_rows += 1; - // Output each of the cells - var cells = row.cellIterator(); - var spacers: usize = 0; - while (cells.next()) |cell| { + var blank_cells: usize = 0; + for (cells) |*cell| { // Skip spacers - if (cell.attrs.wide_spacer_head or cell.attrs.wide_spacer_tail) continue; + switch (cell.wide) { + .narrow, .wide => {}, + .spacer_head, .spacer_tail => continue, + } // If we have a zero value, then we accumulate a counter. We // only want to turn zero values into spaces if we have a non-zero // char sometime later. - if (cell.char == 0) { - spacers += 1; + if (!cell.hasText()) { + blank_cells += 1; continue; } - if (spacers > 0) { - for (0..spacers) |_| try writer.writeByte(' '); - spacers = 0; + if (blank_cells > 0) { + for (0..blank_cells) |_| try writer.writeByte(' '); + blank_cells = 0; } - const codepoint: u21 = @intCast(cell.char); - try writer.print("{u}", .{codepoint}); + switch (cell.content_tag) { + .codepoint => { + try writer.print("{u}", .{cell.content.codepoint}); + }, - var it = row.codepointIterator(cells.i - 1); - while (it.next()) |cp| { - try writer.print("{u}", .{cp}); + .codepoint_grapheme => { + try writer.print("{u}", .{cell.content.codepoint}); + const cps = row_offset.page.data.lookupGrapheme(cell).?; + for (cps) |cp| { + try writer.print("{u}", .{cp}); + } + }, + + else => unreachable, } } } } -/// 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 { +pub fn dumpStringAlloc( + self: *const Screen, + alloc: Allocator, + tl: point.Point, +) ![]const u8 { var builder = std.ArrayList(u8).init(alloc); defer builder.deinit(); - try self.dumpString(builder.writer(), .{ - .start = tag.index(0), - .end = tag.index(tag.maxLen(self) - 1), - - // historically our testString wants to view the screen as-is without - // unwrapping soft-wrapped lines so turn this off. - .unwrap = false, - }); + try self.dumpString(builder.writer(), tl); return try builder.toOwnedSlice(); } -test "Row: isEmpty with no data" { - const testing = std.testing; - const alloc = testing.allocator; +/// This is basically a really jank version of Terminal.printString. We +/// have to reimplement it here because we want a way to print to the screen +/// to test it but don't want all the features of Terminal. +pub fn testWriteString(self: *Screen, text: []const u8) !void { + const view = try std.unicode.Utf8View.init(text); + var iter = view.iterator(); + while (iter.nextCodepoint()) |c| { + // Explicit newline forces a new row + if (c == '\n') { + try self.cursorDownOrScroll(); + self.cursorHorizontalAbsolute(0); + self.cursor.pending_wrap = false; + continue; + } - var s = try init(alloc, 5, 5, 0); - defer s.deinit(); + const width: usize = if (c <= 0xFF) 1 else @intCast(unicode.table.get(c).width); + if (width == 0) { + const cell = cell: { + var cell = self.cursorCellLeft(1); + switch (cell.wide) { + .narrow => {}, + .wide => {}, + .spacer_head => unreachable, + .spacer_tail => cell = self.cursorCellLeft(2), + } - const row = s.getRow(.{ .active = 0 }); - try testing.expect(row.isEmpty()); -} + break :cell cell; + }; -test "Row: isEmpty with a character at the end" { - const testing = std.testing; - const alloc = testing.allocator; + try self.cursor.page_pin.page.data.appendGrapheme( + self.cursor.page_row, + cell, + c, + ); + continue; + } - var s = try init(alloc, 5, 5, 0); - defer s.deinit(); + if (self.cursor.pending_wrap) { + assert(self.cursor.x == self.pages.cols - 1); + self.cursor.pending_wrap = false; + self.cursor.page_row.wrap = true; + try self.cursorDownOrScroll(); + self.cursorHorizontalAbsolute(0); + self.cursor.page_row.wrap_continuation = true; + } - const row = s.getRow(.{ .active = 0 }); - const cell = row.getCellPtr(4); - cell.*.char = 'A'; - try testing.expect(!row.isEmpty()); -} + assert(width == 1 or width == 2); + switch (width) { + 1 => { + self.cursor.page_cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = c }, + .style_id = self.cursor.style_id, + }; -test "Row: isEmpty with only styled cells" { - const testing = std.testing; - const alloc = testing.allocator; + // If we have a ref-counted style, increase. + if (self.cursor.style_ref) |ref| { + ref.* += 1; + self.cursor.page_row.styled = true; + } + }, - var s = try init(alloc, 5, 5, 0); - defer s.deinit(); + 2 => { + // Need a wide spacer head + if (self.cursor.x == self.pages.cols - 1) { + self.cursor.page_cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = 0 }, + .wide = .spacer_head, + }; - const row = s.getRow(.{ .active = 0 }); - for (0..s.cols) |x| { - const cell = row.getCellPtr(x); - cell.*.bg = .{ .rgb = .{ .r = 0xAA, .g = 0xBB, .b = 0xCC } }; - } - try testing.expect(row.isEmpty()); -} + self.cursor.page_row.wrap = true; + try self.cursorDownOrScroll(); + self.cursorHorizontalAbsolute(0); + self.cursor.page_row.wrap_continuation = true; + } -test "Row: clear with graphemes" { - const testing = std.testing; - const alloc = testing.allocator; + // Write our wide char + self.cursor.page_cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = c }, + .style_id = self.cursor.style_id, + .wide = .wide, + }; - var s = try init(alloc, 5, 5, 0); - defer s.deinit(); + // Write our tail + self.cursorRight(1); + self.cursor.page_cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = 0 }, + .wide = .spacer_tail, + }; + }, - const row = s.getRow(.{ .active = 0 }); - try testing.expect(row.getId() > 0); - try testing.expectEqual(@as(usize, 5), row.lenCells()); - try testing.expect(!row.header().flags.grapheme); + else => unreachable, + } - // Lets add a cell with a grapheme - { - const cell = row.getCellPtr(2); - cell.*.char = 'A'; - try row.attachGrapheme(2, 'B'); - try testing.expect(cell.attrs.grapheme); - try testing.expect(row.header().flags.grapheme); - try testing.expect(s.graphemes.count() == 1); - } - - // Clear the row - row.clear(.{}); - try testing.expect(!row.header().flags.grapheme); - try testing.expect(s.graphemes.count() == 0); -} - -test "Row: copy row with graphemes in destination" { - const testing = std.testing; - const alloc = testing.allocator; - - var s = try init(alloc, 5, 5, 0); - defer s.deinit(); - - // Source row does NOT have graphemes - const row_src = s.getRow(.{ .active = 0 }); - { - const cell = row_src.getCellPtr(2); - cell.*.char = 'A'; - } - - // Destination has graphemes - const row = s.getRow(.{ .active = 1 }); - { - const cell = row.getCellPtr(1); - cell.*.char = 'B'; - try row.attachGrapheme(1, 'C'); - try testing.expect(cell.attrs.grapheme); - try testing.expect(row.header().flags.grapheme); - try testing.expect(s.graphemes.count() == 1); - } - - // Copy - try row.copyRow(row_src); - try testing.expect(!row.header().flags.grapheme); - try testing.expect(s.graphemes.count() == 0); -} - -test "Row: copy row with graphemes in source" { - const testing = std.testing; - const alloc = testing.allocator; - - var s = try init(alloc, 5, 5, 0); - defer s.deinit(); - - // Source row does NOT have graphemes - const row_src = s.getRow(.{ .active = 0 }); - { - const cell = row_src.getCellPtr(2); - cell.*.char = 'A'; - try row_src.attachGrapheme(2, 'B'); - try testing.expect(cell.attrs.grapheme); - try testing.expect(row_src.header().flags.grapheme); - try testing.expect(s.graphemes.count() == 1); - } - - // Destination has no graphemes - const row = s.getRow(.{ .active = 1 }); - try row.copyRow(row_src); - try testing.expect(row.header().flags.grapheme); - try testing.expect(s.graphemes.count() == 2); - - row_src.clear(.{}); - try testing.expect(s.graphemes.count() == 1); -} - -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); - { - const 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' }); - const contents = try s.testString(alloc, .screen); - defer alloc.free(contents); - try testing.expectEqualStrings("AAAAA\nAAAAA\nAAAAA", contents); + if (self.cursor.x + 1 < self.pages.cols) { + self.cursorRight(1); + } else { + self.cursor.pending_wrap = true; + } } } -test "Screen: write graphemes" { +test "Screen read and write" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 5, 5, 0); + var s = try Screen.init(alloc, 80, 24, 1000); defer s.deinit(); + try testing.expectEqual(@as(style.Id, 0), s.cursor.style_id); - // Sanity check that our test helpers work - var buf: [32]u8 = undefined; - var buf_idx: usize = 0; - buf_idx += try std.unicode.utf8Encode(0x1F44D, buf[buf_idx..]); // Thumbs up plain - buf_idx += try std.unicode.utf8Encode(0x1F44D, buf[buf_idx..]); // Thumbs up plain - buf_idx += try std.unicode.utf8Encode(0x1F3FD, buf[buf_idx..]); // Medium skin tone - - // Note the assertions below are NOT the correct way to handle graphemes - // in general, but they're "correct" for historical purposes for terminals. - // For terminals, all double-wide codepoints are counted as part of the - // width. - - try s.testWriteString(buf[0..buf_idx]); - try testing.expect(s.rowsWritten() == 2); - try testing.expectEqual(@as(usize, 2), s.cursor.x); + try s.testWriteString("hello, world"); + const str = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); + defer alloc.free(str); + try testing.expectEqualStrings("hello, world", str); } -test "Screen: write long emoji" { +test "Screen read and write newline" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 5, 30, 0); + var s = try Screen.init(alloc, 80, 24, 1000); defer s.deinit(); + try testing.expectEqual(@as(style.Id, 0), s.cursor.style_id); - // Sanity check that our test helpers work - var buf: [32]u8 = undefined; - var buf_idx: usize = 0; - buf_idx += try std.unicode.utf8Encode(0x1F9D4, buf[buf_idx..]); // man: beard - buf_idx += try std.unicode.utf8Encode(0x1F3FB, buf[buf_idx..]); // light skin tone (Fitz 1-2) - buf_idx += try std.unicode.utf8Encode(0x200D, buf[buf_idx..]); // ZWJ - buf_idx += try std.unicode.utf8Encode(0x2642, buf[buf_idx..]); // male sign - buf_idx += try std.unicode.utf8Encode(0xFE0F, buf[buf_idx..]); // emoji representation - - // Note the assertions below are NOT the correct way to handle graphemes - // in general, but they're "correct" for historical purposes for terminals. - // For terminals, all double-wide codepoints are counted as part of the - // width. - - try s.testWriteString(buf[0..buf_idx]); - try testing.expect(s.rowsWritten() == 1); - try testing.expectEqual(@as(usize, 5), s.cursor.x); + try s.testWriteString("hello\nworld"); + const str = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); + defer alloc.free(str); + try testing.expectEqualStrings("hello\nworld", str); } -test "Screen: lineIterator" { +test "Screen read and write scrollback" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 5, 5, 0); + var s = try Screen.init(alloc, 80, 2, 1000); defer s.deinit(); - // Sanity check that our test helpers work - const str = "1ABCD\n2EFGH"; - try s.testWriteString(str); - - // Test the line iterator - var iter = s.lineIterator(.viewport); + try s.testWriteString("hello\nworld\ntest"); { - const line = iter.next().?; - const actual = try line.string(alloc); - defer alloc.free(actual); - try testing.expectEqualStrings("1ABCD", actual); + const str = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); + defer alloc.free(str); + try testing.expectEqualStrings("hello\nworld\ntest", str); } { - const line = iter.next().?; - const actual = try line.string(alloc); - defer alloc.free(actual); - try testing.expectEqualStrings("2EFGH", actual); + const str = try s.dumpStringAlloc(alloc, .{ .active = .{} }); + defer alloc.free(str); + try testing.expectEqualStrings("world\ntest", str); } - try testing.expect(iter.next() == null); } -test "Screen: lineIterator soft wrap" { +test "Screen read and write no scrollback small" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 5, 5, 0); + var s = try Screen.init(alloc, 80, 2, 0); defer s.deinit(); - // Sanity check that our test helpers work - const str = "1ABCD2EFGH\n3ABCD"; - try s.testWriteString(str); - - // Test the line iterator - var iter = s.lineIterator(.viewport); + try s.testWriteString("hello\nworld\ntest"); { - const line = iter.next().?; - const actual = try line.string(alloc); - defer alloc.free(actual); - try testing.expectEqualStrings("1ABCD2EFGH", actual); + const str = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); + defer alloc.free(str); + try testing.expectEqualStrings("world\ntest", str); } { - const line = iter.next().?; - const actual = try line.string(alloc); - defer alloc.free(actual); - try testing.expectEqualStrings("3ABCD", actual); + const str = try s.dumpStringAlloc(alloc, .{ .active = .{} }); + defer alloc.free(str); + try testing.expectEqualStrings("world\ntest", str); } - try testing.expect(iter.next() == null); } -test "Screen: getLine soft wrap" { +test "Screen read and write no scrollback large" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 5, 5, 0); + var s = try Screen.init(alloc, 80, 2, 0); defer s.deinit(); - // Sanity check that our test helpers work - const str = "1ABCD2EFGH\n3ABCD"; - try s.testWriteString(str); - - // Test the line iterator - { - const line = s.getLine(.{ .x = 2, .y = 1 }).?; - const actual = try line.string(alloc); - defer alloc.free(actual); - try testing.expectEqualStrings("1ABCD2EFGH", actual); - } - { - const line = s.getLine(.{ .x = 2, .y = 2 }).?; - const actual = try line.string(alloc); - defer alloc.free(actual); - try testing.expectEqualStrings("3ABCD", actual); + for (0..1_000) |i| { + var buf: [128]u8 = undefined; + const str = try std.fmt.bufPrint(&buf, "{}\n", .{i}); + try s.testWriteString(str); } + try s.testWriteString("1000"); - try testing.expect(s.getLine(.{ .x = 2, .y = 3 }) == null); - try testing.expect(s.getLine(.{ .x = 7, .y = 1 }) == null); + { + const str = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); + defer alloc.free(str); + try testing.expectEqualStrings("999\n1000", str); + } +} + +test "Screen style basics" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try Screen.init(alloc, 80, 24, 1000); + defer s.deinit(); + const page = s.cursor.page_pin.page.data; + try testing.expectEqual(@as(usize, 0), page.styles.count(page.memory)); + + // Set a new style + try s.setAttribute(.{ .bold = {} }); + try testing.expect(s.cursor.style_id != 0); + try testing.expectEqual(@as(usize, 1), page.styles.count(page.memory)); + try testing.expect(s.cursor.style.flags.bold); + + // Set another style, we should still only have one since it was unused + try s.setAttribute(.{ .italic = {} }); + try testing.expect(s.cursor.style_id != 0); + try testing.expectEqual(@as(usize, 1), page.styles.count(page.memory)); + try testing.expect(s.cursor.style.flags.italic); +} + +test "Screen style reset to default" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try Screen.init(alloc, 80, 24, 1000); + defer s.deinit(); + const page = s.cursor.page_pin.page.data; + try testing.expectEqual(@as(usize, 0), page.styles.count(page.memory)); + + // Set a new style + try s.setAttribute(.{ .bold = {} }); + try testing.expect(s.cursor.style_id != 0); + try testing.expectEqual(@as(usize, 1), page.styles.count(page.memory)); + + // Reset to default + try s.setAttribute(.{ .reset_bold = {} }); + try testing.expect(s.cursor.style_id == 0); + try testing.expectEqual(@as(usize, 0), page.styles.count(page.memory)); +} + +test "Screen style reset with unset" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try Screen.init(alloc, 80, 24, 1000); + defer s.deinit(); + const page = s.cursor.page_pin.page.data; + try testing.expectEqual(@as(usize, 0), page.styles.count(page.memory)); + + // Set a new style + try s.setAttribute(.{ .bold = {} }); + try testing.expect(s.cursor.style_id != 0); + try testing.expectEqual(@as(usize, 1), page.styles.count(page.memory)); + + // Reset to default + try s.setAttribute(.{ .unset = {} }); + try testing.expect(s.cursor.style_id == 0); + try testing.expectEqual(@as(usize, 0), page.styles.count(page.memory)); +} + +test "Screen clearRows active one line" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try Screen.init(alloc, 80, 24, 1000); + defer s.deinit(); + + try s.testWriteString("hello, world"); + s.clearRows(.{ .active = .{} }, null, false); + const str = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); + defer alloc.free(str); + try testing.expectEqualStrings("", str); +} + +test "Screen clearRows active multi line" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try Screen.init(alloc, 80, 24, 1000); + defer s.deinit(); + + try s.testWriteString("hello\nworld"); + s.clearRows(.{ .active = .{} }, null, false); + const str = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); + defer alloc.free(str); + try testing.expectEqualStrings("", str); +} + +test "Screen clearRows active styled line" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try Screen.init(alloc, 80, 24, 1000); + defer s.deinit(); + + try s.setAttribute(.{ .bold = {} }); + try s.testWriteString("hello world"); + try s.setAttribute(.{ .unset = {} }); + + // We should have one style + const page = s.cursor.page_pin.page.data; + try testing.expectEqual(@as(usize, 1), page.styles.count(page.memory)); + + s.clearRows(.{ .active = .{} }, null, false); + + // We should have none because active cleared it + try testing.expectEqual(@as(usize, 0), page.styles.count(page.memory)); + + const str = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); + defer alloc.free(str); + try testing.expectEqualStrings("", str); +} + +test "Screen eraseRows history" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try Screen.init(alloc, 5, 5, 1000); + defer s.deinit(); + + try s.testWriteString("1\n2\n3\n4\n5\n6"); + + { + const str = try s.dumpStringAlloc(alloc, .{ .active = .{} }); + defer alloc.free(str); + try testing.expectEqualStrings("2\n3\n4\n5\n6", str); + } + { + const str = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); + defer alloc.free(str); + try testing.expectEqualStrings("1\n2\n3\n4\n5\n6", str); + } + + s.eraseRows(.{ .history = .{} }, null); + + { + const str = try s.dumpStringAlloc(alloc, .{ .active = .{} }); + defer alloc.free(str); + try testing.expectEqualStrings("2\n3\n4\n5\n6", str); + } + { + const str = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); + defer alloc.free(str); + try testing.expectEqualStrings("2\n3\n4\n5\n6", str); + } +} + +test "Screen eraseRows history with more lines" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try Screen.init(alloc, 5, 5, 1000); + defer s.deinit(); + + try s.testWriteString("A\nB\nC\n1\n2\n3\n4\n5\n6"); + + { + const str = try s.dumpStringAlloc(alloc, .{ .active = .{} }); + defer alloc.free(str); + try testing.expectEqualStrings("2\n3\n4\n5\n6", str); + } + { + const str = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); + defer alloc.free(str); + try testing.expectEqualStrings("A\nB\nC\n1\n2\n3\n4\n5\n6", str); + } + + s.eraseRows(.{ .history = .{} }, null); + + { + const str = try s.dumpStringAlloc(alloc, .{ .active = .{} }); + defer alloc.free(str); + try testing.expectEqualStrings("2\n3\n4\n5\n6", str); + } + { + const str = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); + defer alloc.free(str); + try testing.expectEqualStrings("2\n3\n4\n5\n6", str); + } } -// X test "Screen: scrolling" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 3, 5, 0); + var s = try init(alloc, 10, 3, 0); defer s.deinit(); - s.cursor.pen.bg = .{ .rgb = .{ .r = 155 } }; + try s.setAttribute(.{ .direct_color_bg = .{ .r = 155 } }); try s.testWriteString("1ABCD\n2EFGH\n3IJKL"); - try testing.expect(s.viewportIsBottom()); // Scroll down, should still be bottom - try s.scroll(.{ .screen = 1 }); - try testing.expect(s.viewportIsBottom()); - + try s.cursorDownScroll(); { - // Test our contents rotated - const contents = try s.testString(alloc, .viewport); + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); try testing.expectEqualStrings("2EFGH\n3IJKL", contents); } { - // Test that our new row has the correct background - const cell = s.getCell(.active, 2, 0); - try testing.expectEqual(@as(u8, 155), cell.bg.rgb.r); + const list_cell = s.pages.getCell(.{ .active = .{ .x = 0, .y = 2 } }).?; + const cell = list_cell.cell; + try testing.expect(cell.content_tag == .bg_color_rgb); + try testing.expectEqual(Cell.RGB{ + .r = 155, + .g = 0, + .b = 0, + }, cell.content.color_rgb); } // Scrolling to the bottom does nothing - try s.scroll(.{ .bottom = {} }); + s.scroll(.{ .active = {} }); { - // Test our contents rotated - const contents = try s.testString(alloc, .viewport); + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); try testing.expectEqualStrings("2EFGH\n3IJKL", contents); } } -// X test "Screen: scroll down from 0" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 3, 5, 0); + var s = try init(alloc, 10, 3, 0); defer s.deinit(); try s.testWriteString("1ABCD\n2EFGH\n3IJKL"); // Scrolling up does nothing, but allows it - try s.scroll(.{ .screen = -1 }); - try testing.expect(s.viewportIsBottom()); + s.scroll(.{ .delta_row = -1 }); + try testing.expect(s.pages.viewport == .active); { - // Test our contents rotated - const contents = try s.testString(alloc, .viewport); + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); try testing.expectEqualStrings("1ABCD\n2EFGH\n3IJKL", contents); } } -// X -test "Screen: scrollback" { +test "Screen: scrollback various cases" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 3, 5, 1); + var s = try init(alloc, 10, 3, 1); defer s.deinit(); try s.testWriteString("1ABCD\n2EFGH\n3IJKL"); - try s.scroll(.{ .screen = 1 }); + try s.cursorDownScroll(); { - // Test our contents rotated - const contents = try s.testString(alloc, .viewport); + const contents = try s.dumpStringAlloc(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()); - + s.scroll(.{ .active = {} }); { - // Test our contents rotated - const contents = try s.testString(alloc, .viewport); + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); try testing.expectEqualStrings("2EFGH\n3IJKL", contents); } // Scrolling back should make it visible again - try s.scroll(.{ .screen = -1 }); - try testing.expect(!s.viewportIsBottom()); - + s.scroll(.{ .delta_row = -1 }); + try testing.expect(s.pages.viewport != .active); { - // Test our contents rotated - const contents = try s.testString(alloc, .viewport); + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); try testing.expectEqualStrings("1ABCD\n2EFGH\n3IJKL", contents); } // Scrolling back again should do nothing - try s.scroll(.{ .screen = -1 }); - + s.scroll(.{ .delta_row = -1 }); { - // Test our contents rotated - const contents = try s.testString(alloc, .viewport); + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); try testing.expectEqualStrings("1ABCD\n2EFGH\n3IJKL", contents); } // Scrolling to the bottom - try s.scroll(.{ .bottom = {} }); - + s.scroll(.{ .active = {} }); { - // Test our contents rotated - const contents = try s.testString(alloc, .viewport); + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); try testing.expectEqualStrings("2EFGH\n3IJKL", contents); } // Scrolling forward with no grow should do nothing - try s.scroll(.{ .viewport = 1 }); - + s.scroll(.{ .delta_row = 1 }); { - // Test our contents rotated - const contents = try s.testString(alloc, .viewport); + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); try testing.expectEqualStrings("2EFGH\n3IJKL", contents); } // Scrolling to the top should work - try s.scroll(.{ .top = {} }); - + s.scroll(.{ .top = {} }); { - // Test our contents rotated - const contents = try s.testString(alloc, .viewport); + const contents = try s.dumpStringAlloc(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(.{}); + s.clearRows(.{ .active = .{} }, null, false); { - const contents = try s.testString(alloc, .viewport); + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); try testing.expectEqualStrings("1ABCD", contents); } // Scrolling to the bottom - try s.scroll(.{ .bottom = {} }); - + s.scroll(.{ .active = {} }); { - // Test our contents rotated - const contents = try s.testString(alloc, .viewport); + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); try testing.expectEqualStrings("", contents); } } -// X -test "Screen: scrollback with large delta" { +test "Screen: scrollback with multi-row delta" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 3, 5, 3); + var s = try init(alloc, 10, 3, 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 = {} }); + s.scroll(.{ .top = {} }); { - // Test our contents rotated - const contents = try s.testString(alloc, .viewport); + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); try testing.expectEqualStrings("1ABCD\n2EFGH\n3IJKL", contents); } - // Scroll down a ton - try s.scroll(.{ .viewport = 5 }); - try testing.expect(s.viewportIsBottom()); + // Scroll down multiple + s.scroll(.{ .delta_row = 5 }); + try testing.expect(s.pages.viewport == .active); { - // Test our contents rotated - const contents = try s.testString(alloc, .viewport); + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); try testing.expectEqualStrings("4ABCD\n5EFGH\n6IJKL", contents); } } -// X test "Screen: scrollback empty" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 3, 5, 50); + var s = try init(alloc, 10, 3, 50); defer s.deinit(); try s.testWriteString("1ABCD\n2EFGH\n3IJKL"); - try s.scroll(.{ .viewport = 1 }); - + s.scroll(.{ .delta_row = 1 }); { - // Test our contents - const contents = try s.testString(alloc, .viewport); + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); try testing.expectEqualStrings("1ABCD\n2EFGH\n3IJKL", contents); } } -// X test "Screen: scrollback doesn't move viewport if not at bottom" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 3, 5, 3); + var s = try init(alloc, 10, 3, 3); defer s.deinit(); try s.testWriteString("1ABCD\n2EFGH\n3IJKL\n4ABCD\n5EFGH"); // First test: we scroll up by 1, so we're not at the bottom anymore. - try s.scroll(.{ .screen = -1 }); - try testing.expect(!s.viewportIsBottom()); + s.scroll(.{ .delta_row = -1 }); { - const contents = try s.testString(alloc, .viewport); + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); try testing.expectEqualStrings("2EFGH\n3IJKL\n4ABCD", contents); } // Next, we scroll back down by 1, this grows the scrollback but we // shouldn't move. - try s.scroll(.{ .screen = 1 }); - try testing.expect(!s.viewportIsBottom()); + try s.cursorDownScroll(); { - const contents = try s.testString(alloc, .viewport); + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); try testing.expectEqualStrings("2EFGH\n3IJKL\n4ABCD", contents); } // Scroll again, this clears scrollback so we should move viewports // but still see the same thing since our original view fits. - try s.scroll(.{ .screen = 1 }); - try testing.expect(!s.viewportIsBottom()); + try s.cursorDownScroll(); { - const contents = try s.testString(alloc, .viewport); + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); try testing.expectEqualStrings("2EFGH\n3IJKL\n4ABCD", contents); } - - // Scroll again, this again goes into scrollback but is now deleting - // what we were looking at. We should see changes. - try s.scroll(.{ .screen = 1 }); - try testing.expect(!s.viewportIsBottom()); - { - const contents = try s.testString(alloc, .viewport); - defer alloc.free(contents); - try testing.expectEqualStrings("3IJKL\n4ABCD\n5EFGH", contents); - } } -test "Screen: scrolling moves selection" { - 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()); - - // Select a single line - s.selection = .{ - .start = .{ .x = 0, .y = 1 }, - .end = .{ .x = s.cols - 1, .y = 1 }, - }; - - // Scroll down, should still be bottom - try s.scroll(.{ .screen = 1 }); - try testing.expect(s.viewportIsBottom()); - - // Our selection should've moved up - try testing.expectEqual(Selection{ - .start = .{ .x = 0, .y = 0 }, - .end = .{ .x = s.cols - 1, .y = 0 }, - }, s.selection.?); - - { - // Test our contents rotated - const 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 = {} }); - - // Our selection should've stayed the same - try testing.expectEqual(Selection{ - .start = .{ .x = 0, .y = 0 }, - .end = .{ .x = s.cols - 1, .y = 0 }, - }, s.selection.?); - - { - // Test our contents rotated - const contents = try s.testString(alloc, .viewport); - defer alloc.free(contents); - try testing.expectEqualStrings("2EFGH\n3IJKL", contents); - } - - // Scroll up again - try s.scroll(.{ .screen = 1 }); - - // Our selection should be null because it left the screen. - try testing.expect(s.selection == null); -} - -test "Screen: scrolling with scrollback available doesn't move selection" { - 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 testing.expect(s.viewportIsBottom()); - - // Select a single line - s.selection = .{ - .start = .{ .x = 0, .y = 1 }, - .end = .{ .x = s.cols - 1, .y = 1 }, - }; - - // Scroll down, should still be bottom - try s.scroll(.{ .screen = 1 }); - try testing.expect(s.viewportIsBottom()); - - // Our selection should NOT move since we have scrollback - try testing.expectEqual(Selection{ - .start = .{ .x = 0, .y = 1 }, - .end = .{ .x = s.cols - 1, .y = 1 }, - }, s.selection.?); - - { - // Test our contents rotated - const 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(.{ .screen = -1 }); - try testing.expect(!s.viewportIsBottom()); - - // Our selection should NOT move since we have scrollback - try testing.expectEqual(Selection{ - .start = .{ .x = 0, .y = 1 }, - .end = .{ .x = s.cols - 1, .y = 1 }, - }, s.selection.?); - - { - // Test our contents rotated - const contents = try s.testString(alloc, .viewport); - defer alloc.free(contents); - try testing.expectEqualStrings("1ABCD\n2EFGH\n3IJKL", contents); - } - - // Scroll down, this sends us off the scrollback - try s.scroll(.{ .screen = 2 }); - - // Selection should be gone since we selected a line that went off. - try testing.expect(s.selection == null); - - { - // Test our contents rotated - const contents = try s.testString(alloc, .viewport); - defer alloc.free(contents); - try testing.expectEqualStrings("3IJKL", contents); - } -} - -// X test "Screen: scroll and clear full screen" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 3, 5, 5); + var s = try init(alloc, 10, 3, 5); defer s.deinit(); try s.testWriteString("1ABCD\n2EFGH\n3IJKL"); { - const contents = try s.testString(alloc, .viewport); + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); try testing.expectEqualStrings("1ABCD\n2EFGH\n3IJKL", contents); } - try s.scroll(.{ .clear = {} }); + try s.scrollClear(); { - const contents = try s.testString(alloc, .viewport); + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); try testing.expectEqualStrings("", contents); } { - const contents = try s.testString(alloc, .screen); + const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); defer alloc.free(contents); try testing.expectEqualStrings("1ABCD\n2EFGH\n3IJKL", contents); } } -// X test "Screen: scroll and clear partial screen" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 3, 5, 5); + var s = try init(alloc, 10, 3, 5); defer s.deinit(); try s.testWriteString("1ABCD\n2EFGH"); { - const contents = try s.testString(alloc, .viewport); + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); try testing.expectEqualStrings("1ABCD\n2EFGH", contents); } - try s.scroll(.{ .clear = {} }); + try s.scrollClear(); { - const contents = try s.testString(alloc, .viewport); + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); try testing.expectEqualStrings("", contents); } { - const contents = try s.testString(alloc, .screen); + const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); defer alloc.free(contents); try testing.expectEqualStrings("1ABCD\n2EFGH", contents); } } -// X test "Screen: scroll and clear empty screen" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 3, 5, 5); + var s = try init(alloc, 10, 3, 5); defer s.deinit(); - try s.scroll(.{ .clear = {} }); - try testing.expectEqual(@as(usize, 0), s.viewport); + try s.scrollClear(); + { + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); + defer alloc.free(contents); + try testing.expectEqualStrings("", contents); + } + { + const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); + defer alloc.free(contents); + try testing.expectEqualStrings("", contents); + } } -// X test "Screen: scroll and clear ignore blank lines" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 3, 5, 10); + var s = try init(alloc, 10, 3, 10); defer s.deinit(); try s.testWriteString("1ABCD\n2EFGH"); - try s.scroll(.{ .clear = {} }); + try s.scrollClear(); { - const contents = try s.testString(alloc, .viewport); + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); try testing.expectEqualStrings("", contents); } // Move back to top-left - s.cursor.x = 0; - s.cursor.y = 0; + s.cursorAbsolute(0, 0); // Write and clear try s.testWriteString("3ABCD\n"); - try s.scroll(.{ .clear = {} }); { - const contents = try s.testString(alloc, .viewport); + const contents = try s.dumpStringAlloc(alloc, .{ .active = .{} }); + defer alloc.free(contents); + try testing.expectEqualStrings("3ABCD", contents); + } + + try s.scrollClear(); + { + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); try testing.expectEqualStrings("", contents); } // Move back to top-left - s.cursor.x = 0; - s.cursor.y = 0; + s.cursorAbsolute(0, 0); try s.testWriteString("X"); { - const contents = try s.testString(alloc, .screen); + const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); defer alloc.free(contents); try testing.expectEqualStrings("1ABCD\n2EFGH\n3ABCD\nX", contents); } } -// X - i don't think we need rowIterator -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); - { - const 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); -} - -// X - duplicated test above -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); - { - const contents = try s.testString(alloc, .viewport); - defer alloc.free(contents); - const expected = "3IJKL"; - try testing.expectEqualStrings(expected, contents); - } - { - // Test our contents - const contents = try s.testString(alloc, .screen); - defer alloc.free(contents); - try testing.expectEqualStrings("1ABCD\n2EFGH\n3IJKL", contents); - } - - { - const contents = try s.testString(alloc, .history); - defer alloc.free(contents); - const expected = "1ABCD\n2EFGH"; - try testing.expectEqualStrings(expected, contents); - } -} - -// X - don't need this, internal API -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(.{ .screen = 1 }); - try s.copyRow(.{ .active = 2 }, .{ .active = 0 }); - - // Test our contents - const contents = try s.testString(alloc, .viewport); - defer alloc.free(contents); - try testing.expectEqualStrings("2EFGH\n3IJKL\n2EFGH", contents); -} - -// X test "Screen: clone" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 3, 5, 0); + var s = try init(alloc, 10, 3, 10); + defer s.deinit(); + try s.testWriteString("1ABCD\n2EFGH"); + { + const contents = try s.dumpStringAlloc(alloc, .{ .active = .{} }); + defer alloc.free(contents); + try testing.expectEqualStrings("1ABCD\n2EFGH", contents); + } + + // Clone + var s2 = try s.clone(alloc, .{ .active = .{} }, null); + defer s2.deinit(); + { + const contents = try s2.dumpStringAlloc(alloc, .{ .active = .{} }); + defer alloc.free(contents); + try testing.expectEqualStrings("1ABCD\n2EFGH", contents); + } + + // Write to s1, should not be in s2 + try s.testWriteString("\n34567"); + { + const contents = try s.dumpStringAlloc(alloc, .{ .active = .{} }); + defer alloc.free(contents); + try testing.expectEqualStrings("1ABCD\n2EFGH\n34567", contents); + } + { + const contents = try s2.dumpStringAlloc(alloc, .{ .active = .{} }); + defer alloc.free(contents); + try testing.expectEqualStrings("1ABCD\n2EFGH", contents); + } +} + +test "Screen: clone partial" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 10, 3, 10); + defer s.deinit(); + try s.testWriteString("1ABCD\n2EFGH"); + { + const contents = try s.dumpStringAlloc(alloc, .{ .active = .{} }); + defer alloc.free(contents); + try testing.expectEqualStrings("1ABCD\n2EFGH", contents); + } + + // Clone + var s2 = try s.clone(alloc, .{ .active = .{ .y = 1 } }, null); + defer s2.deinit(); + { + const contents = try s2.dumpStringAlloc(alloc, .{ .active = .{} }); + defer alloc.free(contents); + try testing.expectEqualStrings("2EFGH", contents); + } +} + +test "Screen: clone basic" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 10, 3, 0); defer s.deinit(); try s.testWriteString("1ABCD\n2EFGH\n3IJKL"); - try testing.expect(s.viewportIsBottom()); { - var s2 = try s.clone(alloc, .{ .active = 1 }, .{ .active = 1 }); + var s2 = try s.clone( + alloc, + .{ .active = .{ .y = 1 } }, + .{ .active = .{ .y = 1 } }, + ); defer s2.deinit(); // Test our contents rotated - const contents = try s2.testString(alloc, .viewport); + const contents = try s2.dumpStringAlloc(alloc, .{ .active = .{} }); defer alloc.free(contents); try testing.expectEqualStrings("2EFGH", contents); } { - var s2 = try s.clone(alloc, .{ .active = 1 }, .{ .active = 2 }); + var s2 = try s.clone( + alloc, + .{ .active = .{ .y = 1 } }, + .{ .active = .{ .y = 2 } }, + ); defer s2.deinit(); // Test our contents rotated - const contents = try s2.testString(alloc, .viewport); + const contents = try s2.dumpStringAlloc(alloc, .{ .active = .{} }); defer alloc.free(contents); try testing.expectEqualStrings("2EFGH\n3IJKL", contents); } } -// X test "Screen: clone empty viewport" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 3, 5, 0); + var s = try init(alloc, 10, 3, 0); defer s.deinit(); { - var s2 = try s.clone(alloc, .{ .viewport = 0 }, .{ .viewport = 0 }); + var s2 = try s.clone( + alloc, + .{ .viewport = .{ .y = 0 } }, + .{ .viewport = .{ .y = 0 } }, + ); defer s2.deinit(); // Test our contents rotated - const contents = try s2.testString(alloc, .viewport); + const contents = try s2.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); try testing.expectEqualStrings("", contents); } } -// X test "Screen: clone one line viewport" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 3, 5, 0); + var s = try init(alloc, 10, 3, 0); defer s.deinit(); try s.testWriteString("1ABC"); { - var s2 = try s.clone(alloc, .{ .viewport = 0 }, .{ .viewport = 0 }); + var s2 = try s.clone( + alloc, + .{ .viewport = .{ .y = 0 } }, + .{ .viewport = .{ .y = 0 } }, + ); defer s2.deinit(); // Test our contents - const contents = try s2.testString(alloc, .viewport); + const contents = try s2.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); try testing.expectEqualStrings("1ABC", contents); } } -// X test "Screen: clone empty active" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 3, 5, 0); + var s = try init(alloc, 10, 3, 0); defer s.deinit(); { - var s2 = try s.clone(alloc, .{ .active = 0 }, .{ .active = 0 }); + var s2 = try s.clone( + alloc, + .{ .active = .{ .y = 0 } }, + .{ .active = .{ .y = 0 } }, + ); defer s2.deinit(); // Test our contents rotated - const contents = try s2.testString(alloc, .active); + const contents = try s2.dumpStringAlloc(alloc, .{ .active = .{} }); defer alloc.free(contents); try testing.expectEqualStrings("", contents); } } -// X test "Screen: clone one line active with extra space" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 3, 5, 0); + var s = try init(alloc, 10, 3, 0); defer s.deinit(); try s.testWriteString("1ABC"); - // Should have 1 line written - try testing.expectEqual(@as(usize, 1), s.rowsWritten()); - { - var s2 = try s.clone(alloc, .{ .active = 0 }, .{ .active = s.rows - 1 }); + var s2 = try s.clone( + alloc, + .{ .active = .{ .y = 0 } }, + null, + ); defer s2.deinit(); // Test our contents rotated - const contents = try s2.testString(alloc, .active); + const contents = try s2.dumpStringAlloc(alloc, .{ .active = .{} }); defer alloc.free(contents); try testing.expectEqualStrings("1ABC", contents); } - - // Should still have no history. A bug was that we were generating history - // in this case which is not good! This was causing resizes to have all - // sorts of problems. - try testing.expectEqual(@as(usize, 1), s.rowsWritten()); } -// X -test "Screen: selectLine" { +test "Screen: clear history with no history" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 10, 10, 0); + var s = try init(alloc, 10, 3, 3); defer s.deinit(); - try s.testWriteString("ABC DEF\n 123\n456"); - - // Outside of active area - try testing.expect(s.selectLine(.{ .x = 13, .y = 0 }) == null); - try testing.expect(s.selectLine(.{ .x = 0, .y = 5 }) == null); - - // Going forward + try s.testWriteString("4ABCD\n5EFGH\n6IJKL"); + try testing.expect(s.pages.viewport == .active); + s.eraseRows(.{ .history = .{} }, null); + try testing.expect(s.pages.viewport == .active); { - const sel = s.selectLine(.{ .x = 0, .y = 0 }).?; - try testing.expectEqual(@as(usize, 0), sel.start.x); - try testing.expectEqual(@as(usize, 0), sel.start.y); - try testing.expectEqual(@as(usize, 7), sel.end.x); - try testing.expectEqual(@as(usize, 0), sel.end.y); + // Test our contents rotated + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); + defer alloc.free(contents); + try testing.expectEqualStrings("4ABCD\n5EFGH\n6IJKL", contents); } - - // Going backward { - const sel = s.selectLine(.{ .x = 7, .y = 0 }).?; - try testing.expectEqual(@as(usize, 0), sel.start.x); - try testing.expectEqual(@as(usize, 0), sel.start.y); - try testing.expectEqual(@as(usize, 7), sel.end.x); - try testing.expectEqual(@as(usize, 0), sel.end.y); - } - - // Going forward and backward - { - const sel = s.selectLine(.{ .x = 3, .y = 0 }).?; - try testing.expectEqual(@as(usize, 0), sel.start.x); - try testing.expectEqual(@as(usize, 0), sel.start.y); - try testing.expectEqual(@as(usize, 7), sel.end.x); - try testing.expectEqual(@as(usize, 0), sel.end.y); - } - - // Outside active area - { - const sel = s.selectLine(.{ .x = 9, .y = 0 }).?; - try testing.expectEqual(@as(usize, 0), sel.start.x); - try testing.expectEqual(@as(usize, 0), sel.start.y); - try testing.expectEqual(@as(usize, 7), sel.end.x); - try testing.expectEqual(@as(usize, 0), sel.end.y); + // Test our contents rotated + const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); + defer alloc.free(contents); + try testing.expectEqualStrings("4ABCD\n5EFGH\n6IJKL", contents); + } +} + +test "Screen: clear history" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 10, 3, 3); + defer s.deinit(); + try s.testWriteString("1ABCD\n2EFGH\n3IJKL\n4ABCD\n5EFGH\n6IJKL"); + try testing.expect(s.pages.viewport == .active); + + // Scroll to top + s.scroll(.{ .top = {} }); + { + // Test our contents rotated + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); + defer alloc.free(contents); + try testing.expectEqualStrings("1ABCD\n2EFGH\n3IJKL", contents); + } + + s.eraseRows(.{ .history = .{} }, null); + try testing.expect(s.pages.viewport == .active); + { + // Test our contents rotated + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); + defer alloc.free(contents); + try testing.expectEqualStrings("4ABCD\n5EFGH\n6IJKL", contents); + } + { + // Test our contents rotated + const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); + defer alloc.free(contents); + try testing.expectEqualStrings("4ABCD\n5EFGH\n6IJKL", contents); + } +} + +test "Screen: clear above cursor" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 10, 10, 3); + defer s.deinit(); + try s.testWriteString("4ABCD\n5EFGH\n6IJKL"); + s.clearRows( + .{ .active = .{ .y = 0 } }, + .{ .active = .{ .y = s.cursor.y - 1 } }, + false, + ); + { + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); + defer alloc.free(contents); + try testing.expectEqualStrings("\n\n6IJKL", contents); + } + { + const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); + defer alloc.free(contents); + try testing.expectEqualStrings("\n\n6IJKL", contents); + } + + try testing.expectEqual(@as(usize, 5), s.cursor.x); + try testing.expectEqual(@as(usize, 2), s.cursor.y); +} + +test "Screen: clear above cursor with history" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 10, 3, 3); + defer s.deinit(); + try s.testWriteString("1ABCD\n2EFGH\n3IJKL\n"); + try s.testWriteString("4ABCD\n5EFGH\n6IJKL"); + s.clearRows( + .{ .active = .{ .y = 0 } }, + .{ .active = .{ .y = s.cursor.y - 1 } }, + false, + ); + { + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); + defer alloc.free(contents); + try testing.expectEqualStrings("\n\n6IJKL", contents); + } + { + const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); + defer alloc.free(contents); + try testing.expectEqualStrings("1ABCD\n2EFGH\n3IJKL\n\n\n6IJKL", contents); + } + + try testing.expectEqual(@as(usize, 5), s.cursor.x); + try testing.expectEqual(@as(usize, 2), s.cursor.y); +} + +test "Screen: resize (no reflow) more rows" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 10, 3, 0); + defer s.deinit(); + const str = "1ABCD\n2EFGH\n3IJKL"; + try s.testWriteString(str); + + // Resize + try s.resizeWithoutReflow(10, 10); + { + const contents = try s.dumpStringAlloc(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, 10, 3, 0); + defer s.deinit(); + const str = "1ABCD\n2EFGH\n3IJKL"; + try s.testWriteString(str); + try testing.expectEqual(5, s.cursor.x); + try testing.expectEqual(2, s.cursor.y); + try s.resizeWithoutReflow(10, 2); + + // Since we shrunk, we should adjust our cursor + try testing.expectEqual(5, s.cursor.x); + try testing.expectEqual(1, s.cursor.y); + + { + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); + defer alloc.free(contents); + try testing.expectEqualStrings("2EFGH\n3IJKL", contents); + } +} + +test "Screen: resize (no reflow) less rows trims blank lines" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 10, 3, 0); + defer s.deinit(); + const str = "1ABCD"; + try s.testWriteString(str); + + // Write only a background color into the remaining rows + for (1..s.pages.rows) |y| { + const list_cell = s.pages.getCell(.{ .active = .{ .x = 0, .y = y } }).?; + list_cell.cell.* = .{ + .content_tag = .bg_color_rgb, + .content = .{ .color_rgb = .{ .r = 0xFF, .g = 0, .b = 0 } }, + }; + } + + const cursor = s.cursor; + try s.resizeWithoutReflow(6, 2); + + // Cursor should not move + try testing.expectEqual(cursor.x, s.cursor.x); + try testing.expectEqual(cursor.y, s.cursor.y); + + { + const contents = try s.dumpStringAlloc(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, 10, 3, 0); + defer s.deinit(); + const str = "1ABCD"; + try s.testWriteString(str); + + // Write only a background color into the remaining rows + for (1..s.pages.rows) |y| { + const list_cell = s.pages.getCell(.{ .active = .{ .x = 0, .y = y } }).?; + list_cell.cell.* = .{ + .content_tag = .bg_color_rgb, + .content = .{ .color_rgb = .{ .r = 0xFF, .g = 0, .b = 0 } }, + }; + } + + const cursor = s.cursor; + try s.resizeWithoutReflow(10, 7); + + // Cursor should not move + try testing.expectEqual(cursor.x, s.cursor.x); + try testing.expectEqual(cursor.y, s.cursor.y); + + { + const contents = try s.dumpStringAlloc(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; + + var s = try init(alloc, 10, 3, 0); + defer s.deinit(); + const str = "1ABCD\n2EFGH\n3IJKL"; + try s.testWriteString(str); + try s.resizeWithoutReflow(20, 3); + + { + const contents = try s.dumpStringAlloc(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, 10, 3, 0); + defer s.deinit(); + const str = "1ABCD\n2EFGH\n3IJKL"; + try s.testWriteString(str); + try s.resizeWithoutReflow(4, 3); + + { + const contents = try s.dumpStringAlloc(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 cursor end" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 7, 3, 2); + defer s.deinit(); + const str = "1ABCD\n2EFGH\n3IJKL\n4ABCD\n5EFGH"; + try s.testWriteString(str); + try s.resizeWithoutReflow(7, 10); + + { + const contents = try s.dumpStringAlloc(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, 7, 3, 2); + defer s.deinit(); + const str = "1ABCD\n2EFGH\n3IJKL\n4ABCD\n5EFGH"; + try s.testWriteString(str); + try s.resizeWithoutReflow(7, 2); + + { + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); + defer alloc.free(contents); + const expected = "4ABCD\n5EFGH"; + try testing.expectEqualStrings(expected, contents); + } +} + +// https://github.com/mitchellh/ghostty/issues/1030 +test "Screen: resize (no reflow) less rows with empty trailing" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 5, 3, 5); + defer s.deinit(); + const str = "1\n2\n3\n4\n5\n6\n7\n8"; + try s.testWriteString(str); + try s.scrollClear(); + s.cursorAbsolute(0, 0); + try s.testWriteString("A\nB"); + + const cursor = s.cursor; + try s.resizeWithoutReflow(5, 2); + try testing.expectEqual(cursor.x, s.cursor.x); + try testing.expectEqual(cursor.y, s.cursor.y); + + { + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); + defer alloc.free(contents); + try testing.expectEqualStrings("A\nB", contents); + } +} + +test "Screen: resize (no reflow) more rows with soft wrapping" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 2, 3, 3); + defer s.deinit(); + const str = "1A2B\n3C4E\n5F6G"; + try s.testWriteString(str); + + // Every second row should be wrapped + for (0..6) |y| { + const list_cell = s.pages.getCell(.{ .screen = .{ .x = 0, .y = y } }).?; + const row = list_cell.row; + const wrapped = (y % 2 == 0); + try testing.expectEqual(wrapped, row.wrap); + } + + // Resize + try s.resizeWithoutReflow(2, 10); + { + const contents = try s.dumpStringAlloc(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 + for (0..6) |y| { + const list_cell = s.pages.getCell(.{ .screen = .{ .x = 0, .y = y } }).?; + const row = list_cell.row; + const wrapped = (y % 2 == 0); + try testing.expectEqual(wrapped, row.wrap); + } +} + +test "Screen: resize more rows no scrollback" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 5, 3, 0); + defer s.deinit(); + const str = "1ABCD\n2EFGH\n3IJKL"; + try s.testWriteString(str); + const cursor = s.cursor; + try s.resize(5, 10); + + // Cursor should not move + try testing.expectEqual(cursor.x, s.cursor.x); + try testing.expectEqual(cursor.y, s.cursor.y); + + { + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); + defer alloc.free(contents); + try testing.expectEqualStrings(str, contents); + } + { + const contents = try s.dumpStringAlloc(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, 5, 3, 10); + defer s.deinit(); + const str = "1ABCD\n2EFGH\n3IJKL"; + try s.testWriteString(str); + const cursor = s.cursor; + try s.resize(5, 10); + + // Cursor should not move + try testing.expectEqual(cursor.x, s.cursor.x); + try testing.expectEqual(cursor.y, s.cursor.y); + + { + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); + defer alloc.free(contents); + try testing.expectEqualStrings(str, contents); + } + { + const contents = try s.dumpStringAlloc(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, 5, 3, 5); + defer s.deinit(); + const str = "1ABCD\n2EFGH\n3IJKL\n4ABCD\n5EFGH"; + try s.testWriteString(str); + { + const contents = try s.dumpStringAlloc(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.cursorAbsolute(0, 1); + { + const list_cell = s.pages.getCell(.{ .active = .{ + .x = s.cursor.x, + .y = s.cursor.y, + } }).?; + try testing.expectEqual(@as(u21, '4'), list_cell.cell.content.codepoint); + } + + // Resize + try s.resize(5, 10); + + // Cursor should still be on the "4" + { + const list_cell = s.pages.getCell(.{ .active = .{ + .x = s.cursor.x, + .y = s.cursor.y, + } }).?; + try testing.expectEqual(@as(u21, '4'), list_cell.cell.content.codepoint); + } + + { + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); + defer alloc.free(contents); + const expected = "3IJKL\n4ABCD\n5EFGH"; + try testing.expectEqualStrings(expected, contents); + } +} + +test "Screen: resize more cols no reflow" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 5, 3, 0); + defer s.deinit(); + const str = "1ABCD\n2EFGH\n3IJKL"; + try s.testWriteString(str); + + const cursor = s.cursor; + try s.resize(10, 3); + + // Cursor should not move + try testing.expectEqual(cursor.x, s.cursor.x); + try testing.expectEqual(cursor.y, s.cursor.y); + + { + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); + defer alloc.free(contents); + try testing.expectEqualStrings(str, contents); + } + { + const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); + defer alloc.free(contents); + try testing.expectEqualStrings(str, contents); + } +} + +// https://github.com/mitchellh/ghostty/issues/272#issuecomment-1676038963 +test "Screen: resize more cols perfect split" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 5, 3, 0); + defer s.deinit(); + const str = "1ABCD2EFGH3IJKL"; + try s.testWriteString(str); + try s.resize(10, 3); + + { + const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); + defer alloc.free(contents); + try testing.expectEqualStrings("1ABCD2EFGH\n3IJKL", contents); + } +} + +// https://github.com/mitchellh/ghostty/issues/1159 +test "Screen: resize (no reflow) more cols with scrollback scrolled up" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 5, 3, 5); + defer s.deinit(); + const str = "1\n2\n3\n4\n5\n6\n7\n8"; + try s.testWriteString(str); + + // Cursor at bottom + try testing.expectEqual(@as(size.CellCountInt, 1), s.cursor.x); + try testing.expectEqual(@as(size.CellCountInt, 2), s.cursor.y); + + s.scroll(.{ .delta_row = -4 }); + { + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); + defer alloc.free(contents); + try testing.expectEqualStrings("2\n3\n4", contents); + } + + try s.resize(8, 3); + { + const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); + defer alloc.free(contents); + try testing.expectEqualStrings(str, contents); + } + + // Cursor remains at bottom + try testing.expectEqual(@as(size.CellCountInt, 1), s.cursor.x); + try testing.expectEqual(@as(size.CellCountInt, 2), s.cursor.y); +} + +// https://github.com/mitchellh/ghostty/issues/1159 +test "Screen: resize (no reflow) less cols with scrollback scrolled up" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 5, 3, 5); + defer s.deinit(); + const str = "1\n2\n3\n4\n5\n6\n7\n8"; + try s.testWriteString(str); + + // Cursor at bottom + try testing.expectEqual(@as(size.CellCountInt, 1), s.cursor.x); + try testing.expectEqual(@as(size.CellCountInt, 2), s.cursor.y); + + s.scroll(.{ .delta_row = -4 }); + { + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); + defer alloc.free(contents); + try testing.expectEqualStrings("2\n3\n4", contents); + } + + try s.resize(4, 3); + { + const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); + defer alloc.free(contents); + try testing.expectEqualStrings(str, contents); + } + { + const contents = try s.dumpStringAlloc(alloc, .{ .active = .{} }); + defer alloc.free(contents); + try testing.expectEqualStrings("6\n7\n8", contents); + } + + // Cursor remains at bottom + try testing.expectEqual(@as(size.CellCountInt, 1), s.cursor.x); + try testing.expectEqual(@as(size.CellCountInt, 2), s.cursor.y); + + // Old implementation doesn't do this but it makes sense to me: + // { + // const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); + // defer alloc.free(contents); + // try testing.expectEqualStrings("2\n3\n4", contents); + // } +} + +test "Screen: resize more cols no reflow preserves semantic prompt" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 5, 3, 0); + defer s.deinit(); + const str = "1ABCD\n2EFGH\n3IJKL"; + try s.testWriteString(str); + + // Set one of the rows to be a prompt + { + s.cursorAbsolute(0, 1); + s.cursor.page_row.semantic_prompt = .prompt; + } + + try s.resize(10, 3); + + { + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); + defer alloc.free(contents); + try testing.expectEqualStrings(str, contents); + } + { + const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); + defer alloc.free(contents); + try testing.expectEqualStrings(str, contents); + } + + // Our one row should still be a semantic prompt, the others should not. + { + const list_cell = s.pages.getCell(.{ .active = .{ .x = 0, .y = 0 } }).?; + try testing.expect(list_cell.row.semantic_prompt == .unknown); + } + { + const list_cell = s.pages.getCell(.{ .active = .{ .x = 0, .y = 1 } }).?; + try testing.expect(list_cell.row.semantic_prompt == .prompt); + } + { + const list_cell = s.pages.getCell(.{ .active = .{ .x = 0, .y = 2 } }).?; + try testing.expect(list_cell.row.semantic_prompt == .unknown); + } +} + +test "Screen: resize more cols with reflow that fits full width" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 5, 3, 0); + defer s.deinit(); + const str = "1ABCD2EFGH\n3IJKL"; + try s.testWriteString(str); + + // Verify we soft wrapped + { + const contents = try s.dumpStringAlloc(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.cursorAbsolute(0, 1); + { + const list_cell = s.pages.getCell(.{ .active = .{ + .x = s.cursor.x, + .y = s.cursor.y, + } }).?; + try testing.expectEqual(@as(u21, '2'), list_cell.cell.content.codepoint); + } + + // Resize and verify we undid the soft wrap because we have space now + try s.resize(10, 3); + { + const contents = try s.dumpStringAlloc(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, 6, 3, 0); + defer s.deinit(); + const str = "1ABCD2EFGH\n3IJKL"; + try s.testWriteString(str); + + // Verify we soft wrapped + { + const contents = try s.dumpStringAlloc(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.cursorAbsolute(0, 2); + { + const list_cell = s.pages.getCell(.{ .active = .{ + .x = s.cursor.x, + .y = s.cursor.y, + } }).?; + try testing.expectEqual(@as(u21, '3'), list_cell.cell.content.codepoint); + } + + // Resize and verify we undid the soft wrap because we have space now + try s.resize(10, 3); + { + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); + defer alloc.free(contents); + try testing.expectEqualStrings(str, contents); + } + + // Our cursor should still be on the 3 + { + const list_cell = s.pages.getCell(.{ .active = .{ + .x = s.cursor.x, + .y = s.cursor.y, + } }).?; + try testing.expectEqual(@as(u21, '3'), list_cell.cell.content.codepoint); + } +} + +test "Screen: resize more cols with reflow that forces more wrapping" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 5, 3, 0); + defer s.deinit(); + const str = "1ABCD2EFGH\n3IJKL"; + try s.testWriteString(str); + + // Let's put our cursor on row 2, where the soft wrap is + s.cursorAbsolute(0, 1); + { + const list_cell = s.pages.getCell(.{ .active = .{ + .x = s.cursor.x, + .y = s.cursor.y, + } }).?; + try testing.expectEqual(@as(u21, '2'), list_cell.cell.content.codepoint); + } + + // Verify we soft wrapped + { + const contents = try s.dumpStringAlloc(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(7, 3); + { + const contents = try s.dumpStringAlloc(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(size.CellCountInt, 5), s.cursor.x); + try testing.expectEqual(@as(size.CellCountInt, 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, 5, 3, 0); + defer s.deinit(); + const str = "1ABCD2EFGH3IJKL"; + try s.testWriteString(str); + + // Let's put our cursor on row 2, where the soft wrap is + s.cursorAbsolute(0, 2); + { + const list_cell = s.pages.getCell(.{ .active = .{ + .x = s.cursor.x, + .y = s.cursor.y, + } }).?; + try testing.expectEqual(@as(u21, '3'), list_cell.cell.content.codepoint); + } + + // Verify we soft wrapped + { + const contents = try s.dumpStringAlloc(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(15, 3); + { + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); + defer alloc.free(contents); + const expected = "1ABCD2EFGH3IJKL"; + try testing.expectEqualStrings(expected, contents); + } + + // Our cursor should've moved + try testing.expectEqual(@as(size.CellCountInt, 10), s.cursor.x); + try testing.expectEqual(@as(size.CellCountInt, 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, 5, 3, 5); + defer s.deinit(); + const str = "1ABCD\n2EFGH\n3IJKL\n4ABCD5EFGH"; + try s.testWriteString(str); + { + const contents = try s.dumpStringAlloc(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.cursorAbsolute(0, 2); + { + const list_cell = s.pages.getCell(.{ .active = .{ + .x = s.cursor.x, + .y = s.cursor.y, + } }).?; + try testing.expectEqual(@as(u21, '5'), list_cell.cell.content.codepoint); + } + + // Resize + try s.resize(10, 3); + { + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); + defer alloc.free(contents); + const expected = "2EFGH\n3IJKL\n4ABCD5EFGH"; + try testing.expectEqualStrings(expected, contents); + } + + // Cursor should still be on the "5" + { + const list_cell = s.pages.getCell(.{ .active = .{ + .x = s.cursor.x, + .y = s.cursor.y, + } }).?; + try testing.expectEqual(@as(u21, '5'), list_cell.cell.content.codepoint); + } +} + +test "Screen: resize more cols with reflow" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 2, 3, 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.cursorAbsolute(0, 2); + { + const list_cell = s.pages.getCell(.{ .active = .{ + .x = s.cursor.x, + .y = s.cursor.y, + } }).?; + try testing.expectEqual(@as(u32, 'E'), list_cell.cell.content.codepoint); + } + + // Verify we soft wrapped + { + const contents = try s.dumpStringAlloc(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(7, 3); + { + const contents = try s.dumpStringAlloc(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(size.CellCountInt, 2), s.cursor.x); + try testing.expectEqual(@as(size.CellCountInt, 2), s.cursor.y); +} + +test "Screen: resize more rows and cols with wrapping" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 2, 4, 0); + defer s.deinit(); + const str = "1A2B\n3C4D"; + try s.testWriteString(str); + { + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); + defer alloc.free(contents); + const expected = "1A\n2B\n3C\n4D"; + try testing.expectEqualStrings(expected, contents); + } + + try s.resize(5, 10); + + // Cursor should move due to wrapping + try testing.expectEqual(@as(size.CellCountInt, 3), s.cursor.x); + try testing.expectEqual(@as(size.CellCountInt, 1), s.cursor.y); + + { + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); + defer alloc.free(contents); + try testing.expectEqualStrings(str, contents); + } + { + const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); + defer alloc.free(contents); + try testing.expectEqualStrings(str, contents); + } +} + +test "Screen: resize less rows no scrollback" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 5, 3, 0); + defer s.deinit(); + const str = "1ABCD\n2EFGH\n3IJKL"; + try s.testWriteString(str); + + s.cursorAbsolute(0, 0); + const cursor = s.cursor; + try s.resize(5, 1); + + // Cursor should not move + try testing.expectEqual(cursor.x, s.cursor.x); + try testing.expectEqual(cursor.y, s.cursor.y); + + { + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); + defer alloc.free(contents); + const expected = "3IJKL"; + try testing.expectEqualStrings(expected, contents); + } + { + const contents = try s.dumpStringAlloc(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, 5, 3, 0); + defer s.deinit(); + const str = "1ABCD\n2EFGH\n3IJKL"; + try s.testWriteString(str); + + // Put our cursor on the last line + s.cursorAbsolute(1, 2); + { + const list_cell = s.pages.getCell(.{ .active = .{ + .x = s.cursor.x, + .y = s.cursor.y, + } }).?; + try testing.expectEqual(@as(u32, 'I'), list_cell.cell.content.codepoint); + } + + // Resize + try s.resize(5, 1); + + { + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); + defer alloc.free(contents); + const expected = "3IJKL"; + try testing.expectEqualStrings(expected, contents); + } + { + const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); + defer alloc.free(contents); + const expected = "3IJKL"; + try testing.expectEqualStrings(expected, contents); + } + + // Cursor should be on the last line + try testing.expectEqual(@as(size.CellCountInt, 1), s.cursor.x); + try testing.expectEqual(@as(size.CellCountInt, 0), s.cursor.y); +} + +test "Screen: resize less rows with empty scrollback" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 5, 3, 10); + defer s.deinit(); + const str = "1ABCD\n2EFGH\n3IJKL"; + try s.testWriteString(str); + try s.resize(5, 1); + + { + const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); + defer alloc.free(contents); + try testing.expectEqualStrings(str, contents); + } + { + const contents = try s.dumpStringAlloc(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, 5, 3, 5); + defer s.deinit(); + const str = "1ABCD\n2EFGH\n3IJKL\n4ABCD\n5EFGH"; + try s.testWriteString(str); + { + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); + defer alloc.free(contents); + const expected = "3IJKL\n4ABCD\n5EFGH"; + try testing.expectEqualStrings(expected, contents); + } + + // Resize + try s.resize(5, 1); + + { + const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); + defer alloc.free(contents); + try testing.expectEqualStrings(str, contents); + } + { + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); + defer alloc.free(contents); + const expected = "5EFGH"; + try testing.expectEqualStrings(expected, contents); + } +} + +test "Screen: resize less rows with full scrollback" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 5, 3, 3); + defer s.deinit(); + const str = "00000\n1ABCD\n2EFGH\n3IJKL\n4ABCD\n5EFGH"; + try s.testWriteString(str); + { + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); + defer alloc.free(contents); + const expected = "3IJKL\n4ABCD\n5EFGH"; + try testing.expectEqualStrings(expected, contents); + } + + try testing.expectEqual(@as(size.CellCountInt, 4), s.cursor.x); + try testing.expectEqual(@as(size.CellCountInt, 2), s.cursor.y); + + // Resize + try s.resize(5, 2); + + // Cursor should stay in the same relative place (bottom of the + // screen, same character). + try testing.expectEqual(@as(size.CellCountInt, 4), s.cursor.x); + try testing.expectEqual(@as(size.CellCountInt, 1), s.cursor.y); + + { + const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); + defer alloc.free(contents); + const expected = "00000\n1ABCD\n2EFGH\n3IJKL\n4ABCD\n5EFGH"; + try testing.expectEqualStrings(expected, contents); + } + { + const contents = try s.dumpStringAlloc(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; + + var s = try init(alloc, 5, 3, 0); + defer s.deinit(); + const str = "1AB\n2EF\n3IJ"; + try s.testWriteString(str); + + s.cursorAbsolute(0, 0); + const cursor = s.cursor; + try s.resize(3, 3); + + // Cursor should not move + try testing.expectEqual(cursor.x, s.cursor.x); + try testing.expectEqual(cursor.y, s.cursor.y); + + { + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); + defer alloc.free(contents); + try testing.expectEqualStrings(str, contents); + } + { + const contents = try s.dumpStringAlloc(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, 5, 3, 1); + defer s.deinit(); + const str = "1ABCD"; + try s.testWriteString(str); + + // Put our cursor on the end + s.cursorAbsolute(4, 0); + { + const list_cell = s.pages.getCell(.{ .active = .{ + .x = s.cursor.x, + .y = s.cursor.y, + } }).?; + try testing.expectEqual(@as(u32, 'D'), list_cell.cell.content.codepoint); + } + + try s.resize(3, 3); + { + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); + defer alloc.free(contents); + const expected = "1AB\nCD"; + try testing.expectEqualStrings(expected, contents); + } + { + const contents = try s.dumpStringAlloc(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(size.CellCountInt, 1), s.cursor.x); + try testing.expectEqual(@as(size.CellCountInt, 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, 5, 3, 0); + defer s.deinit(); + const str = "3IJKL\n4ABCD\n5EFGH"; + try s.testWriteString(str); + try s.resize(3, 3); + + { + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); + defer alloc.free(contents); + const expected = "CD\n5EF\nGH"; + try testing.expectEqualStrings(expected, contents); + } + { + const contents = try s.dumpStringAlloc(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, 5, 3, 1); + defer s.deinit(); + const str = "3IJKL\n4ABCD\n5EFGH"; + try s.testWriteString(str); + try s.resize(3, 3); + + { + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); + defer alloc.free(contents); + const expected = "CD\n5EF\nGH"; + try testing.expectEqualStrings(expected, contents); + } + { + const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); + defer alloc.free(contents); + const expected = "3IJ\nKL\n4AB\nCD\n5EF\nGH"; + try testing.expectEqualStrings(expected, contents); + } +} + +test "Screen: resize less cols with reflow previously wrapped" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 5, 3, 0); + defer s.deinit(); + const str = "3IJKL4ABCD5EFGH"; + try s.testWriteString(str); + + // Check + { + const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); + defer alloc.free(contents); + const expected = "3IJKL\n4ABCD\n5EFGH"; + try testing.expectEqualStrings(expected, contents); + } + + try s.resize(3, 3); + + // { + // const contents = try s.testString(alloc, .viewport); + // defer alloc.free(contents); + // const expected = "CD\n5EF\nGH"; + // try testing.expectEqualStrings(expected, contents); + // } + { + const contents = try s.dumpStringAlloc(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, 5, 3, 5); + defer s.deinit(); + const str = "1A\n2B\n3C\n4D\n5E"; + try s.testWriteString(str); + + // Put our cursor on the end + s.cursorAbsolute(1, s.pages.rows - 1); + { + const list_cell = s.pages.getCell(.{ .active = .{ + .x = s.cursor.x, + .y = s.cursor.y, + } }).?; + try testing.expectEqual(@as(u32, 'E'), list_cell.cell.content.codepoint); + } + + try s.resize(3, 3); + + { + const contents = try s.dumpStringAlloc(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(size.CellCountInt, 1), s.cursor.x); + try testing.expectEqual(@as(size.CellCountInt, 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, 5, 3, 2); + defer s.deinit(); + const str = "1ABCD2EFGH3IJKL4ABCD5EFGH"; + try s.testWriteString(str); + + // Check + { + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); + defer alloc.free(contents); + const expected = "3IJKL\n4ABCD\n5EFGH"; + try testing.expectEqualStrings(expected, contents); + } + + // Put our cursor on the end + s.cursorAbsolute(s.pages.cols - 1, s.pages.rows - 1); + { + const list_cell = s.pages.getCell(.{ .active = .{ + .x = s.cursor.x, + .y = s.cursor.y, + } }).?; + try testing.expectEqual(@as(u32, 'H'), list_cell.cell.content.codepoint); + } + + try s.resize(3, 3); + + { + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); + defer alloc.free(contents); + const expected = "CD5\nEFG\nH"; + try testing.expectEqualStrings(expected, contents); + } + { + const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); + defer alloc.free(contents); + const expected = "1AB\nCD2\nEFG\nH3I\nJKL\n4AB\nCD5\nEFG\nH"; + try testing.expectEqualStrings(expected, contents); + } + + // Cursor should be on the last line + try testing.expectEqual(@as(size.CellCountInt, 0), s.cursor.x); + try testing.expectEqual(@as(size.CellCountInt, 2), s.cursor.y); + { + const list_cell = s.pages.getCell(.{ .active = .{ + .x = s.cursor.x, + .y = s.cursor.y, + } }).?; + try testing.expectEqual(@as(u32, 'H'), list_cell.cell.content.codepoint); + } +} + +test "Screen: resize less cols with scrollback keeps cursor row" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 5, 3, 5); + defer s.deinit(); + const str = "1A\n2B\n3C\n4D\n5E"; + try s.testWriteString(str); + + // Lets do a scroll and clear operation + try s.scrollClear(); + + // Move our cursor to the beginning + s.cursorAbsolute(0, 0); + + try s.resize(3, 3); + + { + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); + defer alloc.free(contents); + const expected = ""; + try testing.expectEqualStrings(expected, contents); + } + + // Cursor should be on the last line + try testing.expectEqual(@as(size.CellCountInt, 0), s.cursor.x); + try testing.expectEqual(@as(size.CellCountInt, 0), s.cursor.y); +} + +test "Screen: resize more rows, less cols with reflow with scrollback" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 5, 3, 3); + defer s.deinit(); + const str = "1ABCD\n2EFGH3IJKL\n4MNOP"; + try s.testWriteString(str); + + { + const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); + defer alloc.free(contents); + const expected = "1ABCD\n2EFGH\n3IJKL\n4MNOP"; + try testing.expectEqualStrings(expected, contents); + } + { + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); + defer alloc.free(contents); + const expected = "2EFGH\n3IJKL\n4MNOP"; + try testing.expectEqualStrings(expected, contents); + } + + try s.resize(2, 10); + + { + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); + defer alloc.free(contents); + const expected = "BC\nD\n2E\nFG\nH3\nIJ\nKL\n4M\nNO\nP"; + try testing.expectEqualStrings(expected, contents); + } + { + const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); + defer alloc.free(contents); + const expected = "1A\nBC\nD\n2E\nFG\nH3\nIJ\nKL\n4M\nNO\nP"; + 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, 5, 3, 10); + defer s.deinit(); + const str = "1ABC"; + try s.testWriteString(str); + + // Grow + try s.resize(5, 10); + { + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); + defer alloc.free(contents); + try testing.expectEqualStrings(str, contents); + } + { + const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); + defer alloc.free(contents); + try testing.expectEqualStrings(str, contents); + } + + // Shrink + try s.resize(5, 3); + { + const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); + defer alloc.free(contents); + try testing.expectEqualStrings(str, contents); + } + { + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); + defer alloc.free(contents); + try testing.expectEqualStrings(str, contents); + } + + // Grow again + try s.resize(5, 10); + { + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); + defer alloc.free(contents); + try testing.expectEqualStrings(str, contents); + } + { + const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); + defer alloc.free(contents); + try testing.expectEqualStrings(str, contents); + } +} + +test "Screen: resize less cols to eliminate wide char" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 2, 1, 0); + defer s.deinit(); + const str = "😀"; + try s.testWriteString(str); + { + const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); + defer alloc.free(contents); + try testing.expectEqualStrings(str, contents); + } + { + const list_cell = s.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; + const cell = list_cell.cell; + try testing.expectEqual(Cell.Wide.wide, cell.wide); + try testing.expectEqual(@as(u21, '😀'), cell.content.codepoint); + } + + // Resize to 1 column can't fit a wide char. So it should be deleted. + try s.resize(1, 1); + { + const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); + defer alloc.free(contents); + try testing.expectEqualStrings("", contents); + } + { + const list_cell = s.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; + const cell = list_cell.cell; + try testing.expectEqual(@as(u21, 0), cell.content.codepoint); + try testing.expectEqual(Cell.Wide.narrow, cell.wide); + } +} + +test "Screen: resize less cols to wrap wide char" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 3, 3, 0); + defer s.deinit(); + const str = "x😀"; + try s.testWriteString(str); + { + const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); + defer alloc.free(contents); + try testing.expectEqualStrings(str, contents); + } + { + const list_cell = s.pages.getCell(.{ .screen = .{ .x = 1, .y = 0 } }).?; + const cell = list_cell.cell; + try testing.expectEqual(Cell.Wide.wide, cell.wide); + try testing.expectEqual(@as(u21, '😀'), cell.content.codepoint); + } + { + const list_cell = s.pages.getCell(.{ .screen = .{ .x = 2, .y = 0 } }).?; + const cell = list_cell.cell; + try testing.expectEqual(Cell.Wide.spacer_tail, cell.wide); + } + + try s.resize(2, 3); + { + const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); + defer alloc.free(contents); + try testing.expectEqualStrings("x\n😀", contents); + } + { + const list_cell = s.pages.getCell(.{ .screen = .{ .x = 1, .y = 0 } }).?; + const cell = list_cell.cell; + try testing.expectEqual(Cell.Wide.spacer_head, cell.wide); + try testing.expect(list_cell.row.wrap); + } +} + +test "Screen: resize less cols to eliminate wide char with row space" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 2, 2, 0); + defer s.deinit(); + const str = "😀"; + try s.testWriteString(str); + { + const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); + defer alloc.free(contents); + try testing.expectEqualStrings(str, contents); + } + { + const list_cell = s.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; + const cell = list_cell.cell; + try testing.expectEqual(Cell.Wide.wide, cell.wide); + try testing.expectEqual(@as(u21, '😀'), cell.content.codepoint); + } + { + const list_cell = s.pages.getCell(.{ .screen = .{ .x = 1, .y = 0 } }).?; + const cell = list_cell.cell; + try testing.expectEqual(Cell.Wide.spacer_tail, cell.wide); + } + + try s.resize(1, 2); + { + const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); + defer alloc.free(contents); + try testing.expectEqualStrings("", contents); + } +} + +test "Screen: resize more cols with wide spacer head" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 3, 2, 0); + defer s.deinit(); + const str = " 😀"; + try s.testWriteString(str); + { + const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); + defer alloc.free(contents); + try testing.expectEqualStrings(" \n😀", contents); + } + + // So this is the key point: we end up with a wide spacer head at + // the end of row 1, then the emoji, then a wide spacer tail on row 2. + // We should expect that if we resize to more cols, the wide spacer + // head is replaced with the emoji. + { + const list_cell = s.pages.getCell(.{ .screen = .{ .x = 2, .y = 0 } }).?; + const cell = list_cell.cell; + try testing.expectEqual(Cell.Wide.spacer_head, cell.wide); + } + { + const list_cell = s.pages.getCell(.{ .screen = .{ .x = 0, .y = 1 } }).?; + const cell = list_cell.cell; + try testing.expectEqual(Cell.Wide.wide, cell.wide); + } + { + const list_cell = s.pages.getCell(.{ .screen = .{ .x = 1, .y = 1 } }).?; + const cell = list_cell.cell; + try testing.expectEqual(Cell.Wide.spacer_tail, cell.wide); + } + + try s.resize(4, 2); + { + const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); + defer alloc.free(contents); + try testing.expectEqualStrings(str, contents); + } + { + const list_cell = s.pages.getCell(.{ .screen = .{ .x = 2, .y = 0 } }).?; + const cell = list_cell.cell; + try testing.expectEqual(Cell.Wide.wide, cell.wide); + try testing.expectEqual(@as(u21, '😀'), cell.content.codepoint); + } + { + const list_cell = s.pages.getCell(.{ .screen = .{ .x = 3, .y = 0 } }).?; + const cell = list_cell.cell; + try testing.expectEqual(Cell.Wide.spacer_tail, cell.wide); + } +} + +test "Screen: resize more cols with wide spacer head multiple lines" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 3, 3, 0); + defer s.deinit(); + const str = "xxxyy😀"; + try s.testWriteString(str); + { + const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); + defer alloc.free(contents); + try testing.expectEqualStrings("xxx\nyy\n😀", contents); + } + + // Similar to the "wide spacer head" test, but this time we'er going + // to increase our columns such that multiple rows are unwrapped. + { + const list_cell = s.pages.getCell(.{ .screen = .{ .x = 2, .y = 1 } }).?; + const cell = list_cell.cell; + try testing.expectEqual(Cell.Wide.spacer_head, cell.wide); + } + { + const list_cell = s.pages.getCell(.{ .screen = .{ .x = 0, .y = 2 } }).?; + const cell = list_cell.cell; + try testing.expectEqual(Cell.Wide.wide, cell.wide); + } + { + const list_cell = s.pages.getCell(.{ .screen = .{ .x = 1, .y = 2 } }).?; + const cell = list_cell.cell; + try testing.expectEqual(Cell.Wide.spacer_tail, cell.wide); + } + + try s.resize(8, 2); + { + const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); + defer alloc.free(contents); + try testing.expectEqualStrings(str, contents); + } + { + const list_cell = s.pages.getCell(.{ .screen = .{ .x = 5, .y = 0 } }).?; + const cell = list_cell.cell; + try testing.expectEqual(Cell.Wide.wide, cell.wide); + try testing.expectEqual(@as(u21, '😀'), cell.content.codepoint); + } + { + const list_cell = s.pages.getCell(.{ .screen = .{ .x = 6, .y = 0 } }).?; + const cell = list_cell.cell; + try testing.expectEqual(Cell.Wide.spacer_tail, cell.wide); + } +} + +test "Screen: resize more cols requiring a wide spacer head" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 2, 2, 0); + defer s.deinit(); + const str = "xx😀"; + try s.testWriteString(str); + { + const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); + defer alloc.free(contents); + try testing.expectEqualStrings("xx\n😀", contents); + } + { + const list_cell = s.pages.getCell(.{ .screen = .{ .x = 0, .y = 1 } }).?; + const cell = list_cell.cell; + try testing.expectEqual(Cell.Wide.wide, cell.wide); + } + { + const list_cell = s.pages.getCell(.{ .screen = .{ .x = 1, .y = 1 } }).?; + const cell = list_cell.cell; + try testing.expectEqual(Cell.Wide.spacer_tail, cell.wide); + } + + // This resizes to 3 columns, which isn't enough space for our wide + // char to enter row 1. But we need to mark the wide spacer head on the + // end of the first row since we're wrapping to the next row. + try s.resize(3, 2); + { + const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); + defer alloc.free(contents); + try testing.expectEqualStrings("xx\n😀", contents); + } + { + const list_cell = s.pages.getCell(.{ .screen = .{ .x = 2, .y = 0 } }).?; + const cell = list_cell.cell; + try testing.expectEqual(Cell.Wide.spacer_head, cell.wide); + } + { + const list_cell = s.pages.getCell(.{ .screen = .{ .x = 0, .y = 1 } }).?; + const cell = list_cell.cell; + try testing.expectEqual(Cell.Wide.wide, cell.wide); + try testing.expectEqual(@as(u21, '😀'), cell.content.codepoint); + } + { + const list_cell = s.pages.getCell(.{ .screen = .{ .x = 1, .y = 1 } }).?; + const cell = list_cell.cell; + try testing.expectEqual(Cell.Wide.spacer_tail, cell.wide); } } -// X test "Screen: selectAll" { const testing = std.testing; const alloc = testing.allocator; @@ -4535,144 +4088,297 @@ test "Screen: selectAll" { { try s.testWriteString("ABC DEF\n 123\n456"); - const sel = s.selectAll().?; - try testing.expectEqual(@as(usize, 0), sel.start.x); - try testing.expectEqual(@as(usize, 0), sel.start.y); - try testing.expectEqual(@as(usize, 2), sel.end.x); - try testing.expectEqual(@as(usize, 2), sel.end.y); + var sel = s.selectAll().?; + defer sel.deinit(&s); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 0, + } }, s.pages.pointFromPin(.screen, sel.start()).?); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 2, + .y = 2, + } }, s.pages.pointFromPin(.screen, sel.end()).?); } { try s.testWriteString("\nFOO\n BAR\n BAZ\n QWERTY\n 12345678"); - const sel = s.selectAll().?; - try testing.expectEqual(@as(usize, 0), sel.start.x); - try testing.expectEqual(@as(usize, 0), sel.start.y); - try testing.expectEqual(@as(usize, 8), sel.end.x); - try testing.expectEqual(@as(usize, 7), sel.end.y); + var sel = s.selectAll().?; + defer sel.deinit(&s); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 0, + } }, s.pages.pointFromPin(.screen, sel.start()).?); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 8, + .y = 7, + } }, s.pages.pointFromPin(.screen, sel.end()).?); + } +} + +test "Screen: selectLine" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 10, 10, 0); + defer s.deinit(); + try s.testWriteString("ABC DEF\n 123\n456"); + + // Outside of active area + // try testing.expect(s.selectLine(.{ .x = 13, .y = 0 }) == null); + // try testing.expect(s.selectLine(.{ .x = 0, .y = 5 }) == null); + + // Going forward + { + var sel = s.selectLine(s.pages.pin(.{ .active = .{ + .x = 0, + .y = 0, + } }).?).?; + defer sel.deinit(&s); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 0, + } }, s.pages.pointFromPin(.screen, sel.start()).?); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 7, + .y = 0, + } }, s.pages.pointFromPin(.screen, sel.end()).?); + } + + // Going backward + { + var sel = s.selectLine(s.pages.pin(.{ .active = .{ + .x = 7, + .y = 0, + } }).?).?; + defer sel.deinit(&s); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 0, + } }, s.pages.pointFromPin(.screen, sel.start()).?); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 7, + .y = 0, + } }, s.pages.pointFromPin(.screen, sel.end()).?); + } + + // Going forward and backward + { + var sel = s.selectLine(s.pages.pin(.{ .active = .{ + .x = 3, + .y = 0, + } }).?).?; + defer sel.deinit(&s); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 0, + } }, s.pages.pointFromPin(.screen, sel.start()).?); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 7, + .y = 0, + } }, s.pages.pointFromPin(.screen, sel.end()).?); + } + + // Outside active area + { + var sel = s.selectLine(s.pages.pin(.{ .active = .{ + .x = 9, + .y = 0, + } }).?).?; + defer sel.deinit(&s); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 0, + } }, s.pages.pointFromPin(.screen, sel.start()).?); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 7, + .y = 0, + } }, s.pages.pointFromPin(.screen, sel.end()).?); } } -// X test "Screen: selectLine across soft-wrap" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 10, 5, 0); + var s = try init(alloc, 5, 10, 0); defer s.deinit(); try s.testWriteString(" 12 34012 \n 123"); // Going forward { - const sel = s.selectLine(.{ .x = 1, .y = 0 }).?; - try testing.expectEqual(@as(usize, 1), sel.start.x); - try testing.expectEqual(@as(usize, 0), sel.start.y); - try testing.expectEqual(@as(usize, 3), sel.end.x); - try testing.expectEqual(@as(usize, 1), sel.end.y); + var sel = s.selectLine(s.pages.pin(.{ .active = .{ + .x = 1, + .y = 0, + } }).?).?; + defer sel.deinit(&s); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 1, + .y = 0, + } }, s.pages.pointFromPin(.screen, sel.start()).?); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 3, + .y = 1, + } }, s.pages.pointFromPin(.screen, sel.end()).?); } } -// X -// https://github.com/mitchellh/ghostty/issues/1329 -test "Screen: selectLine semantic prompt boundary" { - const testing = std.testing; - const alloc = testing.allocator; - - var s = try init(alloc, 10, 5, 0); - defer s.deinit(); - try s.testWriteString("ABCDE\nA > "); - - { - const contents = try s.testString(alloc, .screen); - defer alloc.free(contents); - try testing.expectEqualStrings("ABCDE\nA \n> ", contents); - } - - var row = s.getRow(.{ .screen = 2 }); - row.setSemanticPrompt(.prompt); - - // Selecting output stops at the prompt even if soft-wrapped - { - const sel = s.selectLine(.{ .x = 1, .y = 1 }).?; - try testing.expectEqual(@as(usize, 0), sel.start.x); - try testing.expectEqual(@as(usize, 1), sel.start.y); - try testing.expectEqual(@as(usize, 0), sel.end.x); - try testing.expectEqual(@as(usize, 1), sel.end.y); - } - { - const sel = s.selectLine(.{ .x = 1, .y = 2 }).?; - try testing.expectEqual(@as(usize, 0), sel.start.x); - try testing.expectEqual(@as(usize, 2), sel.start.y); - try testing.expectEqual(@as(usize, 0), sel.end.x); - try testing.expectEqual(@as(usize, 2), sel.end.y); - } -} - -// X test "Screen: selectLine across soft-wrap ignores blank lines" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 10, 5, 0); + var s = try init(alloc, 5, 10, 0); defer s.deinit(); try s.testWriteString(" 12 34012 \n 123"); // Going forward { - const sel = s.selectLine(.{ .x = 1, .y = 0 }).?; - try testing.expectEqual(@as(usize, 1), sel.start.x); - try testing.expectEqual(@as(usize, 0), sel.start.y); - try testing.expectEqual(@as(usize, 3), sel.end.x); - try testing.expectEqual(@as(usize, 1), sel.end.y); + var sel = s.selectLine(s.pages.pin(.{ .active = .{ + .x = 1, + .y = 0, + } }).?).?; + defer sel.deinit(&s); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 1, + .y = 0, + } }, s.pages.pointFromPin(.screen, sel.start()).?); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 3, + .y = 1, + } }, s.pages.pointFromPin(.screen, sel.end()).?); } // Going backward { - const sel = s.selectLine(.{ .x = 1, .y = 1 }).?; - try testing.expectEqual(@as(usize, 1), sel.start.x); - try testing.expectEqual(@as(usize, 0), sel.start.y); - try testing.expectEqual(@as(usize, 3), sel.end.x); - try testing.expectEqual(@as(usize, 1), sel.end.y); + var sel = s.selectLine(s.pages.pin(.{ .active = .{ + .x = 1, + .y = 1, + } }).?).?; + defer sel.deinit(&s); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 1, + .y = 0, + } }, s.pages.pointFromPin(.screen, sel.start()).?); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 3, + .y = 1, + } }, s.pages.pointFromPin(.screen, sel.end()).?); } // Going forward and backward { - const sel = s.selectLine(.{ .x = 3, .y = 0 }).?; - try testing.expectEqual(@as(usize, 1), sel.start.x); - try testing.expectEqual(@as(usize, 0), sel.start.y); - try testing.expectEqual(@as(usize, 3), sel.end.x); - try testing.expectEqual(@as(usize, 1), sel.end.y); + var sel = s.selectLine(s.pages.pin(.{ .active = .{ + .x = 3, + .y = 0, + } }).?).?; + defer sel.deinit(&s); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 1, + .y = 0, + } }, s.pages.pointFromPin(.screen, sel.start()).?); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 3, + .y = 1, + } }, s.pages.pointFromPin(.screen, sel.end()).?); } } -// X test "Screen: selectLine with scrollback" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 3, 2, 5); + var s = try init(alloc, 2, 3, 5); defer s.deinit(); try s.testWriteString("1A\n2B\n3C\n4D\n5E"); // Selecting first line { - const sel = s.selectLine(.{ .x = 0, .y = 0 }).?; - try testing.expectEqual(@as(usize, 0), sel.start.x); - try testing.expectEqual(@as(usize, 0), sel.start.y); - try testing.expectEqual(@as(usize, 1), sel.end.x); - try testing.expectEqual(@as(usize, 0), sel.end.y); + var sel = s.selectLine(s.pages.pin(.{ .active = .{ + .x = 0, + .y = 0, + } }).?).?; + defer sel.deinit(&s); + try testing.expectEqual(point.Point{ .active = .{ + .x = 0, + .y = 0, + } }, s.pages.pointFromPin(.active, sel.start()).?); + try testing.expectEqual(point.Point{ .active = .{ + .x = 1, + .y = 0, + } }, s.pages.pointFromPin(.active, sel.end()).?); } // Selecting last line { - const sel = s.selectLine(.{ .x = 0, .y = 4 }).?; - try testing.expectEqual(@as(usize, 0), sel.start.x); - try testing.expectEqual(@as(usize, 4), sel.start.y); - try testing.expectEqual(@as(usize, 1), sel.end.x); - try testing.expectEqual(@as(usize, 4), sel.end.y); + var sel = s.selectLine(s.pages.pin(.{ .active = .{ + .x = 0, + .y = 2, + } }).?).?; + defer sel.deinit(&s); + try testing.expectEqual(point.Point{ .active = .{ + .x = 0, + .y = 2, + } }, s.pages.pointFromPin(.active, sel.start()).?); + try testing.expectEqual(point.Point{ .active = .{ + .x = 1, + .y = 2, + } }, s.pages.pointFromPin(.active, sel.end()).?); + } +} + +// https://github.com/mitchellh/ghostty/issues/1329 +test "Screen: selectLine semantic prompt boundary" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 5, 10, 0); + defer s.deinit(); + try s.testWriteString("ABCDE\nA > "); + + { + const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); + defer alloc.free(contents); + try testing.expectEqualStrings("ABCDE\nA \n> ", contents); + } + + { + const pin = s.pages.pin(.{ .screen = .{ .y = 1 } }).?; + const row = pin.rowAndCell().row; + row.semantic_prompt = .prompt; + } + + // Selecting output stops at the prompt even if soft-wrapped + { + var sel = s.selectLine(s.pages.pin(.{ .active = .{ + .x = 1, + .y = 1, + } }).?).?; + defer sel.deinit(&s); + try testing.expectEqual(point.Point{ .active = .{ + .x = 0, + .y = 1, + } }, s.pages.pointFromPin(.active, sel.start()).?); + try testing.expectEqual(point.Point{ .active = .{ + .x = 0, + .y = 1, + } }, s.pages.pointFromPin(.active, sel.end()).?); + } + { + var sel = s.selectLine(s.pages.pin(.{ .active = .{ + .x = 1, + .y = 2, + } }).?).?; + defer sel.deinit(&s); + try testing.expectEqual(point.Point{ .active = .{ + .x = 0, + .y = 2, + } }, s.pages.pointFromPin(.active, sel.start()).?); + try testing.expectEqual(point.Point{ .active = .{ + .x = 0, + .y = 2, + } }, s.pages.pointFromPin(.active, sel.end()).?); } } -// X test "Screen: selectWord" { const testing = std.testing; const alloc = testing.allocator; @@ -4682,139 +4388,238 @@ test "Screen: selectWord" { try s.testWriteString("ABC DEF\n 123\n456"); // Outside of active area - try testing.expect(s.selectWord(.{ .x = 9, .y = 0 }) == null); - try testing.expect(s.selectWord(.{ .x = 0, .y = 5 }) == null); + // try testing.expect(s.selectWord(.{ .x = 9, .y = 0 }) == null); + // try testing.expect(s.selectWord(.{ .x = 0, .y = 5 }) == null); // Going forward { - const sel = s.selectWord(.{ .x = 0, .y = 0 }).?; - try testing.expectEqual(@as(usize, 0), sel.start.x); - try testing.expectEqual(@as(usize, 0), sel.start.y); - try testing.expectEqual(@as(usize, 2), sel.end.x); - try testing.expectEqual(@as(usize, 0), sel.end.y); + var sel = s.selectWord(s.pages.pin(.{ .active = .{ + .x = 0, + .y = 0, + } }).?).?; + defer sel.deinit(&s); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 0, + } }, s.pages.pointFromPin(.screen, sel.start()).?); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 2, + .y = 0, + } }, s.pages.pointFromPin(.screen, sel.end()).?); } // Going backward { - const sel = s.selectWord(.{ .x = 2, .y = 0 }).?; - try testing.expectEqual(@as(usize, 0), sel.start.x); - try testing.expectEqual(@as(usize, 0), sel.start.y); - try testing.expectEqual(@as(usize, 2), sel.end.x); - try testing.expectEqual(@as(usize, 0), sel.end.y); + var sel = s.selectWord(s.pages.pin(.{ .active = .{ + .x = 2, + .y = 0, + } }).?).?; + defer sel.deinit(&s); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 0, + } }, s.pages.pointFromPin(.screen, sel.start()).?); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 2, + .y = 0, + } }, s.pages.pointFromPin(.screen, sel.end()).?); } // Going forward and backward { - const sel = s.selectWord(.{ .x = 1, .y = 0 }).?; - try testing.expectEqual(@as(usize, 0), sel.start.x); - try testing.expectEqual(@as(usize, 0), sel.start.y); - try testing.expectEqual(@as(usize, 2), sel.end.x); - try testing.expectEqual(@as(usize, 0), sel.end.y); + var sel = s.selectWord(s.pages.pin(.{ .active = .{ + .x = 1, + .y = 0, + } }).?).?; + defer sel.deinit(&s); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 0, + } }, s.pages.pointFromPin(.screen, sel.start()).?); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 2, + .y = 0, + } }, s.pages.pointFromPin(.screen, sel.end()).?); } // Whitespace { - const sel = s.selectWord(.{ .x = 3, .y = 0 }).?; - try testing.expectEqual(@as(usize, 3), sel.start.x); - try testing.expectEqual(@as(usize, 0), sel.start.y); - try testing.expectEqual(@as(usize, 4), sel.end.x); - try testing.expectEqual(@as(usize, 0), sel.end.y); + var sel = s.selectWord(s.pages.pin(.{ .active = .{ + .x = 3, + .y = 0, + } }).?).?; + defer sel.deinit(&s); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 3, + .y = 0, + } }, s.pages.pointFromPin(.screen, sel.start()).?); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 4, + .y = 0, + } }, s.pages.pointFromPin(.screen, sel.end()).?); } // Whitespace single char { - const sel = s.selectWord(.{ .x = 0, .y = 1 }).?; - try testing.expectEqual(@as(usize, 0), sel.start.x); - try testing.expectEqual(@as(usize, 1), sel.start.y); - try testing.expectEqual(@as(usize, 0), sel.end.x); - try testing.expectEqual(@as(usize, 1), sel.end.y); + var sel = s.selectWord(s.pages.pin(.{ .active = .{ + .x = 0, + .y = 1, + } }).?).?; + defer sel.deinit(&s); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 1, + } }, s.pages.pointFromPin(.screen, sel.start()).?); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 1, + } }, s.pages.pointFromPin(.screen, sel.end()).?); } // End of screen { - const sel = s.selectWord(.{ .x = 1, .y = 2 }).?; - try testing.expectEqual(@as(usize, 0), sel.start.x); - try testing.expectEqual(@as(usize, 2), sel.start.y); - try testing.expectEqual(@as(usize, 2), sel.end.x); - try testing.expectEqual(@as(usize, 2), sel.end.y); + var sel = s.selectWord(s.pages.pin(.{ .active = .{ + .x = 1, + .y = 2, + } }).?).?; + defer sel.deinit(&s); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 2, + } }, s.pages.pointFromPin(.screen, sel.start()).?); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 2, + .y = 2, + } }, s.pages.pointFromPin(.screen, sel.end()).?); } } -// X test "Screen: selectWord across soft-wrap" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 10, 5, 0); + var s = try init(alloc, 5, 10, 0); defer s.deinit(); try s.testWriteString(" 1234012\n 123"); + { + const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); + defer alloc.free(contents); + try testing.expectEqualStrings(" 1234\n012\n 123", contents); + } + // Going forward { - const sel = s.selectWord(.{ .x = 1, .y = 0 }).?; - try testing.expectEqual(@as(usize, 1), sel.start.x); - try testing.expectEqual(@as(usize, 0), sel.start.y); - try testing.expectEqual(@as(usize, 2), sel.end.x); - try testing.expectEqual(@as(usize, 1), sel.end.y); + var sel = s.selectWord(s.pages.pin(.{ .active = .{ + .x = 1, + .y = 0, + } }).?).?; + defer sel.deinit(&s); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 1, + .y = 0, + } }, s.pages.pointFromPin(.screen, sel.start()).?); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 2, + .y = 1, + } }, s.pages.pointFromPin(.screen, sel.end()).?); } // Going backward { - const sel = s.selectWord(.{ .x = 1, .y = 1 }).?; - try testing.expectEqual(@as(usize, 1), sel.start.x); - try testing.expectEqual(@as(usize, 0), sel.start.y); - try testing.expectEqual(@as(usize, 2), sel.end.x); - try testing.expectEqual(@as(usize, 1), sel.end.y); + var sel = s.selectWord(s.pages.pin(.{ .active = .{ + .x = 1, + .y = 1, + } }).?).?; + defer sel.deinit(&s); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 1, + .y = 0, + } }, s.pages.pointFromPin(.screen, sel.start()).?); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 2, + .y = 1, + } }, s.pages.pointFromPin(.screen, sel.end()).?); } // Going forward and backward { - const sel = s.selectWord(.{ .x = 3, .y = 0 }).?; - try testing.expectEqual(@as(usize, 1), sel.start.x); - try testing.expectEqual(@as(usize, 0), sel.start.y); - try testing.expectEqual(@as(usize, 2), sel.end.x); - try testing.expectEqual(@as(usize, 1), sel.end.y); + var sel = s.selectWord(s.pages.pin(.{ .active = .{ + .x = 3, + .y = 0, + } }).?).?; + defer sel.deinit(&s); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 1, + .y = 0, + } }, s.pages.pointFromPin(.screen, sel.start()).?); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 2, + .y = 1, + } }, s.pages.pointFromPin(.screen, sel.end()).?); } } -// X test "Screen: selectWord whitespace across soft-wrap" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 10, 5, 0); + var s = try init(alloc, 5, 10, 0); defer s.deinit(); try s.testWriteString("1 1\n 123"); // Going forward { - const sel = s.selectWord(.{ .x = 1, .y = 0 }).?; - try testing.expectEqual(@as(usize, 1), sel.start.x); - try testing.expectEqual(@as(usize, 0), sel.start.y); - try testing.expectEqual(@as(usize, 2), sel.end.x); - try testing.expectEqual(@as(usize, 1), sel.end.y); + var sel = s.selectWord(s.pages.pin(.{ .active = .{ + .x = 1, + .y = 0, + } }).?).?; + defer sel.deinit(&s); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 1, + .y = 0, + } }, s.pages.pointFromPin(.screen, sel.start()).?); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 2, + .y = 1, + } }, s.pages.pointFromPin(.screen, sel.end()).?); } // Going backward { - const sel = s.selectWord(.{ .x = 1, .y = 1 }).?; - try testing.expectEqual(@as(usize, 1), sel.start.x); - try testing.expectEqual(@as(usize, 0), sel.start.y); - try testing.expectEqual(@as(usize, 2), sel.end.x); - try testing.expectEqual(@as(usize, 1), sel.end.y); + var sel = s.selectWord(s.pages.pin(.{ .active = .{ + .x = 1, + .y = 1, + } }).?).?; + defer sel.deinit(&s); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 1, + .y = 0, + } }, s.pages.pointFromPin(.screen, sel.start()).?); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 2, + .y = 1, + } }, s.pages.pointFromPin(.screen, sel.end()).?); } // Going forward and backward { - const sel = s.selectWord(.{ .x = 3, .y = 0 }).?; - try testing.expectEqual(@as(usize, 1), sel.start.x); - try testing.expectEqual(@as(usize, 0), sel.start.y); - try testing.expectEqual(@as(usize, 2), sel.end.x); - try testing.expectEqual(@as(usize, 1), sel.end.y); + var sel = s.selectWord(s.pages.pin(.{ .active = .{ + .x = 3, + .y = 0, + } }).?).?; + defer sel.deinit(&s); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 1, + .y = 0, + } }, s.pages.pointFromPin(.screen, sel.start()).?); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 2, + .y = 1, + } }, s.pages.pointFromPin(.screen, sel.end()).?); } } -// X test "Screen: selectWord with character boundary" { const testing = std.testing; const alloc = testing.allocator; @@ -4838,56 +4643,87 @@ test "Screen: selectWord with character boundary" { }; for (cases) |case| { - var s = try init(alloc, 10, 20, 0); + var s = try init(alloc, 20, 10, 0); defer s.deinit(); try s.testWriteString(case); // Inside character forward { - const sel = s.selectWord(.{ .x = 2, .y = 0 }).?; - try testing.expectEqual(@as(usize, 2), sel.start.x); - try testing.expectEqual(@as(usize, 0), sel.start.y); - try testing.expectEqual(@as(usize, 4), sel.end.x); - try testing.expectEqual(@as(usize, 0), sel.end.y); + var sel = s.selectWord(s.pages.pin(.{ .active = .{ + .x = 2, + .y = 0, + } }).?).?; + defer sel.deinit(&s); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 2, + .y = 0, + } }, s.pages.pointFromPin(.screen, sel.start()).?); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 4, + .y = 0, + } }, s.pages.pointFromPin(.screen, sel.end()).?); } // Inside character backward { - const sel = s.selectWord(.{ .x = 4, .y = 0 }).?; - try testing.expectEqual(@as(usize, 2), sel.start.x); - try testing.expectEqual(@as(usize, 0), sel.start.y); - try testing.expectEqual(@as(usize, 4), sel.end.x); - try testing.expectEqual(@as(usize, 0), sel.end.y); + var sel = s.selectWord(s.pages.pin(.{ .active = .{ + .x = 4, + .y = 0, + } }).?).?; + defer sel.deinit(&s); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 2, + .y = 0, + } }, s.pages.pointFromPin(.screen, sel.start()).?); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 4, + .y = 0, + } }, s.pages.pointFromPin(.screen, sel.end()).?); } // Inside character bidirectional { - const sel = s.selectWord(.{ .x = 3, .y = 0 }).?; - try testing.expectEqual(@as(usize, 2), sel.start.x); - try testing.expectEqual(@as(usize, 0), sel.start.y); - try testing.expectEqual(@as(usize, 4), sel.end.x); - try testing.expectEqual(@as(usize, 0), sel.end.y); + var sel = s.selectWord(s.pages.pin(.{ .active = .{ + .x = 3, + .y = 0, + } }).?).?; + defer sel.deinit(&s); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 2, + .y = 0, + } }, s.pages.pointFromPin(.screen, sel.start()).?); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 4, + .y = 0, + } }, s.pages.pointFromPin(.screen, sel.end()).?); } // On quote // NOTE: this behavior is not ideal, so we can change this one day, // but I think its also not that important compared to the above. { - const sel = s.selectWord(.{ .x = 1, .y = 0 }).?; - try testing.expectEqual(@as(usize, 0), sel.start.x); - try testing.expectEqual(@as(usize, 0), sel.start.y); - try testing.expectEqual(@as(usize, 1), sel.end.x); - try testing.expectEqual(@as(usize, 0), sel.end.y); + var sel = s.selectWord(s.pages.pin(.{ .active = .{ + .x = 1, + .y = 0, + } }).?).?; + defer sel.deinit(&s); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 0, + } }, s.pages.pointFromPin(.screen, sel.start()).?); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 1, + .y = 0, + } }, s.pages.pointFromPin(.screen, sel.end()).?); } } } -// X test "Screen: selectOutput" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 15, 10, 0); + var s = try init(alloc, 10, 15, 0); defer s.deinit(); // zig fmt: off @@ -4906,62 +4742,109 @@ test "Screen: selectOutput" { } // zig fmt: on - var row = s.getRow(.{ .screen = 2 }); - row.setSemanticPrompt(.prompt); - row = s.getRow(.{ .screen = 3 }); - row.setSemanticPrompt(.input); - row = s.getRow(.{ .screen = 4 }); - row.setSemanticPrompt(.command); - row = s.getRow(.{ .screen = 6 }); - row.setSemanticPrompt(.input); - row = s.getRow(.{ .screen = 7 }); - row.setSemanticPrompt(.command); + { + const pin = s.pages.pin(.{ .screen = .{ .y = 2 } }).?; + const row = pin.rowAndCell().row; + row.semantic_prompt = .prompt; + } + { + const pin = s.pages.pin(.{ .screen = .{ .y = 3 } }).?; + const row = pin.rowAndCell().row; + row.semantic_prompt = .input; + } + { + const pin = s.pages.pin(.{ .screen = .{ .y = 4 } }).?; + const row = pin.rowAndCell().row; + row.semantic_prompt = .command; + } + { + const pin = s.pages.pin(.{ .screen = .{ .y = 6 } }).?; + const row = pin.rowAndCell().row; + row.semantic_prompt = .input; + } + { + const pin = s.pages.pin(.{ .screen = .{ .y = 7 } }).?; + const row = pin.rowAndCell().row; + row.semantic_prompt = .command; + } // No start marker, should select from the beginning { - const sel = s.selectOutput(.{ .x = 1, .y = 1 }).?; - try testing.expectEqual(@as(usize, 0), sel.start.x); - try testing.expectEqual(@as(usize, 0), sel.start.y); - try testing.expectEqual(@as(usize, 10), sel.end.x); - try testing.expectEqual(@as(usize, 1), sel.end.y); + var sel = s.selectOutput(s.pages.pin(.{ .active = .{ + .x = 1, + .y = 1, + } }).?).?; + defer sel.deinit(&s); + try testing.expectEqual(point.Point{ .active = .{ + .x = 0, + .y = 0, + } }, s.pages.pointFromPin(.active, sel.start()).?); + try testing.expectEqual(point.Point{ .active = .{ + .x = 9, + .y = 1, + } }, s.pages.pointFromPin(.active, sel.end()).?); } // Both start and end markers, should select between them { - const sel = s.selectOutput(.{ .x = 3, .y = 5 }).?; - try testing.expectEqual(@as(usize, 0), sel.start.x); - try testing.expectEqual(@as(usize, 4), sel.start.y); - try testing.expectEqual(@as(usize, 10), sel.end.x); - try testing.expectEqual(@as(usize, 5), sel.end.y); + var sel = s.selectOutput(s.pages.pin(.{ .active = .{ + .x = 3, + .y = 5, + } }).?).?; + defer sel.deinit(&s); + try testing.expectEqual(point.Point{ .active = .{ + .x = 0, + .y = 4, + } }, s.pages.pointFromPin(.active, sel.start()).?); + try testing.expectEqual(point.Point{ .active = .{ + .x = 9, + .y = 5, + } }, s.pages.pointFromPin(.active, sel.end()).?); } // No end marker, should select till the end { - const sel = s.selectOutput(.{ .x = 2, .y = 7 }).?; - try testing.expectEqual(@as(usize, 0), sel.start.x); - try testing.expectEqual(@as(usize, 7), sel.start.y); - try testing.expectEqual(@as(usize, 9), sel.end.x); - try testing.expectEqual(@as(usize, 10), sel.end.y); + var sel = s.selectOutput(s.pages.pin(.{ .active = .{ + .x = 2, + .y = 7, + } }).?).?; + defer sel.deinit(&s); + try testing.expectEqual(point.Point{ .active = .{ + .x = 0, + .y = 7, + } }, s.pages.pointFromPin(.active, sel.start()).?); + try testing.expectEqual(point.Point{ .active = .{ + .x = 9, + .y = 10, + } }, s.pages.pointFromPin(.active, sel.end()).?); } // input / prompt at y = 0, pt.y = 0 { s.deinit(); - s = try init(alloc, 5, 10, 0); + s = try init(alloc, 10, 5, 0); try s.testWriteString("prompt1$ input1\n"); try s.testWriteString("output1\n"); try s.testWriteString("prompt2\n"); - row = s.getRow(.{ .screen = 0 }); - row.setSemanticPrompt(.input); - row = s.getRow(.{ .screen = 1 }); - row.setSemanticPrompt(.command); - try testing.expect(s.selectOutput(.{ .x = 2, .y = 0 }) == null); + { + const pin = s.pages.pin(.{ .screen = .{ .y = 0 } }).?; + const row = pin.rowAndCell().row; + row.semantic_prompt = .input; + } + { + const pin = s.pages.pin(.{ .screen = .{ .y = 1 } }).?; + const row = pin.rowAndCell().row; + row.semantic_prompt = .command; + } + try testing.expect(s.selectOutput(s.pages.pin(.{ .active = .{ + .x = 2, + .y = 0, + } }).?) == null); } } -// X test "Screen: selectPrompt basics" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 15, 10, 0); + var s = try init(alloc, 10, 15, 0); defer s.deinit(); // zig fmt: off @@ -4980,52 +4863,88 @@ test "Screen: selectPrompt basics" { } // zig fmt: on - var row = s.getRow(.{ .screen = 2 }); - row.setSemanticPrompt(.prompt); - row = s.getRow(.{ .screen = 3 }); - row.setSemanticPrompt(.input); - row = s.getRow(.{ .screen = 4 }); - row.setSemanticPrompt(.command); - row = s.getRow(.{ .screen = 6 }); - row.setSemanticPrompt(.input); - row = s.getRow(.{ .screen = 7 }); - row.setSemanticPrompt(.command); + { + const pin = s.pages.pin(.{ .screen = .{ .y = 2 } }).?; + const row = pin.rowAndCell().row; + row.semantic_prompt = .prompt; + } + { + const pin = s.pages.pin(.{ .screen = .{ .y = 3 } }).?; + const row = pin.rowAndCell().row; + row.semantic_prompt = .input; + } + { + const pin = s.pages.pin(.{ .screen = .{ .y = 4 } }).?; + const row = pin.rowAndCell().row; + row.semantic_prompt = .command; + } + { + const pin = s.pages.pin(.{ .screen = .{ .y = 6 } }).?; + const row = pin.rowAndCell().row; + row.semantic_prompt = .input; + } + { + const pin = s.pages.pin(.{ .screen = .{ .y = 7 } }).?; + const row = pin.rowAndCell().row; + row.semantic_prompt = .command; + } // Not at a prompt { - const sel = s.selectPrompt(.{ .x = 0, .y = 1 }); + const sel = s.selectPrompt(s.pages.pin(.{ .active = .{ + .x = 0, + .y = 1, + } }).?); try testing.expect(sel == null); } { - const sel = s.selectPrompt(.{ .x = 0, .y = 8 }); + const sel = s.selectPrompt(s.pages.pin(.{ .active = .{ + .x = 0, + .y = 8, + } }).?); try testing.expect(sel == null); } // Single line prompt { - const sel = s.selectPrompt(.{ .x = 1, .y = 6 }).?; - try testing.expectEqual(Selection{ - .start = .{ .x = 0, .y = 6 }, - .end = .{ .x = 9, .y = 6 }, - }, sel); + var sel = s.selectPrompt(s.pages.pin(.{ .active = .{ + .x = 1, + .y = 6, + } }).?).?; + defer sel.deinit(&s); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 6, + } }, s.pages.pointFromPin(.screen, sel.start()).?); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 9, + .y = 6, + } }, s.pages.pointFromPin(.screen, sel.end()).?); } // Multi line prompt { - const sel = s.selectPrompt(.{ .x = 1, .y = 3 }).?; - try testing.expectEqual(Selection{ - .start = .{ .x = 0, .y = 2 }, - .end = .{ .x = 9, .y = 3 }, - }, sel); + var sel = s.selectPrompt(s.pages.pin(.{ .active = .{ + .x = 1, + .y = 3, + } }).?).?; + defer sel.deinit(&s); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 2, + } }, s.pages.pointFromPin(.screen, sel.start()).?); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 9, + .y = 3, + } }, s.pages.pointFromPin(.screen, sel.end()).?); } } -// X test "Screen: selectPrompt prompt at start" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 15, 10, 0); + var s = try init(alloc, 10, 15, 0); defer s.deinit(); // zig fmt: off @@ -5038,35 +4957,54 @@ test "Screen: selectPrompt prompt at start" { } // zig fmt: on - var row = s.getRow(.{ .screen = 0 }); - row.setSemanticPrompt(.prompt); - row = s.getRow(.{ .screen = 1 }); - row.setSemanticPrompt(.input); - row = s.getRow(.{ .screen = 2 }); - row.setSemanticPrompt(.command); + { + const pin = s.pages.pin(.{ .screen = .{ .y = 0 } }).?; + const row = pin.rowAndCell().row; + row.semantic_prompt = .prompt; + } + { + const pin = s.pages.pin(.{ .screen = .{ .y = 1 } }).?; + const row = pin.rowAndCell().row; + row.semantic_prompt = .input; + } + { + const pin = s.pages.pin(.{ .screen = .{ .y = 2 } }).?; + const row = pin.rowAndCell().row; + row.semantic_prompt = .command; + } // Not at a prompt { - const sel = s.selectPrompt(.{ .x = 0, .y = 3 }); + const sel = s.selectPrompt(s.pages.pin(.{ .active = .{ + .x = 0, + .y = 3, + } }).?); try testing.expect(sel == null); } // Multi line prompt { - const sel = s.selectPrompt(.{ .x = 1, .y = 1 }).?; - try testing.expectEqual(Selection{ - .start = .{ .x = 0, .y = 0 }, - .end = .{ .x = 9, .y = 1 }, - }, sel); + var sel = s.selectPrompt(s.pages.pin(.{ .active = .{ + .x = 1, + .y = 1, + } }).?).?; + defer sel.deinit(&s); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 0, + } }, s.pages.pointFromPin(.screen, sel.start()).?); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 9, + .y = 1, + } }, s.pages.pointFromPin(.screen, sel.end()).?); } } -// X test "Screen: selectPrompt prompt at end" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 15, 10, 0); + var s = try init(alloc, 10, 15, 0); defer s.deinit(); // zig fmt: off @@ -5079,33 +5017,49 @@ test "Screen: selectPrompt prompt at end" { } // zig fmt: on - var row = s.getRow(.{ .screen = 2 }); - row.setSemanticPrompt(.prompt); - row = s.getRow(.{ .screen = 3 }); - row.setSemanticPrompt(.input); + { + const pin = s.pages.pin(.{ .screen = .{ .y = 2 } }).?; + const row = pin.rowAndCell().row; + row.semantic_prompt = .prompt; + } + { + const pin = s.pages.pin(.{ .screen = .{ .y = 3 } }).?; + const row = pin.rowAndCell().row; + row.semantic_prompt = .input; + } // Not at a prompt { - const sel = s.selectPrompt(.{ .x = 0, .y = 1 }); + const sel = s.selectPrompt(s.pages.pin(.{ .active = .{ + .x = 0, + .y = 1, + } }).?); try testing.expect(sel == null); } // Multi line prompt { - const sel = s.selectPrompt(.{ .x = 1, .y = 2 }).?; - try testing.expectEqual(Selection{ - .start = .{ .x = 0, .y = 2 }, - .end = .{ .x = 9, .y = 3 }, - }, sel); + var sel = s.selectPrompt(s.pages.pin(.{ .active = .{ + .x = 1, + .y = 2, + } }).?).?; + defer sel.deinit(&s); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 2, + } }, s.pages.pointFromPin(.screen, sel.start()).?); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 9, + .y = 3, + } }, s.pages.pointFromPin(.screen, sel.end()).?); } } -// X test "Screen: promptPath" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 15, 10, 0); + var s = try init(alloc, 10, 15, 0); defer s.deinit(); // zig fmt: off @@ -5124,22 +5078,37 @@ test "Screen: promptPath" { } // zig fmt: on - var row = s.getRow(.{ .screen = 2 }); - row.setSemanticPrompt(.prompt); - row = s.getRow(.{ .screen = 3 }); - row.setSemanticPrompt(.input); - row = s.getRow(.{ .screen = 4 }); - row.setSemanticPrompt(.command); - row = s.getRow(.{ .screen = 6 }); - row.setSemanticPrompt(.input); - row = s.getRow(.{ .screen = 7 }); - row.setSemanticPrompt(.command); + { + const pin = s.pages.pin(.{ .screen = .{ .y = 2 } }).?; + const row = pin.rowAndCell().row; + row.semantic_prompt = .prompt; + } + { + const pin = s.pages.pin(.{ .screen = .{ .y = 3 } }).?; + const row = pin.rowAndCell().row; + row.semantic_prompt = .input; + } + { + const pin = s.pages.pin(.{ .screen = .{ .y = 4 } }).?; + const row = pin.rowAndCell().row; + row.semantic_prompt = .command; + } + { + const pin = s.pages.pin(.{ .screen = .{ .y = 6 } }).?; + const row = pin.rowAndCell().row; + row.semantic_prompt = .input; + } + { + const pin = s.pages.pin(.{ .screen = .{ .y = 7 } }).?; + const row = pin.rowAndCell().row; + row.semantic_prompt = .command; + } // From is not in the prompt { const path = s.promptPath( - .{ .x = 0, .y = 1 }, - .{ .x = 0, .y = 2 }, + s.pages.pin(.{ .active = .{ .x = 0, .y = 1 } }).?, + s.pages.pin(.{ .active = .{ .x = 0, .y = 2 } }).?, ); try testing.expectEqual(@as(isize, 0), path.x); try testing.expectEqual(@as(isize, 0), path.y); @@ -5148,8 +5117,8 @@ test "Screen: promptPath" { // Same line { const path = s.promptPath( - .{ .x = 6, .y = 2 }, - .{ .x = 3, .y = 2 }, + s.pages.pin(.{ .active = .{ .x = 6, .y = 2 } }).?, + s.pages.pin(.{ .active = .{ .x = 3, .y = 2 } }).?, ); try testing.expectEqual(@as(isize, -3), path.x); try testing.expectEqual(@as(isize, 0), path.y); @@ -5158,8 +5127,8 @@ test "Screen: promptPath" { // Different lines { const path = s.promptPath( - .{ .x = 6, .y = 2 }, - .{ .x = 3, .y = 3 }, + s.pages.pin(.{ .active = .{ .x = 6, .y = 2 } }).?, + s.pages.pin(.{ .active = .{ .x = 3, .y = 3 } }).?, ); try testing.expectEqual(@as(isize, -3), path.x); try testing.expectEqual(@as(isize, 1), path.y); @@ -5168,8 +5137,8 @@ test "Screen: promptPath" { // To is out of bounds before { const path = s.promptPath( - .{ .x = 6, .y = 2 }, - .{ .x = 3, .y = 1 }, + s.pages.pin(.{ .active = .{ .x = 6, .y = 2 } }).?, + s.pages.pin(.{ .active = .{ .x = 3, .y = 1 } }).?, ); try testing.expectEqual(@as(isize, -6), path.x); try testing.expectEqual(@as(isize, 0), path.y); @@ -5178,449 +5147,97 @@ test "Screen: promptPath" { // To is out of bounds after { const path = s.promptPath( - .{ .x = 6, .y = 2 }, - .{ .x = 3, .y = 9 }, + s.pages.pin(.{ .active = .{ .x = 6, .y = 2 } }).?, + s.pages.pin(.{ .active = .{ .x = 3, .y = 9 } }).?, ); try testing.expectEqual(@as(isize, 3), path.x); try testing.expectEqual(@as(isize, 1), path.y); } } -// X - we don't use this in new terminal -test "Screen: scrollRegionUp single" { - const testing = std.testing; - const alloc = testing.allocator; - - var s = try init(alloc, 4, 5, 0); - defer s.deinit(); - try s.testWriteString("1ABCD\n2EFGH\n3IJKL\n4ABCD"); - - s.scrollRegionUp(.{ .active = 1 }, .{ .active = 2 }, 1); - { - // Test our contents rotated - const contents = try s.testString(alloc, .screen); - defer alloc.free(contents); - try testing.expectEqualStrings("1ABCD\n3IJKL\n\n4ABCD", contents); - } -} - -// X - we don't use this in new terminal -test "Screen: scrollRegionUp same line" { - const testing = std.testing; - const alloc = testing.allocator; - - var s = try init(alloc, 4, 5, 0); - defer s.deinit(); - try s.testWriteString("1ABCD\n2EFGH\n3IJKL\n4ABCD"); - - s.scrollRegionUp(.{ .active = 1 }, .{ .active = 1 }, 1); - { - // Test our contents rotated - const contents = try s.testString(alloc, .screen); - defer alloc.free(contents); - try testing.expectEqualStrings("1ABCD\n2EFGH\n3IJKL\n4ABCD", contents); - } -} - -// X - we don't use this in new terminal -test "Screen: scrollRegionUp single with pen" { - const testing = std.testing; - const alloc = testing.allocator; - - var s = try init(alloc, 4, 5, 0); - defer s.deinit(); - try s.testWriteString("1ABCD\n2EFGH\n3IJKL\n4ABCD"); - - s.cursor.pen = .{ .char = 'X' }; - s.cursor.pen.bg = .{ .rgb = .{ .r = 155 } }; - s.cursor.pen.attrs.bold = true; - s.scrollRegionUp(.{ .active = 1 }, .{ .active = 2 }, 1); - { - // Test our contents rotated - const contents = try s.testString(alloc, .screen); - defer alloc.free(contents); - try testing.expectEqualStrings("1ABCD\n3IJKL\n\n4ABCD", contents); - const cell = s.getCell(.active, 2, 0); - try testing.expectEqual(@as(u8, 155), cell.bg.rgb.r); - try testing.expect(!cell.attrs.bold); - try testing.expect(s.cursor.pen.attrs.bold); - } -} - -// X - we don't use this in new terminal -test "Screen: scrollRegionUp multiple" { - const testing = std.testing; - const alloc = testing.allocator; - - var s = try init(alloc, 4, 5, 0); - defer s.deinit(); - try s.testWriteString("1ABCD\n2EFGH\n3IJKL\n4ABCD"); - - s.scrollRegionUp(.{ .active = 1 }, .{ .active = 3 }, 1); - { - // Test our contents rotated - const contents = try s.testString(alloc, .screen); - defer alloc.free(contents); - try testing.expectEqualStrings("1ABCD\n3IJKL\n4ABCD", contents); - } -} - -// X - we don't use this in new terminal -test "Screen: scrollRegionUp multiple count" { - const testing = std.testing; - const alloc = testing.allocator; - - var s = try init(alloc, 4, 5, 0); - defer s.deinit(); - try s.testWriteString("1ABCD\n2EFGH\n3IJKL\n4ABCD"); - - s.scrollRegionUp(.{ .active = 1 }, .{ .active = 3 }, 2); - { - // Test our contents rotated - const contents = try s.testString(alloc, .screen); - defer alloc.free(contents); - try testing.expectEqualStrings("1ABCD\n4ABCD", contents); - } -} - -// X - we don't use this in new terminal -test "Screen: scrollRegionUp count greater than available lines" { - const testing = std.testing; - const alloc = testing.allocator; - - var s = try init(alloc, 4, 5, 0); - defer s.deinit(); - try s.testWriteString("1ABCD\n2EFGH\n3IJKL\n4ABCD"); - - s.scrollRegionUp(.{ .active = 1 }, .{ .active = 2 }, 10); - { - // Test our contents rotated - const contents = try s.testString(alloc, .screen); - defer alloc.free(contents); - try testing.expectEqualStrings("1ABCD\n\n\n4ABCD", contents); - } -} -// X - we don't use this in new terminal -test "Screen: scrollRegionUp fills with pen" { - const testing = std.testing; - const alloc = testing.allocator; - - var s = try init(alloc, 4, 5, 0); - defer s.deinit(); - try s.testWriteString("A\nB\nC\nD"); - - s.cursor.pen = .{ .char = 'X' }; - s.cursor.pen.bg = .{ .rgb = .{ .r = 155 } }; - s.cursor.pen.attrs.bold = true; - s.scrollRegionUp(.{ .active = 0 }, .{ .active = 2 }, 1); - { - // Test our contents rotated - const contents = try s.testString(alloc, .screen); - defer alloc.free(contents); - try testing.expectEqualStrings("B\nC\n\nD", contents); - const cell = s.getCell(.active, 2, 0); - try testing.expectEqual(@as(u8, 155), cell.bg.rgb.r); - try testing.expect(!cell.attrs.bold); - try testing.expect(s.cursor.pen.attrs.bold); - } -} - -// X - we don't use this in new terminal -test "Screen: scrollRegionUp buffer wrap" { - 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"); - - // Scroll down, should still be bottom, but should wrap because - // we're out of space. - try s.scroll(.{ .screen = 1 }); - s.cursor.x = 0; - try s.testWriteString("1ABCD\n2EFGH\n3IJKL\n4ABCD"); - - // Scroll - s.cursor.pen = .{ .char = 'X' }; - s.cursor.pen.bg = .{ .rgb = .{ .r = 155 } }; - s.cursor.pen.attrs.bold = true; - s.scrollRegionUp(.{ .screen = 0 }, .{ .screen = 2 }, 1); - - { - // Test our contents rotated - const contents = try s.testString(alloc, .screen); - defer alloc.free(contents); - try testing.expectEqualStrings("3IJKL\n4ABCD", contents); - const cell = s.getCell(.active, 2, 0); - try testing.expectEqual(@as(u8, 155), cell.bg.rgb.r); - try testing.expect(!cell.attrs.bold); - try testing.expect(s.cursor.pen.attrs.bold); - } -} - -// X - we don't use this in new terminal -test "Screen: scrollRegionUp buffer wrap alternate" { - 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"); - - // Scroll down, should still be bottom, but should wrap because - // we're out of space. - try s.scroll(.{ .screen = 1 }); - s.cursor.x = 0; - try s.testWriteString("1ABCD\n2EFGH\n3IJKL\n4ABCD"); - - // Scroll - s.cursor.pen = .{ .char = 'X' }; - s.cursor.pen.bg = .{ .rgb = .{ .r = 155 } }; - s.cursor.pen.attrs.bold = true; - s.scrollRegionUp(.{ .screen = 0 }, .{ .screen = 2 }, 2); - - { - // Test our contents rotated - const contents = try s.testString(alloc, .screen); - defer alloc.free(contents); - try testing.expectEqualStrings("4ABCD", contents); - const cell = s.getCell(.active, 2, 0); - try testing.expectEqual(@as(u8, 155), cell.bg.rgb.r); - try testing.expect(!cell.attrs.bold); - try testing.expect(s.cursor.pen.attrs.bold); - } -} - -// X - we don't use this in new terminal -test "Screen: scrollRegionUp buffer wrap alternative with extra lines" { - const testing = std.testing; - const alloc = testing.allocator; - - var s = try init(alloc, 5, 5, 0); - defer s.deinit(); - - // We artificially mess with the circular buffer here. This was discovered - // when debugging https://github.com/mitchellh/ghostty/issues/315. I - // don't know how to "naturally" get the circular buffer into this state - // although it is obviously possible, verified through various - // asciinema casts. - // - // I think the proper way to recreate this state would be to fill - // the screen, scroll the correct number of times, clear the screen - // with a fill. I can try that later to ensure we're hitting the same - // code path. - s.storage.head = 24; - s.storage.tail = 24; - s.storage.full = true; - - // Scroll down, should still be bottom, but should wrap because - // we're out of space. - // try s.scroll(.{ .screen = 2 }); - // s.cursor.x = 0; - try s.testWriteString("1ABCD\n2EFGH\n3IJKL\n4ABCD\n5EFGH"); - - // Scroll - s.scrollRegionUp(.{ .screen = 0 }, .{ .screen = 3 }, 2); - - { - // Test our contents rotated - const contents = try s.testString(alloc, .screen); - defer alloc.free(contents); - try testing.expectEqualStrings("3IJKL\n4ABCD\n\n\n5EFGH", contents); - } -} - -// X -test "Screen: clear history with no history" { - const testing = std.testing; - const alloc = testing.allocator; - - var s = try init(alloc, 3, 5, 3); - defer s.deinit(); - try s.testWriteString("4ABCD\n5EFGH\n6IJKL"); - try testing.expect(s.viewportIsBottom()); - try s.clear(.history); - try testing.expect(s.viewportIsBottom()); - { - // Test our contents rotated - const contents = try s.testString(alloc, .viewport); - defer alloc.free(contents); - try testing.expectEqualStrings("4ABCD\n5EFGH\n6IJKL", contents); - } - { - // Test our contents rotated - const contents = try s.testString(alloc, .screen); - defer alloc.free(contents); - try testing.expectEqualStrings("4ABCD\n5EFGH\n6IJKL", contents); - } -} - -// X -test "Screen: clear history" { - 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 - const contents = try s.testString(alloc, .viewport); - defer alloc.free(contents); - try testing.expectEqualStrings("1ABCD\n2EFGH\n3IJKL", contents); - } - - try s.clear(.history); - try testing.expect(s.viewportIsBottom()); - { - // Test our contents rotated - const contents = try s.testString(alloc, .viewport); - defer alloc.free(contents); - try testing.expectEqualStrings("4ABCD\n5EFGH\n6IJKL", contents); - } - { - // Test our contents rotated - const contents = try s.testString(alloc, .screen); - defer alloc.free(contents); - try testing.expectEqualStrings("4ABCD\n5EFGH\n6IJKL", contents); - } -} - -// X -test "Screen: clear above cursor" { - const testing = std.testing; - const alloc = testing.allocator; - - var s = try init(alloc, 10, 10, 3); - defer s.deinit(); - try s.testWriteString("4ABCD\n5EFGH\n6IJKL"); - try testing.expect(s.viewportIsBottom()); - try s.clear(.above_cursor); - try testing.expect(s.viewportIsBottom()); - { - const contents = try s.testString(alloc, .viewport); - defer alloc.free(contents); - try testing.expectEqualStrings("6IJKL", contents); - } - { - const contents = try s.testString(alloc, .screen); - defer alloc.free(contents); - try testing.expectEqualStrings("6IJKL", contents); - } - - try testing.expectEqual(@as(usize, 5), s.cursor.x); - try testing.expectEqual(@as(usize, 0), s.cursor.y); -} - -// X -test "Screen: clear above cursor with history" { - const testing = std.testing; - const alloc = testing.allocator; - - var s = try init(alloc, 3, 10, 3); - defer s.deinit(); - try s.testWriteString("1ABCD\n2EFGH\n3IJKL\n"); - try s.testWriteString("4ABCD\n5EFGH\n6IJKL"); - try testing.expect(s.viewportIsBottom()); - try s.clear(.above_cursor); - try testing.expect(s.viewportIsBottom()); - { - const contents = try s.testString(alloc, .viewport); - defer alloc.free(contents); - try testing.expectEqualStrings("6IJKL", contents); - } - { - const contents = try s.testString(alloc, .screen); - defer alloc.free(contents); - try testing.expectEqualStrings("1ABCD\n2EFGH\n3IJKL\n6IJKL", contents); - } - - try testing.expectEqual(@as(usize, 5), s.cursor.x); - try testing.expectEqual(@as(usize, 0), s.cursor.y); -} - -// X test "Screen: selectionString basic" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 3, 5, 0); + var s = try init(alloc, 5, 3, 0); defer s.deinit(); const str = "1ABCD\n2EFGH\n3IJKL"; try s.testWriteString(str); { - const contents = try s.selectionString(alloc, .{ - .start = .{ .x = 0, .y = 1 }, - .end = .{ .x = 2, .y = 2 }, - }, true); + const sel = Selection.init( + s.pages.pin(.{ .screen = .{ .x = 0, .y = 1 } }).?, + s.pages.pin(.{ .screen = .{ .x = 2, .y = 2 } }).?, + false, + ); + const contents = try s.selectionString(alloc, sel, true); defer alloc.free(contents); const expected = "2EFGH\n3IJ"; try testing.expectEqualStrings(expected, contents); } } -// X test "Screen: selectionString start outside of written area" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 10, 5, 0); + var s = try init(alloc, 5, 10, 0); defer s.deinit(); const str = "1ABCD\n2EFGH\n3IJKL"; try s.testWriteString(str); { - const contents = try s.selectionString(alloc, .{ - .start = .{ .x = 0, .y = 5 }, - .end = .{ .x = 2, .y = 6 }, - }, true); + const sel = Selection.init( + s.pages.pin(.{ .screen = .{ .x = 0, .y = 5 } }).?, + s.pages.pin(.{ .screen = .{ .x = 2, .y = 6 } }).?, + false, + ); + const contents = try s.selectionString(alloc, sel, true); defer alloc.free(contents); const expected = ""; try testing.expectEqualStrings(expected, contents); } } -// X test "Screen: selectionString end outside of written area" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 10, 5, 0); + var s = try init(alloc, 5, 10, 0); defer s.deinit(); const str = "1ABCD\n2EFGH\n3IJKL"; try s.testWriteString(str); { - const contents = try s.selectionString(alloc, .{ - .start = .{ .x = 0, .y = 2 }, - .end = .{ .x = 2, .y = 6 }, - }, true); + const sel = Selection.init( + s.pages.pin(.{ .screen = .{ .x = 0, .y = 2 } }).?, + s.pages.pin(.{ .screen = .{ .x = 2, .y = 6 } }).?, + false, + ); + const contents = try s.selectionString(alloc, sel, true); defer alloc.free(contents); const expected = "3IJKL"; try testing.expectEqualStrings(expected, contents); } } -// X test "Screen: selectionString trim space" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 3, 5, 0); + var s = try init(alloc, 5, 3, 0); defer s.deinit(); const str = "1AB \n2EFGH\n3IJKL"; try s.testWriteString(str); + const sel = Selection.init( + s.pages.pin(.{ .screen = .{ .x = 0, .y = 0 } }).?, + s.pages.pin(.{ .screen = .{ .x = 2, .y = 1 } }).?, + false, + ); + { - const contents = try s.selectionString(alloc, .{ - .start = .{ .x = 0, .y = 0 }, - .end = .{ .x = 2, .y = 1 }, - }, true); + const contents = try s.selectionString(alloc, sel, true); defer alloc.free(contents); const expected = "1AB\n2EF"; try testing.expectEqualStrings(expected, contents); @@ -5628,17 +5245,13 @@ test "Screen: selectionString trim space" { // No trim { - const contents = try s.selectionString(alloc, .{ - .start = .{ .x = 0, .y = 0 }, - .end = .{ .x = 2, .y = 1 }, - }, false); + const contents = try s.selectionString(alloc, sel, false); defer alloc.free(contents); const expected = "1AB \n2EF"; try testing.expectEqualStrings(expected, contents); } } -// X test "Screen: selectionString trim empty line" { const testing = std.testing; const alloc = testing.allocator; @@ -5648,11 +5261,14 @@ test "Screen: selectionString trim empty line" { const str = "1AB \n\n2EFGH\n3IJKL"; try s.testWriteString(str); + const sel = Selection.init( + s.pages.pin(.{ .screen = .{ .x = 0, .y = 0 } }).?, + s.pages.pin(.{ .screen = .{ .x = 2, .y = 2 } }).?, + false, + ); + { - const contents = try s.selectionString(alloc, .{ - .start = .{ .x = 0, .y = 0 }, - .end = .{ .x = 2, .y = 2 }, - }, true); + const contents = try s.selectionString(alloc, sel, true); defer alloc.free(contents); const expected = "1AB\n\n2EF"; try testing.expectEqualStrings(expected, contents); @@ -5660,133 +5276,109 @@ test "Screen: selectionString trim empty line" { // No trim { - const contents = try s.selectionString(alloc, .{ - .start = .{ .x = 0, .y = 0 }, - .end = .{ .x = 2, .y = 2 }, - }, false); + const contents = try s.selectionString(alloc, sel, false); defer alloc.free(contents); const expected = "1AB \n \n2EF"; try testing.expectEqualStrings(expected, contents); } } -// X test "Screen: selectionString soft wrap" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 3, 5, 0); + var s = try init(alloc, 5, 3, 0); defer s.deinit(); const str = "1ABCD2EFGH3IJKL"; try s.testWriteString(str); { - const contents = try s.selectionString(alloc, .{ - .start = .{ .x = 0, .y = 1 }, - .end = .{ .x = 2, .y = 2 }, - }, true); + const sel = Selection.init( + s.pages.pin(.{ .screen = .{ .x = 0, .y = 1 } }).?, + s.pages.pin(.{ .screen = .{ .x = 2, .y = 2 } }).?, + false, + ); + const contents = try s.selectionString(alloc, sel, true); defer alloc.free(contents); const expected = "2EFGH3IJ"; try testing.expectEqualStrings(expected, contents); } } -// X - can't happen in new terminal -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(.{ .screen = 1 }); - try testing.expect(s.viewportIsBottom()); - try s.testWriteString("1ABCD\n2EFGH\n3IJKL"); - - { - const contents = try s.selectionString(alloc, .{ - .start = .{ .x = 0, .y = 1 }, - .end = .{ .x = 2, .y = 2 }, - }, true); - defer alloc.free(contents); - const expected = "2EFGH\n3IJ"; - try testing.expectEqualStrings(expected, contents); - } -} - -// X test "Screen: selectionString wide char" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 3, 5, 0); + var s = try init(alloc, 5, 3, 0); defer s.deinit(); const str = "1A⚡"; try s.testWriteString(str); { - const contents = try s.selectionString(alloc, .{ - .start = .{ .x = 0, .y = 0 }, - .end = .{ .x = 3, .y = 0 }, - }, true); + const sel = Selection.init( + s.pages.pin(.{ .screen = .{ .x = 0, .y = 0 } }).?, + s.pages.pin(.{ .screen = .{ .x = 3, .y = 0 } }).?, + false, + ); + const contents = try s.selectionString(alloc, sel, true); defer alloc.free(contents); const expected = str; try testing.expectEqualStrings(expected, contents); } { - const contents = try s.selectionString(alloc, .{ - .start = .{ .x = 0, .y = 0 }, - .end = .{ .x = 2, .y = 0 }, - }, true); + const sel = Selection.init( + s.pages.pin(.{ .screen = .{ .x = 0, .y = 0 } }).?, + s.pages.pin(.{ .screen = .{ .x = 2, .y = 0 } }).?, + false, + ); + const contents = try s.selectionString(alloc, sel, true); defer alloc.free(contents); const expected = str; try testing.expectEqualStrings(expected, contents); } { - const contents = try s.selectionString(alloc, .{ - .start = .{ .x = 3, .y = 0 }, - .end = .{ .x = 3, .y = 0 }, - }, true); + const sel = Selection.init( + s.pages.pin(.{ .screen = .{ .x = 3, .y = 0 } }).?, + s.pages.pin(.{ .screen = .{ .x = 3, .y = 0 } }).?, + false, + ); + const contents = try s.selectionString(alloc, sel, true); defer alloc.free(contents); const expected = "⚡"; try testing.expectEqualStrings(expected, contents); } } -// X test "Screen: selectionString wide char with header" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 3, 5, 0); + var s = try init(alloc, 5, 3, 0); defer s.deinit(); const str = "1ABC⚡"; try s.testWriteString(str); { - const contents = try s.selectionString(alloc, .{ - .start = .{ .x = 0, .y = 0 }, - .end = .{ .x = 4, .y = 0 }, - }, true); + const sel = Selection.init( + s.pages.pin(.{ .screen = .{ .x = 0, .y = 0 } }).?, + s.pages.pin(.{ .screen = .{ .x = 4, .y = 0 } }).?, + false, + ); + const contents = try s.selectionString(alloc, sel, true); defer alloc.free(contents); const expected = str; try testing.expectEqualStrings(expected, contents); } } -// X // https://github.com/mitchellh/ghostty/issues/289 test "Screen: selectionString empty with soft wrap" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 2, 5, 0); + var s = try init(alloc, 5, 2, 0); defer s.deinit(); // Let me describe the situation that caused this because this @@ -5800,59 +5392,56 @@ test "Screen: selectionString empty with soft wrap" { try s.testWriteString(" "); { - const contents = try s.selectionString(alloc, .{ - .start = .{ .x = 1, .y = 0 }, - .end = .{ .x = 2, .y = 0 }, - }, true); + const sel = Selection.init( + s.pages.pin(.{ .screen = .{ .x = 1, .y = 0 } }).?, + s.pages.pin(.{ .screen = .{ .x = 2, .y = 0 } }).?, + false, + ); + const contents = try s.selectionString(alloc, sel, true); defer alloc.free(contents); const expected = "👨"; try testing.expectEqualStrings(expected, contents); } } -// X test "Screen: selectionString with zero width joiner" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 1, 10, 0); + var s = try init(alloc, 10, 1, 0); defer s.deinit(); const str = "👨‍"; // this has a ZWJ try s.testWriteString(str); // Integrity check - const row = s.getRow(.{ .screen = 0 }); { - const cell = row.getCell(0); - try testing.expectEqual(@as(u32, 0x1F468), cell.char); - try testing.expect(cell.attrs.wide); - try testing.expectEqual(@as(usize, 2), row.codepointLen(0)); - } - { - const cell = row.getCell(1); - try testing.expectEqual(@as(u32, ' '), cell.char); - try testing.expect(cell.attrs.wide_spacer_tail); - try testing.expectEqual(@as(usize, 1), row.codepointLen(1)); + const pin = s.pages.pin(.{ .screen = .{ .y = 0, .x = 0 } }).?; + const cell = pin.rowAndCell().cell; + try testing.expectEqual(@as(u21, 0x1F468), cell.content.codepoint); + try testing.expectEqual(Cell.Wide.wide, cell.wide); + const cps = pin.page.data.lookupGrapheme(cell).?; + try testing.expectEqual(@as(usize, 1), cps.len); } // The real test { - const contents = try s.selectionString(alloc, .{ - .start = .{ .x = 0, .y = 0 }, - .end = .{ .x = 1, .y = 0 }, - }, true); + const sel = Selection.init( + s.pages.pin(.{ .screen = .{ .x = 0, .y = 0 } }).?, + s.pages.pin(.{ .screen = .{ .x = 1, .y = 0 } }).?, + false, + ); + const contents = try s.selectionString(alloc, sel, true); defer alloc.free(contents); const expected = "👨‍"; try testing.expectEqualStrings(expected, contents); } } -// X test "Screen: selectionString, rectangle, basic" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 5, 30, 0); + var s = try init(alloc, 30, 5, 0); defer s.deinit(); const str = \\Lorem ipsum dolor @@ -5861,11 +5450,11 @@ test "Screen: selectionString, rectangle, basic" { \\eiusmod tempor incididunt \\ut labore et dolore ; - const sel = Selection{ - .start = .{ .x = 2, .y = 1 }, - .end = .{ .x = 6, .y = 3 }, - .rectangle = true, - }; + const sel = Selection.init( + s.pages.pin(.{ .screen = .{ .x = 2, .y = 1 } }).?, + s.pages.pin(.{ .screen = .{ .x = 6, .y = 3 } }).?, + true, + ); const expected = \\t ame \\ipisc @@ -5878,12 +5467,11 @@ test "Screen: selectionString, rectangle, basic" { try testing.expectEqualStrings(expected, contents); } -// X test "Screen: selectionString, rectangle, w/EOL" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 5, 30, 0); + var s = try init(alloc, 30, 5, 0); defer s.deinit(); const str = \\Lorem ipsum dolor @@ -5892,11 +5480,11 @@ test "Screen: selectionString, rectangle, w/EOL" { \\eiusmod tempor incididunt \\ut labore et dolore ; - const sel = Selection{ - .start = .{ .x = 12, .y = 0 }, - .end = .{ .x = 26, .y = 4 }, - .rectangle = true, - }; + const sel = Selection.init( + s.pages.pin(.{ .screen = .{ .x = 12, .y = 0 } }).?, + s.pages.pin(.{ .screen = .{ .x = 26, .y = 4 } }).?, + true, + ); const expected = \\dolor \\nsectetur @@ -5911,12 +5499,11 @@ test "Screen: selectionString, rectangle, w/EOL" { try testing.expectEqualStrings(expected, contents); } -// X test "Screen: selectionString, rectangle, more complex w/breaks" { const testing = std.testing; const alloc = testing.allocator; - var s = try init(alloc, 8, 30, 0); + var s = try init(alloc, 30, 8, 0); defer s.deinit(); const str = \\Lorem ipsum dolor @@ -5928,11 +5515,11 @@ test "Screen: selectionString, rectangle, more complex w/breaks" { \\magna aliqua. Ut enim \\ad minim veniam, quis ; - const sel = Selection{ - .start = .{ .x = 11, .y = 2 }, - .end = .{ .x = 26, .y = 7 }, - .rectangle = true, - }; + const sel = Selection.init( + s.pages.pin(.{ .screen = .{ .x = 11, .y = 2 } }).?, + s.pages.pin(.{ .screen = .{ .x = 26, .y = 7 } }).?, + true, + ); const expected = \\elit, sed do \\por incididunt @@ -5947,1974 +5534,3 @@ test "Screen: selectionString, rectangle, more complex w/breaks" { defer alloc.free(contents); try testing.expectEqualStrings(expected, contents); } - -test "Screen: dirty with getCellPtr" { - 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()); - - // Ensure all are dirty. Clear em. - var iter = s.rowIterator(.viewport); - while (iter.next()) |row| { - try testing.expect(row.isDirty()); - row.setDirty(false); - } - - // Reset our cursor onto the second row. - s.cursor.x = 0; - s.cursor.y = 1; - - try s.testWriteString("foo"); - { - const row = s.getRow(.{ .active = 0 }); - try testing.expect(!row.isDirty()); - } - { - const row = s.getRow(.{ .active = 1 }); - try testing.expect(row.isDirty()); - } - { - const row = s.getRow(.{ .active = 2 }); - try testing.expect(!row.isDirty()); - - _ = row.getCell(0); - try testing.expect(!row.isDirty()); - } -} - -test "Screen: dirty with clear, fill, fillSlice, copyRow" { - 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()); - - // Ensure all are dirty. Clear em. - var iter = s.rowIterator(.viewport); - while (iter.next()) |row| { - try testing.expect(row.isDirty()); - row.setDirty(false); - } - - { - const row = s.getRow(.{ .active = 0 }); - try testing.expect(!row.isDirty()); - row.clear(.{}); - try testing.expect(row.isDirty()); - row.setDirty(false); - } - - { - const row = s.getRow(.{ .active = 0 }); - try testing.expect(!row.isDirty()); - row.fill(.{ .char = 'A' }); - try testing.expect(row.isDirty()); - row.setDirty(false); - } - - { - const row = s.getRow(.{ .active = 0 }); - try testing.expect(!row.isDirty()); - row.fillSlice(.{ .char = 'A' }, 0, 2); - try testing.expect(row.isDirty()); - row.setDirty(false); - } - - { - const src = s.getRow(.{ .active = 0 }); - const row = s.getRow(.{ .active = 1 }); - try testing.expect(!row.isDirty()); - try row.copyRow(src); - try testing.expect(!src.isDirty()); - try testing.expect(row.isDirty()); - row.setDirty(false); - } -} - -test "Screen: dirty with graphemes" { - 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()); - - // Ensure all are dirty. Clear em. - var iter = s.rowIterator(.viewport); - while (iter.next()) |row| { - try testing.expect(row.isDirty()); - row.setDirty(false); - } - - { - const row = s.getRow(.{ .active = 0 }); - try testing.expect(!row.isDirty()); - try row.attachGrapheme(0, 0xFE0F); - try testing.expect(row.isDirty()); - row.setDirty(false); - row.clearGraphemes(0); - try testing.expect(row.isDirty()); - row.setDirty(false); - } -} - -// X -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); - - // Clear dirty rows - var iter = s.rowIterator(.viewport); - while (iter.next()) |row| row.setDirty(false); - - // Resize - try s.resizeWithoutReflow(10, 5); - { - const contents = try s.testString(alloc, .viewport); - defer alloc.free(contents); - try testing.expectEqualStrings(str, contents); - } - - // Everything should be dirty - iter = s.rowIterator(.viewport); - while (iter.next()) |row| try testing.expect(row.isDirty()); -} - -// X -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); - - { - const contents = try s.testString(alloc, .viewport); - defer alloc.free(contents); - try testing.expectEqualStrings("2EFGH\n3IJKL", contents); - } -} - -// X -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 = .{ .rgb = .{ .r = 0xFF, .g = 0, .b = 0 } }; - } - } - - // 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); - - { - const contents = try s.testString(alloc, .viewport); - defer alloc.free(contents); - try testing.expectEqualStrings("1ABCD", contents); - } -} - -// X -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 = .{ .rgb = .{ .r = 0xFF, .g = 0, .b = 0 } }; - } - } - - // 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); - - { - const contents = try s.testString(alloc, .viewport); - defer alloc.free(contents); - try testing.expectEqualStrings("1ABCD", contents); - } -} - -// X -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); - - { - const contents = try s.testString(alloc, .viewport); - defer alloc.free(contents); - try testing.expectEqualStrings(str, contents); - } -} - -// X -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); - - { - const contents = try s.testString(alloc, .viewport); - defer alloc.free(contents); - const expected = "1ABC\n2EFG\n3IJK"; - try testing.expectEqualStrings(expected, contents); - } -} - -// X -test "Screen: resize (no reflow) more rows with scrollback cursor end" { - 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); - - { - const contents = try s.testString(alloc, .viewport); - defer alloc.free(contents); - try testing.expectEqualStrings(str, contents); - } -} - -// X -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); - - { - const contents = try s.testString(alloc, .screen); - defer alloc.free(contents); - const expected = "2EFGH\n3IJKL\n4ABCD\n5EFGH"; - try testing.expectEqualStrings(expected, contents); - } -} - -// X -// https://github.com/mitchellh/ghostty/issues/1030 -test "Screen: resize (no reflow) less rows with empty trailing" { - const testing = std.testing; - const alloc = testing.allocator; - - var s = try init(alloc, 3, 5, 5); - defer s.deinit(); - const str = "1\n2\n3\n4\n5\n6\n7\n8"; - try s.testWriteString(str); - try s.scroll(.{ .clear = {} }); - s.cursor.x = 0; - s.cursor.y = 0; - try s.testWriteString("A\nB"); - - const cursor = s.cursor; - try s.resizeWithoutReflow(2, 5); - try testing.expectEqual(cursor, s.cursor); - - { - const contents = try s.testString(alloc, .viewport); - defer alloc.free(contents); - try testing.expectEqualStrings("A\nB", contents); - } -} - -// X -test "Screen: resize (no reflow) empty 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); - try testing.expectEqual(@as(usize, 5), s.rowsCapacity()); - - try s.resizeWithoutReflow(10, 10); - try testing.expect(s.rowsWritten() == 0); - - // This is the primary test for this test, we want to ensure we - // always have at least enough capacity for our rows. - try testing.expectEqual(@as(usize, 10), s.rowsCapacity()); -} - -// X -test "Screen: resize (no reflow) grapheme copy" { - 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); - - // Attach graphemes to all the columns - { - var iter = s.rowIterator(.viewport); - while (iter.next()) |row| { - var col: usize = 0; - while (col < s.cols) : (col += 1) { - try row.attachGrapheme(col, 0xFE0F); - } - } - } - - // Clear dirty rows - { - var iter = s.rowIterator(.viewport); - while (iter.next()) |row| row.setDirty(false); - } - - // Resize - try s.resizeWithoutReflow(10, 5); - { - const expected = "1️A️B️C️D️\n2️E️F️G️H️\n3️I️J️K️L️"; - const contents = try s.testString(alloc, .viewport); - defer alloc.free(contents); - try testing.expectEqualStrings(expected, contents); - } - - // Everything should be dirty - { - var iter = s.rowIterator(.viewport); - while (iter.next()) |row| try testing.expect(row.isDirty()); - } -} - -// X -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); - { - const 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); - } - } -} - -// X -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); - - { - const contents = try s.testString(alloc, .viewport); - defer alloc.free(contents); - try testing.expectEqualStrings(str, contents); - } - { - const contents = try s.testString(alloc, .screen); - defer alloc.free(contents); - try testing.expectEqualStrings(str, contents); - } -} - -// X -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); - - { - const contents = try s.testString(alloc, .viewport); - defer alloc.free(contents); - try testing.expectEqualStrings(str, contents); - } - { - const contents = try s.testString(alloc, .screen); - defer alloc.free(contents); - try testing.expectEqualStrings(str, contents); - } -} - -// X -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); - { - const 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); - - { - const contents = try s.testString(alloc, .viewport); - defer alloc.free(contents); - const expected = "3IJKL\n4ABCD\n5EFGH"; - try testing.expectEqualStrings(expected, contents); - } -} - -// X -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); - { - const 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); - - { - const contents = try s.testString(alloc, .viewport); - defer alloc.free(contents); - try testing.expectEqualStrings(str, contents); - } - { - const contents = try s.testString(alloc, .screen); - defer alloc.free(contents); - try testing.expectEqualStrings(str, contents); - } -} - -// X -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); - - { - const contents = try s.testString(alloc, .viewport); - defer alloc.free(contents); - try testing.expectEqualStrings(str, contents); - } - { - const contents = try s.testString(alloc, .screen); - defer alloc.free(contents); - try testing.expectEqualStrings(str, contents); - } -} - -// X -// https://github.com/mitchellh/ghostty/issues/272#issuecomment-1676038963 -test "Screen: resize more cols perfect split" { - 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); - try s.resize(3, 10); - - { - const contents = try s.testString(alloc, .screen); - defer alloc.free(contents); - try testing.expectEqualStrings("1ABCD2EFGH\n3IJKL", contents); - } -} - -// X -// https://github.com/mitchellh/ghostty/issues/1159 -test "Screen: resize (no reflow) more cols with scrollback scrolled up" { - const testing = std.testing; - const alloc = testing.allocator; - - var s = try init(alloc, 3, 5, 5); - defer s.deinit(); - const str = "1\n2\n3\n4\n5\n6\n7\n8"; - try s.testWriteString(str); - - // Cursor at bottom - try testing.expectEqual(@as(usize, 1), s.cursor.x); - try testing.expectEqual(@as(usize, 2), s.cursor.y); - - try s.scroll(.{ .viewport = -4 }); - { - const contents = try s.testString(alloc, .viewport); - defer alloc.free(contents); - try testing.expectEqualStrings("2\n3\n4", contents); - } - - try s.resize(3, 8); - { - const contents = try s.testString(alloc, .screen); - defer alloc.free(contents); - try testing.expectEqualStrings(str, contents); - } - - // Cursor remains at bottom - try testing.expectEqual(@as(usize, 1), s.cursor.x); - try testing.expectEqual(@as(usize, 2), s.cursor.y); -} - -// X -// https://github.com/mitchellh/ghostty/issues/1159 -test "Screen: resize (no reflow) less cols with scrollback scrolled up" { - const testing = std.testing; - const alloc = testing.allocator; - - var s = try init(alloc, 3, 5, 5); - defer s.deinit(); - const str = "1\n2\n3\n4\n5\n6\n7\n8"; - try s.testWriteString(str); - - // Cursor at bottom - try testing.expectEqual(@as(usize, 1), s.cursor.x); - try testing.expectEqual(@as(usize, 2), s.cursor.y); - - try s.scroll(.{ .viewport = -4 }); - { - const contents = try s.testString(alloc, .viewport); - defer alloc.free(contents); - try testing.expectEqualStrings("2\n3\n4", contents); - } - - try s.resize(3, 4); - { - const contents = try s.testString(alloc, .screen); - defer alloc.free(contents); - try testing.expectEqualStrings(str, contents); - } - { - const contents = try s.testString(alloc, .active); - defer alloc.free(contents); - try testing.expectEqualStrings("6\n7\n8", contents); - } - - // Cursor remains at bottom - try testing.expectEqual(@as(usize, 1), s.cursor.x); - try testing.expectEqual(@as(usize, 2), s.cursor.y); -} - -// X -test "Screen: resize more cols no reflow preserves semantic prompt" { - 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); - - // Set one of the rows to be a prompt - { - const row = s.getRow(.{ .active = 1 }); - row.setSemanticPrompt(.prompt); - } - - const cursor = s.cursor; - try s.resize(3, 10); - - // Cursor should not move - try testing.expectEqual(cursor, s.cursor); - - { - const contents = try s.testString(alloc, .viewport); - defer alloc.free(contents); - try testing.expectEqualStrings(str, contents); - } - { - const contents = try s.testString(alloc, .screen); - defer alloc.free(contents); - try testing.expectEqualStrings(str, contents); - } - - // Our one row should still be a semantic prompt, the others should not. - { - const row = s.getRow(.{ .active = 0 }); - try testing.expect(row.getSemanticPrompt() == .unknown); - } - { - const row = s.getRow(.{ .active = 1 }); - try testing.expect(row.getSemanticPrompt() == .prompt); - } - { - const row = s.getRow(.{ .active = 2 }); - try testing.expect(row.getSemanticPrompt() == .unknown); - } -} - -// X -test "Screen: resize more cols grapheme map" { - 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); - - // Attach graphemes to all the columns - { - var iter = s.rowIterator(.viewport); - while (iter.next()) |row| { - var col: usize = 0; - while (col < s.cols) : (col += 1) { - try row.attachGrapheme(col, 0xFE0F); - } - } - } - - const cursor = s.cursor; - try s.resize(3, 10); - - // Cursor should not move - try testing.expectEqual(cursor, s.cursor); - - { - const expected = "1️A️B️C️D️\n2️E️F️G️H️\n3️I️J️K️L️"; - const contents = try s.testString(alloc, .viewport); - defer alloc.free(contents); - try testing.expectEqualStrings(expected, contents); - } - { - const expected = "1️A️B️C️D️\n2️E️F️G️H️\n3️I️J️K️L️"; - const contents = try s.testString(alloc, .screen); - defer alloc.free(contents); - try testing.expectEqualStrings(expected, contents); - } -} - -// X -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(); - const str = "1ABCD2EFGH\n3IJKL"; - try s.testWriteString(str); - - // Verify we soft wrapped - { - const 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(.active, s.cursor.y, s.cursor.x).char); - - // Resize and verify we undid the soft wrap because we have space now - try s.resize(3, 10); - { - const 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); -} - -// X -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(); - const str = "1ABCD2EFGH\n3IJKL"; - try s.testWriteString(str); - - // Verify we soft wrapped - { - const 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(.active, s.cursor.y, s.cursor.x).char); - - // Resize and verify we undid the soft wrap because we have space now - try s.resize(3, 10); - { - const 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(.active, s.cursor.y, s.cursor.x).char); -} - -// X -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(); - const str = "1ABCD2EFGH\n3IJKL"; - try 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(.active, s.cursor.y, s.cursor.x).char); - - // Verify we soft wrapped - { - const 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(3, 7); - { - const 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); -} - -// X -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(); - const str = "1ABCD2EFGH3IJKL"; - 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, '3'), s.getCell(.active, s.cursor.y, s.cursor.x).char); - - // Verify we soft wrapped - { - const 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(3, 15); - { - const 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); -} - -// X -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(); - const str = "1ABCD\n2EFGH\n3IJKL\n4ABCD5EFGH"; - try s.testWriteString(str); - { - const 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(.active, s.cursor.y, s.cursor.x).char); - - // Resize - try s.resize(3, 10); - - // Cursor should still be on the "5" - try testing.expectEqual(@as(u32, '5'), s.getCell(.active, s.cursor.y, s.cursor.x).char); - - { - const contents = try s.testString(alloc, .viewport); - defer alloc.free(contents); - const expected = "2EFGH\n3IJKL\n4ABCD5EFGH"; - try testing.expectEqualStrings(expected, contents); - } -} - -// X -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 - { - const 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); - - { - const 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); -} - -// X -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(); - const str = "1ABCD\n2EFGH\n3IJKL"; - try s.testWriteString(str); - s.cursor.x = 0; - s.cursor.y = 0; - const cursor = s.cursor; - try s.resize(1, 5); - - // Cursor should not move - try testing.expectEqual(cursor, s.cursor); - - { - const contents = try s.testString(alloc, .viewport); - defer alloc.free(contents); - const expected = "3IJKL"; - try testing.expectEqualStrings(expected, contents); - } - { - const contents = try s.testString(alloc, .screen); - defer alloc.free(contents); - const expected = "3IJKL"; - try testing.expectEqualStrings(expected, contents); - } -} - -// X -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(); - const str = "1ABCD\n2EFGH\n3IJKL"; - try 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(.active, s.cursor.y, s.cursor.x).char); - - // Resize - try s.resize(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); - - { - const contents = try s.testString(alloc, .viewport); - defer alloc.free(contents); - const expected = "3IJKL"; - try testing.expectEqualStrings(expected, contents); - } - { - const contents = try s.testString(alloc, .screen); - defer alloc.free(contents); - const expected = "3IJKL"; - try testing.expectEqualStrings(expected, contents); - } -} - -// X -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(); - const str = "1ABCD\n2EFGH\n3IJKL"; - try s.testWriteString(str); - try s.resize(1, 5); - - { - const contents = try s.testString(alloc, .screen); - defer alloc.free(contents); - try testing.expectEqualStrings(str, contents); - } - { - const contents = try s.testString(alloc, .viewport); - defer alloc.free(contents); - const expected = "3IJKL"; - try testing.expectEqualStrings(expected, contents); - } -} - -// X -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(); - const str = "1ABCD\n2EFGH\n3IJKL\n4ABCD\n5EFGH"; - try s.testWriteString(str); - { - const contents = try s.testString(alloc, .viewport); - defer alloc.free(contents); - const expected = "3IJKL\n4ABCD\n5EFGH"; - try testing.expectEqualStrings(expected, contents); - } - - // Resize - try s.resize(1, 5); - - { - const contents = try s.testString(alloc, .screen); - defer alloc.free(contents); - try testing.expectEqualStrings(str, contents); - } - { - const contents = try s.testString(alloc, .viewport); - defer alloc.free(contents); - const expected = "5EFGH"; - try testing.expectEqualStrings(expected, contents); - } -} - -// X -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); - { - const 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); - - { - const contents = try s.testString(alloc, .screen); - defer alloc.free(contents); - const expected = "1ABCD\n2EFGH\n3IJKL\n4ABCD\n5EFGH"; - try testing.expectEqualStrings(expected, contents); - } - { - const contents = try s.testString(alloc, .viewport); - defer alloc.free(contents); - const expected = "4ABCD\n5EFGH"; - try testing.expectEqualStrings(expected, contents); - } -} - -// X -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(); - const str = "1AB\n2EF\n3IJ"; - try s.testWriteString(str); - s.cursor.x = 0; - s.cursor.y = 0; - const cursor = s.cursor; - try s.resize(3, 3); - - // Cursor should not move - try testing.expectEqual(cursor, s.cursor); - - { - const contents = try s.testString(alloc, .viewport); - defer alloc.free(contents); - try testing.expectEqualStrings(str, contents); - } - { - const contents = try s.testString(alloc, .screen); - defer alloc.free(contents); - try testing.expectEqualStrings(str, contents); - } -} - -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 = .{ .rgb = .{ .r = 0xFF } } }; - 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); - - { - const contents = try s.testString(alloc, .viewport); - defer alloc.free(contents); - try testing.expectEqualStrings(str, contents); - } - { - const 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.*); - } -} - -// X -test "Screen: resize less cols with graphemes" { - const testing = std.testing; - const alloc = testing.allocator; - - var s = try init(alloc, 3, 5, 0); - defer s.deinit(); - const str = "1AB\n2EF\n3IJ"; - try s.testWriteString(str); - - // Attach graphemes to all the columns - { - var iter = s.rowIterator(.viewport); - while (iter.next()) |row| { - var col: usize = 0; - while (col < 3) : (col += 1) { - try row.attachGrapheme(col, 0xFE0F); - } - } - } - - s.cursor.x = 0; - s.cursor.y = 0; - const cursor = s.cursor; - try s.resize(3, 3); - - // Cursor should not move - try testing.expectEqual(cursor, s.cursor); - - { - const expected = "1️A️B️\n2️E️F️\n3️I️J️"; - const contents = try s.testString(alloc, .viewport); - defer alloc.free(contents); - try testing.expectEqualStrings(expected, contents); - } - { - const expected = "1️A️B️\n2️E️F️\n3️I️J️"; - const contents = try s.testString(alloc, .screen); - defer alloc.free(contents); - try testing.expectEqualStrings(expected, contents); - } -} - -// X -test "Screen: resize less cols no reflow preserves semantic prompt" { - const testing = std.testing; - const alloc = testing.allocator; - - var s = try init(alloc, 3, 5, 0); - defer s.deinit(); - const str = "1AB\n2EF\n3IJ"; - try s.testWriteString(str); - - // Set one of the rows to be a prompt - { - const row = s.getRow(.{ .active = 1 }); - row.setSemanticPrompt(.prompt); - } - - s.cursor.x = 0; - s.cursor.y = 0; - const cursor = s.cursor; - try s.resize(3, 3); - - // Cursor should not move - try testing.expectEqual(cursor, s.cursor); - - { - const contents = try s.testString(alloc, .viewport); - defer alloc.free(contents); - try testing.expectEqualStrings(str, contents); - } - { - const contents = try s.testString(alloc, .screen); - defer alloc.free(contents); - try testing.expectEqualStrings(str, contents); - } - - // Our one row should still be a semantic prompt, the others should not. - { - const row = s.getRow(.{ .active = 0 }); - try testing.expect(row.getSemanticPrompt() == .unknown); - } - { - const row = s.getRow(.{ .active = 1 }); - try testing.expect(row.getSemanticPrompt() == .prompt); - } - { - const row = s.getRow(.{ .active = 2 }); - try testing.expect(row.getSemanticPrompt() == .unknown); - } -} - -// X -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(); - const str = "1ABCD"; - try 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(.active, s.cursor.y, s.cursor.x).char); - - try s.resize(3, 3); - { - const contents = try s.testString(alloc, .viewport); - defer alloc.free(contents); - const expected = "1AB\nCD"; - try testing.expectEqualStrings(expected, contents); - } - { - const 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); -} - -// X -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(); - const str = "3IJKL\n4ABCD\n5EFGH"; - try s.testWriteString(str); - try s.resize(3, 3); - - { - const contents = try s.testString(alloc, .viewport); - defer alloc.free(contents); - const expected = "CD\n5EF\nGH"; - try testing.expectEqualStrings(expected, contents); - } - { - const contents = try s.testString(alloc, .screen); - defer alloc.free(contents); - const expected = "CD\n5EF\nGH"; - try testing.expectEqualStrings(expected, contents); - } -} - -// X -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(); - const str = "3IJKL\n4ABCD\n5EFGH"; - try s.testWriteString(str); - try s.resize(3, 3); - - { - const contents = try s.testString(alloc, .viewport); - defer alloc.free(contents); - const expected = "CD\n5EF\nGH"; - try testing.expectEqualStrings(expected, contents); - } - { - const contents = try s.testString(alloc, .screen); - defer alloc.free(contents); - const expected = "4AB\nCD\n5EF\nGH"; - try testing.expectEqualStrings(expected, contents); - } -} - -// X -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 - { - const 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); - - // { - // const contents = try s.testString(alloc, .viewport); - // defer alloc.free(contents); - // const expected = "CD\n5EF\nGH"; - // try testing.expectEqualStrings(expected, contents); - // } - { - const contents = try s.testString(alloc, .screen); - defer alloc.free(contents); - const expected = "ABC\nD5E\nFGH"; - try testing.expectEqualStrings(expected, contents); - } -} - -// X -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); - - { - const 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); -} - -// X -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 - { - const 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); - - { - const contents = try s.testString(alloc, .viewport); - defer alloc.free(contents); - const expected = "CD5\nEFG\nH"; - try testing.expectEqualStrings(expected, contents); - } - { - const 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); -} - -// X -test "Screen: resize less cols with scrollback keeps cursor row" { - 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); - - // Lets do a scroll and clear operation - try s.scroll(.{ .clear = {} }); - - // Move our cursor to the beginning - s.cursor.x = 0; - s.cursor.y = 0; - - try s.resize(3, 3); - - { - const contents = try s.testString(alloc, .viewport); - defer alloc.free(contents); - const expected = ""; - try testing.expectEqualStrings(expected, contents); - } - - // Cursor should be on the last line - try testing.expectEqual(@as(usize, 0), s.cursor.x); - try testing.expectEqual(@as(usize, 0), s.cursor.y); -} - -// X -test "Screen: resize more rows, less cols with reflow with scrollback" { - const testing = std.testing; - const alloc = testing.allocator; - - var s = try init(alloc, 3, 5, 3); - defer s.deinit(); - const str = "1ABCD\n2EFGH3IJKL\n4MNOP"; - try s.testWriteString(str); - - { - const contents = try s.testString(alloc, .screen); - defer alloc.free(contents); - const expected = "1ABCD\n2EFGH\n3IJKL\n4MNOP"; - try testing.expectEqualStrings(expected, contents); - } - { - const contents = try s.testString(alloc, .viewport); - defer alloc.free(contents); - const expected = "2EFGH\n3IJKL\n4MNOP"; - try testing.expectEqualStrings(expected, contents); - } - - try s.resize(10, 2); - - { - const contents = try s.testString(alloc, .viewport); - defer alloc.free(contents); - const expected = "BC\nD\n2E\nFG\nH3\nIJ\nKL\n4M\nNO\nP"; - try testing.expectEqualStrings(expected, contents); - } - { - const contents = try s.testString(alloc, .screen); - defer alloc.free(contents); - const expected = "1A\nBC\nD\n2E\nFG\nH3\nIJ\nKL\n4M\nNO\nP"; - try testing.expectEqualStrings(expected, contents); - } -} - -// X -// 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(); - const str = "1ABC"; - try s.testWriteString(str); - - // Grow - try s.resize(10, 5); - { - const contents = try s.testString(alloc, .viewport); - defer alloc.free(contents); - try testing.expectEqualStrings(str, contents); - } - { - const contents = try s.testString(alloc, .screen); - defer alloc.free(contents); - try testing.expectEqualStrings(str, contents); - } - - // Shrink - try s.resize(3, 5); - { - const contents = try s.testString(alloc, .screen); - defer alloc.free(contents); - try testing.expectEqualStrings(str, contents); - } - { - const contents = try s.testString(alloc, .viewport); - defer alloc.free(contents); - try testing.expectEqualStrings(str, contents); - } - - // Grow again - try s.resize(10, 5); - { - const contents = try s.testString(alloc, .viewport); - defer alloc.free(contents); - try testing.expectEqualStrings(str, contents); - } - { - const contents = try s.testString(alloc, .screen); - defer alloc.free(contents); - try testing.expectEqualStrings(str, contents); - } -} - -// X -test "Screen: resize less cols to eliminate wide char" { - const testing = std.testing; - const alloc = testing.allocator; - - var s = try init(alloc, 1, 2, 0); - defer s.deinit(); - const str = "😀"; - try s.testWriteString(str); - { - const contents = try s.testString(alloc, .screen); - defer alloc.free(contents); - try testing.expectEqualStrings(str, contents); - } - { - const cell = s.getCell(.screen, 0, 0); - try testing.expectEqual(@as(u32, '😀'), cell.char); - try testing.expect(cell.attrs.wide); - } - - // Resize to 1 column can't fit a wide char. So it should be deleted. - try s.resize(1, 1); - { - const contents = try s.testString(alloc, .screen); - defer alloc.free(contents); - try testing.expectEqualStrings(" ", contents); - } - - const cell = s.getCell(.screen, 0, 0); - try testing.expectEqual(@as(u32, ' '), cell.char); - try testing.expect(!cell.attrs.wide); - try testing.expect(!cell.attrs.wide_spacer_tail); - try testing.expect(!cell.attrs.wide_spacer_head); -} - -// X -test "Screen: resize less cols to wrap wide char" { - const testing = std.testing; - const alloc = testing.allocator; - - var s = try init(alloc, 3, 3, 0); - defer s.deinit(); - const str = "x😀"; - try s.testWriteString(str); - { - const contents = try s.testString(alloc, .screen); - defer alloc.free(contents); - try testing.expectEqualStrings(str, contents); - } - { - const cell = s.getCell(.screen, 0, 1); - try testing.expectEqual(@as(u32, '😀'), cell.char); - try testing.expect(cell.attrs.wide); - try testing.expect(s.getCell(.screen, 0, 2).attrs.wide_spacer_tail); - } - - try s.resize(3, 2); - { - const contents = try s.testString(alloc, .screen); - defer alloc.free(contents); - try testing.expectEqualStrings("x\n😀", contents); - } - { - const cell = s.getCell(.screen, 0, 1); - try testing.expectEqual(@as(u32, ' '), cell.char); - try testing.expect(!cell.attrs.wide); - try testing.expect(!cell.attrs.wide_spacer_tail); - try testing.expect(cell.attrs.wide_spacer_head); - } -} - -// X -test "Screen: resize less cols to eliminate wide char with row space" { - const testing = std.testing; - const alloc = testing.allocator; - - var s = try init(alloc, 2, 2, 0); - defer s.deinit(); - const str = "😀"; - try s.testWriteString(str); - { - const contents = try s.testString(alloc, .screen); - defer alloc.free(contents); - try testing.expectEqualStrings(str, contents); - } - { - const cell = s.getCell(.screen, 0, 0); - try testing.expectEqual(@as(u32, '😀'), cell.char); - try testing.expect(cell.attrs.wide); - try testing.expect(s.getCell(.screen, 0, 1).attrs.wide_spacer_tail); - } - - try s.resize(2, 1); - { - const contents = try s.testString(alloc, .screen); - defer alloc.free(contents); - try testing.expectEqualStrings(" \n ", contents); - } - { - const cell = s.getCell(.screen, 0, 0); - try testing.expectEqual(@as(u32, ' '), cell.char); - try testing.expect(!cell.attrs.wide); - try testing.expect(!cell.attrs.wide_spacer_tail); - try testing.expect(!cell.attrs.wide_spacer_head); - } -} - -// X -test "Screen: resize more cols with wide spacer head" { - const testing = std.testing; - const alloc = testing.allocator; - - var s = try init(alloc, 2, 3, 0); - defer s.deinit(); - const str = " 😀"; - try s.testWriteString(str); - { - const contents = try s.testString(alloc, .screen); - defer alloc.free(contents); - try testing.expectEqualStrings(" \n😀", contents); - } - - // So this is the key point: we end up with a wide spacer head at - // the end of row 1, then the emoji, then a wide spacer tail on row 2. - // We should expect that if we resize to more cols, the wide spacer - // head is replaced with the emoji. - { - const cell = s.getCell(.screen, 0, 2); - try testing.expectEqual(@as(u32, ' '), cell.char); - try testing.expect(cell.attrs.wide_spacer_head); - try testing.expect(s.getCell(.screen, 1, 0).attrs.wide); - try testing.expect(s.getCell(.screen, 1, 1).attrs.wide_spacer_tail); - } - - try s.resize(2, 4); - { - const contents = try s.testString(alloc, .screen); - defer alloc.free(contents); - try testing.expectEqualStrings(str, contents); - } - { - const cell = s.getCell(.screen, 0, 2); - try testing.expectEqual(@as(u32, '😀'), cell.char); - try testing.expect(!cell.attrs.wide_spacer_head); - try testing.expect(cell.attrs.wide); - try testing.expect(s.getCell(.screen, 0, 3).attrs.wide_spacer_tail); - } -} - -// X -test "Screen: resize less cols preserves grapheme cluster" { - const testing = std.testing; - const alloc = testing.allocator; - - var s = try init(alloc, 1, 5, 0); - defer s.deinit(); - const str: []const u8 = &.{ 0x43, 0xE2, 0x83, 0x90 }; // C⃐ (C with combining left arrow) - try s.testWriteString(str); - - // We should have a single cell with all the codepoints - { - const row = s.getRow(.{ .screen = 0 }); - try testing.expectEqual(@as(usize, 2), row.codepointLen(0)); - } - { - const contents = try s.testString(alloc, .screen); - defer alloc.free(contents); - try testing.expectEqualStrings(str, contents); - } - - // Resize to less columns. No wrapping, but we should still have - // the same grapheme cluster. - try s.resize(1, 4); - { - const contents = try s.testString(alloc, .screen); - defer alloc.free(contents); - try testing.expectEqualStrings(str, contents); - } -} - -// X -test "Screen: resize more cols with wide spacer head multiple lines" { - const testing = std.testing; - const alloc = testing.allocator; - - var s = try init(alloc, 3, 3, 0); - defer s.deinit(); - const str = "xxxyy😀"; - try s.testWriteString(str); - { - const contents = try s.testString(alloc, .screen); - defer alloc.free(contents); - try testing.expectEqualStrings("xxx\nyy\n😀", contents); - } - - // Similar to the "wide spacer head" test, but this time we'er going - // to increase our columns such that multiple rows are unwrapped. - { - const cell = s.getCell(.screen, 1, 2); - try testing.expectEqual(@as(u32, ' '), cell.char); - try testing.expect(cell.attrs.wide_spacer_head); - try testing.expect(s.getCell(.screen, 2, 0).attrs.wide); - try testing.expect(s.getCell(.screen, 2, 1).attrs.wide_spacer_tail); - } - - try s.resize(2, 8); - { - const contents = try s.testString(alloc, .screen); - defer alloc.free(contents); - try testing.expectEqualStrings(str, contents); - } - { - const cell = s.getCell(.screen, 0, 5); - try testing.expect(!cell.attrs.wide_spacer_head); - try testing.expectEqual(@as(u32, '😀'), cell.char); - try testing.expect(cell.attrs.wide); - try testing.expect(s.getCell(.screen, 0, 6).attrs.wide_spacer_tail); - } -} - -// X -test "Screen: resize more cols requiring a wide spacer head" { - const testing = std.testing; - const alloc = testing.allocator; - - var s = try init(alloc, 2, 2, 0); - defer s.deinit(); - const str = "xx😀"; - try s.testWriteString(str); - { - const contents = try s.testString(alloc, .screen); - defer alloc.free(contents); - try testing.expectEqualStrings("xx\n😀", contents); - } - { - try testing.expect(s.getCell(.screen, 1, 0).attrs.wide); - try testing.expect(s.getCell(.screen, 1, 1).attrs.wide_spacer_tail); - } - - // This resizes to 3 columns, which isn't enough space for our wide - // char to enter row 1. But we need to mark the wide spacer head on the - // end of the first row since we're wrapping to the next row. - try s.resize(2, 3); - { - const contents = try s.testString(alloc, .screen); - defer alloc.free(contents); - try testing.expectEqualStrings("xx\n😀", contents); - } - { - const cell = s.getCell(.screen, 0, 2); - try testing.expectEqual(@as(u32, ' '), cell.char); - try testing.expect(cell.attrs.wide_spacer_head); - try testing.expect(s.getCell(.screen, 1, 0).attrs.wide); - try testing.expect(s.getCell(.screen, 1, 1).attrs.wide_spacer_tail); - } - { - const cell = s.getCell(.screen, 1, 0); - try testing.expectEqual(@as(u32, '😀'), cell.char); - try testing.expect(cell.attrs.wide); - try testing.expect(s.getCell(.screen, 1, 1).attrs.wide_spacer_tail); - } -} - -test "Screen: jump zero" { - const testing = std.testing; - const alloc = testing.allocator; - - var s = try init(alloc, 3, 5, 10); - defer s.deinit(); - try s.testWriteString("1ABCD\n2EFGH\n3IJKL\n"); - try s.testWriteString("4ABCD\n5EFGH\n6IJKL"); - try testing.expect(s.viewportIsBottom()); - - // Set semantic prompts - { - const row = s.getRow(.{ .screen = 1 }); - row.setSemanticPrompt(.prompt); - } - { - const row = s.getRow(.{ .screen = 5 }); - row.setSemanticPrompt(.prompt); - } - - try testing.expect(!s.jump(.{ .prompt_delta = 0 })); - try testing.expectEqual(@as(usize, 3), s.viewport); -} - -test "Screen: jump to prompt" { - const testing = std.testing; - const alloc = testing.allocator; - - var s = try init(alloc, 3, 5, 10); - defer s.deinit(); - try s.testWriteString("1ABCD\n2EFGH\n3IJKL\n"); - try s.testWriteString("4ABCD\n5EFGH\n6IJKL"); - try testing.expect(s.viewportIsBottom()); - - // Set semantic prompts - { - const row = s.getRow(.{ .screen = 1 }); - row.setSemanticPrompt(.prompt); - } - { - const row = s.getRow(.{ .screen = 5 }); - row.setSemanticPrompt(.prompt); - } - - // Jump back - try testing.expect(s.jump(.{ .prompt_delta = -1 })); - try testing.expectEqual(@as(usize, 1), s.viewport); - - // Jump back - try testing.expect(!s.jump(.{ .prompt_delta = -1 })); - try testing.expectEqual(@as(usize, 1), s.viewport); - - // Jump forward - try testing.expect(s.jump(.{ .prompt_delta = 1 })); - try testing.expectEqual(@as(usize, 3), s.viewport); - - // Jump forward - try testing.expect(!s.jump(.{ .prompt_delta = 1 })); - try testing.expectEqual(@as(usize, 3), s.viewport); -} - -test "Screen: row graphemeBreak" { - const testing = std.testing; - const alloc = testing.allocator; - - var s = try init(alloc, 1, 10, 0); - defer s.deinit(); - try s.testWriteString("x"); - try s.testWriteString("👨‍A"); - - const row = s.getRow(.{ .screen = 0 }); - - // Normal char is a break - try testing.expect(row.graphemeBreak(0)); - - // Emoji with ZWJ is not - try testing.expect(!row.graphemeBreak(1)); -} diff --git a/src/terminal/Selection.zig b/src/terminal/Selection.zig index d29513d73..a404cf0e5 100644 --- a/src/terminal/Selection.zig +++ b/src/terminal/Selection.zig @@ -1,48 +1,243 @@ -/// Represents a single selection within the terminal -/// (i.e. a highlight region). +//! Represents a single selection within the terminal (i.e. a highlight region). const Selection = @This(); const std = @import("std"); const assert = std.debug.assert; +const page = @import("page.zig"); const point = @import("point.zig"); +const PageList = @import("PageList.zig"); const Screen = @import("Screen.zig"); -const ScreenPoint = point.ScreenPoint; +const Pin = PageList.Pin; -/// Start and end of the selection. There is no guarantee that -/// start is before end or vice versa. If a user selects backwards, -/// start will be after end, and vice versa. Use the struct functions -/// to not have to worry about this. -start: ScreenPoint, -end: ScreenPoint, +// NOTE(mitchellh): I'm not very happy with how this is implemented, because +// the ordering operations which are used frequently require using +// pointFromPin which -- at the time of writing this -- is slow. The overall +// style of this struct is due to porting it from the previous implementation +// which had an efficient ordering operation. +// +// While reimplementing this, there were too many callers that already +// depended on this behavior so I kept it despite the inefficiency. In the +// future, we should take a look at this again! + +/// The bounds of the selection. +bounds: Bounds, /// Whether or not this selection refers to a rectangle, rather than whole /// lines of a buffer. In this mode, start and end refer to the top left and /// bottom right of the rectangle, or vice versa if the selection is backwards. rectangle: bool = false, -/// Converts a selection screen points to viewport points (still typed -/// as ScreenPoints) if the selection is present within the viewport -/// of the screen. -pub fn toViewport(self: Selection, screen: *const Screen) ?Selection { - const top = (point.Viewport{ .x = 0, .y = 0 }).toScreen(screen); - const bot = (point.Viewport{ .x = screen.cols - 1, .y = screen.rows - 1 }).toScreen(screen); +/// The bounds of the selection. A selection bounds can be either tracked +/// or untracked. Untracked bounds are unsafe beyond the point the terminal +/// screen may be modified, since they may point to invalid memory. Tracked +/// bounds are always valid and will be updated as the screen changes, but +/// are more expensive to exist. +/// +/// In all cases, start and end can be in any order. There is no guarantee that +/// start is before end or vice versa. If a user selects backwards, +/// start will be after end, and vice versa. Use the struct functions +/// to not have to worry about this. +pub const Bounds = union(enum) { + untracked: struct { + start: Pin, + end: Pin, + }, - // If our selection isn't within the viewport, do nothing. - if (!self.within(top, bot)) return null; + tracked: struct { + start: *Pin, + end: *Pin, + }, +}; - // Convert - const start = self.start.toViewport(screen); - const end = self.end.toViewport(screen); - return Selection{ - .start = .{ .x = if (self.rectangle) self.start.x else start.x, .y = start.y }, - .end = .{ .x = if (self.rectangle) self.end.x else end.x, .y = end.y }, - .rectangle = self.rectangle, +/// Initialize a new selection with the given start and end pins on +/// the screen. The screen will be used for pin tracking. +pub fn init( + start_pin: Pin, + end_pin: Pin, + rect: bool, +) Selection { + return .{ + .bounds = .{ .untracked = .{ + .start = start_pin, + .end = end_pin, + } }, + .rectangle = rect, }; } -/// Returns true if the selection is empty. -pub fn empty(self: Selection) bool { - return self.start.x == self.end.x and self.start.y == self.end.y; +pub fn deinit( + self: Selection, + s: *Screen, +) void { + switch (self.bounds) { + .tracked => |v| { + s.pages.untrackPin(v.start); + s.pages.untrackPin(v.end); + }, + + .untracked => {}, + } +} + +/// Returns true if this selection is equal to another selection. +pub fn eql(self: Selection, other: Selection) bool { + return self.start().eql(other.start()) and + self.end().eql(other.end()) and + self.rectangle == other.rectangle; +} + +/// The starting pin of the selection. This is NOT ordered. +pub fn startPtr(self: *Selection) *Pin { + return switch (self.bounds) { + .untracked => |*v| &v.start, + .tracked => |v| v.start, + }; +} + +/// The ending pin of the selection. This is NOT ordered. +pub fn endPtr(self: *Selection) *Pin { + return switch (self.bounds) { + .untracked => |*v| &v.end, + .tracked => |v| v.end, + }; +} + +pub fn start(self: Selection) Pin { + return switch (self.bounds) { + .untracked => |v| v.start, + .tracked => |v| v.start.*, + }; +} + +pub fn end(self: Selection) Pin { + return switch (self.bounds) { + .untracked => |v| v.end, + .tracked => |v| v.end.*, + }; +} + +/// Returns true if this is a tracked selection. +pub fn tracked(self: *const Selection) bool { + return switch (self.bounds) { + .untracked => false, + .tracked => true, + }; +} + +/// Convert this selection a tracked selection. It is asserted this is +/// an untracked selection. +pub fn track(self: *Selection, s: *Screen) !void { + assert(!self.tracked()); + + // Track our pins + const start_pin = self.bounds.untracked.start; + const end_pin = self.bounds.untracked.end; + const tracked_start = try s.pages.trackPin(start_pin); + errdefer s.pages.untrackPin(tracked_start); + const tracked_end = try s.pages.trackPin(end_pin); + errdefer s.pages.untrackPin(tracked_end); + + self.bounds = .{ .tracked = .{ + .start = tracked_start, + .end = tracked_end, + } }; +} + +/// Returns the top left point of the selection. +pub fn topLeft(self: Selection, s: *const Screen) Pin { + return switch (self.order(s)) { + .forward => self.start(), + .reverse => self.end(), + .mirrored_forward => pin: { + var p = self.start(); + p.x = self.end().x; + break :pin p; + }, + .mirrored_reverse => pin: { + var p = self.end(); + p.x = self.start().x; + break :pin p; + }, + }; +} + +/// Returns the bottom right point of the selection. +pub fn bottomRight(self: Selection, s: *const Screen) Pin { + return switch (self.order(s)) { + .forward => self.end(), + .reverse => self.start(), + .mirrored_forward => pin: { + var p = self.end(); + p.x = self.start().x; + break :pin p; + }, + .mirrored_reverse => pin: { + var p = self.start(); + p.x = self.end().x; + break :pin p; + }, + }; +} + +/// The order of the selection: +/// +/// * forward: start(x, y) is before end(x, y) (top-left to bottom-right). +/// * reverse: end(x, y) is before start(x, y) (bottom-right to top-left). +/// * mirrored_[forward|reverse]: special, rectangle selections only (see below). +/// +/// For regular selections, the above also holds for top-right to bottom-left +/// (forward) and bottom-left to top-right (reverse). However, for rectangle +/// selections, both of these selections are *mirrored* as orientation +/// operations only flip the x or y axis, not both. Depending on the y axis +/// direction, this is either mirrored_forward or mirrored_reverse. +/// +pub const Order = enum { forward, reverse, mirrored_forward, mirrored_reverse }; + +pub fn order(self: Selection, s: *const Screen) Order { + const start_pt = s.pages.pointFromPin(.screen, self.start()).?.screen; + const end_pt = s.pages.pointFromPin(.screen, self.end()).?.screen; + + if (self.rectangle) { + // Reverse (also handles single-column) + if (start_pt.y > end_pt.y and start_pt.x >= end_pt.x) return .reverse; + if (start_pt.y >= end_pt.y and start_pt.x > end_pt.x) return .reverse; + + // Mirror, bottom-left to top-right + if (start_pt.y > end_pt.y and start_pt.x < end_pt.x) return .mirrored_reverse; + + // Mirror, top-right to bottom-left + if (start_pt.y < end_pt.y and start_pt.x > end_pt.x) return .mirrored_forward; + + // Forward + return .forward; + } + + if (start_pt.y < end_pt.y) return .forward; + if (start_pt.y > end_pt.y) return .reverse; + if (start_pt.x <= end_pt.x) return .forward; + return .reverse; +} + +/// Returns the selection in the given order. +/// +/// The returned selection is always a new untracked selection. +/// +/// Note that only forward and reverse are useful desired orders for this +/// function. All other orders act as if forward order was desired. +pub fn ordered(self: Selection, s: *const Screen, desired: Order) Selection { + if (self.order(s) == desired) return Selection.init( + self.start(), + self.end(), + self.rectangle, + ); + + const tl = self.topLeft(s); + const br = self.bottomRight(s); + return switch (desired) { + .forward => Selection.init(tl, br, self.rectangle), + .reverse => Selection.init(br, tl, self.rectangle), + else => Selection.init(tl, br, self.rectangle), + }; } /// Returns true if the selection contains the given point. @@ -50,12 +245,15 @@ pub fn empty(self: Selection) bool { /// This recalculates top left and bottom right each call. If you have /// many points to check, it is cheaper to do the containment logic /// yourself and cache the topleft/bottomright. -pub fn contains(self: Selection, p: ScreenPoint) bool { - const tl = self.topLeft(); - const br = self.bottomRight(); +pub fn contains(self: Selection, s: *const Screen, pin: Pin) bool { + const tl_pin = self.topLeft(s); + const br_pin = self.bottomRight(s); - // Honestly there is probably way more efficient boolean logic here. - // Look back at this in the future... + // This is definitely not very efficient. Low-hanging fruit to + // improve this. + const tl = s.pages.pointFromPin(.screen, tl_pin).?.screen; + const br = s.pages.pointFromPin(.screen, br_pin).?.screen; + const p = s.pages.pointFromPin(.screen, pin).?.screen; // If we're in rectangle select, we can short-circuit with an easy check // here @@ -77,151 +275,6 @@ pub fn contains(self: Selection, p: ScreenPoint) bool { return p.y > tl.y and p.y < br.y; } -/// Returns true if the selection contains any of the points between -/// (and including) the start and end. The x values are ignored this is -/// just a section match -pub fn within(self: Selection, start: ScreenPoint, end: ScreenPoint) bool { - const tl = self.topLeft(); - const br = self.bottomRight(); - - // Bottom right is before start, no way we are in it. - if (br.y < start.y) return false; - // Bottom right is the first line, only if our x is in it. - if (br.y == start.y) return br.x >= start.x; - - // If top left is beyond the end, we're not in it. - if (tl.y > end.y) return false; - // If top left is on the end, only if our x is in it. - if (tl.y == end.y) return tl.x <= end.x; - - return true; -} - -/// Returns true if the selection contains the row of the given point, -/// regardless of the x value. -pub fn containsRow(self: Selection, p: ScreenPoint) bool { - const tl = self.topLeft(); - const br = self.bottomRight(); - return p.y >= tl.y and p.y <= br.y; -} - -/// Get a selection for a single row in the screen. This will return null -/// if the row is not included in the selection. -pub fn containedRow(self: Selection, screen: *const Screen, p: ScreenPoint) ?Selection { - const tl = self.topLeft(); - const br = self.bottomRight(); - if (p.y < tl.y or p.y > br.y) return null; - - // Rectangle case: we can return early as the x range will always be the - // same. We've already validated that the row is in the selection. - if (self.rectangle) return .{ - .start = .{ .y = p.y, .x = tl.x }, - .end = .{ .y = p.y, .x = br.x }, - .rectangle = true, - }; - - if (p.y == tl.y) { - // If the selection is JUST this line, return it as-is. - if (p.y == br.y) { - return self; - } - - // Selection top-left line matches only. - return .{ - .start = tl, - .end = .{ .y = tl.y, .x = screen.cols - 1 }, - }; - } - - // Row is our bottom selection, so we return the selection from the - // beginning of the line to the br. We know our selection is more than - // one line (due to conditionals above) - if (p.y == br.y) { - assert(p.y != tl.y); - return .{ - .start = .{ .y = br.y, .x = 0 }, - .end = br, - }; - } - - // Row is somewhere between our selection lines so we return the full line. - return .{ - .start = .{ .y = p.y, .x = 0 }, - .end = .{ .y = p.y, .x = screen.cols - 1 }, - }; -} - -/// Returns the top left point of the selection. -pub fn topLeft(self: Selection) ScreenPoint { - return switch (self.order()) { - .forward => self.start, - .reverse => self.end, - .mirrored_forward => .{ .x = self.end.x, .y = self.start.y }, - .mirrored_reverse => .{ .x = self.start.x, .y = self.end.y }, - }; -} - -/// Returns the bottom right point of the selection. -pub fn bottomRight(self: Selection) ScreenPoint { - return switch (self.order()) { - .forward => self.end, - .reverse => self.start, - .mirrored_forward => .{ .x = self.start.x, .y = self.end.y }, - .mirrored_reverse => .{ .x = self.end.x, .y = self.start.y }, - }; -} - -/// Returns the selection in the given order. -/// -/// Note that only forward and reverse are useful desired orders for this -/// function. All other orders act as if forward order was desired. -pub fn ordered(self: Selection, desired: Order) Selection { - if (self.order() == desired) return self; - const tl = self.topLeft(); - const br = self.bottomRight(); - return switch (desired) { - .forward => .{ .start = tl, .end = br, .rectangle = self.rectangle }, - .reverse => .{ .start = br, .end = tl, .rectangle = self.rectangle }, - else => .{ .start = tl, .end = br, .rectangle = self.rectangle }, - }; -} - -/// The order of the selection: -/// -/// * forward: start(x, y) is before end(x, y) (top-left to bottom-right). -/// * reverse: end(x, y) is before start(x, y) (bottom-right to top-left). -/// * mirrored_[forward|reverse]: special, rectangle selections only (see below). -/// -/// For regular selections, the above also holds for top-right to bottom-left -/// (forward) and bottom-left to top-right (reverse). However, for rectangle -/// selections, both of these selections are *mirrored* as orientation -/// operations only flip the x or y axis, not both. Depending on the y axis -/// direction, this is either mirrored_forward or mirrored_reverse. -/// -pub const Order = enum { forward, reverse, mirrored_forward, mirrored_reverse }; - -pub fn order(self: Selection) Order { - if (self.rectangle) { - // Reverse (also handles single-column) - if (self.start.y > self.end.y and self.start.x >= self.end.x) return .reverse; - if (self.start.y >= self.end.y and self.start.x > self.end.x) return .reverse; - - // Mirror, bottom-left to top-right - if (self.start.y > self.end.y and self.start.x < self.end.x) return .mirrored_reverse; - - // Mirror, top-right to bottom-left - if (self.start.y < self.end.y and self.start.x > self.end.x) return .mirrored_forward; - - // Forward - return .forward; - } - - if (self.start.y < self.end.y) return .forward; - if (self.start.y > self.end.y) return .reverse; - if (self.start.x <= self.end.x) return .forward; - return .reverse; -} - /// Possible adjustments to the selection. pub const Adjustment = enum { left, @@ -236,45 +289,50 @@ pub const Adjustment = enum { /// Adjust the selection by some given adjustment. An adjustment allows /// a selection to be expanded slightly left, right, up, down, etc. -pub fn adjust(self: Selection, screen: *Screen, adjustment: Adjustment) Selection { - const screen_end = Screen.RowIndexTag.screen.maxLen(screen) - 1; - - // Make an editable one because its so much easier to use modification - // logic below than it is to reconstruct the selection every time. - var result = self; - +pub fn adjust( + self: *Selection, + s: *const Screen, + adjustment: Adjustment, +) void { // Note that we always adjusts "end" because end always represents // the last point of the selection by mouse, not necessarilly the // top/bottom visually. So this results in the right behavior // whether the user drags up or down. + const end_pin = self.endPtr(); switch (adjustment) { - .up => if (result.end.y == 0) { - result.end.x = 0; + .up => if (end_pin.up(1)) |new_end| { + end_pin.* = new_end; } else { - result.end.y -= 1; + end_pin.x = 0; }, - .down => if (result.end.y >= screen_end) { - result.end.y = screen_end; - result.end.x = screen.cols - 1; - } else { - result.end.y += 1; + .down => { + // Find the next non-blank row + var current = end_pin.*; + while (current.down(1)) |next| : (current = next) { + const rac = next.rowAndCell(); + const cells = next.page.data.getCells(rac.row); + if (page.Cell.hasTextAny(cells)) { + end_pin.* = next; + break; + } + } else { + // If we're at the bottom, just go to the end of the line + end_pin.x = end_pin.page.data.size.cols - 1; + } }, .left => { - // Step left, wrapping to the next row up at the start of each new line, - // until we find a non-empty cell. - // - // This iterator emits the start point first, throw it out. - var iterator = result.end.iterator(screen, .left_up); - _ = iterator.next(); - while (iterator.next()) |next| { - if (screen.getCell( - .screen, - next.y, - next.x, - ).char != 0) { - result.end = next; + var it = s.pages.cellIterator( + .left_up, + .{ .screen = .{} }, + s.pages.pointFromPin(.screen, end_pin.*).?, + ); + _ = it.next(); + while (it.next()) |next| { + const rac = next.rowAndCell(); + if (rac.cell.hasText()) { + end_pin.* = next; break; } } @@ -283,543 +341,472 @@ pub fn adjust(self: Selection, screen: *Screen, adjustment: Adjustment) Selectio .right => { // Step right, wrapping to the next row down at the start of each new line, // until we find a non-empty cell. - var iterator = result.end.iterator(screen, .right_down); - _ = iterator.next(); - while (iterator.next()) |next| { - if (next.y > screen_end) break; - if (screen.getCell( - .screen, - next.y, - next.x, - ).char != 0) { - if (next.y > screen_end) { - result.end.y = screen_end; - } else { - result.end = next; - } + var it = s.pages.cellIterator( + .right_down, + s.pages.pointFromPin(.screen, end_pin.*).?, + null, + ); + _ = it.next(); + while (it.next()) |next| { + const rac = next.rowAndCell(); + if (rac.cell.hasText()) { + end_pin.* = next; break; } } }, - .page_up => if (screen.rows > result.end.y) { - result.end.y = 0; - result.end.x = 0; + .page_up => if (end_pin.up(s.pages.rows)) |new_end| { + end_pin.* = new_end; } else { - result.end.y -= screen.rows; + self.adjust(s, .home); }, - .page_down => if (screen.rows > screen_end - result.end.y) { - result.end.y = screen_end; - result.end.x = screen.cols - 1; + // TODO(paged-terminal): this doesn't take into account blanks + .page_down => if (end_pin.down(s.pages.rows)) |new_end| { + end_pin.* = new_end; } else { - result.end.y += screen.rows; + self.adjust(s, .end); }, - .home => { - result.end.y = 0; - result.end.x = 0; - }, + .home => end_pin.* = s.pages.pin(.{ .screen = .{ + .x = 0, + .y = 0, + } }).?, .end => { - result.end.y = screen_end; - result.end.x = screen.cols - 1; + var it = s.pages.rowIterator( + .left_up, + .{ .screen = .{} }, + null, + ); + while (it.next()) |next| { + const rac = next.rowAndCell(); + const cells = next.page.data.getCells(rac.row); + if (page.Cell.hasTextAny(cells)) { + end_pin.* = next; + end_pin.x = cells.len - 1; + break; + } + } }, } - - return result; } -// X test "Selection: adjust right" { const testing = std.testing; - var screen = try Screen.init(testing.allocator, 5, 10, 0); - defer screen.deinit(); - try screen.testWriteString("A1234\nB5678\nC1234\nD5678"); + var s = try Screen.init(testing.allocator, 5, 10, 0); + defer s.deinit(); + try s.testWriteString("A1234\nB5678\nC1234\nD5678"); // Simple movement right { - const sel = (Selection{ - .start = .{ .x = 5, .y = 1 }, - .end = .{ .x = 3, .y = 3 }, - }).adjust(&screen, .right); + var sel = Selection.init( + s.pages.pin(.{ .screen = .{ .x = 5, .y = 1 } }).?, + s.pages.pin(.{ .screen = .{ .x = 3, .y = 3 } }).?, + false, + ); + defer sel.deinit(&s); + sel.adjust(&s, .right); - try testing.expectEqual(Selection{ - .start = .{ .x = 5, .y = 1 }, - .end = .{ .x = 4, .y = 3 }, - }, sel); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 5, + .y = 1, + } }, s.pages.pointFromPin(.screen, sel.start()).?); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 4, + .y = 3, + } }, s.pages.pointFromPin(.screen, sel.end()).?); } // Already at end of the line. { - const sel = (Selection{ - .start = .{ .x = 5, .y = 1 }, - .end = .{ .x = 4, .y = 2 }, - }).adjust(&screen, .right); + var sel = Selection.init( + s.pages.pin(.{ .screen = .{ .x = 4, .y = 1 } }).?, + s.pages.pin(.{ .screen = .{ .x = 4, .y = 2 } }).?, + false, + ); + defer sel.deinit(&s); + sel.adjust(&s, .right); - try testing.expectEqual(Selection{ - .start = .{ .x = 5, .y = 1 }, - .end = .{ .x = 0, .y = 3 }, - }, sel); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 4, + .y = 1, + } }, s.pages.pointFromPin(.screen, sel.start()).?); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 3, + } }, s.pages.pointFromPin(.screen, sel.end()).?); } // Already at end of the screen { - const sel = (Selection{ - .start = .{ .x = 5, .y = 1 }, - .end = .{ .x = 4, .y = 3 }, - }).adjust(&screen, .right); + var sel = Selection.init( + s.pages.pin(.{ .screen = .{ .x = 5, .y = 1 } }).?, + s.pages.pin(.{ .screen = .{ .x = 4, .y = 3 } }).?, + false, + ); + defer sel.deinit(&s); + sel.adjust(&s, .right); - try testing.expectEqual(Selection{ - .start = .{ .x = 5, .y = 1 }, - .end = .{ .x = 4, .y = 3 }, - }, sel); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 5, + .y = 1, + } }, s.pages.pointFromPin(.screen, sel.start()).?); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 4, + .y = 3, + } }, s.pages.pointFromPin(.screen, sel.end()).?); } } -// X test "Selection: adjust left" { const testing = std.testing; - var screen = try Screen.init(testing.allocator, 5, 10, 0); - defer screen.deinit(); - try screen.testWriteString("A1234\nB5678\nC1234\nD5678"); + var s = try Screen.init(testing.allocator, 5, 10, 0); + defer s.deinit(); + try s.testWriteString("A1234\nB5678\nC1234\nD5678"); // Simple movement left { - const sel = (Selection{ - .start = .{ .x = 5, .y = 1 }, - .end = .{ .x = 3, .y = 3 }, - }).adjust(&screen, .left); + var sel = Selection.init( + s.pages.pin(.{ .screen = .{ .x = 5, .y = 1 } }).?, + s.pages.pin(.{ .screen = .{ .x = 3, .y = 3 } }).?, + false, + ); + defer sel.deinit(&s); + sel.adjust(&s, .left); // Start line - try testing.expectEqual(Selection{ - .start = .{ .x = 5, .y = 1 }, - .end = .{ .x = 2, .y = 3 }, - }, sel); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 5, + .y = 1, + } }, s.pages.pointFromPin(.screen, sel.start()).?); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 2, + .y = 3, + } }, s.pages.pointFromPin(.screen, sel.end()).?); } // Already at beginning of the line. { - const sel = (Selection{ - .start = .{ .x = 5, .y = 1 }, - .end = .{ .x = 0, .y = 3 }, - }).adjust(&screen, .left); + var sel = Selection.init( + s.pages.pin(.{ .screen = .{ .x = 5, .y = 1 } }).?, + s.pages.pin(.{ .screen = .{ .x = 0, .y = 3 } }).?, + false, + ); + defer sel.deinit(&s); + sel.adjust(&s, .left); // Start line - try testing.expectEqual(Selection{ - .start = .{ .x = 5, .y = 1 }, - .end = .{ .x = 4, .y = 2 }, - }, sel); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 5, + .y = 1, + } }, s.pages.pointFromPin(.screen, sel.start()).?); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 4, + .y = 2, + } }, s.pages.pointFromPin(.screen, sel.end()).?); } } -// X test "Selection: adjust left skips blanks" { const testing = std.testing; - var screen = try Screen.init(testing.allocator, 5, 10, 0); - defer screen.deinit(); - try screen.testWriteString("A1234\nB5678\nC12\nD56"); + var s = try Screen.init(testing.allocator, 5, 10, 0); + defer s.deinit(); + try s.testWriteString("A1234\nB5678\nC12\nD56"); // Same line { - const sel = (Selection{ - .start = .{ .x = 5, .y = 1 }, - .end = .{ .x = 4, .y = 3 }, - }).adjust(&screen, .left); + var sel = Selection.init( + s.pages.pin(.{ .screen = .{ .x = 5, .y = 1 } }).?, + s.pages.pin(.{ .screen = .{ .x = 4, .y = 3 } }).?, + false, + ); + defer sel.deinit(&s); + sel.adjust(&s, .left); // Start line - try testing.expectEqual(Selection{ - .start = .{ .x = 5, .y = 1 }, - .end = .{ .x = 2, .y = 3 }, - }, sel); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 5, + .y = 1, + } }, s.pages.pointFromPin(.screen, sel.start()).?); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 2, + .y = 3, + } }, s.pages.pointFromPin(.screen, sel.end()).?); } // Edge { - const sel = (Selection{ - .start = .{ .x = 5, .y = 1 }, - .end = .{ .x = 0, .y = 3 }, - }).adjust(&screen, .left); + var sel = Selection.init( + s.pages.pin(.{ .screen = .{ .x = 5, .y = 1 } }).?, + s.pages.pin(.{ .screen = .{ .x = 0, .y = 3 } }).?, + false, + ); + defer sel.deinit(&s); + sel.adjust(&s, .left); // Start line - try testing.expectEqual(Selection{ - .start = .{ .x = 5, .y = 1 }, - .end = .{ .x = 2, .y = 2 }, - }, sel); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 5, + .y = 1, + } }, s.pages.pointFromPin(.screen, sel.start()).?); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 2, + .y = 2, + } }, s.pages.pointFromPin(.screen, sel.end()).?); } } -// X test "Selection: adjust up" { const testing = std.testing; - var screen = try Screen.init(testing.allocator, 5, 10, 0); - defer screen.deinit(); - try screen.testWriteString("A\nB\nC\nD\nE"); + var s = try Screen.init(testing.allocator, 5, 10, 0); + defer s.deinit(); + try s.testWriteString("A\nB\nC\nD\nE"); // Not on the first line { - const sel = (Selection{ - .start = .{ .x = 5, .y = 1 }, - .end = .{ .x = 3, .y = 3 }, - }).adjust(&screen, .up); + var sel = Selection.init( + s.pages.pin(.{ .screen = .{ .x = 5, .y = 1 } }).?, + s.pages.pin(.{ .screen = .{ .x = 3, .y = 3 } }).?, + false, + ); + defer sel.deinit(&s); + sel.adjust(&s, .up); - // Start line - try testing.expectEqual(Selection{ - .start = .{ .x = 5, .y = 1 }, - .end = .{ .x = 3, .y = 2 }, - }, sel); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 5, + .y = 1, + } }, s.pages.pointFromPin(.screen, sel.start()).?); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 3, + .y = 2, + } }, s.pages.pointFromPin(.screen, sel.end()).?); } // On the first line { - const sel = (Selection{ - .start = .{ .x = 5, .y = 1 }, - .end = .{ .x = 3, .y = 0 }, - }).adjust(&screen, .up); + var sel = Selection.init( + s.pages.pin(.{ .screen = .{ .x = 5, .y = 1 } }).?, + s.pages.pin(.{ .screen = .{ .x = 3, .y = 0 } }).?, + false, + ); + defer sel.deinit(&s); + sel.adjust(&s, .up); - // Start line - try testing.expectEqual(Selection{ - .start = .{ .x = 5, .y = 1 }, - .end = .{ .x = 0, .y = 0 }, - }, sel); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 5, + .y = 1, + } }, s.pages.pointFromPin(.screen, sel.start()).?); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 0, + } }, s.pages.pointFromPin(.screen, sel.end()).?); } } -// X test "Selection: adjust down" { const testing = std.testing; - var screen = try Screen.init(testing.allocator, 5, 10, 0); - defer screen.deinit(); - try screen.testWriteString("A\nB\nC\nD\nE"); + var s = try Screen.init(testing.allocator, 5, 10, 0); + defer s.deinit(); + try s.testWriteString("A\nB\nC\nD\nE"); // Not on the first line { - const sel = (Selection{ - .start = .{ .x = 5, .y = 1 }, - .end = .{ .x = 3, .y = 3 }, - }).adjust(&screen, .down); + var sel = Selection.init( + s.pages.pin(.{ .screen = .{ .x = 5, .y = 1 } }).?, + s.pages.pin(.{ .screen = .{ .x = 3, .y = 3 } }).?, + false, + ); + defer sel.deinit(&s); + sel.adjust(&s, .down); - // Start line - try testing.expectEqual(Selection{ - .start = .{ .x = 5, .y = 1 }, - .end = .{ .x = 3, .y = 4 }, - }, sel); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 5, + .y = 1, + } }, s.pages.pointFromPin(.screen, sel.start()).?); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 3, + .y = 4, + } }, s.pages.pointFromPin(.screen, sel.end()).?); } // On the last line { - const sel = (Selection{ - .start = .{ .x = 5, .y = 1 }, - .end = .{ .x = 3, .y = 4 }, - }).adjust(&screen, .down); + var sel = Selection.init( + s.pages.pin(.{ .screen = .{ .x = 4, .y = 1 } }).?, + s.pages.pin(.{ .screen = .{ .x = 3, .y = 4 } }).?, + false, + ); + defer sel.deinit(&s); + sel.adjust(&s, .down); - // Start line - try testing.expectEqual(Selection{ - .start = .{ .x = 5, .y = 1 }, - .end = .{ .x = 9, .y = 4 }, - }, sel); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 4, + .y = 1, + } }, s.pages.pointFromPin(.screen, sel.start()).?); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 4, + .y = 4, + } }, s.pages.pointFromPin(.screen, sel.end()).?); } } -// X test "Selection: adjust down with not full screen" { const testing = std.testing; - var screen = try Screen.init(testing.allocator, 5, 10, 0); - defer screen.deinit(); - try screen.testWriteString("A\nB\nC"); + var s = try Screen.init(testing.allocator, 5, 10, 0); + defer s.deinit(); + try s.testWriteString("A\nB\nC"); // On the last line { - const sel = (Selection{ - .start = .{ .x = 5, .y = 1 }, - .end = .{ .x = 3, .y = 2 }, - }).adjust(&screen, .down); + var sel = Selection.init( + s.pages.pin(.{ .screen = .{ .x = 4, .y = 1 } }).?, + s.pages.pin(.{ .screen = .{ .x = 3, .y = 2 } }).?, + false, + ); + defer sel.deinit(&s); + sel.adjust(&s, .down); // Start line - try testing.expectEqual(Selection{ - .start = .{ .x = 5, .y = 1 }, - .end = .{ .x = 9, .y = 2 }, - }, sel); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 4, + .y = 1, + } }, s.pages.pointFromPin(.screen, sel.start()).?); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 4, + .y = 2, + } }, s.pages.pointFromPin(.screen, sel.end()).?); } } -// X -test "Selection: contains" { +test "Selection: adjust home" { const testing = std.testing; + var s = try Screen.init(testing.allocator, 5, 10, 0); + defer s.deinit(); + try s.testWriteString("A\nB\nC"); + + // On the last line { - const sel: Selection = .{ - .start = .{ .x = 5, .y = 1 }, - .end = .{ .x = 3, .y = 2 }, - }; - - try testing.expect(sel.contains(.{ .x = 6, .y = 1 })); - try testing.expect(sel.contains(.{ .x = 1, .y = 2 })); - try testing.expect(!sel.contains(.{ .x = 1, .y = 1 })); - try testing.expect(!sel.contains(.{ .x = 5, .y = 2 })); - try testing.expect(!sel.containsRow(.{ .x = 1, .y = 3 })); - try testing.expect(sel.containsRow(.{ .x = 1, .y = 1 })); - try testing.expect(sel.containsRow(.{ .x = 5, .y = 2 })); - } - - // Reverse - { - const sel: Selection = .{ - .start = .{ .x = 3, .y = 2 }, - .end = .{ .x = 5, .y = 1 }, - }; - - try testing.expect(sel.contains(.{ .x = 6, .y = 1 })); - try testing.expect(sel.contains(.{ .x = 1, .y = 2 })); - try testing.expect(!sel.contains(.{ .x = 1, .y = 1 })); - try testing.expect(!sel.contains(.{ .x = 5, .y = 2 })); - } - - // Single line - { - const sel: Selection = .{ - .start = .{ .x = 5, .y = 1 }, - .end = .{ .x = 10, .y = 1 }, - }; - - try testing.expect(sel.contains(.{ .x = 6, .y = 1 })); - try testing.expect(!sel.contains(.{ .x = 2, .y = 1 })); - try testing.expect(!sel.contains(.{ .x = 12, .y = 1 })); - } -} - -// X -test "Selection: contains, rectangle" { - const testing = std.testing; - { - const sel: Selection = .{ - .start = .{ .x = 3, .y = 3 }, - .end = .{ .x = 7, .y = 9 }, - .rectangle = true, - }; - - try testing.expect(sel.contains(.{ .x = 5, .y = 6 })); // Center - try testing.expect(sel.contains(.{ .x = 3, .y = 6 })); // Left border - try testing.expect(sel.contains(.{ .x = 7, .y = 6 })); // Right border - try testing.expect(sel.contains(.{ .x = 5, .y = 3 })); // Top border - try testing.expect(sel.contains(.{ .x = 5, .y = 9 })); // Bottom border - - try testing.expect(!sel.contains(.{ .x = 5, .y = 2 })); // Above center - try testing.expect(!sel.contains(.{ .x = 5, .y = 10 })); // Below center - try testing.expect(!sel.contains(.{ .x = 2, .y = 6 })); // Left center - try testing.expect(!sel.contains(.{ .x = 8, .y = 6 })); // Right center - try testing.expect(!sel.contains(.{ .x = 8, .y = 3 })); // Just right of top right - try testing.expect(!sel.contains(.{ .x = 2, .y = 9 })); // Just left of bottom left - - try testing.expect(!sel.containsRow(.{ .x = 1, .y = 1 })); - try testing.expect(sel.containsRow(.{ .x = 1, .y = 3 })); // x does not matter - try testing.expect(sel.containsRow(.{ .x = 1, .y = 6 })); - try testing.expect(sel.containsRow(.{ .x = 5, .y = 9 })); - try testing.expect(!sel.containsRow(.{ .x = 5, .y = 10 })); - } - - // Reverse - { - const sel: Selection = .{ - .start = .{ .x = 7, .y = 9 }, - .end = .{ .x = 3, .y = 3 }, - .rectangle = true, - }; - - try testing.expect(sel.contains(.{ .x = 5, .y = 6 })); // Center - try testing.expect(sel.contains(.{ .x = 3, .y = 6 })); // Left border - try testing.expect(sel.contains(.{ .x = 7, .y = 6 })); // Right border - try testing.expect(sel.contains(.{ .x = 5, .y = 3 })); // Top border - try testing.expect(sel.contains(.{ .x = 5, .y = 9 })); // Bottom border - - try testing.expect(!sel.contains(.{ .x = 5, .y = 2 })); // Above center - try testing.expect(!sel.contains(.{ .x = 5, .y = 10 })); // Below center - try testing.expect(!sel.contains(.{ .x = 2, .y = 6 })); // Left center - try testing.expect(!sel.contains(.{ .x = 8, .y = 6 })); // Right center - try testing.expect(!sel.contains(.{ .x = 8, .y = 3 })); // Just right of top right - try testing.expect(!sel.contains(.{ .x = 2, .y = 9 })); // Just left of bottom left - - try testing.expect(!sel.containsRow(.{ .x = 1, .y = 1 })); - try testing.expect(sel.containsRow(.{ .x = 1, .y = 3 })); // x does not matter - try testing.expect(sel.containsRow(.{ .x = 1, .y = 6 })); - try testing.expect(sel.containsRow(.{ .x = 5, .y = 9 })); - try testing.expect(!sel.containsRow(.{ .x = 5, .y = 10 })); - } - - // Single line - // NOTE: This is the same as normal selection but we just do it for brevity - { - const sel: Selection = .{ - .start = .{ .x = 5, .y = 1 }, - .end = .{ .x = 10, .y = 1 }, - .rectangle = true, - }; - - try testing.expect(sel.contains(.{ .x = 6, .y = 1 })); - try testing.expect(!sel.contains(.{ .x = 2, .y = 1 })); - try testing.expect(!sel.contains(.{ .x = 12, .y = 1 })); - } -} - -test "Selection: containedRow" { - const testing = std.testing; - var screen = try Screen.init(testing.allocator, 5, 10, 0); - defer screen.deinit(); - - { - const sel: Selection = .{ - .start = .{ .x = 5, .y = 1 }, - .end = .{ .x = 3, .y = 3 }, - }; - - // Not contained - try testing.expect(sel.containedRow(&screen, .{ .x = 1, .y = 4 }) == null); + var sel = Selection.init( + s.pages.pin(.{ .screen = .{ .x = 4, .y = 1 } }).?, + s.pages.pin(.{ .screen = .{ .x = 1, .y = 2 } }).?, + false, + ); + defer sel.deinit(&s); + sel.adjust(&s, .home); // Start line - try testing.expectEqual(Selection{ - .start = sel.start, - .end = .{ .x = screen.cols - 1, .y = 1 }, - }, sel.containedRow(&screen, .{ .x = 1, .y = 1 }).?); - - // End line - try testing.expectEqual(Selection{ - .start = .{ .x = 0, .y = 3 }, - .end = sel.end, - }, sel.containedRow(&screen, .{ .x = 2, .y = 3 }).?); - - // Middle line - try testing.expectEqual(Selection{ - .start = .{ .x = 0, .y = 2 }, - .end = .{ .x = screen.cols - 1, .y = 2 }, - }, sel.containedRow(&screen, .{ .x = 2, .y = 2 }).?); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 4, + .y = 1, + } }, s.pages.pointFromPin(.screen, sel.start()).?); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 0, + } }, s.pages.pointFromPin(.screen, sel.end()).?); } +} - // Rectangle +test "Selection: adjust end with not full screen" { + const testing = std.testing; + var s = try Screen.init(testing.allocator, 5, 10, 0); + defer s.deinit(); + try s.testWriteString("A\nB\nC"); + + // On the last line { - const sel: Selection = .{ - .start = .{ .x = 3, .y = 1 }, - .end = .{ .x = 6, .y = 3 }, - .rectangle = true, - }; - - // Not contained - try testing.expect(sel.containedRow(&screen, .{ .x = 1, .y = 4 }) == null); + var sel = Selection.init( + s.pages.pin(.{ .screen = .{ .x = 4, .y = 0 } }).?, + s.pages.pin(.{ .screen = .{ .x = 1, .y = 1 } }).?, + false, + ); + defer sel.deinit(&s); + sel.adjust(&s, .end); // Start line - try testing.expectEqual(Selection{ - .start = .{ .x = 3, .y = 1 }, - .end = .{ .x = 6, .y = 1 }, - .rectangle = true, - }, sel.containedRow(&screen, .{ .x = 1, .y = 1 }).?); - - // End line - try testing.expectEqual(Selection{ - .start = .{ .x = 3, .y = 3 }, - .end = .{ .x = 6, .y = 3 }, - .rectangle = true, - }, sel.containedRow(&screen, .{ .x = 2, .y = 3 }).?); - - // Middle line - try testing.expectEqual(Selection{ - .start = .{ .x = 3, .y = 2 }, - .end = .{ .x = 6, .y = 2 }, - .rectangle = true, - }, sel.containedRow(&screen, .{ .x = 2, .y = 2 }).?); - } - - // Single-line selection - { - const sel: Selection = .{ - .start = .{ .x = 2, .y = 1 }, - .end = .{ .x = 6, .y = 1 }, - }; - - // Not contained - try testing.expect(sel.containedRow(&screen, .{ .x = 1, .y = 0 }) == null); - try testing.expect(sel.containedRow(&screen, .{ .x = 1, .y = 2 }) == null); - - // Contained - try testing.expectEqual(Selection{ - .start = sel.start, - .end = sel.end, - }, sel.containedRow(&screen, .{ .x = 1, .y = 1 }).?); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 4, + .y = 0, + } }, s.pages.pointFromPin(.screen, sel.start()).?); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 4, + .y = 2, + } }, s.pages.pointFromPin(.screen, sel.end()).?); } } -test "Selection: within" { - const testing = std.testing; - { - const sel: Selection = .{ - .start = .{ .x = 5, .y = 1 }, - .end = .{ .x = 3, .y = 2 }, - }; - - // Fully within - try testing.expect(sel.within(.{ .x = 6, .y = 0 }, .{ .x = 6, .y = 3 })); - try testing.expect(sel.within(.{ .x = 3, .y = 1 }, .{ .x = 6, .y = 3 })); - try testing.expect(sel.within(.{ .x = 3, .y = 0 }, .{ .x = 6, .y = 2 })); - - // Partially within - try testing.expect(sel.within(.{ .x = 1, .y = 2 }, .{ .x = 6, .y = 3 })); - try testing.expect(sel.within(.{ .x = 1, .y = 0 }, .{ .x = 6, .y = 1 })); - - // Not within at all - try testing.expect(!sel.within(.{ .x = 0, .y = 0 }, .{ .x = 4, .y = 1 })); - } -} - -// X test "Selection: order, standard" { const testing = std.testing; + const alloc = testing.allocator; + + var s = try Screen.init(alloc, 100, 100, 1); + defer s.deinit(); + { // forward, multi-line - const sel: Selection = .{ - .start = .{ .x = 2, .y = 1 }, - .end = .{ .x = 2, .y = 2 }, - }; + const sel = Selection.init( + s.pages.pin(.{ .screen = .{ .x = 2, .y = 1 } }).?, + s.pages.pin(.{ .screen = .{ .x = 2, .y = 2 } }).?, + false, + ); + defer sel.deinit(&s); - try testing.expect(sel.order() == .forward); + try testing.expect(sel.order(&s) == .forward); } { // reverse, multi-line - const sel: Selection = .{ - .start = .{ .x = 2, .y = 2 }, - .end = .{ .x = 2, .y = 1 }, - }; + const sel = Selection.init( + s.pages.pin(.{ .screen = .{ .x = 2, .y = 2 } }).?, + s.pages.pin(.{ .screen = .{ .x = 2, .y = 1 } }).?, + false, + ); + defer sel.deinit(&s); - try testing.expect(sel.order() == .reverse); + try testing.expect(sel.order(&s) == .reverse); } { // forward, same-line - const sel: Selection = .{ - .start = .{ .x = 2, .y = 1 }, - .end = .{ .x = 3, .y = 1 }, - }; + const sel = Selection.init( + s.pages.pin(.{ .screen = .{ .x = 2, .y = 1 } }).?, + s.pages.pin(.{ .screen = .{ .x = 3, .y = 1 } }).?, + false, + ); + defer sel.deinit(&s); - try testing.expect(sel.order() == .forward); + try testing.expect(sel.order(&s) == .forward); } { // forward, single char - const sel: Selection = .{ - .start = .{ .x = 2, .y = 1 }, - .end = .{ .x = 2, .y = 1 }, - }; + const sel = Selection.init( + s.pages.pin(.{ .screen = .{ .x = 2, .y = 1 } }).?, + s.pages.pin(.{ .screen = .{ .x = 2, .y = 1 } }).?, + false, + ); + defer sel.deinit(&s); - try testing.expect(sel.order() == .forward); + try testing.expect(sel.order(&s) == .forward); } { // reverse, single line - const sel: Selection = .{ - .start = .{ .x = 2, .y = 1 }, - .end = .{ .x = 1, .y = 1 }, - }; + const sel = Selection.init( + s.pages.pin(.{ .screen = .{ .x = 2, .y = 1 } }).?, + s.pages.pin(.{ .screen = .{ .x = 1, .y = 1 } }).?, + false, + ); + defer sel.deinit(&s); - try testing.expect(sel.order() == .reverse); + try testing.expect(sel.order(&s) == .reverse); } } -// X test "Selection: order, rectangle" { const testing = std.testing; + const alloc = testing.allocator; + + var s = try Screen.init(alloc, 100, 100, 1); + defer s.deinit(); + // Conventions: // TL - top left // BL - bottom left @@ -827,339 +814,417 @@ test "Selection: order, rectangle" { // BR - bottom right { // forward (TL -> BR) - const sel: Selection = .{ - .start = .{ .x = 1, .y = 1 }, - .end = .{ .x = 2, .y = 2 }, - .rectangle = true, - }; + const sel = Selection.init( + s.pages.pin(.{ .screen = .{ .x = 1, .y = 1 } }).?, + s.pages.pin(.{ .screen = .{ .x = 2, .y = 2 } }).?, + true, + ); + defer sel.deinit(&s); - try testing.expect(sel.order() == .forward); + try testing.expect(sel.order(&s) == .forward); } { // reverse (BR -> TL) - const sel: Selection = .{ - .start = .{ .x = 2, .y = 2 }, - .end = .{ .x = 1, .y = 1 }, - .rectangle = true, - }; + const sel = Selection.init( + s.pages.pin(.{ .screen = .{ .x = 2, .y = 2 } }).?, + s.pages.pin(.{ .screen = .{ .x = 1, .y = 1 } }).?, + true, + ); + defer sel.deinit(&s); - try testing.expect(sel.order() == .reverse); + try testing.expect(sel.order(&s) == .reverse); } { // mirrored_forward (TR -> BL) - const sel: Selection = .{ - .start = .{ .x = 3, .y = 1 }, - .end = .{ .x = 1, .y = 3 }, - .rectangle = true, - }; + const sel = Selection.init( + s.pages.pin(.{ .screen = .{ .x = 3, .y = 1 } }).?, + s.pages.pin(.{ .screen = .{ .x = 1, .y = 3 } }).?, + true, + ); + defer sel.deinit(&s); - try testing.expect(sel.order() == .mirrored_forward); + try testing.expect(sel.order(&s) == .mirrored_forward); } { // mirrored_reverse (BL -> TR) - const sel: Selection = .{ - .start = .{ .x = 1, .y = 3 }, - .end = .{ .x = 3, .y = 1 }, - .rectangle = true, - }; + const sel = Selection.init( + s.pages.pin(.{ .screen = .{ .x = 1, .y = 3 } }).?, + s.pages.pin(.{ .screen = .{ .x = 3, .y = 1 } }).?, + true, + ); + defer sel.deinit(&s); - try testing.expect(sel.order() == .mirrored_reverse); + try testing.expect(sel.order(&s) == .mirrored_reverse); } { // forward, single line (left -> right ) - const sel: Selection = .{ - .start = .{ .x = 1, .y = 1 }, - .end = .{ .x = 3, .y = 1 }, - .rectangle = true, - }; + const sel = Selection.init( + s.pages.pin(.{ .screen = .{ .x = 1, .y = 1 } }).?, + s.pages.pin(.{ .screen = .{ .x = 3, .y = 1 } }).?, + true, + ); + defer sel.deinit(&s); - try testing.expect(sel.order() == .forward); + try testing.expect(sel.order(&s) == .forward); } { // reverse, single line (right -> left) - const sel: Selection = .{ - .start = .{ .x = 3, .y = 1 }, - .end = .{ .x = 1, .y = 1 }, - .rectangle = true, - }; + const sel = Selection.init( + s.pages.pin(.{ .screen = .{ .x = 3, .y = 1 } }).?, + s.pages.pin(.{ .screen = .{ .x = 1, .y = 1 } }).?, + true, + ); + defer sel.deinit(&s); - try testing.expect(sel.order() == .reverse); + try testing.expect(sel.order(&s) == .reverse); } { // forward, single column (top -> bottom) - const sel: Selection = .{ - .start = .{ .x = 2, .y = 1 }, - .end = .{ .x = 2, .y = 3 }, - .rectangle = true, - }; + const sel = Selection.init( + s.pages.pin(.{ .screen = .{ .x = 2, .y = 1 } }).?, + s.pages.pin(.{ .screen = .{ .x = 2, .y = 3 } }).?, + true, + ); + defer sel.deinit(&s); - try testing.expect(sel.order() == .forward); + try testing.expect(sel.order(&s) == .forward); } { // reverse, single column (bottom -> top) - const sel: Selection = .{ - .start = .{ .x = 2, .y = 3 }, - .end = .{ .x = 2, .y = 1 }, - .rectangle = true, - }; + const sel = Selection.init( + s.pages.pin(.{ .screen = .{ .x = 2, .y = 3 } }).?, + s.pages.pin(.{ .screen = .{ .x = 2, .y = 1 } }).?, + true, + ); + defer sel.deinit(&s); - try testing.expect(sel.order() == .reverse); + try testing.expect(sel.order(&s) == .reverse); } { // forward, single cell - const sel: Selection = .{ - .start = .{ .x = 1, .y = 1 }, - .end = .{ .x = 1, .y = 1 }, - .rectangle = true, - }; + const sel = Selection.init( + s.pages.pin(.{ .screen = .{ .x = 1, .y = 1 } }).?, + s.pages.pin(.{ .screen = .{ .x = 1, .y = 1 } }).?, + true, + ); + defer sel.deinit(&s); - try testing.expect(sel.order() == .forward); + try testing.expect(sel.order(&s) == .forward); } } -// X test "topLeft" { const testing = std.testing; + + var s = try Screen.init(testing.allocator, 5, 10, 0); + defer s.deinit(); { // forward - const sel: Selection = .{ - .start = .{ .x = 1, .y = 1 }, - .end = .{ .x = 3, .y = 1 }, - }; - const expected: ScreenPoint = .{ .x = 1, .y = 1 }; - try testing.expectEqual(sel.topLeft(), expected); + const sel = Selection.init( + s.pages.pin(.{ .screen = .{ .x = 1, .y = 1 } }).?, + s.pages.pin(.{ .screen = .{ .x = 3, .y = 1 } }).?, + true, + ); + defer sel.deinit(&s); + const tl = sel.topLeft(&s); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 1, + .y = 1, + } }, s.pages.pointFromPin(.screen, tl)); } { // reverse - const sel: Selection = .{ - .start = .{ .x = 3, .y = 1 }, - .end = .{ .x = 1, .y = 1 }, - }; - const expected: ScreenPoint = .{ .x = 1, .y = 1 }; - try testing.expectEqual(sel.topLeft(), expected); + const sel = Selection.init( + s.pages.pin(.{ .screen = .{ .x = 3, .y = 1 } }).?, + s.pages.pin(.{ .screen = .{ .x = 1, .y = 1 } }).?, + true, + ); + defer sel.deinit(&s); + const tl = sel.topLeft(&s); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 1, + .y = 1, + } }, s.pages.pointFromPin(.screen, tl)); } { // mirrored_forward - const sel: Selection = .{ - .start = .{ .x = 3, .y = 1 }, - .end = .{ .x = 1, .y = 3 }, - .rectangle = true, - }; - const expected: ScreenPoint = .{ .x = 1, .y = 1 }; - try testing.expectEqual(sel.topLeft(), expected); + const sel = Selection.init( + s.pages.pin(.{ .screen = .{ .x = 3, .y = 1 } }).?, + s.pages.pin(.{ .screen = .{ .x = 1, .y = 3 } }).?, + true, + ); + defer sel.deinit(&s); + const tl = sel.topLeft(&s); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 1, + .y = 1, + } }, s.pages.pointFromPin(.screen, tl)); } { // mirrored_reverse - const sel: Selection = .{ - .start = .{ .x = 1, .y = 3 }, - .end = .{ .x = 3, .y = 1 }, - .rectangle = true, - }; - const expected: ScreenPoint = .{ .x = 1, .y = 1 }; - try testing.expectEqual(sel.topLeft(), expected); + const sel = Selection.init( + s.pages.pin(.{ .screen = .{ .x = 1, .y = 3 } }).?, + s.pages.pin(.{ .screen = .{ .x = 3, .y = 1 } }).?, + true, + ); + defer sel.deinit(&s); + const tl = sel.topLeft(&s); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 1, + .y = 1, + } }, s.pages.pointFromPin(.screen, tl)); } } -// X test "bottomRight" { const testing = std.testing; + + var s = try Screen.init(testing.allocator, 5, 10, 0); + defer s.deinit(); { // forward - const sel: Selection = .{ - .start = .{ .x = 1, .y = 1 }, - .end = .{ .x = 3, .y = 1 }, - }; - const expected: ScreenPoint = .{ .x = 3, .y = 1 }; - try testing.expectEqual(sel.bottomRight(), expected); + const sel = Selection.init( + s.pages.pin(.{ .screen = .{ .x = 1, .y = 1 } }).?, + s.pages.pin(.{ .screen = .{ .x = 3, .y = 1 } }).?, + false, + ); + defer sel.deinit(&s); + const br = sel.bottomRight(&s); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 3, + .y = 1, + } }, s.pages.pointFromPin(.screen, br)); } { // reverse - const sel: Selection = .{ - .start = .{ .x = 3, .y = 1 }, - .end = .{ .x = 1, .y = 1 }, - }; - const expected: ScreenPoint = .{ .x = 3, .y = 1 }; - try testing.expectEqual(sel.bottomRight(), expected); + const sel = Selection.init( + s.pages.pin(.{ .screen = .{ .x = 3, .y = 1 } }).?, + s.pages.pin(.{ .screen = .{ .x = 1, .y = 1 } }).?, + false, + ); + defer sel.deinit(&s); + const br = sel.bottomRight(&s); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 3, + .y = 1, + } }, s.pages.pointFromPin(.screen, br)); } { // mirrored_forward - const sel: Selection = .{ - .start = .{ .x = 3, .y = 1 }, - .end = .{ .x = 1, .y = 3 }, - .rectangle = true, - }; - const expected: ScreenPoint = .{ .x = 3, .y = 3 }; - try testing.expectEqual(sel.bottomRight(), expected); + const sel = Selection.init( + s.pages.pin(.{ .screen = .{ .x = 3, .y = 1 } }).?, + s.pages.pin(.{ .screen = .{ .x = 1, .y = 3 } }).?, + true, + ); + defer sel.deinit(&s); + const br = sel.bottomRight(&s); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 3, + .y = 3, + } }, s.pages.pointFromPin(.screen, br)); } { // mirrored_reverse - const sel: Selection = .{ - .start = .{ .x = 1, .y = 3 }, - .end = .{ .x = 3, .y = 1 }, - .rectangle = true, - }; - const expected: ScreenPoint = .{ .x = 3, .y = 3 }; - try testing.expectEqual(sel.bottomRight(), expected); + const sel = Selection.init( + s.pages.pin(.{ .screen = .{ .x = 1, .y = 3 } }).?, + s.pages.pin(.{ .screen = .{ .x = 3, .y = 1 } }).?, + true, + ); + defer sel.deinit(&s); + const br = sel.bottomRight(&s); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 3, + .y = 3, + } }, s.pages.pointFromPin(.screen, br)); } } -// X test "ordered" { const testing = std.testing; + + var s = try Screen.init(testing.allocator, 5, 10, 0); + defer s.deinit(); { // forward - const sel: Selection = .{ - .start = .{ .x = 1, .y = 1 }, - .end = .{ .x = 3, .y = 1 }, - }; - const sel_reverse: Selection = .{ - .start = .{ .x = 3, .y = 1 }, - .end = .{ .x = 1, .y = 1 }, - }; - try testing.expectEqual(sel.ordered(.forward), sel); - try testing.expectEqual(sel.ordered(.reverse), sel_reverse); - try testing.expectEqual(sel.ordered(.mirrored_reverse), sel); + const sel = Selection.init( + s.pages.pin(.{ .screen = .{ .x = 1, .y = 1 } }).?, + s.pages.pin(.{ .screen = .{ .x = 3, .y = 1 } }).?, + false, + ); + const sel_reverse = Selection.init( + s.pages.pin(.{ .screen = .{ .x = 3, .y = 1 } }).?, + s.pages.pin(.{ .screen = .{ .x = 1, .y = 1 } }).?, + false, + ); + try testing.expect(sel.ordered(&s, .forward).eql(sel)); + try testing.expect(sel.ordered(&s, .reverse).eql(sel_reverse)); + try testing.expect(sel.ordered(&s, .mirrored_forward).eql(sel)); } { // reverse - const sel: Selection = .{ - .start = .{ .x = 3, .y = 1 }, - .end = .{ .x = 1, .y = 1 }, - }; - const sel_forward: Selection = .{ - .start = .{ .x = 1, .y = 1 }, - .end = .{ .x = 3, .y = 1 }, - }; - try testing.expectEqual(sel.ordered(.forward), sel_forward); - try testing.expectEqual(sel.ordered(.reverse), sel); - try testing.expectEqual(sel.ordered(.mirrored_forward), sel_forward); + const sel = Selection.init( + s.pages.pin(.{ .screen = .{ .x = 3, .y = 1 } }).?, + s.pages.pin(.{ .screen = .{ .x = 1, .y = 1 } }).?, + false, + ); + const sel_forward = Selection.init( + s.pages.pin(.{ .screen = .{ .x = 1, .y = 1 } }).?, + s.pages.pin(.{ .screen = .{ .x = 3, .y = 1 } }).?, + false, + ); + try testing.expect(sel.ordered(&s, .forward).eql(sel_forward)); + try testing.expect(sel.ordered(&s, .reverse).eql(sel)); + try testing.expect(sel.ordered(&s, .mirrored_forward).eql(sel_forward)); } { // mirrored_forward - const sel: Selection = .{ - .start = .{ .x = 3, .y = 1 }, - .end = .{ .x = 1, .y = 3 }, - .rectangle = true, - }; - const sel_forward: Selection = .{ - .start = .{ .x = 1, .y = 1 }, - .end = .{ .x = 3, .y = 3 }, - .rectangle = true, - }; - const sel_reverse: Selection = .{ - .start = .{ .x = 3, .y = 3 }, - .end = .{ .x = 1, .y = 1 }, - .rectangle = true, - }; - try testing.expectEqual(sel.ordered(.forward), sel_forward); - try testing.expectEqual(sel.ordered(.reverse), sel_reverse); - try testing.expectEqual(sel.ordered(.mirrored_reverse), sel_forward); + const sel = Selection.init( + s.pages.pin(.{ .screen = .{ .x = 3, .y = 1 } }).?, + s.pages.pin(.{ .screen = .{ .x = 1, .y = 3 } }).?, + true, + ); + const sel_forward = Selection.init( + s.pages.pin(.{ .screen = .{ .x = 1, .y = 1 } }).?, + s.pages.pin(.{ .screen = .{ .x = 3, .y = 3 } }).?, + true, + ); + const sel_reverse = Selection.init( + s.pages.pin(.{ .screen = .{ .x = 3, .y = 3 } }).?, + s.pages.pin(.{ .screen = .{ .x = 1, .y = 1 } }).?, + true, + ); + try testing.expect(sel.ordered(&s, .forward).eql(sel_forward)); + try testing.expect(sel.ordered(&s, .reverse).eql(sel_reverse)); + try testing.expect(sel.ordered(&s, .mirrored_reverse).eql(sel_forward)); } { // mirrored_reverse - const sel: Selection = .{ - .start = .{ .x = 1, .y = 3 }, - .end = .{ .x = 3, .y = 1 }, - .rectangle = true, - }; - const sel_forward: Selection = .{ - .start = .{ .x = 1, .y = 1 }, - .end = .{ .x = 3, .y = 3 }, - .rectangle = true, - }; - const sel_reverse: Selection = .{ - .start = .{ .x = 3, .y = 3 }, - .end = .{ .x = 1, .y = 1 }, - .rectangle = true, - }; - try testing.expectEqual(sel.ordered(.forward), sel_forward); - try testing.expectEqual(sel.ordered(.reverse), sel_reverse); - try testing.expectEqual(sel.ordered(.mirrored_forward), sel_forward); + const sel = Selection.init( + s.pages.pin(.{ .screen = .{ .x = 1, .y = 3 } }).?, + s.pages.pin(.{ .screen = .{ .x = 3, .y = 1 } }).?, + true, + ); + const sel_forward = Selection.init( + s.pages.pin(.{ .screen = .{ .x = 1, .y = 1 } }).?, + s.pages.pin(.{ .screen = .{ .x = 3, .y = 3 } }).?, + true, + ); + const sel_reverse = Selection.init( + s.pages.pin(.{ .screen = .{ .x = 3, .y = 3 } }).?, + s.pages.pin(.{ .screen = .{ .x = 1, .y = 1 } }).?, + true, + ); + try testing.expect(sel.ordered(&s, .forward).eql(sel_forward)); + try testing.expect(sel.ordered(&s, .reverse).eql(sel_reverse)); + try testing.expect(sel.ordered(&s, .mirrored_forward).eql(sel_forward)); } } -test "toViewport" { +test "Selection: contains" { const testing = std.testing; - var screen = try Screen.init(testing.allocator, 24, 80, 0); - defer screen.deinit(); - screen.viewport = 11; // Scroll us down a bit + + var s = try Screen.init(testing.allocator, 5, 10, 0); + defer s.deinit(); { - // Not in viewport (null) - const sel: Selection = .{ - .start = .{ .x = 10, .y = 1 }, - .end = .{ .x = 3, .y = 3 }, - .rectangle = false, - }; - try testing.expectEqual(null, sel.toViewport(&screen)); + const sel = Selection.init( + s.pages.pin(.{ .screen = .{ .x = 5, .y = 1 } }).?, + s.pages.pin(.{ .screen = .{ .x = 3, .y = 2 } }).?, + false, + ); + + try testing.expect(sel.contains(&s, s.pages.pin(.{ .screen = .{ .x = 6, .y = 1 } }).?)); + try testing.expect(sel.contains(&s, s.pages.pin(.{ .screen = .{ .x = 1, .y = 2 } }).?)); + try testing.expect(!sel.contains(&s, s.pages.pin(.{ .screen = .{ .x = 1, .y = 1 } }).?)); + try testing.expect(!sel.contains(&s, s.pages.pin(.{ .screen = .{ .x = 5, .y = 2 } }).?)); } + + // Reverse { - // In viewport - const sel: Selection = .{ - .start = .{ .x = 10, .y = 11 }, - .end = .{ .x = 3, .y = 13 }, - .rectangle = false, - }; - const want: Selection = .{ - .start = .{ .x = 10, .y = 0 }, - .end = .{ .x = 3, .y = 2 }, - .rectangle = false, - }; - try testing.expectEqual(want, sel.toViewport(&screen)); + const sel = Selection.init( + s.pages.pin(.{ .screen = .{ .x = 3, .y = 2 } }).?, + s.pages.pin(.{ .screen = .{ .x = 5, .y = 1 } }).?, + false, + ); + + try testing.expect(sel.contains(&s, s.pages.pin(.{ .screen = .{ .x = 6, .y = 1 } }).?)); + try testing.expect(sel.contains(&s, s.pages.pin(.{ .screen = .{ .x = 1, .y = 2 } }).?)); + try testing.expect(!sel.contains(&s, s.pages.pin(.{ .screen = .{ .x = 1, .y = 1 } }).?)); + try testing.expect(!sel.contains(&s, s.pages.pin(.{ .screen = .{ .x = 5, .y = 2 } }).?)); } + + // Single line { - // Top off viewport - const sel: Selection = .{ - .start = .{ .x = 10, .y = 1 }, - .end = .{ .x = 3, .y = 13 }, - .rectangle = false, - }; - const want: Selection = .{ - .start = .{ .x = 0, .y = 0 }, - .end = .{ .x = 3, .y = 2 }, - .rectangle = false, - }; - try testing.expectEqual(want, sel.toViewport(&screen)); - } - { - // Bottom off viewport - const sel: Selection = .{ - .start = .{ .x = 10, .y = 11 }, - .end = .{ .x = 3, .y = 40 }, - .rectangle = false, - }; - const want: Selection = .{ - .start = .{ .x = 10, .y = 0 }, - .end = .{ .x = 79, .y = 23 }, - .rectangle = false, - }; - try testing.expectEqual(want, sel.toViewport(&screen)); - } - { - // Both off viewport - const sel: Selection = .{ - .start = .{ .x = 10, .y = 1 }, - .end = .{ .x = 3, .y = 40 }, - .rectangle = false, - }; - const want: Selection = .{ - .start = .{ .x = 0, .y = 0 }, - .end = .{ .x = 79, .y = 23 }, - .rectangle = false, - }; - try testing.expectEqual(want, sel.toViewport(&screen)); - } - { - // Both off viewport (rectangle) - const sel: Selection = .{ - .start = .{ .x = 10, .y = 1 }, - .end = .{ .x = 3, .y = 40 }, - .rectangle = true, - }; - const want: Selection = .{ - .start = .{ .x = 10, .y = 0 }, - .end = .{ .x = 3, .y = 23 }, - .rectangle = true, - }; - try testing.expectEqual(want, sel.toViewport(&screen)); + const sel = Selection.init( + s.pages.pin(.{ .screen = .{ .x = 5, .y = 1 } }).?, + s.pages.pin(.{ .screen = .{ .x = 10, .y = 1 } }).?, + false, + ); + + try testing.expect(sel.contains(&s, s.pages.pin(.{ .screen = .{ .x = 6, .y = 1 } }).?)); + try testing.expect(!sel.contains(&s, s.pages.pin(.{ .screen = .{ .x = 2, .y = 1 } }).?)); + try testing.expect(!sel.contains(&s, s.pages.pin(.{ .screen = .{ .x = 12, .y = 1 } }).?)); + } +} + +test "Selection: contains, rectangle" { + const testing = std.testing; + + var s = try Screen.init(testing.allocator, 15, 15, 0); + defer s.deinit(); + { + const sel = Selection.init( + s.pages.pin(.{ .screen = .{ .x = 3, .y = 3 } }).?, + s.pages.pin(.{ .screen = .{ .x = 7, .y = 9 } }).?, + true, + ); + + try testing.expect(sel.contains(&s, s.pages.pin(.{ .screen = .{ .x = 5, .y = 6 } }).?)); // Center + try testing.expect(sel.contains(&s, s.pages.pin(.{ .screen = .{ .x = 3, .y = 6 } }).?)); // Left border + try testing.expect(sel.contains(&s, s.pages.pin(.{ .screen = .{ .x = 7, .y = 6 } }).?)); // Right border + try testing.expect(sel.contains(&s, s.pages.pin(.{ .screen = .{ .x = 5, .y = 3 } }).?)); // Top border + try testing.expect(sel.contains(&s, s.pages.pin(.{ .screen = .{ .x = 5, .y = 9 } }).?)); // Bottom border + + try testing.expect(!sel.contains(&s, s.pages.pin(.{ .screen = .{ .x = 5, .y = 2 } }).?)); // Above center + try testing.expect(!sel.contains(&s, s.pages.pin(.{ .screen = .{ .x = 5, .y = 10 } }).?)); // Below center + try testing.expect(!sel.contains(&s, s.pages.pin(.{ .screen = .{ .x = 2, .y = 6 } }).?)); // Left center + try testing.expect(!sel.contains(&s, s.pages.pin(.{ .screen = .{ .x = 8, .y = 6 } }).?)); // Right center + try testing.expect(!sel.contains(&s, s.pages.pin(.{ .screen = .{ .x = 8, .y = 3 } }).?)); // Just right of top right + try testing.expect(!sel.contains(&s, s.pages.pin(.{ .screen = .{ .x = 2, .y = 9 } }).?)); // Just left of bottom left + } + + // Reverse + { + const sel = Selection.init( + s.pages.pin(.{ .screen = .{ .x = 7, .y = 9 } }).?, + s.pages.pin(.{ .screen = .{ .x = 3, .y = 3 } }).?, + true, + ); + + try testing.expect(sel.contains(&s, s.pages.pin(.{ .screen = .{ .x = 5, .y = 6 } }).?)); // Center + try testing.expect(sel.contains(&s, s.pages.pin(.{ .screen = .{ .x = 3, .y = 6 } }).?)); // Left border + try testing.expect(sel.contains(&s, s.pages.pin(.{ .screen = .{ .x = 7, .y = 6 } }).?)); // Right border + try testing.expect(sel.contains(&s, s.pages.pin(.{ .screen = .{ .x = 5, .y = 3 } }).?)); // Top border + try testing.expect(sel.contains(&s, s.pages.pin(.{ .screen = .{ .x = 5, .y = 9 } }).?)); // Bottom border + + try testing.expect(!sel.contains(&s, s.pages.pin(.{ .screen = .{ .x = 5, .y = 2 } }).?)); // Above center + try testing.expect(!sel.contains(&s, s.pages.pin(.{ .screen = .{ .x = 5, .y = 10 } }).?)); // Below center + try testing.expect(!sel.contains(&s, s.pages.pin(.{ .screen = .{ .x = 2, .y = 6 } }).?)); // Left center + try testing.expect(!sel.contains(&s, s.pages.pin(.{ .screen = .{ .x = 8, .y = 6 } }).?)); // Right center + try testing.expect(!sel.contains(&s, s.pages.pin(.{ .screen = .{ .x = 8, .y = 3 } }).?)); // Just right of top right + try testing.expect(!sel.contains(&s, s.pages.pin(.{ .screen = .{ .x = 2, .y = 9 } }).?)); // Just left of bottom left + } + + // Single line + // NOTE: This is the same as normal selection but we just do it for brevity + { + const sel = Selection.init( + s.pages.pin(.{ .screen = .{ .x = 5, .y = 1 } }).?, + s.pages.pin(.{ .screen = .{ .x = 10, .y = 1 } }).?, + true, + ); + + try testing.expect(sel.contains(&s, s.pages.pin(.{ .screen = .{ .x = 6, .y = 1 } }).?)); + try testing.expect(!sel.contains(&s, s.pages.pin(.{ .screen = .{ .x = 2, .y = 1 } }).?)); + try testing.expect(!sel.contains(&s, s.pages.pin(.{ .screen = .{ .x = 12, .y = 1 } }).?)); } } diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index 5ff2591cb..94d33f734 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -1,15 +1,17 @@ //! The primary terminal emulation structure. This represents a single -//! //! "terminal" containing a grid of characters and exposes various operations //! on that grid. This also maintains the scrollback buffer. const Terminal = @This(); +// TODO on new terminal branch: +// - page splitting +// - resize tests when multiple pages are required + const std = @import("std"); const builtin = @import("builtin"); -const testing = std.testing; const assert = std.debug.assert; +const testing = std.testing; const Allocator = std.mem.Allocator; -const simd = @import("../simd/main.zig"); const unicode = @import("../unicode/main.zig"); const ansi = @import("ansi.zig"); @@ -20,9 +22,16 @@ const kitty = @import("kitty.zig"); const sgr = @import("sgr.zig"); const Tabstops = @import("Tabstops.zig"); const color = @import("color.zig"); -const Screen = @import("Screen.zig"); const mouse_shape = @import("mouse_shape.zig"); +const size = @import("size.zig"); +const pagepkg = @import("page.zig"); +const style = @import("style.zig"); +const Screen = @import("Screen.zig"); +const Page = pagepkg.Page; +const Cell = pagepkg.Cell; +const Row = pagepkg.Row; + const log = std.log.scoped(.terminal); /// Default tabstop interval @@ -34,18 +43,6 @@ pub const ScreenType = enum { alternate, }; -/// The semantic prompt type. This is used when tracking a line type and -/// requires integration with the shell. By default, we mark a line as "none" -/// meaning we don't know what type it is. -/// -/// See: https://gitlab.freedesktop.org/Per_Bothner/specifications/blob/master/proposals/semantic-prompts.md -pub const SemanticPrompt = enum { - prompt, - prompt_continuation, - input, - command, -}; - /// Screen is the current screen state. The "active_screen" field says what /// the current screen is. The backup screen is the opposite of the active /// screen. @@ -62,8 +59,8 @@ status_display: ansi.StatusDisplay = .main, tabstops: Tabstops, /// The size of the terminal. -rows: usize, -cols: usize, +rows: size.CellCountInt, +cols: size.CellCountInt, /// The size of the screen in pixels. This is used for pty events and images width_px: u32 = 0, @@ -152,26 +149,26 @@ pub const MouseFormat = enum(u3) { pub const ScrollingRegion = struct { // Top and bottom of the scroll region (0-indexed) // Precondition: top < bottom - top: usize, - bottom: usize, + top: size.CellCountInt, + bottom: size.CellCountInt, // Left/right scroll regions. // Precondition: right > left // Precondition: right <= cols - 1 - left: usize, - right: usize, + left: size.CellCountInt, + right: size.CellCountInt, }; /// Initialize a new terminal. -pub fn init(alloc: Allocator, cols: usize, rows: usize) !Terminal { +pub fn init(alloc: Allocator, cols: size.CellCountInt, rows: size.CellCountInt) !Terminal { return Terminal{ .cols = cols, .rows = rows, .active_screen = .primary, // TODO: configurable scrollback - .screen = try Screen.init(alloc, rows, cols, 10000), + .screen = try Screen.init(alloc, cols, rows, 10000), // No scrollback for the alternate screen - .secondary_screen = try Screen.init(alloc, rows, cols, 0), + .secondary_screen = try Screen.init(alloc, cols, rows, 0), .tabstops = try Tabstops.init(alloc, cols, TABSTOP_INTERVAL), .scrolling_region = .{ .top = 0, @@ -191,86 +188,1806 @@ pub fn deinit(self: *Terminal, alloc: Allocator) void { self.* = undefined; } -/// Options for switching to the alternate screen. -pub const AlternateScreenOptions = struct { - cursor_save: bool = false, - clear_on_enter: bool = false, - clear_on_exit: bool = false, -}; +/// Print UTF-8 encoded string to the terminal. +pub fn printString(self: *Terminal, str: []const u8) !void { + const view = try std.unicode.Utf8View.init(str); + var it = view.iterator(); + while (it.nextCodepoint()) |cp| { + switch (cp) { + '\n' => { + self.carriageReturn(); + try self.linefeed(); + }, -/// Switch to the alternate screen buffer. -/// -/// The alternate screen buffer: -/// * has its own grid -/// * has its own cursor state (included saved cursor) -/// * does not support scrollback -/// -pub fn alternateScreen( - self: *Terminal, - alloc: Allocator, - options: AlternateScreenOptions, -) void { - //log.info("alt screen active={} options={} cursor={}", .{ self.active_screen, options, self.screen.cursor }); - - // TODO: test - // TODO(mitchellh): what happens if we enter alternate screen multiple times? - // for now, we ignore... - if (self.active_screen == .alternate) return; - - // If we requested cursor save, we save the cursor in the primary screen - if (options.cursor_save) self.saveCursor(); - - // Switch the screens - const old = self.screen; - self.screen = self.secondary_screen; - self.secondary_screen = old; - self.active_screen = .alternate; - - // Bring our pen with us - self.screen.cursor = old.cursor; - - // Bring our charset state with us - self.screen.charset = old.charset; - - // Clear our selection - self.screen.selection = null; - - // Mark kitty images as dirty so they redraw - self.screen.kitty_images.dirty = true; - - if (options.clear_on_enter) { - self.eraseDisplay(alloc, .complete, false); + else => try self.print(cp), + } } } -/// Switch back to the primary screen (reset alternate screen mode). -pub fn primaryScreen( +/// Print the previous printed character a repeated amount of times. +pub fn printRepeat(self: *Terminal, count_req: usize) !void { + if (self.previous_char) |c| { + const count = @max(count_req, 1); + for (0..count) |_| try self.print(c); + } +} + +pub fn print(self: *Terminal, c: u21) !void { + // log.debug("print={x} y={} x={}", .{ c, self.screen.cursor.y, self.screen.cursor.x }); + + // If we're not on the main display, do nothing for now + if (self.status_display != .main) return; + + // Our right margin depends where our cursor is now. + const right_limit = if (self.screen.cursor.x > self.scrolling_region.right) + self.cols + else + self.scrolling_region.right + 1; + + // Perform grapheme clustering if grapheme support is enabled (mode 2027). + // This is MUCH slower than the normal path so the conditional below is + // purposely ordered in least-likely to most-likely so we can drop out + // as quickly as possible. + if (c > 255 and + self.modes.get(.grapheme_cluster) and + self.screen.cursor.x > 0) + grapheme: { + // We need the previous cell to determine if we're at a grapheme + // break or not. If we are NOT, then we are still combining the + // same grapheme. Otherwise, we can stay in this cell. + const Prev = struct { cell: *Cell, left: size.CellCountInt }; + const prev: Prev = prev: { + const left: size.CellCountInt = left: { + // If we have wraparound, then we always use the prev col + if (self.modes.get(.wraparound)) break :left 1; + + // If we do not have wraparound, the logic is trickier. If + // we're not on the last column, then we just use the previous + // column. Otherwise, we need to check if there is text to + // figure out if we're attaching to the prev or current. + if (self.screen.cursor.x != right_limit - 1) break :left 1; + break :left @intFromBool(!self.screen.cursor.page_cell.hasText()); + }; + + // If the previous cell is a wide spacer tail, then we actually + // want to use the cell before that because that has the actual + // content. + const immediate = self.screen.cursorCellLeft(left); + break :prev switch (immediate.wide) { + else => .{ .cell = immediate, .left = left }, + .spacer_tail => .{ + .cell = self.screen.cursorCellLeft(left + 1), + .left = left + 1, + }, + }; + }; + + // If our cell has no content, then this is a new cell and + // necessarily a grapheme break. + if (!prev.cell.hasText()) break :grapheme; + + const grapheme_break = brk: { + var state: unicode.GraphemeBreakState = .{}; + var cp1: u21 = prev.cell.content.codepoint; + if (prev.cell.hasGrapheme()) { + const cps = self.screen.cursor.page_pin.page.data.lookupGrapheme(prev.cell).?; + for (cps) |cp2| { + // log.debug("cp1={x} cp2={x}", .{ cp1, cp2 }); + assert(!unicode.graphemeBreak(cp1, cp2, &state)); + cp1 = cp2; + } + } + + // log.debug("cp1={x} cp2={x} end", .{ cp1, c }); + break :brk unicode.graphemeBreak(cp1, c, &state); + }; + + // If we can NOT break, this means that "c" is part of a grapheme + // with the previous char. + if (!grapheme_break) { + // If this is an emoji variation selector then we need to modify + // the cell width accordingly. VS16 makes the character wide and + // VS15 makes it narrow. + if (c == 0xFE0F or c == 0xFE0E) { + // This only applies to emoji + const prev_props = unicode.getProperties(prev.cell.content.codepoint); + const emoji = prev_props.grapheme_boundary_class == .extended_pictographic; + if (!emoji) return; + + switch (c) { + 0xFE0F => wide: { + if (prev.cell.wide == .wide) break :wide; + + // Move our cursor back to the previous. We'll move + // the cursor within this block to the proper location. + self.screen.cursorLeft(prev.left); + + // If we don't have space for the wide char, we need + // to insert spacers and wrap. Then we just print the wide + // char as normal. + if (self.screen.cursor.x == right_limit - 1) { + if (!self.modes.get(.wraparound)) return; + self.printCell(' ', .spacer_head); + try self.printWrap(); + } + + self.printCell(prev.cell.content.codepoint, .wide); + + // Write our spacer + self.screen.cursorRight(1); + self.printCell(' ', .spacer_tail); + + // Move the cursor again so we're beyond our spacer + if (self.screen.cursor.x == right_limit - 1) { + self.screen.cursor.pending_wrap = true; + } else { + self.screen.cursorRight(1); + } + }, + + 0xFE0E => narrow: { + // Prev cell is no longer wide + if (prev.cell.wide != .wide) break :narrow; + prev.cell.wide = .narrow; + + // Remove the wide spacer tail + const cell = self.screen.cursorCellLeft(prev.left - 1); + cell.wide = .narrow; + + break :narrow; + }, + + else => unreachable, + } + } + + log.debug("c={x} grapheme attach to left={}", .{ c, prev.left }); + try self.screen.cursor.page_pin.page.data.appendGrapheme( + self.screen.cursor.page_row, + prev.cell, + c, + ); + return; + } + } + + // Determine the width of this character so we can handle + // non-single-width characters properly. We have a fast-path for + // byte-sized characters since they're so common. We can ignore + // control characters because they're always filtered prior. + const width: usize = if (c <= 0xFF) 1 else @intCast(unicode.table.get(c).width); + + // Note: it is possible to have a width of "3" and a width of "-1" + // from ziglyph. We should look into those cases and handle them + // appropriately. + assert(width <= 2); + // log.debug("c={x} width={}", .{ c, width }); + + // Attach zero-width characters to our cell as grapheme data. + if (width == 0) { + // If we have grapheme clustering enabled, we don't blindly attach + // any zero width character to our cells and we instead just ignore + // it. + if (self.modes.get(.grapheme_cluster)) return; + + // If we're at cell zero, then this is malformed data and we don't + // print anything or even store this. Zero-width characters are ALWAYS + // attached to some other non-zero-width character at the time of + // writing. + if (self.screen.cursor.x == 0) { + log.warn("zero-width character with no prior character, ignoring", .{}); + return; + } + + // Find our previous cell + const prev = prev: { + const immediate = self.screen.cursorCellLeft(1); + if (immediate.wide != .spacer_tail) break :prev immediate; + break :prev self.screen.cursorCellLeft(2); + }; + + // If our previous cell has no text, just ignore the zero-width character + if (!prev.hasText()) { + log.warn("zero-width character with no prior character, ignoring", .{}); + return; + } + + // If this is a emoji variation selector, prev must be an emoji + if (c == 0xFE0F or c == 0xFE0E) { + const prev_props = unicode.getProperties(prev.content.codepoint); + const emoji = prev_props.grapheme_boundary_class == .extended_pictographic; + if (!emoji) return; + } + + try self.screen.cursor.page_pin.page.data.appendGrapheme( + self.screen.cursor.page_row, + prev, + c, + ); + return; + } + + // We have a printable character, save it + self.previous_char = c; + + // If we're soft-wrapping, then handle that first. + if (self.screen.cursor.pending_wrap and self.modes.get(.wraparound)) { + try self.printWrap(); + } + + // If we have insert mode enabled then we need to handle that. We + // only do insert mode if we're not at the end of the line. + if (self.modes.get(.insert) and + self.screen.cursor.x + width < self.cols) + { + self.insertBlanks(width); + } + + switch (width) { + // Single cell is very easy: just write in the cell + 1 => @call(.always_inline, printCell, .{ self, c, .narrow }), + + // Wide character requires a spacer. We print this by + // using two cells: the first is flagged "wide" and has the + // wide char. The second is guaranteed to be a spacer if + // we're not at the end of the line. + 2 => if ((right_limit - self.scrolling_region.left) > 1) { + // If we don't have space for the wide char, we need + // to insert spacers and wrap. Then we just print the wide + // char as normal. + if (self.screen.cursor.x == right_limit - 1) { + // If we don't have wraparound enabled then we don't print + // this character at all and don't move the cursor. This is + // how xterm behaves. + if (!self.modes.get(.wraparound)) return; + + self.printCell(' ', .spacer_head); + try self.printWrap(); + } + + self.printCell(c, .wide); + self.screen.cursorRight(1); + self.printCell(' ', .spacer_tail); + } else { + // This is pretty broken, terminals should never be only 1-wide. + // We sould prevent this downstream. + self.printCell(' ', .narrow); + }, + + else => unreachable, + } + + // If we're at the column limit, then we need to wrap the next time. + // In this case, we don't move the cursor. + if (self.screen.cursor.x == right_limit - 1) { + self.screen.cursor.pending_wrap = true; + return; + } + + // Move the cursor + self.screen.cursorRight(1); +} + +fn printCell( + self: *Terminal, + unmapped_c: u21, + wide: Cell.Wide, +) void { + // TODO: spacers should use a bgcolor only cell + + const c: u21 = c: { + // TODO: non-utf8 handling, gr + + // If we're single shifting, then we use the key exactly once. + const key = if (self.screen.charset.single_shift) |key_once| blk: { + self.screen.charset.single_shift = null; + break :blk key_once; + } else self.screen.charset.gl; + const set = self.screen.charset.charsets.get(key); + + // UTF-8 or ASCII is used as-is + if (set == .utf8 or set == .ascii) break :c unmapped_c; + + // If we're outside of ASCII range this is an invalid value in + // this table so we just return space. + if (unmapped_c > std.math.maxInt(u8)) break :c ' '; + + // Get our lookup table and map it + const table = set.table(); + break :c @intCast(table[@intCast(unmapped_c)]); + }; + + const cell = self.screen.cursor.page_cell; + + // If the wide property of this cell is the same, then we don't + // need to do the special handling here because the structure will + // be the same. If it is NOT the same, then we may need to clear some + // cells. + if (cell.wide != wide) { + switch (cell.wide) { + // Previous cell was narrow. Do nothing. + .narrow => {}, + + // Previous cell was wide. We need to clear the tail and head. + .wide => wide: { + if (self.screen.cursor.x >= self.cols - 1) break :wide; + + const spacer_cell = self.screen.cursorCellRight(1); + spacer_cell.* = .{ .style_id = self.screen.cursor.style_id }; + if (self.screen.cursor.y > 0 and self.screen.cursor.x <= 1) { + const head_cell = self.screen.cursorCellEndOfPrev(); + head_cell.wide = .narrow; + } + }, + + .spacer_tail => { + assert(self.screen.cursor.x > 0); + + const wide_cell = self.screen.cursorCellLeft(1); + wide_cell.* = .{ .style_id = self.screen.cursor.style_id }; + if (self.screen.cursor.y > 0 and self.screen.cursor.x <= 1) { + const head_cell = self.screen.cursorCellEndOfPrev(); + head_cell.wide = .narrow; + } + }, + + // TODO: this case was not handled in the old terminal implementation + // but it feels like we should do something. investigate other + // terminals (xterm mainly) and see whats up. + .spacer_head => {}, + } + } + + // If the prior value had graphemes, clear those + if (cell.hasGrapheme()) { + self.screen.cursor.page_pin.page.data.clearGrapheme( + self.screen.cursor.page_row, + cell, + ); + } + + // Keep track of the previous style so we can decrement the ref count + const prev_style_id = cell.style_id; + + // Write + cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = c }, + .style_id = self.screen.cursor.style_id, + .wide = wide, + .protected = self.screen.cursor.protected, + }; + + // Handle the style ref count handling + style_ref: { + if (prev_style_id != style.default_id) { + const row = self.screen.cursor.page_row; + assert(row.styled); + + // If our previous cell had the same style ID as us currently, + // then we don't bother with any ref counts because we're the same. + if (prev_style_id == self.screen.cursor.style_id) break :style_ref; + + // Slow path: we need to lookup this style so we can decrement + // the ref count. Since we've already loaded everything, we also + // just go ahead and GC it if it reaches zero, too. + var page = &self.screen.cursor.page_pin.page.data; + if (page.styles.lookupId(page.memory, prev_style_id)) |prev_style| { + // Below upsert can't fail because it should already be present + const md = page.styles.upsert(page.memory, prev_style.*) catch unreachable; + assert(md.ref > 0); + md.ref -= 1; + if (md.ref == 0) page.styles.remove(page.memory, prev_style_id); + } + } + + // If we have a ref-counted style, increase. + if (self.screen.cursor.style_ref) |ref| { + ref.* += 1; + self.screen.cursor.page_row.styled = true; + } + } +} + +fn printWrap(self: *Terminal) !void { + self.screen.cursor.page_row.wrap = true; + + // Get the old semantic prompt so we can extend it to the next + // line. We need to do this before we index() because we may + // modify memory. + const old_prompt = self.screen.cursor.page_row.semantic_prompt; + + // Move to the next line + try self.index(); + self.screen.cursorHorizontalAbsolute(self.scrolling_region.left); + + // New line must inherit semantic prompt of the old line + self.screen.cursor.page_row.semantic_prompt = old_prompt; + self.screen.cursor.page_row.wrap_continuation = true; +} + +/// Set the charset into the given slot. +pub fn configureCharset(self: *Terminal, slot: charsets.Slots, set: charsets.Charset) void { + self.screen.charset.charsets.set(slot, set); +} + +/// Invoke the charset in slot into the active slot. If single is true, +/// then this will only be invoked for a single character. +pub fn invokeCharset( + self: *Terminal, + active: charsets.ActiveSlot, + slot: charsets.Slots, + single: bool, +) void { + if (single) { + assert(active == .GL); + self.screen.charset.single_shift = slot; + return; + } + + switch (active) { + .GL => self.screen.charset.gl = slot, + .GR => self.screen.charset.gr = slot, + } +} + +/// Carriage return moves the cursor to the first column. +pub fn carriageReturn(self: *Terminal) void { + // Always reset pending wrap state + self.screen.cursor.pending_wrap = false; + + // In origin mode we always move to the left margin + self.screen.cursorHorizontalAbsolute(if (self.modes.get(.origin)) + self.scrolling_region.left + else if (self.screen.cursor.x >= self.scrolling_region.left) + self.scrolling_region.left + else + 0); +} + +/// Linefeed moves the cursor to the next line. +pub fn linefeed(self: *Terminal) !void { + try self.index(); + if (self.modes.get(.linefeed)) self.carriageReturn(); +} + +/// Backspace moves the cursor back a column (but not less than 0). +pub fn backspace(self: *Terminal) void { + self.cursorLeft(1); +} + +/// Move the cursor up amount lines. If amount is greater than the maximum +/// move distance then it is internally adjusted to the maximum. If amount is +/// 0, adjust it to 1. +pub fn cursorUp(self: *Terminal, count_req: usize) void { + // Always resets pending wrap + self.screen.cursor.pending_wrap = false; + + // The maximum amount the cursor can move up depends on scrolling regions + const max = if (self.screen.cursor.y >= self.scrolling_region.top) + self.screen.cursor.y - self.scrolling_region.top + else + self.screen.cursor.y; + const count = @min(max, @max(count_req, 1)); + + // We can safely intCast below because of the min/max clamping we did above. + self.screen.cursorUp(@intCast(count)); +} + +/// Move the cursor down amount lines. If amount is greater than the maximum +/// move distance then it is internally adjusted to the maximum. This sequence +/// will not scroll the screen or scroll region. If amount is 0, adjust it to 1. +pub fn cursorDown(self: *Terminal, count_req: usize) void { + // Always resets pending wrap + self.screen.cursor.pending_wrap = false; + + // The max the cursor can move to depends where the cursor currently is + const max = if (self.screen.cursor.y <= self.scrolling_region.bottom) + self.scrolling_region.bottom - self.screen.cursor.y + else + self.rows - self.screen.cursor.y - 1; + const count = @min(max, @max(count_req, 1)); + self.screen.cursorDown(@intCast(count)); +} + +/// Move the cursor right amount columns. If amount is greater than the +/// maximum move distance then it is internally adjusted to the maximum. +/// This sequence will not scroll the screen or scroll region. If amount is +/// 0, adjust it to 1. +pub fn cursorRight(self: *Terminal, count_req: usize) void { + // Always resets pending wrap + self.screen.cursor.pending_wrap = false; + + // The max the cursor can move to depends where the cursor currently is + const max = if (self.screen.cursor.x <= self.scrolling_region.right) + self.scrolling_region.right - self.screen.cursor.x + else + self.cols - self.screen.cursor.x - 1; + const count = @min(max, @max(count_req, 1)); + self.screen.cursorRight(@intCast(count)); +} + +/// Move the cursor to the left amount cells. If amount is 0, adjust it to 1. +pub fn cursorLeft(self: *Terminal, count_req: usize) void { + // Wrapping behavior depends on various terminal modes + const WrapMode = enum { none, reverse, reverse_extended }; + const wrap_mode: WrapMode = wrap_mode: { + if (!self.modes.get(.wraparound)) break :wrap_mode .none; + if (self.modes.get(.reverse_wrap_extended)) break :wrap_mode .reverse_extended; + if (self.modes.get(.reverse_wrap)) break :wrap_mode .reverse; + break :wrap_mode .none; + }; + + var count = @max(count_req, 1); + + // If we are in no wrap mode, then we move the cursor left and exit + // since this is the fastest and most typical path. + if (wrap_mode == .none) { + self.screen.cursorLeft(@min(count, self.screen.cursor.x)); + self.screen.cursor.pending_wrap = false; + return; + } + + // If we have a pending wrap state and we are in either reverse wrap + // modes then we decrement the amount we move by one to match xterm. + if (self.screen.cursor.pending_wrap) { + count -= 1; + self.screen.cursor.pending_wrap = false; + } + + // The margins we can move to. + const top = self.scrolling_region.top; + const bottom = self.scrolling_region.bottom; + const right_margin = self.scrolling_region.right; + const left_margin = if (self.screen.cursor.x < self.scrolling_region.left) + 0 + else + self.scrolling_region.left; + + // Handle some edge cases when our cursor is already on the left margin. + if (self.screen.cursor.x == left_margin) { + switch (wrap_mode) { + // In reverse mode, if we're already before the top margin + // then we just set our cursor to the top-left and we're done. + .reverse => if (self.screen.cursor.y <= top) { + self.screen.cursorAbsolute(left_margin, top); + return; + }, + + // Handled in while loop + .reverse_extended => {}, + + // Handled above + .none => unreachable, + } + } + + while (true) { + // We can move at most to the left margin. + const max = self.screen.cursor.x - left_margin; + + // We want to move at most the number of columns we have left + // or our remaining count. Do the move. + const amount = @min(max, count); + count -= amount; + self.screen.cursorLeft(amount); + + // If we have no more to move, then we're done. + if (count == 0) break; + + // If we are at the top, then we are done. + if (self.screen.cursor.y == top) { + if (wrap_mode != .reverse_extended) break; + + self.screen.cursorAbsolute(right_margin, bottom); + count -= 1; + continue; + } + + // UNDEFINED TERMINAL BEHAVIOR. This situation is not handled in xterm + // and currently results in a crash in xterm. Given no other known + // terminal [to me] implements XTREVWRAP2, I decided to just mimick + // the behavior of xterm up and not including the crash by wrapping + // up to the (0, 0) and stopping there. My reasoning is that for an + // appropriately sized value of "count" this is the behavior that xterm + // would have. This is unit tested. + if (self.screen.cursor.y == 0) { + assert(self.screen.cursor.x == left_margin); + break; + } + + // If our previous line is not wrapped then we are done. + if (wrap_mode != .reverse_extended) { + const prev_row = self.screen.cursorRowUp(1); + if (!prev_row.wrap) break; + } + + self.screen.cursorAbsolute(right_margin, self.screen.cursor.y - 1); + count -= 1; + } +} + +/// Save cursor position and further state. +/// +/// The primary and alternate screen have distinct save state. One saved state +/// is kept per screen (main / alternative). If for the current screen state +/// was already saved it is overwritten. +pub fn saveCursor(self: *Terminal) void { + self.screen.saved_cursor = .{ + .x = self.screen.cursor.x, + .y = self.screen.cursor.y, + .style = self.screen.cursor.style, + .protected = self.screen.cursor.protected, + .pending_wrap = self.screen.cursor.pending_wrap, + .origin = self.modes.get(.origin), + .charset = self.screen.charset, + }; +} + +/// Restore cursor position and other state. +/// +/// The primary and alternate screen have distinct save state. +/// If no save was done before values are reset to their initial values. +pub fn restoreCursor(self: *Terminal) !void { + const saved: Screen.SavedCursor = self.screen.saved_cursor orelse .{ + .x = 0, + .y = 0, + .style = .{}, + .protected = false, + .pending_wrap = false, + .origin = false, + .charset = .{}, + }; + + // Set the style first because it can fail + const old_style = self.screen.cursor.style; + self.screen.cursor.style = saved.style; + errdefer self.screen.cursor.style = old_style; + try self.screen.manualStyleUpdate(); + + self.screen.charset = saved.charset; + self.modes.set(.origin, saved.origin); + self.screen.cursor.pending_wrap = saved.pending_wrap; + self.screen.cursor.protected = saved.protected; + self.screen.cursorAbsolute( + @min(saved.x, self.cols - 1), + @min(saved.y, self.rows - 1), + ); +} + +/// Set the character protection mode for the terminal. +pub fn setProtectedMode(self: *Terminal, mode: ansi.ProtectedMode) void { + switch (mode) { + .off => { + self.screen.cursor.protected = false; + + // screen.protected_mode is NEVER reset to ".off" because + // logic such as eraseChars depends on knowing what the + // _most recent_ mode was. + }, + + .iso => { + self.screen.cursor.protected = true; + self.screen.protected_mode = .iso; + }, + + .dec => { + self.screen.cursor.protected = true; + self.screen.protected_mode = .dec; + }, + } +} + +/// The semantic prompt type. This is used when tracking a line type and +/// requires integration with the shell. By default, we mark a line as "none" +/// meaning we don't know what type it is. +/// +/// See: https://gitlab.freedesktop.org/Per_Bothner/specifications/blob/master/proposals/semantic-prompts.md +pub const SemanticPrompt = enum { + prompt, + prompt_continuation, + input, + command, +}; + +/// Mark the current semantic prompt information. Current escape sequences +/// (OSC 133) only allow setting this for wherever the current active cursor +/// is located. +pub fn markSemanticPrompt(self: *Terminal, p: SemanticPrompt) void { + //log.debug("semantic_prompt y={} p={}", .{ self.screen.cursor.y, p }); + self.screen.cursor.page_row.semantic_prompt = switch (p) { + .prompt => .prompt, + .prompt_continuation => .prompt_continuation, + .input => .input, + .command => .command, + }; +} + +/// Returns true if the cursor is currently at a prompt. Another way to look +/// at this is it returns false if the shell is currently outputting something. +/// This requires shell integration (semantic prompt integration). +/// +/// If the shell integration doesn't exist, this will always return false. +pub fn cursorIsAtPrompt(self: *Terminal) bool { + // If we're on the secondary screen, we're never at a prompt. + if (self.active_screen == .alternate) return false; + + // Reverse through the active + const start_x, const start_y = .{ self.screen.cursor.x, self.screen.cursor.y }; + defer self.screen.cursorAbsolute(start_x, start_y); + + for (0..start_y + 1) |i| { + if (i > 0) self.screen.cursorUp(1); + switch (self.screen.cursor.page_row.semantic_prompt) { + // If we're at a prompt or input area, then we are at a prompt. + .prompt, + .prompt_continuation, + .input, + => return true, + + // If we have command output, then we're most certainly not + // at a prompt. + .command => return false, + + // If we don't know, we keep searching. + .unknown => {}, + } + } + + return false; +} + +/// Horizontal tab moves the cursor to the next tabstop, clearing +/// the screen to the left the tabstop. +pub fn horizontalTab(self: *Terminal) !void { + while (self.screen.cursor.x < self.scrolling_region.right) { + // Move the cursor right + self.screen.cursorRight(1); + + // If the last cursor position was a tabstop we return. We do + // "last cursor position" because we want a space to be written + // at the tabstop unless we're at the end (the while condition). + if (self.tabstops.get(self.screen.cursor.x)) return; + } +} + +// Same as horizontalTab but moves to the previous tabstop instead of the next. +pub fn horizontalTabBack(self: *Terminal) !void { + // With origin mode enabled, our leftmost limit is the left margin. + const left_limit = if (self.modes.get(.origin)) self.scrolling_region.left else 0; + + while (true) { + // If we're already at the edge of the screen, then we're done. + if (self.screen.cursor.x <= left_limit) return; + + // Move the cursor left + self.screen.cursorLeft(1); + if (self.tabstops.get(self.screen.cursor.x)) return; + } +} + +/// Clear tab stops. +pub fn tabClear(self: *Terminal, cmd: csi.TabClear) void { + switch (cmd) { + .current => self.tabstops.unset(self.screen.cursor.x), + .all => self.tabstops.reset(0), + else => log.warn("invalid or unknown tab clear setting: {}", .{cmd}), + } +} + +/// Set a tab stop on the current cursor. +/// TODO: test +pub fn tabSet(self: *Terminal) void { + self.tabstops.set(self.screen.cursor.x); +} + +/// TODO: test +pub fn tabReset(self: *Terminal) void { + self.tabstops.reset(TABSTOP_INTERVAL); +} + +/// Move the cursor to the next line in the scrolling region, possibly scrolling. +/// +/// If the cursor is outside of the scrolling region: move the cursor one line +/// down if it is not on the bottom-most line of the screen. +/// +/// If the cursor is inside the scrolling region: +/// If the cursor is on the bottom-most line of the scrolling region: +/// invoke scroll up with amount=1 +/// If the cursor is not on the bottom-most line of the scrolling region: +/// move the cursor one line down +/// +/// This unsets the pending wrap state without wrapping. +pub fn index(self: *Terminal) !void { + // Unset pending wrap state + self.screen.cursor.pending_wrap = false; + + // Outside of the scroll region we move the cursor one line down. + if (self.screen.cursor.y < self.scrolling_region.top or + self.screen.cursor.y > self.scrolling_region.bottom) + { + // We only move down if we're not already at the bottom of + // the screen. + if (self.screen.cursor.y < self.rows - 1) { + self.screen.cursorDown(1); + } + + return; + } + + // If the cursor is inside the scrolling region and on the bottom-most + // line, then we scroll up. If our scrolling region is the full screen + // we create scrollback. + if (self.screen.cursor.y == self.scrolling_region.bottom and + self.screen.cursor.x >= self.scrolling_region.left and + self.screen.cursor.x <= self.scrolling_region.right) + { + // If our scrolling region is the full screen, we create scrollback. + // Otherwise, we simply scroll the region. + if (self.scrolling_region.top == 0 and + self.scrolling_region.bottom == self.rows - 1 and + self.scrolling_region.left == 0 and + self.scrolling_region.right == self.cols - 1) + { + try self.screen.cursorDownScroll(); + } else { + self.scrollUp(1); + } + + return; + } + + // Increase cursor by 1, maximum to bottom of scroll region + if (self.screen.cursor.y < self.scrolling_region.bottom) { + self.screen.cursorDown(1); + } +} + +/// Move the cursor to the previous line in the scrolling region, possibly +/// scrolling. +/// +/// If the cursor is outside of the scrolling region, move the cursor one +/// line up if it is not on the top-most line of the screen. +/// +/// If the cursor is inside the scrolling region: +/// +/// * If the cursor is on the top-most line of the scrolling region: +/// invoke scroll down with amount=1 +/// * If the cursor is not on the top-most line of the scrolling region: +/// move the cursor one line up +pub fn reverseIndex(self: *Terminal) void { + if (self.screen.cursor.y != self.scrolling_region.top or + self.screen.cursor.x < self.scrolling_region.left or + self.screen.cursor.x > self.scrolling_region.right) + { + self.cursorUp(1); + return; + } + + self.scrollDown(1); +} + +// Set Cursor Position. Move cursor to the position indicated +// by row and column (1-indexed). If column is 0, it is adjusted to 1. +// If column is greater than the right-most column it is adjusted to +// the right-most column. If row is 0, it is adjusted to 1. If row is +// greater than the bottom-most row it is adjusted to the bottom-most +// row. +pub fn setCursorPos(self: *Terminal, row_req: usize, col_req: usize) void { + // If cursor origin mode is set the cursor row will be moved relative to + // the top margin row and adjusted to be above or at bottom-most row in + // the current scroll region. + // + // If origin mode is set and left and right margin mode is set the cursor + // will be moved relative to the left margin column and adjusted to be on + // or left of the right margin column. + const params: struct { + x_offset: size.CellCountInt = 0, + y_offset: size.CellCountInt = 0, + x_max: size.CellCountInt, + y_max: size.CellCountInt, + } = if (self.modes.get(.origin)) .{ + .x_offset = self.scrolling_region.left, + .y_offset = self.scrolling_region.top, + .x_max = self.scrolling_region.right + 1, // We need this 1-indexed + .y_max = self.scrolling_region.bottom + 1, // We need this 1-indexed + } else .{ + .x_max = self.cols, + .y_max = self.rows, + }; + + // Unset pending wrap state + self.screen.cursor.pending_wrap = false; + + // Calculate our new x/y + const row = if (row_req == 0) 1 else row_req; + const col = if (col_req == 0) 1 else col_req; + const x = @min(params.x_max, col + params.x_offset) -| 1; + const y = @min(params.y_max, row + params.y_offset) -| 1; + + // If the y is unchanged then this is fast pointer math + if (y == self.screen.cursor.y) { + if (x > self.screen.cursor.x) { + self.screen.cursorRight(x - self.screen.cursor.x); + } else { + self.screen.cursorLeft(self.screen.cursor.x - x); + } + + return; + } + + // If everything changed we do an absolute change which is slightly slower + self.screen.cursorAbsolute(x, y); + // log.info("set cursor position: col={} row={}", .{ self.screen.cursor.x, self.screen.cursor.y }); +} + +/// Set Top and Bottom Margins If bottom is not specified, 0 or bigger than +/// the number of the bottom-most row, it is adjusted to the number of the +/// bottom most row. +/// +/// If top < bottom set the top and bottom row of the scroll region according +/// to top and bottom and move the cursor to the top-left cell of the display +/// (when in cursor origin mode is set to the top-left cell of the scroll region). +/// +/// Otherwise: Set the top and bottom row of the scroll region to the top-most +/// and bottom-most line of the screen. +/// +/// Top and bottom are 1-indexed. +pub fn setTopAndBottomMargin(self: *Terminal, top_req: usize, bottom_req: usize) void { + const top = @max(1, top_req); + const bottom = @min(self.rows, if (bottom_req == 0) self.rows else bottom_req); + if (top >= bottom) return; + + self.scrolling_region.top = @intCast(top - 1); + self.scrolling_region.bottom = @intCast(bottom - 1); + self.setCursorPos(1, 1); +} + +/// DECSLRM +pub fn setLeftAndRightMargin(self: *Terminal, left_req: usize, right_req: usize) void { + // We must have this mode enabled to do anything + if (!self.modes.get(.enable_left_and_right_margin)) return; + + const left = @max(1, left_req); + const right = @min(self.cols, if (right_req == 0) self.cols else right_req); + if (left >= right) return; + + self.scrolling_region.left = @intCast(left - 1); + self.scrolling_region.right = @intCast(right - 1); + self.setCursorPos(1, 1); +} + +/// Scroll the text down by one row. +pub fn scrollDown(self: *Terminal, count: usize) void { + // Preserve our x/y to restore. + const old_x = self.screen.cursor.x; + const old_y = self.screen.cursor.y; + const old_wrap = self.screen.cursor.pending_wrap; + defer { + self.screen.cursorAbsolute(old_x, old_y); + self.screen.cursor.pending_wrap = old_wrap; + } + + // Move to the top of the scroll region + self.screen.cursorAbsolute(self.scrolling_region.left, self.scrolling_region.top); + self.insertLines(count); +} + +/// Removes amount lines from the top of the scroll region. The remaining lines +/// to the bottom margin are shifted up and space from the bottom margin up +/// is filled with empty lines. +/// +/// The new lines are created according to the current SGR state. +/// +/// Does not change the (absolute) cursor position. +pub fn scrollUp(self: *Terminal, count: usize) void { + // Preserve our x/y to restore. + const old_x = self.screen.cursor.x; + const old_y = self.screen.cursor.y; + const old_wrap = self.screen.cursor.pending_wrap; + defer { + self.screen.cursorAbsolute(old_x, old_y); + self.screen.cursor.pending_wrap = old_wrap; + } + + // Move to the top of the scroll region + self.screen.cursorAbsolute(self.scrolling_region.left, self.scrolling_region.top); + self.deleteLines(count); +} + +/// Options for scrolling the viewport of the terminal grid. +pub const ScrollViewport = union(enum) { + /// Scroll to the top of the scrollback + top: void, + + /// Scroll to the bottom, i.e. the top of the active area + bottom: void, + + /// Scroll by some delta amount, up is negative. + delta: isize, +}; + +/// Scroll the viewport of the terminal grid. +pub fn scrollViewport(self: *Terminal, behavior: ScrollViewport) !void { + self.screen.scroll(switch (behavior) { + .top => .{ .top = {} }, + .bottom => .{ .active = {} }, + .delta => |delta| .{ .delta_row = delta }, + }); +} + +/// Insert amount lines at the current cursor row. The contents of the line +/// at the current cursor row and below (to the bottom-most line in the +/// scrolling region) are shifted down by amount lines. The contents of the +/// amount bottom-most lines in the scroll region are lost. +/// +/// This unsets the pending wrap state without wrapping. If the current cursor +/// position is outside of the current scroll region it does nothing. +/// +/// If amount is greater than the remaining number of lines in the scrolling +/// region it is adjusted down (still allowing for scrolling out every remaining +/// line in the scrolling region) +/// +/// In left and right margin mode the margins are respected; lines are only +/// scrolled in the scroll region. +/// +/// All cleared space is colored according to the current SGR state. +/// +/// Moves the cursor to the left margin. +pub fn insertLines(self: *Terminal, count: usize) void { + // Rare, but happens + if (count == 0) return; + + // If the cursor is outside the scroll region we do nothing. + if (self.screen.cursor.y < self.scrolling_region.top or + self.screen.cursor.y > self.scrolling_region.bottom or + self.screen.cursor.x < self.scrolling_region.left or + self.screen.cursor.x > self.scrolling_region.right) return; + + // Remaining rows from our cursor to the bottom of the scroll region. + const rem = self.scrolling_region.bottom - self.screen.cursor.y + 1; + + // We can only insert lines up to our remaining lines in the scroll + // region. So we take whichever is smaller. + const adjusted_count = @min(count, rem); + + // top is just the cursor position. insertLines starts at the cursor + // so this is our top. We want to shift lines down, down to the bottom + // of the scroll region. + const top: [*]Row = @ptrCast(self.screen.cursor.page_row); + + // This is the amount of space at the bottom of the scroll region + // that will NOT be blank, so we need to shift the correct lines down. + // "scroll_amount" is the number of such lines. + const scroll_amount = rem - adjusted_count; + if (scroll_amount > 0) { + var y: [*]Row = top + (scroll_amount - 1); + + // TODO: detect active area split across multiple pages + + // If we have left/right scroll margins we have a slower path. + const left_right = self.scrolling_region.left > 0 or + self.scrolling_region.right < self.cols - 1; + + // We work backwards so we don't overwrite data. + while (@intFromPtr(y) >= @intFromPtr(top)) : (y -= 1) { + const src: *Row = @ptrCast(y); + const dst: *Row = @ptrCast(y + adjusted_count); + + if (!left_right) { + // Swap the src/dst cells. This ensures that our dst gets the proper + // shifted rows and src gets non-garbage cell data that we can clear. + const dst_row = dst.*; + dst.* = src.*; + src.* = dst_row; + continue; + } + + // Left/right scroll margins we have to copy cells, which is much slower... + var page = &self.screen.cursor.page_pin.page.data; + page.moveCells( + src, + self.scrolling_region.left, + dst, + self.scrolling_region.left, + (self.scrolling_region.right - self.scrolling_region.left) + 1, + ); + } + } + + // Inserted lines should keep our bg color + for (0..adjusted_count) |i| { + const row: *Row = @ptrCast(top + i); + + // Clear the src row. + var page = &self.screen.cursor.page_pin.page.data; + const cells = page.getCells(row); + const cells_write = cells[self.scrolling_region.left .. self.scrolling_region.right + 1]; + self.screen.clearCells(page, row, cells_write); + } + + // Move the cursor to the left margin. But importantly this also + // forces screen.cursor.page_cell to reload because the rows above + // shifted cell ofsets so this will ensure the cursor is pointing + // to the correct cell. + self.screen.cursorAbsolute( + self.scrolling_region.left, + self.screen.cursor.y, + ); + + // Always unset pending wrap + self.screen.cursor.pending_wrap = false; +} + +/// Removes amount lines from the current cursor row down. The remaining lines +/// to the bottom margin are shifted up and space from the bottom margin up is +/// filled with empty lines. +/// +/// If the current cursor position is outside of the current scroll region it +/// does nothing. If amount is greater than the remaining number of lines in the +/// scrolling region it is adjusted down. +/// +/// In left and right margin mode the margins are respected; lines are only +/// scrolled in the scroll region. +/// +/// If the cell movement splits a multi cell character that character cleared, +/// by replacing it by spaces, keeping its current attributes. All other +/// cleared space is colored according to the current SGR state. +/// +/// Moves the cursor to the left margin. +pub fn deleteLines(self: *Terminal, count_req: usize) void { + // If the cursor is outside the scroll region we do nothing. + if (self.screen.cursor.y < self.scrolling_region.top or + self.screen.cursor.y > self.scrolling_region.bottom or + self.screen.cursor.x < self.scrolling_region.left or + self.screen.cursor.x > self.scrolling_region.right) return; + + // top is just the cursor position. insertLines starts at the cursor + // so this is our top. We want to shift lines down, down to the bottom + // of the scroll region. + const top: [*]Row = @ptrCast(self.screen.cursor.page_row); + var y: [*]Row = top; + + // Remaining rows from our cursor to the bottom of the scroll region. + const rem = self.scrolling_region.bottom - self.screen.cursor.y + 1; + + // The maximum we can delete is the remaining lines in the scroll region. + const count = @min(count_req, rem); + + // This is the amount of space at the bottom of the scroll region + // that will NOT be blank, so we need to shift the correct lines down. + // "scroll_amount" is the number of such lines. + const scroll_amount = rem - count; + if (scroll_amount > 0) { + // If we have left/right scroll margins we have a slower path. + const left_right = self.scrolling_region.left > 0 or + self.scrolling_region.right < self.cols - 1; + + const bottom: [*]Row = top + (scroll_amount - 1); + while (@intFromPtr(y) <= @intFromPtr(bottom)) : (y += 1) { + const src: *Row = @ptrCast(y + count); + const dst: *Row = @ptrCast(y); + + if (!left_right) { + // Swap the src/dst cells. This ensures that our dst gets the proper + // shifted rows and src gets non-garbage cell data that we can clear. + const dst_row = dst.*; + dst.* = src.*; + src.* = dst_row; + continue; + } + + // Left/right scroll margins we have to copy cells, which is much slower... + var page = &self.screen.cursor.page_pin.page.data; + page.moveCells( + src, + self.scrolling_region.left, + dst, + self.scrolling_region.left, + (self.scrolling_region.right - self.scrolling_region.left) + 1, + ); + } + } + + const bottom: [*]Row = top + (rem - 1); + while (@intFromPtr(y) <= @intFromPtr(bottom)) : (y += 1) { + const row: *Row = @ptrCast(y); + + // Clear the src row. + var page = &self.screen.cursor.page_pin.page.data; + const cells = page.getCells(row); + const cells_write = cells[self.scrolling_region.left .. self.scrolling_region.right + 1]; + self.screen.clearCells(page, row, cells_write); + } + + // Move the cursor to the left margin. But importantly this also + // forces screen.cursor.page_cell to reload because the rows above + // shifted cell ofsets so this will ensure the cursor is pointing + // to the correct cell. + self.screen.cursorAbsolute( + self.scrolling_region.left, + self.screen.cursor.y, + ); + + // Always unset pending wrap + self.screen.cursor.pending_wrap = false; +} + +/// Inserts spaces at current cursor position moving existing cell contents +/// to the right. The contents of the count right-most columns in the scroll +/// region are lost. The cursor position is not changed. +/// +/// This unsets the pending wrap state without wrapping. +/// +/// The inserted cells are colored according to the current SGR state. +pub fn insertBlanks(self: *Terminal, count: usize) void { + // Unset pending wrap state without wrapping. Note: this purposely + // happens BEFORE the scroll region check below, because that's what + // xterm does. + self.screen.cursor.pending_wrap = false; + + // If our cursor is outside the margins then do nothing. We DO reset + // wrap state still so this must remain below the above logic. + if (self.screen.cursor.x < self.scrolling_region.left or + self.screen.cursor.x > self.scrolling_region.right) return; + + // If our count is larger than the remaining amount, we just erase right. + // We only do this if we can erase the entire line (no right margin). + // if (right_limit == self.cols and + // count > right_limit - self.screen.cursor.x) + // { + // self.eraseLine(.right, false); + // return; + // } + + // left is just the cursor position but as a multi-pointer + const left: [*]Cell = @ptrCast(self.screen.cursor.page_cell); + var page = &self.screen.cursor.page_pin.page.data; + + // Remaining cols from our cursor to the right margin. + const rem = self.scrolling_region.right - self.screen.cursor.x + 1; + + // We can only insert blanks up to our remaining cols + const adjusted_count = @min(count, rem); + + // This is the amount of space at the right of the scroll region + // that will NOT be blank, so we need to shift the correct cols right. + // "scroll_amount" is the number of such cols. + const scroll_amount = rem - adjusted_count; + if (scroll_amount > 0) { + var x: [*]Cell = left + (scroll_amount - 1); + + // If our last cell we're shifting is wide, then we need to clear + // it to be empty so we don't split the multi-cell char. + const end: *Cell = @ptrCast(x); + if (end.wide == .wide) { + self.screen.clearCells(page, self.screen.cursor.page_row, end[0..1]); + } + + // We work backwards so we don't overwrite data. + while (@intFromPtr(x) >= @intFromPtr(left)) : (x -= 1) { + const src: *Cell = @ptrCast(x); + const dst: *Cell = @ptrCast(x + adjusted_count); + + // If the destination has graphemes we need to delete them. + // Graphemes are stored by cell offset so we have to do this + // now before we move. + if (dst.hasGrapheme()) { + page.clearGrapheme(self.screen.cursor.page_row, dst); + } + + // Copy our src to our dst + const old_dst = dst.*; + dst.* = src.*; + src.* = old_dst; + + // If the original source (now copied to dst) had graphemes, + // we have to move them since they're stored by cell offset. + if (dst.hasGrapheme()) { + assert(!src.hasGrapheme()); + page.moveGraphemeWithinRow(src, dst); + } + } + } + + // Insert blanks. The blanks preserve the background color. + self.screen.clearCells(page, self.screen.cursor.page_row, left[0..adjusted_count]); +} + +/// Removes amount characters from the current cursor position to the right. +/// The remaining characters are shifted to the left and space from the right +/// margin is filled with spaces. +/// +/// If amount is greater than the remaining number of characters in the +/// scrolling region, it is adjusted down. +/// +/// Does not change the cursor position. +pub fn deleteChars(self: *Terminal, count: usize) void { + if (count == 0) return; + + // If our cursor is outside the margins then do nothing. We DO reset + // wrap state still so this must remain below the above logic. + if (self.screen.cursor.x < self.scrolling_region.left or + self.screen.cursor.x > self.scrolling_region.right) return; + + // This resets the pending wrap state + self.screen.cursor.pending_wrap = false; + + // left is just the cursor position but as a multi-pointer + const left: [*]Cell = @ptrCast(self.screen.cursor.page_cell); + var page = &self.screen.cursor.page_pin.page.data; + + // If our X is a wide spacer tail then we need to erase the + // previous cell too so we don't split a multi-cell character. + if (self.screen.cursor.page_cell.wide == .spacer_tail) { + assert(self.screen.cursor.x > 0); + self.screen.clearCells(page, self.screen.cursor.page_row, (left - 1)[0..2]); + } + + // Remaining cols from our cursor to the right margin. + const rem = self.scrolling_region.right - self.screen.cursor.x + 1; + + // We can only insert blanks up to our remaining cols + const adjusted_count = @min(count, rem); + + // This is the amount of space at the right of the scroll region + // that will NOT be blank, so we need to shift the correct cols right. + // "scroll_amount" is the number of such cols. + const scroll_amount = rem - adjusted_count; + var x: [*]Cell = left; + if (scroll_amount > 0) { + const right: [*]Cell = left + (scroll_amount - 1); + + // If our last cell we're shifting is wide, then we need to clear + // it to be empty so we don't split the multi-cell char. + const end: *Cell = @ptrCast(right + count); + if (end.wide == .spacer_tail) { + const wide: [*]Cell = right + count - 1; + assert(wide[0].wide == .wide); + self.screen.clearCells(page, self.screen.cursor.page_row, wide[0..2]); + } + + while (@intFromPtr(x) <= @intFromPtr(right)) : (x += 1) { + const src: *Cell = @ptrCast(x + count); + const dst: *Cell = @ptrCast(x); + + // If the destination has graphemes we need to delete them. + // Graphemes are stored by cell offset so we have to do this + // now before we move. + if (dst.hasGrapheme()) { + page.clearGrapheme(self.screen.cursor.page_row, dst); + } + + // Copy our src to our dst + const old_dst = dst.*; + dst.* = src.*; + src.* = old_dst; + + // If the original source (now copied to dst) had graphemes, + // we have to move them since they're stored by cell offset. + if (dst.hasGrapheme()) { + assert(!src.hasGrapheme()); + page.moveGraphemeWithinRow(src, dst); + } + } + } + + // Insert blanks. The blanks preserve the background color. + self.screen.clearCells(page, self.screen.cursor.page_row, x[0 .. rem - scroll_amount]); +} + +pub fn eraseChars(self: *Terminal, count_req: usize) void { + const count = @max(count_req, 1); + + // This resets the soft-wrap of this line + self.screen.cursor.page_row.wrap = false; + + // This resets the pending wrap state + self.screen.cursor.pending_wrap = false; + + // Our last index is at most the end of the number of chars we have + // in the current line. + const end = end: { + const remaining = self.cols - self.screen.cursor.x; + var end = @min(remaining, 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 != remaining) { + const last = self.screen.cursorCellRight(end - 1); + if (last.wide == .wide) end += 1; + } + + break :end end; + }; + + // Clear the cells + const cells: [*]Cell = @ptrCast(self.screen.cursor.page_cell); + + // 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) { + self.screen.clearCells( + &self.screen.cursor.page_pin.page.data, + self.screen.cursor.page_row, + cells[0..end], + ); + return; + } + + // SLOW PATH + // We had a protection mode at some point. We must go through each + // cell and check its protection attribute. + for (0..end) |x| { + const cell_multi: [*]Cell = @ptrCast(cells + x); + const cell: *Cell = @ptrCast(&cell_multi[0]); + if (cell.protected) continue; + self.screen.clearCells( + &self.screen.cursor.page_pin.page.data, + self.screen.cursor.page_row, + cell_multi[0..1], + ); + } +} + +/// Erase the line. +pub fn eraseLine( + self: *Terminal, + mode: csi.EraseLine, + protected_req: bool, +) void { + // Get our start/end positions depending on mode. + const start, const end = switch (mode) { + .right => right: { + var x = self.screen.cursor.x; + + // If our X is a wide spacer tail then we need to erase the + // previous cell too so we don't split a multi-cell character. + if (x > 0 and self.screen.cursor.page_cell.wide == .spacer_tail) { + x -= 1; + } + + // This resets the soft-wrap of this line + self.screen.cursor.page_row.wrap = false; + + break :right .{ x, self.cols }; + }, + + .left => left: { + var x = self.screen.cursor.x; + + // If our x is a wide char we need to delete the tail too. + if (self.screen.cursor.page_cell.wide == .wide) { + x += 1; + } + + break :left .{ 0, x + 1 }; + }, + + // Note that it seems like complete should reset the soft-wrap + // state of the line but in xterm it does not. + .complete => .{ 0, self.cols }, + + else => { + log.err("unimplemented erase line mode: {}", .{mode}); + return; + }, + }; + + // All modes will clear the pending wrap state and we know we have + // a valid mode at this point. + self.screen.cursor.pending_wrap = false; + + // Start of our cells + const cells: [*]Cell = cells: { + const cells: [*]Cell = @ptrCast(self.screen.cursor.page_cell); + break :cells cells - self.screen.cursor.x; + }; + + // We respect protected attributes if explicitly requested (probably + // a DECSEL sequence) or if our last protected mode was ISO even if its + // not currently set. + const protected = self.screen.protected_mode == .iso or protected_req; + + // If we're not respecting protected attributes, we can use a fast-path + // to fill the entire line. + if (!protected) { + self.screen.clearCells( + &self.screen.cursor.page_pin.page.data, + self.screen.cursor.page_row, + cells[start..end], + ); + return; + } + + for (start..end) |x| { + const cell_multi: [*]Cell = @ptrCast(cells + x); + const cell: *Cell = @ptrCast(&cell_multi[0]); + if (cell.protected) continue; + self.screen.clearCells( + &self.screen.cursor.page_pin.page.data, + self.screen.cursor.page_row, + cell_multi[0..1], + ); + } +} + +/// Erase the display. +pub fn eraseDisplay( + self: *Terminal, + mode: csi.EraseDisplay, + protected_req: bool, +) void { + // We respect protected attributes if explicitly requested (probably + // a DECSEL sequence) or if our last protected mode was ISO even if its + // not currently set. + const protected = self.screen.protected_mode == .iso or protected_req; + + switch (mode) { + .scroll_complete => { + self.screen.scrollClear() catch |err| { + log.warn("scroll clear failed, doing a normal clear err={}", .{err}); + self.eraseDisplay(.complete, protected_req); + return; + }; + + // Unsets pending wrap state + self.screen.cursor.pending_wrap = false; + + // Clear all Kitty graphics state for this screen + // TODO + // self.screen.kitty_images.delete(alloc, self, .{ .all = true }); + }, + + .complete => { + // If we're on the primary screen and our last non-empty row is + // a prompt, then we do a scroll_complete instead. This is a + // heuristic to get the generally desirable behavior that ^L + // at a prompt scrolls the screen contents prior to clearing. + // Most shells send `ESC [ H ESC [ 2 J` so we can't just check + // our current cursor position. See #905 + // if (self.active_screen == .primary) at_prompt: { + // // Go from the bottom of the viewport up and see if we're + // // at a prompt. + // const viewport_max = Screen.RowIndexTag.viewport.maxLen(&self.screen); + // for (0..viewport_max) |y| { + // const bottom_y = viewport_max - y - 1; + // const row = self.screen.getRow(.{ .viewport = bottom_y }); + // if (row.isEmpty()) continue; + // switch (row.getSemanticPrompt()) { + // // If we're at a prompt or input area, then we are at a prompt. + // .prompt, + // .prompt_continuation, + // .input, + // => break, + // + // // If we have command output, then we're most certainly not + // // at a prompt. + // .command => break :at_prompt, + // + // // If we don't know, we keep searching. + // .unknown => {}, + // } + // } else break :at_prompt; + // + // self.screen.scroll(.{ .clear = {} }) catch { + // // If we fail, we just fall back to doing a normal clear + // // so we don't worry about the error. + // }; + // } + + // All active area + self.screen.clearRows( + .{ .active = .{} }, + null, + protected, + ); + + // Unsets pending wrap state + self.screen.cursor.pending_wrap = false; + + // Clear all Kitty graphics state for this screen + // TODO + //self.screen.kitty_images.delete(alloc, self, .{ .all = true }); + }, + + .below => { + // All lines to the right (including the cursor) + self.eraseLine(.right, protected_req); + + // All lines below + if (self.screen.cursor.y + 1 < self.rows) { + self.screen.clearRows( + .{ .active = .{ .y = self.screen.cursor.y + 1 } }, + null, + protected, + ); + } + + // Unsets pending wrap state. Should be done by eraseLine. + assert(!self.screen.cursor.pending_wrap); + }, + + .above => { + // Erase to the left (including the cursor) + self.eraseLine(.left, protected_req); + + // All lines above + if (self.screen.cursor.y > 0) { + self.screen.clearRows( + .{ .active = .{ .y = 0 } }, + .{ .active = .{ .y = self.screen.cursor.y - 1 } }, + protected, + ); + } + + // Unsets pending wrap state + assert(!self.screen.cursor.pending_wrap); + }, + + .scrollback => self.screen.eraseRows(.{ .history = .{} }, null), + } +} + +/// Resets all margins and fills the whole screen with the character 'E' +/// +/// Sets the cursor to the top left corner. +pub fn decaln(self: *Terminal) !void { + // Clear our stylistic attributes. This is the only thing that can + // fail so we do it first so we can undo it. + const old_style = self.screen.cursor.style; + self.screen.cursor.style = .{ + .bg_color = self.screen.cursor.style.bg_color, + .fg_color = self.screen.cursor.style.fg_color, + // TODO: protected attribute + // .protected = self.screen.cursor.pen.attrs.protected, + }; + errdefer self.screen.cursor.style = old_style; + try self.screen.manualStyleUpdate(); + + // Reset margins, also sets cursor to top-left + self.scrolling_region = .{ + .top = 0, + .bottom = self.rows - 1, + .left = 0, + .right = self.cols - 1, + }; + + // Origin mode is disabled + self.modes.set(.origin, false); + + // Move our cursor to the top-left + self.setCursorPos(1, 1); + + // Erase the display which will deallocate graphames, styles, etc. + self.eraseDisplay(.complete, false); + + // Fill with Es, does not move cursor. + var it = self.screen.pages.pageIterator(.right_down, .{ .active = .{} }, null); + while (it.next()) |chunk| { + for (chunk.rows()) |*row| { + const cells_multi: [*]Cell = row.cells.ptr(chunk.page.data.memory); + const cells = cells_multi[0..self.cols]; + @memset(cells, .{ + .content_tag = .codepoint, + .content = .{ .codepoint = 'E' }, + .style_id = self.screen.cursor.style_id, + .protected = self.screen.cursor.protected, + }); + + // If we have a ref-counted style, increase + if (self.screen.cursor.style_ref) |ref| { + ref.* += @intCast(cells.len); + row.styled = true; + } + } + } +} + +/// Execute a kitty graphics command. The buf is used to populate with +/// the response that should be sent as an APC sequence. The response will +/// be a full, valid APC sequence. +/// +/// If an error occurs, the caller should response to the pty that a +/// an error occurred otherwise the behavior of the graphics protocol is +/// undefined. +pub fn kittyGraphics( self: *Terminal, alloc: Allocator, - options: AlternateScreenOptions, -) void { - //log.info("primary screen active={} options={}", .{ self.active_screen, options }); + cmd: *kitty.graphics.Command, +) ?kitty.graphics.Response { + return kitty.graphics.execute(alloc, self, cmd); +} - // TODO: test - // TODO(mitchellh): what happens if we enter alternate screen multiple times? - if (self.active_screen == .primary) return; +/// Set a style attribute. +pub fn setAttribute(self: *Terminal, attr: sgr.Attribute) !void { + try self.screen.setAttribute(attr); +} - if (options.clear_on_exit) self.eraseDisplay(alloc, .complete, false); +/// Print the active attributes as a string. This is used to respond to DECRQSS +/// requests. +/// +/// Boolean attributes are printed first, followed by foreground color, then +/// background color. Each attribute is separated by a semicolon. +pub fn printAttributes(self: *Terminal, buf: []u8) ![]const u8 { + var stream = std.io.fixedBufferStream(buf); + const writer = stream.writer(); - // Switch the screens - const old = self.screen; - self.screen = self.secondary_screen; - self.secondary_screen = old; - self.active_screen = .primary; + // The SGR response always starts with a 0. See https://vt100.net/docs/vt510-rm/DECRPSS + try writer.writeByte('0'); - // Clear our selection - self.screen.selection = null; + const pen = self.screen.cursor.style; + var attrs = [_]u8{0} ** 8; + var i: usize = 0; - // Mark kitty images as dirty so they redraw - self.screen.kitty_images.dirty = true; + if (pen.flags.bold) { + attrs[i] = '1'; + i += 1; + } - // Restore the cursor from the primary screen - if (options.cursor_save) self.restoreCursor(); + if (pen.flags.faint) { + attrs[i] = '2'; + i += 1; + } + + if (pen.flags.italic) { + attrs[i] = '3'; + i += 1; + } + + if (pen.flags.underline != .none) { + attrs[i] = '4'; + i += 1; + } + + if (pen.flags.blink) { + attrs[i] = '5'; + i += 1; + } + + if (pen.flags.inverse) { + attrs[i] = '7'; + i += 1; + } + + if (pen.flags.invisible) { + attrs[i] = '8'; + i += 1; + } + + if (pen.flags.strikethrough) { + attrs[i] = '9'; + i += 1; + } + + for (attrs[0..i]) |c| { + try writer.print(";{c}", .{c}); + } + + switch (pen.fg_color) { + .none => {}, + .palette => |idx| if (idx >= 16) + try writer.print(";38:5:{}", .{idx}) + else if (idx >= 8) + try writer.print(";9{}", .{idx - 8}) + else + try writer.print(";3{}", .{idx}), + .rgb => |rgb| try writer.print(";38:2::{[r]}:{[g]}:{[b]}", rgb), + } + + switch (pen.bg_color) { + .none => {}, + .palette => |idx| if (idx >= 16) + try writer.print(";48:5:{}", .{idx}) + else if (idx >= 8) + try writer.print(";10{}", .{idx - 8}) + else + try writer.print(";4{}", .{idx}), + .rgb => |rgb| try writer.print(";48:2::{[r]}:{[g]}:{[b]}", rgb), + } + + return stream.getWritten(); } /// The modes for DECCOLM. @@ -309,17 +2026,21 @@ pub fn deccolm(self: *Terminal, alloc: Allocator, mode: DeccolmMode) !void { ); // Erase our display and move our cursor. - self.eraseDisplay(alloc, .complete, false); + self.eraseDisplay(.complete, false); self.setCursorPos(1, 1); } /// Resize the underlying terminal. -pub fn resize(self: *Terminal, alloc: Allocator, cols: usize, rows: usize) !void { +pub fn resize( + self: *Terminal, + alloc: Allocator, + cols: size.CellCountInt, + rows: size.CellCountInt, +) !void { // If our cols/rows didn't change then we're done if (self.cols == cols and self.rows == rows) return; // Resize our tabstops - // TODO: use resize, but it doesn't set new tabstops if (self.cols != cols) { self.tabstops.deinit(alloc); self.tabstops = try Tabstops.init(alloc, cols, 8); @@ -360,1764 +2081,8 @@ pub fn resize(self: *Terminal, alloc: Allocator, cols: usize, rows: usize) !void /// then this will clear the screen from the cursor down if the cursor is /// on a prompt in order to allow the shell to redraw the prompt. fn clearPromptForResize(self: *Terminal) void { - assert(self.active_screen == .primary); - - if (!self.flags.shell_redraws_prompt) return; - - // We need to find the first y that is a prompt. If we find any line - // that is NOT a prompt (or input -- which is part of a prompt) then - // we are not at a prompt and we can exit this function. - const prompt_y: usize = prompt_y: { - // Keep track of the found value, because we want to find the START - var found: ?usize = null; - - // Search from the cursor up - var y: usize = 0; - while (y <= self.screen.cursor.y) : (y += 1) { - const real_y = self.screen.cursor.y - y; - const row = self.screen.getRow(.{ .active = real_y }); - switch (row.getSemanticPrompt()) { - // We are at a prompt but we're not at the start of the prompt. - // We mark our found value and continue because the prompt - // may be multi-line. - .input => found = real_y, - - // If we find the prompt then we're done. We are also done - // if we find any prompt continuation, because the shells - // that send this currently (zsh) cannot redraw every line. - .prompt, .prompt_continuation => { - found = real_y; - break; - }, - - // If we have command output, then we're most certainly not - // at a prompt. Break out of the loop. - .command => break, - - // If we don't know, we keep searching. - .unknown => {}, - } - } - - if (found) |found_y| break :prompt_y found_y; - return; - }; - assert(prompt_y < self.rows); - - // We want to clear all the lines from prompt_y downwards because - // the shell will redraw the prompt. - for (prompt_y..self.rows) |y| { - const row = self.screen.getRow(.{ .active = y }); - row.setWrapped(false); - row.setDirty(true); - row.clear(.{}); - } -} - -/// Return the current string value of the terminal. Newlines are -/// 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 { - return try self.screen.testString(alloc, .viewport); -} - -/// Save cursor position and further state. -/// -/// The primary and alternate screen have distinct save state. One saved state -/// is kept per screen (main / alternative). If for the current screen state -/// was already saved it is overwritten. -pub fn saveCursor(self: *Terminal) void { - self.screen.saved_cursor = .{ - .x = self.screen.cursor.x, - .y = self.screen.cursor.y, - .pen = self.screen.cursor.pen, - .pending_wrap = self.screen.cursor.pending_wrap, - .origin = self.modes.get(.origin), - .charset = self.screen.charset, - }; -} - -/// Restore cursor position and other state. -/// -/// The primary and alternate screen have distinct save state. -/// If no save was done before values are reset to their initial values. -pub fn restoreCursor(self: *Terminal) void { - const saved: Screen.Cursor.Saved = self.screen.saved_cursor orelse .{ - .x = 0, - .y = 0, - .pen = .{}, - .pending_wrap = false, - .origin = false, - .charset = .{}, - }; - - self.screen.cursor.pen = saved.pen; - self.screen.charset = saved.charset; - self.modes.set(.origin, saved.origin); - self.screen.cursor.x = @min(saved.x, self.cols - 1); - self.screen.cursor.y = @min(saved.y, self.rows - 1); - self.screen.cursor.pending_wrap = saved.pending_wrap; -} - -/// TODO: test -pub fn setAttribute(self: *Terminal, attr: sgr.Attribute) !void { - switch (attr) { - .unset => { - self.screen.cursor.pen.fg = .none; - self.screen.cursor.pen.bg = .none; - self.screen.cursor.pen.attrs = .{}; - }, - - .bold => { - self.screen.cursor.pen.attrs.bold = true; - }, - - .reset_bold => { - // Bold and faint share the same SGR code for this - self.screen.cursor.pen.attrs.bold = false; - self.screen.cursor.pen.attrs.faint = false; - }, - - .italic => { - self.screen.cursor.pen.attrs.italic = true; - }, - - .reset_italic => { - self.screen.cursor.pen.attrs.italic = false; - }, - - .faint => { - self.screen.cursor.pen.attrs.faint = true; - }, - - .underline => |v| { - self.screen.cursor.pen.attrs.underline = v; - }, - - .reset_underline => { - self.screen.cursor.pen.attrs.underline = .none; - }, - - .underline_color => |rgb| { - self.screen.cursor.pen.attrs.underline_color = true; - self.screen.cursor.pen.underline_fg = .{ - .r = rgb.r, - .g = rgb.g, - .b = rgb.b, - }; - }, - - .@"256_underline_color" => |idx| { - self.screen.cursor.pen.attrs.underline_color = true; - self.screen.cursor.pen.underline_fg = self.color_palette.colors[idx]; - }, - - .reset_underline_color => { - self.screen.cursor.pen.attrs.underline_color = false; - }, - - .blink => { - log.warn("blink requested, but not implemented", .{}); - self.screen.cursor.pen.attrs.blink = true; - }, - - .reset_blink => { - self.screen.cursor.pen.attrs.blink = false; - }, - - .inverse => { - self.screen.cursor.pen.attrs.inverse = true; - }, - - .reset_inverse => { - self.screen.cursor.pen.attrs.inverse = false; - }, - - .invisible => { - self.screen.cursor.pen.attrs.invisible = true; - }, - - .reset_invisible => { - self.screen.cursor.pen.attrs.invisible = false; - }, - - .strikethrough => { - self.screen.cursor.pen.attrs.strikethrough = true; - }, - - .reset_strikethrough => { - self.screen.cursor.pen.attrs.strikethrough = false; - }, - - .direct_color_fg => |rgb| { - self.screen.cursor.pen.fg = .{ - .rgb = .{ - .r = rgb.r, - .g = rgb.g, - .b = rgb.b, - }, - }; - }, - - .direct_color_bg => |rgb| { - self.screen.cursor.pen.bg = .{ - .rgb = .{ - .r = rgb.r, - .g = rgb.g, - .b = rgb.b, - }, - }; - }, - - .@"8_fg" => |n| { - self.screen.cursor.pen.fg = .{ .indexed = @intFromEnum(n) }; - }, - - .@"8_bg" => |n| { - self.screen.cursor.pen.bg = .{ .indexed = @intFromEnum(n) }; - }, - - .reset_fg => self.screen.cursor.pen.fg = .none, - - .reset_bg => self.screen.cursor.pen.bg = .none, - - .@"8_bright_fg" => |n| { - self.screen.cursor.pen.fg = .{ .indexed = @intFromEnum(n) }; - }, - - .@"8_bright_bg" => |n| { - self.screen.cursor.pen.bg = .{ .indexed = @intFromEnum(n) }; - }, - - .@"256_fg" => |idx| { - self.screen.cursor.pen.fg = .{ .indexed = idx }; - }, - - .@"256_bg" => |idx| { - self.screen.cursor.pen.bg = .{ .indexed = idx }; - }, - - .unknown => return error.InvalidAttribute, - } -} - -/// Print the active attributes as a string. This is used to respond to DECRQSS -/// requests. -/// -/// Boolean attributes are printed first, followed by foreground color, then -/// background color. Each attribute is separated by a semicolon. -pub fn printAttributes(self: *Terminal, buf: []u8) ![]const u8 { - var stream = std.io.fixedBufferStream(buf); - const writer = stream.writer(); - - // The SGR response always starts with a 0. See https://vt100.net/docs/vt510-rm/DECRPSS - try writer.writeByte('0'); - - const pen = self.screen.cursor.pen; - var attrs = [_]u8{0} ** 8; - var i: usize = 0; - - if (pen.attrs.bold) { - attrs[i] = '1'; - i += 1; - } - - if (pen.attrs.faint) { - attrs[i] = '2'; - i += 1; - } - - if (pen.attrs.italic) { - attrs[i] = '3'; - i += 1; - } - - if (pen.attrs.underline != .none) { - attrs[i] = '4'; - i += 1; - } - - if (pen.attrs.blink) { - attrs[i] = '5'; - i += 1; - } - - if (pen.attrs.inverse) { - attrs[i] = '7'; - i += 1; - } - - if (pen.attrs.invisible) { - attrs[i] = '8'; - i += 1; - } - - if (pen.attrs.strikethrough) { - attrs[i] = '9'; - i += 1; - } - - for (attrs[0..i]) |c| { - try writer.print(";{c}", .{c}); - } - - switch (pen.fg) { - .none => {}, - .indexed => |idx| if (idx >= 16) - try writer.print(";38:5:{}", .{idx}) - else if (idx >= 8) - try writer.print(";9{}", .{idx - 8}) - else - try writer.print(";3{}", .{idx}), - .rgb => |rgb| try writer.print(";38:2::{[r]}:{[g]}:{[b]}", rgb), - } - - switch (pen.bg) { - .none => {}, - .indexed => |idx| if (idx >= 16) - try writer.print(";48:5:{}", .{idx}) - else if (idx >= 8) - try writer.print(";10{}", .{idx - 8}) - else - try writer.print(";4{}", .{idx}), - .rgb => |rgb| try writer.print(";48:2::{[r]}:{[g]}:{[b]}", rgb), - } - - return stream.getWritten(); -} - -/// Set the charset into the given slot. -pub fn configureCharset(self: *Terminal, slot: charsets.Slots, set: charsets.Charset) void { - self.screen.charset.charsets.set(slot, set); -} - -/// Invoke the charset in slot into the active slot. If single is true, -/// then this will only be invoked for a single character. -pub fn invokeCharset( - self: *Terminal, - active: charsets.ActiveSlot, - slot: charsets.Slots, - single: bool, -) void { - if (single) { - assert(active == .GL); - self.screen.charset.single_shift = slot; - return; - } - - switch (active) { - .GL => self.screen.charset.gl = slot, - .GR => self.screen.charset.gr = slot, - } -} - -/// Print UTF-8 encoded string to the terminal. -pub fn printString(self: *Terminal, str: []const u8) !void { - const view = try std.unicode.Utf8View.init(str); - var it = view.iterator(); - while (it.nextCodepoint()) |cp| { - switch (cp) { - '\n' => { - self.carriageReturn(); - try self.linefeed(); - }, - - else => try self.print(cp), - } - } -} - -pub fn print(self: *Terminal, c: u21) !void { - // log.debug("print={x} y={} x={}", .{ c, self.screen.cursor.y, self.screen.cursor.x }); - - // If we're not on the main display, do nothing for now - if (self.status_display != .main) return; - - // Our right margin depends where our cursor is now. - const right_limit = if (self.screen.cursor.x > self.scrolling_region.right) - self.cols - else - self.scrolling_region.right + 1; - - // Perform grapheme clustering if grapheme support is enabled (mode 2027). - // This is MUCH slower than the normal path so the conditional below is - // purposely ordered in least-likely to most-likely so we can drop out - // as quickly as possible. - if (c > 255 and self.modes.get(.grapheme_cluster) and self.screen.cursor.x > 0) grapheme: { - const row = self.screen.getRow(.{ .active = self.screen.cursor.y }); - - // We need the previous cell to determine if we're at a grapheme - // break or not. If we are NOT, then we are still combining the - // same grapheme. Otherwise, we can stay in this cell. - const Prev = struct { cell: *Screen.Cell, x: usize }; - const prev: Prev = prev: { - const x = x: { - // If we have wraparound, then we always use the prev col - if (self.modes.get(.wraparound)) break :x self.screen.cursor.x - 1; - - // If we do not have wraparound, the logic is trickier. If - // we're not on the last column, then we just use the previous - // column. Otherwise, we need to check if there is text to - // figure out if we're attaching to the prev or current. - if (self.screen.cursor.x != right_limit - 1) break :x self.screen.cursor.x - 1; - const current = row.getCellPtr(self.screen.cursor.x); - break :x self.screen.cursor.x - @intFromBool(current.char == 0); - }; - const immediate = row.getCellPtr(x); - - // If the previous cell is a wide spacer tail, then we actually - // want to use the cell before that because that has the actual - // content. - if (!immediate.attrs.wide_spacer_tail) break :prev .{ - .cell = immediate, - .x = x, - }; - - break :prev .{ - .cell = row.getCellPtr(x - 1), - .x = x - 1, - }; - }; - - // If our cell has no content, then this is a new cell and - // necessarily a grapheme break. - if (prev.cell.char == 0) break :grapheme; - - const grapheme_break = brk: { - var state: unicode.GraphemeBreakState = .{}; - var cp1: u21 = @intCast(prev.cell.char); - if (prev.cell.attrs.grapheme) { - var it = row.codepointIterator(prev.x); - while (it.next()) |cp2| { - // log.debug("cp1={x} cp2={x}", .{ cp1, cp2 }); - assert(!unicode.graphemeBreak(cp1, cp2, &state)); - cp1 = cp2; - } - } - - // log.debug("cp1={x} cp2={x} end", .{ cp1, c }); - break :brk unicode.graphemeBreak(cp1, c, &state); - }; - - // If we can NOT break, this means that "c" is part of a grapheme - // with the previous char. - if (!grapheme_break) { - // If this is an emoji variation selector then we need to modify - // the cell width accordingly. VS16 makes the character wide and - // VS15 makes it narrow. - if (c == 0xFE0F or c == 0xFE0E) { - // This only applies to emoji - const prev_props = unicode.getProperties(@intCast(prev.cell.char)); - const emoji = prev_props.grapheme_boundary_class == .extended_pictographic; - if (!emoji) return; - - switch (c) { - 0xFE0F => wide: { - if (prev.cell.attrs.wide) break :wide; - - // Move our cursor back to the previous. We'll move - // the cursor within this block to the proper location. - self.screen.cursor.x = prev.x; - - // If we don't have space for the wide char, we need - // to insert spacers and wrap. Then we just print the wide - // char as normal. - if (prev.x == right_limit - 1) { - if (!self.modes.get(.wraparound)) return; - const spacer_head = self.printCell(' '); - spacer_head.attrs.wide_spacer_head = true; - try self.printWrap(); - } - - const wide_cell = self.printCell(@intCast(prev.cell.char)); - wide_cell.attrs.wide = true; - - // Write our spacer - self.screen.cursor.x += 1; - const spacer = self.printCell(' '); - spacer.attrs.wide_spacer_tail = true; - - // Move the cursor again so we're beyond our spacer - self.screen.cursor.x += 1; - if (self.screen.cursor.x == right_limit) { - self.screen.cursor.x -= 1; - self.screen.cursor.pending_wrap = true; - } - }, - - 0xFE0E => narrow: { - // Prev cell is no longer wide - if (!prev.cell.attrs.wide) break :narrow; - prev.cell.attrs.wide = false; - - // Remove the wide spacer tail - const cell = row.getCellPtr(prev.x + 1); - cell.attrs.wide_spacer_tail = false; - - break :narrow; - }, - - else => unreachable, - } - } - - log.debug("c={x} grapheme attach to x={}", .{ c, prev.x }); - try row.attachGrapheme(prev.x, c); - return; - } - } - - // Determine the width of this character so we can handle - // non-single-width characters properly. We have a fast-path for - // byte-sized characters since they're so common. We can ignore - // control characters because they're always filtered prior. - const width: usize = if (c <= 0xFF) 1 else @intCast(unicode.table.get(c).width); - - // Note: it is possible to have a width of "3" and a width of "-1" - // from ziglyph. We should look into those cases and handle them - // appropriately. - assert(width <= 2); - // log.debug("c={x} width={}", .{ c, width }); - - // Attach zero-width characters to our cell as grapheme data. - if (width == 0) { - // If we have grapheme clustering enabled, we don't blindly attach - // any zero width character to our cells and we instead just ignore - // it. - if (self.modes.get(.grapheme_cluster)) return; - - // If we're at cell zero, then this is malformed data and we don't - // print anything or even store this. Zero-width characters are ALWAYS - // attached to some other non-zero-width character at the time of - // writing. - if (self.screen.cursor.x == 0) { - log.warn("zero-width character with no prior character, ignoring", .{}); - return; - } - - // Find our previous cell - const row = self.screen.getRow(.{ .active = self.screen.cursor.y }); - const prev: usize = prev: { - const x = self.screen.cursor.x - 1; - const immediate = row.getCellPtr(x); - if (!immediate.attrs.wide_spacer_tail) break :prev x; - break :prev x - 1; - }; - - // If this is a emoji variation selector, prev must be an emoji - if (c == 0xFE0F or c == 0xFE0E) { - const prev_cell = row.getCellPtr(prev); - const prev_props = unicode.getProperties(@intCast(prev_cell.char)); - const emoji = prev_props.grapheme_boundary_class == .extended_pictographic; - if (!emoji) return; - } - - try row.attachGrapheme(prev, c); - return; - } - - // We have a printable character, save it - self.previous_char = c; - - // If we're soft-wrapping, then handle that first. - if (self.screen.cursor.pending_wrap and self.modes.get(.wraparound)) - try self.printWrap(); - - // If we have insert mode enabled then we need to handle that. We - // only do insert mode if we're not at the end of the line. - if (self.modes.get(.insert) and - self.screen.cursor.x + width < self.cols) - { - self.insertBlanks(width); - } - - switch (width) { - // Single cell is very easy: just write in the cell - 1 => _ = @call(.always_inline, printCell, .{ self, c }), - - // Wide character requires a spacer. We print this by - // using two cells: the first is flagged "wide" and has the - // wide char. The second is guaranteed to be a spacer if - // we're not at the end of the line. - 2 => if ((right_limit - self.scrolling_region.left) > 1) { - // If we don't have space for the wide char, we need - // to insert spacers and wrap. Then we just print the wide - // char as normal. - if (self.screen.cursor.x == right_limit - 1) { - // If we don't have wraparound enabled then we don't print - // this character at all and don't move the cursor. This is - // how xterm behaves. - if (!self.modes.get(.wraparound)) return; - - const spacer_head = self.printCell(' '); - spacer_head.attrs.wide_spacer_head = true; - try self.printWrap(); - } - - const wide_cell = self.printCell(c); - wide_cell.attrs.wide = true; - - // Write our spacer - self.screen.cursor.x += 1; - const spacer = self.printCell(' '); - spacer.attrs.wide_spacer_tail = true; - } else { - // This is pretty broken, terminals should never be only 1-wide. - // We sould prevent this downstream. - _ = self.printCell(' '); - }, - - else => unreachable, - } - - // Move the cursor - self.screen.cursor.x += 1; - - // If we're at the column limit, then we need to wrap the next time. - // This is unlikely so we do the increment above and decrement here - // if we need to rather than check once. - if (self.screen.cursor.x == right_limit) { - self.screen.cursor.x -= 1; - self.screen.cursor.pending_wrap = true; - } -} - -fn printCell(self: *Terminal, unmapped_c: u21) *Screen.Cell { - const c: u21 = c: { - // TODO: non-utf8 handling, gr - - // If we're single shifting, then we use the key exactly once. - const key = if (self.screen.charset.single_shift) |key_once| blk: { - self.screen.charset.single_shift = null; - break :blk key_once; - } else self.screen.charset.gl; - const set = self.screen.charset.charsets.get(key); - - // UTF-8 or ASCII is used as-is - if (set == .utf8 or set == .ascii) break :c unmapped_c; - - // If we're outside of ASCII range this is an invalid value in - // this table so we just return space. - if (unmapped_c > std.math.maxInt(u8)) break :c ' '; - - // Get our lookup table and map it - const table = set.table(); - break :c @intCast(table[@intCast(unmapped_c)]); - }; - - 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 - // single-width characters into that. - if (cell.attrs.wide) { - const x = self.screen.cursor.x + 1; - if (x < self.cols) { - const spacer_cell = row.getCellPtr(x); - spacer_cell.* = self.screen.cursor.pen; - } - - if (self.screen.cursor.y > 0 and self.screen.cursor.x <= 1) { - self.clearWideSpacerHead(); - } - } else if (cell.attrs.wide_spacer_tail) { - assert(self.screen.cursor.x > 0); - const x = self.screen.cursor.x - 1; - - const wide_cell = row.getCellPtr(x); - wide_cell.* = self.screen.cursor.pen; - - if (self.screen.cursor.y > 0 and self.screen.cursor.x <= 1) { - self.clearWideSpacerHead(); - } - } - - // If the prior value had graphemes, clear those - if (cell.attrs.grapheme) row.clearGraphemes(self.screen.cursor.x); - - // Write - cell.* = self.screen.cursor.pen; - cell.char = @intCast(c); - return cell; -} - -fn printWrap(self: *Terminal) !void { - const row = self.screen.getRow(.{ .active = self.screen.cursor.y }); - row.setWrapped(true); - - // Get the old semantic prompt so we can extend it to the next - // line. We need to do this before we index() because we may - // modify memory. - const old_prompt = row.getSemanticPrompt(); - - // Move to the next line - try self.index(); - self.screen.cursor.x = self.scrolling_region.left; - - // New line must inherit semantic prompt of the old line - const new_row = self.screen.getRow(.{ .active = self.screen.cursor.y }); - new_row.setSemanticPrompt(old_prompt); -} - -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.getCellPtr( - .active, - self.screen.cursor.y - 1, - self.cols - 1, - ); - cell.attrs.wide_spacer_head = false; -} - -/// Print the previous printed character a repeated amount of times. -pub fn printRepeat(self: *Terminal, count_req: usize) !void { - if (self.previous_char) |c| { - const count = @max(count_req, 1); - for (0..count) |_| try self.print(c); - } -} - -/// Resets all margins and fills the whole screen with the character 'E' -/// -/// Sets the cursor to the top left corner. -pub fn decaln(self: *Terminal) !void { - // Reset margins, also sets cursor to top-left - self.scrolling_region = .{ - .top = 0, - .bottom = self.rows - 1, - .left = 0, - .right = self.cols - 1, - }; - - // Origin mode is disabled - self.modes.set(.origin, false); - - // Move our cursor to the top-left - self.setCursorPos(1, 1); - - // Clear our stylistic attributes - self.screen.cursor.pen = .{ - .bg = self.screen.cursor.pen.bg, - .fg = self.screen.cursor.pen.fg, - .attrs = .{ - .protected = self.screen.cursor.pen.attrs.protected, - }, - }; - - // Our pen has the letter E - const pen: Screen.Cell = .{ .char = 'E' }; - - // Fill with Es, does not move cursor. - for (0..self.rows) |y| { - const filled = self.screen.getRow(.{ .active = y }); - filled.fill(pen); - } -} - -/// Move the cursor to the next line in the scrolling region, possibly scrolling. -/// -/// If the cursor is outside of the scrolling region: move the cursor one line -/// down if it is not on the bottom-most line of the screen. -/// -/// If the cursor is inside the scrolling region: -/// If the cursor is on the bottom-most line of the scrolling region: -/// invoke scroll up with amount=1 -/// If the cursor is not on the bottom-most line of the scrolling region: -/// move the cursor one line down -/// -/// This unsets the pending wrap state without wrapping. -pub fn index(self: *Terminal) !void { - // Unset pending wrap state - self.screen.cursor.pending_wrap = false; - - // Outside of the scroll region we move the cursor one line down. - if (self.screen.cursor.y < self.scrolling_region.top or - self.screen.cursor.y > self.scrolling_region.bottom) - { - self.screen.cursor.y = @min(self.screen.cursor.y + 1, self.rows - 1); - return; - } - - // If the cursor is inside the scrolling region and on the bottom-most - // line, then we scroll up. If our scrolling region is the full screen - // we create scrollback. - if (self.screen.cursor.y == self.scrolling_region.bottom and - self.screen.cursor.x >= self.scrolling_region.left and - self.screen.cursor.x <= self.scrolling_region.right) - { - // If our scrolling region is the full screen, we create scrollback. - // Otherwise, we simply scroll the region. - if (self.scrolling_region.top == 0 and - self.scrolling_region.bottom == self.rows - 1 and - self.scrolling_region.left == 0 and - self.scrolling_region.right == self.cols - 1) - { - try self.screen.scroll(.{ .screen = 1 }); - } else { - try self.scrollUp(1); - } - - return; - } - - // Increase cursor by 1, maximum to bottom of scroll region - self.screen.cursor.y = @min(self.screen.cursor.y + 1, self.scrolling_region.bottom); -} - -/// Move the cursor to the previous line in the scrolling region, possibly -/// scrolling. -/// -/// If the cursor is outside of the scrolling region, move the cursor one -/// line up if it is not on the top-most line of the screen. -/// -/// If the cursor is inside the scrolling region: -/// -/// * If the cursor is on the top-most line of the scrolling region: -/// invoke scroll down with amount=1 -/// * If the cursor is not on the top-most line of the scrolling region: -/// move the cursor one line up -pub fn reverseIndex(self: *Terminal) !void { - if (self.screen.cursor.y != self.scrolling_region.top or - self.screen.cursor.x < self.scrolling_region.left or - self.screen.cursor.x > self.scrolling_region.right) - { - self.cursorUp(1); - return; - } - - try self.scrollDown(1); -} - -// Set Cursor Position. Move cursor to the position indicated -// by row and column (1-indexed). If column is 0, it is adjusted to 1. -// If column is greater than the right-most column it is adjusted to -// the right-most column. If row is 0, it is adjusted to 1. If row is -// greater than the bottom-most row it is adjusted to the bottom-most -// row. -pub fn setCursorPos(self: *Terminal, row_req: usize, col_req: usize) void { - // If cursor origin mode is set the cursor row will be moved relative to - // the top margin row and adjusted to be above or at bottom-most row in - // the current scroll region. - // - // If origin mode is set and left and right margin mode is set the cursor - // will be moved relative to the left margin column and adjusted to be on - // or left of the right margin column. - const params: struct { - x_offset: usize = 0, - y_offset: usize = 0, - x_max: usize, - y_max: usize, - } = if (self.modes.get(.origin)) .{ - .x_offset = self.scrolling_region.left, - .y_offset = self.scrolling_region.top, - .x_max = self.scrolling_region.right + 1, // We need this 1-indexed - .y_max = self.scrolling_region.bottom + 1, // We need this 1-indexed - } else .{ - .x_max = self.cols, - .y_max = self.rows, - }; - - const row = if (row_req == 0) 1 else row_req; - const col = if (col_req == 0) 1 else col_req; - self.screen.cursor.x = @min(params.x_max, col + params.x_offset) -| 1; - self.screen.cursor.y = @min(params.y_max, row + params.y_offset) -| 1; - // log.info("set cursor position: col={} row={}", .{ self.screen.cursor.x, self.screen.cursor.y }); - - // Unset pending wrap state - self.screen.cursor.pending_wrap = false; -} - -/// Erase the display. -pub fn eraseDisplay( - self: *Terminal, - alloc: Allocator, - mode: csi.EraseDisplay, - protected_req: bool, -) void { - // Erasing clears all attributes / colors _except_ the background - const pen: Screen.Cell = switch (self.screen.cursor.pen.bg) { - .none => .{}, - else => |bg| .{ .bg = bg }, - }; - - // We respect protected attributes if explicitly requested (probably - // a DECSEL sequence) or if our last protected mode was ISO even if its - // not currently set. - const protected = self.screen.protected_mode == .iso or protected_req; - - switch (mode) { - .scroll_complete => { - self.screen.scroll(.{ .clear = {} }) catch |err| { - log.warn("scroll clear failed, doing a normal clear err={}", .{err}); - self.eraseDisplay(alloc, .complete, protected_req); - return; - }; - - // Unsets pending wrap state - self.screen.cursor.pending_wrap = false; - - // Clear all Kitty graphics state for this screen - self.screen.kitty_images.delete(alloc, self, .{ .all = true }); - }, - - .complete => { - // If we're on the primary screen and our last non-empty row is - // a prompt, then we do a scroll_complete instead. This is a - // heuristic to get the generally desirable behavior that ^L - // at a prompt scrolls the screen contents prior to clearing. - // Most shells send `ESC [ H ESC [ 2 J` so we can't just check - // our current cursor position. See #905 - if (self.active_screen == .primary) at_prompt: { - // Go from the bottom of the viewport up and see if we're - // at a prompt. - const viewport_max = Screen.RowIndexTag.viewport.maxLen(&self.screen); - for (0..viewport_max) |y| { - const bottom_y = viewport_max - y - 1; - const row = self.screen.getRow(.{ .viewport = bottom_y }); - if (row.isEmpty()) continue; - switch (row.getSemanticPrompt()) { - // If we're at a prompt or input area, then we are at a prompt. - .prompt, - .prompt_continuation, - .input, - => break, - - // If we have command output, then we're most certainly not - // at a prompt. - .command => break :at_prompt, - - // If we don't know, we keep searching. - .unknown => {}, - } - } else break :at_prompt; - - self.screen.scroll(.{ .clear = {} }) catch { - // If we fail, we just fall back to doing a normal clear - // so we don't worry about the error. - }; - } - - var it = self.screen.rowIterator(.active); - while (it.next()) |row| { - row.setWrapped(false); - row.setDirty(true); - - if (!protected) { - row.clear(pen); - continue; - } - - // Protected mode erase - for (0..row.lenCells()) |x| { - const cell = row.getCellPtr(x); - if (cell.attrs.protected) continue; - cell.* = pen; - } - } - - // Unsets pending wrap state - self.screen.cursor.pending_wrap = false; - - // Clear all Kitty graphics state for this screen - self.screen.kitty_images.delete(alloc, self, .{ .all = true }); - }, - - .below => { - // All lines to the right (including the cursor) - { - self.eraseLine(.right, protected_req); - const row = self.screen.getRow(.{ .active = self.screen.cursor.y }); - row.setWrapped(false); - row.setDirty(true); - } - - // All lines below - 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); - if (protected and cell.attrs.protected) continue; - cell.* = pen; - cell.char = 0; - } - } - - // Unsets pending wrap state - self.screen.cursor.pending_wrap = false; - }, - - .above => { - // Erase to the left (including the cursor) - self.eraseLine(.left, protected_req); - - // All lines above - var y: usize = 0; - while (y < self.screen.cursor.y) : (y += 1) { - var x: usize = 0; - while (x < self.cols) : (x += 1) { - const cell = self.screen.getCellPtr(.active, y, x); - if (protected and cell.attrs.protected) continue; - cell.* = pen; - cell.char = 0; - } - } - - // Unsets pending wrap state - self.screen.cursor.pending_wrap = false; - }, - - .scrollback => self.screen.clear(.history) catch |err| { - // This isn't a huge issue, so just log it. - log.err("failed to clear scrollback: {}", .{err}); - }, - } -} - -/// Erase the line. -pub fn eraseLine( - self: *Terminal, - mode: csi.EraseLine, - protected_req: bool, -) void { - // We always fill with the background - const pen: Screen.Cell = switch (self.screen.cursor.pen.bg) { - .none => .{}, - else => |bg| .{ .bg = bg }, - }; - - // Get our start/end positions depending on mode. - const row = self.screen.getRow(.{ .active = self.screen.cursor.y }); - const start, const end = switch (mode) { - .right => right: { - var x = self.screen.cursor.x; - - // If our X is a wide spacer tail then we need to erase the - // previous cell too so we don't split a multi-cell character. - if (x > 0) { - const cell = row.getCellPtr(x); - if (cell.attrs.wide_spacer_tail) x -= 1; - } - - // This resets the soft-wrap of this line - row.setWrapped(false); - - break :right .{ x, row.lenCells() }; - }, - - .left => left: { - var x = self.screen.cursor.x; - - // If our x is a wide char we need to delete the tail too. - const cell = row.getCellPtr(x); - if (cell.attrs.wide) { - if (row.getCellPtr(x + 1).attrs.wide_spacer_tail) { - x += 1; - } - } - - break :left .{ 0, x + 1 }; - }, - - // Note that it seems like complete should reset the soft-wrap - // state of the line but in xterm it does not. - .complete => .{ 0, row.lenCells() }, - - else => { - log.err("unimplemented erase line mode: {}", .{mode}); - return; - }, - }; - - // All modes will clear the pending wrap state and we know we have - // a valid mode at this point. - self.screen.cursor.pending_wrap = false; - - // We respect protected attributes if explicitly requested (probably - // a DECSEL sequence) or if our last protected mode was ISO even if its - // not currently set. - const protected = self.screen.protected_mode == .iso or protected_req; - - // If we're not respecting protected attributes, we can use a fast-path - // to fill the entire line. - if (!protected) { - row.fillSlice(self.screen.cursor.pen, start, end); - return; - } - - for (start..end) |x| { - const cell = row.getCellPtr(x); - if (cell.attrs.protected) continue; - cell.* = pen; - } -} - -/// Removes amount characters from the current cursor position to the right. -/// The remaining characters are shifted to the left and space from the right -/// margin is filled with spaces. -/// -/// If amount is greater than the remaining number of characters in the -/// scrolling region, it is adjusted down. -/// -/// Does not change the cursor position. -pub fn deleteChars(self: *Terminal, count: usize) !void { - if (count == 0) return; - - // If our cursor is outside the margins then do nothing. We DO reset - // wrap state still so this must remain below the above logic. - if (self.screen.cursor.x < self.scrolling_region.left or - self.screen.cursor.x > self.scrolling_region.right) return; - - // This resets the pending wrap state - self.screen.cursor.pending_wrap = false; - - const pen: Screen.Cell = .{ - .bg = self.screen.cursor.pen.bg, - }; - - // If our X is a wide spacer tail then we need to erase the - // previous cell too so we don't split a multi-cell character. - const line = self.screen.getRow(.{ .active = self.screen.cursor.y }); - if (self.screen.cursor.x > 0) { - const cell = line.getCellPtr(self.screen.cursor.x); - if (cell.attrs.wide_spacer_tail) { - line.getCellPtr(self.screen.cursor.x - 1).* = pen; - } - } - - // We go from our cursor right to the end and either copy the cell - // "count" away or clear it. - for (self.screen.cursor.x..self.scrolling_region.right + 1) |x| { - const copy_x = x + count; - if (copy_x >= self.scrolling_region.right + 1) { - line.getCellPtr(x).* = pen; - continue; - } - - const copy_cell = line.getCellPtr(copy_x); - if (x == 0 and copy_cell.attrs.wide_spacer_tail) { - line.getCellPtr(x).* = pen; - continue; - } - line.getCellPtr(x).* = copy_cell.*; - copy_cell.char = 0; - } -} - -pub fn eraseChars(self: *Terminal, count_req: usize) void { - const count = @max(count_req, 1); - - // This resets the pending wrap state - self.screen.cursor.pending_wrap = false; - - // Our last index is at most the end of the number of chars we have - // in the current line. - 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; - }; - - // This resets the soft-wrap of this line - row.setWrapped(false); - - const pen: Screen.Cell = .{ - .bg = self.screen.cursor.pen.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. -pub fn cursorLeft(self: *Terminal, count_req: usize) void { - // Wrapping behavior depends on various terminal modes - const WrapMode = enum { none, reverse, reverse_extended }; - const wrap_mode: WrapMode = wrap_mode: { - if (!self.modes.get(.wraparound)) break :wrap_mode .none; - if (self.modes.get(.reverse_wrap_extended)) break :wrap_mode .reverse_extended; - if (self.modes.get(.reverse_wrap)) break :wrap_mode .reverse; - break :wrap_mode .none; - }; - - var count: usize = @max(count_req, 1); - - // If we are in no wrap mode, then we move the cursor left and exit - // since this is the fastest and most typical path. - if (wrap_mode == .none) { - self.screen.cursor.x -|= count; - self.screen.cursor.pending_wrap = false; - return; - } - - // If we have a pending wrap state and we are in either reverse wrap - // modes then we decrement the amount we move by one to match xterm. - if (self.screen.cursor.pending_wrap) { - count -= 1; - self.screen.cursor.pending_wrap = false; - } - - // The margins we can move to. - const top = self.scrolling_region.top; - const bottom = self.scrolling_region.bottom; - const right_margin = self.scrolling_region.right; - const left_margin = if (self.screen.cursor.x < self.scrolling_region.left) - 0 - else - self.scrolling_region.left; - - // Handle some edge cases when our cursor is already on the left margin. - if (self.screen.cursor.x == left_margin) { - switch (wrap_mode) { - // In reverse mode, if we're already before the top margin - // then we just set our cursor to the top-left and we're done. - .reverse => if (self.screen.cursor.y <= top) { - self.screen.cursor.x = left_margin; - self.screen.cursor.y = top; - return; - }, - - // Handled in while loop - .reverse_extended => {}, - - // Handled above - .none => unreachable, - } - } - - while (true) { - // We can move at most to the left margin. - const max = self.screen.cursor.x - left_margin; - - // We want to move at most the number of columns we have left - // or our remaining count. Do the move. - const amount = @min(max, count); - count -= amount; - self.screen.cursor.x -= amount; - - // If we have no more to move, then we're done. - if (count == 0) break; - - // If we are at the top, then we are done. - if (self.screen.cursor.y == top) { - if (wrap_mode != .reverse_extended) break; - - self.screen.cursor.y = bottom; - self.screen.cursor.x = right_margin; - count -= 1; - continue; - } - - // UNDEFINED TERMINAL BEHAVIOR. This situation is not handled in xterm - // and currently results in a crash in xterm. Given no other known - // terminal [to me] implements XTREVWRAP2, I decided to just mimick - // the behavior of xterm up and not including the crash by wrapping - // up to the (0, 0) and stopping there. My reasoning is that for an - // appropriately sized value of "count" this is the behavior that xterm - // would have. This is unit tested. - if (self.screen.cursor.y == 0) { - assert(self.screen.cursor.x == left_margin); - break; - } - - // If our previous line is not wrapped then we are done. - if (wrap_mode != .reverse_extended) { - const row = self.screen.getRow(.{ .active = self.screen.cursor.y - 1 }); - if (!row.isWrapped()) break; - } - - self.screen.cursor.y -= 1; - self.screen.cursor.x = right_margin; - count -= 1; - } -} - -/// Move the cursor right amount columns. If amount is greater than the -/// maximum move distance then it is internally adjusted to the maximum. -/// This sequence will not scroll the screen or scroll region. If amount is -/// 0, adjust it to 1. -pub fn cursorRight(self: *Terminal, count_req: usize) void { - // Always resets pending wrap - self.screen.cursor.pending_wrap = false; - - // The max the cursor can move to depends where the cursor currently is - const max = if (self.screen.cursor.x <= self.scrolling_region.right) - self.scrolling_region.right - else - self.cols - 1; - - const count = @max(count_req, 1); - self.screen.cursor.x = @min(max, self.screen.cursor.x +| count); -} - -/// Move the cursor down amount lines. If amount is greater than the maximum -/// move distance then it is internally adjusted to the maximum. This sequence -/// will not scroll the screen or scroll region. If amount is 0, adjust it to 1. -pub fn cursorDown(self: *Terminal, count_req: usize) void { - // Always resets pending wrap - self.screen.cursor.pending_wrap = false; - - // The max the cursor can move to depends where the cursor currently is - const max = if (self.screen.cursor.y <= self.scrolling_region.bottom) - self.scrolling_region.bottom - else - self.rows - 1; - - const count = @max(count_req, 1); - self.screen.cursor.y = @min(max, self.screen.cursor.y +| count); -} - -/// Move the cursor up amount lines. If amount is greater than the maximum -/// move distance then it is internally adjusted to the maximum. If amount is -/// 0, adjust it to 1. -pub fn cursorUp(self: *Terminal, count_req: usize) void { - // Always resets pending wrap - self.screen.cursor.pending_wrap = false; - - // The min the cursor can move to depends where the cursor currently is - const min = if (self.screen.cursor.y >= self.scrolling_region.top) - self.scrolling_region.top - else - 0; - - const count = @max(count_req, 1); - self.screen.cursor.y = @max(min, self.screen.cursor.y -| count); -} - -/// Backspace moves the cursor back a column (but not less than 0). -pub fn backspace(self: *Terminal) void { - self.cursorLeft(1); -} - -/// Horizontal tab moves the cursor to the next tabstop, clearing -/// the screen to the left the tabstop. -pub fn horizontalTab(self: *Terminal) !void { - while (self.screen.cursor.x < self.scrolling_region.right) { - // Move the cursor right - self.screen.cursor.x += 1; - - // If the last cursor position was a tabstop we return. We do - // "last cursor position" because we want a space to be written - // at the tabstop unless we're at the end (the while condition). - if (self.tabstops.get(self.screen.cursor.x)) return; - } -} - -// Same as horizontalTab but moves to the previous tabstop instead of the next. -pub fn horizontalTabBack(self: *Terminal) !void { - // With origin mode enabled, our leftmost limit is the left margin. - const left_limit = if (self.modes.get(.origin)) self.scrolling_region.left else 0; - - while (true) { - // If we're already at the edge of the screen, then we're done. - if (self.screen.cursor.x <= left_limit) return; - - // Move the cursor left - self.screen.cursor.x -= 1; - if (self.tabstops.get(self.screen.cursor.x)) return; - } -} - -/// Clear tab stops. -pub fn tabClear(self: *Terminal, cmd: csi.TabClear) void { - switch (cmd) { - .current => self.tabstops.unset(self.screen.cursor.x), - .all => self.tabstops.reset(0), - else => log.warn("invalid or unknown tab clear setting: {}", .{cmd}), - } -} - -/// Set a tab stop on the current cursor. -/// TODO: test -pub fn tabSet(self: *Terminal) void { - self.tabstops.set(self.screen.cursor.x); -} - -/// TODO: test -pub fn tabReset(self: *Terminal) void { - self.tabstops.reset(TABSTOP_INTERVAL); -} - -/// Carriage return moves the cursor to the first column. -pub fn carriageReturn(self: *Terminal) void { - // Always reset pending wrap state - self.screen.cursor.pending_wrap = false; - - // In origin mode we always move to the left margin - self.screen.cursor.x = if (self.modes.get(.origin)) - self.scrolling_region.left - else if (self.screen.cursor.x >= self.scrolling_region.left) - self.scrolling_region.left - else - 0; -} - -/// Linefeed moves the cursor to the next line. -pub fn linefeed(self: *Terminal) !void { - try self.index(); - if (self.modes.get(.linefeed)) self.carriageReturn(); -} - -/// Inserts spaces at current cursor position moving existing cell contents -/// to the right. The contents of the count right-most columns in the scroll -/// region are lost. The cursor position is not changed. -/// -/// This unsets the pending wrap state without wrapping. -/// -/// The inserted cells are colored according to the current SGR state. -pub fn insertBlanks(self: *Terminal, count: usize) void { - // Unset pending wrap state without wrapping. Note: this purposely - // happens BEFORE the scroll region check below, because that's what - // xterm does. - self.screen.cursor.pending_wrap = false; - - // If our cursor is outside the margins then do nothing. We DO reset - // wrap state still so this must remain below the above logic. - if (self.screen.cursor.x < self.scrolling_region.left or - self.screen.cursor.x > self.scrolling_region.right) return; - - // The limit we can shift to is our right margin. We add 1 since the - // math around this is 1-indexed. - const right_limit = self.scrolling_region.right + 1; - - // If our count is larger than the remaining amount, we just erase right. - // We only do this if we can erase the entire line (no right margin). - if (right_limit == self.cols and - count > right_limit - self.screen.cursor.x) - { - self.eraseLine(.right, false); - return; - } - - // Get the current row - const row = self.screen.getRow(.{ .active = self.screen.cursor.y }); - - // Determine our indexes. - const start = self.screen.cursor.x; - const pivot = @min(self.screen.cursor.x + count, right_limit); - - // 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 = right_limit - pivot; - if (copyable > 0) { - // This is the index of the final copyable value that we need to copy. - const copyable_end = start + copyable - 1; - - // If our last cell we're shifting is wide, then we need to clear - // it to be empty so we don't split the multi-cell char. - const cell = row.getCellPtr(copyable_end); - if (cell.attrs.wide) cell.char = 0; - - // Shift count cells. We have to do this backwards since we're not - // allocated new space, otherwise we'll copy duplicates. - var i: usize = 0; - while (i < copyable) : (i += 1) { - const to = right_limit - 1 - i; - const from = copyable_end - i; - const src = row.getCell(from); - const dst = row.getCellPtr(to); - dst.* = src; - } - } - - // Insert blanks. The blanks preserve the background color. - row.fillSlice(.{ - .bg = self.screen.cursor.pen.bg, - }, start, pivot); -} - -/// Insert amount lines at the current cursor row. The contents of the line -/// at the current cursor row and below (to the bottom-most line in the -/// scrolling region) are shifted down by amount lines. The contents of the -/// amount bottom-most lines in the scroll region are lost. -/// -/// This unsets the pending wrap state without wrapping. If the current cursor -/// position is outside of the current scroll region it does nothing. -/// -/// If amount is greater than the remaining number of lines in the scrolling -/// region it is adjusted down (still allowing for scrolling out every remaining -/// line in the scrolling region) -/// -/// In left and right margin mode the margins are respected; lines are only -/// scrolled in the scroll region. -/// -/// All cleared space is colored according to the current SGR state. -/// -/// Moves the cursor to the left margin. -pub fn insertLines(self: *Terminal, count: usize) !void { - // Rare, but happens - if (count == 0) return; - - // If the cursor is outside the scroll region we do nothing. - if (self.screen.cursor.y < self.scrolling_region.top or - self.screen.cursor.y > self.scrolling_region.bottom or - self.screen.cursor.x < self.scrolling_region.left or - self.screen.cursor.x > self.scrolling_region.right) return; - - // Move the cursor to the left margin - self.screen.cursor.x = self.scrolling_region.left; - self.screen.cursor.pending_wrap = false; - - // Remaining rows from our cursor - const rem = self.scrolling_region.bottom - self.screen.cursor.y + 1; - - // If count is greater than the amount of rows, adjust down. - const adjusted_count = @min(count, rem); - - // The the top `scroll_amount` lines need to move to the bottom - // scroll area. We may have nothing to scroll if we're clearing. - const scroll_amount = rem - adjusted_count; - var y: usize = self.scrolling_region.bottom; - const top = y - scroll_amount; - - // Ensure we have the lines populated to the end - while (y > top) : (y -= 1) { - const src = self.screen.getRow(.{ .active = y - adjusted_count }); - const dst = self.screen.getRow(.{ .active = y }); - for (self.scrolling_region.left..self.scrolling_region.right + 1) |x| { - try dst.copyCell(src, x); - } - } - - // Insert count blank lines - y = self.screen.cursor.y; - while (y < self.screen.cursor.y + adjusted_count) : (y += 1) { - const row = self.screen.getRow(.{ .active = y }); - row.fillSlice(.{ - .bg = self.screen.cursor.pen.bg, - }, self.scrolling_region.left, self.scrolling_region.right + 1); - } -} - -/// Removes amount lines from the current cursor row down. The remaining lines -/// to the bottom margin are shifted up and space from the bottom margin up is -/// filled with empty lines. -/// -/// If the current cursor position is outside of the current scroll region it -/// does nothing. If amount is greater than the remaining number of lines in the -/// scrolling region it is adjusted down. -/// -/// In left and right margin mode the margins are respected; lines are only -/// scrolled in the scroll region. -/// -/// If the cell movement splits a multi cell character that character cleared, -/// by replacing it by spaces, keeping its current attributes. All other -/// cleared space is colored according to the current SGR state. -/// -/// Moves the cursor to the left margin. -pub fn deleteLines(self: *Terminal, count: usize) !void { - // If the cursor is outside the scroll region we do nothing. - if (self.screen.cursor.y < self.scrolling_region.top or - self.screen.cursor.y > self.scrolling_region.bottom or - self.screen.cursor.x < self.scrolling_region.left or - self.screen.cursor.x > self.scrolling_region.right) return; - - // Move the cursor to the left margin - self.screen.cursor.x = self.scrolling_region.left; - self.screen.cursor.pending_wrap = false; - - // If this is a full line margin then we can do a faster scroll. - if (self.scrolling_region.left == 0 and - self.scrolling_region.right == self.cols - 1) - { - self.screen.scrollRegionUp( - .{ .active = self.screen.cursor.y }, - .{ .active = self.scrolling_region.bottom }, - @min(count, (self.scrolling_region.bottom - self.screen.cursor.y) + 1), - ); - return; - } - - // Left/right margin is set, we need to do a slower scroll. - // Remaining rows from our cursor in the region, 1-indexed. - const rem = self.scrolling_region.bottom - self.screen.cursor.y + 1; - - // If our count is greater than the remaining amount, we can just - // clear the region using insertLines. - if (count >= rem) { - try self.insertLines(count); - return; - } - - // The amount of lines we need to scroll up. - const scroll_amount = rem - count; - const scroll_end_y = self.screen.cursor.y + scroll_amount; - for (self.screen.cursor.y..scroll_end_y) |y| { - const src = self.screen.getRow(.{ .active = y + count }); - const dst = self.screen.getRow(.{ .active = y }); - for (self.scrolling_region.left..self.scrolling_region.right + 1) |x| { - try dst.copyCell(src, x); - } - } - - // Insert blank lines - for (scroll_end_y..self.scrolling_region.bottom + 1) |y| { - const row = self.screen.getRow(.{ .active = y }); - row.setWrapped(false); - row.fillSlice(.{ - .bg = self.screen.cursor.pen.bg, - }, self.scrolling_region.left, self.scrolling_region.right + 1); - } -} - -/// Scroll the text down by one row. -pub fn scrollDown(self: *Terminal, count: usize) !void { - // Preserve the cursor - const cursor = self.screen.cursor; - defer self.screen.cursor = cursor; - - // Move to the top of the scroll region - self.screen.cursor.y = self.scrolling_region.top; - self.screen.cursor.x = self.scrolling_region.left; - try self.insertLines(count); -} - -/// Removes amount lines from the top of the scroll region. The remaining lines -/// to the bottom margin are shifted up and space from the bottom margin up -/// is filled with empty lines. -/// -/// The new lines are created according to the current SGR state. -/// -/// Does not change the (absolute) cursor position. -pub fn scrollUp(self: *Terminal, count: usize) !void { - // Preserve the cursor - const cursor = self.screen.cursor; - defer self.screen.cursor = cursor; - - // Move to the top of the scroll region - self.screen.cursor.y = self.scrolling_region.top; - self.screen.cursor.x = self.scrolling_region.left; - try self.deleteLines(count); -} - -/// Options for scrolling the viewport of the terminal grid. -pub const ScrollViewport = union(enum) { - /// Scroll to the top of the scrollback - top: void, - - /// Scroll to the bottom, i.e. the top of the active area - bottom: void, - - /// Scroll by some delta amount, up is negative. - delta: isize, -}; - -/// Scroll the viewport of the terminal grid. -pub fn scrollViewport(self: *Terminal, behavior: ScrollViewport) !void { - try self.screen.scroll(switch (behavior) { - .top => .{ .top = {} }, - .bottom => .{ .bottom = {} }, - .delta => |delta| .{ .viewport = delta }, - }); -} - -/// Set Top and Bottom Margins If bottom is not specified, 0 or bigger than -/// the number of the bottom-most row, it is adjusted to the number of the -/// bottom most row. -/// -/// If top < bottom set the top and bottom row of the scroll region according -/// to top and bottom and move the cursor to the top-left cell of the display -/// (when in cursor origin mode is set to the top-left cell of the scroll region). -/// -/// Otherwise: Set the top and bottom row of the scroll region to the top-most -/// and bottom-most line of the screen. -/// -/// Top and bottom are 1-indexed. -pub fn setTopAndBottomMargin(self: *Terminal, top_req: usize, bottom_req: usize) void { - const top = @max(1, top_req); - const bottom = @min(self.rows, if (bottom_req == 0) self.rows else bottom_req); - if (top >= bottom) return; - - self.scrolling_region.top = top - 1; - self.scrolling_region.bottom = bottom - 1; - self.setCursorPos(1, 1); -} - -/// DECSLRM -pub fn setLeftAndRightMargin(self: *Terminal, left_req: usize, right_req: usize) void { - // We must have this mode enabled to do anything - if (!self.modes.get(.enable_left_and_right_margin)) return; - - const left = @max(1, left_req); - const right = @min(self.cols, if (right_req == 0) self.cols else right_req); - if (left >= right) return; - - self.scrolling_region.left = left - 1; - self.scrolling_region.right = right - 1; - self.setCursorPos(1, 1); -} - -/// Mark the current semantic prompt information. Current escape sequences -/// (OSC 133) only allow setting this for wherever the current active cursor -/// is located. -pub fn markSemanticPrompt(self: *Terminal, p: SemanticPrompt) void { - //log.debug("semantic_prompt y={} p={}", .{ self.screen.cursor.y, p }); - const row = self.screen.getRow(.{ .active = self.screen.cursor.y }); - row.setSemanticPrompt(switch (p) { - .prompt => .prompt, - .prompt_continuation => .prompt_continuation, - .input => .input, - .command => .command, - }); -} - -/// Returns true if the cursor is currently at a prompt. Another way to look -/// at this is it returns false if the shell is currently outputting something. -/// This requires shell integration (semantic prompt integration). -/// -/// If the shell integration doesn't exist, this will always return false. -pub fn cursorIsAtPrompt(self: *Terminal) bool { - // If we're on the secondary screen, we're never at a prompt. - if (self.active_screen == .alternate) return false; - - var y: usize = 0; - while (y <= self.screen.cursor.y) : (y += 1) { - // We want to go bottom up - const bottom_y = self.screen.cursor.y - y; - const row = self.screen.getRow(.{ .active = bottom_y }); - switch (row.getSemanticPrompt()) { - // If we're at a prompt or input area, then we are at a prompt. - .prompt, - .prompt_continuation, - .input, - => return true, - - // If we have command output, then we're most certainly not - // at a prompt. - .command => return false, - - // If we don't know, we keep searching. - .unknown => {}, - } - } - - return false; + // TODO + _ = self; } /// Set the pwd for the terminal. @@ -2133,52 +2098,113 @@ pub fn getPwd(self: *const Terminal) ?[]const u8 { return self.pwd.items; } -/// Execute a kitty graphics command. The buf is used to populate with -/// the response that should be sent as an APC sequence. The response will -/// be a full, valid APC sequence. +/// Options for switching to the alternate screen. +pub const AlternateScreenOptions = struct { + cursor_save: bool = false, + clear_on_enter: bool = false, + clear_on_exit: bool = false, +}; + +/// Switch to the alternate screen buffer. /// -/// If an error occurs, the caller should response to the pty that a -/// an error occurred otherwise the behavior of the graphics protocol is -/// undefined. -pub fn kittyGraphics( +/// The alternate screen buffer: +/// * has its own grid +/// * has its own cursor state (included saved cursor) +/// * does not support scrollback +/// +pub fn alternateScreen( self: *Terminal, - alloc: Allocator, - cmd: *kitty.graphics.Command, -) ?kitty.graphics.Response { - return kitty.graphics.execute(alloc, self, cmd); + options: AlternateScreenOptions, +) void { + //log.info("alt screen active={} options={} cursor={}", .{ self.active_screen, options, self.screen.cursor }); + + // TODO: test + // TODO(mitchellh): what happens if we enter alternate screen multiple times? + // for now, we ignore... + if (self.active_screen == .alternate) return; + + // If we requested cursor save, we save the cursor in the primary screen + if (options.cursor_save) self.saveCursor(); + + // Switch the screens + const old = self.screen; + self.screen = self.secondary_screen; + self.secondary_screen = old; + self.active_screen = .alternate; + + // Bring our charset state with us + self.screen.charset = old.charset; + + // Clear our selection + self.screen.selection = null; + + // Mark kitty images as dirty so they redraw + self.screen.kitty_images.dirty = true; + + // Bring our pen with us + self.screen.cursor = old.cursor; + self.screen.cursor.style_id = 0; + self.screen.cursor.style_ref = null; + self.screen.cursorAbsolute(old.cursor.x, old.cursor.y); + + if (options.clear_on_enter) { + self.eraseDisplay(.complete, false); + } + + // Update any style ref after we erase the display so we definitely have space + self.screen.manualStyleUpdate() catch |err| { + log.warn("style update failed entering alt screen err={}", .{err}); + }; } -/// Set the character protection mode for the terminal. -pub fn setProtectedMode(self: *Terminal, mode: ansi.ProtectedMode) void { - switch (mode) { - .off => { - self.screen.cursor.pen.attrs.protected = false; +/// Switch back to the primary screen (reset alternate screen mode). +pub fn primaryScreen( + self: *Terminal, + options: AlternateScreenOptions, +) void { + //log.info("primary screen active={} options={}", .{ self.active_screen, options }); - // screen.protected_mode is NEVER reset to ".off" because - // logic such as eraseChars depends on knowing what the - // _most recent_ mode was. - }, + // TODO: test + // TODO(mitchellh): what happens if we enter alternate screen multiple times? + if (self.active_screen == .primary) return; - .iso => { - self.screen.cursor.pen.attrs.protected = true; - self.screen.protected_mode = .iso; - }, + if (options.clear_on_exit) self.eraseDisplay(.complete, false); - .dec => { - self.screen.cursor.pen.attrs.protected = true; - self.screen.protected_mode = .dec; - }, - } + // Switch the screens + const old = self.screen; + self.screen = self.secondary_screen; + self.secondary_screen = old; + self.active_screen = .primary; + + // Clear our selection + self.screen.selection = null; + + // Mark kitty images as dirty so they redraw + self.screen.kitty_images.dirty = true; + + // Restore the cursor from the primary screen. This should not + // fail because we should not have to allocate memory since swapping + // screens does not create new cursors. + if (options.cursor_save) self.restoreCursor() catch |err| { + log.warn("restore cursor on primary screen failed err={}", .{err}); + }; +} + +/// Return the current string value of the terminal. Newlines are +/// 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 { + return try self.screen.dumpStringAlloc(alloc, .{ .viewport = .{} }); } /// Full reset -pub fn fullReset(self: *Terminal, alloc: Allocator) void { - self.primaryScreen(alloc, .{ .clear_on_exit = true, .cursor_save = true }); +pub fn fullReset(self: *Terminal) void { + self.primaryScreen(.{ .clear_on_exit = true, .cursor_save = true }); self.screen.charset = .{}; self.modes = .{}; self.flags = .{}; self.tabstops.reset(TABSTOP_INTERVAL); - self.screen.cursor = .{}; self.screen.saved_cursor = null; self.screen.selection = null; self.screen.kitty_keyboard = .{}; @@ -2190,68 +2216,62 @@ pub fn fullReset(self: *Terminal, alloc: Allocator) void { .right = self.cols - 1, }; self.previous_char = null; - self.eraseDisplay(alloc, .scrollback, false); - self.eraseDisplay(alloc, .complete, false); + self.eraseDisplay(.scrollback, false); + self.eraseDisplay(.complete, false); + self.screen.cursorAbsolute(0, 0); self.pwd.clearRetainingCapacity(); self.status_display = .main; } -// X -test "Terminal: fullReset with a non-empty pen" { - var t = try init(testing.allocator, 80, 80); - defer t.deinit(testing.allocator); - - t.screen.cursor.pen.bg = .{ .rgb = .{ .r = 0xFF, .g = 0x00, .b = 0x7F } }; - t.screen.cursor.pen.fg = .{ .rgb = .{ .r = 0xFF, .g = 0x00, .b = 0x7F } }; - t.fullReset(testing.allocator); - - const cell = t.screen.getCell(.active, t.screen.cursor.y, t.screen.cursor.x); - try testing.expect(cell.bg == .none); - try testing.expect(cell.fg == .none); -} - -// X -test "Terminal: fullReset origin mode" { - var t = try init(testing.allocator, 10, 10); - defer t.deinit(testing.allocator); - - t.setCursorPos(3, 5); - t.modes.set(.origin, true); - t.fullReset(testing.allocator); - - // Origin mode should be reset and the cursor should be moved - try testing.expectEqual(@as(usize, 0), t.screen.cursor.y); - try testing.expectEqual(@as(usize, 0), t.screen.cursor.x); - try testing.expect(!t.modes.get(.origin)); -} - -// X -test "Terminal: fullReset status display" { - var t = try init(testing.allocator, 10, 10); - defer t.deinit(testing.allocator); - - t.status_display = .status_line; - t.fullReset(testing.allocator); - try testing.expect(t.status_display == .main); -} - -// X test "Terminal: input with no control characters" { - var t = try init(testing.allocator, 80, 80); - defer t.deinit(testing.allocator); + const alloc = testing.allocator; + var t = try init(alloc, 40, 40); + defer t.deinit(alloc); // Basic grid writing for ("hello") |c| try t.print(c); try testing.expectEqual(@as(usize, 0), t.screen.cursor.y); try testing.expectEqual(@as(usize, 5), t.screen.cursor.x); { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); + const str = try t.plainString(alloc); + defer alloc.free(str); try testing.expectEqualStrings("hello", str); } } -// X +test "Terminal: input with basic wraparound" { + const alloc = testing.allocator; + var t = try init(alloc, 5, 40); + defer t.deinit(alloc); + + // Basic grid writing + for ("helloworldabc12") |c| try t.print(c); + try testing.expectEqual(@as(usize, 2), t.screen.cursor.y); + try testing.expectEqual(@as(usize, 4), t.screen.cursor.x); + try testing.expect(t.screen.cursor.pending_wrap); + { + const str = try t.plainString(alloc); + defer alloc.free(str); + try testing.expectEqualStrings("hello\nworld\nabc12", str); + } +} + +test "Terminal: input that forces scroll" { + const alloc = testing.allocator; + var t = try init(alloc, 1, 5); + defer t.deinit(alloc); + + // Basic grid writing + for ("abcdef") |c| try t.print(c); + try testing.expectEqual(@as(usize, 4), t.screen.cursor.y); + try testing.expectEqual(@as(usize, 0), t.screen.cursor.x); + { + const str = try t.plainString(alloc); + defer alloc.free(str); + try testing.expectEqualStrings("b\nc\nd\ne\nf", str); + } +} + test "Terminal: zero-width character at start" { var t = try init(testing.allocator, 80, 80); defer t.deinit(testing.allocator); @@ -2265,17 +2285,61 @@ test "Terminal: zero-width character at start" { } // https://github.com/mitchellh/ghostty/issues/1400 -// X test "Terminal: print single very long line" { var t = try init(testing.allocator, 5, 5); defer t.deinit(testing.allocator); // This would crash for issue 1400. So the assertion here is // that we simply do not crash. - for (0..500) |_| try t.print('x'); + for (0..1000) |_| try t.print('x'); +} + +test "Terminal: print wide char" { + var t = try init(testing.allocator, 80, 80); + defer t.deinit(testing.allocator); + + try t.print(0x1F600); // Smiley face + try testing.expectEqual(@as(usize, 0), t.screen.cursor.y); + try testing.expectEqual(@as(usize, 2), t.screen.cursor.x); + + { + const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; + const cell = list_cell.cell; + try testing.expectEqual(@as(u21, 0x1F600), cell.content.codepoint); + try testing.expectEqual(Cell.Wide.wide, cell.wide); + } + { + const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 1, .y = 0 } }).?; + const cell = list_cell.cell; + try testing.expectEqual(Cell.Wide.spacer_tail, cell.wide); + } +} + +test "Terminal: print wide char with 1-column width" { + const alloc = testing.allocator; + var t = try init(alloc, 1, 2); + defer t.deinit(alloc); + + try t.print('😀'); // 0x1F600 +} + +test "Terminal: print wide char in single-width terminal" { + var t = try init(testing.allocator, 1, 80); + defer t.deinit(testing.allocator); + + try t.print(0x1F600); // Smiley face + try testing.expectEqual(@as(usize, 0), t.screen.cursor.y); + try testing.expectEqual(@as(usize, 0), t.screen.cursor.x); + try testing.expect(t.screen.cursor.pending_wrap); + + { + const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; + const cell = list_cell.cell; + try testing.expectEqual(@as(u21, ' '), cell.content.codepoint); + try testing.expectEqual(Cell.Wide.narrow, cell.wide); + } } -// X test "Terminal: print over wide char at 0,0" { var t = try init(testing.allocator, 80, 80); defer t.deinit(testing.allocator); @@ -2287,20 +2351,20 @@ test "Terminal: print over wide char at 0,0" { try testing.expectEqual(@as(usize, 0), t.screen.cursor.y); try testing.expectEqual(@as(usize, 1), t.screen.cursor.x); - const row = t.screen.getRow(.{ .screen = 0 }); { - const cell = row.getCell(0); - try testing.expectEqual(@as(u32, 'A'), cell.char); - try testing.expect(!cell.attrs.wide); - try testing.expectEqual(@as(usize, 1), row.codepointLen(0)); + const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; + const cell = list_cell.cell; + try testing.expectEqual(@as(u21, 'A'), cell.content.codepoint); + try testing.expectEqual(Cell.Wide.narrow, cell.wide); } { - const cell = row.getCell(1); - try testing.expect(!cell.attrs.wide_spacer_tail); + const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 1, .y = 0 } }).?; + const cell = list_cell.cell; + try testing.expectEqual(@as(u21, 0), cell.content.codepoint); + try testing.expectEqual(Cell.Wide.narrow, cell.wide); } } -// X test "Terminal: print over wide spacer tail" { var t = try init(testing.allocator, 5, 5); defer t.deinit(testing.allocator); @@ -2309,140 +2373,26 @@ test "Terminal: print over wide spacer tail" { t.setCursorPos(1, 2); try t.print('X'); + { + const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; + const cell = list_cell.cell; + try testing.expectEqual(@as(u21, 0), cell.content.codepoint); + try testing.expectEqual(Cell.Wide.narrow, cell.wide); + } + { + const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 1, .y = 0 } }).?; + const cell = list_cell.cell; + try testing.expectEqual(@as(u21, 'X'), cell.content.codepoint); + try testing.expectEqual(Cell.Wide.narrow, cell.wide); + } + { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); try testing.expectEqualStrings(" X", str); } - - const row = t.screen.getRow(.{ .screen = 0 }); - { - const cell = row.getCell(0); - try testing.expectEqual(@as(u32, 0), cell.char); - try testing.expect(!cell.attrs.wide); - try testing.expectEqual(@as(usize, 1), row.codepointLen(0)); - } - { - const cell = row.getCell(1); - try testing.expectEqual(@as(u32, 'X'), cell.char); - try testing.expect(!cell.attrs.wide_spacer_tail); - try testing.expectEqual(@as(usize, 1), row.codepointLen(1)); - } } -// X -test "Terminal: VS15 to make narrow character" { - var t = try init(testing.allocator, 5, 5); - defer t.deinit(testing.allocator); - - // Enable grapheme clustering - t.modes.set(.grapheme_cluster, true); - - try t.print(0x26C8); // Thunder cloud and rain - try t.print(0xFE0E); // VS15 to make narrow - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("⛈︎", str); - } - - const row = t.screen.getRow(.{ .screen = 0 }); - { - const cell = row.getCell(0); - try testing.expectEqual(@as(u32, 0x26C8), cell.char); - try testing.expect(!cell.attrs.wide); - try testing.expectEqual(@as(usize, 2), row.codepointLen(0)); - } -} - -// X -test "Terminal: VS16 to make wide character with mode 2027" { - var t = try init(testing.allocator, 5, 5); - defer t.deinit(testing.allocator); - - // Enable grapheme clustering - t.modes.set(.grapheme_cluster, true); - - try t.print(0x2764); // Heart - try t.print(0xFE0F); // VS16 to make wide - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("❤️", str); - } - - const row = t.screen.getRow(.{ .screen = 0 }); - { - const cell = row.getCell(0); - try testing.expectEqual(@as(u32, 0x2764), cell.char); - try testing.expect(cell.attrs.wide); - try testing.expectEqual(@as(usize, 2), row.codepointLen(0)); - } -} - -// X -test "Terminal: VS16 repeated with mode 2027" { - var t = try init(testing.allocator, 5, 5); - defer t.deinit(testing.allocator); - - // Enable grapheme clustering - t.modes.set(.grapheme_cluster, true); - - try t.print(0x2764); // Heart - try t.print(0xFE0F); // VS16 to make wide - try t.print(0x2764); // Heart - try t.print(0xFE0F); // VS16 to make wide - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("❤️❤️", str); - } - - const row = t.screen.getRow(.{ .screen = 0 }); - { - const cell = row.getCell(0); - try testing.expectEqual(@as(u32, 0x2764), cell.char); - try testing.expect(cell.attrs.wide); - try testing.expectEqual(@as(usize, 2), row.codepointLen(0)); - } - { - const cell = row.getCell(2); - try testing.expectEqual(@as(u32, 0x2764), cell.char); - try testing.expect(cell.attrs.wide); - try testing.expectEqual(@as(usize, 2), row.codepointLen(2)); - } -} - -// X -test "Terminal: VS16 doesn't make character with 2027 disabled" { - var t = try init(testing.allocator, 5, 5); - defer t.deinit(testing.allocator); - - // Disable grapheme clustering - t.modes.set(.grapheme_cluster, false); - - try t.print(0x2764); // Heart - try t.print(0xFE0F); // VS16 to make wide - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("❤️", str); - } - - const row = t.screen.getRow(.{ .screen = 0 }); - { - const cell = row.getCell(0); - try testing.expectEqual(@as(u32, 0x2764), cell.char); - try testing.expect(!cell.attrs.wide); - try testing.expectEqual(@as(usize, 2), row.codepointLen(0)); - } -} - -// X test "Terminal: print multicodepoint grapheme, disabled mode 2027" { var t = try init(testing.allocator, 80, 80); defer t.deinit(testing.allocator); @@ -2461,44 +2411,113 @@ test "Terminal: print multicodepoint grapheme, disabled mode 2027" { // Assert various properties about our screen to verify // we have all expected cells. - const row = t.screen.getRow(.{ .screen = 0 }); { - const cell = row.getCell(0); - try testing.expectEqual(@as(u32, 0x1F468), cell.char); - try testing.expect(cell.attrs.wide); - try testing.expectEqual(@as(usize, 2), row.codepointLen(0)); + const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; + const cell = list_cell.cell; + try testing.expectEqual(@as(u21, 0x1F468), cell.content.codepoint); + try testing.expect(cell.hasGrapheme()); + try testing.expectEqual(Cell.Wide.wide, cell.wide); + const cps = list_cell.page.data.lookupGrapheme(cell).?; + try testing.expectEqual(@as(usize, 1), cps.len); } { - const cell = row.getCell(1); - try testing.expectEqual(@as(u32, ' '), cell.char); - try testing.expect(cell.attrs.wide_spacer_tail); - try testing.expectEqual(@as(usize, 1), row.codepointLen(1)); + const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 1, .y = 0 } }).?; + const cell = list_cell.cell; + try testing.expectEqual(@as(u21, ' '), cell.content.codepoint); + try testing.expect(!cell.hasGrapheme()); + try testing.expectEqual(Cell.Wide.spacer_tail, cell.wide); + try testing.expect(list_cell.page.data.lookupGrapheme(cell) == null); } { - const cell = row.getCell(2); - try testing.expectEqual(@as(u32, 0x1F469), cell.char); - try testing.expect(cell.attrs.wide); - try testing.expectEqual(@as(usize, 2), row.codepointLen(2)); + const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 2, .y = 0 } }).?; + const cell = list_cell.cell; + try testing.expectEqual(@as(u21, 0x1F469), cell.content.codepoint); + try testing.expect(cell.hasGrapheme()); + try testing.expectEqual(Cell.Wide.wide, cell.wide); + const cps = list_cell.page.data.lookupGrapheme(cell).?; + try testing.expectEqual(@as(usize, 1), cps.len); } { - const cell = row.getCell(3); - try testing.expectEqual(@as(u32, ' '), cell.char); - try testing.expect(cell.attrs.wide_spacer_tail); + const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 3, .y = 0 } }).?; + const cell = list_cell.cell; + try testing.expectEqual(@as(u21, ' '), cell.content.codepoint); + try testing.expect(!cell.hasGrapheme()); + try testing.expectEqual(Cell.Wide.spacer_tail, cell.wide); + try testing.expect(list_cell.page.data.lookupGrapheme(cell) == null); } { - const cell = row.getCell(4); - try testing.expectEqual(@as(u32, 0x1F467), cell.char); - try testing.expect(cell.attrs.wide); - try testing.expectEqual(@as(usize, 1), row.codepointLen(4)); + const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 4, .y = 0 } }).?; + const cell = list_cell.cell; + try testing.expectEqual(@as(u21, 0x1F467), cell.content.codepoint); + try testing.expect(!cell.hasGrapheme()); + try testing.expectEqual(Cell.Wide.wide, cell.wide); + try testing.expect(list_cell.page.data.lookupGrapheme(cell) == null); } { - const cell = row.getCell(5); - try testing.expectEqual(@as(u32, ' '), cell.char); - try testing.expect(cell.attrs.wide_spacer_tail); + const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 5, .y = 0 } }).?; + const cell = list_cell.cell; + try testing.expectEqual(@as(u21, ' '), cell.content.codepoint); + try testing.expect(!cell.hasGrapheme()); + try testing.expectEqual(Cell.Wide.spacer_tail, cell.wide); + try testing.expect(list_cell.page.data.lookupGrapheme(cell) == null); + } +} + +test "Terminal: VS16 doesn't make character with 2027 disabled" { + var t = try init(testing.allocator, 5, 5); + defer t.deinit(testing.allocator); + + // Disable grapheme clustering + t.modes.set(.grapheme_cluster, false); + + try t.print(0x2764); // Heart + try t.print(0xFE0F); // VS16 to make wide + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("❤️", str); + } + + { + const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; + const cell = list_cell.cell; + try testing.expectEqual(@as(u21, 0x2764), cell.content.codepoint); + try testing.expect(cell.hasGrapheme()); + try testing.expectEqual(Cell.Wide.narrow, cell.wide); + const cps = list_cell.page.data.lookupGrapheme(cell).?; + try testing.expectEqual(@as(usize, 1), cps.len); + } +} + +test "Terminal: print invalid VS16 non-grapheme" { + var t = try init(testing.allocator, 80, 80); + defer t.deinit(testing.allocator); + + // https://github.com/mitchellh/ghostty/issues/1482 + try t.print('x'); + try t.print(0xFE0F); + + // We should have 2 cells taken up. It is one character but "wide". + try testing.expectEqual(@as(usize, 0), t.screen.cursor.y); + try testing.expectEqual(@as(usize, 1), t.screen.cursor.x); + + // Assert various properties about our screen to verify + // we have all expected cells. + { + const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; + const cell = list_cell.cell; + try testing.expectEqual(@as(u21, 'x'), cell.content.codepoint); + try testing.expect(!cell.hasGrapheme()); + try testing.expectEqual(Cell.Wide.narrow, cell.wide); + } + { + const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 1, .y = 0 } }).?; + const cell = list_cell.cell; + try testing.expectEqual(@as(u21, 0), cell.content.codepoint); } } -// X test "Terminal: print multicodepoint grapheme, mode 2027" { var t = try init(testing.allocator, 80, 80); defer t.deinit(testing.allocator); @@ -2520,50 +2539,116 @@ test "Terminal: print multicodepoint grapheme, mode 2027" { // Assert various properties about our screen to verify // we have all expected cells. - const row = t.screen.getRow(.{ .screen = 0 }); { - const cell = row.getCell(0); - try testing.expectEqual(@as(u32, 0x1F468), cell.char); - try testing.expect(cell.attrs.wide); - try testing.expectEqual(@as(usize, 5), row.codepointLen(0)); + const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; + const cell = list_cell.cell; + try testing.expectEqual(@as(u21, 0x1F468), cell.content.codepoint); + try testing.expect(cell.hasGrapheme()); + try testing.expectEqual(Cell.Wide.wide, cell.wide); + const cps = list_cell.page.data.lookupGrapheme(cell).?; + try testing.expectEqual(@as(usize, 4), cps.len); } { - const cell = row.getCell(1); - try testing.expectEqual(@as(u32, ' '), cell.char); - try testing.expect(cell.attrs.wide_spacer_tail); - try testing.expectEqual(@as(usize, 1), row.codepointLen(1)); + const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 1, .y = 0 } }).?; + const cell = list_cell.cell; + try testing.expectEqual(@as(u21, ' '), cell.content.codepoint); + try testing.expect(!cell.hasGrapheme()); + try testing.expectEqual(Cell.Wide.spacer_tail, cell.wide); } } -// X -test "Terminal: print invalid VS16 non-grapheme" { - var t = try init(testing.allocator, 80, 80); +test "Terminal: VS15 to make narrow character" { + var t = try init(testing.allocator, 5, 5); defer t.deinit(testing.allocator); - // https://github.com/mitchellh/ghostty/issues/1482 - try t.print('x'); - try t.print(0xFE0F); + // Enable grapheme clustering + t.modes.set(.grapheme_cluster, true); - // We should have 2 cells taken up. It is one character but "wide". - try testing.expectEqual(@as(usize, 0), t.screen.cursor.y); - try testing.expectEqual(@as(usize, 1), t.screen.cursor.x); + try t.print(0x26C8); // Thunder cloud and rain + try t.print(0xFE0E); // VS15 to make narrow - // Assert various properties about our screen to verify - // we have all expected cells. - const row = t.screen.getRow(.{ .screen = 0 }); { - const cell = row.getCell(0); - try testing.expectEqual(@as(u32, 'x'), cell.char); - try testing.expect(!cell.attrs.wide); - try testing.expectEqual(@as(usize, 1), row.codepointLen(0)); + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("⛈︎", str); } + { - const cell = row.getCell(1); - try testing.expectEqual(@as(u32, 0), cell.char); + const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; + const cell = list_cell.cell; + try testing.expectEqual(@as(u21, 0x26C8), cell.content.codepoint); + try testing.expect(cell.hasGrapheme()); + try testing.expectEqual(Cell.Wide.narrow, cell.wide); + const cps = list_cell.page.data.lookupGrapheme(cell).?; + try testing.expectEqual(@as(usize, 1), cps.len); + } +} + +test "Terminal: VS16 to make wide character with mode 2027" { + var t = try init(testing.allocator, 5, 5); + defer t.deinit(testing.allocator); + + // Enable grapheme clustering + t.modes.set(.grapheme_cluster, true); + + try t.print(0x2764); // Heart + try t.print(0xFE0F); // VS16 to make wide + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("❤️", str); + } + + { + const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; + const cell = list_cell.cell; + try testing.expectEqual(@as(u21, 0x2764), cell.content.codepoint); + try testing.expect(cell.hasGrapheme()); + try testing.expectEqual(Cell.Wide.wide, cell.wide); + const cps = list_cell.page.data.lookupGrapheme(cell).?; + try testing.expectEqual(@as(usize, 1), cps.len); + } +} + +test "Terminal: VS16 repeated with mode 2027" { + var t = try init(testing.allocator, 5, 5); + defer t.deinit(testing.allocator); + + // Enable grapheme clustering + t.modes.set(.grapheme_cluster, true); + + try t.print(0x2764); // Heart + try t.print(0xFE0F); // VS16 to make wide + try t.print(0x2764); // Heart + try t.print(0xFE0F); // VS16 to make wide + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("❤️❤️", str); + } + + { + const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; + const cell = list_cell.cell; + try testing.expectEqual(@as(u21, 0x2764), cell.content.codepoint); + try testing.expect(cell.hasGrapheme()); + try testing.expectEqual(Cell.Wide.wide, cell.wide); + const cps = list_cell.page.data.lookupGrapheme(cell).?; + try testing.expectEqual(@as(usize, 1), cps.len); + } + { + const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 2, .y = 0 } }).?; + const cell = list_cell.cell; + try testing.expectEqual(@as(u21, 0x2764), cell.content.codepoint); + try testing.expect(cell.hasGrapheme()); + try testing.expectEqual(Cell.Wide.wide, cell.wide); + const cps = list_cell.page.data.lookupGrapheme(cell).?; + try testing.expectEqual(@as(usize, 1), cps.len); } } -// X test "Terminal: print invalid VS16 grapheme" { var t = try init(testing.allocator, 80, 80); defer t.deinit(testing.allocator); @@ -2581,20 +2666,21 @@ test "Terminal: print invalid VS16 grapheme" { // Assert various properties about our screen to verify // we have all expected cells. - const row = t.screen.getRow(.{ .screen = 0 }); { - const cell = row.getCell(0); - try testing.expectEqual(@as(u32, 'x'), cell.char); - try testing.expect(!cell.attrs.wide); - try testing.expectEqual(@as(usize, 1), row.codepointLen(0)); + const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; + const cell = list_cell.cell; + try testing.expectEqual(@as(u21, 'x'), cell.content.codepoint); + try testing.expect(!cell.hasGrapheme()); + try testing.expectEqual(Cell.Wide.narrow, cell.wide); } { - const cell = row.getCell(1); - try testing.expectEqual(@as(u32, 0), cell.char); + const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 1, .y = 0 } }).?; + const cell = list_cell.cell; + try testing.expectEqual(@as(u21, 0), cell.content.codepoint); + try testing.expectEqual(Cell.Wide.narrow, cell.wide); } } -// X test "Terminal: print invalid VS16 with second char" { var t = try init(testing.allocator, 80, 80); defer t.deinit(testing.allocator); @@ -2613,145 +2699,50 @@ test "Terminal: print invalid VS16 with second char" { // Assert various properties about our screen to verify // we have all expected cells. - const row = t.screen.getRow(.{ .screen = 0 }); { - const cell = row.getCell(0); - try testing.expectEqual(@as(u32, 'x'), cell.char); - try testing.expect(!cell.attrs.wide); - try testing.expectEqual(@as(usize, 1), row.codepointLen(0)); + const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; + const cell = list_cell.cell; + try testing.expectEqual(@as(u21, 'x'), cell.content.codepoint); + try testing.expect(!cell.hasGrapheme()); + try testing.expectEqual(Cell.Wide.narrow, cell.wide); } + { - const cell = row.getCell(1); - try testing.expectEqual(@as(u32, 'y'), cell.char); - try testing.expect(!cell.attrs.wide); - try testing.expectEqual(@as(usize, 1), row.codepointLen(0)); + const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 1, .y = 0 } }).?; + const cell = list_cell.cell; + try testing.expectEqual(@as(u21, 'y'), cell.content.codepoint); + try testing.expect(!cell.hasGrapheme()); + try testing.expectEqual(Cell.Wide.narrow, cell.wide); } } -// X -test "Terminal: soft wrap" { - var t = try init(testing.allocator, 3, 80); - defer t.deinit(testing.allocator); - - // Basic grid writing - for ("hello") |c| try t.print(c); - try testing.expectEqual(@as(usize, 1), t.screen.cursor.y); - try testing.expectEqual(@as(usize, 2), t.screen.cursor.x); - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("hel\nlo", str); - } -} - -// X -test "Terminal: soft wrap with semantic prompt" { - var t = try init(testing.allocator, 3, 80); - defer t.deinit(testing.allocator); - - t.markSemanticPrompt(.prompt); - for ("hello") |c| try t.print(c); - - { - const row = t.screen.getRow(.{ .active = 0 }); - try testing.expect(row.getSemanticPrompt() == .prompt); - } - { - const row = t.screen.getRow(.{ .active = 1 }); - try testing.expect(row.getSemanticPrompt() == .prompt); - } -} - -// X -test "Terminal: disabled wraparound with wide char and one space" { - var t = try init(testing.allocator, 5, 5); - defer t.deinit(testing.allocator); - - t.modes.set(.wraparound, false); - - // This puts our cursor at the end and there is NO SPACE for a - // wide character. - try t.printString("AAAA"); - try t.print(0x1F6A8); // Police car light - try testing.expectEqual(@as(usize, 0), t.screen.cursor.y); - try testing.expectEqual(@as(usize, 4), t.screen.cursor.x); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("AAAA", str); - } - - // Make sure we printed nothing - const row = t.screen.getRow(.{ .screen = 0 }); - { - const cell = row.getCell(4); - try testing.expectEqual(@as(u32, 0), cell.char); - try testing.expect(!cell.attrs.wide); - } -} - -// X -test "Terminal: disabled wraparound with wide char and no space" { - var t = try init(testing.allocator, 5, 5); - defer t.deinit(testing.allocator); - - t.modes.set(.wraparound, false); - - // This puts our cursor at the end and there is NO SPACE for a - // wide character. - try t.printString("AAAAA"); - try t.print(0x1F6A8); // Police car light - try testing.expectEqual(@as(usize, 0), t.screen.cursor.y); - try testing.expectEqual(@as(usize, 4), t.screen.cursor.x); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("AAAAA", str); - } - - // Make sure we printed nothing - const row = t.screen.getRow(.{ .screen = 0 }); - { - const cell = row.getCell(4); - try testing.expectEqual(@as(u32, 'A'), cell.char); - try testing.expect(!cell.attrs.wide); - } -} - -// X -test "Terminal: disabled wraparound with wide grapheme and half space" { +test "Terminal: overwrite grapheme should clear grapheme data" { var t = try init(testing.allocator, 5, 5); defer t.deinit(testing.allocator); + // Enable grapheme clustering t.modes.set(.grapheme_cluster, true); - t.modes.set(.wraparound, false); - // This puts our cursor at the end and there is NO SPACE for a - // wide character. - try t.printString("AAAA"); - try t.print(0x2764); // Heart - try t.print(0xFE0F); // VS16 to make wide - try testing.expectEqual(@as(usize, 0), t.screen.cursor.y); - try testing.expectEqual(@as(usize, 4), t.screen.cursor.x); + try t.print(0x26C8); // Thunder cloud and rain + try t.print(0xFE0E); // VS15 to make narrow + t.setCursorPos(1, 1); + try t.print('A'); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("AAAA❤", str); + try testing.expectEqualStrings("A", str); } - // Make sure we printed nothing - const row = t.screen.getRow(.{ .screen = 0 }); { - const cell = row.getCell(4); - try testing.expectEqual(@as(u32, '❤'), cell.char); - try testing.expect(!cell.attrs.wide); + const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; + const cell = list_cell.cell; + try testing.expectEqual(@as(u21, 'A'), cell.content.codepoint); + try testing.expect(!cell.hasGrapheme()); + try testing.expectEqual(Cell.Wide.narrow, cell.wide); } } -// X test "Terminal: print writes to bottom if scrolled" { var t = try init(testing.allocator, 5, 2); defer t.deinit(testing.allocator); @@ -2772,7 +2763,7 @@ test "Terminal: print writes to bottom if scrolled" { } // Scroll to the top - try t.scrollViewport(.{ .top = {} }); + t.screen.scroll(.{ .top = {} }); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); @@ -2781,7 +2772,7 @@ test "Terminal: print writes to bottom if scrolled" { // Type try t.print('A'); - try t.scrollViewport(.{ .bottom = {} }); + t.screen.scroll(.{ .active = {} }); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); @@ -2789,7 +2780,6 @@ test "Terminal: print writes to bottom if scrolled" { } } -// X test "Terminal: print charset" { var t = try init(testing.allocator, 80, 80); defer t.deinit(testing.allocator); @@ -2814,7 +2804,6 @@ test "Terminal: print charset" { } } -// X test "Terminal: print charset outside of ASCII" { var t = try init(testing.allocator, 80, 80); defer t.deinit(testing.allocator); @@ -2835,7 +2824,6 @@ test "Terminal: print charset outside of ASCII" { } } -// X test "Terminal: print invoke charset" { var t = try init(testing.allocator, 80, 80); defer t.deinit(testing.allocator); @@ -2856,7 +2844,6 @@ test "Terminal: print invoke charset" { } } -// X test "Terminal: print invoke charset single" { var t = try init(testing.allocator, 80, 80); defer t.deinit(testing.allocator); @@ -2875,7 +2862,122 @@ test "Terminal: print invoke charset single" { } } -// X +test "Terminal: soft wrap" { + var t = try init(testing.allocator, 3, 80); + defer t.deinit(testing.allocator); + + // Basic grid writing + for ("hello") |c| try t.print(c); + try testing.expectEqual(@as(usize, 1), t.screen.cursor.y); + try testing.expectEqual(@as(usize, 2), t.screen.cursor.x); + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("hel\nlo", str); + } +} + +test "Terminal: soft wrap with semantic prompt" { + var t = try init(testing.allocator, 3, 80); + defer t.deinit(testing.allocator); + + t.markSemanticPrompt(.prompt); + for ("hello") |c| try t.print(c); + + { + const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; + try testing.expectEqual(Row.SemanticPrompt.prompt, list_cell.row.semantic_prompt); + } + { + const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 0, .y = 1 } }).?; + try testing.expectEqual(Row.SemanticPrompt.prompt, list_cell.row.semantic_prompt); + } +} + +test "Terminal: disabled wraparound with wide char and one space" { + var t = try init(testing.allocator, 5, 5); + defer t.deinit(testing.allocator); + + t.modes.set(.wraparound, false); + + // This puts our cursor at the end and there is NO SPACE for a + // wide character. + try t.printString("AAAA"); + try t.print(0x1F6A8); // Police car light + try testing.expectEqual(@as(usize, 0), t.screen.cursor.y); + try testing.expectEqual(@as(usize, 4), t.screen.cursor.x); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("AAAA", str); + } + + // Make sure we printed nothing + { + const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 4, .y = 0 } }).?; + const cell = list_cell.cell; + try testing.expectEqual(@as(u21, 0), cell.content.codepoint); + try testing.expectEqual(Cell.Wide.narrow, cell.wide); + } +} + +test "Terminal: disabled wraparound with wide char and no space" { + var t = try init(testing.allocator, 5, 5); + defer t.deinit(testing.allocator); + + t.modes.set(.wraparound, false); + + // This puts our cursor at the end and there is NO SPACE for a + // wide character. + try t.printString("AAAAA"); + try t.print(0x1F6A8); // Police car light + try testing.expectEqual(@as(usize, 0), t.screen.cursor.y); + try testing.expectEqual(@as(usize, 4), t.screen.cursor.x); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("AAAAA", str); + } + + { + const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 4, .y = 0 } }).?; + const cell = list_cell.cell; + try testing.expectEqual(@as(u21, 'A'), cell.content.codepoint); + try testing.expectEqual(Cell.Wide.narrow, cell.wide); + } +} + +test "Terminal: disabled wraparound with wide grapheme and half space" { + var t = try init(testing.allocator, 5, 5); + defer t.deinit(testing.allocator); + + t.modes.set(.grapheme_cluster, true); + t.modes.set(.wraparound, false); + + // This puts our cursor at the end and there is NO SPACE for a + // wide character. + try t.printString("AAAA"); + try t.print(0x2764); // Heart + try t.print(0xFE0F); // VS16 to make wide + try testing.expectEqual(@as(usize, 0), t.screen.cursor.y); + try testing.expectEqual(@as(usize, 4), t.screen.cursor.x); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("AAAA❤", str); + } + + { + const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 4, .y = 0 } }).?; + const cell = list_cell.cell; + try testing.expectEqual(@as(u21, '❤'), cell.content.codepoint); + try testing.expectEqual(Cell.Wide.narrow, cell.wide); + } +} + test "Terminal: print right margin wrap" { var t = try init(testing.allocator, 10, 5); defer t.deinit(testing.allocator); @@ -2893,7 +2995,6 @@ test "Terminal: print right margin wrap" { } } -// X test "Terminal: print right margin outside" { var t = try init(testing.allocator, 10, 5); defer t.deinit(testing.allocator); @@ -2911,7 +3012,6 @@ test "Terminal: print right margin outside" { } } -// X test "Terminal: print right margin outside wrap" { var t = try init(testing.allocator, 10, 5); defer t.deinit(testing.allocator); @@ -2929,7 +3029,6 @@ test "Terminal: print right margin outside wrap" { } } -// X test "Terminal: linefeed and carriage return" { var t = try init(testing.allocator, 80, 80); defer t.deinit(testing.allocator); @@ -2948,7 +3047,6 @@ test "Terminal: linefeed and carriage return" { } } -// X test "Terminal: linefeed unsets pending wrap" { var t = try init(testing.allocator, 5, 80); defer t.deinit(testing.allocator); @@ -2960,7 +3058,6 @@ test "Terminal: linefeed unsets pending wrap" { try testing.expect(t.screen.cursor.pending_wrap == false); } -// X test "Terminal: linefeed mode automatic carriage return" { var t = try init(testing.allocator, 10, 10); defer t.deinit(testing.allocator); @@ -2977,7 +3074,6 @@ test "Terminal: linefeed mode automatic carriage return" { } } -// X test "Terminal: carriage return unsets pending wrap" { var t = try init(testing.allocator, 5, 80); defer t.deinit(testing.allocator); @@ -2989,7 +3085,6 @@ test "Terminal: carriage return unsets pending wrap" { try testing.expect(t.screen.cursor.pending_wrap == false); } -// X test "Terminal: carriage return origin mode moves to left margin" { var t = try init(testing.allocator, 5, 80); defer t.deinit(testing.allocator); @@ -3001,7 +3096,6 @@ test "Terminal: carriage return origin mode moves to left margin" { try testing.expectEqual(@as(usize, 2), t.screen.cursor.x); } -// X test "Terminal: carriage return left of left margin moves to zero" { var t = try init(testing.allocator, 5, 80); defer t.deinit(testing.allocator); @@ -3012,7 +3106,6 @@ test "Terminal: carriage return left of left margin moves to zero" { try testing.expectEqual(@as(usize, 0), t.screen.cursor.x); } -// X test "Terminal: carriage return right of left margin moves to left margin" { var t = try init(testing.allocator, 5, 80); defer t.deinit(testing.allocator); @@ -3023,7 +3116,6 @@ test "Terminal: carriage return right of left margin moves to left margin" { try testing.expectEqual(@as(usize, 2), t.screen.cursor.x); } -// X test "Terminal: backspace" { var t = try init(testing.allocator, 80, 80); defer t.deinit(testing.allocator); @@ -3041,7 +3133,6 @@ test "Terminal: backspace" { } } -// X test "Terminal: horizontal tabs" { const alloc = testing.allocator; var t = try init(alloc, 20, 5); @@ -3063,15 +3154,14 @@ test "Terminal: horizontal tabs" { try testing.expectEqual(@as(usize, 19), t.screen.cursor.x); } -// X test "Terminal: horizontal tabs starting on tabstop" { const alloc = testing.allocator; var t = try init(alloc, 20, 5); defer t.deinit(alloc); - t.screen.cursor.x = 8; + t.setCursorPos(t.screen.cursor.y, 9); try t.print('X'); - t.screen.cursor.x = 8; + t.setCursorPos(t.screen.cursor.y, 9); try t.horizontalTab(); try t.print('A'); @@ -3082,7 +3172,6 @@ test "Terminal: horizontal tabs starting on tabstop" { } } -// X test "Terminal: horizontal tabs with right margin" { const alloc = testing.allocator; var t = try init(alloc, 20, 5); @@ -3090,7 +3179,7 @@ test "Terminal: horizontal tabs with right margin" { t.scrolling_region.left = 2; t.scrolling_region.right = 5; - t.screen.cursor.x = 0; + t.setCursorPos(t.screen.cursor.y, 1); try t.print('X'); try t.horizontalTab(); try t.print('A'); @@ -3102,14 +3191,13 @@ test "Terminal: horizontal tabs with right margin" { } } -// X test "Terminal: horizontal tabs back" { const alloc = testing.allocator; var t = try init(alloc, 20, 5); defer t.deinit(alloc); // Edge of screen - t.screen.cursor.x = 19; + t.setCursorPos(t.screen.cursor.y, 20); // HT try t.horizontalTabBack(); @@ -3126,15 +3214,14 @@ test "Terminal: horizontal tabs back" { try testing.expectEqual(@as(usize, 0), t.screen.cursor.x); } -// X test "Terminal: horizontal tabs back starting on tabstop" { const alloc = testing.allocator; var t = try init(alloc, 20, 5); defer t.deinit(alloc); - t.screen.cursor.x = 8; + t.setCursorPos(t.screen.cursor.y, 9); try t.print('X'); - t.screen.cursor.x = 8; + t.setCursorPos(t.screen.cursor.y, 9); try t.horizontalTabBack(); try t.print('A'); @@ -3145,7 +3232,6 @@ test "Terminal: horizontal tabs back starting on tabstop" { } } -// X test "Terminal: horizontal tabs with left margin in origin mode" { const alloc = testing.allocator; var t = try init(alloc, 20, 5); @@ -3154,7 +3240,7 @@ test "Terminal: horizontal tabs with left margin in origin mode" { t.modes.set(.origin, true); t.scrolling_region.left = 2; t.scrolling_region.right = 5; - t.screen.cursor.x = 3; + t.setCursorPos(1, 2); try t.print('X'); try t.horizontalTabBack(); try t.print('A'); @@ -3166,7 +3252,6 @@ test "Terminal: horizontal tabs with left margin in origin mode" { } } -// X test "Terminal: horizontal tab back with cursor before left margin" { const alloc = testing.allocator; var t = try init(alloc, 20, 5); @@ -3176,7 +3261,7 @@ test "Terminal: horizontal tab back with cursor before left margin" { t.saveCursor(); t.modes.set(.enable_left_and_right_margin, true); t.setLeftAndRightMargin(5, 0); - t.restoreCursor(); + try t.restoreCursor(); try t.horizontalTabBack(); try t.print('X'); @@ -3187,7 +3272,6 @@ test "Terminal: horizontal tab back with cursor before left margin" { } } -// X test "Terminal: cursorPos resets wrap" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); @@ -3206,7 +3290,6 @@ test "Terminal: cursorPos resets wrap" { } } -// X test "Terminal: cursorPos off the screen" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); @@ -3222,7 +3305,6 @@ test "Terminal: cursorPos off the screen" { } } -// X test "Terminal: cursorPos relative to origin" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); @@ -3241,7 +3323,6 @@ test "Terminal: cursorPos relative to origin" { } } -// X test "Terminal: cursorPos relative to origin with left/right" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); @@ -3262,7 +3343,6 @@ test "Terminal: cursorPos relative to origin with left/right" { } } -// X test "Terminal: cursorPos limits with full scroll region" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); @@ -3283,7 +3363,7 @@ test "Terminal: cursorPos limits with full scroll region" { } } -// X +// Probably outdated, but dates back to the original terminal implementation. test "Terminal: setCursorPos (original test)" { var t = try init(testing.allocator, 80, 80); defer t.deinit(testing.allocator); @@ -3336,7 +3416,6 @@ test "Terminal: setCursorPos (original test)" { try testing.expectEqual(@as(usize, 10), t.screen.cursor.y); } -// X test "Terminal: setTopAndBottomMargin simple" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); @@ -3350,7 +3429,7 @@ test "Terminal: setTopAndBottomMargin simple" { try t.linefeed(); try t.printString("GHI"); t.setTopAndBottomMargin(0, 0); - try t.scrollDown(1); + t.scrollDown(1); { const str = try t.plainString(testing.allocator); @@ -3359,7 +3438,6 @@ test "Terminal: setTopAndBottomMargin simple" { } } -// X test "Terminal: setTopAndBottomMargin top only" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); @@ -3373,7 +3451,7 @@ test "Terminal: setTopAndBottomMargin top only" { try t.linefeed(); try t.printString("GHI"); t.setTopAndBottomMargin(2, 0); - try t.scrollDown(1); + t.scrollDown(1); { const str = try t.plainString(testing.allocator); @@ -3382,7 +3460,6 @@ test "Terminal: setTopAndBottomMargin top only" { } } -// X test "Terminal: setTopAndBottomMargin top and bottom" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); @@ -3396,7 +3473,7 @@ test "Terminal: setTopAndBottomMargin top and bottom" { try t.linefeed(); try t.printString("GHI"); t.setTopAndBottomMargin(1, 2); - try t.scrollDown(1); + t.scrollDown(1); { const str = try t.plainString(testing.allocator); @@ -3405,7 +3482,6 @@ test "Terminal: setTopAndBottomMargin top and bottom" { } } -// X test "Terminal: setTopAndBottomMargin top equal to bottom" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); @@ -3419,7 +3495,7 @@ test "Terminal: setTopAndBottomMargin top equal to bottom" { try t.linefeed(); try t.printString("GHI"); t.setTopAndBottomMargin(2, 2); - try t.scrollDown(1); + t.scrollDown(1); { const str = try t.plainString(testing.allocator); @@ -3428,7 +3504,6 @@ test "Terminal: setTopAndBottomMargin top equal to bottom" { } } -// X test "Terminal: setLeftAndRightMargin simple" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); @@ -3452,7 +3527,6 @@ test "Terminal: setLeftAndRightMargin simple" { } } -// X test "Terminal: setLeftAndRightMargin left only" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); @@ -3470,7 +3544,7 @@ test "Terminal: setLeftAndRightMargin left only" { try testing.expectEqual(@as(usize, 1), t.scrolling_region.left); try testing.expectEqual(@as(usize, t.cols - 1), t.scrolling_region.right); t.setCursorPos(1, 2); - try t.insertLines(1); + t.insertLines(1); { const str = try t.plainString(testing.allocator); @@ -3479,7 +3553,6 @@ test "Terminal: setLeftAndRightMargin left only" { } } -// X test "Terminal: setLeftAndRightMargin left and right" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); @@ -3495,7 +3568,7 @@ test "Terminal: setLeftAndRightMargin left and right" { t.modes.set(.enable_left_and_right_margin, true); t.setLeftAndRightMargin(1, 2); t.setCursorPos(1, 2); - try t.insertLines(1); + t.insertLines(1); { const str = try t.plainString(testing.allocator); @@ -3504,7 +3577,6 @@ test "Terminal: setLeftAndRightMargin left and right" { } } -// X test "Terminal: setLeftAndRightMargin left equal right" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); @@ -3520,7 +3592,7 @@ test "Terminal: setLeftAndRightMargin left equal right" { t.modes.set(.enable_left_and_right_margin, true); t.setLeftAndRightMargin(2, 2); t.setCursorPos(1, 2); - try t.insertLines(1); + t.insertLines(1); { const str = try t.plainString(testing.allocator); @@ -3529,7 +3601,6 @@ test "Terminal: setLeftAndRightMargin left equal right" { } } -// X test "Terminal: setLeftAndRightMargin mode 69 unset" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); @@ -3545,7 +3616,7 @@ test "Terminal: setLeftAndRightMargin mode 69 unset" { t.modes.set(.enable_left_and_right_margin, false); t.setLeftAndRightMargin(1, 2); t.setCursorPos(1, 2); - try t.insertLines(1); + t.insertLines(1); { const str = try t.plainString(testing.allocator); @@ -3554,8 +3625,1799 @@ test "Terminal: setLeftAndRightMargin mode 69 unset" { } } -// X -test "Terminal: deleteLines" { +test "Terminal: insertLines simple" { + const alloc = testing.allocator; + var t = try init(alloc, 5, 5); + defer t.deinit(alloc); + + try t.printString("ABC"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("DEF"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("GHI"); + t.setCursorPos(2, 2); + t.insertLines(1); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("ABC\n\nDEF\nGHI", str); + } +} + +test "Terminal: insertLines colors with bg color" { + const alloc = testing.allocator; + var t = try init(alloc, 5, 5); + defer t.deinit(alloc); + + try t.printString("ABC"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("DEF"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("GHI"); + t.setCursorPos(2, 2); + + try t.setAttribute(.{ .direct_color_bg = .{ + .r = 0xFF, + .g = 0, + .b = 0, + } }); + t.insertLines(1); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("ABC\n\nDEF\nGHI", str); + } + + for (0..t.cols) |x| { + const list_cell = t.screen.pages.getCell(.{ .active = .{ .x = x, .y = 1 } }).?; + try testing.expect(list_cell.cell.content_tag == .bg_color_rgb); + try testing.expectEqual(Cell.RGB{ + .r = 0xFF, + .g = 0, + .b = 0, + }, list_cell.cell.content.color_rgb); + } +} + +test "Terminal: insertLines handles style refs" { + const alloc = testing.allocator; + var t = try init(alloc, 5, 3); + defer t.deinit(alloc); + + try t.printString("ABC"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("DEF"); + t.carriageReturn(); + try t.linefeed(); + + // For the line being deleted, create a refcounted style + try t.setAttribute(.{ .bold = {} }); + try t.printString("GHI"); + try t.setAttribute(.{ .unset = {} }); + + // verify we have styles in our style map + const page = t.screen.cursor.page_pin.page.data; + try testing.expectEqual(@as(usize, 1), page.styles.count(page.memory)); + + t.setCursorPos(2, 2); + t.insertLines(1); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("ABC\n\nDEF", str); + } + + // verify we have no styles in our style map + try testing.expectEqual(@as(usize, 0), page.styles.count(page.memory)); +} + +test "Terminal: insertLines outside of scroll region" { + const alloc = testing.allocator; + var t = try init(alloc, 5, 5); + defer t.deinit(alloc); + + try t.printString("ABC"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("DEF"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("GHI"); + t.setTopAndBottomMargin(3, 4); + t.setCursorPos(2, 2); + t.insertLines(1); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("ABC\nDEF\nGHI", str); + } +} + +test "Terminal: insertLines top/bottom scroll region" { + const alloc = testing.allocator; + var t = try init(alloc, 5, 5); + defer t.deinit(alloc); + + try t.printString("ABC"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("DEF"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("GHI"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("123"); + t.setTopAndBottomMargin(1, 3); + t.setCursorPos(2, 2); + t.insertLines(1); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("ABC\n\nDEF\n123", str); + } +} + +test "Terminal: insertLines (legacy test)" { + const alloc = testing.allocator; + var t = try init(alloc, 2, 5); + defer t.deinit(alloc); + + // Initial value + try t.print('A'); + t.carriageReturn(); + try t.linefeed(); + try t.print('B'); + t.carriageReturn(); + try t.linefeed(); + try t.print('C'); + t.carriageReturn(); + try t.linefeed(); + try t.print('D'); + t.carriageReturn(); + try t.linefeed(); + try t.print('E'); + + // Move to row 2 + t.setCursorPos(2, 1); + + // Insert two lines + t.insertLines(2); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("A\n\n\nB\nC", str); + } +} + +test "Terminal: insertLines zero" { + const alloc = testing.allocator; + var t = try init(alloc, 2, 5); + defer t.deinit(alloc); + + // This should do nothing + t.setCursorPos(1, 1); + t.insertLines(0); +} + +test "Terminal: insertLines with scroll region" { + const alloc = testing.allocator; + var t = try init(alloc, 2, 6); + defer t.deinit(alloc); + + // Initial value + try t.print('A'); + t.carriageReturn(); + try t.linefeed(); + try t.print('B'); + t.carriageReturn(); + try t.linefeed(); + try t.print('C'); + t.carriageReturn(); + try t.linefeed(); + try t.print('D'); + t.carriageReturn(); + try t.linefeed(); + try t.print('E'); + + t.setTopAndBottomMargin(1, 2); + t.setCursorPos(1, 1); + t.insertLines(1); + + try t.print('X'); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("X\nA\nC\nD\nE", str); + } +} + +test "Terminal: insertLines more than remaining" { + const alloc = testing.allocator; + var t = try init(alloc, 2, 5); + defer t.deinit(alloc); + + // Initial value + try t.print('A'); + t.carriageReturn(); + try t.linefeed(); + try t.print('B'); + t.carriageReturn(); + try t.linefeed(); + try t.print('C'); + t.carriageReturn(); + try t.linefeed(); + try t.print('D'); + t.carriageReturn(); + try t.linefeed(); + try t.print('E'); + + // Move to row 2 + t.setCursorPos(2, 1); + + // Insert a bunch of lines + t.insertLines(20); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("A", str); + } +} + +test "Terminal: insertLines resets wrap" { + const alloc = testing.allocator; + var t = try init(alloc, 5, 5); + defer t.deinit(alloc); + + for ("ABCDE") |c| try t.print(c); + try testing.expect(t.screen.cursor.pending_wrap); + t.insertLines(1); + try testing.expect(!t.screen.cursor.pending_wrap); + try t.print('B'); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("B\nABCDE", str); + } +} + +test "Terminal: insertLines multi-codepoint graphemes" { + const alloc = testing.allocator; + var t = try init(alloc, 5, 5); + defer t.deinit(alloc); + + // Disable grapheme clustering + t.modes.set(.grapheme_cluster, true); + + try t.printString("ABC"); + t.carriageReturn(); + try t.linefeed(); + + // This is: 👨‍👩‍👧 (which may or may not render correctly) + try t.print(0x1F468); + try t.print(0x200D); + try t.print(0x1F469); + try t.print(0x200D); + try t.print(0x1F467); + + t.carriageReturn(); + try t.linefeed(); + try t.printString("GHI"); + t.setCursorPos(2, 2); + t.insertLines(1); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("ABC\n\n👨‍👩‍👧\nGHI", str); + } +} + +test "Terminal: insertLines left/right scroll region" { + const alloc = testing.allocator; + var t = try init(alloc, 10, 10); + defer t.deinit(alloc); + + try t.printString("ABC123"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("DEF456"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("GHI789"); + t.scrolling_region.left = 1; + t.scrolling_region.right = 3; + t.setCursorPos(2, 2); + t.insertLines(1); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("ABC123\nD 56\nGEF489\n HI7", str); + } +} + +test "Terminal: scrollUp simple" { + const alloc = testing.allocator; + var t = try init(alloc, 5, 5); + defer t.deinit(alloc); + + try t.printString("ABC"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("DEF"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("GHI"); + t.setCursorPos(2, 2); + const cursor = t.screen.cursor; + t.scrollUp(1); + try testing.expectEqual(cursor.x, t.screen.cursor.x); + try testing.expectEqual(cursor.y, t.screen.cursor.y); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("DEF\nGHI", str); + } +} + +test "Terminal: scrollUp top/bottom scroll region" { + const alloc = testing.allocator; + var t = try init(alloc, 5, 5); + defer t.deinit(alloc); + + try t.printString("ABC"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("DEF"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("GHI"); + t.setTopAndBottomMargin(2, 3); + t.setCursorPos(1, 1); + t.scrollUp(1); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("ABC\nGHI", str); + } +} + +test "Terminal: scrollUp left/right scroll region" { + const alloc = testing.allocator; + var t = try init(alloc, 10, 10); + defer t.deinit(alloc); + + try t.printString("ABC123"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("DEF456"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("GHI789"); + t.scrolling_region.left = 1; + t.scrolling_region.right = 3; + t.setCursorPos(2, 2); + const cursor = t.screen.cursor; + t.scrollUp(1); + try testing.expectEqual(cursor.x, t.screen.cursor.x); + try testing.expectEqual(cursor.y, t.screen.cursor.y); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("AEF423\nDHI756\nG 89", str); + } +} + +test "Terminal: scrollUp preserves pending wrap" { + const alloc = testing.allocator; + var t = try init(alloc, 5, 5); + defer t.deinit(alloc); + + t.setCursorPos(1, 5); + try t.print('A'); + t.setCursorPos(2, 5); + try t.print('B'); + t.setCursorPos(3, 5); + try t.print('C'); + t.scrollUp(1); + try t.print('X'); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings(" B\n C\n\nX", str); + } +} + +test "Terminal: scrollUp full top/bottom region" { + const alloc = testing.allocator; + var t = try init(alloc, 5, 5); + defer t.deinit(alloc); + + try t.printString("top"); + t.setCursorPos(5, 1); + try t.printString("ABCDE"); + t.setTopAndBottomMargin(2, 5); + t.scrollUp(4); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("top", str); + } +} + +test "Terminal: scrollUp full top/bottomleft/right scroll region" { + const alloc = testing.allocator; + var t = try init(alloc, 5, 5); + defer t.deinit(alloc); + + try t.printString("top"); + t.setCursorPos(5, 1); + try t.printString("ABCDE"); + t.modes.set(.enable_left_and_right_margin, true); + t.setTopAndBottomMargin(2, 5); + t.setLeftAndRightMargin(2, 4); + t.scrollUp(4); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("top\n\n\n\nA E", str); + } +} + +test "Terminal: scrollDown simple" { + const alloc = testing.allocator; + var t = try init(alloc, 5, 5); + defer t.deinit(alloc); + + try t.printString("ABC"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("DEF"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("GHI"); + t.setCursorPos(2, 2); + const cursor = t.screen.cursor; + t.scrollDown(1); + try testing.expectEqual(cursor.x, t.screen.cursor.x); + try testing.expectEqual(cursor.y, t.screen.cursor.y); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("\nABC\nDEF\nGHI", str); + } +} + +test "Terminal: scrollDown outside of scroll region" { + const alloc = testing.allocator; + var t = try init(alloc, 5, 5); + defer t.deinit(alloc); + + try t.printString("ABC"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("DEF"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("GHI"); + t.setTopAndBottomMargin(3, 4); + t.setCursorPos(2, 2); + const cursor = t.screen.cursor; + t.scrollDown(1); + try testing.expectEqual(cursor.x, t.screen.cursor.x); + try testing.expectEqual(cursor.y, t.screen.cursor.y); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("ABC\nDEF\n\nGHI", str); + } +} + +test "Terminal: scrollDown left/right scroll region" { + const alloc = testing.allocator; + var t = try init(alloc, 10, 10); + defer t.deinit(alloc); + + try t.printString("ABC123"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("DEF456"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("GHI789"); + t.scrolling_region.left = 1; + t.scrolling_region.right = 3; + t.setCursorPos(2, 2); + const cursor = t.screen.cursor; + t.scrollDown(1); + try testing.expectEqual(cursor.x, t.screen.cursor.x); + try testing.expectEqual(cursor.y, t.screen.cursor.y); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("A 23\nDBC156\nGEF489\n HI7", str); + } +} + +test "Terminal: scrollDown outside of left/right scroll region" { + const alloc = testing.allocator; + var t = try init(alloc, 10, 10); + defer t.deinit(alloc); + + try t.printString("ABC123"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("DEF456"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("GHI789"); + t.scrolling_region.left = 1; + t.scrolling_region.right = 3; + t.setCursorPos(1, 1); + const cursor = t.screen.cursor; + t.scrollDown(1); + try testing.expectEqual(cursor.x, t.screen.cursor.x); + try testing.expectEqual(cursor.y, t.screen.cursor.y); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("A 23\nDBC156\nGEF489\n HI7", str); + } +} + +test "Terminal: scrollDown preserves pending wrap" { + const alloc = testing.allocator; + var t = try init(alloc, 5, 10); + defer t.deinit(alloc); + + t.setCursorPos(1, 5); + try t.print('A'); + t.setCursorPos(2, 5); + try t.print('B'); + t.setCursorPos(3, 5); + try t.print('C'); + t.scrollDown(1); + try t.print('X'); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("\n A\n B\nX C", str); + } +} + +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'); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("X C", str); + } +} + +test "Terminal: eraseChars minimum one" { + 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(0); + try t.print('X'); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("XBC", 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); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings(" A", str); + } +} + +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'); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("X BC", str); + } +} + +test "Terminal: eraseChars resets pending wrap" { + const alloc = testing.allocator; + var t = try init(alloc, 5, 5); + defer t.deinit(alloc); + + for ("ABCDE") |c| try t.print(c); + try testing.expect(t.screen.cursor.pending_wrap); + t.eraseChars(1); + try testing.expect(!t.screen.cursor.pending_wrap); + try t.print('X'); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("ABCDX", str); + } +} + +test "Terminal: eraseChars resets wrap" { + const alloc = testing.allocator; + var t = try init(alloc, 5, 5); + defer t.deinit(alloc); + + for ("ABCDE123") |c| try t.print(c); + { + const list_cell = t.screen.pages.getCell(.{ .active = .{ .x = 0, .y = 0 } }).?; + const row = list_cell.row; + try testing.expect(row.wrap); + } + + t.setCursorPos(1, 1); + t.eraseChars(1); + + { + const list_cell = t.screen.pages.getCell(.{ .active = .{ .x = 0, .y = 0 } }).?; + const row = list_cell.row; + try testing.expect(!row.wrap); + } + + try t.print('X'); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("XBCDE\n123", str); + } +} + +test "Terminal: eraseChars preserves background sgr" { + const alloc = testing.allocator; + var t = try init(alloc, 10, 10); + defer t.deinit(alloc); + + for ("ABC") |c| try t.print(c); + t.setCursorPos(1, 1); + try t.setAttribute(.{ .direct_color_bg = .{ + .r = 0xFF, + .g = 0, + .b = 0, + } }); + t.eraseChars(2); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings(" C", str); + { + const list_cell = t.screen.pages.getCell(.{ .active = .{ .x = 0, .y = 0 } }).?; + try testing.expect(list_cell.cell.content_tag == .bg_color_rgb); + try testing.expectEqual(Cell.RGB{ + .r = 0xFF, + .g = 0, + .b = 0, + }, list_cell.cell.content.color_rgb); + } + { + const list_cell = t.screen.pages.getCell(.{ .active = .{ .x = 1, .y = 0 } }).?; + try testing.expect(list_cell.cell.content_tag == .bg_color_rgb); + try testing.expectEqual(Cell.RGB{ + .r = 0xFF, + .g = 0, + .b = 0, + }, list_cell.cell.content.color_rgb); + } + } +} + +test "Terminal: eraseChars handles refcounted styles" { + const alloc = testing.allocator; + var t = try init(alloc, 10, 10); + defer t.deinit(alloc); + + try t.setAttribute(.{ .bold = {} }); + try t.print('A'); + try t.print('B'); + try t.setAttribute(.{ .unset = {} }); + try t.print('C'); + + // verify we have styles in our style map + const page = t.screen.cursor.page_pin.page.data; + try testing.expectEqual(@as(usize, 1), page.styles.count(page.memory)); + + t.setCursorPos(1, 1); + t.eraseChars(2); + + // verify we have no styles in our style map + try testing.expectEqual(@as(usize, 0), page.styles.count(page.memory)); +} + +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); + + { + const 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); + + { + const 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); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings(" C", str); + } +} + +test "Terminal: reverseIndex" { + const alloc = testing.allocator; + var t = try init(alloc, 2, 5); + defer t.deinit(alloc); + + // Initial value + try t.print('A'); + t.carriageReturn(); + try t.linefeed(); + try t.print('B'); + t.carriageReturn(); + try t.linefeed(); + try t.print('C'); + t.reverseIndex(); + try t.print('D'); + t.carriageReturn(); + try t.linefeed(); + t.carriageReturn(); + try t.linefeed(); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("A\nBD\nC", str); + } +} + +test "Terminal: reverseIndex from the top" { + const alloc = testing.allocator; + var t = try init(alloc, 2, 5); + defer t.deinit(alloc); + + try t.print('A'); + t.carriageReturn(); + try t.linefeed(); + try t.print('B'); + t.carriageReturn(); + try t.linefeed(); + t.carriageReturn(); + try t.linefeed(); + + t.setCursorPos(1, 1); + t.reverseIndex(); + try t.print('D'); + + t.carriageReturn(); + try t.linefeed(); + t.setCursorPos(1, 1); + t.reverseIndex(); + try t.print('E'); + t.carriageReturn(); + try t.linefeed(); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("E\nD\nA\nB", str); + } +} + +test "Terminal: reverseIndex top of scrolling region" { + const alloc = testing.allocator; + var t = try init(alloc, 2, 10); + defer t.deinit(alloc); + + // Initial value + t.setCursorPos(2, 1); + try t.print('A'); + t.carriageReturn(); + try t.linefeed(); + try t.print('B'); + t.carriageReturn(); + try t.linefeed(); + try t.print('C'); + t.carriageReturn(); + try t.linefeed(); + try t.print('D'); + t.carriageReturn(); + try t.linefeed(); + + // Set our scroll region + t.setTopAndBottomMargin(2, 5); + t.setCursorPos(2, 1); + t.reverseIndex(); + try t.print('X'); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("\nX\nA\nB\nC", str); + } +} + +test "Terminal: reverseIndex top of screen" { + const alloc = testing.allocator; + var t = try init(alloc, 5, 5); + defer t.deinit(alloc); + + try t.print('A'); + t.setCursorPos(2, 1); + try t.print('B'); + t.setCursorPos(3, 1); + try t.print('C'); + t.setCursorPos(1, 1); + t.reverseIndex(); + try t.print('X'); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("X\nA\nB\nC", str); + } +} + +test "Terminal: reverseIndex not top of screen" { + const alloc = testing.allocator; + var t = try init(alloc, 5, 5); + defer t.deinit(alloc); + + try t.print('A'); + t.setCursorPos(2, 1); + try t.print('B'); + t.setCursorPos(3, 1); + try t.print('C'); + t.setCursorPos(2, 1); + t.reverseIndex(); + try t.print('X'); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("X\nB\nC", str); + } +} + +test "Terminal: reverseIndex top/bottom margins" { + const alloc = testing.allocator; + var t = try init(alloc, 5, 5); + defer t.deinit(alloc); + + try t.print('A'); + t.setCursorPos(2, 1); + try t.print('B'); + t.setCursorPos(3, 1); + try t.print('C'); + t.setTopAndBottomMargin(2, 3); + t.setCursorPos(2, 1); + t.reverseIndex(); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("A\n\nB", str); + } +} + +test "Terminal: reverseIndex outside top/bottom margins" { + const alloc = testing.allocator; + var t = try init(alloc, 5, 5); + defer t.deinit(alloc); + + try t.print('A'); + t.setCursorPos(2, 1); + try t.print('B'); + t.setCursorPos(3, 1); + try t.print('C'); + t.setTopAndBottomMargin(2, 3); + t.setCursorPos(1, 1); + t.reverseIndex(); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("A\nB\nC", str); + } +} + +test "Terminal: reverseIndex left/right margins" { + const alloc = testing.allocator; + var t = try init(alloc, 5, 5); + defer t.deinit(alloc); + + try t.printString("ABC"); + t.setCursorPos(2, 1); + try t.printString("DEF"); + t.setCursorPos(3, 1); + try t.printString("GHI"); + t.modes.set(.enable_left_and_right_margin, true); + t.setLeftAndRightMargin(2, 3); + t.setCursorPos(1, 2); + t.reverseIndex(); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("A\nDBC\nGEF\n HI", str); + } +} + +test "Terminal: reverseIndex outside left/right margins" { + const alloc = testing.allocator; + var t = try init(alloc, 5, 5); + defer t.deinit(alloc); + + try t.printString("ABC"); + t.setCursorPos(2, 1); + try t.printString("DEF"); + t.setCursorPos(3, 1); + try t.printString("GHI"); + t.modes.set(.enable_left_and_right_margin, true); + t.setLeftAndRightMargin(2, 3); + t.setCursorPos(1, 1); + t.reverseIndex(); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("ABC\nDEF\nGHI", str); + } +} + +test "Terminal: index" { + const alloc = testing.allocator; + var t = try init(alloc, 2, 5); + defer t.deinit(alloc); + + try t.index(); + try t.print('A'); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("\nA", str); + } +} + +test "Terminal: index from the bottom" { + const alloc = testing.allocator; + var t = try init(alloc, 2, 5); + defer t.deinit(alloc); + + t.setCursorPos(5, 1); + try t.print('A'); + t.cursorLeft(1); // undo moving right from 'A' + try t.index(); + + try t.print('B'); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("\n\n\nA\nB", str); + } +} + +test "Terminal: index outside of scrolling region" { + const alloc = testing.allocator; + var t = try init(alloc, 2, 5); + defer t.deinit(alloc); + + try testing.expectEqual(@as(usize, 0), t.screen.cursor.y); + t.setTopAndBottomMargin(2, 5); + try t.index(); + try testing.expectEqual(@as(usize, 1), t.screen.cursor.y); +} + +test "Terminal: index from the bottom outside of scroll region" { + const alloc = testing.allocator; + var t = try init(alloc, 2, 5); + defer t.deinit(alloc); + + t.setTopAndBottomMargin(1, 2); + t.setCursorPos(5, 1); + try t.print('A'); + try t.index(); + try t.print('B'); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("\n\n\n\nAB", str); + } +} + +test "Terminal: index no scroll region, top of screen" { + const alloc = testing.allocator; + var t = try init(alloc, 5, 5); + defer t.deinit(alloc); + + try t.print('A'); + try t.index(); + try t.print('X'); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("A\n X", str); + } +} + +test "Terminal: index bottom of primary screen" { + const alloc = testing.allocator; + var t = try init(alloc, 5, 5); + defer t.deinit(alloc); + + t.setCursorPos(5, 1); + try t.print('A'); + try t.index(); + try t.print('X'); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("\n\n\nA\n X", str); + } +} + +test "Terminal: index bottom of primary screen background sgr" { + const alloc = testing.allocator; + var t = try init(alloc, 5, 5); + defer t.deinit(alloc); + + t.setCursorPos(5, 1); + try t.print('A'); + try t.setAttribute(.{ .direct_color_bg = .{ + .r = 0xFF, + .g = 0, + .b = 0, + } }); + try t.index(); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("\n\n\nA", str); + for (0..5) |x| { + const list_cell = t.screen.pages.getCell(.{ .active = .{ .x = x, .y = 4 } }).?; + try testing.expect(list_cell.cell.content_tag == .bg_color_rgb); + try testing.expectEqual(Cell.RGB{ + .r = 0xFF, + .g = 0, + .b = 0, + }, list_cell.cell.content.color_rgb); + } + } +} + +test "Terminal: index inside scroll region" { + const alloc = testing.allocator; + var t = try init(alloc, 5, 5); + defer t.deinit(alloc); + + t.setTopAndBottomMargin(1, 3); + try t.print('A'); + try t.index(); + try t.print('X'); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("A\n X", str); + } +} + +test "Terminal: index bottom of primary screen with scroll region" { + const alloc = testing.allocator; + var t = try init(alloc, 5, 5); + defer t.deinit(alloc); + + t.setTopAndBottomMargin(1, 3); + t.setCursorPos(3, 1); + try t.print('A'); + t.setCursorPos(5, 1); + try t.index(); + try t.index(); + try t.index(); + try t.print('X'); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("\n\nA\n\nX", str); + } +} + +test "Terminal: index outside left/right margin" { + const alloc = testing.allocator; + var t = try init(alloc, 10, 5); + defer t.deinit(alloc); + + t.setTopAndBottomMargin(1, 3); + t.scrolling_region.left = 3; + t.scrolling_region.right = 5; + t.setCursorPos(3, 3); + try t.print('A'); + t.setCursorPos(3, 1); + try t.index(); + try t.print('X'); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("\n\nX A", str); + } +} + +test "Terminal: index inside left/right margin" { + const alloc = testing.allocator; + var t = try init(alloc, 10, 5); + defer t.deinit(alloc); + + try t.printString("AAAAAA"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("AAAAAA"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("AAAAAA"); + t.modes.set(.enable_left_and_right_margin, true); + t.setTopAndBottomMargin(1, 3); + t.setLeftAndRightMargin(1, 3); + t.setCursorPos(3, 1); + try t.index(); + + try testing.expectEqual(@as(usize, 2), t.screen.cursor.y); + try testing.expectEqual(@as(usize, 0), t.screen.cursor.x); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("AAAAAA\nAAAAAA\n AAA", str); + } +} + +test "Terminal: index bottom of scroll region" { + const alloc = testing.allocator; + var t = try init(alloc, 5, 5); + defer t.deinit(alloc); + + t.setTopAndBottomMargin(1, 3); + t.setCursorPos(4, 1); + try t.print('B'); + t.setCursorPos(3, 1); + try t.print('A'); + try t.index(); + try t.print('X'); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("\nA\n X\nB", str); + } +} + +test "Terminal: cursorUp basic" { + const alloc = testing.allocator; + var t = try init(alloc, 5, 5); + defer t.deinit(alloc); + + t.setCursorPos(3, 1); + try t.print('A'); + t.cursorUp(10); + try t.print('X'); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings(" X\n\nA", str); + } +} + +test "Terminal: cursorUp below top scroll margin" { + const alloc = testing.allocator; + var t = try init(alloc, 5, 5); + defer t.deinit(alloc); + + t.setTopAndBottomMargin(2, 4); + t.setCursorPos(3, 1); + try t.print('A'); + t.cursorUp(5); + try t.print('X'); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("\n X\nA", str); + } +} + +test "Terminal: cursorUp above top scroll margin" { + const alloc = testing.allocator; + var t = try init(alloc, 5, 5); + defer t.deinit(alloc); + + t.setTopAndBottomMargin(3, 5); + t.setCursorPos(3, 1); + try t.print('A'); + t.setCursorPos(2, 1); + t.cursorUp(10); + try t.print('X'); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("X\n\nA", str); + } +} + +test "Terminal: cursorUp resets wrap" { + const alloc = testing.allocator; + var t = try init(alloc, 5, 5); + defer t.deinit(alloc); + + for ("ABCDE") |c| try t.print(c); + try testing.expect(t.screen.cursor.pending_wrap); + t.cursorUp(1); + try testing.expect(!t.screen.cursor.pending_wrap); + try t.print('X'); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("ABCDX", str); + } +} + +test "Terminal: cursorLeft no wrap" { + const alloc = testing.allocator; + var t = try init(alloc, 10, 5); + defer t.deinit(alloc); + + try t.print('A'); + t.carriageReturn(); + try t.linefeed(); + try t.print('B'); + t.cursorLeft(10); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("A\nB", str); + } +} + +test "Terminal: cursorLeft unsets pending wrap state" { + const alloc = testing.allocator; + var t = try init(alloc, 5, 5); + defer t.deinit(alloc); + + for ("ABCDE") |c| try t.print(c); + try testing.expect(t.screen.cursor.pending_wrap); + t.cursorLeft(1); + try testing.expect(!t.screen.cursor.pending_wrap); + try t.print('X'); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("ABCXE", str); + } +} + +test "Terminal: cursorLeft unsets pending wrap state with longer jump" { + const alloc = testing.allocator; + var t = try init(alloc, 5, 5); + defer t.deinit(alloc); + + for ("ABCDE") |c| try t.print(c); + try testing.expect(t.screen.cursor.pending_wrap); + t.cursorLeft(3); + try testing.expect(!t.screen.cursor.pending_wrap); + try t.print('X'); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("AXCDE", str); + } +} + +test "Terminal: cursorLeft reverse wrap with pending wrap state" { + const alloc = testing.allocator; + var t = try init(alloc, 5, 5); + defer t.deinit(alloc); + + t.modes.set(.wraparound, true); + t.modes.set(.reverse_wrap, true); + + for ("ABCDE") |c| try t.print(c); + try testing.expect(t.screen.cursor.pending_wrap); + t.cursorLeft(1); + try testing.expect(!t.screen.cursor.pending_wrap); + try t.print('X'); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("ABCDX", str); + } +} + +test "Terminal: cursorLeft reverse wrap extended with pending wrap state" { + const alloc = testing.allocator; + var t = try init(alloc, 5, 5); + defer t.deinit(alloc); + + t.modes.set(.wraparound, true); + t.modes.set(.reverse_wrap_extended, true); + + for ("ABCDE") |c| try t.print(c); + try testing.expect(t.screen.cursor.pending_wrap); + t.cursorLeft(1); + try testing.expect(!t.screen.cursor.pending_wrap); + try t.print('X'); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("ABCDX", str); + } +} + +test "Terminal: cursorLeft reverse wrap" { + const alloc = testing.allocator; + var t = try init(alloc, 5, 5); + defer t.deinit(alloc); + + t.modes.set(.wraparound, true); + t.modes.set(.reverse_wrap, true); + + for ("ABCDE1") |c| try t.print(c); + t.cursorLeft(2); + try t.print('X'); + try testing.expect(t.screen.cursor.pending_wrap); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("ABCDX\n1", str); + } +} + +test "Terminal: cursorLeft reverse wrap with no soft wrap" { + const alloc = testing.allocator; + var t = try init(alloc, 5, 5); + defer t.deinit(alloc); + + t.modes.set(.wraparound, true); + t.modes.set(.reverse_wrap, true); + + for ("ABCDE") |c| try t.print(c); + t.carriageReturn(); + try t.linefeed(); + try t.print('1'); + t.cursorLeft(2); + try t.print('X'); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("ABCDE\nX", str); + } +} + +test "Terminal: cursorLeft reverse wrap before left margin" { + const alloc = testing.allocator; + var t = try init(alloc, 5, 5); + defer t.deinit(alloc); + + t.modes.set(.wraparound, true); + t.modes.set(.reverse_wrap, true); + t.setTopAndBottomMargin(3, 0); + t.cursorLeft(1); + try t.print('X'); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("\n\nX", str); + } +} + +test "Terminal: cursorLeft extended reverse wrap" { + const alloc = testing.allocator; + var t = try init(alloc, 5, 5); + defer t.deinit(alloc); + + t.modes.set(.wraparound, true); + t.modes.set(.reverse_wrap_extended, true); + + for ("ABCDE") |c| try t.print(c); + t.carriageReturn(); + try t.linefeed(); + try t.print('1'); + t.cursorLeft(2); + try t.print('X'); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("ABCDX\n1", str); + } +} + +test "Terminal: cursorLeft extended reverse wrap bottom wraparound" { + const alloc = testing.allocator; + var t = try init(alloc, 5, 3); + defer t.deinit(alloc); + + t.modes.set(.wraparound, true); + t.modes.set(.reverse_wrap_extended, true); + + for ("ABCDE") |c| try t.print(c); + t.carriageReturn(); + try t.linefeed(); + try t.print('1'); + t.cursorLeft(1 + t.cols + 1); + try t.print('X'); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("ABCDE\n1\n X", str); + } +} + +test "Terminal: cursorLeft extended reverse wrap is priority if both set" { + const alloc = testing.allocator; + var t = try init(alloc, 5, 3); + defer t.deinit(alloc); + + t.modes.set(.wraparound, true); + t.modes.set(.reverse_wrap, true); + t.modes.set(.reverse_wrap_extended, true); + + for ("ABCDE") |c| try t.print(c); + t.carriageReturn(); + try t.linefeed(); + try t.print('1'); + t.cursorLeft(1 + t.cols + 1); + try t.print('X'); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("ABCDE\n1\n X", str); + } +} + +test "Terminal: cursorLeft extended reverse wrap above top scroll region" { + const alloc = testing.allocator; + var t = try init(alloc, 5, 5); + defer t.deinit(alloc); + + t.modes.set(.wraparound, true); + t.modes.set(.reverse_wrap_extended, true); + + t.setTopAndBottomMargin(3, 0); + t.setCursorPos(2, 1); + t.cursorLeft(1000); + + try testing.expectEqual(@as(usize, 0), t.screen.cursor.x); + try testing.expectEqual(@as(usize, 0), t.screen.cursor.y); +} + +test "Terminal: cursorLeft reverse wrap on first row" { + const alloc = testing.allocator; + var t = try init(alloc, 5, 5); + defer t.deinit(alloc); + + t.modes.set(.wraparound, true); + t.modes.set(.reverse_wrap, true); + + t.setTopAndBottomMargin(3, 0); + t.setCursorPos(1, 2); + t.cursorLeft(1000); + + try testing.expectEqual(@as(usize, 0), t.screen.cursor.x); + try testing.expectEqual(@as(usize, 0), t.screen.cursor.y); +} + +test "Terminal: cursorDown basic" { + const alloc = testing.allocator; + var t = try init(alloc, 5, 5); + defer t.deinit(alloc); + + try t.print('A'); + t.cursorDown(10); + try t.print('X'); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("A\n\n\n\n X", str); + } +} + +test "Terminal: cursorDown above bottom scroll margin" { + const alloc = testing.allocator; + var t = try init(alloc, 5, 5); + defer t.deinit(alloc); + + t.setTopAndBottomMargin(1, 3); + try t.print('A'); + t.cursorDown(10); + try t.print('X'); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("A\n\n X", str); + } +} + +test "Terminal: cursorDown below bottom scroll margin" { + const alloc = testing.allocator; + var t = try init(alloc, 5, 5); + defer t.deinit(alloc); + + t.setTopAndBottomMargin(1, 3); + try t.print('A'); + t.setCursorPos(4, 1); + t.cursorDown(10); + try t.print('X'); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("A\n\n\n\nX", str); + } +} + +test "Terminal: cursorDown resets wrap" { + const alloc = testing.allocator; + var t = try init(alloc, 5, 5); + defer t.deinit(alloc); + + for ("ABCDE") |c| try t.print(c); + try testing.expect(t.screen.cursor.pending_wrap); + t.cursorDown(1); + try testing.expect(!t.screen.cursor.pending_wrap); + try t.print('X'); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("ABCDE\n X", str); + } +} + +test "Terminal: cursorRight resets wrap" { + const alloc = testing.allocator; + var t = try init(alloc, 5, 5); + defer t.deinit(alloc); + + for ("ABCDE") |c| try t.print(c); + try testing.expect(t.screen.cursor.pending_wrap); + t.cursorRight(1); + try testing.expect(!t.screen.cursor.pending_wrap); + try t.print('X'); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("ABCDX", str); + } +} + +test "Terminal: cursorRight to the edge of screen" { + const alloc = testing.allocator; + var t = try init(alloc, 5, 5); + defer t.deinit(alloc); + + t.cursorRight(100); + try t.print('X'); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings(" X", str); + } +} + +test "Terminal: cursorRight left of right margin" { + const alloc = testing.allocator; + var t = try init(alloc, 5, 5); + defer t.deinit(alloc); + + t.scrolling_region.right = 2; + t.cursorRight(100); + try t.print('X'); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings(" X", str); + } +} + +test "Terminal: cursorRight right of right margin" { + const alloc = testing.allocator; + var t = try init(alloc, 5, 5); + defer t.deinit(alloc); + + t.scrolling_region.right = 2; + t.setCursorPos(1, 4); + t.cursorRight(100); + try t.print('X'); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings(" X", str); + } +} + +test "Terminal: deleteLines simple" { + const alloc = testing.allocator; + var t = try init(alloc, 5, 5); + defer t.deinit(alloc); + + try t.printString("ABC"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("DEF"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("GHI"); + t.setCursorPos(2, 2); + t.deleteLines(1); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("ABC\nGHI", str); + } +} + +test "Terminal: deleteLines colors with bg color" { + const alloc = testing.allocator; + var t = try init(alloc, 5, 5); + defer t.deinit(alloc); + + try t.printString("ABC"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("DEF"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("GHI"); + t.setCursorPos(2, 2); + + try t.setAttribute(.{ .direct_color_bg = .{ + .r = 0xFF, + .g = 0, + .b = 0, + } }); + t.deleteLines(1); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("ABC\nGHI", str); + } + + for (0..t.cols) |x| { + const list_cell = t.screen.pages.getCell(.{ .active = .{ .x = x, .y = 4 } }).?; + try testing.expect(list_cell.cell.content_tag == .bg_color_rgb); + try testing.expectEqual(Cell.RGB{ + .r = 0xFF, + .g = 0, + .b = 0, + }, list_cell.cell.content.color_rgb); + } +} + +test "Terminal: deleteLines (legacy)" { const alloc = testing.allocator; var t = try init(alloc, 80, 80); defer t.deinit(alloc); @@ -3573,7 +5435,7 @@ test "Terminal: deleteLines" { try t.print('D'); t.cursorUp(2); - try t.deleteLines(1); + t.deleteLines(1); try t.print('E'); t.carriageReturn(); @@ -3590,7 +5452,6 @@ test "Terminal: deleteLines" { } } -// X test "Terminal: deleteLines with scroll region" { const alloc = testing.allocator; var t = try init(alloc, 80, 80); @@ -3610,7 +5471,7 @@ test "Terminal: deleteLines with scroll region" { t.setTopAndBottomMargin(1, 3); t.setCursorPos(1, 1); - try t.deleteLines(1); + t.deleteLines(1); try t.print('E'); t.carriageReturn(); @@ -3647,7 +5508,7 @@ test "Terminal: deleteLines with scroll region, large count" { t.setTopAndBottomMargin(1, 3); t.setCursorPos(1, 1); - try t.deleteLines(5); + t.deleteLines(5); try t.print('E'); t.carriageReturn(); @@ -3684,7 +5545,7 @@ test "Terminal: deleteLines with scroll region, cursor outside of region" { t.setTopAndBottomMargin(1, 3); t.setCursorPos(4, 1); - try t.deleteLines(1); + t.deleteLines(1); { const str = try t.plainString(testing.allocator); @@ -3693,7 +5554,6 @@ test "Terminal: deleteLines with scroll region, cursor outside of region" { } } -// X test "Terminal: deleteLines resets wrap" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); @@ -3701,7 +5561,7 @@ test "Terminal: deleteLines resets wrap" { for ("ABCDE") |c| try t.print(c); try testing.expect(t.screen.cursor.pending_wrap); - try t.deleteLines(1); + t.deleteLines(1); try testing.expect(!t.screen.cursor.pending_wrap); try t.print('B'); @@ -3712,30 +5572,6 @@ test "Terminal: deleteLines resets wrap" { } } -// X -test "Terminal: deleteLines simple" { - const alloc = testing.allocator; - var t = try init(alloc, 5, 5); - defer t.deinit(alloc); - - try t.printString("ABC"); - t.carriageReturn(); - try t.linefeed(); - try t.printString("DEF"); - t.carriageReturn(); - try t.linefeed(); - try t.printString("GHI"); - t.setCursorPos(2, 2); - try t.deleteLines(1); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("ABC\nGHI", str); - } -} - -// X test "Terminal: deleteLines left/right scroll region" { const alloc = testing.allocator; var t = try init(alloc, 10, 10); @@ -3751,7 +5587,7 @@ test "Terminal: deleteLines left/right scroll region" { t.scrolling_region.left = 1; t.scrolling_region.right = 3; t.setCursorPos(2, 2); - try t.deleteLines(1); + t.deleteLines(1); { const str = try t.plainString(testing.allocator); @@ -3760,26 +5596,6 @@ test "Terminal: deleteLines left/right scroll region" { } } -test "Terminal: deleteLines left/right scroll region clears row wrap" { - const alloc = testing.allocator; - var t = try init(alloc, 5, 5); - defer t.deinit(alloc); - - try t.print('0'); - t.modes.set(.enable_left_and_right_margin, true); - t.setLeftAndRightMargin(2, 3); - try t.printRepeat(1000); - for (0..t.rows - 1) |y| { - const row = t.screen.getRow(.{ .active = y }); - try testing.expect(row.isWrapped()); - } - { - const row = t.screen.getRow(.{ .active = t.rows - 1 }); - try testing.expect(!row.isWrapped()); - } -} - -// X test "Terminal: deleteLines left/right scroll region from top" { const alloc = testing.allocator; var t = try init(alloc, 10, 10); @@ -3795,7 +5611,7 @@ test "Terminal: deleteLines left/right scroll region from top" { t.scrolling_region.left = 1; t.scrolling_region.right = 3; t.setCursorPos(1, 2); - try t.deleteLines(1); + t.deleteLines(1); { const str = try t.plainString(testing.allocator); @@ -3804,7 +5620,6 @@ test "Terminal: deleteLines left/right scroll region from top" { } } -// X test "Terminal: deleteLines left/right scroll region high count" { const alloc = testing.allocator; var t = try init(alloc, 10, 10); @@ -3820,7 +5635,7 @@ test "Terminal: deleteLines left/right scroll region high count" { t.scrolling_region.left = 1; t.scrolling_region.right = 3; t.setCursorPos(2, 2); - try t.deleteLines(100); + t.deleteLines(100); { const str = try t.plainString(testing.allocator); @@ -3829,708 +5644,99 @@ test "Terminal: deleteLines left/right scroll region high count" { } } -// X -test "Terminal: insertLines simple" { +test "Terminal: default style is empty" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); defer t.deinit(alloc); - try t.printString("ABC"); - t.carriageReturn(); - try t.linefeed(); - try t.printString("DEF"); - t.carriageReturn(); - try t.linefeed(); - try t.printString("GHI"); - t.setCursorPos(2, 2); - try t.insertLines(1); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("ABC\n\nDEF\nGHI", str); - } -} - -// X -test "Terminal: insertLines outside of scroll region" { - const alloc = testing.allocator; - var t = try init(alloc, 5, 5); - defer t.deinit(alloc); - - try t.printString("ABC"); - t.carriageReturn(); - try t.linefeed(); - try t.printString("DEF"); - t.carriageReturn(); - try t.linefeed(); - try t.printString("GHI"); - t.setTopAndBottomMargin(3, 4); - t.setCursorPos(2, 2); - try t.insertLines(1); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("ABC\nDEF\nGHI", str); - } -} - -// X -test "Terminal: insertLines top/bottom scroll region" { - const alloc = testing.allocator; - var t = try init(alloc, 5, 5); - defer t.deinit(alloc); - - try t.printString("ABC"); - t.carriageReturn(); - try t.linefeed(); - try t.printString("DEF"); - t.carriageReturn(); - try t.linefeed(); - try t.printString("GHI"); - t.carriageReturn(); - try t.linefeed(); - try t.printString("123"); - t.setTopAndBottomMargin(1, 3); - t.setCursorPos(2, 2); - try t.insertLines(1); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("ABC\n\nDEF\n123", str); - } -} - -// X -test "Terminal: insertLines left/right scroll region" { - const alloc = testing.allocator; - var t = try init(alloc, 10, 10); - defer t.deinit(alloc); - - try t.printString("ABC123"); - t.carriageReturn(); - try t.linefeed(); - try t.printString("DEF456"); - t.carriageReturn(); - try t.linefeed(); - try t.printString("GHI789"); - t.scrolling_region.left = 1; - t.scrolling_region.right = 3; - t.setCursorPos(2, 2); - try t.insertLines(1); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("ABC123\nD 56\nGEF489\n HI7", str); - } -} - -// X -test "Terminal: insertLines" { - const alloc = testing.allocator; - var t = try init(alloc, 2, 5); - defer t.deinit(alloc); - - // Initial value try t.print('A'); - t.carriageReturn(); - try t.linefeed(); - try t.print('B'); - t.carriageReturn(); - try t.linefeed(); - try t.print('C'); - t.carriageReturn(); - try t.linefeed(); - try t.print('D'); - t.carriageReturn(); - try t.linefeed(); - try t.print('E'); - - // Move to row 2 - t.setCursorPos(2, 1); - - // Insert two lines - try t.insertLines(2); { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("A\n\n\nB\nC", str); + const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; + const cell = list_cell.cell; + try testing.expectEqual(@as(u21, 'A'), cell.content.codepoint); + try testing.expectEqual(@as(style.Id, 0), cell.style_id); } } -// X -test "Terminal: insertLines zero" { +test "Terminal: bold style" { const alloc = testing.allocator; - var t = try init(alloc, 2, 5); + var t = try init(alloc, 5, 5); defer t.deinit(alloc); - // This should do nothing + try t.setAttribute(.{ .bold = {} }); + try t.print('A'); + + { + const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; + const cell = list_cell.cell; + try testing.expectEqual(@as(u21, 'A'), cell.content.codepoint); + try testing.expect(cell.style_id != 0); + try testing.expect(t.screen.cursor.style_ref.?.* > 0); + } +} + +test "Terminal: garbage collect overwritten" { + const alloc = testing.allocator; + var t = try init(alloc, 5, 5); + defer t.deinit(alloc); + + try t.setAttribute(.{ .bold = {} }); + try t.print('A'); t.setCursorPos(1, 1); - try t.insertLines(0); -} - -// X -test "Terminal: insertLines with scroll region" { - const alloc = testing.allocator; - var t = try init(alloc, 2, 6); - defer t.deinit(alloc); - - // Initial value - try t.print('A'); - t.carriageReturn(); - try t.linefeed(); + try t.setAttribute(.{ .unset = {} }); try t.print('B'); - t.carriageReturn(); - try t.linefeed(); - try t.print('C'); - t.carriageReturn(); - try t.linefeed(); - try t.print('D'); - t.carriageReturn(); - try t.linefeed(); - try t.print('E'); - - t.setTopAndBottomMargin(1, 2); - t.setCursorPos(1, 1); - try t.insertLines(1); - - try t.print('X'); { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("X\nA\nC\nD\nE", str); + const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; + const cell = list_cell.cell; + try testing.expectEqual(@as(u21, 'B'), cell.content.codepoint); + try testing.expect(cell.style_id == 0); } + + // verify we have no styles in our style map + const page = t.screen.cursor.page_pin.page.data; + try testing.expectEqual(@as(usize, 0), page.styles.count(page.memory)); } -// X -test "Terminal: insertLines more than remaining" { - const alloc = testing.allocator; - var t = try init(alloc, 2, 5); - defer t.deinit(alloc); - - // Initial value - try t.print('A'); - t.carriageReturn(); - try t.linefeed(); - try t.print('B'); - t.carriageReturn(); - try t.linefeed(); - try t.print('C'); - t.carriageReturn(); - try t.linefeed(); - try t.print('D'); - t.carriageReturn(); - try t.linefeed(); - try t.print('E'); - - // Move to row 2 - t.setCursorPos(2, 1); - - // Insert a bunch of lines - try t.insertLines(20); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("A", str); - } -} - -// X -test "Terminal: insertLines resets wrap" { +test "Terminal: do not garbage collect old styles in use" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); defer t.deinit(alloc); - for ("ABCDE") |c| try t.print(c); - try testing.expect(t.screen.cursor.pending_wrap); - try t.insertLines(1); - try testing.expect(!t.screen.cursor.pending_wrap); + try t.setAttribute(.{ .bold = {} }); + try t.print('A'); + try t.setAttribute(.{ .unset = {} }); try t.print('B'); { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("B\nABCDE", str); + const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 1, .y = 0 } }).?; + const cell = list_cell.cell; + try testing.expectEqual(@as(u21, 'B'), cell.content.codepoint); + try testing.expect(cell.style_id == 0); } + + // verify we have no styles in our style map + const page = t.screen.cursor.page_pin.page.data; + try testing.expectEqual(@as(usize, 1), page.styles.count(page.memory)); } -// X -test "Terminal: reverseIndex" { - const alloc = testing.allocator; - var t = try init(alloc, 2, 5); - defer t.deinit(alloc); - - // Initial value - try t.print('A'); - t.carriageReturn(); - try t.linefeed(); - try t.print('B'); - t.carriageReturn(); - try t.linefeed(); - try t.print('C'); - try t.reverseIndex(); - try t.print('D'); - t.carriageReturn(); - try t.linefeed(); - t.carriageReturn(); - try t.linefeed(); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("A\nBD\nC", str); - } -} - -// X -test "Terminal: reverseIndex from the top" { - const alloc = testing.allocator; - var t = try init(alloc, 2, 5); - defer t.deinit(alloc); - - try t.print('A'); - t.carriageReturn(); - try t.linefeed(); - try t.print('B'); - t.carriageReturn(); - try t.linefeed(); - t.carriageReturn(); - try t.linefeed(); - - t.setCursorPos(1, 1); - try t.reverseIndex(); - try t.print('D'); - - t.carriageReturn(); - try t.linefeed(); - t.setCursorPos(1, 1); - try t.reverseIndex(); - try t.print('E'); - t.carriageReturn(); - try t.linefeed(); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("E\nD\nA\nB", str); - } -} - -// X -test "Terminal: reverseIndex top of scrolling region" { - const alloc = testing.allocator; - var t = try init(alloc, 2, 10); - defer t.deinit(alloc); - - // Initial value - t.setCursorPos(2, 1); - try t.print('A'); - t.carriageReturn(); - try t.linefeed(); - try t.print('B'); - t.carriageReturn(); - try t.linefeed(); - try t.print('C'); - t.carriageReturn(); - try t.linefeed(); - try t.print('D'); - t.carriageReturn(); - try t.linefeed(); - - // Set our scroll region - t.setTopAndBottomMargin(2, 5); - t.setCursorPos(2, 1); - try t.reverseIndex(); - try t.print('X'); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("\nX\nA\nB\nC", str); - } -} - -// X -test "Terminal: reverseIndex top of screen" { +test "Terminal: print with style marks the row as styled" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); defer t.deinit(alloc); + try t.setAttribute(.{ .bold = {} }); try t.print('A'); - t.setCursorPos(2, 1); - try t.print('B'); - t.setCursorPos(3, 1); - try t.print('C'); - t.setCursorPos(1, 1); - try t.reverseIndex(); - try t.print('X'); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("X\nA\nB\nC", str); - } -} - -// X -test "Terminal: reverseIndex not top of screen" { - const alloc = testing.allocator; - var t = try init(alloc, 5, 5); - defer t.deinit(alloc); - - try t.print('A'); - t.setCursorPos(2, 1); - try t.print('B'); - t.setCursorPos(3, 1); - try t.print('C'); - t.setCursorPos(2, 1); - try t.reverseIndex(); - try t.print('X'); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("X\nB\nC", str); - } -} - -// X -test "Terminal: reverseIndex top/bottom margins" { - const alloc = testing.allocator; - var t = try init(alloc, 5, 5); - defer t.deinit(alloc); - - try t.print('A'); - t.setCursorPos(2, 1); - try t.print('B'); - t.setCursorPos(3, 1); - try t.print('C'); - t.setTopAndBottomMargin(2, 3); - t.setCursorPos(2, 1); - try t.reverseIndex(); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("A\n\nB", str); - } -} - -// X -test "Terminal: reverseIndex outside top/bottom margins" { - const alloc = testing.allocator; - var t = try init(alloc, 5, 5); - defer t.deinit(alloc); - - try t.print('A'); - t.setCursorPos(2, 1); - try t.print('B'); - t.setCursorPos(3, 1); - try t.print('C'); - t.setTopAndBottomMargin(2, 3); - t.setCursorPos(1, 1); - try t.reverseIndex(); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("A\nB\nC", str); - } -} - -// X -test "Terminal: reverseIndex left/right margins" { - const alloc = testing.allocator; - var t = try init(alloc, 5, 5); - defer t.deinit(alloc); - - try t.printString("ABC"); - t.setCursorPos(2, 1); - try t.printString("DEF"); - t.setCursorPos(3, 1); - try t.printString("GHI"); - t.modes.set(.enable_left_and_right_margin, true); - t.setLeftAndRightMargin(2, 3); - t.setCursorPos(1, 2); - try t.reverseIndex(); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("A\nDBC\nGEF\n HI", str); - } -} - -// X -test "Terminal: reverseIndex outside left/right margins" { - const alloc = testing.allocator; - var t = try init(alloc, 5, 5); - defer t.deinit(alloc); - - try t.printString("ABC"); - t.setCursorPos(2, 1); - try t.printString("DEF"); - t.setCursorPos(3, 1); - try t.printString("GHI"); - t.modes.set(.enable_left_and_right_margin, true); - t.setLeftAndRightMargin(2, 3); - t.setCursorPos(1, 1); - try t.reverseIndex(); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("ABC\nDEF\nGHI", str); - } -} - -// X -test "Terminal: index" { - const alloc = testing.allocator; - var t = try init(alloc, 2, 5); - defer t.deinit(alloc); - - try t.index(); - try t.print('A'); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("\nA", str); - } -} - -// X -test "Terminal: index from the bottom" { - const alloc = testing.allocator; - var t = try init(alloc, 2, 5); - defer t.deinit(alloc); - - t.setCursorPos(5, 1); - try t.print('A'); - t.cursorLeft(1); // undo moving right from 'A' - try t.index(); - + try t.setAttribute(.{ .unset = {} }); try t.print('B'); { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("\n\n\nA\nB", str); + const list_cell = t.screen.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; + try testing.expect(list_cell.row.styled); } } -// X -test "Terminal: index outside of scrolling region" { - const alloc = testing.allocator; - var t = try init(alloc, 2, 5); - defer t.deinit(alloc); - - try testing.expectEqual(@as(usize, 0), t.screen.cursor.y); - t.setTopAndBottomMargin(2, 5); - try t.index(); - try testing.expectEqual(@as(usize, 1), t.screen.cursor.y); -} - -// X -test "Terminal: index from the bottom outside of scroll region" { - const alloc = testing.allocator; - var t = try init(alloc, 2, 5); - defer t.deinit(alloc); - - t.setTopAndBottomMargin(1, 2); - t.setCursorPos(5, 1); - try t.print('A'); - try t.index(); - try t.print('B'); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("\n\n\n\nAB", str); - } -} - -// X -test "Terminal: index no scroll region, top of screen" { - const alloc = testing.allocator; - var t = try init(alloc, 5, 5); - defer t.deinit(alloc); - - try t.print('A'); - try t.index(); - try t.print('X'); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("A\n X", str); - } -} - -// X -test "Terminal: index bottom of primary screen" { - const alloc = testing.allocator; - var t = try init(alloc, 5, 5); - defer t.deinit(alloc); - - t.setCursorPos(5, 1); - try t.print('A'); - try t.index(); - try t.print('X'); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("\n\n\nA\n X", str); - } -} - -// X -test "Terminal: index bottom of primary screen background sgr" { - const alloc = testing.allocator; - var t = try init(alloc, 5, 5); - defer t.deinit(alloc); - - const pen: Screen.Cell = .{ - .bg = .{ .rgb = .{ .r = 0xFF, .g = 0x00, .b = 0x00 } }, - }; - - t.setCursorPos(5, 1); - try t.print('A'); - t.screen.cursor.pen = pen; - try t.index(); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("\n\n\nA", str); - for (0..5) |x| { - const cell = t.screen.getCell(.active, 4, x); - try testing.expectEqual(pen, cell); - } - } -} - -// X -test "Terminal: index inside scroll region" { - const alloc = testing.allocator; - var t = try init(alloc, 5, 5); - defer t.deinit(alloc); - - t.setTopAndBottomMargin(1, 3); - try t.print('A'); - try t.index(); - try t.print('X'); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("A\n X", str); - } -} - -// X -test "Terminal: index bottom of scroll region" { - const alloc = testing.allocator; - var t = try init(alloc, 5, 5); - defer t.deinit(alloc); - - t.setTopAndBottomMargin(1, 3); - t.setCursorPos(4, 1); - try t.print('B'); - t.setCursorPos(3, 1); - try t.print('A'); - try t.index(); - try t.print('X'); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("\nA\n X\nB", str); - } -} - -// X -test "Terminal: index bottom of primary screen with scroll region" { - const alloc = testing.allocator; - var t = try init(alloc, 5, 5); - defer t.deinit(alloc); - - t.setTopAndBottomMargin(1, 3); - t.setCursorPos(3, 1); - try t.print('A'); - t.setCursorPos(5, 1); - try t.index(); - try t.index(); - try t.index(); - try t.print('X'); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("\n\nA\n\nX", str); - } -} - -// X -test "Terminal: index outside left/right margin" { - const alloc = testing.allocator; - var t = try init(alloc, 10, 5); - defer t.deinit(alloc); - - t.setTopAndBottomMargin(1, 3); - t.scrolling_region.left = 3; - t.scrolling_region.right = 5; - t.setCursorPos(3, 3); - try t.print('A'); - t.setCursorPos(3, 1); - try t.index(); - try t.print('X'); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("\n\nX A", str); - } -} - -// X -test "Terminal: index inside left/right margin" { - const alloc = testing.allocator; - var t = try init(alloc, 10, 5); - defer t.deinit(alloc); - - try t.printString("AAAAAA"); - t.carriageReturn(); - try t.linefeed(); - try t.printString("AAAAAA"); - t.carriageReturn(); - try t.linefeed(); - try t.printString("AAAAAA"); - t.modes.set(.enable_left_and_right_margin, true); - t.setTopAndBottomMargin(1, 3); - t.setLeftAndRightMargin(1, 3); - t.setCursorPos(3, 1); - try t.index(); - - try testing.expectEqual(@as(usize, 2), t.screen.cursor.y); - try testing.expectEqual(@as(usize, 0), t.screen.cursor.x); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("AAAAAA\nAAAAAA\n AAA", str); - } -} - -// X test "Terminal: DECALN" { const alloc = testing.allocator; var t = try init(alloc, 2, 2); @@ -4553,7 +5759,6 @@ test "Terminal: DECALN" { } } -// X test "Terminal: decaln reset margins" { const alloc = testing.allocator; var t = try init(alloc, 3, 3); @@ -4563,7 +5768,7 @@ test "Terminal: decaln reset margins" { t.modes.set(.origin, true); t.setTopAndBottomMargin(2, 3); try t.decaln(); - try t.scrollDown(1); + t.scrollDown(1); { const str = try t.plainString(testing.allocator); @@ -4572,33 +5777,35 @@ test "Terminal: decaln reset margins" { } } -// X test "Terminal: decaln preserves color" { const alloc = testing.allocator; var t = try init(alloc, 3, 3); defer t.deinit(alloc); - const pen: Screen.Cell = .{ - .bg = .{ .rgb = .{ .r = 0xFF, .g = 0x00, .b = 0x00 } }, - }; - // Initial value - t.screen.cursor.pen = pen; + try t.setAttribute(.{ .direct_color_bg = .{ .r = 0xFF, .g = 0, .b = 0 } }); t.modes.set(.origin, true); t.setTopAndBottomMargin(2, 3); try t.decaln(); - try t.scrollDown(1); + t.scrollDown(1); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); try testing.expectEqualStrings("\nEEE\nEEE", str); - const cell = t.screen.getCell(.active, 0, 0); - try testing.expectEqual(pen, cell); + } + + { + const list_cell = t.screen.pages.getCell(.{ .active = .{ .x = 0, .y = 0 } }).?; + try testing.expect(list_cell.cell.content_tag == .bg_color_rgb); + try testing.expectEqual(Cell.RGB{ + .r = 0xFF, + .g = 0, + .b = 0, + }, list_cell.cell.content.color_rgb); } } -// X test "Terminal: insertBlanks" { // NOTE: this is not verified with conformance tests, so these // tests might actually be verifying wrong behavior. @@ -4609,7 +5816,6 @@ test "Terminal: insertBlanks" { try t.print('A'); try t.print('B'); try t.print('C'); - t.screen.cursor.pen.attrs.bold = true; t.setCursorPos(1, 1); t.insertBlanks(2); @@ -4617,12 +5823,9 @@ test "Terminal: insertBlanks" { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); try testing.expectEqualStrings(" ABC", str); - const cell = t.screen.getCell(.active, 0, 0); - try testing.expect(!cell.attrs.bold); } } -// X test "Terminal: insertBlanks pushes off end" { // NOTE: this is not verified with conformance tests, so these // tests might actually be verifying wrong behavior. @@ -4643,7 +5846,6 @@ test "Terminal: insertBlanks pushes off end" { } } -// X test "Terminal: insertBlanks more than size" { // NOTE: this is not verified with conformance tests, so these // tests might actually be verifying wrong behavior. @@ -4664,7 +5866,6 @@ test "Terminal: insertBlanks more than size" { } } -// X test "Terminal: insertBlanks no scroll region, fits" { const alloc = testing.allocator; var t = try init(alloc, 10, 10); @@ -4681,31 +5882,36 @@ test "Terminal: insertBlanks no scroll region, fits" { } } -// X test "Terminal: insertBlanks preserves background sgr" { const alloc = testing.allocator; var t = try init(alloc, 10, 10); defer t.deinit(alloc); - const pen: Screen.Cell = .{ - .bg = .{ .rgb = .{ .r = 0xFF, .g = 0x00, .b = 0x00 } }, - }; - for ("ABC") |c| try t.print(c); t.setCursorPos(1, 1); - t.screen.cursor.pen = pen; + try t.setAttribute(.{ .direct_color_bg = .{ + .r = 0xFF, + .g = 0, + .b = 0, + } }); t.insertBlanks(2); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); try testing.expectEqualStrings(" ABC", str); - const cell = t.screen.getCell(.active, 0, 0); - try testing.expectEqual(pen, cell); + } + { + const list_cell = t.screen.pages.getCell(.{ .active = .{ .x = 0, .y = 0 } }).?; + try testing.expect(list_cell.cell.content_tag == .bg_color_rgb); + try testing.expectEqual(Cell.RGB{ + .r = 0xFF, + .g = 0, + .b = 0, + }, list_cell.cell.content.color_rgb); } } -// X test "Terminal: insertBlanks shift off screen" { const alloc = testing.allocator; var t = try init(alloc, 5, 10); @@ -4723,7 +5929,6 @@ test "Terminal: insertBlanks shift off screen" { } } -// X test "Terminal: insertBlanks split multi-cell character" { const alloc = testing.allocator; var t = try init(alloc, 5, 10); @@ -4741,7 +5946,6 @@ test "Terminal: insertBlanks split multi-cell character" { } } -// X test "Terminal: insertBlanks inside left/right scroll region" { const alloc = testing.allocator; var t = try init(alloc, 10, 10); @@ -4762,7 +5966,6 @@ test "Terminal: insertBlanks inside left/right scroll region" { } } -// X test "Terminal: insertBlanks outside left/right scroll region" { const alloc = testing.allocator; var t = try init(alloc, 6, 10); @@ -4784,7 +5987,6 @@ test "Terminal: insertBlanks outside left/right scroll region" { } } -// X test "Terminal: insertBlanks left/right scroll region large count" { const alloc = testing.allocator; var t = try init(alloc, 10, 10); @@ -4804,7 +6006,74 @@ test "Terminal: insertBlanks left/right scroll region large count" { } } -// X +test "Terminal: insertBlanks deleting graphemes" { + const alloc = testing.allocator; + var t = try init(alloc, 5, 5); + defer t.deinit(alloc); + + // Disable grapheme clustering + t.modes.set(.grapheme_cluster, true); + + try t.printString("ABC"); + + // This is: 👨‍👩‍👧 (which may or may not render correctly) + try t.print(0x1F468); + try t.print(0x200D); + try t.print(0x1F469); + try t.print(0x200D); + try t.print(0x1F467); + + // We should have one cell with graphemes + const page = t.screen.cursor.page_pin.page.data; + try testing.expectEqual(@as(usize, 1), page.graphemeCount()); + + t.setCursorPos(1, 1); + t.insertBlanks(4); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings(" A", str); + } + + // We should have no graphemes + try testing.expectEqual(@as(usize, 0), page.graphemeCount()); +} + +test "Terminal: insertBlanks shift graphemes" { + const alloc = testing.allocator; + var t = try init(alloc, 5, 5); + defer t.deinit(alloc); + + // Disable grapheme clustering + t.modes.set(.grapheme_cluster, true); + + try t.printString("A"); + + // This is: 👨‍👩‍👧 (which may or may not render correctly) + try t.print(0x1F468); + try t.print(0x200D); + try t.print(0x1F469); + try t.print(0x200D); + try t.print(0x1F467); + + // We should have one cell with graphemes + const page = t.screen.cursor.page_pin.page.data; + try testing.expectEqual(@as(usize, 1), page.graphemeCount()); + + t.setCursorPos(1, 1); + t.insertBlanks(1); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings(" A👨‍👩‍👧", str); + } + + // We should have no graphemes + try testing.expectEqual(@as(usize, 1), page.graphemeCount()); +} + test "Terminal: insert mode with space" { const alloc = testing.allocator; var t = try init(alloc, 10, 2); @@ -4822,7 +6091,6 @@ test "Terminal: insert mode with space" { } } -// X test "Terminal: insert mode doesn't wrap pushed characters" { const alloc = testing.allocator; var t = try init(alloc, 5, 2); @@ -4840,7 +6108,6 @@ test "Terminal: insert mode doesn't wrap pushed characters" { } } -// X test "Terminal: insert mode does nothing at the end of the line" { const alloc = testing.allocator; var t = try init(alloc, 5, 2); @@ -4857,7 +6124,6 @@ test "Terminal: insert mode does nothing at the end of the line" { } } -// X test "Terminal: insert mode with wide characters" { const alloc = testing.allocator; var t = try init(alloc, 5, 2); @@ -4875,7 +6141,6 @@ test "Terminal: insert mode with wide characters" { } } -// X test "Terminal: insert mode with wide characters at end" { const alloc = testing.allocator; var t = try init(alloc, 5, 2); @@ -4892,7 +6157,6 @@ test "Terminal: insert mode with wide characters at end" { } } -// X test "Terminal: insert mode pushing off wide character" { const alloc = testing.allocator; var t = try init(alloc, 5, 2); @@ -4911,64 +6175,6 @@ test "Terminal: insert mode pushing off wide character" { } } -// X -test "Terminal: cursorIsAtPrompt" { - const alloc = testing.allocator; - var t = try init(alloc, 3, 2); - defer t.deinit(alloc); - - try testing.expect(!t.cursorIsAtPrompt()); - t.markSemanticPrompt(.prompt); - try testing.expect(t.cursorIsAtPrompt()); - - // Input is also a prompt - t.markSemanticPrompt(.input); - try testing.expect(t.cursorIsAtPrompt()); - - // Newline -- we expect we're still at a prompt if we received - // prompt stuff before. - try t.linefeed(); - try testing.expect(t.cursorIsAtPrompt()); - - // But once we say we're starting output, we're not a prompt - t.markSemanticPrompt(.command); - try testing.expect(!t.cursorIsAtPrompt()); - try t.linefeed(); - try testing.expect(!t.cursorIsAtPrompt()); - - // Until we know we're at a prompt again - try t.linefeed(); - t.markSemanticPrompt(.prompt); - try testing.expect(t.cursorIsAtPrompt()); -} - -// X -test "Terminal: cursorIsAtPrompt alternate screen" { - const alloc = testing.allocator; - var t = try init(alloc, 3, 2); - defer t.deinit(alloc); - - try testing.expect(!t.cursorIsAtPrompt()); - t.markSemanticPrompt(.prompt); - try testing.expect(t.cursorIsAtPrompt()); - - // Secondary screen is never a prompt - t.alternateScreen(alloc, .{}); - try testing.expect(!t.cursorIsAtPrompt()); - t.markSemanticPrompt(.prompt); - try testing.expect(!t.cursorIsAtPrompt()); -} - -// X -test "Terminal: print wide char with 1-column width" { - const alloc = testing.allocator; - var t = try init(alloc, 1, 2); - defer t.deinit(alloc); - - try t.print('😀'); // 0x1F600 -} - -// X test "Terminal: deleteChars" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); @@ -4977,21 +6183,14 @@ test "Terminal: deleteChars" { for ("ABCDE") |c| try t.print(c); t.setCursorPos(1, 2); - // the cells that shifted in should not have this attribute set - t.screen.cursor.pen = .{ .attrs = .{ .bold = true } }; - - try t.deleteChars(2); + t.deleteChars(2); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); try testing.expectEqualStrings("ADE", str); - - const cell = t.screen.getCell(.active, 0, 4); - try testing.expect(!cell.attrs.bold); } } -// X test "Terminal: deleteChars zero count" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); @@ -5000,7 +6199,7 @@ test "Terminal: deleteChars zero count" { for ("ABCDE") |c| try t.print(c); t.setCursorPos(1, 2); - try t.deleteChars(0); + t.deleteChars(0); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); @@ -5008,7 +6207,6 @@ test "Terminal: deleteChars zero count" { } } -// X test "Terminal: deleteChars more than half" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); @@ -5017,7 +6215,7 @@ test "Terminal: deleteChars more than half" { for ("ABCDE") |c| try t.print(c); t.setCursorPos(1, 2); - try t.deleteChars(3); + t.deleteChars(3); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); @@ -5025,7 +6223,6 @@ test "Terminal: deleteChars more than half" { } } -// X test "Terminal: deleteChars more than line width" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); @@ -5034,7 +6231,7 @@ test "Terminal: deleteChars more than line width" { for ("ABCDE") |c| try t.print(c); t.setCursorPos(1, 2); - try t.deleteChars(10); + t.deleteChars(10); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); @@ -5042,7 +6239,6 @@ test "Terminal: deleteChars more than line width" { } } -// X test "Terminal: deleteChars should shift left" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); @@ -5051,7 +6247,7 @@ test "Terminal: deleteChars should shift left" { for ("ABCDE") |c| try t.print(c); t.setCursorPos(1, 2); - try t.deleteChars(1); + t.deleteChars(1); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); @@ -5059,7 +6255,6 @@ test "Terminal: deleteChars should shift left" { } } -// X test "Terminal: deleteChars resets wrap" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); @@ -5067,7 +6262,7 @@ test "Terminal: deleteChars resets wrap" { for ("ABCDE") |c| try t.print(c); try testing.expect(t.screen.cursor.pending_wrap); - try t.deleteChars(1); + t.deleteChars(1); try testing.expect(!t.screen.cursor.pending_wrap); try t.print('X'); @@ -5078,7 +6273,6 @@ test "Terminal: deleteChars resets wrap" { } } -// X test "Terminal: deleteChars simple operation" { const alloc = testing.allocator; var t = try init(alloc, 10, 10); @@ -5086,7 +6280,7 @@ test "Terminal: deleteChars simple operation" { try t.printString("ABC123"); t.setCursorPos(1, 3); - try t.deleteChars(2); + t.deleteChars(2); { const str = try t.plainString(testing.allocator); @@ -5095,33 +6289,36 @@ test "Terminal: deleteChars simple operation" { } } -// X -test "Terminal: deleteChars background sgr" { +test "Terminal: deleteChars preserves background sgr" { const alloc = testing.allocator; var t = try init(alloc, 10, 10); defer t.deinit(alloc); - const pen: Screen.Cell = .{ - .bg = .{ .rgb = .{ .r = 0xFF, .g = 0x00, .b = 0x00 } }, - }; - - try t.printString("ABC123"); + for ("ABC123") |c| try t.print(c); t.setCursorPos(1, 3); - t.screen.cursor.pen = pen; - try t.deleteChars(2); + try t.setAttribute(.{ .direct_color_bg = .{ + .r = 0xFF, + .g = 0, + .b = 0, + } }); + t.deleteChars(2); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); try testing.expectEqualStrings("AB23", str); - for (t.cols - 2..t.cols) |x| { - const cell = t.screen.getCell(.active, 0, x); - try testing.expectEqual(pen, cell); - } + } + for (t.cols - 2..t.cols) |x| { + const list_cell = t.screen.pages.getCell(.{ .active = .{ .x = x, .y = 0 } }).?; + try testing.expect(list_cell.cell.content_tag == .bg_color_rgb); + try testing.expectEqual(Cell.RGB{ + .r = 0xFF, + .g = 0, + .b = 0, + }, list_cell.cell.content.color_rgb); } } -// X test "Terminal: deleteChars outside scroll region" { const alloc = testing.allocator; var t = try init(alloc, 6, 10); @@ -5131,7 +6328,7 @@ test "Terminal: deleteChars outside scroll region" { t.scrolling_region.left = 2; t.scrolling_region.right = 4; try testing.expect(t.screen.cursor.pending_wrap); - try t.deleteChars(2); + t.deleteChars(2); try testing.expect(t.screen.cursor.pending_wrap); { @@ -5141,7 +6338,6 @@ test "Terminal: deleteChars outside scroll region" { } } -// X test "Terminal: deleteChars inside scroll region" { const alloc = testing.allocator; var t = try init(alloc, 6, 10); @@ -5151,7 +6347,7 @@ test "Terminal: deleteChars inside scroll region" { t.scrolling_region.left = 2; t.scrolling_region.right = 4; t.setCursorPos(1, 4); - try t.deleteChars(1); + t.deleteChars(1); { const str = try t.plainString(testing.allocator); @@ -5160,7 +6356,6 @@ test "Terminal: deleteChars inside scroll region" { } } -// X test "Terminal: deleteChars split wide character" { const alloc = testing.allocator; var t = try init(alloc, 6, 10); @@ -5168,7 +6363,7 @@ test "Terminal: deleteChars split wide character" { try t.printString("A橋123"); t.setCursorPos(1, 3); - try t.deleteChars(1); + t.deleteChars(1); { const str = try t.plainString(testing.allocator); @@ -5177,7 +6372,6 @@ test "Terminal: deleteChars split wide character" { } } -// X test "Terminal: deleteChars split wide character tail" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); @@ -5186,7 +6380,7 @@ test "Terminal: deleteChars split wide character tail" { t.setCursorPos(1, t.cols - 1); try t.print(0x6A4B); // 橋 t.carriageReturn(); - try t.deleteChars(t.cols - 1); + t.deleteChars(t.cols - 1); try t.print('0'); { @@ -5196,341 +6390,54 @@ test "Terminal: deleteChars split wide character tail" { } } -// X -test "Terminal: eraseChars resets pending wrap" { - const alloc = testing.allocator; - var t = try init(alloc, 5, 5); - defer t.deinit(alloc); - - for ("ABCDE") |c| try t.print(c); - try testing.expect(t.screen.cursor.pending_wrap); - t.eraseChars(1); - try testing.expect(!t.screen.cursor.pending_wrap); - try t.print('X'); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("ABCDX", str); - } -} - -// X -test "Terminal: eraseChars resets wrap" { - const alloc = testing.allocator; - var t = try init(alloc, 5, 5); - defer t.deinit(alloc); - - for ("ABCDE123") |c| try t.print(c); - { - const row = t.screen.getRow(.{ .active = 0 }); - try testing.expect(row.isWrapped()); - } - - t.setCursorPos(1, 1); - t.eraseChars(1); - - { - const row = t.screen.getRow(.{ .active = 0 }); - try testing.expect(!row.isWrapped()); - } - try t.print('X'); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("XBCDE\n123", str); - } -} - -// X -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'); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("X C", str); - } -} - -// X -test "Terminal: eraseChars minimum one" { - 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(0); - try t.print('X'); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("XBC", str); - } -} - -// X -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); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings(" A", str); - } -} - -// X -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 = .{ .rgb = .{ .r = 0xFF, .g = 0x00, .b = 0x00 } }, - }; - - for ("ABC") |c| try t.print(c); - t.setCursorPos(1, 1); - t.screen.cursor.pen = pen; - t.eraseChars(2); - - { - const 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); - } - } -} - -// X -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'); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("X BC", str); - } -} - -// X -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); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("ABC", str); - } -} - -// X -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); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings(" C", str); - } -} - -// X -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); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings(" C", str); - } -} - -// X -// 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. -test "Terminal: resize less cols with wide char then print" { - const alloc = testing.allocator; - var t = try init(alloc, 3, 3); - defer t.deinit(alloc); - - try t.print('x'); - try t.print('😀'); // 0x1F600 - try t.resize(alloc, 2, 3); - t.setCursorPos(1, 2); - try t.print('😀'); // 0x1F600 -} - -// X -// https://github.com/mitchellh/ghostty/issues/723 -// This was found via fuzzing so its highly specific. -test "Terminal: resize with left and right margin set" { - const alloc = testing.allocator; - const cols = 70; - const rows = 23; - var t = try init(alloc, cols, rows); - defer t.deinit(alloc); - - t.modes.set(.enable_left_and_right_margin, true); - try t.print('0'); - t.modes.set(.enable_mode_3, true); - try t.resize(alloc, cols, rows); - t.setLeftAndRightMargin(2, 0); - try t.printRepeat(1850); - _ = t.modes.restore(.enable_mode_3); - try t.resize(alloc, cols, rows); -} - -// X -// https://github.com/mitchellh/ghostty/issues/1343 -test "Terminal: resize with wraparound off" { - const alloc = testing.allocator; - const cols = 4; - const rows = 2; - var t = try init(alloc, cols, rows); - defer t.deinit(alloc); - - t.modes.set(.wraparound, false); - try t.print('0'); - try t.print('1'); - try t.print('2'); - try t.print('3'); - const new_cols = 2; - try t.resize(alloc, new_cols, rows); - - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("01", str); -} - -// X -test "Terminal: resize with wraparound on" { - const alloc = testing.allocator; - const cols = 4; - const rows = 2; - var t = try init(alloc, cols, rows); - defer t.deinit(alloc); - - t.modes.set(.wraparound, true); - try t.print('0'); - try t.print('1'); - try t.print('2'); - try t.print('3'); - const new_cols = 2; - try t.resize(alloc, new_cols, rows); - - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("01\n23", str); -} - -// X test "Terminal: saveCursor" { const alloc = testing.allocator; var t = try init(alloc, 3, 3); defer t.deinit(alloc); - t.screen.cursor.pen.attrs.bold = true; + try t.setAttribute(.{ .bold = {} }); t.screen.charset.gr = .G3; t.modes.set(.origin, true); t.saveCursor(); t.screen.charset.gr = .G0; - t.screen.cursor.pen.attrs.bold = false; + try t.setAttribute(.{ .unset = {} }); t.modes.set(.origin, false); - t.restoreCursor(); - try testing.expect(t.screen.cursor.pen.attrs.bold); + try t.restoreCursor(); + try testing.expect(t.screen.cursor.style.flags.bold); try testing.expect(t.screen.charset.gr == .G3); try testing.expect(t.modes.get(.origin)); } -// X test "Terminal: saveCursor with screen change" { const alloc = testing.allocator; var t = try init(alloc, 3, 3); defer t.deinit(alloc); - t.screen.cursor.pen.attrs.bold = true; + try t.setAttribute(.{ .bold = {} }); t.screen.cursor.x = 2; t.screen.charset.gr = .G3; t.modes.set(.origin, true); - t.alternateScreen(alloc, .{ + t.alternateScreen(.{ .cursor_save = true, .clear_on_enter = true, }); // make sure our cursor and charset have come with us - try testing.expect(t.screen.cursor.pen.attrs.bold); + try testing.expect(t.screen.cursor.style.flags.bold); try testing.expect(t.screen.cursor.x == 2); try testing.expect(t.screen.charset.gr == .G3); try testing.expect(t.modes.get(.origin)); t.screen.charset.gr = .G0; - t.screen.cursor.pen.attrs.bold = false; + try t.setAttribute(.{ .reset_bold = {} }); t.modes.set(.origin, false); - t.primaryScreen(alloc, .{ + t.primaryScreen(.{ .cursor_save = true, .clear_on_enter = true, }); - try testing.expect(t.screen.cursor.pen.attrs.bold); + try testing.expect(t.screen.cursor.style.flags.bold); try testing.expect(t.screen.charset.gr == .G3); try testing.expect(t.modes.get(.origin)); } -// X test "Terminal: saveCursor position" { const alloc = testing.allocator; var t = try init(alloc, 10, 5); @@ -5541,7 +6448,7 @@ test "Terminal: saveCursor position" { t.saveCursor(); t.setCursorPos(1, 1); try t.print('B'); - t.restoreCursor(); + try t.restoreCursor(); try t.print('X'); { @@ -5551,7 +6458,6 @@ test "Terminal: saveCursor position" { } } -// X test "Terminal: saveCursor pending wrap state" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); @@ -5562,7 +6468,7 @@ test "Terminal: saveCursor pending wrap state" { t.saveCursor(); t.setCursorPos(1, 1); try t.print('B'); - t.restoreCursor(); + try t.restoreCursor(); try t.print('X'); { @@ -5572,7 +6478,6 @@ test "Terminal: saveCursor pending wrap state" { } } -// X test "Terminal: saveCursor origin mode" { const alloc = testing.allocator; var t = try init(alloc, 10, 5); @@ -5583,7 +6488,7 @@ test "Terminal: saveCursor origin mode" { t.modes.set(.enable_left_and_right_margin, true); t.setLeftAndRightMargin(3, 5); t.setTopAndBottomMargin(2, 4); - t.restoreCursor(); + try t.restoreCursor(); try t.print('X'); { @@ -5593,7 +6498,6 @@ test "Terminal: saveCursor origin mode" { } } -// X test "Terminal: saveCursor resize" { const alloc = testing.allocator; var t = try init(alloc, 10, 5); @@ -5602,7 +6506,7 @@ test "Terminal: saveCursor resize" { t.setCursorPos(1, 10); t.saveCursor(); try t.resize(alloc, 5, 5); - t.restoreCursor(); + try t.restoreCursor(); try t.print('X'); { @@ -5612,40 +6516,37 @@ test "Terminal: saveCursor resize" { } } -// X test "Terminal: saveCursor protected pen" { const alloc = testing.allocator; var t = try init(alloc, 10, 5); defer t.deinit(alloc); t.setProtectedMode(.iso); - try testing.expect(t.screen.cursor.pen.attrs.protected); + try testing.expect(t.screen.cursor.protected); t.setCursorPos(1, 10); t.saveCursor(); t.setProtectedMode(.off); - try testing.expect(!t.screen.cursor.pen.attrs.protected); - t.restoreCursor(); - try testing.expect(t.screen.cursor.pen.attrs.protected); + try testing.expect(!t.screen.cursor.protected); + try t.restoreCursor(); + try testing.expect(t.screen.cursor.protected); } -// X test "Terminal: setProtectedMode" { const alloc = testing.allocator; var t = try init(alloc, 3, 3); defer t.deinit(alloc); - try testing.expect(!t.screen.cursor.pen.attrs.protected); + try testing.expect(!t.screen.cursor.protected); t.setProtectedMode(.off); - try testing.expect(!t.screen.cursor.pen.attrs.protected); + try testing.expect(!t.screen.cursor.protected); t.setProtectedMode(.iso); - try testing.expect(t.screen.cursor.pen.attrs.protected); + try testing.expect(t.screen.cursor.protected); t.setProtectedMode(.dec); - try testing.expect(t.screen.cursor.pen.attrs.protected); + try testing.expect(t.screen.cursor.protected); t.setProtectedMode(.off); - try testing.expect(!t.screen.cursor.pen.attrs.protected); + try testing.expect(!t.screen.cursor.protected); } -// X test "Terminal: eraseLine simple erase right" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); @@ -5662,7 +6563,6 @@ test "Terminal: eraseLine simple erase right" { } } -// X test "Terminal: eraseLine resets pending wrap" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); @@ -5681,7 +6581,6 @@ test "Terminal: eraseLine resets pending wrap" { } } -// X test "Terminal: eraseLine resets wrap" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); @@ -5689,16 +6588,16 @@ test "Terminal: eraseLine resets wrap" { for ("ABCDE123") |c| try t.print(c); { - const row = t.screen.getRow(.{ .active = 0 }); - try testing.expect(row.isWrapped()); + const list_cell = t.screen.pages.getCell(.{ .active = .{ .x = 0, .y = 0 } }).?; + try testing.expect(list_cell.row.wrap); } t.setCursorPos(1, 1); t.eraseLine(.right, false); { - const row = t.screen.getRow(.{ .active = 0 }); - try testing.expect(!row.isWrapped()); + const list_cell = t.screen.pages.getCell(.{ .active = .{ .x = 0, .y = 0 } }).?; + try testing.expect(!list_cell.row.wrap); } try t.print('X'); @@ -5709,19 +6608,18 @@ test "Terminal: eraseLine resets wrap" { } } -// X test "Terminal: eraseLine right preserves background sgr" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); defer t.deinit(alloc); - const pen: Screen.Cell = .{ - .bg = .{ .rgb = .{ .r = 0xFF, .g = 0x00, .b = 0x00 } }, - }; - for ("ABCDE") |c| try t.print(c); t.setCursorPos(1, 2); - t.screen.cursor.pen = pen; + try t.setAttribute(.{ .direct_color_bg = .{ + .r = 0xFF, + .g = 0, + .b = 0, + } }); t.eraseLine(.right, false); { @@ -5729,13 +6627,17 @@ test "Terminal: eraseLine right preserves background sgr" { defer testing.allocator.free(str); try testing.expectEqualStrings("A", str); for (1..5) |x| { - const cell = t.screen.getCell(.active, 0, x); - try testing.expectEqual(pen, cell); + const list_cell = t.screen.pages.getCell(.{ .active = .{ .x = x, .y = 0 } }).?; + try testing.expect(list_cell.cell.content_tag == .bg_color_rgb); + try testing.expectEqual(Cell.RGB{ + .r = 0xFF, + .g = 0, + .b = 0, + }, list_cell.cell.content.color_rgb); } } } -// X test "Terminal: eraseLine right wide character" { const alloc = testing.allocator; var t = try init(alloc, 10, 5); @@ -5754,7 +6656,6 @@ test "Terminal: eraseLine right wide character" { } } -// X test "Terminal: eraseLine right protected attributes respected with iso" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); @@ -5772,7 +6673,6 @@ test "Terminal: eraseLine right protected attributes respected with iso" { } } -// X test "Terminal: eraseLine right protected attributes ignored with dec most recent" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); @@ -5792,7 +6692,6 @@ test "Terminal: eraseLine right protected attributes ignored with dec most recen } } -// X test "Terminal: eraseLine right protected attributes ignored with dec set" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); @@ -5810,7 +6709,6 @@ test "Terminal: eraseLine right protected attributes ignored with dec set" { } } -// X test "Terminal: eraseLine right protected requested" { const alloc = testing.allocator; var t = try init(alloc, 10, 5); @@ -5830,7 +6728,6 @@ test "Terminal: eraseLine right protected requested" { } } -// X test "Terminal: eraseLine simple erase left" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); @@ -5847,7 +6744,6 @@ test "Terminal: eraseLine simple erase left" { } } -// X test "Terminal: eraseLine left resets wrap" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); @@ -5866,19 +6762,18 @@ test "Terminal: eraseLine left resets wrap" { } } -// X test "Terminal: eraseLine left preserves background sgr" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); defer t.deinit(alloc); - const pen: Screen.Cell = .{ - .bg = .{ .rgb = .{ .r = 0xFF, .g = 0x00, .b = 0x00 } }, - }; - for ("ABCDE") |c| try t.print(c); t.setCursorPos(1, 2); - t.screen.cursor.pen = pen; + try t.setAttribute(.{ .direct_color_bg = .{ + .r = 0xFF, + .g = 0, + .b = 0, + } }); t.eraseLine(.left, false); { @@ -5886,13 +6781,17 @@ test "Terminal: eraseLine left preserves background sgr" { defer testing.allocator.free(str); try testing.expectEqualStrings(" CDE", str); for (0..2) |x| { - const cell = t.screen.getCell(.active, 0, x); - try testing.expectEqual(pen, cell); + const list_cell = t.screen.pages.getCell(.{ .active = .{ .x = x, .y = 0 } }).?; + try testing.expect(list_cell.cell.content_tag == .bg_color_rgb); + try testing.expectEqual(Cell.RGB{ + .r = 0xFF, + .g = 0, + .b = 0, + }, list_cell.cell.content.color_rgb); } } } -// X test "Terminal: eraseLine left wide character" { const alloc = testing.allocator; var t = try init(alloc, 10, 5); @@ -5911,7 +6810,6 @@ test "Terminal: eraseLine left wide character" { } } -// X test "Terminal: eraseLine left protected attributes respected with iso" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); @@ -5929,7 +6827,6 @@ test "Terminal: eraseLine left protected attributes respected with iso" { } } -// X test "Terminal: eraseLine left protected attributes ignored with dec most recent" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); @@ -5949,7 +6846,6 @@ test "Terminal: eraseLine left protected attributes ignored with dec most recent } } -// X test "Terminal: eraseLine left protected attributes ignored with dec set" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); @@ -5967,7 +6863,6 @@ test "Terminal: eraseLine left protected attributes ignored with dec set" { } } -// X test "Terminal: eraseLine left protected requested" { const alloc = testing.allocator; var t = try init(alloc, 10, 5); @@ -5987,19 +6882,18 @@ test "Terminal: eraseLine left protected requested" { } } -// X test "Terminal: eraseLine complete preserves background sgr" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); defer t.deinit(alloc); - const pen: Screen.Cell = .{ - .bg = .{ .rgb = .{ .r = 0xFF, .g = 0x00, .b = 0x00 } }, - }; - for ("ABCDE") |c| try t.print(c); t.setCursorPos(1, 2); - t.screen.cursor.pen = pen; + try t.setAttribute(.{ .direct_color_bg = .{ + .r = 0xFF, + .g = 0, + .b = 0, + } }); t.eraseLine(.complete, false); { @@ -6007,13 +6901,17 @@ test "Terminal: eraseLine complete preserves background sgr" { defer testing.allocator.free(str); try testing.expectEqualStrings("", str); for (0..5) |x| { - const cell = t.screen.getCell(.active, 0, x); - try testing.expectEqual(pen, cell); + const list_cell = t.screen.pages.getCell(.{ .active = .{ .x = x, .y = 0 } }).?; + try testing.expect(list_cell.cell.content_tag == .bg_color_rgb); + try testing.expectEqual(Cell.RGB{ + .r = 0xFF, + .g = 0, + .b = 0, + }, list_cell.cell.content.color_rgb); } } } -// X test "Terminal: eraseLine complete protected attributes respected with iso" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); @@ -6031,7 +6929,6 @@ test "Terminal: eraseLine complete protected attributes respected with iso" { } } -// X test "Terminal: eraseLine complete protected attributes ignored with dec most recent" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); @@ -6051,7 +6948,6 @@ test "Terminal: eraseLine complete protected attributes ignored with dec most re } } -// X test "Terminal: eraseLine complete protected attributes ignored with dec set" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); @@ -6069,7 +6965,6 @@ test "Terminal: eraseLine complete protected attributes ignored with dec set" { } } -// X test "Terminal: eraseLine complete protected requested" { const alloc = testing.allocator; var t = try init(alloc, 10, 5); @@ -6089,1319 +6984,6 @@ test "Terminal: eraseLine complete protected requested" { } } -// X -test "Terminal: eraseDisplay simple erase below" { - const alloc = testing.allocator; - var t = try init(alloc, 5, 5); - defer t.deinit(alloc); - - for ("ABC") |c| try t.print(c); - t.carriageReturn(); - try t.linefeed(); - for ("DEF") |c| try t.print(c); - t.carriageReturn(); - try t.linefeed(); - for ("GHI") |c| try t.print(c); - t.setCursorPos(2, 2); - t.eraseDisplay(alloc, .below, false); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("ABC\nD", str); - } -} - -// X -test "Terminal: eraseDisplay erase below preserves SGR bg" { - const alloc = testing.allocator; - var t = try init(alloc, 5, 5); - defer t.deinit(alloc); - - for ("ABC") |c| try t.print(c); - t.carriageReturn(); - try t.linefeed(); - for ("DEF") |c| try t.print(c); - t.carriageReturn(); - try t.linefeed(); - for ("GHI") |c| try t.print(c); - t.setCursorPos(2, 2); - - const pen: Screen.Cell = .{ - .bg = .{ .rgb = .{ .r = 0xFF, .g = 0x00, .b = 0x00 } }, - }; - - t.screen.cursor.pen = pen; - t.eraseDisplay(alloc, .below, false); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("ABC\nD", str); - for (1..5) |x| { - const cell = t.screen.getCell(.active, 1, x); - try testing.expectEqual(pen, cell); - } - } -} - -// X -test "Terminal: eraseDisplay below split multi-cell" { - const alloc = testing.allocator; - var t = try init(alloc, 5, 5); - defer t.deinit(alloc); - - try t.printString("AB橋C"); - t.carriageReturn(); - try t.linefeed(); - try t.printString("DE橋F"); - t.carriageReturn(); - try t.linefeed(); - try t.printString("GH橋I"); - t.setCursorPos(2, 4); - t.eraseDisplay(alloc, .below, false); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("AB橋C\nDE", str); - } -} - -// X -test "Terminal: eraseDisplay below 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.carriageReturn(); - try t.linefeed(); - for ("DEF") |c| try t.print(c); - t.carriageReturn(); - try t.linefeed(); - for ("GHI") |c| try t.print(c); - t.setCursorPos(2, 2); - t.eraseDisplay(alloc, .below, false); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("ABC\nDEF\nGHI", str); - } -} - -// X -test "Terminal: eraseDisplay below 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.carriageReturn(); - try t.linefeed(); - for ("DEF") |c| try t.print(c); - t.carriageReturn(); - try t.linefeed(); - for ("GHI") |c| try t.print(c); - t.setProtectedMode(.dec); - t.setProtectedMode(.off); - t.setCursorPos(2, 2); - t.eraseDisplay(alloc, .below, false); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("ABC\nD", str); - } -} - -// X -test "Terminal: eraseDisplay below 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.carriageReturn(); - try t.linefeed(); - for ("DEF") |c| try t.print(c); - t.carriageReturn(); - try t.linefeed(); - for ("GHI") |c| try t.print(c); - t.setCursorPos(2, 2); - t.eraseDisplay(alloc, .below, false); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("ABC\nD", str); - } -} - -// X -test "Terminal: eraseDisplay simple erase above" { - const alloc = testing.allocator; - var t = try init(alloc, 5, 5); - defer t.deinit(alloc); - - for ("ABC") |c| try t.print(c); - t.carriageReturn(); - try t.linefeed(); - for ("DEF") |c| try t.print(c); - t.carriageReturn(); - try t.linefeed(); - for ("GHI") |c| try t.print(c); - t.setCursorPos(2, 2); - t.eraseDisplay(alloc, .above, false); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("\n F\nGHI", str); - } -} - -// X -test "Terminal: eraseDisplay below protected attributes respected with force" { - 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.carriageReturn(); - try t.linefeed(); - for ("DEF") |c| try t.print(c); - t.carriageReturn(); - try t.linefeed(); - for ("GHI") |c| try t.print(c); - t.setCursorPos(2, 2); - t.eraseDisplay(alloc, .below, true); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("ABC\nDEF\nGHI", str); - } -} - -// X -test "Terminal: eraseDisplay erase above preserves SGR bg" { - const alloc = testing.allocator; - var t = try init(alloc, 5, 5); - defer t.deinit(alloc); - - for ("ABC") |c| try t.print(c); - t.carriageReturn(); - try t.linefeed(); - for ("DEF") |c| try t.print(c); - t.carriageReturn(); - try t.linefeed(); - for ("GHI") |c| try t.print(c); - t.setCursorPos(2, 2); - - const pen: Screen.Cell = .{ - .bg = .{ .rgb = .{ .r = 0xFF, .g = 0x00, .b = 0x00 } }, - }; - - t.screen.cursor.pen = pen; - t.eraseDisplay(alloc, .above, false); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("\n F\nGHI", str); - for (0..2) |x| { - const cell = t.screen.getCell(.active, 1, x); - try testing.expectEqual(pen, cell); - } - } -} - -// X -test "Terminal: eraseDisplay above split multi-cell" { - const alloc = testing.allocator; - var t = try init(alloc, 5, 5); - defer t.deinit(alloc); - - try t.printString("AB橋C"); - t.carriageReturn(); - try t.linefeed(); - try t.printString("DE橋F"); - t.carriageReturn(); - try t.linefeed(); - try t.printString("GH橋I"); - t.setCursorPos(2, 3); - t.eraseDisplay(alloc, .above, false); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("\n F\nGH橋I", str); - } -} - -// X -test "Terminal: eraseDisplay above 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.carriageReturn(); - try t.linefeed(); - for ("DEF") |c| try t.print(c); - t.carriageReturn(); - try t.linefeed(); - for ("GHI") |c| try t.print(c); - t.setCursorPos(2, 2); - t.eraseDisplay(alloc, .above, false); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("ABC\nDEF\nGHI", str); - } -} - -// X -test "Terminal: eraseDisplay above 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.carriageReturn(); - try t.linefeed(); - for ("DEF") |c| try t.print(c); - t.carriageReturn(); - try t.linefeed(); - for ("GHI") |c| try t.print(c); - t.setProtectedMode(.dec); - t.setProtectedMode(.off); - t.setCursorPos(2, 2); - t.eraseDisplay(alloc, .above, false); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("\n F\nGHI", str); - } -} - -// X -test "Terminal: eraseDisplay above 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.carriageReturn(); - try t.linefeed(); - for ("DEF") |c| try t.print(c); - t.carriageReturn(); - try t.linefeed(); - for ("GHI") |c| try t.print(c); - t.setCursorPos(2, 2); - t.eraseDisplay(alloc, .above, false); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("\n F\nGHI", str); - } -} - -// X -test "Terminal: eraseDisplay above protected attributes respected with force" { - 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.carriageReturn(); - try t.linefeed(); - for ("DEF") |c| try t.print(c); - t.carriageReturn(); - try t.linefeed(); - for ("GHI") |c| try t.print(c); - t.setCursorPos(2, 2); - t.eraseDisplay(alloc, .above, true); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("ABC\nDEF\nGHI", str); - } -} - -// X -test "Terminal: eraseDisplay above" { - var t = try init(testing.allocator, 80, 80); - defer t.deinit(testing.allocator); - - const pink = color.RGB{ .r = 0xFF, .g = 0x00, .b = 0x7F }; - t.screen.cursor.pen = Screen.Cell{ - .char = 'a', - .bg = .{ .rgb = pink }, - .fg = .{ .rgb = pink }, - .attrs = .{ .bold = true }, - }; - const cell_ptr = t.screen.getCellPtr(.active, 0, 0); - cell_ptr.* = t.screen.cursor.pen; - // verify the cell was set - var cell = t.screen.getCell(.active, 0, 0); - try testing.expect(cell.bg.rgb.eql(pink)); - try testing.expect(cell.fg.rgb.eql(pink)); - try testing.expect(cell.char == 'a'); - try testing.expect(cell.attrs.bold); - // move the cursor below it - t.screen.cursor.y = 40; - t.screen.cursor.x = 40; - // erase above the cursor - t.eraseDisplay(testing.allocator, .above, false); - // check it was erased - cell = t.screen.getCell(.active, 0, 0); - try testing.expect(cell.bg.rgb.eql(pink)); - try testing.expect(cell.fg == .none); - try testing.expect(cell.char == 0); - try testing.expect(!cell.attrs.bold); - - // Check that our pen hasn't changed - try testing.expect(t.screen.cursor.pen.attrs.bold); - - // check that another cell got the correct bg - cell = t.screen.getCell(.active, 0, 1); - try testing.expect(cell.bg.rgb.eql(pink)); -} - -// X -test "Terminal: eraseDisplay below" { - var t = try init(testing.allocator, 80, 80); - defer t.deinit(testing.allocator); - - const pink = color.RGB{ .r = 0xFF, .g = 0x00, .b = 0x7F }; - t.screen.cursor.pen = Screen.Cell{ - .char = 'a', - .bg = .{ .rgb = pink }, - .fg = .{ .rgb = pink }, - .attrs = .{ .bold = true }, - }; - const cell_ptr = t.screen.getCellPtr(.active, 60, 60); - cell_ptr.* = t.screen.cursor.pen; - // verify the cell was set - var cell = t.screen.getCell(.active, 60, 60); - try testing.expect(cell.bg.rgb.eql(pink)); - try testing.expect(cell.fg.rgb.eql(pink)); - try testing.expect(cell.char == 'a'); - try testing.expect(cell.attrs.bold); - // erase below the cursor - t.eraseDisplay(testing.allocator, .below, false); - // check it was erased - cell = t.screen.getCell(.active, 60, 60); - try testing.expect(cell.bg.rgb.eql(pink)); - try testing.expect(cell.fg == .none); - try testing.expect(cell.char == 0); - try testing.expect(!cell.attrs.bold); - - // check that another cell got the correct bg - cell = t.screen.getCell(.active, 0, 1); - try testing.expect(cell.bg.rgb.eql(pink)); -} - -// X -test "Terminal: eraseDisplay complete" { - var t = try init(testing.allocator, 80, 80); - defer t.deinit(testing.allocator); - - const pink = color.RGB{ .r = 0xFF, .g = 0x00, .b = 0x7F }; - t.screen.cursor.pen = Screen.Cell{ - .char = 'a', - .bg = .{ .rgb = pink }, - .fg = .{ .rgb = pink }, - .attrs = .{ .bold = true }, - }; - var cell_ptr = t.screen.getCellPtr(.active, 60, 60); - cell_ptr.* = t.screen.cursor.pen; - cell_ptr = t.screen.getCellPtr(.active, 0, 0); - cell_ptr.* = t.screen.cursor.pen; - // verify the cell was set - var cell = t.screen.getCell(.active, 60, 60); - try testing.expect(cell.bg.rgb.eql(pink)); - try testing.expect(cell.fg.rgb.eql(pink)); - try testing.expect(cell.char == 'a'); - try testing.expect(cell.attrs.bold); - // verify the cell was set - cell = t.screen.getCell(.active, 0, 0); - try testing.expect(cell.bg.rgb.eql(pink)); - try testing.expect(cell.fg.rgb.eql(pink)); - try testing.expect(cell.char == 'a'); - try testing.expect(cell.attrs.bold); - // position our cursor between the cells - t.screen.cursor.y = 30; - // erase everything - t.eraseDisplay(testing.allocator, .complete, false); - // check they were erased - cell = t.screen.getCell(.active, 60, 60); - try testing.expect(cell.bg.rgb.eql(pink)); - try testing.expect(cell.fg == .none); - try testing.expect(cell.char == 0); - try testing.expect(!cell.attrs.bold); - cell = t.screen.getCell(.active, 0, 0); - try testing.expect(cell.bg.rgb.eql(pink)); - try testing.expect(cell.fg == .none); - try testing.expect(cell.char == 0); - try testing.expect(!cell.attrs.bold); -} - -// X -test "Terminal: eraseDisplay protected complete" { - const alloc = testing.allocator; - var t = try init(alloc, 10, 5); - defer t.deinit(alloc); - - try t.print('A'); - t.carriageReturn(); - try t.linefeed(); - for ("123456789") |c| try t.print(c); - t.setCursorPos(t.screen.cursor.y + 1, 6); - t.setProtectedMode(.dec); - try t.print('X'); - t.setCursorPos(t.screen.cursor.y + 1, 4); - t.eraseDisplay(alloc, .complete, true); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("\n X", str); - } -} - -// X -test "Terminal: eraseDisplay protected below" { - const alloc = testing.allocator; - var t = try init(alloc, 10, 5); - defer t.deinit(alloc); - - try t.print('A'); - t.carriageReturn(); - try t.linefeed(); - for ("123456789") |c| try t.print(c); - t.setCursorPos(t.screen.cursor.y + 1, 6); - t.setProtectedMode(.dec); - try t.print('X'); - t.setCursorPos(t.screen.cursor.y + 1, 4); - t.eraseDisplay(alloc, .below, true); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("A\n123 X", str); - } -} - -// X -test "Terminal: eraseDisplay protected above" { - const alloc = testing.allocator; - var t = try init(alloc, 10, 5); - defer t.deinit(alloc); - - try t.print('A'); - t.carriageReturn(); - try t.linefeed(); - t.eraseDisplay(alloc, .scroll_complete, false); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("", str); - } -} - -// X -test "Terminal: eraseDisplay scroll complete" { - const alloc = testing.allocator; - var t = try init(alloc, 10, 3); - defer t.deinit(alloc); - - try t.print('A'); - t.carriageReturn(); - try t.linefeed(); - for ("123456789") |c| try t.print(c); - t.setCursorPos(t.screen.cursor.y + 1, 6); - t.setProtectedMode(.dec); - try t.print('X'); - t.setCursorPos(t.screen.cursor.y + 1, 8); - t.eraseDisplay(alloc, .above, true); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("\n X 9", str); - } -} - -// X -test "Terminal: cursorLeft no wrap" { - const alloc = testing.allocator; - var t = try init(alloc, 10, 5); - defer t.deinit(alloc); - - try t.print('A'); - t.carriageReturn(); - try t.linefeed(); - try t.print('B'); - t.cursorLeft(10); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("A\nB", str); - } -} - -// X -test "Terminal: cursorLeft unsets pending wrap state" { - const alloc = testing.allocator; - var t = try init(alloc, 5, 5); - defer t.deinit(alloc); - - for ("ABCDE") |c| try t.print(c); - try testing.expect(t.screen.cursor.pending_wrap); - t.cursorLeft(1); - try testing.expect(!t.screen.cursor.pending_wrap); - try t.print('X'); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("ABCXE", str); - } -} - -// X -test "Terminal: cursorLeft unsets pending wrap state with longer jump" { - const alloc = testing.allocator; - var t = try init(alloc, 5, 5); - defer t.deinit(alloc); - - for ("ABCDE") |c| try t.print(c); - try testing.expect(t.screen.cursor.pending_wrap); - t.cursorLeft(3); - try testing.expect(!t.screen.cursor.pending_wrap); - try t.print('X'); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("AXCDE", str); - } -} - -// X -test "Terminal: cursorLeft reverse wrap with pending wrap state" { - const alloc = testing.allocator; - var t = try init(alloc, 5, 5); - defer t.deinit(alloc); - - t.modes.set(.wraparound, true); - t.modes.set(.reverse_wrap, true); - - for ("ABCDE") |c| try t.print(c); - try testing.expect(t.screen.cursor.pending_wrap); - t.cursorLeft(1); - try testing.expect(!t.screen.cursor.pending_wrap); - try t.print('X'); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("ABCDX", str); - } -} - -// X -test "Terminal: cursorLeft reverse wrap extended with pending wrap state" { - const alloc = testing.allocator; - var t = try init(alloc, 5, 5); - defer t.deinit(alloc); - - t.modes.set(.wraparound, true); - t.modes.set(.reverse_wrap_extended, true); - - for ("ABCDE") |c| try t.print(c); - try testing.expect(t.screen.cursor.pending_wrap); - t.cursorLeft(1); - try testing.expect(!t.screen.cursor.pending_wrap); - try t.print('X'); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("ABCDX", str); - } -} - -// X -test "Terminal: cursorLeft reverse wrap" { - const alloc = testing.allocator; - var t = try init(alloc, 5, 5); - defer t.deinit(alloc); - - t.modes.set(.wraparound, true); - t.modes.set(.reverse_wrap, true); - - for ("ABCDE1") |c| try t.print(c); - t.cursorLeft(2); - try t.print('X'); - try testing.expect(t.screen.cursor.pending_wrap); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("ABCDX\n1", str); - } -} - -// X -test "Terminal: cursorLeft reverse wrap with no soft wrap" { - const alloc = testing.allocator; - var t = try init(alloc, 5, 5); - defer t.deinit(alloc); - - t.modes.set(.wraparound, true); - t.modes.set(.reverse_wrap, true); - - for ("ABCDE") |c| try t.print(c); - t.carriageReturn(); - try t.linefeed(); - try t.print('1'); - t.cursorLeft(2); - try t.print('X'); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("ABCDE\nX", str); - } -} - -// X -test "Terminal: cursorLeft reverse wrap before left margin" { - const alloc = testing.allocator; - var t = try init(alloc, 5, 5); - defer t.deinit(alloc); - - t.modes.set(.wraparound, true); - t.modes.set(.reverse_wrap, true); - t.setTopAndBottomMargin(3, 0); - t.cursorLeft(1); - try t.print('X'); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("\n\nX", str); - } -} - -// X -test "Terminal: cursorLeft extended reverse wrap" { - const alloc = testing.allocator; - var t = try init(alloc, 5, 5); - defer t.deinit(alloc); - - t.modes.set(.wraparound, true); - t.modes.set(.reverse_wrap_extended, true); - - for ("ABCDE") |c| try t.print(c); - t.carriageReturn(); - try t.linefeed(); - try t.print('1'); - t.cursorLeft(2); - try t.print('X'); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("ABCDX\n1", str); - } -} - -// X -test "Terminal: cursorLeft extended reverse wrap bottom wraparound" { - const alloc = testing.allocator; - var t = try init(alloc, 5, 3); - defer t.deinit(alloc); - - t.modes.set(.wraparound, true); - t.modes.set(.reverse_wrap_extended, true); - - for ("ABCDE") |c| try t.print(c); - t.carriageReturn(); - try t.linefeed(); - try t.print('1'); - t.cursorLeft(1 + t.cols + 1); - try t.print('X'); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("ABCDE\n1\n X", str); - } -} - -// X -test "Terminal: cursorLeft extended reverse wrap is priority if both set" { - const alloc = testing.allocator; - var t = try init(alloc, 5, 3); - defer t.deinit(alloc); - - t.modes.set(.wraparound, true); - t.modes.set(.reverse_wrap, true); - t.modes.set(.reverse_wrap_extended, true); - - for ("ABCDE") |c| try t.print(c); - t.carriageReturn(); - try t.linefeed(); - try t.print('1'); - t.cursorLeft(1 + t.cols + 1); - try t.print('X'); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("ABCDE\n1\n X", str); - } -} - -// X -test "Terminal: cursorLeft extended reverse wrap above top scroll region" { - const alloc = testing.allocator; - var t = try init(alloc, 5, 5); - defer t.deinit(alloc); - - t.modes.set(.wraparound, true); - t.modes.set(.reverse_wrap_extended, true); - - t.setTopAndBottomMargin(3, 0); - t.setCursorPos(2, 1); - t.cursorLeft(1000); - - try testing.expectEqual(@as(usize, 0), t.screen.cursor.x); - try testing.expectEqual(@as(usize, 0), t.screen.cursor.y); -} - -// X -test "Terminal: cursorLeft reverse wrap on first row" { - const alloc = testing.allocator; - var t = try init(alloc, 5, 5); - defer t.deinit(alloc); - - t.modes.set(.wraparound, true); - t.modes.set(.reverse_wrap, true); - - t.setTopAndBottomMargin(3, 0); - t.setCursorPos(1, 2); - t.cursorLeft(1000); - - try testing.expectEqual(@as(usize, 0), t.screen.cursor.x); - try testing.expectEqual(@as(usize, 0), t.screen.cursor.y); -} - -// X -test "Terminal: cursorDown basic" { - const alloc = testing.allocator; - var t = try init(alloc, 5, 5); - defer t.deinit(alloc); - - try t.print('A'); - t.cursorDown(10); - try t.print('X'); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("A\n\n\n\n X", str); - } -} - -// X -test "Terminal: cursorDown above bottom scroll margin" { - const alloc = testing.allocator; - var t = try init(alloc, 5, 5); - defer t.deinit(alloc); - - t.setTopAndBottomMargin(1, 3); - try t.print('A'); - t.cursorDown(10); - try t.print('X'); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("A\n\n X", str); - } -} - -// X -test "Terminal: cursorDown below bottom scroll margin" { - const alloc = testing.allocator; - var t = try init(alloc, 5, 5); - defer t.deinit(alloc); - - t.setTopAndBottomMargin(1, 3); - try t.print('A'); - t.setCursorPos(4, 1); - t.cursorDown(10); - try t.print('X'); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("A\n\n\n\nX", str); - } -} - -// X -test "Terminal: cursorDown resets wrap" { - const alloc = testing.allocator; - var t = try init(alloc, 5, 5); - defer t.deinit(alloc); - - for ("ABCDE") |c| try t.print(c); - try testing.expect(t.screen.cursor.pending_wrap); - t.cursorDown(1); - try testing.expect(!t.screen.cursor.pending_wrap); - try t.print('X'); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("ABCDE\n X", str); - } -} - -// X -test "Terminal: cursorUp basic" { - const alloc = testing.allocator; - var t = try init(alloc, 5, 5); - defer t.deinit(alloc); - - t.setCursorPos(3, 1); - try t.print('A'); - t.cursorUp(10); - try t.print('X'); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings(" X\n\nA", str); - } -} - -// X -test "Terminal: cursorUp below top scroll margin" { - const alloc = testing.allocator; - var t = try init(alloc, 5, 5); - defer t.deinit(alloc); - - t.setTopAndBottomMargin(2, 4); - t.setCursorPos(3, 1); - try t.print('A'); - t.cursorUp(5); - try t.print('X'); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("\n X\nA", str); - } -} - -// X -test "Terminal: cursorUp above top scroll margin" { - const alloc = testing.allocator; - var t = try init(alloc, 5, 5); - defer t.deinit(alloc); - - t.setTopAndBottomMargin(3, 5); - t.setCursorPos(3, 1); - try t.print('A'); - t.setCursorPos(2, 1); - t.cursorUp(10); - try t.print('X'); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("X\n\nA", str); - } -} - -// X -test "Terminal: cursorUp resets wrap" { - const alloc = testing.allocator; - var t = try init(alloc, 5, 5); - defer t.deinit(alloc); - - for ("ABCDE") |c| try t.print(c); - try testing.expect(t.screen.cursor.pending_wrap); - t.cursorUp(1); - try testing.expect(!t.screen.cursor.pending_wrap); - try t.print('X'); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("ABCDX", str); - } -} - -// X -test "Terminal: cursorRight resets wrap" { - const alloc = testing.allocator; - var t = try init(alloc, 5, 5); - defer t.deinit(alloc); - - for ("ABCDE") |c| try t.print(c); - try testing.expect(t.screen.cursor.pending_wrap); - t.cursorRight(1); - try testing.expect(!t.screen.cursor.pending_wrap); - try t.print('X'); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("ABCDX", str); - } -} - -// X -test "Terminal: cursorRight to the edge of screen" { - const alloc = testing.allocator; - var t = try init(alloc, 5, 5); - defer t.deinit(alloc); - - t.cursorRight(100); - try t.print('X'); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings(" X", str); - } -} - -// X -test "Terminal: cursorRight left of right margin" { - const alloc = testing.allocator; - var t = try init(alloc, 5, 5); - defer t.deinit(alloc); - - t.scrolling_region.right = 2; - t.cursorRight(100); - try t.print('X'); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings(" X", str); - } -} - -// X -test "Terminal: cursorRight right of right margin" { - const alloc = testing.allocator; - var t = try init(alloc, 5, 5); - defer t.deinit(alloc); - - t.scrolling_region.right = 2; - t.screen.cursor.x = 3; - t.cursorRight(100); - try t.print('X'); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings(" X", str); - } -} - -// X -test "Terminal: scrollDown simple" { - const alloc = testing.allocator; - var t = try init(alloc, 5, 5); - defer t.deinit(alloc); - - try t.printString("ABC"); - t.carriageReturn(); - try t.linefeed(); - try t.printString("DEF"); - t.carriageReturn(); - try t.linefeed(); - try t.printString("GHI"); - t.setCursorPos(2, 2); - const cursor = t.screen.cursor; - try t.scrollDown(1); - try testing.expectEqual(cursor, t.screen.cursor); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("\nABC\nDEF\nGHI", str); - } -} - -// X -test "Terminal: scrollDown outside of scroll region" { - const alloc = testing.allocator; - var t = try init(alloc, 5, 5); - defer t.deinit(alloc); - - try t.printString("ABC"); - t.carriageReturn(); - try t.linefeed(); - try t.printString("DEF"); - t.carriageReturn(); - try t.linefeed(); - try t.printString("GHI"); - t.setTopAndBottomMargin(3, 4); - t.setCursorPos(2, 2); - const cursor = t.screen.cursor; - try t.scrollDown(1); - try testing.expectEqual(cursor, t.screen.cursor); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("ABC\nDEF\n\nGHI", str); - } -} - -// X -test "Terminal: scrollDown left/right scroll region" { - const alloc = testing.allocator; - var t = try init(alloc, 10, 10); - defer t.deinit(alloc); - - try t.printString("ABC123"); - t.carriageReturn(); - try t.linefeed(); - try t.printString("DEF456"); - t.carriageReturn(); - try t.linefeed(); - try t.printString("GHI789"); - t.scrolling_region.left = 1; - t.scrolling_region.right = 3; - t.setCursorPos(2, 2); - const cursor = t.screen.cursor; - try t.scrollDown(1); - try testing.expectEqual(cursor, t.screen.cursor); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("A 23\nDBC156\nGEF489\n HI7", str); - } -} - -// X -test "Terminal: scrollDown outside of left/right scroll region" { - const alloc = testing.allocator; - var t = try init(alloc, 10, 10); - defer t.deinit(alloc); - - try t.printString("ABC123"); - t.carriageReturn(); - try t.linefeed(); - try t.printString("DEF456"); - t.carriageReturn(); - try t.linefeed(); - try t.printString("GHI789"); - t.scrolling_region.left = 1; - t.scrolling_region.right = 3; - t.setCursorPos(1, 1); - const cursor = t.screen.cursor; - try t.scrollDown(1); - try testing.expectEqual(cursor, t.screen.cursor); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("A 23\nDBC156\nGEF489\n HI7", str); - } -} - -// X -test "Terminal: scrollDown preserves pending wrap" { - const alloc = testing.allocator; - var t = try init(alloc, 5, 10); - defer t.deinit(alloc); - - t.setCursorPos(1, 5); - try t.print('A'); - t.setCursorPos(2, 5); - try t.print('B'); - t.setCursorPos(3, 5); - try t.print('C'); - try t.scrollDown(1); - try t.print('X'); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("\n A\n B\nX C", str); - } -} - -// X -test "Terminal: scrollUp simple" { - const alloc = testing.allocator; - var t = try init(alloc, 5, 5); - defer t.deinit(alloc); - - try t.printString("ABC"); - t.carriageReturn(); - try t.linefeed(); - try t.printString("DEF"); - t.carriageReturn(); - try t.linefeed(); - try t.printString("GHI"); - t.setCursorPos(2, 2); - const cursor = t.screen.cursor; - try t.scrollUp(1); - try testing.expectEqual(cursor, t.screen.cursor); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("DEF\nGHI", str); - } -} - -// X -test "Terminal: scrollUp top/bottom scroll region" { - const alloc = testing.allocator; - var t = try init(alloc, 5, 5); - defer t.deinit(alloc); - - try t.printString("ABC"); - t.carriageReturn(); - try t.linefeed(); - try t.printString("DEF"); - t.carriageReturn(); - try t.linefeed(); - try t.printString("GHI"); - t.setTopAndBottomMargin(2, 3); - t.setCursorPos(1, 1); - try t.scrollUp(1); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("ABC\nGHI", str); - } -} - -// X -test "Terminal: scrollUp left/right scroll region" { - const alloc = testing.allocator; - var t = try init(alloc, 10, 10); - defer t.deinit(alloc); - - try t.printString("ABC123"); - t.carriageReturn(); - try t.linefeed(); - try t.printString("DEF456"); - t.carriageReturn(); - try t.linefeed(); - try t.printString("GHI789"); - t.scrolling_region.left = 1; - t.scrolling_region.right = 3; - t.setCursorPos(2, 2); - const cursor = t.screen.cursor; - try t.scrollUp(1); - try testing.expectEqual(cursor, t.screen.cursor); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("AEF423\nDHI756\nG 89", str); - } -} - -// X -test "Terminal: scrollUp preserves pending wrap" { - const alloc = testing.allocator; - var t = try init(alloc, 5, 5); - defer t.deinit(alloc); - - t.setCursorPos(1, 5); - try t.print('A'); - t.setCursorPos(2, 5); - try t.print('B'); - t.setCursorPos(3, 5); - try t.print('C'); - try t.scrollUp(1); - try t.print('X'); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings(" B\n C\n\nX", str); - } -} - -// X -test "Terminal: scrollUp full top/bottom region" { - const alloc = testing.allocator; - var t = try init(alloc, 5, 5); - defer t.deinit(alloc); - - try t.printString("top"); - t.setCursorPos(5, 1); - try t.printString("ABCDE"); - t.setTopAndBottomMargin(2, 5); - try t.scrollUp(4); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("top", str); - } -} - -// X -test "Terminal: scrollUp full top/bottomleft/right scroll region" { - const alloc = testing.allocator; - var t = try init(alloc, 5, 5); - defer t.deinit(alloc); - - try t.printString("top"); - t.setCursorPos(5, 1); - try t.printString("ABCDE"); - t.modes.set(.enable_left_and_right_margin, true); - t.setTopAndBottomMargin(2, 5); - t.setLeftAndRightMargin(2, 4); - try t.scrollUp(4); - - { - const str = try t.plainString(testing.allocator); - defer testing.allocator.free(str); - try testing.expectEqualStrings("top\n\n\n\nA E", str); - } -} - -// X test "Terminal: tabClear single" { const alloc = testing.allocator; var t = try init(alloc, 30, 5); @@ -7414,7 +6996,6 @@ test "Terminal: tabClear single" { try testing.expectEqual(@as(usize, 16), t.screen.cursor.x); } -// X test "Terminal: tabClear all" { const alloc = testing.allocator; var t = try init(alloc, 30, 5); @@ -7426,7 +7007,6 @@ test "Terminal: tabClear all" { try testing.expectEqual(@as(usize, 29), t.screen.cursor.x); } -// X test "Terminal: printRepeat simple" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); @@ -7442,7 +7022,6 @@ test "Terminal: printRepeat simple" { } } -// X test "Terminal: printRepeat wrap" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); @@ -7458,7 +7037,6 @@ test "Terminal: printRepeat wrap" { } } -// X test "Terminal: printRepeat no previous character" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); @@ -7473,88 +7051,6 @@ test "Terminal: printRepeat no previous character" { } } -// X -test "Terminal: DECCOLM without DEC mode 40" { - const alloc = testing.allocator; - var t = try init(alloc, 5, 5); - defer t.deinit(alloc); - - t.modes.set(.@"132_column", true); - try t.deccolm(alloc, .@"132_cols"); - try testing.expectEqual(@as(usize, 5), t.cols); - try testing.expectEqual(@as(usize, 5), t.rows); - try testing.expect(!t.modes.get(.@"132_column")); -} - -// X -test "Terminal: DECCOLM unset" { - const alloc = testing.allocator; - var t = try init(alloc, 5, 5); - defer t.deinit(alloc); - - t.modes.set(.enable_mode_3, true); - try t.deccolm(alloc, .@"80_cols"); - try testing.expectEqual(@as(usize, 80), t.cols); - try testing.expectEqual(@as(usize, 5), t.rows); -} - -// X -test "Terminal: DECCOLM resets pending wrap" { - const alloc = testing.allocator; - var t = try init(alloc, 5, 5); - defer t.deinit(alloc); - - for ("ABCDE") |c| try t.print(c); - try testing.expect(t.screen.cursor.pending_wrap); - - t.modes.set(.enable_mode_3, true); - try t.deccolm(alloc, .@"80_cols"); - try testing.expectEqual(@as(usize, 80), t.cols); - try testing.expectEqual(@as(usize, 5), t.rows); - try testing.expect(!t.screen.cursor.pending_wrap); -} - -// X -test "Terminal: DECCOLM preserves SGR bg" { - const alloc = testing.allocator; - var t = try init(alloc, 5, 5); - defer t.deinit(alloc); - - const pen: Screen.Cell = .{ - .bg = .{ .rgb = .{ .r = 0xFF, .g = 0x00, .b = 0x00 } }, - }; - - t.screen.cursor.pen = pen; - t.modes.set(.enable_mode_3, true); - try t.deccolm(alloc, .@"80_cols"); - - { - const cell = t.screen.getCell(.active, 0, 0); - try testing.expectEqual(pen, cell); - } -} - -// X -test "Terminal: DECCOLM resets scroll region" { - const alloc = testing.allocator; - var t = try init(alloc, 5, 5); - defer t.deinit(alloc); - - t.modes.set(.enable_left_and_right_margin, true); - t.setTopAndBottomMargin(2, 3); - t.setLeftAndRightMargin(3, 5); - - t.modes.set(.enable_mode_3, true); - try t.deccolm(alloc, .@"80_cols"); - - try testing.expect(t.modes.get(.enable_left_and_right_margin)); - try testing.expectEqual(@as(usize, 0), t.scrolling_region.top); - try testing.expectEqual(@as(usize, 4), t.scrolling_region.bottom); - try testing.expectEqual(@as(usize, 0), t.scrolling_region.left); - try testing.expectEqual(@as(usize, 79), t.scrolling_region.right); -} - -// X test "Terminal: printAttributes" { const alloc = testing.allocator; var t = try init(alloc, 5, 5); @@ -7606,27 +7102,678 @@ test "Terminal: printAttributes" { } } -test "Terminal: preserve grapheme cluster on large scrollback" { +test "Terminal: eraseDisplay simple erase below" { const alloc = testing.allocator; - var t = try init(alloc, 5, 3); + var t = try init(alloc, 5, 5); defer t.deinit(alloc); - // This is the label emoji + the VS16 variant selector - const label = "\u{1F3F7}\u{FE0F}"; + for ("ABC") |c| try t.print(c); + t.carriageReturn(); + try t.linefeed(); + for ("DEF") |c| try t.print(c); + t.carriageReturn(); + try t.linefeed(); + for ("GHI") |c| try t.print(c); + t.setCursorPos(2, 2); + t.eraseDisplay(.below, false); - // This bug required a certain behavior around scrollback interacting - // with the circular buffer that we use at the time of writing this test. - // Mainly, we want to verify that in certain scroll scenarios we preserve - // grapheme clusters. This test is admittedly somewhat brittle but we - // should keep it around to prevent this regression. - for (0..t.screen.max_scrollback * 2) |_| { - try t.printString(label ++ "\n"); - } - - try t.scrollViewport(.{ .delta = -1 }); { - const str = try t.screen.testString(alloc, .viewport); + const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("🏷️\n🏷️\n🏷️", str); + try testing.expectEqualStrings("ABC\nD", str); } } + +test "Terminal: eraseDisplay erase below preserves SGR bg" { + const alloc = testing.allocator; + var t = try init(alloc, 5, 5); + defer t.deinit(alloc); + + for ("ABC") |c| try t.print(c); + t.carriageReturn(); + try t.linefeed(); + for ("DEF") |c| try t.print(c); + t.carriageReturn(); + try t.linefeed(); + for ("GHI") |c| try t.print(c); + t.setCursorPos(2, 2); + + try t.setAttribute(.{ .direct_color_bg = .{ + .r = 0xFF, + .g = 0, + .b = 0, + } }); + t.eraseDisplay(.below, false); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("ABC\nD", str); + for (1..5) |x| { + const list_cell = t.screen.pages.getCell(.{ .active = .{ .x = x, .y = 1 } }).?; + try testing.expect(list_cell.cell.content_tag == .bg_color_rgb); + try testing.expectEqual(Cell.RGB{ + .r = 0xFF, + .g = 0, + .b = 0, + }, list_cell.cell.content.color_rgb); + } + } +} + +test "Terminal: eraseDisplay below split multi-cell" { + const alloc = testing.allocator; + var t = try init(alloc, 5, 5); + defer t.deinit(alloc); + + try t.printString("AB橋C"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("DE橋F"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("GH橋I"); + t.setCursorPos(2, 4); + t.eraseDisplay(.below, false); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("AB橋C\nDE", str); + } +} + +test "Terminal: eraseDisplay below 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.carriageReturn(); + try t.linefeed(); + for ("DEF") |c| try t.print(c); + t.carriageReturn(); + try t.linefeed(); + for ("GHI") |c| try t.print(c); + t.setCursorPos(2, 2); + t.eraseDisplay(.below, false); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("ABC\nDEF\nGHI", str); + } +} + +test "Terminal: eraseDisplay below 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.carriageReturn(); + try t.linefeed(); + for ("DEF") |c| try t.print(c); + t.carriageReturn(); + try t.linefeed(); + for ("GHI") |c| try t.print(c); + t.setProtectedMode(.dec); + t.setProtectedMode(.off); + t.setCursorPos(2, 2); + t.eraseDisplay(.below, false); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("ABC\nD", str); + } +} + +test "Terminal: eraseDisplay below 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.carriageReturn(); + try t.linefeed(); + for ("DEF") |c| try t.print(c); + t.carriageReturn(); + try t.linefeed(); + for ("GHI") |c| try t.print(c); + t.setCursorPos(2, 2); + t.eraseDisplay(.below, false); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("ABC\nD", str); + } +} + +test "Terminal: eraseDisplay below protected attributes respected with force" { + 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.carriageReturn(); + try t.linefeed(); + for ("DEF") |c| try t.print(c); + t.carriageReturn(); + try t.linefeed(); + for ("GHI") |c| try t.print(c); + t.setCursorPos(2, 2); + t.eraseDisplay(.below, true); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("ABC\nDEF\nGHI", str); + } +} + +test "Terminal: eraseDisplay simple erase above" { + const alloc = testing.allocator; + var t = try init(alloc, 5, 5); + defer t.deinit(alloc); + + for ("ABC") |c| try t.print(c); + t.carriageReturn(); + try t.linefeed(); + for ("DEF") |c| try t.print(c); + t.carriageReturn(); + try t.linefeed(); + for ("GHI") |c| try t.print(c); + t.setCursorPos(2, 2); + t.eraseDisplay(.above, false); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("\n F\nGHI", str); + } +} + +test "Terminal: eraseDisplay erase above preserves SGR bg" { + const alloc = testing.allocator; + var t = try init(alloc, 5, 5); + defer t.deinit(alloc); + + for ("ABC") |c| try t.print(c); + t.carriageReturn(); + try t.linefeed(); + for ("DEF") |c| try t.print(c); + t.carriageReturn(); + try t.linefeed(); + for ("GHI") |c| try t.print(c); + t.setCursorPos(2, 2); + + try t.setAttribute(.{ .direct_color_bg = .{ + .r = 0xFF, + .g = 0, + .b = 0, + } }); + t.eraseDisplay(.above, false); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("\n F\nGHI", str); + for (0..2) |x| { + const list_cell = t.screen.pages.getCell(.{ .active = .{ .x = x, .y = 1 } }).?; + try testing.expect(list_cell.cell.content_tag == .bg_color_rgb); + try testing.expectEqual(Cell.RGB{ + .r = 0xFF, + .g = 0, + .b = 0, + }, list_cell.cell.content.color_rgb); + } + } +} + +test "Terminal: eraseDisplay above split multi-cell" { + const alloc = testing.allocator; + var t = try init(alloc, 5, 5); + defer t.deinit(alloc); + + try t.printString("AB橋C"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("DE橋F"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("GH橋I"); + t.setCursorPos(2, 3); + t.eraseDisplay(.above, false); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("\n F\nGH橋I", str); + } +} + +test "Terminal: eraseDisplay above 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.carriageReturn(); + try t.linefeed(); + for ("DEF") |c| try t.print(c); + t.carriageReturn(); + try t.linefeed(); + for ("GHI") |c| try t.print(c); + t.setCursorPos(2, 2); + t.eraseDisplay(.above, false); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("ABC\nDEF\nGHI", str); + } +} + +test "Terminal: eraseDisplay above 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.carriageReturn(); + try t.linefeed(); + for ("DEF") |c| try t.print(c); + t.carriageReturn(); + try t.linefeed(); + for ("GHI") |c| try t.print(c); + t.setProtectedMode(.dec); + t.setProtectedMode(.off); + t.setCursorPos(2, 2); + t.eraseDisplay(.above, false); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("\n F\nGHI", str); + } +} + +test "Terminal: eraseDisplay above 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.carriageReturn(); + try t.linefeed(); + for ("DEF") |c| try t.print(c); + t.carriageReturn(); + try t.linefeed(); + for ("GHI") |c| try t.print(c); + t.setCursorPos(2, 2); + t.eraseDisplay(.above, false); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("\n F\nGHI", str); + } +} + +test "Terminal: eraseDisplay above protected attributes respected with force" { + 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.carriageReturn(); + try t.linefeed(); + for ("DEF") |c| try t.print(c); + t.carriageReturn(); + try t.linefeed(); + for ("GHI") |c| try t.print(c); + t.setCursorPos(2, 2); + t.eraseDisplay(.above, true); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("ABC\nDEF\nGHI", str); + } +} + +test "Terminal: eraseDisplay protected complete" { + const alloc = testing.allocator; + var t = try init(alloc, 10, 5); + defer t.deinit(alloc); + + try t.print('A'); + t.carriageReturn(); + try t.linefeed(); + for ("123456789") |c| try t.print(c); + t.setCursorPos(t.screen.cursor.y + 1, 6); + t.setProtectedMode(.dec); + try t.print('X'); + t.setCursorPos(t.screen.cursor.y + 1, 4); + t.eraseDisplay(.complete, true); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("\n X", str); + } +} + +test "Terminal: eraseDisplay protected below" { + const alloc = testing.allocator; + var t = try init(alloc, 10, 5); + defer t.deinit(alloc); + + try t.print('A'); + t.carriageReturn(); + try t.linefeed(); + for ("123456789") |c| try t.print(c); + t.setCursorPos(t.screen.cursor.y + 1, 6); + t.setProtectedMode(.dec); + try t.print('X'); + t.setCursorPos(t.screen.cursor.y + 1, 4); + t.eraseDisplay(.below, true); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("A\n123 X", str); + } +} + +test "Terminal: eraseDisplay scroll complete" { + const alloc = testing.allocator; + var t = try init(alloc, 10, 5); + defer t.deinit(alloc); + + try t.print('A'); + t.carriageReturn(); + try t.linefeed(); + t.eraseDisplay(.scroll_complete, false); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("", str); + } +} + +test "Terminal: eraseDisplay protected above" { + const alloc = testing.allocator; + var t = try init(alloc, 10, 3); + defer t.deinit(alloc); + + try t.print('A'); + t.carriageReturn(); + try t.linefeed(); + for ("123456789") |c| try t.print(c); + t.setCursorPos(t.screen.cursor.y + 1, 6); + t.setProtectedMode(.dec); + try t.print('X'); + t.setCursorPos(t.screen.cursor.y + 1, 8); + t.eraseDisplay(.above, true); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("\n X 9", str); + } +} + +test "Terminal: cursorIsAtPrompt" { + const alloc = testing.allocator; + var t = try init(alloc, 3, 2); + defer t.deinit(alloc); + + try testing.expect(!t.cursorIsAtPrompt()); + t.markSemanticPrompt(.prompt); + try testing.expect(t.cursorIsAtPrompt()); + + // Input is also a prompt + t.markSemanticPrompt(.input); + try testing.expect(t.cursorIsAtPrompt()); + + // Newline -- we expect we're still at a prompt if we received + // prompt stuff before. + try t.linefeed(); + try testing.expect(t.cursorIsAtPrompt()); + + // But once we say we're starting output, we're not a prompt + t.markSemanticPrompt(.command); + try testing.expect(!t.cursorIsAtPrompt()); + try t.linefeed(); + try testing.expect(!t.cursorIsAtPrompt()); + + // Until we know we're at a prompt again + try t.linefeed(); + t.markSemanticPrompt(.prompt); + try testing.expect(t.cursorIsAtPrompt()); +} + +test "Terminal: cursorIsAtPrompt alternate screen" { + const alloc = testing.allocator; + var t = try init(alloc, 3, 2); + defer t.deinit(alloc); + + try testing.expect(!t.cursorIsAtPrompt()); + t.markSemanticPrompt(.prompt); + try testing.expect(t.cursorIsAtPrompt()); + + // Secondary screen is never a prompt + t.alternateScreen(.{}); + try testing.expect(!t.cursorIsAtPrompt()); + t.markSemanticPrompt(.prompt); + try testing.expect(!t.cursorIsAtPrompt()); +} + +test "Terminal: fullReset with a non-empty pen" { + var t = try init(testing.allocator, 80, 80); + defer t.deinit(testing.allocator); + + try t.setAttribute(.{ .direct_color_fg = .{ .r = 0xFF, .g = 0, .b = 0x7F } }); + try t.setAttribute(.{ .direct_color_bg = .{ .r = 0xFF, .g = 0, .b = 0x7F } }); + t.fullReset(); + + { + const list_cell = t.screen.pages.getCell(.{ .active = .{ + .x = t.screen.cursor.x, + .y = t.screen.cursor.y, + } }).?; + const cell = list_cell.cell; + try testing.expect(cell.style_id == 0); + } +} + +test "Terminal: fullReset origin mode" { + var t = try init(testing.allocator, 10, 10); + defer t.deinit(testing.allocator); + + t.setCursorPos(3, 5); + t.modes.set(.origin, true); + t.fullReset(); + + // Origin mode should be reset and the cursor should be moved + try testing.expectEqual(@as(usize, 0), t.screen.cursor.y); + try testing.expectEqual(@as(usize, 0), t.screen.cursor.x); + try testing.expect(!t.modes.get(.origin)); +} + +test "Terminal: fullReset status display" { + var t = try init(testing.allocator, 10, 10); + defer t.deinit(testing.allocator); + + t.status_display = .status_line; + t.fullReset(); + try testing.expect(t.status_display == .main); +} + +// 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. +test "Terminal: resize less cols with wide char then print" { + const alloc = testing.allocator; + var t = try init(alloc, 3, 3); + defer t.deinit(alloc); + + try t.print('x'); + try t.print('😀'); // 0x1F600 + try t.resize(alloc, 2, 3); + t.setCursorPos(1, 2); + try t.print('😀'); // 0x1F600 +} + +// https://github.com/mitchellh/ghostty/issues/723 +// This was found via fuzzing so its highly specific. +test "Terminal: resize with left and right margin set" { + const alloc = testing.allocator; + const cols = 70; + const rows = 23; + var t = try init(alloc, cols, rows); + defer t.deinit(alloc); + + t.modes.set(.enable_left_and_right_margin, true); + try t.print('0'); + t.modes.set(.enable_mode_3, true); + try t.resize(alloc, cols, rows); + t.setLeftAndRightMargin(2, 0); + try t.printRepeat(1850); + _ = t.modes.restore(.enable_mode_3); + try t.resize(alloc, cols, rows); +} + +// https://github.com/mitchellh/ghostty/issues/1343 +test "Terminal: resize with wraparound off" { + const alloc = testing.allocator; + const cols = 4; + const rows = 2; + var t = try init(alloc, cols, rows); + defer t.deinit(alloc); + + t.modes.set(.wraparound, false); + try t.print('0'); + try t.print('1'); + try t.print('2'); + try t.print('3'); + const new_cols = 2; + try t.resize(alloc, new_cols, rows); + + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("01", str); +} + +test "Terminal: resize with wraparound on" { + const alloc = testing.allocator; + const cols = 4; + const rows = 2; + var t = try init(alloc, cols, rows); + defer t.deinit(alloc); + + t.modes.set(.wraparound, true); + try t.print('0'); + try t.print('1'); + try t.print('2'); + try t.print('3'); + const new_cols = 2; + try t.resize(alloc, new_cols, rows); + + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("01\n23", str); +} + +test "Terminal: DECCOLM without DEC mode 40" { + const alloc = testing.allocator; + var t = try init(alloc, 5, 5); + defer t.deinit(alloc); + + t.modes.set(.@"132_column", true); + try t.deccolm(alloc, .@"132_cols"); + try testing.expectEqual(@as(usize, 5), t.cols); + try testing.expectEqual(@as(usize, 5), t.rows); + try testing.expect(!t.modes.get(.@"132_column")); +} + +test "Terminal: DECCOLM unset" { + const alloc = testing.allocator; + var t = try init(alloc, 5, 5); + defer t.deinit(alloc); + + t.modes.set(.enable_mode_3, true); + try t.deccolm(alloc, .@"80_cols"); + try testing.expectEqual(@as(usize, 80), t.cols); + try testing.expectEqual(@as(usize, 5), t.rows); +} + +test "Terminal: DECCOLM resets pending wrap" { + const alloc = testing.allocator; + var t = try init(alloc, 5, 5); + defer t.deinit(alloc); + + for ("ABCDE") |c| try t.print(c); + try testing.expect(t.screen.cursor.pending_wrap); + + t.modes.set(.enable_mode_3, true); + try t.deccolm(alloc, .@"80_cols"); + try testing.expectEqual(@as(usize, 80), t.cols); + try testing.expectEqual(@as(usize, 5), t.rows); + try testing.expect(!t.screen.cursor.pending_wrap); +} + +test "Terminal: DECCOLM preserves SGR bg" { + const alloc = testing.allocator; + var t = try init(alloc, 5, 5); + defer t.deinit(alloc); + + try t.setAttribute(.{ .direct_color_bg = .{ + .r = 0xFF, + .g = 0, + .b = 0, + } }); + t.modes.set(.enable_mode_3, true); + try t.deccolm(alloc, .@"80_cols"); + + { + const list_cell = t.screen.pages.getCell(.{ .active = .{ .x = 0, .y = 0 } }).?; + try testing.expect(list_cell.cell.content_tag == .bg_color_rgb); + try testing.expectEqual(Cell.RGB{ + .r = 0xFF, + .g = 0, + .b = 0, + }, list_cell.cell.content.color_rgb); + } +} + +test "Terminal: DECCOLM resets scroll region" { + const alloc = testing.allocator; + var t = try init(alloc, 5, 5); + defer t.deinit(alloc); + + t.modes.set(.enable_left_and_right_margin, true); + t.setTopAndBottomMargin(2, 3); + t.setLeftAndRightMargin(3, 5); + + t.modes.set(.enable_mode_3, true); + try t.deccolm(alloc, .@"80_cols"); + + try testing.expect(t.modes.get(.enable_left_and_right_margin)); + try testing.expectEqual(@as(usize, 0), t.scrolling_region.top); + try testing.expectEqual(@as(usize, 4), t.scrolling_region.bottom); + try testing.expectEqual(@as(usize, 0), t.scrolling_region.left); + try testing.expectEqual(@as(usize, 79), t.scrolling_region.right); +} diff --git a/src/terminal2/bitmap_allocator.zig b/src/terminal/bitmap_allocator.zig similarity index 100% rename from src/terminal2/bitmap_allocator.zig rename to src/terminal/bitmap_allocator.zig diff --git a/src/terminal2/hash_map.zig b/src/terminal/hash_map.zig similarity index 100% rename from src/terminal2/hash_map.zig rename to src/terminal/hash_map.zig diff --git a/src/terminal/kitty/graphics_image.zig b/src/terminal/kitty/graphics_image.zig index 067217eaa..4f3e3e48f 100644 --- a/src/terminal/kitty/graphics_image.zig +++ b/src/terminal/kitty/graphics_image.zig @@ -7,6 +7,7 @@ const posix = std.posix; const command = @import("graphics_command.zig"); const point = @import("../point.zig"); +const PageList = @import("../PageList.zig"); const internal_os = @import("../../os/main.zig"); const stb = @import("../../stb/main.zig"); @@ -452,16 +453,8 @@ pub const Image = struct { /// be rounded up to the nearest grid cell since we can't place images /// in partial grid cells. pub const Rect = struct { - top_left: point.ScreenPoint = .{}, - bottom_right: point.ScreenPoint = .{}, - - /// True if the rect contains a given screen point. - pub fn contains(self: Rect, p: point.ScreenPoint) bool { - return p.y >= self.top_left.y and - p.y <= self.bottom_right.y and - p.x >= self.top_left.x and - p.x <= self.bottom_right.x; - } + top_left: PageList.Pin, + bottom_right: PageList.Pin, }; /// Easy base64 encoding function. diff --git a/src/terminal/kitty/graphics_storage.zig b/src/terminal/kitty/graphics_storage.zig index 6e4efc55b..bde44074b 100644 --- a/src/terminal/kitty/graphics_storage.zig +++ b/src/terminal/kitty/graphics_storage.zig @@ -6,12 +6,12 @@ const ArenaAllocator = std.heap.ArenaAllocator; const terminal = @import("../main.zig"); const point = @import("../point.zig"); const command = @import("graphics_command.zig"); +const PageList = @import("../PageList.zig"); const Screen = @import("../Screen.zig"); const LoadingImage = @import("graphics_image.zig").LoadingImage; const Image = @import("graphics_image.zig").Image; const Rect = @import("graphics_image.zig").Rect; const Command = command.Command; -const ScreenPoint = point.ScreenPoint; const log = std.log.scoped(.kitty_gfx); @@ -53,13 +53,18 @@ pub const ImageStorage = struct { total_bytes: usize = 0, total_limit: usize = 320 * 1000 * 1000, // 320MB - pub fn deinit(self: *ImageStorage, alloc: Allocator) void { + pub fn deinit( + self: *ImageStorage, + alloc: Allocator, + s: *terminal.Screen, + ) void { if (self.loading) |loading| loading.destroy(alloc); var it = self.images.iterator(); while (it.next()) |kv| kv.value_ptr.deinit(alloc); self.images.deinit(alloc); + self.clearPlacements(s); self.placements.deinit(alloc); } @@ -170,6 +175,12 @@ pub const ImageStorage = struct { self.dirty = true; } + fn clearPlacements(self: *ImageStorage, s: *terminal.Screen) void { + var it = self.placements.iterator(); + while (it.next()) |entry| entry.value_ptr.deinit(s); + self.placements.clearRetainingCapacity(); + } + /// Get an image by its ID. If the image doesn't exist, null is returned. pub fn imageById(self: *const ImageStorage, image_id: u32) ?Image { return self.images.get(image_id); @@ -197,19 +208,20 @@ pub const ImageStorage = struct { pub fn delete( self: *ImageStorage, alloc: Allocator, - t: *const terminal.Terminal, + t: *terminal.Terminal, cmd: command.Delete, ) void { switch (cmd) { .all => |delete_images| if (delete_images) { // We just reset our entire state. - self.deinit(alloc); + self.deinit(alloc, &t.screen); self.* = .{ .dirty = true, .total_limit = self.total_limit, }; } else { // Delete all our placements + self.clearPlacements(&t.screen); self.placements.deinit(alloc); self.placements = .{}; self.dirty = true; @@ -217,6 +229,7 @@ pub const ImageStorage = struct { .id => |v| self.deleteById( alloc, + &t.screen, v.image_id, v.placement_id, v.delete, @@ -224,29 +237,59 @@ pub const ImageStorage = struct { .newest => |v| newest: { const img = self.imageByNumber(v.image_number) orelse break :newest; - self.deleteById(alloc, img.id, v.placement_id, v.delete); + self.deleteById( + alloc, + &t.screen, + img.id, + v.placement_id, + v.delete, + ); }, .intersect_cursor => |delete_images| { - const target = (point.Viewport{ - .x = t.screen.cursor.x, - .y = t.screen.cursor.y, - }).toScreen(&t.screen); - self.deleteIntersecting(alloc, t, target, delete_images, {}, null); + self.deleteIntersecting( + alloc, + t, + .{ .active = .{ + .x = t.screen.cursor.x, + .y = t.screen.cursor.y, + } }, + delete_images, + {}, + null, + ); }, .intersect_cell => |v| { - const target = (point.Viewport{ .x = v.x, .y = v.y }).toScreen(&t.screen); - self.deleteIntersecting(alloc, t, target, v.delete, {}, null); + self.deleteIntersecting( + alloc, + t, + .{ .active = .{ + .x = v.x, + .y = v.y, + } }, + v.delete, + {}, + null, + ); }, .intersect_cell_z => |v| { - const target = (point.Viewport{ .x = v.x, .y = v.y }).toScreen(&t.screen); - self.deleteIntersecting(alloc, t, target, v.delete, v.z, struct { - fn filter(ctx: i32, p: Placement) bool { - return p.z == ctx; - } - }.filter); + self.deleteIntersecting( + alloc, + t, + .{ .active = .{ + .x = v.x, + .y = v.y, + } }, + v.delete, + v.z, + struct { + fn filter(ctx: i32, p: Placement) bool { + return p.z == ctx; + } + }.filter, + ); }, .column => |v| { @@ -255,6 +298,7 @@ pub const ImageStorage = struct { const img = self.imageById(entry.key_ptr.image_id) orelse continue; const rect = entry.value_ptr.rect(img, t); if (rect.top_left.x <= v.x and rect.bottom_right.x >= v.x) { + entry.value_ptr.deinit(&t.screen); self.placements.removeByPtr(entry.key_ptr); if (v.delete) self.deleteIfUnused(alloc, img.id); } @@ -264,15 +308,24 @@ pub const ImageStorage = struct { self.dirty = true; }, - .row => |v| { - // Get the screenpoint y - const y = (point.Viewport{ .x = 0, .y = v.y }).toScreen(&t.screen).y; + .row => |v| row: { + // v.y is in active coords so we want to convert it to a pin + // so we can compare by page offsets. + const target_pin = t.screen.pages.pin(.{ .active = .{ + .y = v.y, + } }) orelse break :row; var it = self.placements.iterator(); while (it.next()) |entry| { const img = self.imageById(entry.key_ptr.image_id) orelse continue; const rect = entry.value_ptr.rect(img, t); - if (rect.top_left.y <= y and rect.bottom_right.y >= y) { + + // We need to copy our pin to ensure we are at least at + // the top-left x. + var target_pin_copy = target_pin; + target_pin_copy.x = rect.top_left.x; + if (target_pin_copy.isBetween(rect.top_left, rect.bottom_right)) { + entry.value_ptr.deinit(&t.screen); self.placements.removeByPtr(entry.key_ptr); if (v.delete) self.deleteIfUnused(alloc, img.id); } @@ -287,6 +340,7 @@ pub const ImageStorage = struct { while (it.next()) |entry| { if (entry.value_ptr.z == v.z) { const image_id = entry.key_ptr.image_id; + entry.value_ptr.deinit(&t.screen); self.placements.removeByPtr(entry.key_ptr); if (v.delete) self.deleteIfUnused(alloc, image_id); } @@ -305,6 +359,7 @@ pub const ImageStorage = struct { fn deleteById( self: *ImageStorage, alloc: Allocator, + s: *terminal.Screen, image_id: u32, placement_id: u32, delete_unused: bool, @@ -314,14 +369,18 @@ pub const ImageStorage = struct { var it = self.placements.iterator(); while (it.next()) |entry| { if (entry.key_ptr.image_id == image_id) { + entry.value_ptr.deinit(s); self.placements.removeByPtr(entry.key_ptr); } } } else { - _ = self.placements.remove(.{ + if (self.placements.getEntry(.{ .image_id = image_id, .placement_id = .{ .tag = .external, .id = placement_id }, - }); + })) |entry| { + entry.value_ptr.deinit(s); + self.placements.removeByPtr(entry.key_ptr); + } } // If this is specified, then we also delete the image @@ -353,18 +412,22 @@ pub const ImageStorage = struct { fn deleteIntersecting( self: *ImageStorage, alloc: Allocator, - t: *const terminal.Terminal, - p: point.ScreenPoint, + t: *terminal.Terminal, + p: point.Point, delete_unused: bool, filter_ctx: anytype, comptime filter: ?fn (@TypeOf(filter_ctx), Placement) bool, ) void { + // Convert our target point to a pin for comparison. + const target_pin = t.screen.pages.pin(p) orelse return; + var it = self.placements.iterator(); while (it.next()) |entry| { const img = self.imageById(entry.key_ptr.image_id) orelse continue; const rect = entry.value_ptr.rect(img, t); - if (rect.contains(p)) { + if (target_pin.isBetween(rect.top_left, rect.bottom_right)) { if (filter) |f| if (!f(filter_ctx, entry.value_ptr.*)) continue; + entry.value_ptr.deinit(&t.screen); self.placements.removeByPtr(entry.key_ptr); if (delete_unused) self.deleteIfUnused(alloc, img.id); } @@ -486,8 +549,8 @@ pub const ImageStorage = struct { }; pub const Placement = struct { - /// The location of the image on the screen. - point: ScreenPoint, + /// The tracked pin for this placement. + pin: *PageList.Pin, /// Offset of the x/y from the top-left of the cell. x_offset: u32 = 0, @@ -506,6 +569,13 @@ pub const ImageStorage = struct { /// The z-index for this placement. z: i32 = 0, + pub fn deinit( + self: *const Placement, + s: *terminal.Screen, + ) void { + s.pages.untrackPin(self.pin); + } + /// Returns a selection of the entire rectangle this placement /// occupies within the screen. pub fn rect( @@ -515,13 +585,13 @@ pub const ImageStorage = struct { ) Rect { // If we have columns/rows specified we can simplify this whole thing. if (self.columns > 0 and self.rows > 0) { - return .{ - .top_left = self.point, - .bottom_right = .{ - .x = @min(self.point.x + self.columns, t.cols - 1), - .y = self.point.y + self.rows, - }, + var br = switch (self.pin.downOverflow(self.rows)) { + .offset => |v| v, + .overflow => |v| v.end, }; + br.x = @min(self.pin.x + self.columns, t.cols - 1); + + return .{ .top_left = self.pin.*, .bottom_right = br }; } // Calculate our cell size. @@ -542,17 +612,31 @@ pub const ImageStorage = struct { const width_cells: u32 = @intFromFloat(@ceil(width_f64 / cell_width_f64)); const height_cells: u32 = @intFromFloat(@ceil(height_f64 / cell_height_f64)); + // TODO(paged-terminal): clean this logic up above + var br = switch (self.pin.downOverflow(height_cells)) { + .offset => |v| v, + .overflow => |v| v.end, + }; + br.x = @min(self.pin.x + width_cells, t.cols - 1); + return .{ - .top_left = self.point, - .bottom_right = .{ - .x = @min(self.point.x + width_cells, t.cols - 1), - .y = self.point.y + height_cells, - }, + .top_left = self.pin.*, + .bottom_right = br, }; } }; }; +// Our pin for the placement +fn trackPin( + t: *terminal.Terminal, + pt: point.Point.Coordinate, +) !*PageList.Pin { + return try t.screen.pages.trackPin(t.screen.pages.pin(.{ + .active = pt, + }).?); +} + test "storage: add placement with zero placement id" { const testing = std.testing; const alloc = testing.allocator; @@ -562,11 +646,11 @@ test "storage: add placement with zero placement id" { t.height_px = 100; var s: ImageStorage = .{}; - defer s.deinit(alloc); + defer s.deinit(alloc, &t.screen); try s.addImage(alloc, .{ .id = 1, .width = 50, .height = 50 }); try s.addImage(alloc, .{ .id = 2, .width = 25, .height = 25 }); - try s.addPlacement(alloc, 1, 0, .{ .point = .{ .x = 25, .y = 25 } }); - try s.addPlacement(alloc, 1, 0, .{ .point = .{ .x = 25, .y = 25 } }); + try s.addPlacement(alloc, 1, 0, .{ .pin = try trackPin(&t, .{ .x = 25, .y = 25 }) }); + try s.addPlacement(alloc, 1, 0, .{ .pin = try trackPin(&t, .{ .x = 25, .y = 25 }) }); try testing.expectEqual(@as(usize, 2), s.placements.count()); try testing.expectEqual(@as(usize, 2), s.images.count()); @@ -587,20 +671,22 @@ test "storage: delete all placements and images" { const alloc = testing.allocator; var t = try terminal.Terminal.init(alloc, 3, 3); defer t.deinit(alloc); + const tracked = t.screen.pages.countTrackedPins(); var s: ImageStorage = .{}; - defer s.deinit(alloc); + defer s.deinit(alloc, &t.screen); try s.addImage(alloc, .{ .id = 1 }); try s.addImage(alloc, .{ .id = 2 }); try s.addImage(alloc, .{ .id = 3 }); - try s.addPlacement(alloc, 1, 1, .{ .point = .{ .x = 1, .y = 1 } }); - try s.addPlacement(alloc, 2, 1, .{ .point = .{ .x = 1, .y = 1 } }); + try s.addPlacement(alloc, 1, 1, .{ .pin = try trackPin(&t, .{ .x = 1, .y = 1 }) }); + try s.addPlacement(alloc, 2, 1, .{ .pin = try trackPin(&t, .{ .x = 1, .y = 1 }) }); s.dirty = false; s.delete(alloc, &t, .{ .all = true }); try testing.expect(s.dirty); try testing.expectEqual(@as(usize, 0), s.images.count()); try testing.expectEqual(@as(usize, 0), s.placements.count()); + try testing.expectEqual(tracked, t.screen.pages.countTrackedPins()); } test "storage: delete all placements and images preserves limit" { @@ -608,15 +694,16 @@ test "storage: delete all placements and images preserves limit" { const alloc = testing.allocator; var t = try terminal.Terminal.init(alloc, 3, 3); defer t.deinit(alloc); + const tracked = t.screen.pages.countTrackedPins(); var s: ImageStorage = .{}; - defer s.deinit(alloc); + defer s.deinit(alloc, &t.screen); s.total_limit = 5000; try s.addImage(alloc, .{ .id = 1 }); try s.addImage(alloc, .{ .id = 2 }); try s.addImage(alloc, .{ .id = 3 }); - try s.addPlacement(alloc, 1, 1, .{ .point = .{ .x = 1, .y = 1 } }); - try s.addPlacement(alloc, 2, 1, .{ .point = .{ .x = 1, .y = 1 } }); + try s.addPlacement(alloc, 1, 1, .{ .pin = try trackPin(&t, .{ .x = 1, .y = 1 }) }); + try s.addPlacement(alloc, 2, 1, .{ .pin = try trackPin(&t, .{ .x = 1, .y = 1 }) }); s.dirty = false; s.delete(alloc, &t, .{ .all = true }); @@ -624,6 +711,7 @@ test "storage: delete all placements and images preserves limit" { try testing.expectEqual(@as(usize, 0), s.images.count()); try testing.expectEqual(@as(usize, 0), s.placements.count()); try testing.expectEqual(@as(usize, 5000), s.total_limit); + try testing.expectEqual(tracked, t.screen.pages.countTrackedPins()); } test "storage: delete all placements" { @@ -631,20 +719,22 @@ test "storage: delete all placements" { const alloc = testing.allocator; var t = try terminal.Terminal.init(alloc, 3, 3); defer t.deinit(alloc); + const tracked = t.screen.pages.countTrackedPins(); var s: ImageStorage = .{}; - defer s.deinit(alloc); + defer s.deinit(alloc, &t.screen); try s.addImage(alloc, .{ .id = 1 }); try s.addImage(alloc, .{ .id = 2 }); try s.addImage(alloc, .{ .id = 3 }); - try s.addPlacement(alloc, 1, 1, .{ .point = .{ .x = 1, .y = 1 } }); - try s.addPlacement(alloc, 2, 1, .{ .point = .{ .x = 1, .y = 1 } }); + try s.addPlacement(alloc, 1, 1, .{ .pin = try trackPin(&t, .{ .x = 1, .y = 1 }) }); + try s.addPlacement(alloc, 2, 1, .{ .pin = try trackPin(&t, .{ .x = 1, .y = 1 }) }); s.dirty = false; s.delete(alloc, &t, .{ .all = false }); try testing.expect(s.dirty); try testing.expectEqual(@as(usize, 0), s.placements.count()); try testing.expectEqual(@as(usize, 3), s.images.count()); + try testing.expectEqual(tracked, t.screen.pages.countTrackedPins()); } test "storage: delete all placements by image id" { @@ -652,20 +742,22 @@ test "storage: delete all placements by image id" { const alloc = testing.allocator; var t = try terminal.Terminal.init(alloc, 3, 3); defer t.deinit(alloc); + const tracked = t.screen.pages.countTrackedPins(); var s: ImageStorage = .{}; - defer s.deinit(alloc); + defer s.deinit(alloc, &t.screen); try s.addImage(alloc, .{ .id = 1 }); try s.addImage(alloc, .{ .id = 2 }); try s.addImage(alloc, .{ .id = 3 }); - try s.addPlacement(alloc, 1, 1, .{ .point = .{ .x = 1, .y = 1 } }); - try s.addPlacement(alloc, 2, 1, .{ .point = .{ .x = 1, .y = 1 } }); + try s.addPlacement(alloc, 1, 1, .{ .pin = try trackPin(&t, .{ .x = 1, .y = 1 }) }); + try s.addPlacement(alloc, 2, 1, .{ .pin = try trackPin(&t, .{ .x = 1, .y = 1 }) }); s.dirty = false; s.delete(alloc, &t, .{ .id = .{ .image_id = 2 } }); try testing.expect(s.dirty); try testing.expectEqual(@as(usize, 1), s.placements.count()); try testing.expectEqual(@as(usize, 3), s.images.count()); + try testing.expectEqual(tracked + 1, t.screen.pages.countTrackedPins()); } test "storage: delete all placements by image id and unused images" { @@ -673,20 +765,22 @@ test "storage: delete all placements by image id and unused images" { const alloc = testing.allocator; var t = try terminal.Terminal.init(alloc, 3, 3); defer t.deinit(alloc); + const tracked = t.screen.pages.countTrackedPins(); var s: ImageStorage = .{}; - defer s.deinit(alloc); + defer s.deinit(alloc, &t.screen); try s.addImage(alloc, .{ .id = 1 }); try s.addImage(alloc, .{ .id = 2 }); try s.addImage(alloc, .{ .id = 3 }); - try s.addPlacement(alloc, 1, 1, .{ .point = .{ .x = 1, .y = 1 } }); - try s.addPlacement(alloc, 2, 1, .{ .point = .{ .x = 1, .y = 1 } }); + try s.addPlacement(alloc, 1, 1, .{ .pin = try trackPin(&t, .{ .x = 1, .y = 1 }) }); + try s.addPlacement(alloc, 2, 1, .{ .pin = try trackPin(&t, .{ .x = 1, .y = 1 }) }); s.dirty = false; s.delete(alloc, &t, .{ .id = .{ .delete = true, .image_id = 2 } }); try testing.expect(s.dirty); try testing.expectEqual(@as(usize, 1), s.placements.count()); try testing.expectEqual(@as(usize, 2), s.images.count()); + try testing.expectEqual(tracked + 1, t.screen.pages.countTrackedPins()); } test "storage: delete placement by specific id" { @@ -694,15 +788,16 @@ test "storage: delete placement by specific id" { const alloc = testing.allocator; var t = try terminal.Terminal.init(alloc, 3, 3); defer t.deinit(alloc); + const tracked = t.screen.pages.countTrackedPins(); var s: ImageStorage = .{}; - defer s.deinit(alloc); + defer s.deinit(alloc, &t.screen); try s.addImage(alloc, .{ .id = 1 }); try s.addImage(alloc, .{ .id = 2 }); try s.addImage(alloc, .{ .id = 3 }); - try s.addPlacement(alloc, 1, 1, .{ .point = .{ .x = 1, .y = 1 } }); - try s.addPlacement(alloc, 1, 2, .{ .point = .{ .x = 1, .y = 1 } }); - try s.addPlacement(alloc, 2, 1, .{ .point = .{ .x = 1, .y = 1 } }); + try s.addPlacement(alloc, 1, 1, .{ .pin = try trackPin(&t, .{ .x = 1, .y = 1 }) }); + try s.addPlacement(alloc, 1, 2, .{ .pin = try trackPin(&t, .{ .x = 1, .y = 1 }) }); + try s.addPlacement(alloc, 2, 1, .{ .pin = try trackPin(&t, .{ .x = 1, .y = 1 }) }); s.dirty = false; s.delete(alloc, &t, .{ .id = .{ @@ -713,6 +808,7 @@ test "storage: delete placement by specific id" { try testing.expect(s.dirty); try testing.expectEqual(@as(usize, 2), s.placements.count()); try testing.expectEqual(@as(usize, 3), s.images.count()); + try testing.expectEqual(tracked + 2, t.screen.pages.countTrackedPins()); } test "storage: delete intersecting cursor" { @@ -722,22 +818,23 @@ test "storage: delete intersecting cursor" { defer t.deinit(alloc); t.width_px = 100; t.height_px = 100; + const tracked = t.screen.pages.countTrackedPins(); var s: ImageStorage = .{}; - defer s.deinit(alloc); + defer s.deinit(alloc, &t.screen); try s.addImage(alloc, .{ .id = 1, .width = 50, .height = 50 }); try s.addImage(alloc, .{ .id = 2, .width = 25, .height = 25 }); - try s.addPlacement(alloc, 1, 1, .{ .point = .{ .x = 0, .y = 0 } }); - try s.addPlacement(alloc, 1, 2, .{ .point = .{ .x = 25, .y = 25 } }); + try s.addPlacement(alloc, 1, 1, .{ .pin = try trackPin(&t, .{ .x = 0, .y = 0 }) }); + try s.addPlacement(alloc, 1, 2, .{ .pin = try trackPin(&t, .{ .x = 25, .y = 25 }) }); - t.screen.cursor.x = 12; - t.screen.cursor.y = 12; + t.screen.cursorAbsolute(12, 12); s.dirty = false; s.delete(alloc, &t, .{ .intersect_cursor = false }); try testing.expect(s.dirty); try testing.expectEqual(@as(usize, 1), s.placements.count()); try testing.expectEqual(@as(usize, 2), s.images.count()); + try testing.expectEqual(tracked + 1, t.screen.pages.countTrackedPins()); // verify the placement is what we expect try testing.expect(s.placements.get(.{ @@ -753,22 +850,23 @@ test "storage: delete intersecting cursor plus unused" { defer t.deinit(alloc); t.width_px = 100; t.height_px = 100; + const tracked = t.screen.pages.countTrackedPins(); var s: ImageStorage = .{}; - defer s.deinit(alloc); + defer s.deinit(alloc, &t.screen); try s.addImage(alloc, .{ .id = 1, .width = 50, .height = 50 }); try s.addImage(alloc, .{ .id = 2, .width = 25, .height = 25 }); - try s.addPlacement(alloc, 1, 1, .{ .point = .{ .x = 0, .y = 0 } }); - try s.addPlacement(alloc, 1, 2, .{ .point = .{ .x = 25, .y = 25 } }); + try s.addPlacement(alloc, 1, 1, .{ .pin = try trackPin(&t, .{ .x = 0, .y = 0 }) }); + try s.addPlacement(alloc, 1, 2, .{ .pin = try trackPin(&t, .{ .x = 25, .y = 25 }) }); - t.screen.cursor.x = 12; - t.screen.cursor.y = 12; + t.screen.cursorAbsolute(12, 12); s.dirty = false; s.delete(alloc, &t, .{ .intersect_cursor = true }); try testing.expect(s.dirty); try testing.expectEqual(@as(usize, 1), s.placements.count()); try testing.expectEqual(@as(usize, 2), s.images.count()); + try testing.expectEqual(tracked + 1, t.screen.pages.countTrackedPins()); // verify the placement is what we expect try testing.expect(s.placements.get(.{ @@ -784,22 +882,23 @@ test "storage: delete intersecting cursor hits multiple" { defer t.deinit(alloc); t.width_px = 100; t.height_px = 100; + const tracked = t.screen.pages.countTrackedPins(); var s: ImageStorage = .{}; - defer s.deinit(alloc); + defer s.deinit(alloc, &t.screen); try s.addImage(alloc, .{ .id = 1, .width = 50, .height = 50 }); try s.addImage(alloc, .{ .id = 2, .width = 25, .height = 25 }); - try s.addPlacement(alloc, 1, 1, .{ .point = .{ .x = 0, .y = 0 } }); - try s.addPlacement(alloc, 1, 2, .{ .point = .{ .x = 25, .y = 25 } }); + try s.addPlacement(alloc, 1, 1, .{ .pin = try trackPin(&t, .{ .x = 0, .y = 0 }) }); + try s.addPlacement(alloc, 1, 2, .{ .pin = try trackPin(&t, .{ .x = 25, .y = 25 }) }); - t.screen.cursor.x = 26; - t.screen.cursor.y = 26; + t.screen.cursorAbsolute(26, 26); s.dirty = false; s.delete(alloc, &t, .{ .intersect_cursor = true }); try testing.expect(s.dirty); try testing.expectEqual(@as(usize, 0), s.placements.count()); try testing.expectEqual(@as(usize, 1), s.images.count()); + try testing.expectEqual(tracked, t.screen.pages.countTrackedPins()); } test "storage: delete by column" { @@ -809,13 +908,14 @@ test "storage: delete by column" { defer t.deinit(alloc); t.width_px = 100; t.height_px = 100; + const tracked = t.screen.pages.countTrackedPins(); var s: ImageStorage = .{}; - defer s.deinit(alloc); + defer s.deinit(alloc, &t.screen); try s.addImage(alloc, .{ .id = 1, .width = 50, .height = 50 }); try s.addImage(alloc, .{ .id = 2, .width = 25, .height = 25 }); - try s.addPlacement(alloc, 1, 1, .{ .point = .{ .x = 0, .y = 0 } }); - try s.addPlacement(alloc, 1, 2, .{ .point = .{ .x = 25, .y = 25 } }); + try s.addPlacement(alloc, 1, 1, .{ .pin = try trackPin(&t, .{ .x = 0, .y = 0 }) }); + try s.addPlacement(alloc, 1, 2, .{ .pin = try trackPin(&t, .{ .x = 25, .y = 25 }) }); s.dirty = false; s.delete(alloc, &t, .{ .column = .{ @@ -825,6 +925,7 @@ test "storage: delete by column" { try testing.expect(s.dirty); try testing.expectEqual(@as(usize, 1), s.placements.count()); try testing.expectEqual(@as(usize, 2), s.images.count()); + try testing.expectEqual(tracked + 1, t.screen.pages.countTrackedPins()); // verify the placement is what we expect try testing.expect(s.placements.get(.{ @@ -840,13 +941,14 @@ test "storage: delete by row" { defer t.deinit(alloc); t.width_px = 100; t.height_px = 100; + const tracked = t.screen.pages.countTrackedPins(); var s: ImageStorage = .{}; - defer s.deinit(alloc); + defer s.deinit(alloc, &t.screen); try s.addImage(alloc, .{ .id = 1, .width = 50, .height = 50 }); try s.addImage(alloc, .{ .id = 2, .width = 25, .height = 25 }); - try s.addPlacement(alloc, 1, 1, .{ .point = .{ .x = 0, .y = 0 } }); - try s.addPlacement(alloc, 1, 2, .{ .point = .{ .x = 25, .y = 25 } }); + try s.addPlacement(alloc, 1, 1, .{ .pin = try trackPin(&t, .{ .x = 0, .y = 0 }) }); + try s.addPlacement(alloc, 1, 2, .{ .pin = try trackPin(&t, .{ .x = 25, .y = 25 }) }); s.dirty = false; s.delete(alloc, &t, .{ .row = .{ @@ -856,6 +958,7 @@ test "storage: delete by row" { try testing.expect(s.dirty); try testing.expectEqual(@as(usize, 1), s.placements.count()); try testing.expectEqual(@as(usize, 2), s.images.count()); + try testing.expectEqual(tracked + 1, t.screen.pages.countTrackedPins()); // verify the placement is what we expect try testing.expect(s.placements.get(.{ diff --git a/src/terminal/main.zig b/src/terminal/main.zig index 51261d8d4..25a97cb2e 100644 --- a/src/terminal/main.zig +++ b/src/terminal/main.zig @@ -15,21 +15,27 @@ pub const color = @import("color.zig"); pub const device_status = @import("device_status.zig"); pub const kitty = @import("kitty.zig"); pub const modes = @import("modes.zig"); +pub const page = @import("page.zig"); pub const parse_table = @import("parse_table.zig"); pub const x11_color = @import("x11_color.zig"); pub const Charset = charsets.Charset; pub const CharsetSlot = charsets.Slots; pub const CharsetActiveSlot = charsets.ActiveSlot; +pub const Cell = page.Cell; pub const CSI = Parser.Action.CSI; pub const DCS = Parser.Action.DCS; pub const MouseShape = @import("mouse_shape.zig").MouseShape; +pub const Page = page.Page; +pub const PageList = @import("PageList.zig"); pub const Parser = @import("Parser.zig"); -pub const Selection = @import("Selection.zig"); +pub const Pin = PageList.Pin; pub const Screen = @import("Screen.zig"); +pub const Selection = @import("Selection.zig"); pub const Terminal = @import("Terminal.zig"); pub const Stream = stream.Stream; pub const Cursor = Screen.Cursor; +pub const CursorStyle = Screen.CursorStyle; pub const CursorStyleReq = ansi.CursorStyle; pub const DeviceAttributeReq = ansi.DeviceAttributeReq; pub const Mode = modes.Mode; @@ -42,17 +48,12 @@ pub const EraseLine = csi.EraseLine; pub const TabClear = csi.TabClear; pub const Attribute = sgr.Attribute; -// TODO(paged-terminal) -pub const StringMap = @import("StringMap.zig"); - -/// If we're targeting wasm then we export some wasm APIs. -pub usingnamespace if (builtin.target.isWasm()) struct { - pub usingnamespace @import("wasm.zig"); -} else struct {}; - -// TODO(paged-terminal) remove before merge -pub const new = @import("../terminal2/main.zig"); - test { @import("std").testing.refAllDecls(@This()); + + // todo: make top-level imports + _ = @import("bitmap_allocator.zig"); + _ = @import("hash_map.zig"); + _ = @import("size.zig"); + _ = @import("style.zig"); } diff --git a/src/terminal2/page.zig b/src/terminal/page.zig similarity index 100% rename from src/terminal2/page.zig rename to src/terminal/page.zig diff --git a/src/terminal/point.zig b/src/terminal/point.zig index 8c694f992..4f1d7836b 100644 --- a/src/terminal/point.zig +++ b/src/terminal/point.zig @@ -1,254 +1,86 @@ const std = @import("std"); -const terminal = @import("main.zig"); -const Screen = terminal.Screen; +const Allocator = std.mem.Allocator; +const assert = std.debug.assert; -// This file contains various types to represent x/y coordinates. We -// use different types so that we can lean on type-safety to get the -// exact expected type of point. +/// The possible reference locations for a point. When someone says "(42, 80)" in the context of a terminal, that could mean multiple +/// things: it is in the current visible viewport? the current active +/// area of the screen where the cursor is? the entire scrollback history? +/// etc. This tag is used to differentiate those cases. +pub const Tag = enum { + /// Top-left is part of the active area where a running program can + /// jump the cursor and make changes. The active area is the "editable" + /// part of the screen. + /// + /// The bottom-right of the active tag differs from all other tags + /// because it includes the full height (rows) of the screen, including + /// rows that may not be written yet. This is required because the active + /// area is fully "addressable" by the running program (see below) whereas + /// the other tags are used primarliy for reading/modifying past-written + /// data so they can't address unwritten rows. + /// + /// Note for those less familiar with terminal functionality: there + /// are escape sequences to move the cursor to any position on + /// the screen, but it is limited to the size of the viewport and + /// the bottommost part of the screen. Terminal programs can't -- + /// with sequences at the time of writing this comment -- modify + /// anything in the scrollback, visible viewport (if it differs + /// from the active area), etc. + active, -/// Active is a point within the active part of the screen. -pub const Active = struct { - x: usize = 0, - y: usize = 0, + /// Top-left is the visible viewport. This means that if the user + /// has scrolled in any direction, top-left changes. The bottom-right + /// is the last written row from the top-left. + viewport, - pub fn toScreen(self: Active, screen: *const Screen) ScreenPoint { - return .{ - .x = self.x, - .y = screen.history + self.y, + /// Top-left is the furthest back in the scrollback history + /// supported by the screen and the bottom-right is the bottom-right + /// of the last written row. Note this last point is important: the + /// bottom right is NOT necessarilly the same as "active" because + /// "active" always allows referencing the full rows tall of the + /// screen whereas "screen" only contains written rows. + screen, + + /// The top-left is the same as "screen" but the bottom-right is + /// the line just before the top of "active". This contains only + /// the scrollback history. + history, +}; + +/// An x/y point in the terminal for some definition of location (tag). +pub const Point = union(Tag) { + active: Coordinate, + viewport: Coordinate, + screen: Coordinate, + history: Coordinate, + + pub const Coordinate = struct { + x: usize = 0, + y: usize = 0, + }; + + pub fn coord(self: Point) Coordinate { + return switch (self) { + .active, + .viewport, + .screen, + .history, + => |v| v, }; } - - test "toScreen with scrollback" { - const testing = std.testing; - const alloc = testing.allocator; - - var s = try Screen.init(alloc, 3, 5, 3); - defer s.deinit(); - const str = "1\n2\n3\n4\n5\n6\n7\n8"; - try s.testWriteString(str); - - try testing.expectEqual(ScreenPoint{ - .x = 1, - .y = 5, - }, (Active{ .x = 1, .y = 2 }).toScreen(&s)); - } }; -/// Viewport is a point within the viewport of the screen. +/// A point in the terminal that is always in the viewport area. pub const Viewport = struct { x: usize = 0, y: usize = 0, - pub fn toScreen(self: Viewport, screen: *const Screen) ScreenPoint { - // x is unchanged, y we have to add the visible offset to - // get the full offset from the top. - return .{ - .x = self.x, - .y = screen.viewport + self.y, - }; - } - pub fn eql(self: Viewport, other: Viewport) bool { return self.x == other.x and self.y == other.y; } - - test "toScreen with no scrollback" { - const testing = std.testing; - const alloc = testing.allocator; - - var s = try Screen.init(alloc, 3, 5, 0); - defer s.deinit(); - - try testing.expectEqual(ScreenPoint{ - .x = 1, - .y = 1, - }, (Viewport{ .x = 1, .y = 1 }).toScreen(&s)); - } - - test "toScreen with scrollback" { - const testing = std.testing; - const alloc = testing.allocator; - - var s = try Screen.init(alloc, 3, 5, 3); - defer s.deinit(); - - // At the bottom - try s.scroll(.{ .screen = 6 }); - try testing.expectEqual(ScreenPoint{ - .x = 0, - .y = 3, - }, (Viewport{ .x = 0, .y = 0 }).toScreen(&s)); - - // Move the viewport a bit up - try s.scroll(.{ .screen = -1 }); - try testing.expectEqual(ScreenPoint{ - .x = 0, - .y = 2, - }, (Viewport{ .x = 0, .y = 0 }).toScreen(&s)); - - // Move the viewport to top - try s.scroll(.{ .top = {} }); - try testing.expectEqual(ScreenPoint{ - .x = 0, - .y = 0, - }, (Viewport{ .x = 0, .y = 0 }).toScreen(&s)); - } }; -/// A screen point. This is offset from the top of the scrollback -/// buffer. If the screen is scrolled or resized, this will have to -/// be recomputed. -pub const ScreenPoint = struct { +/// A point in the terminal that is in relation to the entire screen. +pub const Screen = struct { x: usize = 0, y: usize = 0, - - /// Returns if this point is before another point. - pub fn before(self: ScreenPoint, other: ScreenPoint) bool { - return self.y < other.y or - (self.y == other.y and self.x < other.x); - } - - /// Returns if two points are equal. - pub fn eql(self: ScreenPoint, other: ScreenPoint) bool { - return self.x == other.x and self.y == other.y; - } - - /// Returns true if this screen point is currently in the active viewport. - pub fn inViewport(self: ScreenPoint, screen: *const Screen) bool { - return self.y >= screen.viewport and - self.y < screen.viewport + screen.rows; - } - - /// Converts this to a viewport point. If the point is above the - /// viewport this will move the point to (0, 0) and if it is below - /// the viewport it'll move it to (cols - 1, rows - 1). - pub fn toViewport(self: ScreenPoint, screen: *const Screen) Viewport { - // TODO: test - - // Before viewport - if (self.y < screen.viewport) return .{ .x = 0, .y = 0 }; - - // After viewport - if (self.y > screen.viewport + screen.rows) return .{ - .x = screen.cols - 1, - .y = screen.rows - 1, - }; - - return .{ .x = self.x, .y = self.y - screen.viewport }; - } - - /// Returns a screen point iterator. This will iterate over all of - /// of the points in a screen in a given direction one by one. - /// - /// The iterator is only valid as long as the screen is not resized. - pub fn iterator( - self: ScreenPoint, - screen: *const Screen, - dir: Direction, - ) Iterator { - return .{ .screen = screen, .current = self, .direction = dir }; - } - - pub const Iterator = struct { - screen: *const Screen, - current: ?ScreenPoint, - direction: Direction, - - pub fn next(self: *Iterator) ?ScreenPoint { - const current = self.current orelse return null; - self.current = switch (self.direction) { - .left_up => left_up: { - if (current.x == 0) { - if (current.y == 0) break :left_up null; - break :left_up .{ - .x = self.screen.cols - 1, - .y = current.y - 1, - }; - } - - break :left_up .{ - .x = current.x - 1, - .y = current.y, - }; - }, - - .right_down => right_down: { - if (current.x == self.screen.cols - 1) { - const max = self.screen.rows + self.screen.max_scrollback; - if (current.y == max - 1) break :right_down null; - break :right_down .{ - .x = 0, - .y = current.y + 1, - }; - } - - break :right_down .{ - .x = current.x + 1, - .y = current.y, - }; - }, - }; - - return current; - } - }; - - test "before" { - const testing = std.testing; - - const p: ScreenPoint = .{ .x = 5, .y = 2 }; - try testing.expect(p.before(.{ .x = 6, .y = 2 })); - try testing.expect(p.before(.{ .x = 3, .y = 3 })); - } - - test "iterator" { - const testing = std.testing; - const alloc = testing.allocator; - - var s = try Screen.init(alloc, 5, 5, 0); - defer s.deinit(); - - // Back from the first line - { - var pt: ScreenPoint = .{ .x = 1, .y = 0 }; - var it = pt.iterator(&s, .left_up); - try testing.expectEqual(ScreenPoint{ .x = 1, .y = 0 }, it.next().?); - try testing.expectEqual(ScreenPoint{ .x = 0, .y = 0 }, it.next().?); - try testing.expect(it.next() == null); - } - - // Back from second line - { - var pt: ScreenPoint = .{ .x = 1, .y = 1 }; - var it = pt.iterator(&s, .left_up); - try testing.expectEqual(ScreenPoint{ .x = 1, .y = 1 }, it.next().?); - try testing.expectEqual(ScreenPoint{ .x = 0, .y = 1 }, it.next().?); - try testing.expectEqual(ScreenPoint{ .x = 4, .y = 0 }, it.next().?); - } - - // Forward last line - { - var pt: ScreenPoint = .{ .x = 3, .y = 4 }; - var it = pt.iterator(&s, .right_down); - try testing.expectEqual(ScreenPoint{ .x = 3, .y = 4 }, it.next().?); - try testing.expectEqual(ScreenPoint{ .x = 4, .y = 4 }, it.next().?); - try testing.expect(it.next() == null); - } - - // Forward not last line - { - var pt: ScreenPoint = .{ .x = 3, .y = 3 }; - var it = pt.iterator(&s, .right_down); - try testing.expectEqual(ScreenPoint{ .x = 3, .y = 3 }, it.next().?); - try testing.expectEqual(ScreenPoint{ .x = 4, .y = 3 }, it.next().?); - try testing.expectEqual(ScreenPoint{ .x = 0, .y = 4 }, it.next().?); - } - } }; - -/// Direction that points can go. -pub const Direction = enum { left_up, right_down }; - -test { - std.testing.refAllDecls(@This()); -} diff --git a/src/terminal2/size.zig b/src/terminal/size.zig similarity index 100% rename from src/terminal2/size.zig rename to src/terminal/size.zig diff --git a/src/terminal2/style.zig b/src/terminal/style.zig similarity index 100% rename from src/terminal2/style.zig rename to src/terminal/style.zig diff --git a/src/terminal2/Screen.zig b/src/terminal2/Screen.zig deleted file mode 100644 index 694d5dfc0..000000000 --- a/src/terminal2/Screen.zig +++ /dev/null @@ -1,5536 +0,0 @@ -const Screen = @This(); - -const std = @import("std"); -const Allocator = std.mem.Allocator; -const assert = std.debug.assert; -const ansi = @import("ansi.zig"); -const charsets = @import("charsets.zig"); -const kitty = @import("kitty.zig"); -const sgr = @import("sgr.zig"); -const unicode = @import("../unicode/main.zig"); -const Selection = @import("Selection.zig"); -const PageList = @import("PageList.zig"); -const pagepkg = @import("page.zig"); -const point = @import("point.zig"); -const size = @import("size.zig"); -const style = @import("style.zig"); -const Page = pagepkg.Page; -const Row = pagepkg.Row; -const Cell = pagepkg.Cell; -const Pin = PageList.Pin; - -/// The general purpose allocator to use for all memory allocations. -/// Unfortunately some screen operations do require allocation. -alloc: Allocator, - -/// The list of pages in the screen. -pages: PageList, - -/// Special-case where we want no scrollback whatsoever. We have to flag -/// this because max_size 0 in PageList gets rounded up to two pages so -/// we can always have an active screen. -no_scrollback: bool = false, - -/// The current cursor position -cursor: Cursor, - -/// The saved cursor -saved_cursor: ?SavedCursor = null, - -/// The selection for this screen (if any). -//selection: ?Selection = null, -selection: ?void = null, - -/// The charset state -charset: CharsetState = .{}, - -/// 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, - -/// The kitty keyboard settings. -kitty_keyboard: kitty.KeyFlagStack = .{}, - -/// Kitty graphics protocol state. -kitty_images: kitty.graphics.ImageStorage = .{}, - -/// The cursor position. -pub const Cursor = struct { - // The x/y position within the viewport. - x: size.CellCountInt, - y: size.CellCountInt, - - /// The visual style of the cursor. This defaults to block because - /// it has to default to something, but users of this struct are - /// encouraged to set their own default. - cursor_style: CursorStyle = .block, - - /// The "last column flag (LCF)" as its called. If this is set then the - /// next character print will force a soft-wrap. - pending_wrap: bool = false, - - /// The protected mode state of the cursor. If this is true then - /// all new characters printed will have the protected state set. - protected: bool = false, - - /// The currently active style. This is the concrete style value - /// that should be kept up to date. The style ID to use for cell writing - /// is below. - style: style.Style = .{}, - - /// The currently active style ID. The style is page-specific so when - /// we change pages we need to ensure that we update that page with - /// our style when used. - style_id: style.Id = style.default_id, - style_ref: ?*size.CellCountInt = null, - - /// The pointers into the page list where the cursor is currently - /// located. This makes it faster to move the cursor. - page_pin: *PageList.Pin, - page_row: *pagepkg.Row, - page_cell: *pagepkg.Cell, -}; - -/// The visual style of the cursor. Whether or not it blinks -/// is determined by mode 12 (modes.zig). This mode is synchronized -/// with CSI q, the same as xterm. -pub const CursorStyle = enum { bar, block, underline }; - -/// Saved cursor state. -pub const SavedCursor = struct { - x: size.CellCountInt, - y: size.CellCountInt, - style: style.Style, - protected: bool, - pending_wrap: bool, - origin: bool, - charset: CharsetState, -}; - -/// State required for all charset operations. -pub const CharsetState = struct { - /// The list of graphical charsets by slot - charsets: CharsetArray = CharsetArray.initFill(charsets.Charset.utf8), - - /// GL is the slot to use when using a 7-bit printable char (up to 127) - /// GR used for 8-bit printable chars. - gl: charsets.Slots = .G0, - gr: charsets.Slots = .G2, - - /// Single shift where a slot is used for exactly one char. - single_shift: ?charsets.Slots = null, - - /// An array to map a charset slot to a lookup table. - const CharsetArray = std.EnumArray(charsets.Slots, charsets.Charset); -}; - -/// Initialize a new screen. -/// -/// max_scrollback is the amount of scrollback to keep in bytes. This -/// will be rounded UP to the nearest page size because our minimum allocation -/// size is that anyways. -/// -/// If max scrollback is 0, then no scrollback is kept at all. -pub fn init( - alloc: Allocator, - cols: size.CellCountInt, - rows: size.CellCountInt, - max_scrollback: usize, -) !Screen { - // Initialize our backing pages. - var pages = try PageList.init(alloc, cols, rows, max_scrollback); - errdefer pages.deinit(); - - // Create our tracked pin for the cursor. - const page_pin = try pages.trackPin(.{ .page = pages.pages.first.? }); - errdefer pages.untrackPin(page_pin); - const page_rac = page_pin.rowAndCell(); - - return .{ - .alloc = alloc, - .pages = pages, - .no_scrollback = max_scrollback == 0, - .cursor = .{ - .x = 0, - .y = 0, - .page_pin = page_pin, - .page_row = page_rac.row, - .page_cell = page_rac.cell, - }, - }; -} - -pub fn deinit(self: *Screen) void { - self.kitty_images.deinit(self.alloc, self); - self.pages.deinit(); -} - -/// Clone the screen. -/// -/// This will copy: -/// -/// - Screen dimensions -/// - Screen data (cell state, etc.) for the region -/// -/// Anything not mentioned above is NOT copied. Some of this is for -/// very good reason: -/// -/// - Kitty images have a LOT of data. This is not efficient to copy. -/// Use a lock and access the image data. The dirty bit is there for -/// a reason. -/// - Cursor location can be expensive to calculate with respect to the -/// specified region. It is faster to grab the cursor from the old -/// screen and then move it to the new screen. -/// -/// If not mentioned above, then there isn't a specific reason right now -/// to not copy some data other than we probably didn't need it and it -/// isn't necessary for screen coherency. -/// -/// Other notes: -/// -/// - The viewport will always be set to the active area of the new -/// screen. This is the bottom "rows" rows. -/// - If the clone region is smaller than a viewport area, blanks will -/// be filled in at the bottom. -/// -pub fn clone( - self: *const Screen, - alloc: Allocator, - top: point.Point, - bot: ?point.Point, -) !Screen { - return try self.clonePool(alloc, null, top, bot); -} - -/// Same as clone but you can specify a custom memory pool to use for -/// the screen. -pub fn clonePool( - self: *const Screen, - alloc: Allocator, - pool: ?*PageList.MemoryPool, - top: point.Point, - bot: ?point.Point, -) !Screen { - var pages = if (pool) |p| - try self.pages.clonePool(p, top, bot) - else - try self.pages.clone(alloc, top, bot); - errdefer pages.deinit(); - - return .{ - .alloc = alloc, - .pages = pages, - .no_scrollback = self.no_scrollback, - - // TODO: let's make this reasonble - .cursor = undefined, - }; -} - -pub fn cursorCellRight(self: *Screen, n: size.CellCountInt) *pagepkg.Cell { - assert(self.cursor.x + n < self.pages.cols); - const cell: [*]pagepkg.Cell = @ptrCast(self.cursor.page_cell); - return @ptrCast(cell + n); -} - -pub fn cursorCellLeft(self: *Screen, n: size.CellCountInt) *pagepkg.Cell { - assert(self.cursor.x >= n); - const cell: [*]pagepkg.Cell = @ptrCast(self.cursor.page_cell); - return @ptrCast(cell - n); -} - -pub fn cursorCellEndOfPrev(self: *Screen) *pagepkg.Cell { - assert(self.cursor.y > 0); - - var page_pin = self.cursor.page_pin.up(1).?; - page_pin.x = self.pages.cols - 1; - const page_rac = page_pin.rowAndCell(); - return page_rac.cell; -} - -/// Move the cursor right. This is a specialized function that is very fast -/// if the caller can guarantee we have space to move right (no wrapping). -pub fn cursorRight(self: *Screen, n: size.CellCountInt) void { - assert(self.cursor.x + n < self.pages.cols); - - const cell: [*]pagepkg.Cell = @ptrCast(self.cursor.page_cell); - self.cursor.page_cell = @ptrCast(cell + n); - self.cursor.page_pin.x += n; - self.cursor.x += n; -} - -/// Move the cursor left. -pub fn cursorLeft(self: *Screen, n: size.CellCountInt) void { - assert(self.cursor.x >= n); - - const cell: [*]pagepkg.Cell = @ptrCast(self.cursor.page_cell); - self.cursor.page_cell = @ptrCast(cell - n); - self.cursor.page_pin.x -= n; - self.cursor.x -= n; -} - -/// Move the cursor up. -/// -/// Precondition: The cursor is not at the top of the screen. -pub fn cursorUp(self: *Screen, n: size.CellCountInt) void { - assert(self.cursor.y >= n); - - const page_pin = self.cursor.page_pin.up(n).?; - const page_rac = page_pin.rowAndCell(); - self.cursor.page_pin.* = page_pin; - self.cursor.page_row = page_rac.row; - self.cursor.page_cell = page_rac.cell; - self.cursor.y -= n; -} - -pub fn cursorRowUp(self: *Screen, n: size.CellCountInt) *pagepkg.Row { - assert(self.cursor.y >= n); - - const page_pin = self.cursor.page_pin.up(n).?; - const page_rac = page_pin.rowAndCell(); - return page_rac.row; -} - -/// Move the cursor down. -/// -/// Precondition: The cursor is not at the bottom of the screen. -pub fn cursorDown(self: *Screen, n: size.CellCountInt) void { - assert(self.cursor.y + n < self.pages.rows); - - // We move the offset into our page list to the next row and then - // get the pointers to the row/cell and set all the cursor state up. - const page_pin = self.cursor.page_pin.down(n).?; - const page_rac = page_pin.rowAndCell(); - self.cursor.page_pin.* = page_pin; - self.cursor.page_row = page_rac.row; - self.cursor.page_cell = page_rac.cell; - - // Y of course increases - self.cursor.y += n; -} - -/// Move the cursor to some absolute horizontal position. -pub fn cursorHorizontalAbsolute(self: *Screen, x: size.CellCountInt) void { - assert(x < self.pages.cols); - - self.cursor.page_pin.x = x; - const page_rac = self.cursor.page_pin.rowAndCell(); - self.cursor.page_cell = page_rac.cell; - self.cursor.x = x; -} - -/// Move the cursor to some absolute position. -pub fn cursorAbsolute(self: *Screen, x: size.CellCountInt, y: size.CellCountInt) void { - assert(x < self.pages.cols); - assert(y < self.pages.rows); - - var page_pin = if (y < self.cursor.y) - self.cursor.page_pin.up(self.cursor.y - y).? - else if (y > self.cursor.y) - self.cursor.page_pin.down(y - self.cursor.y).? - else - self.cursor.page_pin.*; - page_pin.x = x; - const page_rac = page_pin.rowAndCell(); - self.cursor.page_pin.* = page_pin; - self.cursor.page_row = page_rac.row; - self.cursor.page_cell = page_rac.cell; - self.cursor.x = x; - self.cursor.y = y; -} - -/// Reloads the cursor pointer information into the screen. This is expensive -/// so it should only be done in cases where the pointers are invalidated -/// in such a way that its difficult to recover otherwise. -pub fn cursorReload(self: *Screen) void { - // Our tracked pin is ALWAYS accurate, so we derive the active - // point from the pin. If this returns null it means our pin - // points outside the active area. In that case, we update the - // pin to be the top-left. - const pt: point.Point = self.pages.pointFromPin( - .active, - self.cursor.page_pin.*, - ) orelse reset: { - const pin = self.pages.pin(.{ .active = .{} }).?; - self.cursor.page_pin.* = pin; - break :reset self.pages.pointFromPin(.active, pin).?; - }; - - self.cursor.x = @intCast(pt.active.x); - self.cursor.y = @intCast(pt.active.y); - const page_rac = self.cursor.page_pin.rowAndCell(); - self.cursor.page_row = page_rac.row; - self.cursor.page_cell = page_rac.cell; -} - -/// Scroll the active area and keep the cursor at the bottom of the screen. -/// This is a very specialized function but it keeps it fast. -pub fn cursorDownScroll(self: *Screen) !void { - assert(self.cursor.y == self.pages.rows - 1); - - // If we have no scrollback, then we shift all our rows instead. - if (self.no_scrollback) { - // Erase rows will shift our rows up - self.pages.eraseRows(.{ .active = .{} }, .{ .active = .{} }); - - // We need to move our cursor down one because eraseRows will - // preserve our pin directly and we're erasing one row. - const page_pin = self.cursor.page_pin.down(1).?; - const page_rac = page_pin.rowAndCell(); - self.cursor.page_pin.* = page_pin; - self.cursor.page_row = page_rac.row; - self.cursor.page_cell = page_rac.cell; - - // Erase rows does NOT clear the cells because in all other cases - // we never write those rows again. Active erasing is a bit - // different so we manually clear our one row. - self.clearCells( - &page_pin.page.data, - self.cursor.page_row, - page_pin.page.data.getCells(self.cursor.page_row), - ); - } else { - // Grow our pages by one row. The PageList will handle if we need to - // allocate, prune scrollback, whatever. - _ = try self.pages.grow(); - const page_pin = self.cursor.page_pin.down(1).?; - const page_rac = page_pin.rowAndCell(); - self.cursor.page_pin.* = page_pin; - self.cursor.page_row = page_rac.row; - self.cursor.page_cell = page_rac.cell; - - // Clear the new row so it gets our bg color. We only do this - // if we have a bg color at all. - if (self.cursor.style.bg_color != .none) { - self.clearCells( - &page_pin.page.data, - self.cursor.page_row, - page_pin.page.data.getCells(self.cursor.page_row), - ); - } - } - - // The newly created line needs to be styled according to the bg color - // if it is set. - if (self.cursor.style_id != style.default_id) { - if (self.cursor.style.bgCell()) |blank_cell| { - const cell_current: [*]pagepkg.Cell = @ptrCast(self.cursor.page_cell); - const cells = cell_current - self.cursor.x; - @memset(cells[0..self.pages.cols], blank_cell); - } - } -} - -/// Move the cursor down if we're not at the bottom of the screen. Otherwise -/// scroll. Currently only used for testing. -fn cursorDownOrScroll(self: *Screen) !void { - if (self.cursor.y + 1 < self.pages.rows) { - self.cursorDown(1); - } else { - try self.cursorDownScroll(); - } -} - -/// Options for scrolling the viewport of the terminal grid. The reason -/// we have this in addition to PageList.Scroll is because we have additional -/// scroll behaviors that are not part of the PageList.Scroll enum. -pub const Scroll = union(enum) { - /// For all of these, see PageList.Scroll. - active, - top, - delta_row: isize, -}; - -/// Scroll the viewport of the terminal grid. -pub fn scroll(self: *Screen, behavior: Scroll) void { - // No matter what, scrolling marks our image state as dirty since - // it could move placements. If there are no placements or no images - // this is still a very cheap operation. - self.kitty_images.dirty = true; - - switch (behavior) { - .active => self.pages.scroll(.{ .active = {} }), - .top => self.pages.scroll(.{ .top = {} }), - .delta_row => |v| self.pages.scroll(.{ .delta_row = v }), - } -} - -/// See PageList.scrollClear. In addition to that, we reset the cursor -/// to be on top. -pub fn scrollClear(self: *Screen) !void { - try self.pages.scrollClear(); - self.cursorReload(); - - // No matter what, scrolling marks our image state as dirty since - // it could move placements. If there are no placements or no images - // this is still a very cheap operation. - self.kitty_images.dirty = true; -} - -/// Returns true if the viewport is scrolled to the bottom of the screen. -pub fn viewportIsBottom(self: Screen) bool { - return self.pages.viewport == .active; -} - -/// Erase the region specified by tl and br, inclusive. This will physically -/// erase the rows meaning the memory will be reclaimed (if the underlying -/// page is empty) and other rows will be shifted up. -pub fn eraseRows( - self: *Screen, - tl: point.Point, - bl: ?point.Point, -) void { - // Erase the rows - self.pages.eraseRows(tl, bl); - - // Just to be safe, reset our cursor since it is possible depending - // on the points that our active area shifted so our pointers are - // invalid. - self.cursorReload(); -} - -// Clear the region specified by tl and bl, inclusive. Cleared cells are -// colored with the current style background color. This will clear all -// cells in the rows. -// -// If protected is true, the protected flag will be respected and only -// unprotected cells will be cleared. Otherwise, all cells will be cleared. -pub fn clearRows( - self: *Screen, - tl: point.Point, - bl: ?point.Point, - protected: bool, -) void { - var it = self.pages.pageIterator(.right_down, tl, bl); - while (it.next()) |chunk| { - for (chunk.rows()) |*row| { - const cells_offset = row.cells; - const cells_multi: [*]Cell = row.cells.ptr(chunk.page.data.memory); - const cells = cells_multi[0..self.pages.cols]; - - // Clear all cells - if (protected) { - self.clearUnprotectedCells(&chunk.page.data, row, cells); - } else { - self.clearCells(&chunk.page.data, row, cells); - } - - // Reset our row to point to the proper memory but everything - // else is zeroed. - row.* = .{ .cells = cells_offset }; - } - } -} - -/// Clear the cells with the blank cell. This takes care to handle -/// cleaning up graphemes and styles. -pub fn clearCells( - self: *Screen, - page: *Page, - row: *Row, - cells: []Cell, -) void { - // If this row has graphemes, then we need go through a slow path - // and delete the cell graphemes. - if (row.grapheme) { - for (cells) |*cell| { - if (cell.hasGrapheme()) page.clearGrapheme(row, cell); - } - } - - if (row.styled) { - for (cells) |*cell| { - if (cell.style_id == style.default_id) continue; - - // Fast-path, the style ID matches, in this case we just update - // our own ref and continue. We never delete because our style - // is still active. - if (cell.style_id == self.cursor.style_id) { - self.cursor.style_ref.?.* -= 1; - continue; - } - - // Slow path: we need to lookup this style so we can decrement - // the ref count. Since we've already loaded everything, we also - // just go ahead and GC it if it reaches zero, too. - if (page.styles.lookupId(page.memory, cell.style_id)) |prev_style| { - // Below upsert can't fail because it should already be present - const md = page.styles.upsert(page.memory, prev_style.*) catch unreachable; - assert(md.ref > 0); - md.ref -= 1; - if (md.ref == 0) page.styles.remove(page.memory, cell.style_id); - } - } - - // If we have no left/right scroll region we can be sure that - // the row is no longer styled. - if (cells.len == self.pages.cols) row.styled = false; - } - - @memset(cells, self.blankCell()); -} - -/// Clear cells but only if they are not protected. -pub fn clearUnprotectedCells( - self: *Screen, - page: *Page, - row: *Row, - cells: []Cell, -) void { - for (cells) |*cell| { - if (cell.protected) continue; - const cell_multi: [*]Cell = @ptrCast(cell); - self.clearCells(page, row, cell_multi[0..1]); - } -} - -/// Returns the blank cell to use when doing terminal operations that -/// require preserving the bg color. -fn blankCell(self: *const Screen) Cell { - if (self.cursor.style_id == style.default_id) return .{}; - return self.cursor.style.bgCell() orelse .{}; -} - -/// Resize the screen. The rows or cols can be bigger or smaller. -/// -/// This will reflow soft-wrapped text. If the screen size is getting -/// smaller and the maximum scrollback size is exceeded, data will be -/// lost from the top of the scrollback. -/// -/// If this returns an error, the screen is left in a likely garbage state. -/// It is very hard to undo this operation without blowing up our memory -/// usage. The only way to recover is to reset the screen. The only way -/// this really fails is if page allocation is required and fails, which -/// probably means the system is in trouble anyways. I'd like to improve this -/// in the future but it is not a priority particularly because this scenario -/// (resize) is difficult. -pub fn resize( - self: *Screen, - cols: size.CellCountInt, - rows: size.CellCountInt, -) !void { - try self.resizeInternal(cols, rows, true); -} - -/// 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, - cols: size.CellCountInt, - rows: size.CellCountInt, -) !void { - try self.resizeInternal(cols, rows, false); -} - -/// Resize the screen. -// TODO: replace resize and resizeWithoutReflow with this. -fn resizeInternal( - self: *Screen, - cols: size.CellCountInt, - rows: size.CellCountInt, - reflow: bool, -) !void { - // No matter what we mark our image state as dirty - self.kitty_images.dirty = true; - - // Perform the resize operation. This will update cursor by reference. - try self.pages.resize(.{ - .rows = rows, - .cols = cols, - .reflow = reflow, - .cursor = .{ .x = self.cursor.x, .y = self.cursor.y }, - }); - - // If we have no scrollback and we shrunk our rows, we must explicitly - // erase our history. This is beacuse PageList always keeps at least - // a page size of history. - if (self.no_scrollback) { - self.pages.eraseRows(.{ .history = .{} }, null); - } - - // If our cursor was updated, we do a full reload so all our cursor - // state is correct. - self.cursorReload(); -} - -/// Set a style attribute for the current cursor. -/// -/// This can cause a page split if the current page cannot fit this style. -/// This is the only scenario an error return is possible. -pub fn setAttribute(self: *Screen, attr: sgr.Attribute) !void { - switch (attr) { - .unset => { - self.cursor.style = .{}; - }, - - .bold => { - self.cursor.style.flags.bold = true; - }, - - .reset_bold => { - // Bold and faint share the same SGR code for this - self.cursor.style.flags.bold = false; - self.cursor.style.flags.faint = false; - }, - - .italic => { - self.cursor.style.flags.italic = true; - }, - - .reset_italic => { - self.cursor.style.flags.italic = false; - }, - - .faint => { - self.cursor.style.flags.faint = true; - }, - - .underline => |v| { - self.cursor.style.flags.underline = v; - }, - - .reset_underline => { - self.cursor.style.flags.underline = .none; - }, - - .underline_color => |rgb| { - self.cursor.style.underline_color = .{ .rgb = .{ - .r = rgb.r, - .g = rgb.g, - .b = rgb.b, - } }; - }, - - .@"256_underline_color" => |idx| { - self.cursor.style.underline_color = .{ .palette = idx }; - }, - - .reset_underline_color => { - self.cursor.style.underline_color = .none; - }, - - .blink => { - self.cursor.style.flags.blink = true; - }, - - .reset_blink => { - self.cursor.style.flags.blink = false; - }, - - .inverse => { - self.cursor.style.flags.inverse = true; - }, - - .reset_inverse => { - self.cursor.style.flags.inverse = false; - }, - - .invisible => { - self.cursor.style.flags.invisible = true; - }, - - .reset_invisible => { - self.cursor.style.flags.invisible = false; - }, - - .strikethrough => { - self.cursor.style.flags.strikethrough = true; - }, - - .reset_strikethrough => { - self.cursor.style.flags.strikethrough = false; - }, - - .direct_color_fg => |rgb| { - self.cursor.style.fg_color = .{ - .rgb = .{ - .r = rgb.r, - .g = rgb.g, - .b = rgb.b, - }, - }; - }, - - .direct_color_bg => |rgb| { - self.cursor.style.bg_color = .{ - .rgb = .{ - .r = rgb.r, - .g = rgb.g, - .b = rgb.b, - }, - }; - }, - - .@"8_fg" => |n| { - self.cursor.style.fg_color = .{ .palette = @intFromEnum(n) }; - }, - - .@"8_bg" => |n| { - self.cursor.style.bg_color = .{ .palette = @intFromEnum(n) }; - }, - - .reset_fg => self.cursor.style.fg_color = .none, - - .reset_bg => self.cursor.style.bg_color = .none, - - .@"8_bright_fg" => |n| { - self.cursor.style.fg_color = .{ .palette = @intFromEnum(n) }; - }, - - .@"8_bright_bg" => |n| { - self.cursor.style.bg_color = .{ .palette = @intFromEnum(n) }; - }, - - .@"256_fg" => |idx| { - self.cursor.style.fg_color = .{ .palette = idx }; - }, - - .@"256_bg" => |idx| { - self.cursor.style.bg_color = .{ .palette = idx }; - }, - - .unknown => return, - } - - try self.manualStyleUpdate(); -} - -/// Call this whenever you manually change the cursor style. -pub fn manualStyleUpdate(self: *Screen) !void { - var page = &self.cursor.page_pin.page.data; - - // Remove our previous style if is unused. - if (self.cursor.style_ref) |ref| { - if (ref.* == 0) { - page.styles.remove(page.memory, self.cursor.style_id); - } - } - - // If our new style is the default, just reset to that - if (self.cursor.style.default()) { - self.cursor.style_id = 0; - self.cursor.style_ref = null; - return; - } - - // After setting the style, we need to update our style map. - // Note that we COULD lazily do this in print. We should look into - // if that makes a meaningful difference. Our priority is to keep print - // fast because setting a ton of styles that do nothing is uncommon - // and weird. - const md = try page.styles.upsert(page.memory, self.cursor.style); - self.cursor.style_id = md.id; - self.cursor.style_ref = &md.ref; -} - -/// 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, - trim: bool, -) ![:0]const u8 { - // Use an ArrayList so that we can grow the array as we go. We - // build an initial capacity of just our rows in our selection times - // columns. It can be more or less based on graphemes, newlines, etc. - var strbuilder = std.ArrayList(u8).init(alloc); - defer strbuilder.deinit(); - - const sel_ordered = sel.ordered(self, .forward); - const sel_start = start: { - var start = sel.start(); - const cell = start.rowAndCell().cell; - if (cell.wide == .spacer_tail) start.x -= 1; - break :start start; - }; - const sel_end = end: { - var end = sel.end(); - const cell = end.rowAndCell().cell; - switch (cell.wide) { - .narrow, .wide => {}, - - // We can omit the tail - .spacer_tail => end.x -= 1, - - // With the head we want to include the wrapped wide character. - .spacer_head => if (end.down(1)) |p| { - end = p; - end.x = 0; - }, - } - break :end end; - }; - - var page_it = sel_start.pageIterator(.right_down, sel_end); - var row_count: usize = 0; - while (page_it.next()) |chunk| { - const rows = chunk.rows(); - for (rows) |row| { - const cells_ptr = row.cells.ptr(chunk.page.data.memory); - - const start_x = if (row_count == 0 or sel_ordered.rectangle) - sel_start.x - else - 0; - const end_x = if (row_count == rows.len - 1 or sel_ordered.rectangle) - sel_end.x + 1 - else - self.pages.cols; - - const cells = cells_ptr[start_x..end_x]; - for (cells) |*cell| { - // Skip wide spacers - switch (cell.wide) { - .narrow, .wide => {}, - .spacer_head, .spacer_tail => continue, - } - - var buf: [4]u8 = undefined; - { - const raw: u21 = if (cell.hasText()) cell.content.codepoint else 0; - const char = if (raw > 0) raw else ' '; - const encode_len = try std.unicode.utf8Encode(char, &buf); - try strbuilder.appendSlice(buf[0..encode_len]); - } - if (cell.hasGrapheme()) { - const cps = chunk.page.data.lookupGrapheme(cell).?; - for (cps) |cp| { - const encode_len = try std.unicode.utf8Encode(cp, &buf); - try strbuilder.appendSlice(buf[0..encode_len]); - } - } - } - - if (row_count < rows.len - 1 and - (!row.wrap or sel_ordered.rectangle)) - { - try strbuilder.append('\n'); - } - - row_count += 1; - } - } - - // Remove any trailing spaces on lines. We could do optimize this by - // doing this in the loop above but this isn't very hot path code and - // this is simple. - if (trim) { - var it = std.mem.tokenizeScalar(u8, strbuilder.items, '\n'); - - // Reset our items. We retain our capacity. Because we're only - // removing bytes, we know that the trimmed string must be no longer - // than the original string so we copy directly back into our - // allocated memory. - strbuilder.clearRetainingCapacity(); - while (it.next()) |line| { - const trimmed = std.mem.trimRight(u8, line, " \t"); - const i = strbuilder.items.len; - strbuilder.items.len += trimmed.len; - std.mem.copyForwards(u8, strbuilder.items[i..], trimmed); - try strbuilder.append('\n'); - } - - // Remove all trailing newlines - for (0..strbuilder.items.len) |_| { - if (strbuilder.items[strbuilder.items.len - 1] != '\n') break; - strbuilder.items.len -= 1; - } - } - - // Get our final string - const string = try strbuilder.toOwnedSliceSentinel(0); - errdefer alloc.free(string); - - return string; -} - -/// Select the line under the given point. This will select across soft-wrapped -/// lines and will omit the leading and trailing whitespace. If the point is -/// over whitespace but the line has non-whitespace characters elsewhere, the -/// line will be selected. -pub fn selectLine(self: *Screen, pin: Pin) ?Selection { - _ = self; - - // Whitespace characters for selection purposes - const whitespace = &[_]u32{ 0, ' ', '\t' }; - - // Get the current point semantic prompt state since that determines - // boundary conditions too. This makes it so that line selection can - // only happen within the same prompt state. For example, if you triple - // click output, but the shell uses spaces to soft-wrap to the prompt - // then the selection will stop prior to the prompt. See issue #1329. - const semantic_prompt_state = state: { - const rac = pin.rowAndCell(); - break :state rac.row.semantic_prompt.promptOrInput(); - }; - - // The real start of the row is the first row in the soft-wrap. - const start_pin: Pin = start_pin: { - var it = pin.rowIterator(.left_up, null); - var it_prev: Pin = pin; - while (it.next()) |p| { - const row = p.rowAndCell().row; - - if (!row.wrap) { - var copy = it_prev; - copy.x = 0; - break :start_pin copy; - } - - // See semantic_prompt_state comment for why - const current_prompt = row.semantic_prompt.promptOrInput(); - if (current_prompt != semantic_prompt_state) { - var copy = it_prev; - copy.x = 0; - break :start_pin copy; - } - - it_prev = p; - } else { - var copy = it_prev; - copy.x = 0; - break :start_pin copy; - } - }; - - // The real end of the row is the final row in the soft-wrap. - const end_pin: Pin = end_pin: { - var it = pin.rowIterator(.right_down, null); - while (it.next()) |p| { - const row = p.rowAndCell().row; - - // See semantic_prompt_state comment for why - const current_prompt = row.semantic_prompt.promptOrInput(); - if (current_prompt != semantic_prompt_state) { - var prev = p.up(1).?; - prev.x = p.page.data.size.cols - 1; - break :end_pin prev; - } - - if (!row.wrap) { - var copy = p; - copy.x = p.page.data.size.cols - 1; - break :end_pin copy; - } - } - - return null; - }; - - // Go forward from the start to find the first non-whitespace character. - const start: Pin = start: { - var it = start_pin.cellIterator(.right_down, end_pin); - while (it.next()) |p| { - const cell = p.rowAndCell().cell; - if (!cell.hasText()) continue; - - // Non-empty means we found it. - const this_whitespace = std.mem.indexOfAny( - u32, - whitespace, - &[_]u32{cell.content.codepoint}, - ) != null; - if (this_whitespace) continue; - - break :start p; - } - - return null; - }; - - // Go backward from the end to find the first non-whitespace character. - const end: Pin = end: { - var it = end_pin.cellIterator(.left_up, start_pin); - while (it.next()) |p| { - const cell = p.rowAndCell().cell; - if (!cell.hasText()) continue; - - // Non-empty means we found it. - const this_whitespace = std.mem.indexOfAny( - u32, - whitespace, - &[_]u32{cell.content.codepoint}, - ) != null; - if (this_whitespace) continue; - - break :end p; - } - - return null; - }; - - return Selection.init(start, end, false); -} - -/// Return the selection for all contents on the screen. Surrounding -/// whitespace is omitted. If there is no selection, this returns null. -pub fn selectAll(self: *Screen) ?Selection { - const whitespace = &[_]u32{ 0, ' ', '\t' }; - - const start: Pin = start: { - var it = self.pages.cellIterator( - .right_down, - .{ .screen = .{} }, - null, - ); - while (it.next()) |p| { - const cell = p.rowAndCell().cell; - if (!cell.hasText()) continue; - - // Non-empty means we found it. - const this_whitespace = std.mem.indexOfAny( - u32, - whitespace, - &[_]u32{cell.content.codepoint}, - ) != null; - if (this_whitespace) continue; - - break :start p; - } - - return null; - }; - - const end: Pin = end: { - var it = self.pages.cellIterator( - .left_up, - .{ .screen = .{} }, - null, - ); - while (it.next()) |p| { - const cell = p.rowAndCell().cell; - if (!cell.hasText()) continue; - - // Non-empty means we found it. - const this_whitespace = std.mem.indexOfAny( - u32, - whitespace, - &[_]u32{cell.content.codepoint}, - ) != null; - if (this_whitespace) continue; - - break :end p; - } - - return null; - }; - - return Selection.init(start, end, false); -} - -/// Select the word under the given point. A word is any consecutive series -/// of characters that are exclusively whitespace or exclusively non-whitespace. -/// A selection can span multiple physical lines if they are soft-wrapped. -/// -/// This will return null if a selection is impossible. The only scenario -/// this happens is if the point pt is outside of the written screen space. -pub fn selectWord(self: *Screen, pin: Pin) ?Selection { - _ = self; - - // Boundary characters for selection purposes - const boundary = &[_]u32{ - 0, - ' ', - '\t', - '\'', - '"', - '│', - '`', - '|', - ':', - ',', - '(', - ')', - '[', - ']', - '{', - '}', - '<', - '>', - }; - - // If our cell is empty we can't select a word, because we can't select - // areas where the screen is not yet written. - const start_cell = pin.rowAndCell().cell; - if (!start_cell.hasText()) return null; - - // Determine if we are a boundary or not to determine what our boundary is. - const expect_boundary = std.mem.indexOfAny( - u32, - boundary, - &[_]u32{start_cell.content.codepoint}, - ) != null; - - // Go forwards to find our end boundary - const end: Pin = end: { - var it = pin.cellIterator(.right_down, null); - var prev = it.next().?; // Consume one, our start - while (it.next()) |p| { - const rac = p.rowAndCell(); - const cell = rac.cell; - - // If we reached an empty cell its always a boundary - if (!cell.hasText()) break :end prev; - - // If we do not match our expected set, we hit a boundary - const this_boundary = std.mem.indexOfAny( - u32, - boundary, - &[_]u32{cell.content.codepoint}, - ) != null; - if (this_boundary != expect_boundary) break :end prev; - - // If we are going to the next row and it isn't wrapped, we - // return the previous. - if (p.x == p.page.data.size.cols - 1 and !rac.row.wrap) { - break :end p; - } - - prev = p; - } - - break :end prev; - }; - - // Go backwards to find our start boundary - const start: Pin = start: { - var it = pin.cellIterator(.left_up, null); - var prev = it.next().?; // Consume one, our start - while (it.next()) |p| { - const rac = p.rowAndCell(); - const cell = rac.cell; - - // If we are going to the next row and it isn't wrapped, we - // return the previous. - if (p.x == p.page.data.size.cols - 1 and !rac.row.wrap) { - break :start prev; - } - - // If we reached an empty cell its always a boundary - if (!cell.hasText()) break :start prev; - - // If we do not match our expected set, we hit a boundary - const this_boundary = std.mem.indexOfAny( - u32, - boundary, - &[_]u32{cell.content.codepoint}, - ) != null; - if (this_boundary != expect_boundary) break :start prev; - - prev = p; - } - - break :start prev; - }; - - return Selection.init(start, end, false); -} - -/// Select the command output under the given point. The limits of the output -/// are determined by semantic prompt information provided by shell integration. -/// A selection can span multiple physical lines if they are soft-wrapped. -/// -/// This will return null if a selection is impossible. The only scenarios -/// this happens is if: -/// - the point pt is outside of the written screen space. -/// - the point pt is on a prompt / input line. -pub fn selectOutput(self: *Screen, pin: Pin) ?Selection { - _ = self; - - switch (pin.rowAndCell().row.semantic_prompt) { - .input, .prompt_continuation, .prompt => { - // Cursor on a prompt line, selection impossible - return null; - }, - - else => {}, - } - - // Go forwards to find our end boundary - // We are looking for input start / prompt markers - const end: Pin = boundary: { - var it = pin.rowIterator(.right_down, null); - var it_prev = pin; - while (it.next()) |p| { - const row = p.rowAndCell().row; - switch (row.semantic_prompt) { - .input, .prompt_continuation, .prompt => { - var copy = it_prev; - copy.x = it_prev.page.data.size.cols - 1; - break :boundary copy; - }, - else => {}, - } - - it_prev = p; - } - - // Find the last non-blank row - it = it_prev.rowIterator(.left_up, null); - while (it.next()) |p| { - const row = p.rowAndCell().row; - const cells = p.page.data.getCells(row); - if (Cell.hasTextAny(cells)) { - var copy = p; - copy.x = p.page.data.size.cols - 1; - break :boundary copy; - } - } - - // In this case it means that all our rows are blank. Let's - // just return no selection, this is a weird case. - return null; - }; - - // Go backwards to find our start boundary - // We are looking for output start markers - const start: Pin = boundary: { - var it = pin.rowIterator(.left_up, null); - var it_prev = pin; - while (it.next()) |p| { - const row = p.rowAndCell().row; - switch (row.semantic_prompt) { - .command => break :boundary p, - else => {}, - } - - it_prev = p; - } - - break :boundary it_prev; - }; - - return Selection.init(start, end, false); -} - -/// Returns the selection bounds for the prompt at the given point. If the -/// point is not on a prompt line, this returns null. Note that due to -/// the underlying protocol, this will only return the y-coordinates of -/// the prompt. The x-coordinates of the start will always be zero and -/// the x-coordinates of the end will always be the last column. -/// -/// Note that this feature requires shell integration. If shell integration -/// is not enabled, this will always return null. -pub fn selectPrompt(self: *Screen, pin: Pin) ?Selection { - _ = self; - - // Ensure that the line the point is on is a prompt. - const is_known = switch (pin.rowAndCell().row.semantic_prompt) { - .prompt, .prompt_continuation, .input => true, - .command => return null, - - // We allow unknown to continue because not all shells output any - // semantic prompt information for continuation lines. This has the - // possibility of making this function VERY slow (we look at all - // scrollback) so we should try to avoid this in the future by - // setting a flag or something if we have EVER seen a semantic - // prompt sequence. - .unknown => false, - }; - - // Find the start of the prompt. - var saw_semantic_prompt = is_known; - const start: Pin = start: { - var it = pin.rowIterator(.left_up, null); - var it_prev = it.next().?; - while (it.next()) |p| { - const row = p.rowAndCell().row; - switch (row.semantic_prompt) { - // A prompt, we continue searching. - .prompt, .prompt_continuation, .input => saw_semantic_prompt = true, - - // See comment about "unknown" a few lines above. If we have - // previously seen a semantic prompt then if we see an unknown - // we treat it as a boundary. - .unknown => if (saw_semantic_prompt) break :start it_prev, - - // Command output or unknown, definitely not a prompt. - .command => break :start it_prev, - } - - it_prev = p; - } - - break :start it_prev; - }; - - // If we never saw a semantic prompt flag, then we can't trust our - // start value and we return null. This scenario usually means that - // semantic prompts aren't enabled via the shell. - if (!saw_semantic_prompt) return null; - - // Find the end of the prompt. - const end: Pin = end: { - var it = pin.rowIterator(.right_down, null); - var it_prev = it.next().?; - it_prev.x = it_prev.page.data.size.cols - 1; - while (it.next()) |p| { - const row = p.rowAndCell().row; - switch (row.semantic_prompt) { - // A prompt, we continue searching. - .prompt, .prompt_continuation, .input => {}, - - // Command output or unknown, definitely not a prompt. - .command, .unknown => break :end it_prev, - } - - it_prev = p; - it_prev.x = it_prev.page.data.size.cols - 1; - } - - break :end it_prev; - }; - - return Selection.init(start, end, false); -} - -/// Returns the change in x/y that is needed to reach "to" from "from" -/// within a prompt. If "to" is before or after the prompt bounds then -/// the result will be bounded to the prompt. -/// -/// This feature requires shell integration. If shell integration is not -/// enabled, this will always return zero for both x and y (no path). -pub fn promptPath( - self: *Screen, - from: Pin, - to: Pin, -) struct { - x: isize, - y: isize, -} { - // Get our prompt bounds assuming "from" is at a prompt. - const bounds = self.selectPrompt(from) orelse return .{ .x = 0, .y = 0 }; - - // Get our actual "to" point clamped to the bounds of the prompt. - const to_clamped = if (bounds.contains(self, to)) - to - else if (to.before(bounds.start())) - bounds.start() - else - bounds.end(); - - // Convert to points - const from_pt = self.pages.pointFromPin(.screen, from).?.screen; - const to_pt = self.pages.pointFromPin(.screen, to_clamped).?.screen; - - // Basic math to calculate our path. - const from_x: isize = @intCast(from_pt.x); - const from_y: isize = @intCast(from_pt.y); - const to_x: isize = @intCast(to_pt.x); - const to_y: isize = @intCast(to_pt.y); - return .{ .x = to_x - from_x, .y = to_y - from_y }; -} - -/// Dump the screen to a string. The writer given should be buffered; -/// this function does not attempt to efficiently write and generally writes -/// one byte at a time. -pub fn dumpString( - self: *const Screen, - writer: anytype, - tl: point.Point, -) !void { - var blank_rows: usize = 0; - - var iter = self.pages.rowIterator(.right_down, tl, null); - while (iter.next()) |row_offset| { - const rac = row_offset.rowAndCell(); - const cells = cells: { - const cells: [*]pagepkg.Cell = @ptrCast(rac.cell); - break :cells cells[0..self.pages.cols]; - }; - - if (!pagepkg.Cell.hasTextAny(cells)) { - blank_rows += 1; - continue; - } - if (blank_rows > 0) { - for (0..blank_rows) |_| try writer.writeByte('\n'); - blank_rows = 0; - } - - // TODO: handle wrap - blank_rows += 1; - - var blank_cells: usize = 0; - for (cells) |*cell| { - // Skip spacers - switch (cell.wide) { - .narrow, .wide => {}, - .spacer_head, .spacer_tail => continue, - } - - // If we have a zero value, then we accumulate a counter. We - // only want to turn zero values into spaces if we have a non-zero - // char sometime later. - if (!cell.hasText()) { - blank_cells += 1; - continue; - } - if (blank_cells > 0) { - for (0..blank_cells) |_| try writer.writeByte(' '); - blank_cells = 0; - } - - switch (cell.content_tag) { - .codepoint => { - try writer.print("{u}", .{cell.content.codepoint}); - }, - - .codepoint_grapheme => { - try writer.print("{u}", .{cell.content.codepoint}); - const cps = row_offset.page.data.lookupGrapheme(cell).?; - for (cps) |cp| { - try writer.print("{u}", .{cp}); - } - }, - - else => unreachable, - } - } - } -} - -pub fn dumpStringAlloc( - self: *const Screen, - alloc: Allocator, - tl: point.Point, -) ![]const u8 { - var builder = std.ArrayList(u8).init(alloc); - defer builder.deinit(); - try self.dumpString(builder.writer(), tl); - return try builder.toOwnedSlice(); -} - -/// This is basically a really jank version of Terminal.printString. We -/// have to reimplement it here because we want a way to print to the screen -/// to test it but don't want all the features of Terminal. -pub fn testWriteString(self: *Screen, text: []const u8) !void { - const view = try std.unicode.Utf8View.init(text); - var iter = view.iterator(); - while (iter.nextCodepoint()) |c| { - // Explicit newline forces a new row - if (c == '\n') { - try self.cursorDownOrScroll(); - self.cursorHorizontalAbsolute(0); - self.cursor.pending_wrap = false; - continue; - } - - const width: usize = if (c <= 0xFF) 1 else @intCast(unicode.table.get(c).width); - if (width == 0) { - const cell = cell: { - var cell = self.cursorCellLeft(1); - switch (cell.wide) { - .narrow => {}, - .wide => {}, - .spacer_head => unreachable, - .spacer_tail => cell = self.cursorCellLeft(2), - } - - break :cell cell; - }; - - try self.cursor.page_pin.page.data.appendGrapheme( - self.cursor.page_row, - cell, - c, - ); - continue; - } - - if (self.cursor.pending_wrap) { - assert(self.cursor.x == self.pages.cols - 1); - self.cursor.pending_wrap = false; - self.cursor.page_row.wrap = true; - try self.cursorDownOrScroll(); - self.cursorHorizontalAbsolute(0); - self.cursor.page_row.wrap_continuation = true; - } - - assert(width == 1 or width == 2); - switch (width) { - 1 => { - self.cursor.page_cell.* = .{ - .content_tag = .codepoint, - .content = .{ .codepoint = c }, - .style_id = self.cursor.style_id, - }; - - // If we have a ref-counted style, increase. - if (self.cursor.style_ref) |ref| { - ref.* += 1; - self.cursor.page_row.styled = true; - } - }, - - 2 => { - // Need a wide spacer head - if (self.cursor.x == self.pages.cols - 1) { - self.cursor.page_cell.* = .{ - .content_tag = .codepoint, - .content = .{ .codepoint = 0 }, - .wide = .spacer_head, - }; - - self.cursor.page_row.wrap = true; - try self.cursorDownOrScroll(); - self.cursorHorizontalAbsolute(0); - self.cursor.page_row.wrap_continuation = true; - } - - // Write our wide char - self.cursor.page_cell.* = .{ - .content_tag = .codepoint, - .content = .{ .codepoint = c }, - .style_id = self.cursor.style_id, - .wide = .wide, - }; - - // Write our tail - self.cursorRight(1); - self.cursor.page_cell.* = .{ - .content_tag = .codepoint, - .content = .{ .codepoint = 0 }, - .wide = .spacer_tail, - }; - }, - - else => unreachable, - } - - if (self.cursor.x + 1 < self.pages.cols) { - self.cursorRight(1); - } else { - self.cursor.pending_wrap = true; - } - } -} - -test "Screen read and write" { - const testing = std.testing; - const alloc = testing.allocator; - - var s = try Screen.init(alloc, 80, 24, 1000); - defer s.deinit(); - try testing.expectEqual(@as(style.Id, 0), s.cursor.style_id); - - try s.testWriteString("hello, world"); - const str = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); - defer alloc.free(str); - try testing.expectEqualStrings("hello, world", str); -} - -test "Screen read and write newline" { - const testing = std.testing; - const alloc = testing.allocator; - - var s = try Screen.init(alloc, 80, 24, 1000); - defer s.deinit(); - try testing.expectEqual(@as(style.Id, 0), s.cursor.style_id); - - try s.testWriteString("hello\nworld"); - const str = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); - defer alloc.free(str); - try testing.expectEqualStrings("hello\nworld", str); -} - -test "Screen read and write scrollback" { - const testing = std.testing; - const alloc = testing.allocator; - - var s = try Screen.init(alloc, 80, 2, 1000); - defer s.deinit(); - - try s.testWriteString("hello\nworld\ntest"); - { - const str = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); - defer alloc.free(str); - try testing.expectEqualStrings("hello\nworld\ntest", str); - } - { - const str = try s.dumpStringAlloc(alloc, .{ .active = .{} }); - defer alloc.free(str); - try testing.expectEqualStrings("world\ntest", str); - } -} - -test "Screen read and write no scrollback small" { - const testing = std.testing; - const alloc = testing.allocator; - - var s = try Screen.init(alloc, 80, 2, 0); - defer s.deinit(); - - try s.testWriteString("hello\nworld\ntest"); - { - const str = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); - defer alloc.free(str); - try testing.expectEqualStrings("world\ntest", str); - } - { - const str = try s.dumpStringAlloc(alloc, .{ .active = .{} }); - defer alloc.free(str); - try testing.expectEqualStrings("world\ntest", str); - } -} - -test "Screen read and write no scrollback large" { - const testing = std.testing; - const alloc = testing.allocator; - - var s = try Screen.init(alloc, 80, 2, 0); - defer s.deinit(); - - for (0..1_000) |i| { - var buf: [128]u8 = undefined; - const str = try std.fmt.bufPrint(&buf, "{}\n", .{i}); - try s.testWriteString(str); - } - try s.testWriteString("1000"); - - { - const str = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); - defer alloc.free(str); - try testing.expectEqualStrings("999\n1000", str); - } -} - -test "Screen style basics" { - const testing = std.testing; - const alloc = testing.allocator; - - var s = try Screen.init(alloc, 80, 24, 1000); - defer s.deinit(); - const page = s.cursor.page_pin.page.data; - try testing.expectEqual(@as(usize, 0), page.styles.count(page.memory)); - - // Set a new style - try s.setAttribute(.{ .bold = {} }); - try testing.expect(s.cursor.style_id != 0); - try testing.expectEqual(@as(usize, 1), page.styles.count(page.memory)); - try testing.expect(s.cursor.style.flags.bold); - - // Set another style, we should still only have one since it was unused - try s.setAttribute(.{ .italic = {} }); - try testing.expect(s.cursor.style_id != 0); - try testing.expectEqual(@as(usize, 1), page.styles.count(page.memory)); - try testing.expect(s.cursor.style.flags.italic); -} - -test "Screen style reset to default" { - const testing = std.testing; - const alloc = testing.allocator; - - var s = try Screen.init(alloc, 80, 24, 1000); - defer s.deinit(); - const page = s.cursor.page_pin.page.data; - try testing.expectEqual(@as(usize, 0), page.styles.count(page.memory)); - - // Set a new style - try s.setAttribute(.{ .bold = {} }); - try testing.expect(s.cursor.style_id != 0); - try testing.expectEqual(@as(usize, 1), page.styles.count(page.memory)); - - // Reset to default - try s.setAttribute(.{ .reset_bold = {} }); - try testing.expect(s.cursor.style_id == 0); - try testing.expectEqual(@as(usize, 0), page.styles.count(page.memory)); -} - -test "Screen style reset with unset" { - const testing = std.testing; - const alloc = testing.allocator; - - var s = try Screen.init(alloc, 80, 24, 1000); - defer s.deinit(); - const page = s.cursor.page_pin.page.data; - try testing.expectEqual(@as(usize, 0), page.styles.count(page.memory)); - - // Set a new style - try s.setAttribute(.{ .bold = {} }); - try testing.expect(s.cursor.style_id != 0); - try testing.expectEqual(@as(usize, 1), page.styles.count(page.memory)); - - // Reset to default - try s.setAttribute(.{ .unset = {} }); - try testing.expect(s.cursor.style_id == 0); - try testing.expectEqual(@as(usize, 0), page.styles.count(page.memory)); -} - -test "Screen clearRows active one line" { - const testing = std.testing; - const alloc = testing.allocator; - - var s = try Screen.init(alloc, 80, 24, 1000); - defer s.deinit(); - - try s.testWriteString("hello, world"); - s.clearRows(.{ .active = .{} }, null, false); - const str = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); - defer alloc.free(str); - try testing.expectEqualStrings("", str); -} - -test "Screen clearRows active multi line" { - const testing = std.testing; - const alloc = testing.allocator; - - var s = try Screen.init(alloc, 80, 24, 1000); - defer s.deinit(); - - try s.testWriteString("hello\nworld"); - s.clearRows(.{ .active = .{} }, null, false); - const str = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); - defer alloc.free(str); - try testing.expectEqualStrings("", str); -} - -test "Screen clearRows active styled line" { - const testing = std.testing; - const alloc = testing.allocator; - - var s = try Screen.init(alloc, 80, 24, 1000); - defer s.deinit(); - - try s.setAttribute(.{ .bold = {} }); - try s.testWriteString("hello world"); - try s.setAttribute(.{ .unset = {} }); - - // We should have one style - const page = s.cursor.page_pin.page.data; - try testing.expectEqual(@as(usize, 1), page.styles.count(page.memory)); - - s.clearRows(.{ .active = .{} }, null, false); - - // We should have none because active cleared it - try testing.expectEqual(@as(usize, 0), page.styles.count(page.memory)); - - const str = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); - defer alloc.free(str); - try testing.expectEqualStrings("", str); -} - -test "Screen eraseRows history" { - const testing = std.testing; - const alloc = testing.allocator; - - var s = try Screen.init(alloc, 5, 5, 1000); - defer s.deinit(); - - try s.testWriteString("1\n2\n3\n4\n5\n6"); - - { - const str = try s.dumpStringAlloc(alloc, .{ .active = .{} }); - defer alloc.free(str); - try testing.expectEqualStrings("2\n3\n4\n5\n6", str); - } - { - const str = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); - defer alloc.free(str); - try testing.expectEqualStrings("1\n2\n3\n4\n5\n6", str); - } - - s.eraseRows(.{ .history = .{} }, null); - - { - const str = try s.dumpStringAlloc(alloc, .{ .active = .{} }); - defer alloc.free(str); - try testing.expectEqualStrings("2\n3\n4\n5\n6", str); - } - { - const str = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); - defer alloc.free(str); - try testing.expectEqualStrings("2\n3\n4\n5\n6", str); - } -} - -test "Screen eraseRows history with more lines" { - const testing = std.testing; - const alloc = testing.allocator; - - var s = try Screen.init(alloc, 5, 5, 1000); - defer s.deinit(); - - try s.testWriteString("A\nB\nC\n1\n2\n3\n4\n5\n6"); - - { - const str = try s.dumpStringAlloc(alloc, .{ .active = .{} }); - defer alloc.free(str); - try testing.expectEqualStrings("2\n3\n4\n5\n6", str); - } - { - const str = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); - defer alloc.free(str); - try testing.expectEqualStrings("A\nB\nC\n1\n2\n3\n4\n5\n6", str); - } - - s.eraseRows(.{ .history = .{} }, null); - - { - const str = try s.dumpStringAlloc(alloc, .{ .active = .{} }); - defer alloc.free(str); - try testing.expectEqualStrings("2\n3\n4\n5\n6", str); - } - { - const str = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); - defer alloc.free(str); - try testing.expectEqualStrings("2\n3\n4\n5\n6", str); - } -} - -test "Screen: scrolling" { - const testing = std.testing; - const alloc = testing.allocator; - - var s = try init(alloc, 10, 3, 0); - defer s.deinit(); - try s.setAttribute(.{ .direct_color_bg = .{ .r = 155 } }); - try s.testWriteString("1ABCD\n2EFGH\n3IJKL"); - - // Scroll down, should still be bottom - try s.cursorDownScroll(); - { - const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); - defer alloc.free(contents); - try testing.expectEqualStrings("2EFGH\n3IJKL", contents); - } - { - const list_cell = s.pages.getCell(.{ .active = .{ .x = 0, .y = 2 } }).?; - const cell = list_cell.cell; - try testing.expect(cell.content_tag == .bg_color_rgb); - try testing.expectEqual(Cell.RGB{ - .r = 155, - .g = 0, - .b = 0, - }, cell.content.color_rgb); - } - - // Scrolling to the bottom does nothing - s.scroll(.{ .active = {} }); - - { - const contents = try s.dumpStringAlloc(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, 10, 3, 0); - defer s.deinit(); - try s.testWriteString("1ABCD\n2EFGH\n3IJKL"); - - // Scrolling up does nothing, but allows it - s.scroll(.{ .delta_row = -1 }); - try testing.expect(s.pages.viewport == .active); - - { - const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); - defer alloc.free(contents); - try testing.expectEqualStrings("1ABCD\n2EFGH\n3IJKL", contents); - } -} - -test "Screen: scrollback various cases" { - const testing = std.testing; - const alloc = testing.allocator; - - var s = try init(alloc, 10, 3, 1); - defer s.deinit(); - try s.testWriteString("1ABCD\n2EFGH\n3IJKL"); - try s.cursorDownScroll(); - - { - const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); - defer alloc.free(contents); - try testing.expectEqualStrings("2EFGH\n3IJKL", contents); - } - - // Scrolling to the bottom - s.scroll(.{ .active = {} }); - { - const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); - defer alloc.free(contents); - try testing.expectEqualStrings("2EFGH\n3IJKL", contents); - } - - // Scrolling back should make it visible again - s.scroll(.{ .delta_row = -1 }); - try testing.expect(s.pages.viewport != .active); - { - const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); - defer alloc.free(contents); - try testing.expectEqualStrings("1ABCD\n2EFGH\n3IJKL", contents); - } - - // Scrolling back again should do nothing - s.scroll(.{ .delta_row = -1 }); - { - const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); - defer alloc.free(contents); - try testing.expectEqualStrings("1ABCD\n2EFGH\n3IJKL", contents); - } - - // Scrolling to the bottom - s.scroll(.{ .active = {} }); - { - const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); - defer alloc.free(contents); - try testing.expectEqualStrings("2EFGH\n3IJKL", contents); - } - - // Scrolling forward with no grow should do nothing - s.scroll(.{ .delta_row = 1 }); - { - const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); - defer alloc.free(contents); - try testing.expectEqualStrings("2EFGH\n3IJKL", contents); - } - - // Scrolling to the top should work - s.scroll(.{ .top = {} }); - { - const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); - defer alloc.free(contents); - try testing.expectEqualStrings("1ABCD\n2EFGH\n3IJKL", contents); - } - - // Should be able to easily clear active area only - s.clearRows(.{ .active = .{} }, null, false); - { - const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); - defer alloc.free(contents); - try testing.expectEqualStrings("1ABCD", contents); - } - - // Scrolling to the bottom - s.scroll(.{ .active = {} }); - { - const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); - defer alloc.free(contents); - try testing.expectEqualStrings("", contents); - } -} - -test "Screen: scrollback with multi-row delta" { - const testing = std.testing; - const alloc = testing.allocator; - - var s = try init(alloc, 10, 3, 3); - defer s.deinit(); - try s.testWriteString("1ABCD\n2EFGH\n3IJKL\n4ABCD\n5EFGH\n6IJKL"); - - // Scroll to top - s.scroll(.{ .top = {} }); - { - const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); - defer alloc.free(contents); - try testing.expectEqualStrings("1ABCD\n2EFGH\n3IJKL", contents); - } - - // Scroll down multiple - s.scroll(.{ .delta_row = 5 }); - try testing.expect(s.pages.viewport == .active); - { - const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); - defer alloc.free(contents); - try testing.expectEqualStrings("4ABCD\n5EFGH\n6IJKL", contents); - } -} - -test "Screen: scrollback empty" { - const testing = std.testing; - const alloc = testing.allocator; - - var s = try init(alloc, 10, 3, 50); - defer s.deinit(); - try s.testWriteString("1ABCD\n2EFGH\n3IJKL"); - s.scroll(.{ .delta_row = 1 }); - { - const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); - defer alloc.free(contents); - try testing.expectEqualStrings("1ABCD\n2EFGH\n3IJKL", contents); - } -} - -test "Screen: scrollback doesn't move viewport if not at bottom" { - const testing = std.testing; - const alloc = testing.allocator; - - var s = try init(alloc, 10, 3, 3); - defer s.deinit(); - try s.testWriteString("1ABCD\n2EFGH\n3IJKL\n4ABCD\n5EFGH"); - - // First test: we scroll up by 1, so we're not at the bottom anymore. - s.scroll(.{ .delta_row = -1 }); - { - const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); - defer alloc.free(contents); - try testing.expectEqualStrings("2EFGH\n3IJKL\n4ABCD", contents); - } - - // Next, we scroll back down by 1, this grows the scrollback but we - // shouldn't move. - try s.cursorDownScroll(); - { - const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); - defer alloc.free(contents); - try testing.expectEqualStrings("2EFGH\n3IJKL\n4ABCD", contents); - } - - // Scroll again, this clears scrollback so we should move viewports - // but still see the same thing since our original view fits. - try s.cursorDownScroll(); - { - const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); - defer alloc.free(contents); - try testing.expectEqualStrings("2EFGH\n3IJKL\n4ABCD", contents); - } -} - -test "Screen: scroll and clear full screen" { - const testing = std.testing; - const alloc = testing.allocator; - - var s = try init(alloc, 10, 3, 5); - defer s.deinit(); - try s.testWriteString("1ABCD\n2EFGH\n3IJKL"); - - { - const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); - defer alloc.free(contents); - try testing.expectEqualStrings("1ABCD\n2EFGH\n3IJKL", contents); - } - - try s.scrollClear(); - { - const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); - defer alloc.free(contents); - try testing.expectEqualStrings("", contents); - } - { - const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); - defer alloc.free(contents); - try testing.expectEqualStrings("1ABCD\n2EFGH\n3IJKL", contents); - } -} - -test "Screen: scroll and clear partial screen" { - const testing = std.testing; - const alloc = testing.allocator; - - var s = try init(alloc, 10, 3, 5); - defer s.deinit(); - try s.testWriteString("1ABCD\n2EFGH"); - - { - const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); - defer alloc.free(contents); - try testing.expectEqualStrings("1ABCD\n2EFGH", contents); - } - - try s.scrollClear(); - { - const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); - defer alloc.free(contents); - try testing.expectEqualStrings("", contents); - } - { - const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); - defer alloc.free(contents); - try testing.expectEqualStrings("1ABCD\n2EFGH", contents); - } -} - -test "Screen: scroll and clear empty screen" { - const testing = std.testing; - const alloc = testing.allocator; - - var s = try init(alloc, 10, 3, 5); - defer s.deinit(); - try s.scrollClear(); - { - const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); - defer alloc.free(contents); - try testing.expectEqualStrings("", contents); - } - { - const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); - defer alloc.free(contents); - try testing.expectEqualStrings("", contents); - } -} - -test "Screen: scroll and clear ignore blank lines" { - const testing = std.testing; - const alloc = testing.allocator; - - var s = try init(alloc, 10, 3, 10); - defer s.deinit(); - try s.testWriteString("1ABCD\n2EFGH"); - try s.scrollClear(); - { - const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); - defer alloc.free(contents); - try testing.expectEqualStrings("", contents); - } - - // Move back to top-left - s.cursorAbsolute(0, 0); - - // Write and clear - try s.testWriteString("3ABCD\n"); - { - const contents = try s.dumpStringAlloc(alloc, .{ .active = .{} }); - defer alloc.free(contents); - try testing.expectEqualStrings("3ABCD", contents); - } - - try s.scrollClear(); - { - const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); - defer alloc.free(contents); - try testing.expectEqualStrings("", contents); - } - - // Move back to top-left - s.cursorAbsolute(0, 0); - try s.testWriteString("X"); - - { - const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); - defer alloc.free(contents); - try testing.expectEqualStrings("1ABCD\n2EFGH\n3ABCD\nX", contents); - } -} - -test "Screen: clone" { - const testing = std.testing; - const alloc = testing.allocator; - - var s = try init(alloc, 10, 3, 10); - defer s.deinit(); - try s.testWriteString("1ABCD\n2EFGH"); - { - const contents = try s.dumpStringAlloc(alloc, .{ .active = .{} }); - defer alloc.free(contents); - try testing.expectEqualStrings("1ABCD\n2EFGH", contents); - } - - // Clone - var s2 = try s.clone(alloc, .{ .active = .{} }, null); - defer s2.deinit(); - { - const contents = try s2.dumpStringAlloc(alloc, .{ .active = .{} }); - defer alloc.free(contents); - try testing.expectEqualStrings("1ABCD\n2EFGH", contents); - } - - // Write to s1, should not be in s2 - try s.testWriteString("\n34567"); - { - const contents = try s.dumpStringAlloc(alloc, .{ .active = .{} }); - defer alloc.free(contents); - try testing.expectEqualStrings("1ABCD\n2EFGH\n34567", contents); - } - { - const contents = try s2.dumpStringAlloc(alloc, .{ .active = .{} }); - defer alloc.free(contents); - try testing.expectEqualStrings("1ABCD\n2EFGH", contents); - } -} - -test "Screen: clone partial" { - const testing = std.testing; - const alloc = testing.allocator; - - var s = try init(alloc, 10, 3, 10); - defer s.deinit(); - try s.testWriteString("1ABCD\n2EFGH"); - { - const contents = try s.dumpStringAlloc(alloc, .{ .active = .{} }); - defer alloc.free(contents); - try testing.expectEqualStrings("1ABCD\n2EFGH", contents); - } - - // Clone - var s2 = try s.clone(alloc, .{ .active = .{ .y = 1 } }, null); - defer s2.deinit(); - { - const contents = try s2.dumpStringAlloc(alloc, .{ .active = .{} }); - defer alloc.free(contents); - try testing.expectEqualStrings("2EFGH", contents); - } -} - -test "Screen: clone basic" { - const testing = std.testing; - const alloc = testing.allocator; - - var s = try init(alloc, 10, 3, 0); - defer s.deinit(); - try s.testWriteString("1ABCD\n2EFGH\n3IJKL"); - - { - var s2 = try s.clone( - alloc, - .{ .active = .{ .y = 1 } }, - .{ .active = .{ .y = 1 } }, - ); - defer s2.deinit(); - - // Test our contents rotated - const contents = try s2.dumpStringAlloc(alloc, .{ .active = .{} }); - defer alloc.free(contents); - try testing.expectEqualStrings("2EFGH", contents); - } - - { - var s2 = try s.clone( - alloc, - .{ .active = .{ .y = 1 } }, - .{ .active = .{ .y = 2 } }, - ); - defer s2.deinit(); - - // Test our contents rotated - const contents = try s2.dumpStringAlloc(alloc, .{ .active = .{} }); - defer alloc.free(contents); - try testing.expectEqualStrings("2EFGH\n3IJKL", contents); - } -} - -test "Screen: clone empty viewport" { - const testing = std.testing; - const alloc = testing.allocator; - - var s = try init(alloc, 10, 3, 0); - defer s.deinit(); - - { - var s2 = try s.clone( - alloc, - .{ .viewport = .{ .y = 0 } }, - .{ .viewport = .{ .y = 0 } }, - ); - defer s2.deinit(); - - // Test our contents rotated - const contents = try s2.dumpStringAlloc(alloc, .{ .viewport = .{} }); - defer alloc.free(contents); - try testing.expectEqualStrings("", contents); - } -} - -test "Screen: clone one line viewport" { - const testing = std.testing; - const alloc = testing.allocator; - - var s = try init(alloc, 10, 3, 0); - defer s.deinit(); - try s.testWriteString("1ABC"); - - { - var s2 = try s.clone( - alloc, - .{ .viewport = .{ .y = 0 } }, - .{ .viewport = .{ .y = 0 } }, - ); - defer s2.deinit(); - - // Test our contents - const contents = try s2.dumpStringAlloc(alloc, .{ .viewport = .{} }); - defer alloc.free(contents); - try testing.expectEqualStrings("1ABC", contents); - } -} - -test "Screen: clone empty active" { - const testing = std.testing; - const alloc = testing.allocator; - - var s = try init(alloc, 10, 3, 0); - defer s.deinit(); - - { - var s2 = try s.clone( - alloc, - .{ .active = .{ .y = 0 } }, - .{ .active = .{ .y = 0 } }, - ); - defer s2.deinit(); - - // Test our contents rotated - const contents = try s2.dumpStringAlloc(alloc, .{ .active = .{} }); - defer alloc.free(contents); - try testing.expectEqualStrings("", contents); - } -} - -test "Screen: clone one line active with extra space" { - const testing = std.testing; - const alloc = testing.allocator; - - var s = try init(alloc, 10, 3, 0); - defer s.deinit(); - try s.testWriteString("1ABC"); - - { - var s2 = try s.clone( - alloc, - .{ .active = .{ .y = 0 } }, - null, - ); - defer s2.deinit(); - - // Test our contents rotated - const contents = try s2.dumpStringAlloc(alloc, .{ .active = .{} }); - defer alloc.free(contents); - try testing.expectEqualStrings("1ABC", contents); - } -} - -test "Screen: clear history with no history" { - const testing = std.testing; - const alloc = testing.allocator; - - var s = try init(alloc, 10, 3, 3); - defer s.deinit(); - try s.testWriteString("4ABCD\n5EFGH\n6IJKL"); - try testing.expect(s.pages.viewport == .active); - s.eraseRows(.{ .history = .{} }, null); - try testing.expect(s.pages.viewport == .active); - { - // Test our contents rotated - const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); - defer alloc.free(contents); - try testing.expectEqualStrings("4ABCD\n5EFGH\n6IJKL", contents); - } - { - // Test our contents rotated - const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); - defer alloc.free(contents); - try testing.expectEqualStrings("4ABCD\n5EFGH\n6IJKL", contents); - } -} - -test "Screen: clear history" { - const testing = std.testing; - const alloc = testing.allocator; - - var s = try init(alloc, 10, 3, 3); - defer s.deinit(); - try s.testWriteString("1ABCD\n2EFGH\n3IJKL\n4ABCD\n5EFGH\n6IJKL"); - try testing.expect(s.pages.viewport == .active); - - // Scroll to top - s.scroll(.{ .top = {} }); - { - // Test our contents rotated - const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); - defer alloc.free(contents); - try testing.expectEqualStrings("1ABCD\n2EFGH\n3IJKL", contents); - } - - s.eraseRows(.{ .history = .{} }, null); - try testing.expect(s.pages.viewport == .active); - { - // Test our contents rotated - const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); - defer alloc.free(contents); - try testing.expectEqualStrings("4ABCD\n5EFGH\n6IJKL", contents); - } - { - // Test our contents rotated - const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); - defer alloc.free(contents); - try testing.expectEqualStrings("4ABCD\n5EFGH\n6IJKL", contents); - } -} - -test "Screen: clear above cursor" { - const testing = std.testing; - const alloc = testing.allocator; - - var s = try init(alloc, 10, 10, 3); - defer s.deinit(); - try s.testWriteString("4ABCD\n5EFGH\n6IJKL"); - s.clearRows( - .{ .active = .{ .y = 0 } }, - .{ .active = .{ .y = s.cursor.y - 1 } }, - false, - ); - { - const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); - defer alloc.free(contents); - try testing.expectEqualStrings("\n\n6IJKL", contents); - } - { - const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); - defer alloc.free(contents); - try testing.expectEqualStrings("\n\n6IJKL", contents); - } - - try testing.expectEqual(@as(usize, 5), s.cursor.x); - try testing.expectEqual(@as(usize, 2), s.cursor.y); -} - -test "Screen: clear above cursor with history" { - const testing = std.testing; - const alloc = testing.allocator; - - var s = try init(alloc, 10, 3, 3); - defer s.deinit(); - try s.testWriteString("1ABCD\n2EFGH\n3IJKL\n"); - try s.testWriteString("4ABCD\n5EFGH\n6IJKL"); - s.clearRows( - .{ .active = .{ .y = 0 } }, - .{ .active = .{ .y = s.cursor.y - 1 } }, - false, - ); - { - const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); - defer alloc.free(contents); - try testing.expectEqualStrings("\n\n6IJKL", contents); - } - { - const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); - defer alloc.free(contents); - try testing.expectEqualStrings("1ABCD\n2EFGH\n3IJKL\n\n\n6IJKL", contents); - } - - try testing.expectEqual(@as(usize, 5), s.cursor.x); - try testing.expectEqual(@as(usize, 2), s.cursor.y); -} - -test "Screen: resize (no reflow) more rows" { - const testing = std.testing; - const alloc = testing.allocator; - - var s = try init(alloc, 10, 3, 0); - defer s.deinit(); - const str = "1ABCD\n2EFGH\n3IJKL"; - try s.testWriteString(str); - - // Resize - try s.resizeWithoutReflow(10, 10); - { - const contents = try s.dumpStringAlloc(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, 10, 3, 0); - defer s.deinit(); - const str = "1ABCD\n2EFGH\n3IJKL"; - try s.testWriteString(str); - try testing.expectEqual(5, s.cursor.x); - try testing.expectEqual(2, s.cursor.y); - try s.resizeWithoutReflow(10, 2); - - // Since we shrunk, we should adjust our cursor - try testing.expectEqual(5, s.cursor.x); - try testing.expectEqual(1, s.cursor.y); - - { - const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); - defer alloc.free(contents); - try testing.expectEqualStrings("2EFGH\n3IJKL", contents); - } -} - -test "Screen: resize (no reflow) less rows trims blank lines" { - const testing = std.testing; - const alloc = testing.allocator; - - var s = try init(alloc, 10, 3, 0); - defer s.deinit(); - const str = "1ABCD"; - try s.testWriteString(str); - - // Write only a background color into the remaining rows - for (1..s.pages.rows) |y| { - const list_cell = s.pages.getCell(.{ .active = .{ .x = 0, .y = y } }).?; - list_cell.cell.* = .{ - .content_tag = .bg_color_rgb, - .content = .{ .color_rgb = .{ .r = 0xFF, .g = 0, .b = 0 } }, - }; - } - - const cursor = s.cursor; - try s.resizeWithoutReflow(6, 2); - - // Cursor should not move - try testing.expectEqual(cursor.x, s.cursor.x); - try testing.expectEqual(cursor.y, s.cursor.y); - - { - const contents = try s.dumpStringAlloc(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, 10, 3, 0); - defer s.deinit(); - const str = "1ABCD"; - try s.testWriteString(str); - - // Write only a background color into the remaining rows - for (1..s.pages.rows) |y| { - const list_cell = s.pages.getCell(.{ .active = .{ .x = 0, .y = y } }).?; - list_cell.cell.* = .{ - .content_tag = .bg_color_rgb, - .content = .{ .color_rgb = .{ .r = 0xFF, .g = 0, .b = 0 } }, - }; - } - - const cursor = s.cursor; - try s.resizeWithoutReflow(10, 7); - - // Cursor should not move - try testing.expectEqual(cursor.x, s.cursor.x); - try testing.expectEqual(cursor.y, s.cursor.y); - - { - const contents = try s.dumpStringAlloc(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; - - var s = try init(alloc, 10, 3, 0); - defer s.deinit(); - const str = "1ABCD\n2EFGH\n3IJKL"; - try s.testWriteString(str); - try s.resizeWithoutReflow(20, 3); - - { - const contents = try s.dumpStringAlloc(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, 10, 3, 0); - defer s.deinit(); - const str = "1ABCD\n2EFGH\n3IJKL"; - try s.testWriteString(str); - try s.resizeWithoutReflow(4, 3); - - { - const contents = try s.dumpStringAlloc(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 cursor end" { - const testing = std.testing; - const alloc = testing.allocator; - - var s = try init(alloc, 7, 3, 2); - defer s.deinit(); - const str = "1ABCD\n2EFGH\n3IJKL\n4ABCD\n5EFGH"; - try s.testWriteString(str); - try s.resizeWithoutReflow(7, 10); - - { - const contents = try s.dumpStringAlloc(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, 7, 3, 2); - defer s.deinit(); - const str = "1ABCD\n2EFGH\n3IJKL\n4ABCD\n5EFGH"; - try s.testWriteString(str); - try s.resizeWithoutReflow(7, 2); - - { - const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); - defer alloc.free(contents); - const expected = "4ABCD\n5EFGH"; - try testing.expectEqualStrings(expected, contents); - } -} - -// https://github.com/mitchellh/ghostty/issues/1030 -test "Screen: resize (no reflow) less rows with empty trailing" { - const testing = std.testing; - const alloc = testing.allocator; - - var s = try init(alloc, 5, 3, 5); - defer s.deinit(); - const str = "1\n2\n3\n4\n5\n6\n7\n8"; - try s.testWriteString(str); - try s.scrollClear(); - s.cursorAbsolute(0, 0); - try s.testWriteString("A\nB"); - - const cursor = s.cursor; - try s.resizeWithoutReflow(5, 2); - try testing.expectEqual(cursor.x, s.cursor.x); - try testing.expectEqual(cursor.y, s.cursor.y); - - { - const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); - defer alloc.free(contents); - try testing.expectEqualStrings("A\nB", contents); - } -} - -test "Screen: resize (no reflow) more rows with soft wrapping" { - const testing = std.testing; - const alloc = testing.allocator; - - var s = try init(alloc, 2, 3, 3); - defer s.deinit(); - const str = "1A2B\n3C4E\n5F6G"; - try s.testWriteString(str); - - // Every second row should be wrapped - for (0..6) |y| { - const list_cell = s.pages.getCell(.{ .screen = .{ .x = 0, .y = y } }).?; - const row = list_cell.row; - const wrapped = (y % 2 == 0); - try testing.expectEqual(wrapped, row.wrap); - } - - // Resize - try s.resizeWithoutReflow(2, 10); - { - const contents = try s.dumpStringAlloc(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 - for (0..6) |y| { - const list_cell = s.pages.getCell(.{ .screen = .{ .x = 0, .y = y } }).?; - const row = list_cell.row; - const wrapped = (y % 2 == 0); - try testing.expectEqual(wrapped, row.wrap); - } -} - -test "Screen: resize more rows no scrollback" { - const testing = std.testing; - const alloc = testing.allocator; - - var s = try init(alloc, 5, 3, 0); - defer s.deinit(); - const str = "1ABCD\n2EFGH\n3IJKL"; - try s.testWriteString(str); - const cursor = s.cursor; - try s.resize(5, 10); - - // Cursor should not move - try testing.expectEqual(cursor.x, s.cursor.x); - try testing.expectEqual(cursor.y, s.cursor.y); - - { - const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); - defer alloc.free(contents); - try testing.expectEqualStrings(str, contents); - } - { - const contents = try s.dumpStringAlloc(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, 5, 3, 10); - defer s.deinit(); - const str = "1ABCD\n2EFGH\n3IJKL"; - try s.testWriteString(str); - const cursor = s.cursor; - try s.resize(5, 10); - - // Cursor should not move - try testing.expectEqual(cursor.x, s.cursor.x); - try testing.expectEqual(cursor.y, s.cursor.y); - - { - const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); - defer alloc.free(contents); - try testing.expectEqualStrings(str, contents); - } - { - const contents = try s.dumpStringAlloc(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, 5, 3, 5); - defer s.deinit(); - const str = "1ABCD\n2EFGH\n3IJKL\n4ABCD\n5EFGH"; - try s.testWriteString(str); - { - const contents = try s.dumpStringAlloc(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.cursorAbsolute(0, 1); - { - const list_cell = s.pages.getCell(.{ .active = .{ - .x = s.cursor.x, - .y = s.cursor.y, - } }).?; - try testing.expectEqual(@as(u21, '4'), list_cell.cell.content.codepoint); - } - - // Resize - try s.resize(5, 10); - - // Cursor should still be on the "4" - { - const list_cell = s.pages.getCell(.{ .active = .{ - .x = s.cursor.x, - .y = s.cursor.y, - } }).?; - try testing.expectEqual(@as(u21, '4'), list_cell.cell.content.codepoint); - } - - { - const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); - defer alloc.free(contents); - const expected = "3IJKL\n4ABCD\n5EFGH"; - try testing.expectEqualStrings(expected, contents); - } -} - -test "Screen: resize more cols no reflow" { - const testing = std.testing; - const alloc = testing.allocator; - - var s = try init(alloc, 5, 3, 0); - defer s.deinit(); - const str = "1ABCD\n2EFGH\n3IJKL"; - try s.testWriteString(str); - - const cursor = s.cursor; - try s.resize(10, 3); - - // Cursor should not move - try testing.expectEqual(cursor.x, s.cursor.x); - try testing.expectEqual(cursor.y, s.cursor.y); - - { - const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); - defer alloc.free(contents); - try testing.expectEqualStrings(str, contents); - } - { - const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); - defer alloc.free(contents); - try testing.expectEqualStrings(str, contents); - } -} - -// https://github.com/mitchellh/ghostty/issues/272#issuecomment-1676038963 -test "Screen: resize more cols perfect split" { - const testing = std.testing; - const alloc = testing.allocator; - - var s = try init(alloc, 5, 3, 0); - defer s.deinit(); - const str = "1ABCD2EFGH3IJKL"; - try s.testWriteString(str); - try s.resize(10, 3); - - { - const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); - defer alloc.free(contents); - try testing.expectEqualStrings("1ABCD2EFGH\n3IJKL", contents); - } -} - -// https://github.com/mitchellh/ghostty/issues/1159 -test "Screen: resize (no reflow) more cols with scrollback scrolled up" { - const testing = std.testing; - const alloc = testing.allocator; - - var s = try init(alloc, 5, 3, 5); - defer s.deinit(); - const str = "1\n2\n3\n4\n5\n6\n7\n8"; - try s.testWriteString(str); - - // Cursor at bottom - try testing.expectEqual(@as(size.CellCountInt, 1), s.cursor.x); - try testing.expectEqual(@as(size.CellCountInt, 2), s.cursor.y); - - s.scroll(.{ .delta_row = -4 }); - { - const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); - defer alloc.free(contents); - try testing.expectEqualStrings("2\n3\n4", contents); - } - - try s.resize(8, 3); - { - const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); - defer alloc.free(contents); - try testing.expectEqualStrings(str, contents); - } - - // Cursor remains at bottom - try testing.expectEqual(@as(size.CellCountInt, 1), s.cursor.x); - try testing.expectEqual(@as(size.CellCountInt, 2), s.cursor.y); -} - -// https://github.com/mitchellh/ghostty/issues/1159 -test "Screen: resize (no reflow) less cols with scrollback scrolled up" { - const testing = std.testing; - const alloc = testing.allocator; - - var s = try init(alloc, 5, 3, 5); - defer s.deinit(); - const str = "1\n2\n3\n4\n5\n6\n7\n8"; - try s.testWriteString(str); - - // Cursor at bottom - try testing.expectEqual(@as(size.CellCountInt, 1), s.cursor.x); - try testing.expectEqual(@as(size.CellCountInt, 2), s.cursor.y); - - s.scroll(.{ .delta_row = -4 }); - { - const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); - defer alloc.free(contents); - try testing.expectEqualStrings("2\n3\n4", contents); - } - - try s.resize(4, 3); - { - const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); - defer alloc.free(contents); - try testing.expectEqualStrings(str, contents); - } - { - const contents = try s.dumpStringAlloc(alloc, .{ .active = .{} }); - defer alloc.free(contents); - try testing.expectEqualStrings("6\n7\n8", contents); - } - - // Cursor remains at bottom - try testing.expectEqual(@as(size.CellCountInt, 1), s.cursor.x); - try testing.expectEqual(@as(size.CellCountInt, 2), s.cursor.y); - - // Old implementation doesn't do this but it makes sense to me: - // { - // const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); - // defer alloc.free(contents); - // try testing.expectEqualStrings("2\n3\n4", contents); - // } -} - -test "Screen: resize more cols no reflow preserves semantic prompt" { - const testing = std.testing; - const alloc = testing.allocator; - - var s = try init(alloc, 5, 3, 0); - defer s.deinit(); - const str = "1ABCD\n2EFGH\n3IJKL"; - try s.testWriteString(str); - - // Set one of the rows to be a prompt - { - s.cursorAbsolute(0, 1); - s.cursor.page_row.semantic_prompt = .prompt; - } - - try s.resize(10, 3); - - { - const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); - defer alloc.free(contents); - try testing.expectEqualStrings(str, contents); - } - { - const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); - defer alloc.free(contents); - try testing.expectEqualStrings(str, contents); - } - - // Our one row should still be a semantic prompt, the others should not. - { - const list_cell = s.pages.getCell(.{ .active = .{ .x = 0, .y = 0 } }).?; - try testing.expect(list_cell.row.semantic_prompt == .unknown); - } - { - const list_cell = s.pages.getCell(.{ .active = .{ .x = 0, .y = 1 } }).?; - try testing.expect(list_cell.row.semantic_prompt == .prompt); - } - { - const list_cell = s.pages.getCell(.{ .active = .{ .x = 0, .y = 2 } }).?; - try testing.expect(list_cell.row.semantic_prompt == .unknown); - } -} - -test "Screen: resize more cols with reflow that fits full width" { - const testing = std.testing; - const alloc = testing.allocator; - - var s = try init(alloc, 5, 3, 0); - defer s.deinit(); - const str = "1ABCD2EFGH\n3IJKL"; - try s.testWriteString(str); - - // Verify we soft wrapped - { - const contents = try s.dumpStringAlloc(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.cursorAbsolute(0, 1); - { - const list_cell = s.pages.getCell(.{ .active = .{ - .x = s.cursor.x, - .y = s.cursor.y, - } }).?; - try testing.expectEqual(@as(u21, '2'), list_cell.cell.content.codepoint); - } - - // Resize and verify we undid the soft wrap because we have space now - try s.resize(10, 3); - { - const contents = try s.dumpStringAlloc(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, 6, 3, 0); - defer s.deinit(); - const str = "1ABCD2EFGH\n3IJKL"; - try s.testWriteString(str); - - // Verify we soft wrapped - { - const contents = try s.dumpStringAlloc(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.cursorAbsolute(0, 2); - { - const list_cell = s.pages.getCell(.{ .active = .{ - .x = s.cursor.x, - .y = s.cursor.y, - } }).?; - try testing.expectEqual(@as(u21, '3'), list_cell.cell.content.codepoint); - } - - // Resize and verify we undid the soft wrap because we have space now - try s.resize(10, 3); - { - const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); - defer alloc.free(contents); - try testing.expectEqualStrings(str, contents); - } - - // Our cursor should still be on the 3 - { - const list_cell = s.pages.getCell(.{ .active = .{ - .x = s.cursor.x, - .y = s.cursor.y, - } }).?; - try testing.expectEqual(@as(u21, '3'), list_cell.cell.content.codepoint); - } -} - -test "Screen: resize more cols with reflow that forces more wrapping" { - const testing = std.testing; - const alloc = testing.allocator; - - var s = try init(alloc, 5, 3, 0); - defer s.deinit(); - const str = "1ABCD2EFGH\n3IJKL"; - try s.testWriteString(str); - - // Let's put our cursor on row 2, where the soft wrap is - s.cursorAbsolute(0, 1); - { - const list_cell = s.pages.getCell(.{ .active = .{ - .x = s.cursor.x, - .y = s.cursor.y, - } }).?; - try testing.expectEqual(@as(u21, '2'), list_cell.cell.content.codepoint); - } - - // Verify we soft wrapped - { - const contents = try s.dumpStringAlloc(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(7, 3); - { - const contents = try s.dumpStringAlloc(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(size.CellCountInt, 5), s.cursor.x); - try testing.expectEqual(@as(size.CellCountInt, 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, 5, 3, 0); - defer s.deinit(); - const str = "1ABCD2EFGH3IJKL"; - try s.testWriteString(str); - - // Let's put our cursor on row 2, where the soft wrap is - s.cursorAbsolute(0, 2); - { - const list_cell = s.pages.getCell(.{ .active = .{ - .x = s.cursor.x, - .y = s.cursor.y, - } }).?; - try testing.expectEqual(@as(u21, '3'), list_cell.cell.content.codepoint); - } - - // Verify we soft wrapped - { - const contents = try s.dumpStringAlloc(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(15, 3); - { - const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); - defer alloc.free(contents); - const expected = "1ABCD2EFGH3IJKL"; - try testing.expectEqualStrings(expected, contents); - } - - // Our cursor should've moved - try testing.expectEqual(@as(size.CellCountInt, 10), s.cursor.x); - try testing.expectEqual(@as(size.CellCountInt, 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, 5, 3, 5); - defer s.deinit(); - const str = "1ABCD\n2EFGH\n3IJKL\n4ABCD5EFGH"; - try s.testWriteString(str); - { - const contents = try s.dumpStringAlloc(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.cursorAbsolute(0, 2); - { - const list_cell = s.pages.getCell(.{ .active = .{ - .x = s.cursor.x, - .y = s.cursor.y, - } }).?; - try testing.expectEqual(@as(u21, '5'), list_cell.cell.content.codepoint); - } - - // Resize - try s.resize(10, 3); - { - const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); - defer alloc.free(contents); - const expected = "2EFGH\n3IJKL\n4ABCD5EFGH"; - try testing.expectEqualStrings(expected, contents); - } - - // Cursor should still be on the "5" - { - const list_cell = s.pages.getCell(.{ .active = .{ - .x = s.cursor.x, - .y = s.cursor.y, - } }).?; - try testing.expectEqual(@as(u21, '5'), list_cell.cell.content.codepoint); - } -} - -test "Screen: resize more cols with reflow" { - const testing = std.testing; - const alloc = testing.allocator; - - var s = try init(alloc, 2, 3, 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.cursorAbsolute(0, 2); - { - const list_cell = s.pages.getCell(.{ .active = .{ - .x = s.cursor.x, - .y = s.cursor.y, - } }).?; - try testing.expectEqual(@as(u32, 'E'), list_cell.cell.content.codepoint); - } - - // Verify we soft wrapped - { - const contents = try s.dumpStringAlloc(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(7, 3); - { - const contents = try s.dumpStringAlloc(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(size.CellCountInt, 2), s.cursor.x); - try testing.expectEqual(@as(size.CellCountInt, 2), s.cursor.y); -} - -test "Screen: resize more rows and cols with wrapping" { - const testing = std.testing; - const alloc = testing.allocator; - - var s = try init(alloc, 2, 4, 0); - defer s.deinit(); - const str = "1A2B\n3C4D"; - try s.testWriteString(str); - { - const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); - defer alloc.free(contents); - const expected = "1A\n2B\n3C\n4D"; - try testing.expectEqualStrings(expected, contents); - } - - try s.resize(5, 10); - - // Cursor should move due to wrapping - try testing.expectEqual(@as(size.CellCountInt, 3), s.cursor.x); - try testing.expectEqual(@as(size.CellCountInt, 1), s.cursor.y); - - { - const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); - defer alloc.free(contents); - try testing.expectEqualStrings(str, contents); - } - { - const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); - defer alloc.free(contents); - try testing.expectEqualStrings(str, contents); - } -} - -test "Screen: resize less rows no scrollback" { - const testing = std.testing; - const alloc = testing.allocator; - - var s = try init(alloc, 5, 3, 0); - defer s.deinit(); - const str = "1ABCD\n2EFGH\n3IJKL"; - try s.testWriteString(str); - - s.cursorAbsolute(0, 0); - const cursor = s.cursor; - try s.resize(5, 1); - - // Cursor should not move - try testing.expectEqual(cursor.x, s.cursor.x); - try testing.expectEqual(cursor.y, s.cursor.y); - - { - const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); - defer alloc.free(contents); - const expected = "3IJKL"; - try testing.expectEqualStrings(expected, contents); - } - { - const contents = try s.dumpStringAlloc(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, 5, 3, 0); - defer s.deinit(); - const str = "1ABCD\n2EFGH\n3IJKL"; - try s.testWriteString(str); - - // Put our cursor on the last line - s.cursorAbsolute(1, 2); - { - const list_cell = s.pages.getCell(.{ .active = .{ - .x = s.cursor.x, - .y = s.cursor.y, - } }).?; - try testing.expectEqual(@as(u32, 'I'), list_cell.cell.content.codepoint); - } - - // Resize - try s.resize(5, 1); - - { - const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); - defer alloc.free(contents); - const expected = "3IJKL"; - try testing.expectEqualStrings(expected, contents); - } - { - const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); - defer alloc.free(contents); - const expected = "3IJKL"; - try testing.expectEqualStrings(expected, contents); - } - - // Cursor should be on the last line - try testing.expectEqual(@as(size.CellCountInt, 1), s.cursor.x); - try testing.expectEqual(@as(size.CellCountInt, 0), s.cursor.y); -} - -test "Screen: resize less rows with empty scrollback" { - const testing = std.testing; - const alloc = testing.allocator; - - var s = try init(alloc, 5, 3, 10); - defer s.deinit(); - const str = "1ABCD\n2EFGH\n3IJKL"; - try s.testWriteString(str); - try s.resize(5, 1); - - { - const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); - defer alloc.free(contents); - try testing.expectEqualStrings(str, contents); - } - { - const contents = try s.dumpStringAlloc(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, 5, 3, 5); - defer s.deinit(); - const str = "1ABCD\n2EFGH\n3IJKL\n4ABCD\n5EFGH"; - try s.testWriteString(str); - { - const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); - defer alloc.free(contents); - const expected = "3IJKL\n4ABCD\n5EFGH"; - try testing.expectEqualStrings(expected, contents); - } - - // Resize - try s.resize(5, 1); - - { - const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); - defer alloc.free(contents); - try testing.expectEqualStrings(str, contents); - } - { - const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); - defer alloc.free(contents); - const expected = "5EFGH"; - try testing.expectEqualStrings(expected, contents); - } -} - -test "Screen: resize less rows with full scrollback" { - const testing = std.testing; - const alloc = testing.allocator; - - var s = try init(alloc, 5, 3, 3); - defer s.deinit(); - const str = "00000\n1ABCD\n2EFGH\n3IJKL\n4ABCD\n5EFGH"; - try s.testWriteString(str); - { - const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); - defer alloc.free(contents); - const expected = "3IJKL\n4ABCD\n5EFGH"; - try testing.expectEqualStrings(expected, contents); - } - - try testing.expectEqual(@as(size.CellCountInt, 4), s.cursor.x); - try testing.expectEqual(@as(size.CellCountInt, 2), s.cursor.y); - - // Resize - try s.resize(5, 2); - - // Cursor should stay in the same relative place (bottom of the - // screen, same character). - try testing.expectEqual(@as(size.CellCountInt, 4), s.cursor.x); - try testing.expectEqual(@as(size.CellCountInt, 1), s.cursor.y); - - { - const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); - defer alloc.free(contents); - const expected = "00000\n1ABCD\n2EFGH\n3IJKL\n4ABCD\n5EFGH"; - try testing.expectEqualStrings(expected, contents); - } - { - const contents = try s.dumpStringAlloc(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; - - var s = try init(alloc, 5, 3, 0); - defer s.deinit(); - const str = "1AB\n2EF\n3IJ"; - try s.testWriteString(str); - - s.cursorAbsolute(0, 0); - const cursor = s.cursor; - try s.resize(3, 3); - - // Cursor should not move - try testing.expectEqual(cursor.x, s.cursor.x); - try testing.expectEqual(cursor.y, s.cursor.y); - - { - const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); - defer alloc.free(contents); - try testing.expectEqualStrings(str, contents); - } - { - const contents = try s.dumpStringAlloc(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, 5, 3, 1); - defer s.deinit(); - const str = "1ABCD"; - try s.testWriteString(str); - - // Put our cursor on the end - s.cursorAbsolute(4, 0); - { - const list_cell = s.pages.getCell(.{ .active = .{ - .x = s.cursor.x, - .y = s.cursor.y, - } }).?; - try testing.expectEqual(@as(u32, 'D'), list_cell.cell.content.codepoint); - } - - try s.resize(3, 3); - { - const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); - defer alloc.free(contents); - const expected = "1AB\nCD"; - try testing.expectEqualStrings(expected, contents); - } - { - const contents = try s.dumpStringAlloc(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(size.CellCountInt, 1), s.cursor.x); - try testing.expectEqual(@as(size.CellCountInt, 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, 5, 3, 0); - defer s.deinit(); - const str = "3IJKL\n4ABCD\n5EFGH"; - try s.testWriteString(str); - try s.resize(3, 3); - - { - const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); - defer alloc.free(contents); - const expected = "CD\n5EF\nGH"; - try testing.expectEqualStrings(expected, contents); - } - { - const contents = try s.dumpStringAlloc(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, 5, 3, 1); - defer s.deinit(); - const str = "3IJKL\n4ABCD\n5EFGH"; - try s.testWriteString(str); - try s.resize(3, 3); - - { - const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); - defer alloc.free(contents); - const expected = "CD\n5EF\nGH"; - try testing.expectEqualStrings(expected, contents); - } - { - const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); - defer alloc.free(contents); - const expected = "3IJ\nKL\n4AB\nCD\n5EF\nGH"; - try testing.expectEqualStrings(expected, contents); - } -} - -test "Screen: resize less cols with reflow previously wrapped" { - const testing = std.testing; - const alloc = testing.allocator; - - var s = try init(alloc, 5, 3, 0); - defer s.deinit(); - const str = "3IJKL4ABCD5EFGH"; - try s.testWriteString(str); - - // Check - { - const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); - defer alloc.free(contents); - const expected = "3IJKL\n4ABCD\n5EFGH"; - try testing.expectEqualStrings(expected, contents); - } - - try s.resize(3, 3); - - // { - // const contents = try s.testString(alloc, .viewport); - // defer alloc.free(contents); - // const expected = "CD\n5EF\nGH"; - // try testing.expectEqualStrings(expected, contents); - // } - { - const contents = try s.dumpStringAlloc(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, 5, 3, 5); - defer s.deinit(); - const str = "1A\n2B\n3C\n4D\n5E"; - try s.testWriteString(str); - - // Put our cursor on the end - s.cursorAbsolute(1, s.pages.rows - 1); - { - const list_cell = s.pages.getCell(.{ .active = .{ - .x = s.cursor.x, - .y = s.cursor.y, - } }).?; - try testing.expectEqual(@as(u32, 'E'), list_cell.cell.content.codepoint); - } - - try s.resize(3, 3); - - { - const contents = try s.dumpStringAlloc(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(size.CellCountInt, 1), s.cursor.x); - try testing.expectEqual(@as(size.CellCountInt, 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, 5, 3, 2); - defer s.deinit(); - const str = "1ABCD2EFGH3IJKL4ABCD5EFGH"; - try s.testWriteString(str); - - // Check - { - const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); - defer alloc.free(contents); - const expected = "3IJKL\n4ABCD\n5EFGH"; - try testing.expectEqualStrings(expected, contents); - } - - // Put our cursor on the end - s.cursorAbsolute(s.pages.cols - 1, s.pages.rows - 1); - { - const list_cell = s.pages.getCell(.{ .active = .{ - .x = s.cursor.x, - .y = s.cursor.y, - } }).?; - try testing.expectEqual(@as(u32, 'H'), list_cell.cell.content.codepoint); - } - - try s.resize(3, 3); - - { - const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); - defer alloc.free(contents); - const expected = "CD5\nEFG\nH"; - try testing.expectEqualStrings(expected, contents); - } - { - const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); - defer alloc.free(contents); - const expected = "1AB\nCD2\nEFG\nH3I\nJKL\n4AB\nCD5\nEFG\nH"; - try testing.expectEqualStrings(expected, contents); - } - - // Cursor should be on the last line - try testing.expectEqual(@as(size.CellCountInt, 0), s.cursor.x); - try testing.expectEqual(@as(size.CellCountInt, 2), s.cursor.y); - { - const list_cell = s.pages.getCell(.{ .active = .{ - .x = s.cursor.x, - .y = s.cursor.y, - } }).?; - try testing.expectEqual(@as(u32, 'H'), list_cell.cell.content.codepoint); - } -} - -test "Screen: resize less cols with scrollback keeps cursor row" { - const testing = std.testing; - const alloc = testing.allocator; - - var s = try init(alloc, 5, 3, 5); - defer s.deinit(); - const str = "1A\n2B\n3C\n4D\n5E"; - try s.testWriteString(str); - - // Lets do a scroll and clear operation - try s.scrollClear(); - - // Move our cursor to the beginning - s.cursorAbsolute(0, 0); - - try s.resize(3, 3); - - { - const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); - defer alloc.free(contents); - const expected = ""; - try testing.expectEqualStrings(expected, contents); - } - - // Cursor should be on the last line - try testing.expectEqual(@as(size.CellCountInt, 0), s.cursor.x); - try testing.expectEqual(@as(size.CellCountInt, 0), s.cursor.y); -} - -test "Screen: resize more rows, less cols with reflow with scrollback" { - const testing = std.testing; - const alloc = testing.allocator; - - var s = try init(alloc, 5, 3, 3); - defer s.deinit(); - const str = "1ABCD\n2EFGH3IJKL\n4MNOP"; - try s.testWriteString(str); - - { - const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); - defer alloc.free(contents); - const expected = "1ABCD\n2EFGH\n3IJKL\n4MNOP"; - try testing.expectEqualStrings(expected, contents); - } - { - const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); - defer alloc.free(contents); - const expected = "2EFGH\n3IJKL\n4MNOP"; - try testing.expectEqualStrings(expected, contents); - } - - try s.resize(2, 10); - - { - const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); - defer alloc.free(contents); - const expected = "BC\nD\n2E\nFG\nH3\nIJ\nKL\n4M\nNO\nP"; - try testing.expectEqualStrings(expected, contents); - } - { - const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); - defer alloc.free(contents); - const expected = "1A\nBC\nD\n2E\nFG\nH3\nIJ\nKL\n4M\nNO\nP"; - 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, 5, 3, 10); - defer s.deinit(); - const str = "1ABC"; - try s.testWriteString(str); - - // Grow - try s.resize(5, 10); - { - const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); - defer alloc.free(contents); - try testing.expectEqualStrings(str, contents); - } - { - const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); - defer alloc.free(contents); - try testing.expectEqualStrings(str, contents); - } - - // Shrink - try s.resize(5, 3); - { - const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); - defer alloc.free(contents); - try testing.expectEqualStrings(str, contents); - } - { - const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); - defer alloc.free(contents); - try testing.expectEqualStrings(str, contents); - } - - // Grow again - try s.resize(5, 10); - { - const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); - defer alloc.free(contents); - try testing.expectEqualStrings(str, contents); - } - { - const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); - defer alloc.free(contents); - try testing.expectEqualStrings(str, contents); - } -} - -test "Screen: resize less cols to eliminate wide char" { - const testing = std.testing; - const alloc = testing.allocator; - - var s = try init(alloc, 2, 1, 0); - defer s.deinit(); - const str = "😀"; - try s.testWriteString(str); - { - const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); - defer alloc.free(contents); - try testing.expectEqualStrings(str, contents); - } - { - const list_cell = s.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; - const cell = list_cell.cell; - try testing.expectEqual(Cell.Wide.wide, cell.wide); - try testing.expectEqual(@as(u21, '😀'), cell.content.codepoint); - } - - // Resize to 1 column can't fit a wide char. So it should be deleted. - try s.resize(1, 1); - { - const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); - defer alloc.free(contents); - try testing.expectEqualStrings("", contents); - } - { - const list_cell = s.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; - const cell = list_cell.cell; - try testing.expectEqual(@as(u21, 0), cell.content.codepoint); - try testing.expectEqual(Cell.Wide.narrow, cell.wide); - } -} - -test "Screen: resize less cols to wrap wide char" { - const testing = std.testing; - const alloc = testing.allocator; - - var s = try init(alloc, 3, 3, 0); - defer s.deinit(); - const str = "x😀"; - try s.testWriteString(str); - { - const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); - defer alloc.free(contents); - try testing.expectEqualStrings(str, contents); - } - { - const list_cell = s.pages.getCell(.{ .screen = .{ .x = 1, .y = 0 } }).?; - const cell = list_cell.cell; - try testing.expectEqual(Cell.Wide.wide, cell.wide); - try testing.expectEqual(@as(u21, '😀'), cell.content.codepoint); - } - { - const list_cell = s.pages.getCell(.{ .screen = .{ .x = 2, .y = 0 } }).?; - const cell = list_cell.cell; - try testing.expectEqual(Cell.Wide.spacer_tail, cell.wide); - } - - try s.resize(2, 3); - { - const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); - defer alloc.free(contents); - try testing.expectEqualStrings("x\n😀", contents); - } - { - const list_cell = s.pages.getCell(.{ .screen = .{ .x = 1, .y = 0 } }).?; - const cell = list_cell.cell; - try testing.expectEqual(Cell.Wide.spacer_head, cell.wide); - try testing.expect(list_cell.row.wrap); - } -} - -test "Screen: resize less cols to eliminate wide char with row space" { - const testing = std.testing; - const alloc = testing.allocator; - - var s = try init(alloc, 2, 2, 0); - defer s.deinit(); - const str = "😀"; - try s.testWriteString(str); - { - const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); - defer alloc.free(contents); - try testing.expectEqualStrings(str, contents); - } - { - const list_cell = s.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; - const cell = list_cell.cell; - try testing.expectEqual(Cell.Wide.wide, cell.wide); - try testing.expectEqual(@as(u21, '😀'), cell.content.codepoint); - } - { - const list_cell = s.pages.getCell(.{ .screen = .{ .x = 1, .y = 0 } }).?; - const cell = list_cell.cell; - try testing.expectEqual(Cell.Wide.spacer_tail, cell.wide); - } - - try s.resize(1, 2); - { - const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); - defer alloc.free(contents); - try testing.expectEqualStrings("", contents); - } -} - -test "Screen: resize more cols with wide spacer head" { - const testing = std.testing; - const alloc = testing.allocator; - - var s = try init(alloc, 3, 2, 0); - defer s.deinit(); - const str = " 😀"; - try s.testWriteString(str); - { - const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); - defer alloc.free(contents); - try testing.expectEqualStrings(" \n😀", contents); - } - - // So this is the key point: we end up with a wide spacer head at - // the end of row 1, then the emoji, then a wide spacer tail on row 2. - // We should expect that if we resize to more cols, the wide spacer - // head is replaced with the emoji. - { - const list_cell = s.pages.getCell(.{ .screen = .{ .x = 2, .y = 0 } }).?; - const cell = list_cell.cell; - try testing.expectEqual(Cell.Wide.spacer_head, cell.wide); - } - { - const list_cell = s.pages.getCell(.{ .screen = .{ .x = 0, .y = 1 } }).?; - const cell = list_cell.cell; - try testing.expectEqual(Cell.Wide.wide, cell.wide); - } - { - const list_cell = s.pages.getCell(.{ .screen = .{ .x = 1, .y = 1 } }).?; - const cell = list_cell.cell; - try testing.expectEqual(Cell.Wide.spacer_tail, cell.wide); - } - - try s.resize(4, 2); - { - const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); - defer alloc.free(contents); - try testing.expectEqualStrings(str, contents); - } - { - const list_cell = s.pages.getCell(.{ .screen = .{ .x = 2, .y = 0 } }).?; - const cell = list_cell.cell; - try testing.expectEqual(Cell.Wide.wide, cell.wide); - try testing.expectEqual(@as(u21, '😀'), cell.content.codepoint); - } - { - const list_cell = s.pages.getCell(.{ .screen = .{ .x = 3, .y = 0 } }).?; - const cell = list_cell.cell; - try testing.expectEqual(Cell.Wide.spacer_tail, cell.wide); - } -} - -test "Screen: resize more cols with wide spacer head multiple lines" { - const testing = std.testing; - const alloc = testing.allocator; - - var s = try init(alloc, 3, 3, 0); - defer s.deinit(); - const str = "xxxyy😀"; - try s.testWriteString(str); - { - const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); - defer alloc.free(contents); - try testing.expectEqualStrings("xxx\nyy\n😀", contents); - } - - // Similar to the "wide spacer head" test, but this time we'er going - // to increase our columns such that multiple rows are unwrapped. - { - const list_cell = s.pages.getCell(.{ .screen = .{ .x = 2, .y = 1 } }).?; - const cell = list_cell.cell; - try testing.expectEqual(Cell.Wide.spacer_head, cell.wide); - } - { - const list_cell = s.pages.getCell(.{ .screen = .{ .x = 0, .y = 2 } }).?; - const cell = list_cell.cell; - try testing.expectEqual(Cell.Wide.wide, cell.wide); - } - { - const list_cell = s.pages.getCell(.{ .screen = .{ .x = 1, .y = 2 } }).?; - const cell = list_cell.cell; - try testing.expectEqual(Cell.Wide.spacer_tail, cell.wide); - } - - try s.resize(8, 2); - { - const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); - defer alloc.free(contents); - try testing.expectEqualStrings(str, contents); - } - { - const list_cell = s.pages.getCell(.{ .screen = .{ .x = 5, .y = 0 } }).?; - const cell = list_cell.cell; - try testing.expectEqual(Cell.Wide.wide, cell.wide); - try testing.expectEqual(@as(u21, '😀'), cell.content.codepoint); - } - { - const list_cell = s.pages.getCell(.{ .screen = .{ .x = 6, .y = 0 } }).?; - const cell = list_cell.cell; - try testing.expectEqual(Cell.Wide.spacer_tail, cell.wide); - } -} - -test "Screen: resize more cols requiring a wide spacer head" { - const testing = std.testing; - const alloc = testing.allocator; - - var s = try init(alloc, 2, 2, 0); - defer s.deinit(); - const str = "xx😀"; - try s.testWriteString(str); - { - const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); - defer alloc.free(contents); - try testing.expectEqualStrings("xx\n😀", contents); - } - { - const list_cell = s.pages.getCell(.{ .screen = .{ .x = 0, .y = 1 } }).?; - const cell = list_cell.cell; - try testing.expectEqual(Cell.Wide.wide, cell.wide); - } - { - const list_cell = s.pages.getCell(.{ .screen = .{ .x = 1, .y = 1 } }).?; - const cell = list_cell.cell; - try testing.expectEqual(Cell.Wide.spacer_tail, cell.wide); - } - - // This resizes to 3 columns, which isn't enough space for our wide - // char to enter row 1. But we need to mark the wide spacer head on the - // end of the first row since we're wrapping to the next row. - try s.resize(3, 2); - { - const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); - defer alloc.free(contents); - try testing.expectEqualStrings("xx\n😀", contents); - } - { - const list_cell = s.pages.getCell(.{ .screen = .{ .x = 2, .y = 0 } }).?; - const cell = list_cell.cell; - try testing.expectEqual(Cell.Wide.spacer_head, cell.wide); - } - { - const list_cell = s.pages.getCell(.{ .screen = .{ .x = 0, .y = 1 } }).?; - const cell = list_cell.cell; - try testing.expectEqual(Cell.Wide.wide, cell.wide); - try testing.expectEqual(@as(u21, '😀'), cell.content.codepoint); - } - { - const list_cell = s.pages.getCell(.{ .screen = .{ .x = 1, .y = 1 } }).?; - const cell = list_cell.cell; - try testing.expectEqual(Cell.Wide.spacer_tail, cell.wide); - } -} - -test "Screen: selectAll" { - const testing = std.testing; - const alloc = testing.allocator; - - var s = try init(alloc, 10, 10, 0); - defer s.deinit(); - - { - try s.testWriteString("ABC DEF\n 123\n456"); - var sel = s.selectAll().?; - defer sel.deinit(&s); - try testing.expectEqual(point.Point{ .screen = .{ - .x = 0, - .y = 0, - } }, s.pages.pointFromPin(.screen, sel.start()).?); - try testing.expectEqual(point.Point{ .screen = .{ - .x = 2, - .y = 2, - } }, s.pages.pointFromPin(.screen, sel.end()).?); - } - - { - try s.testWriteString("\nFOO\n BAR\n BAZ\n QWERTY\n 12345678"); - var sel = s.selectAll().?; - defer sel.deinit(&s); - try testing.expectEqual(point.Point{ .screen = .{ - .x = 0, - .y = 0, - } }, s.pages.pointFromPin(.screen, sel.start()).?); - try testing.expectEqual(point.Point{ .screen = .{ - .x = 8, - .y = 7, - } }, s.pages.pointFromPin(.screen, sel.end()).?); - } -} - -test "Screen: selectLine" { - const testing = std.testing; - const alloc = testing.allocator; - - var s = try init(alloc, 10, 10, 0); - defer s.deinit(); - try s.testWriteString("ABC DEF\n 123\n456"); - - // Outside of active area - // try testing.expect(s.selectLine(.{ .x = 13, .y = 0 }) == null); - // try testing.expect(s.selectLine(.{ .x = 0, .y = 5 }) == null); - - // Going forward - { - var sel = s.selectLine(s.pages.pin(.{ .active = .{ - .x = 0, - .y = 0, - } }).?).?; - defer sel.deinit(&s); - try testing.expectEqual(point.Point{ .screen = .{ - .x = 0, - .y = 0, - } }, s.pages.pointFromPin(.screen, sel.start()).?); - try testing.expectEqual(point.Point{ .screen = .{ - .x = 7, - .y = 0, - } }, s.pages.pointFromPin(.screen, sel.end()).?); - } - - // Going backward - { - var sel = s.selectLine(s.pages.pin(.{ .active = .{ - .x = 7, - .y = 0, - } }).?).?; - defer sel.deinit(&s); - try testing.expectEqual(point.Point{ .screen = .{ - .x = 0, - .y = 0, - } }, s.pages.pointFromPin(.screen, sel.start()).?); - try testing.expectEqual(point.Point{ .screen = .{ - .x = 7, - .y = 0, - } }, s.pages.pointFromPin(.screen, sel.end()).?); - } - - // Going forward and backward - { - var sel = s.selectLine(s.pages.pin(.{ .active = .{ - .x = 3, - .y = 0, - } }).?).?; - defer sel.deinit(&s); - try testing.expectEqual(point.Point{ .screen = .{ - .x = 0, - .y = 0, - } }, s.pages.pointFromPin(.screen, sel.start()).?); - try testing.expectEqual(point.Point{ .screen = .{ - .x = 7, - .y = 0, - } }, s.pages.pointFromPin(.screen, sel.end()).?); - } - - // Outside active area - { - var sel = s.selectLine(s.pages.pin(.{ .active = .{ - .x = 9, - .y = 0, - } }).?).?; - defer sel.deinit(&s); - try testing.expectEqual(point.Point{ .screen = .{ - .x = 0, - .y = 0, - } }, s.pages.pointFromPin(.screen, sel.start()).?); - try testing.expectEqual(point.Point{ .screen = .{ - .x = 7, - .y = 0, - } }, s.pages.pointFromPin(.screen, sel.end()).?); - } -} - -test "Screen: selectLine across soft-wrap" { - const testing = std.testing; - const alloc = testing.allocator; - - var s = try init(alloc, 5, 10, 0); - defer s.deinit(); - try s.testWriteString(" 12 34012 \n 123"); - - // Going forward - { - var sel = s.selectLine(s.pages.pin(.{ .active = .{ - .x = 1, - .y = 0, - } }).?).?; - defer sel.deinit(&s); - try testing.expectEqual(point.Point{ .screen = .{ - .x = 1, - .y = 0, - } }, s.pages.pointFromPin(.screen, sel.start()).?); - try testing.expectEqual(point.Point{ .screen = .{ - .x = 3, - .y = 1, - } }, s.pages.pointFromPin(.screen, sel.end()).?); - } -} - -test "Screen: selectLine across soft-wrap ignores blank lines" { - const testing = std.testing; - const alloc = testing.allocator; - - var s = try init(alloc, 5, 10, 0); - defer s.deinit(); - try s.testWriteString(" 12 34012 \n 123"); - - // Going forward - { - var sel = s.selectLine(s.pages.pin(.{ .active = .{ - .x = 1, - .y = 0, - } }).?).?; - defer sel.deinit(&s); - try testing.expectEqual(point.Point{ .screen = .{ - .x = 1, - .y = 0, - } }, s.pages.pointFromPin(.screen, sel.start()).?); - try testing.expectEqual(point.Point{ .screen = .{ - .x = 3, - .y = 1, - } }, s.pages.pointFromPin(.screen, sel.end()).?); - } - - // Going backward - { - var sel = s.selectLine(s.pages.pin(.{ .active = .{ - .x = 1, - .y = 1, - } }).?).?; - defer sel.deinit(&s); - try testing.expectEqual(point.Point{ .screen = .{ - .x = 1, - .y = 0, - } }, s.pages.pointFromPin(.screen, sel.start()).?); - try testing.expectEqual(point.Point{ .screen = .{ - .x = 3, - .y = 1, - } }, s.pages.pointFromPin(.screen, sel.end()).?); - } - - // Going forward and backward - { - var sel = s.selectLine(s.pages.pin(.{ .active = .{ - .x = 3, - .y = 0, - } }).?).?; - defer sel.deinit(&s); - try testing.expectEqual(point.Point{ .screen = .{ - .x = 1, - .y = 0, - } }, s.pages.pointFromPin(.screen, sel.start()).?); - try testing.expectEqual(point.Point{ .screen = .{ - .x = 3, - .y = 1, - } }, s.pages.pointFromPin(.screen, sel.end()).?); - } -} - -test "Screen: selectLine with scrollback" { - const testing = std.testing; - const alloc = testing.allocator; - - var s = try init(alloc, 2, 3, 5); - defer s.deinit(); - try s.testWriteString("1A\n2B\n3C\n4D\n5E"); - - // Selecting first line - { - var sel = s.selectLine(s.pages.pin(.{ .active = .{ - .x = 0, - .y = 0, - } }).?).?; - defer sel.deinit(&s); - try testing.expectEqual(point.Point{ .active = .{ - .x = 0, - .y = 0, - } }, s.pages.pointFromPin(.active, sel.start()).?); - try testing.expectEqual(point.Point{ .active = .{ - .x = 1, - .y = 0, - } }, s.pages.pointFromPin(.active, sel.end()).?); - } - - // Selecting last line - { - var sel = s.selectLine(s.pages.pin(.{ .active = .{ - .x = 0, - .y = 2, - } }).?).?; - defer sel.deinit(&s); - try testing.expectEqual(point.Point{ .active = .{ - .x = 0, - .y = 2, - } }, s.pages.pointFromPin(.active, sel.start()).?); - try testing.expectEqual(point.Point{ .active = .{ - .x = 1, - .y = 2, - } }, s.pages.pointFromPin(.active, sel.end()).?); - } -} - -// https://github.com/mitchellh/ghostty/issues/1329 -test "Screen: selectLine semantic prompt boundary" { - const testing = std.testing; - const alloc = testing.allocator; - - var s = try init(alloc, 5, 10, 0); - defer s.deinit(); - try s.testWriteString("ABCDE\nA > "); - - { - const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); - defer alloc.free(contents); - try testing.expectEqualStrings("ABCDE\nA \n> ", contents); - } - - { - const pin = s.pages.pin(.{ .screen = .{ .y = 1 } }).?; - const row = pin.rowAndCell().row; - row.semantic_prompt = .prompt; - } - - // Selecting output stops at the prompt even if soft-wrapped - { - var sel = s.selectLine(s.pages.pin(.{ .active = .{ - .x = 1, - .y = 1, - } }).?).?; - defer sel.deinit(&s); - try testing.expectEqual(point.Point{ .active = .{ - .x = 0, - .y = 1, - } }, s.pages.pointFromPin(.active, sel.start()).?); - try testing.expectEqual(point.Point{ .active = .{ - .x = 0, - .y = 1, - } }, s.pages.pointFromPin(.active, sel.end()).?); - } - { - var sel = s.selectLine(s.pages.pin(.{ .active = .{ - .x = 1, - .y = 2, - } }).?).?; - defer sel.deinit(&s); - try testing.expectEqual(point.Point{ .active = .{ - .x = 0, - .y = 2, - } }, s.pages.pointFromPin(.active, sel.start()).?); - try testing.expectEqual(point.Point{ .active = .{ - .x = 0, - .y = 2, - } }, s.pages.pointFromPin(.active, sel.end()).?); - } -} - -test "Screen: selectWord" { - const testing = std.testing; - const alloc = testing.allocator; - - var s = try init(alloc, 10, 10, 0); - defer s.deinit(); - try s.testWriteString("ABC DEF\n 123\n456"); - - // Outside of active area - // try testing.expect(s.selectWord(.{ .x = 9, .y = 0 }) == null); - // try testing.expect(s.selectWord(.{ .x = 0, .y = 5 }) == null); - - // Going forward - { - var sel = s.selectWord(s.pages.pin(.{ .active = .{ - .x = 0, - .y = 0, - } }).?).?; - defer sel.deinit(&s); - try testing.expectEqual(point.Point{ .screen = .{ - .x = 0, - .y = 0, - } }, s.pages.pointFromPin(.screen, sel.start()).?); - try testing.expectEqual(point.Point{ .screen = .{ - .x = 2, - .y = 0, - } }, s.pages.pointFromPin(.screen, sel.end()).?); - } - - // Going backward - { - var sel = s.selectWord(s.pages.pin(.{ .active = .{ - .x = 2, - .y = 0, - } }).?).?; - defer sel.deinit(&s); - try testing.expectEqual(point.Point{ .screen = .{ - .x = 0, - .y = 0, - } }, s.pages.pointFromPin(.screen, sel.start()).?); - try testing.expectEqual(point.Point{ .screen = .{ - .x = 2, - .y = 0, - } }, s.pages.pointFromPin(.screen, sel.end()).?); - } - - // Going forward and backward - { - var sel = s.selectWord(s.pages.pin(.{ .active = .{ - .x = 1, - .y = 0, - } }).?).?; - defer sel.deinit(&s); - try testing.expectEqual(point.Point{ .screen = .{ - .x = 0, - .y = 0, - } }, s.pages.pointFromPin(.screen, sel.start()).?); - try testing.expectEqual(point.Point{ .screen = .{ - .x = 2, - .y = 0, - } }, s.pages.pointFromPin(.screen, sel.end()).?); - } - - // Whitespace - { - var sel = s.selectWord(s.pages.pin(.{ .active = .{ - .x = 3, - .y = 0, - } }).?).?; - defer sel.deinit(&s); - try testing.expectEqual(point.Point{ .screen = .{ - .x = 3, - .y = 0, - } }, s.pages.pointFromPin(.screen, sel.start()).?); - try testing.expectEqual(point.Point{ .screen = .{ - .x = 4, - .y = 0, - } }, s.pages.pointFromPin(.screen, sel.end()).?); - } - - // Whitespace single char - { - var sel = s.selectWord(s.pages.pin(.{ .active = .{ - .x = 0, - .y = 1, - } }).?).?; - defer sel.deinit(&s); - try testing.expectEqual(point.Point{ .screen = .{ - .x = 0, - .y = 1, - } }, s.pages.pointFromPin(.screen, sel.start()).?); - try testing.expectEqual(point.Point{ .screen = .{ - .x = 0, - .y = 1, - } }, s.pages.pointFromPin(.screen, sel.end()).?); - } - - // End of screen - { - var sel = s.selectWord(s.pages.pin(.{ .active = .{ - .x = 1, - .y = 2, - } }).?).?; - defer sel.deinit(&s); - try testing.expectEqual(point.Point{ .screen = .{ - .x = 0, - .y = 2, - } }, s.pages.pointFromPin(.screen, sel.start()).?); - try testing.expectEqual(point.Point{ .screen = .{ - .x = 2, - .y = 2, - } }, s.pages.pointFromPin(.screen, sel.end()).?); - } -} - -test "Screen: selectWord across soft-wrap" { - const testing = std.testing; - const alloc = testing.allocator; - - var s = try init(alloc, 5, 10, 0); - defer s.deinit(); - try s.testWriteString(" 1234012\n 123"); - - { - const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); - defer alloc.free(contents); - try testing.expectEqualStrings(" 1234\n012\n 123", contents); - } - - // Going forward - { - var sel = s.selectWord(s.pages.pin(.{ .active = .{ - .x = 1, - .y = 0, - } }).?).?; - defer sel.deinit(&s); - try testing.expectEqual(point.Point{ .screen = .{ - .x = 1, - .y = 0, - } }, s.pages.pointFromPin(.screen, sel.start()).?); - try testing.expectEqual(point.Point{ .screen = .{ - .x = 2, - .y = 1, - } }, s.pages.pointFromPin(.screen, sel.end()).?); - } - - // Going backward - { - var sel = s.selectWord(s.pages.pin(.{ .active = .{ - .x = 1, - .y = 1, - } }).?).?; - defer sel.deinit(&s); - try testing.expectEqual(point.Point{ .screen = .{ - .x = 1, - .y = 0, - } }, s.pages.pointFromPin(.screen, sel.start()).?); - try testing.expectEqual(point.Point{ .screen = .{ - .x = 2, - .y = 1, - } }, s.pages.pointFromPin(.screen, sel.end()).?); - } - - // Going forward and backward - { - var sel = s.selectWord(s.pages.pin(.{ .active = .{ - .x = 3, - .y = 0, - } }).?).?; - defer sel.deinit(&s); - try testing.expectEqual(point.Point{ .screen = .{ - .x = 1, - .y = 0, - } }, s.pages.pointFromPin(.screen, sel.start()).?); - try testing.expectEqual(point.Point{ .screen = .{ - .x = 2, - .y = 1, - } }, s.pages.pointFromPin(.screen, sel.end()).?); - } -} - -test "Screen: selectWord whitespace across soft-wrap" { - const testing = std.testing; - const alloc = testing.allocator; - - var s = try init(alloc, 5, 10, 0); - defer s.deinit(); - try s.testWriteString("1 1\n 123"); - - // Going forward - { - var sel = s.selectWord(s.pages.pin(.{ .active = .{ - .x = 1, - .y = 0, - } }).?).?; - defer sel.deinit(&s); - try testing.expectEqual(point.Point{ .screen = .{ - .x = 1, - .y = 0, - } }, s.pages.pointFromPin(.screen, sel.start()).?); - try testing.expectEqual(point.Point{ .screen = .{ - .x = 2, - .y = 1, - } }, s.pages.pointFromPin(.screen, sel.end()).?); - } - - // Going backward - { - var sel = s.selectWord(s.pages.pin(.{ .active = .{ - .x = 1, - .y = 1, - } }).?).?; - defer sel.deinit(&s); - try testing.expectEqual(point.Point{ .screen = .{ - .x = 1, - .y = 0, - } }, s.pages.pointFromPin(.screen, sel.start()).?); - try testing.expectEqual(point.Point{ .screen = .{ - .x = 2, - .y = 1, - } }, s.pages.pointFromPin(.screen, sel.end()).?); - } - - // Going forward and backward - { - var sel = s.selectWord(s.pages.pin(.{ .active = .{ - .x = 3, - .y = 0, - } }).?).?; - defer sel.deinit(&s); - try testing.expectEqual(point.Point{ .screen = .{ - .x = 1, - .y = 0, - } }, s.pages.pointFromPin(.screen, sel.start()).?); - try testing.expectEqual(point.Point{ .screen = .{ - .x = 2, - .y = 1, - } }, s.pages.pointFromPin(.screen, sel.end()).?); - } -} - -test "Screen: selectWord with character boundary" { - const testing = std.testing; - const alloc = testing.allocator; - - const cases = [_][]const u8{ - " 'abc' \n123", - " \"abc\" \n123", - " │abc│ \n123", - " `abc` \n123", - " |abc| \n123", - " :abc: \n123", - " ,abc, \n123", - " (abc( \n123", - " )abc) \n123", - " [abc[ \n123", - " ]abc] \n123", - " {abc{ \n123", - " }abc} \n123", - " abc> \n123", - }; - - for (cases) |case| { - var s = try init(alloc, 20, 10, 0); - defer s.deinit(); - try s.testWriteString(case); - - // Inside character forward - { - var sel = s.selectWord(s.pages.pin(.{ .active = .{ - .x = 2, - .y = 0, - } }).?).?; - defer sel.deinit(&s); - try testing.expectEqual(point.Point{ .screen = .{ - .x = 2, - .y = 0, - } }, s.pages.pointFromPin(.screen, sel.start()).?); - try testing.expectEqual(point.Point{ .screen = .{ - .x = 4, - .y = 0, - } }, s.pages.pointFromPin(.screen, sel.end()).?); - } - - // Inside character backward - { - var sel = s.selectWord(s.pages.pin(.{ .active = .{ - .x = 4, - .y = 0, - } }).?).?; - defer sel.deinit(&s); - try testing.expectEqual(point.Point{ .screen = .{ - .x = 2, - .y = 0, - } }, s.pages.pointFromPin(.screen, sel.start()).?); - try testing.expectEqual(point.Point{ .screen = .{ - .x = 4, - .y = 0, - } }, s.pages.pointFromPin(.screen, sel.end()).?); - } - - // Inside character bidirectional - { - var sel = s.selectWord(s.pages.pin(.{ .active = .{ - .x = 3, - .y = 0, - } }).?).?; - defer sel.deinit(&s); - try testing.expectEqual(point.Point{ .screen = .{ - .x = 2, - .y = 0, - } }, s.pages.pointFromPin(.screen, sel.start()).?); - try testing.expectEqual(point.Point{ .screen = .{ - .x = 4, - .y = 0, - } }, s.pages.pointFromPin(.screen, sel.end()).?); - } - - // On quote - // NOTE: this behavior is not ideal, so we can change this one day, - // but I think its also not that important compared to the above. - { - var sel = s.selectWord(s.pages.pin(.{ .active = .{ - .x = 1, - .y = 0, - } }).?).?; - defer sel.deinit(&s); - try testing.expectEqual(point.Point{ .screen = .{ - .x = 0, - .y = 0, - } }, s.pages.pointFromPin(.screen, sel.start()).?); - try testing.expectEqual(point.Point{ .screen = .{ - .x = 1, - .y = 0, - } }, s.pages.pointFromPin(.screen, sel.end()).?); - } - } -} - -test "Screen: selectOutput" { - const testing = std.testing; - const alloc = testing.allocator; - - var s = try init(alloc, 10, 15, 0); - defer s.deinit(); - - // zig fmt: off - { - // line number: - try s.testWriteString("output1\n"); // 0 - try s.testWriteString("output1\n"); // 1 - try s.testWriteString("prompt2\n"); // 2 - try s.testWriteString("input2\n"); // 3 - try s.testWriteString("output2\n"); // 4 - try s.testWriteString("output2\n"); // 5 - try s.testWriteString("prompt3$ input3\n"); // 6 - try s.testWriteString("output3\n"); // 7 - try s.testWriteString("output3\n"); // 8 - try s.testWriteString("output3"); // 9 - } - // zig fmt: on - - { - const pin = s.pages.pin(.{ .screen = .{ .y = 2 } }).?; - const row = pin.rowAndCell().row; - row.semantic_prompt = .prompt; - } - { - const pin = s.pages.pin(.{ .screen = .{ .y = 3 } }).?; - const row = pin.rowAndCell().row; - row.semantic_prompt = .input; - } - { - const pin = s.pages.pin(.{ .screen = .{ .y = 4 } }).?; - const row = pin.rowAndCell().row; - row.semantic_prompt = .command; - } - { - const pin = s.pages.pin(.{ .screen = .{ .y = 6 } }).?; - const row = pin.rowAndCell().row; - row.semantic_prompt = .input; - } - { - const pin = s.pages.pin(.{ .screen = .{ .y = 7 } }).?; - const row = pin.rowAndCell().row; - row.semantic_prompt = .command; - } - - // No start marker, should select from the beginning - { - var sel = s.selectOutput(s.pages.pin(.{ .active = .{ - .x = 1, - .y = 1, - } }).?).?; - defer sel.deinit(&s); - try testing.expectEqual(point.Point{ .active = .{ - .x = 0, - .y = 0, - } }, s.pages.pointFromPin(.active, sel.start()).?); - try testing.expectEqual(point.Point{ .active = .{ - .x = 9, - .y = 1, - } }, s.pages.pointFromPin(.active, sel.end()).?); - } - // Both start and end markers, should select between them - { - var sel = s.selectOutput(s.pages.pin(.{ .active = .{ - .x = 3, - .y = 5, - } }).?).?; - defer sel.deinit(&s); - try testing.expectEqual(point.Point{ .active = .{ - .x = 0, - .y = 4, - } }, s.pages.pointFromPin(.active, sel.start()).?); - try testing.expectEqual(point.Point{ .active = .{ - .x = 9, - .y = 5, - } }, s.pages.pointFromPin(.active, sel.end()).?); - } - // No end marker, should select till the end - { - var sel = s.selectOutput(s.pages.pin(.{ .active = .{ - .x = 2, - .y = 7, - } }).?).?; - defer sel.deinit(&s); - try testing.expectEqual(point.Point{ .active = .{ - .x = 0, - .y = 7, - } }, s.pages.pointFromPin(.active, sel.start()).?); - try testing.expectEqual(point.Point{ .active = .{ - .x = 9, - .y = 10, - } }, s.pages.pointFromPin(.active, sel.end()).?); - } - // input / prompt at y = 0, pt.y = 0 - { - s.deinit(); - s = try init(alloc, 10, 5, 0); - try s.testWriteString("prompt1$ input1\n"); - try s.testWriteString("output1\n"); - try s.testWriteString("prompt2\n"); - { - const pin = s.pages.pin(.{ .screen = .{ .y = 0 } }).?; - const row = pin.rowAndCell().row; - row.semantic_prompt = .input; - } - { - const pin = s.pages.pin(.{ .screen = .{ .y = 1 } }).?; - const row = pin.rowAndCell().row; - row.semantic_prompt = .command; - } - try testing.expect(s.selectOutput(s.pages.pin(.{ .active = .{ - .x = 2, - .y = 0, - } }).?) == null); - } -} - -test "Screen: selectPrompt basics" { - const testing = std.testing; - const alloc = testing.allocator; - - var s = try init(alloc, 10, 15, 0); - defer s.deinit(); - - // zig fmt: off - { - // line number: - try s.testWriteString("output1\n"); // 0 - try s.testWriteString("output1\n"); // 1 - try s.testWriteString("prompt2\n"); // 2 - try s.testWriteString("input2\n"); // 3 - try s.testWriteString("output2\n"); // 4 - try s.testWriteString("output2\n"); // 5 - try s.testWriteString("prompt3$ input3\n"); // 6 - try s.testWriteString("output3\n"); // 7 - try s.testWriteString("output3\n"); // 8 - try s.testWriteString("output3"); // 9 - } - // zig fmt: on - - { - const pin = s.pages.pin(.{ .screen = .{ .y = 2 } }).?; - const row = pin.rowAndCell().row; - row.semantic_prompt = .prompt; - } - { - const pin = s.pages.pin(.{ .screen = .{ .y = 3 } }).?; - const row = pin.rowAndCell().row; - row.semantic_prompt = .input; - } - { - const pin = s.pages.pin(.{ .screen = .{ .y = 4 } }).?; - const row = pin.rowAndCell().row; - row.semantic_prompt = .command; - } - { - const pin = s.pages.pin(.{ .screen = .{ .y = 6 } }).?; - const row = pin.rowAndCell().row; - row.semantic_prompt = .input; - } - { - const pin = s.pages.pin(.{ .screen = .{ .y = 7 } }).?; - const row = pin.rowAndCell().row; - row.semantic_prompt = .command; - } - - // Not at a prompt - { - const sel = s.selectPrompt(s.pages.pin(.{ .active = .{ - .x = 0, - .y = 1, - } }).?); - try testing.expect(sel == null); - } - { - const sel = s.selectPrompt(s.pages.pin(.{ .active = .{ - .x = 0, - .y = 8, - } }).?); - try testing.expect(sel == null); - } - - // Single line prompt - { - var sel = s.selectPrompt(s.pages.pin(.{ .active = .{ - .x = 1, - .y = 6, - } }).?).?; - defer sel.deinit(&s); - try testing.expectEqual(point.Point{ .screen = .{ - .x = 0, - .y = 6, - } }, s.pages.pointFromPin(.screen, sel.start()).?); - try testing.expectEqual(point.Point{ .screen = .{ - .x = 9, - .y = 6, - } }, s.pages.pointFromPin(.screen, sel.end()).?); - } - - // Multi line prompt - { - var sel = s.selectPrompt(s.pages.pin(.{ .active = .{ - .x = 1, - .y = 3, - } }).?).?; - defer sel.deinit(&s); - try testing.expectEqual(point.Point{ .screen = .{ - .x = 0, - .y = 2, - } }, s.pages.pointFromPin(.screen, sel.start()).?); - try testing.expectEqual(point.Point{ .screen = .{ - .x = 9, - .y = 3, - } }, s.pages.pointFromPin(.screen, sel.end()).?); - } -} - -test "Screen: selectPrompt prompt at start" { - const testing = std.testing; - const alloc = testing.allocator; - - var s = try init(alloc, 10, 15, 0); - defer s.deinit(); - - // zig fmt: off - { - // line number: - try s.testWriteString("prompt1\n"); // 0 - try s.testWriteString("input1\n"); // 1 - try s.testWriteString("output2\n"); // 2 - try s.testWriteString("output2\n"); // 3 - } - // zig fmt: on - - { - const pin = s.pages.pin(.{ .screen = .{ .y = 0 } }).?; - const row = pin.rowAndCell().row; - row.semantic_prompt = .prompt; - } - { - const pin = s.pages.pin(.{ .screen = .{ .y = 1 } }).?; - const row = pin.rowAndCell().row; - row.semantic_prompt = .input; - } - { - const pin = s.pages.pin(.{ .screen = .{ .y = 2 } }).?; - const row = pin.rowAndCell().row; - row.semantic_prompt = .command; - } - - // Not at a prompt - { - const sel = s.selectPrompt(s.pages.pin(.{ .active = .{ - .x = 0, - .y = 3, - } }).?); - try testing.expect(sel == null); - } - - // Multi line prompt - { - var sel = s.selectPrompt(s.pages.pin(.{ .active = .{ - .x = 1, - .y = 1, - } }).?).?; - defer sel.deinit(&s); - try testing.expectEqual(point.Point{ .screen = .{ - .x = 0, - .y = 0, - } }, s.pages.pointFromPin(.screen, sel.start()).?); - try testing.expectEqual(point.Point{ .screen = .{ - .x = 9, - .y = 1, - } }, s.pages.pointFromPin(.screen, sel.end()).?); - } -} - -test "Screen: selectPrompt prompt at end" { - const testing = std.testing; - const alloc = testing.allocator; - - var s = try init(alloc, 10, 15, 0); - defer s.deinit(); - - // zig fmt: off - { - // line number: - try s.testWriteString("output2\n"); // 0 - try s.testWriteString("output2\n"); // 1 - try s.testWriteString("prompt1\n"); // 2 - try s.testWriteString("input1\n"); // 3 - } - // zig fmt: on - - { - const pin = s.pages.pin(.{ .screen = .{ .y = 2 } }).?; - const row = pin.rowAndCell().row; - row.semantic_prompt = .prompt; - } - { - const pin = s.pages.pin(.{ .screen = .{ .y = 3 } }).?; - const row = pin.rowAndCell().row; - row.semantic_prompt = .input; - } - - // Not at a prompt - { - const sel = s.selectPrompt(s.pages.pin(.{ .active = .{ - .x = 0, - .y = 1, - } }).?); - try testing.expect(sel == null); - } - - // Multi line prompt - { - var sel = s.selectPrompt(s.pages.pin(.{ .active = .{ - .x = 1, - .y = 2, - } }).?).?; - defer sel.deinit(&s); - try testing.expectEqual(point.Point{ .screen = .{ - .x = 0, - .y = 2, - } }, s.pages.pointFromPin(.screen, sel.start()).?); - try testing.expectEqual(point.Point{ .screen = .{ - .x = 9, - .y = 3, - } }, s.pages.pointFromPin(.screen, sel.end()).?); - } -} - -test "Screen: promptPath" { - const testing = std.testing; - const alloc = testing.allocator; - - var s = try init(alloc, 10, 15, 0); - defer s.deinit(); - - // zig fmt: off - { - // line number: - try s.testWriteString("output1\n"); // 0 - try s.testWriteString("output1\n"); // 1 - try s.testWriteString("prompt2\n"); // 2 - try s.testWriteString("input2\n"); // 3 - try s.testWriteString("output2\n"); // 4 - try s.testWriteString("output2\n"); // 5 - try s.testWriteString("prompt3$ input3\n"); // 6 - try s.testWriteString("output3\n"); // 7 - try s.testWriteString("output3\n"); // 8 - try s.testWriteString("output3"); // 9 - } - // zig fmt: on - - { - const pin = s.pages.pin(.{ .screen = .{ .y = 2 } }).?; - const row = pin.rowAndCell().row; - row.semantic_prompt = .prompt; - } - { - const pin = s.pages.pin(.{ .screen = .{ .y = 3 } }).?; - const row = pin.rowAndCell().row; - row.semantic_prompt = .input; - } - { - const pin = s.pages.pin(.{ .screen = .{ .y = 4 } }).?; - const row = pin.rowAndCell().row; - row.semantic_prompt = .command; - } - { - const pin = s.pages.pin(.{ .screen = .{ .y = 6 } }).?; - const row = pin.rowAndCell().row; - row.semantic_prompt = .input; - } - { - const pin = s.pages.pin(.{ .screen = .{ .y = 7 } }).?; - const row = pin.rowAndCell().row; - row.semantic_prompt = .command; - } - - // From is not in the prompt - { - const path = s.promptPath( - s.pages.pin(.{ .active = .{ .x = 0, .y = 1 } }).?, - s.pages.pin(.{ .active = .{ .x = 0, .y = 2 } }).?, - ); - try testing.expectEqual(@as(isize, 0), path.x); - try testing.expectEqual(@as(isize, 0), path.y); - } - - // Same line - { - const path = s.promptPath( - s.pages.pin(.{ .active = .{ .x = 6, .y = 2 } }).?, - s.pages.pin(.{ .active = .{ .x = 3, .y = 2 } }).?, - ); - try testing.expectEqual(@as(isize, -3), path.x); - try testing.expectEqual(@as(isize, 0), path.y); - } - - // Different lines - { - const path = s.promptPath( - s.pages.pin(.{ .active = .{ .x = 6, .y = 2 } }).?, - s.pages.pin(.{ .active = .{ .x = 3, .y = 3 } }).?, - ); - try testing.expectEqual(@as(isize, -3), path.x); - try testing.expectEqual(@as(isize, 1), path.y); - } - - // To is out of bounds before - { - const path = s.promptPath( - s.pages.pin(.{ .active = .{ .x = 6, .y = 2 } }).?, - s.pages.pin(.{ .active = .{ .x = 3, .y = 1 } }).?, - ); - try testing.expectEqual(@as(isize, -6), path.x); - try testing.expectEqual(@as(isize, 0), path.y); - } - - // To is out of bounds after - { - const path = s.promptPath( - s.pages.pin(.{ .active = .{ .x = 6, .y = 2 } }).?, - s.pages.pin(.{ .active = .{ .x = 3, .y = 9 } }).?, - ); - try testing.expectEqual(@as(isize, 3), path.x); - try testing.expectEqual(@as(isize, 1), path.y); - } -} - -test "Screen: selectionString basic" { - const testing = std.testing; - const alloc = testing.allocator; - - var s = try init(alloc, 5, 3, 0); - defer s.deinit(); - const str = "1ABCD\n2EFGH\n3IJKL"; - try s.testWriteString(str); - - { - const sel = Selection.init( - s.pages.pin(.{ .screen = .{ .x = 0, .y = 1 } }).?, - s.pages.pin(.{ .screen = .{ .x = 2, .y = 2 } }).?, - false, - ); - const contents = try s.selectionString(alloc, sel, true); - defer alloc.free(contents); - const expected = "2EFGH\n3IJ"; - try testing.expectEqualStrings(expected, contents); - } -} - -test "Screen: selectionString start outside of written area" { - const testing = std.testing; - const alloc = testing.allocator; - - var s = try init(alloc, 5, 10, 0); - defer s.deinit(); - const str = "1ABCD\n2EFGH\n3IJKL"; - try s.testWriteString(str); - - { - const sel = Selection.init( - s.pages.pin(.{ .screen = .{ .x = 0, .y = 5 } }).?, - s.pages.pin(.{ .screen = .{ .x = 2, .y = 6 } }).?, - false, - ); - const contents = try s.selectionString(alloc, sel, true); - defer alloc.free(contents); - const expected = ""; - try testing.expectEqualStrings(expected, contents); - } -} - -test "Screen: selectionString end outside of written area" { - const testing = std.testing; - const alloc = testing.allocator; - - var s = try init(alloc, 5, 10, 0); - defer s.deinit(); - const str = "1ABCD\n2EFGH\n3IJKL"; - try s.testWriteString(str); - - { - const sel = Selection.init( - s.pages.pin(.{ .screen = .{ .x = 0, .y = 2 } }).?, - s.pages.pin(.{ .screen = .{ .x = 2, .y = 6 } }).?, - false, - ); - const contents = try s.selectionString(alloc, sel, true); - defer alloc.free(contents); - const expected = "3IJKL"; - try testing.expectEqualStrings(expected, contents); - } -} - -test "Screen: selectionString trim space" { - const testing = std.testing; - const alloc = testing.allocator; - - var s = try init(alloc, 5, 3, 0); - defer s.deinit(); - const str = "1AB \n2EFGH\n3IJKL"; - try s.testWriteString(str); - - const sel = Selection.init( - s.pages.pin(.{ .screen = .{ .x = 0, .y = 0 } }).?, - s.pages.pin(.{ .screen = .{ .x = 2, .y = 1 } }).?, - false, - ); - - { - const contents = try s.selectionString(alloc, sel, true); - defer alloc.free(contents); - const expected = "1AB\n2EF"; - try testing.expectEqualStrings(expected, contents); - } - - // No trim - { - const contents = try s.selectionString(alloc, sel, false); - defer alloc.free(contents); - const expected = "1AB \n2EF"; - try testing.expectEqualStrings(expected, contents); - } -} - -test "Screen: selectionString trim empty line" { - const testing = std.testing; - const alloc = testing.allocator; - - var s = try init(alloc, 5, 5, 0); - defer s.deinit(); - const str = "1AB \n\n2EFGH\n3IJKL"; - try s.testWriteString(str); - - const sel = Selection.init( - s.pages.pin(.{ .screen = .{ .x = 0, .y = 0 } }).?, - s.pages.pin(.{ .screen = .{ .x = 2, .y = 2 } }).?, - false, - ); - - { - const contents = try s.selectionString(alloc, sel, true); - defer alloc.free(contents); - const expected = "1AB\n\n2EF"; - try testing.expectEqualStrings(expected, contents); - } - - // No trim - { - const contents = try s.selectionString(alloc, sel, false); - defer alloc.free(contents); - const expected = "1AB \n \n2EF"; - try testing.expectEqualStrings(expected, contents); - } -} - -test "Screen: selectionString soft wrap" { - const testing = std.testing; - const alloc = testing.allocator; - - var s = try init(alloc, 5, 3, 0); - defer s.deinit(); - const str = "1ABCD2EFGH3IJKL"; - try s.testWriteString(str); - - { - const sel = Selection.init( - s.pages.pin(.{ .screen = .{ .x = 0, .y = 1 } }).?, - s.pages.pin(.{ .screen = .{ .x = 2, .y = 2 } }).?, - false, - ); - const contents = try s.selectionString(alloc, sel, true); - defer alloc.free(contents); - const expected = "2EFGH3IJ"; - try testing.expectEqualStrings(expected, contents); - } -} - -test "Screen: selectionString wide char" { - const testing = std.testing; - const alloc = testing.allocator; - - var s = try init(alloc, 5, 3, 0); - defer s.deinit(); - const str = "1A⚡"; - try s.testWriteString(str); - - { - const sel = Selection.init( - s.pages.pin(.{ .screen = .{ .x = 0, .y = 0 } }).?, - s.pages.pin(.{ .screen = .{ .x = 3, .y = 0 } }).?, - false, - ); - const contents = try s.selectionString(alloc, sel, true); - defer alloc.free(contents); - const expected = str; - try testing.expectEqualStrings(expected, contents); - } - - { - const sel = Selection.init( - s.pages.pin(.{ .screen = .{ .x = 0, .y = 0 } }).?, - s.pages.pin(.{ .screen = .{ .x = 2, .y = 0 } }).?, - false, - ); - const contents = try s.selectionString(alloc, sel, true); - defer alloc.free(contents); - const expected = str; - try testing.expectEqualStrings(expected, contents); - } - - { - const sel = Selection.init( - s.pages.pin(.{ .screen = .{ .x = 3, .y = 0 } }).?, - s.pages.pin(.{ .screen = .{ .x = 3, .y = 0 } }).?, - false, - ); - const contents = try s.selectionString(alloc, sel, true); - 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, 5, 3, 0); - defer s.deinit(); - const str = "1ABC⚡"; - try s.testWriteString(str); - - { - const sel = Selection.init( - s.pages.pin(.{ .screen = .{ .x = 0, .y = 0 } }).?, - s.pages.pin(.{ .screen = .{ .x = 4, .y = 0 } }).?, - false, - ); - const contents = try s.selectionString(alloc, sel, true); - defer alloc.free(contents); - const expected = str; - try testing.expectEqualStrings(expected, contents); - } -} - -// https://github.com/mitchellh/ghostty/issues/289 -test "Screen: selectionString empty with soft wrap" { - const testing = std.testing; - const alloc = testing.allocator; - - var s = try init(alloc, 5, 2, 0); - defer s.deinit(); - - // Let me describe the situation that caused this because this - // test is not obvious. By writing an emoji below, we introduce - // one cell with the emoji and one cell as a "wide char spacer". - // We then soft wrap the line by writing spaces. - // - // By selecting only the tail, we'd select nothing and we had - // a logic error that would cause a crash. - try s.testWriteString("👨"); - try s.testWriteString(" "); - - { - const sel = Selection.init( - s.pages.pin(.{ .screen = .{ .x = 1, .y = 0 } }).?, - s.pages.pin(.{ .screen = .{ .x = 2, .y = 0 } }).?, - false, - ); - const contents = try s.selectionString(alloc, sel, true); - defer alloc.free(contents); - const expected = "👨"; - try testing.expectEqualStrings(expected, contents); - } -} - -test "Screen: selectionString with zero width joiner" { - const testing = std.testing; - const alloc = testing.allocator; - - var s = try init(alloc, 10, 1, 0); - defer s.deinit(); - const str = "👨‍"; // this has a ZWJ - try s.testWriteString(str); - - // Integrity check - { - const pin = s.pages.pin(.{ .screen = .{ .y = 0, .x = 0 } }).?; - const cell = pin.rowAndCell().cell; - try testing.expectEqual(@as(u21, 0x1F468), cell.content.codepoint); - try testing.expectEqual(Cell.Wide.wide, cell.wide); - const cps = pin.page.data.lookupGrapheme(cell).?; - try testing.expectEqual(@as(usize, 1), cps.len); - } - - // The real test - { - const sel = Selection.init( - s.pages.pin(.{ .screen = .{ .x = 0, .y = 0 } }).?, - s.pages.pin(.{ .screen = .{ .x = 1, .y = 0 } }).?, - false, - ); - const contents = try s.selectionString(alloc, sel, true); - defer alloc.free(contents); - const expected = "👨‍"; - try testing.expectEqualStrings(expected, contents); - } -} - -test "Screen: selectionString, rectangle, basic" { - const testing = std.testing; - const alloc = testing.allocator; - - var s = try init(alloc, 30, 5, 0); - defer s.deinit(); - const str = - \\Lorem ipsum dolor - \\sit amet, consectetur - \\adipiscing elit, sed do - \\eiusmod tempor incididunt - \\ut labore et dolore - ; - const sel = Selection.init( - s.pages.pin(.{ .screen = .{ .x = 2, .y = 1 } }).?, - s.pages.pin(.{ .screen = .{ .x = 6, .y = 3 } }).?, - true, - ); - const expected = - \\t ame - \\ipisc - \\usmod - ; - try s.testWriteString(str); - - const contents = try s.selectionString(alloc, sel, true); - defer alloc.free(contents); - try testing.expectEqualStrings(expected, contents); -} - -test "Screen: selectionString, rectangle, w/EOL" { - const testing = std.testing; - const alloc = testing.allocator; - - var s = try init(alloc, 30, 5, 0); - defer s.deinit(); - const str = - \\Lorem ipsum dolor - \\sit amet, consectetur - \\adipiscing elit, sed do - \\eiusmod tempor incididunt - \\ut labore et dolore - ; - const sel = Selection.init( - s.pages.pin(.{ .screen = .{ .x = 12, .y = 0 } }).?, - s.pages.pin(.{ .screen = .{ .x = 26, .y = 4 } }).?, - true, - ); - const expected = - \\dolor - \\nsectetur - \\lit, sed do - \\or incididunt - \\ dolore - ; - try s.testWriteString(str); - - const contents = try s.selectionString(alloc, sel, true); - defer alloc.free(contents); - try testing.expectEqualStrings(expected, contents); -} - -test "Screen: selectionString, rectangle, more complex w/breaks" { - const testing = std.testing; - const alloc = testing.allocator; - - var s = try init(alloc, 30, 8, 0); - defer s.deinit(); - const str = - \\Lorem ipsum dolor - \\sit amet, consectetur - \\adipiscing elit, sed do - \\eiusmod tempor incididunt - \\ut labore et dolore - \\ - \\magna aliqua. Ut enim - \\ad minim veniam, quis - ; - const sel = Selection.init( - s.pages.pin(.{ .screen = .{ .x = 11, .y = 2 } }).?, - s.pages.pin(.{ .screen = .{ .x = 26, .y = 7 } }).?, - true, - ); - const expected = - \\elit, sed do - \\por incididunt - \\t dolore - \\ - \\a. Ut enim - \\niam, quis - ; - try s.testWriteString(str); - - const contents = try s.selectionString(alloc, sel, true); - defer alloc.free(contents); - try testing.expectEqualStrings(expected, contents); -} diff --git a/src/terminal2/Selection.zig b/src/terminal2/Selection.zig deleted file mode 100644 index a404cf0e5..000000000 --- a/src/terminal2/Selection.zig +++ /dev/null @@ -1,1230 +0,0 @@ -//! Represents a single selection within the terminal (i.e. a highlight region). -const Selection = @This(); - -const std = @import("std"); -const assert = std.debug.assert; -const page = @import("page.zig"); -const point = @import("point.zig"); -const PageList = @import("PageList.zig"); -const Screen = @import("Screen.zig"); -const Pin = PageList.Pin; - -// NOTE(mitchellh): I'm not very happy with how this is implemented, because -// the ordering operations which are used frequently require using -// pointFromPin which -- at the time of writing this -- is slow. The overall -// style of this struct is due to porting it from the previous implementation -// which had an efficient ordering operation. -// -// While reimplementing this, there were too many callers that already -// depended on this behavior so I kept it despite the inefficiency. In the -// future, we should take a look at this again! - -/// The bounds of the selection. -bounds: Bounds, - -/// Whether or not this selection refers to a rectangle, rather than whole -/// lines of a buffer. In this mode, start and end refer to the top left and -/// bottom right of the rectangle, or vice versa if the selection is backwards. -rectangle: bool = false, - -/// The bounds of the selection. A selection bounds can be either tracked -/// or untracked. Untracked bounds are unsafe beyond the point the terminal -/// screen may be modified, since they may point to invalid memory. Tracked -/// bounds are always valid and will be updated as the screen changes, but -/// are more expensive to exist. -/// -/// In all cases, start and end can be in any order. There is no guarantee that -/// start is before end or vice versa. If a user selects backwards, -/// start will be after end, and vice versa. Use the struct functions -/// to not have to worry about this. -pub const Bounds = union(enum) { - untracked: struct { - start: Pin, - end: Pin, - }, - - tracked: struct { - start: *Pin, - end: *Pin, - }, -}; - -/// Initialize a new selection with the given start and end pins on -/// the screen. The screen will be used for pin tracking. -pub fn init( - start_pin: Pin, - end_pin: Pin, - rect: bool, -) Selection { - return .{ - .bounds = .{ .untracked = .{ - .start = start_pin, - .end = end_pin, - } }, - .rectangle = rect, - }; -} - -pub fn deinit( - self: Selection, - s: *Screen, -) void { - switch (self.bounds) { - .tracked => |v| { - s.pages.untrackPin(v.start); - s.pages.untrackPin(v.end); - }, - - .untracked => {}, - } -} - -/// Returns true if this selection is equal to another selection. -pub fn eql(self: Selection, other: Selection) bool { - return self.start().eql(other.start()) and - self.end().eql(other.end()) and - self.rectangle == other.rectangle; -} - -/// The starting pin of the selection. This is NOT ordered. -pub fn startPtr(self: *Selection) *Pin { - return switch (self.bounds) { - .untracked => |*v| &v.start, - .tracked => |v| v.start, - }; -} - -/// The ending pin of the selection. This is NOT ordered. -pub fn endPtr(self: *Selection) *Pin { - return switch (self.bounds) { - .untracked => |*v| &v.end, - .tracked => |v| v.end, - }; -} - -pub fn start(self: Selection) Pin { - return switch (self.bounds) { - .untracked => |v| v.start, - .tracked => |v| v.start.*, - }; -} - -pub fn end(self: Selection) Pin { - return switch (self.bounds) { - .untracked => |v| v.end, - .tracked => |v| v.end.*, - }; -} - -/// Returns true if this is a tracked selection. -pub fn tracked(self: *const Selection) bool { - return switch (self.bounds) { - .untracked => false, - .tracked => true, - }; -} - -/// Convert this selection a tracked selection. It is asserted this is -/// an untracked selection. -pub fn track(self: *Selection, s: *Screen) !void { - assert(!self.tracked()); - - // Track our pins - const start_pin = self.bounds.untracked.start; - const end_pin = self.bounds.untracked.end; - const tracked_start = try s.pages.trackPin(start_pin); - errdefer s.pages.untrackPin(tracked_start); - const tracked_end = try s.pages.trackPin(end_pin); - errdefer s.pages.untrackPin(tracked_end); - - self.bounds = .{ .tracked = .{ - .start = tracked_start, - .end = tracked_end, - } }; -} - -/// Returns the top left point of the selection. -pub fn topLeft(self: Selection, s: *const Screen) Pin { - return switch (self.order(s)) { - .forward => self.start(), - .reverse => self.end(), - .mirrored_forward => pin: { - var p = self.start(); - p.x = self.end().x; - break :pin p; - }, - .mirrored_reverse => pin: { - var p = self.end(); - p.x = self.start().x; - break :pin p; - }, - }; -} - -/// Returns the bottom right point of the selection. -pub fn bottomRight(self: Selection, s: *const Screen) Pin { - return switch (self.order(s)) { - .forward => self.end(), - .reverse => self.start(), - .mirrored_forward => pin: { - var p = self.end(); - p.x = self.start().x; - break :pin p; - }, - .mirrored_reverse => pin: { - var p = self.start(); - p.x = self.end().x; - break :pin p; - }, - }; -} - -/// The order of the selection: -/// -/// * forward: start(x, y) is before end(x, y) (top-left to bottom-right). -/// * reverse: end(x, y) is before start(x, y) (bottom-right to top-left). -/// * mirrored_[forward|reverse]: special, rectangle selections only (see below). -/// -/// For regular selections, the above also holds for top-right to bottom-left -/// (forward) and bottom-left to top-right (reverse). However, for rectangle -/// selections, both of these selections are *mirrored* as orientation -/// operations only flip the x or y axis, not both. Depending on the y axis -/// direction, this is either mirrored_forward or mirrored_reverse. -/// -pub const Order = enum { forward, reverse, mirrored_forward, mirrored_reverse }; - -pub fn order(self: Selection, s: *const Screen) Order { - const start_pt = s.pages.pointFromPin(.screen, self.start()).?.screen; - const end_pt = s.pages.pointFromPin(.screen, self.end()).?.screen; - - if (self.rectangle) { - // Reverse (also handles single-column) - if (start_pt.y > end_pt.y and start_pt.x >= end_pt.x) return .reverse; - if (start_pt.y >= end_pt.y and start_pt.x > end_pt.x) return .reverse; - - // Mirror, bottom-left to top-right - if (start_pt.y > end_pt.y and start_pt.x < end_pt.x) return .mirrored_reverse; - - // Mirror, top-right to bottom-left - if (start_pt.y < end_pt.y and start_pt.x > end_pt.x) return .mirrored_forward; - - // Forward - return .forward; - } - - if (start_pt.y < end_pt.y) return .forward; - if (start_pt.y > end_pt.y) return .reverse; - if (start_pt.x <= end_pt.x) return .forward; - return .reverse; -} - -/// Returns the selection in the given order. -/// -/// The returned selection is always a new untracked selection. -/// -/// Note that only forward and reverse are useful desired orders for this -/// function. All other orders act as if forward order was desired. -pub fn ordered(self: Selection, s: *const Screen, desired: Order) Selection { - if (self.order(s) == desired) return Selection.init( - self.start(), - self.end(), - self.rectangle, - ); - - const tl = self.topLeft(s); - const br = self.bottomRight(s); - return switch (desired) { - .forward => Selection.init(tl, br, self.rectangle), - .reverse => Selection.init(br, tl, self.rectangle), - else => Selection.init(tl, br, self.rectangle), - }; -} - -/// Returns true if the selection contains the given point. -/// -/// This recalculates top left and bottom right each call. If you have -/// many points to check, it is cheaper to do the containment logic -/// yourself and cache the topleft/bottomright. -pub fn contains(self: Selection, s: *const Screen, pin: Pin) bool { - const tl_pin = self.topLeft(s); - const br_pin = self.bottomRight(s); - - // This is definitely not very efficient. Low-hanging fruit to - // improve this. - const tl = s.pages.pointFromPin(.screen, tl_pin).?.screen; - const br = s.pages.pointFromPin(.screen, br_pin).?.screen; - const p = s.pages.pointFromPin(.screen, pin).?.screen; - - // If we're in rectangle select, we can short-circuit with an easy check - // here - if (self.rectangle) - return p.y >= tl.y and p.y <= br.y and p.x >= tl.x and p.x <= br.x; - - // If tl/br are same line - if (tl.y == br.y) return p.y == tl.y and - p.x >= tl.x and - p.x <= br.x; - - // If on top line, just has to be left of X - if (p.y == tl.y) return p.x >= tl.x; - - // If on bottom line, just has to be right of X - if (p.y == br.y) return p.x <= br.x; - - // If between the top/bottom, always good. - return p.y > tl.y and p.y < br.y; -} - -/// Possible adjustments to the selection. -pub const Adjustment = enum { - left, - right, - up, - down, - home, - end, - page_up, - page_down, -}; - -/// Adjust the selection by some given adjustment. An adjustment allows -/// a selection to be expanded slightly left, right, up, down, etc. -pub fn adjust( - self: *Selection, - s: *const Screen, - adjustment: Adjustment, -) void { - // Note that we always adjusts "end" because end always represents - // the last point of the selection by mouse, not necessarilly the - // top/bottom visually. So this results in the right behavior - // whether the user drags up or down. - const end_pin = self.endPtr(); - switch (adjustment) { - .up => if (end_pin.up(1)) |new_end| { - end_pin.* = new_end; - } else { - end_pin.x = 0; - }, - - .down => { - // Find the next non-blank row - var current = end_pin.*; - while (current.down(1)) |next| : (current = next) { - const rac = next.rowAndCell(); - const cells = next.page.data.getCells(rac.row); - if (page.Cell.hasTextAny(cells)) { - end_pin.* = next; - break; - } - } else { - // If we're at the bottom, just go to the end of the line - end_pin.x = end_pin.page.data.size.cols - 1; - } - }, - - .left => { - var it = s.pages.cellIterator( - .left_up, - .{ .screen = .{} }, - s.pages.pointFromPin(.screen, end_pin.*).?, - ); - _ = it.next(); - while (it.next()) |next| { - const rac = next.rowAndCell(); - if (rac.cell.hasText()) { - end_pin.* = next; - break; - } - } - }, - - .right => { - // Step right, wrapping to the next row down at the start of each new line, - // until we find a non-empty cell. - var it = s.pages.cellIterator( - .right_down, - s.pages.pointFromPin(.screen, end_pin.*).?, - null, - ); - _ = it.next(); - while (it.next()) |next| { - const rac = next.rowAndCell(); - if (rac.cell.hasText()) { - end_pin.* = next; - break; - } - } - }, - - .page_up => if (end_pin.up(s.pages.rows)) |new_end| { - end_pin.* = new_end; - } else { - self.adjust(s, .home); - }, - - // TODO(paged-terminal): this doesn't take into account blanks - .page_down => if (end_pin.down(s.pages.rows)) |new_end| { - end_pin.* = new_end; - } else { - self.adjust(s, .end); - }, - - .home => end_pin.* = s.pages.pin(.{ .screen = .{ - .x = 0, - .y = 0, - } }).?, - - .end => { - var it = s.pages.rowIterator( - .left_up, - .{ .screen = .{} }, - null, - ); - while (it.next()) |next| { - const rac = next.rowAndCell(); - const cells = next.page.data.getCells(rac.row); - if (page.Cell.hasTextAny(cells)) { - end_pin.* = next; - end_pin.x = cells.len - 1; - break; - } - } - }, - } -} - -test "Selection: adjust right" { - const testing = std.testing; - var s = try Screen.init(testing.allocator, 5, 10, 0); - defer s.deinit(); - try s.testWriteString("A1234\nB5678\nC1234\nD5678"); - - // Simple movement right - { - var sel = Selection.init( - s.pages.pin(.{ .screen = .{ .x = 5, .y = 1 } }).?, - s.pages.pin(.{ .screen = .{ .x = 3, .y = 3 } }).?, - false, - ); - defer sel.deinit(&s); - sel.adjust(&s, .right); - - try testing.expectEqual(point.Point{ .screen = .{ - .x = 5, - .y = 1, - } }, s.pages.pointFromPin(.screen, sel.start()).?); - try testing.expectEqual(point.Point{ .screen = .{ - .x = 4, - .y = 3, - } }, s.pages.pointFromPin(.screen, sel.end()).?); - } - - // Already at end of the line. - { - var sel = Selection.init( - s.pages.pin(.{ .screen = .{ .x = 4, .y = 1 } }).?, - s.pages.pin(.{ .screen = .{ .x = 4, .y = 2 } }).?, - false, - ); - defer sel.deinit(&s); - sel.adjust(&s, .right); - - try testing.expectEqual(point.Point{ .screen = .{ - .x = 4, - .y = 1, - } }, s.pages.pointFromPin(.screen, sel.start()).?); - try testing.expectEqual(point.Point{ .screen = .{ - .x = 0, - .y = 3, - } }, s.pages.pointFromPin(.screen, sel.end()).?); - } - - // Already at end of the screen - { - var sel = Selection.init( - s.pages.pin(.{ .screen = .{ .x = 5, .y = 1 } }).?, - s.pages.pin(.{ .screen = .{ .x = 4, .y = 3 } }).?, - false, - ); - defer sel.deinit(&s); - sel.adjust(&s, .right); - - try testing.expectEqual(point.Point{ .screen = .{ - .x = 5, - .y = 1, - } }, s.pages.pointFromPin(.screen, sel.start()).?); - try testing.expectEqual(point.Point{ .screen = .{ - .x = 4, - .y = 3, - } }, s.pages.pointFromPin(.screen, sel.end()).?); - } -} - -test "Selection: adjust left" { - const testing = std.testing; - var s = try Screen.init(testing.allocator, 5, 10, 0); - defer s.deinit(); - try s.testWriteString("A1234\nB5678\nC1234\nD5678"); - - // Simple movement left - { - var sel = Selection.init( - s.pages.pin(.{ .screen = .{ .x = 5, .y = 1 } }).?, - s.pages.pin(.{ .screen = .{ .x = 3, .y = 3 } }).?, - false, - ); - defer sel.deinit(&s); - sel.adjust(&s, .left); - - // Start line - try testing.expectEqual(point.Point{ .screen = .{ - .x = 5, - .y = 1, - } }, s.pages.pointFromPin(.screen, sel.start()).?); - try testing.expectEqual(point.Point{ .screen = .{ - .x = 2, - .y = 3, - } }, s.pages.pointFromPin(.screen, sel.end()).?); - } - - // Already at beginning of the line. - { - var sel = Selection.init( - s.pages.pin(.{ .screen = .{ .x = 5, .y = 1 } }).?, - s.pages.pin(.{ .screen = .{ .x = 0, .y = 3 } }).?, - false, - ); - defer sel.deinit(&s); - sel.adjust(&s, .left); - - // Start line - try testing.expectEqual(point.Point{ .screen = .{ - .x = 5, - .y = 1, - } }, s.pages.pointFromPin(.screen, sel.start()).?); - try testing.expectEqual(point.Point{ .screen = .{ - .x = 4, - .y = 2, - } }, s.pages.pointFromPin(.screen, sel.end()).?); - } -} - -test "Selection: adjust left skips blanks" { - const testing = std.testing; - var s = try Screen.init(testing.allocator, 5, 10, 0); - defer s.deinit(); - try s.testWriteString("A1234\nB5678\nC12\nD56"); - - // Same line - { - var sel = Selection.init( - s.pages.pin(.{ .screen = .{ .x = 5, .y = 1 } }).?, - s.pages.pin(.{ .screen = .{ .x = 4, .y = 3 } }).?, - false, - ); - defer sel.deinit(&s); - sel.adjust(&s, .left); - - // Start line - try testing.expectEqual(point.Point{ .screen = .{ - .x = 5, - .y = 1, - } }, s.pages.pointFromPin(.screen, sel.start()).?); - try testing.expectEqual(point.Point{ .screen = .{ - .x = 2, - .y = 3, - } }, s.pages.pointFromPin(.screen, sel.end()).?); - } - - // Edge - { - var sel = Selection.init( - s.pages.pin(.{ .screen = .{ .x = 5, .y = 1 } }).?, - s.pages.pin(.{ .screen = .{ .x = 0, .y = 3 } }).?, - false, - ); - defer sel.deinit(&s); - sel.adjust(&s, .left); - - // Start line - try testing.expectEqual(point.Point{ .screen = .{ - .x = 5, - .y = 1, - } }, s.pages.pointFromPin(.screen, sel.start()).?); - try testing.expectEqual(point.Point{ .screen = .{ - .x = 2, - .y = 2, - } }, s.pages.pointFromPin(.screen, sel.end()).?); - } -} - -test "Selection: adjust up" { - const testing = std.testing; - var s = try Screen.init(testing.allocator, 5, 10, 0); - defer s.deinit(); - try s.testWriteString("A\nB\nC\nD\nE"); - - // Not on the first line - { - var sel = Selection.init( - s.pages.pin(.{ .screen = .{ .x = 5, .y = 1 } }).?, - s.pages.pin(.{ .screen = .{ .x = 3, .y = 3 } }).?, - false, - ); - defer sel.deinit(&s); - sel.adjust(&s, .up); - - try testing.expectEqual(point.Point{ .screen = .{ - .x = 5, - .y = 1, - } }, s.pages.pointFromPin(.screen, sel.start()).?); - try testing.expectEqual(point.Point{ .screen = .{ - .x = 3, - .y = 2, - } }, s.pages.pointFromPin(.screen, sel.end()).?); - } - - // On the first line - { - var sel = Selection.init( - s.pages.pin(.{ .screen = .{ .x = 5, .y = 1 } }).?, - s.pages.pin(.{ .screen = .{ .x = 3, .y = 0 } }).?, - false, - ); - defer sel.deinit(&s); - sel.adjust(&s, .up); - - try testing.expectEqual(point.Point{ .screen = .{ - .x = 5, - .y = 1, - } }, s.pages.pointFromPin(.screen, sel.start()).?); - try testing.expectEqual(point.Point{ .screen = .{ - .x = 0, - .y = 0, - } }, s.pages.pointFromPin(.screen, sel.end()).?); - } -} - -test "Selection: adjust down" { - const testing = std.testing; - var s = try Screen.init(testing.allocator, 5, 10, 0); - defer s.deinit(); - try s.testWriteString("A\nB\nC\nD\nE"); - - // Not on the first line - { - var sel = Selection.init( - s.pages.pin(.{ .screen = .{ .x = 5, .y = 1 } }).?, - s.pages.pin(.{ .screen = .{ .x = 3, .y = 3 } }).?, - false, - ); - defer sel.deinit(&s); - sel.adjust(&s, .down); - - try testing.expectEqual(point.Point{ .screen = .{ - .x = 5, - .y = 1, - } }, s.pages.pointFromPin(.screen, sel.start()).?); - try testing.expectEqual(point.Point{ .screen = .{ - .x = 3, - .y = 4, - } }, s.pages.pointFromPin(.screen, sel.end()).?); - } - - // On the last line - { - var sel = Selection.init( - s.pages.pin(.{ .screen = .{ .x = 4, .y = 1 } }).?, - s.pages.pin(.{ .screen = .{ .x = 3, .y = 4 } }).?, - false, - ); - defer sel.deinit(&s); - sel.adjust(&s, .down); - - try testing.expectEqual(point.Point{ .screen = .{ - .x = 4, - .y = 1, - } }, s.pages.pointFromPin(.screen, sel.start()).?); - try testing.expectEqual(point.Point{ .screen = .{ - .x = 4, - .y = 4, - } }, s.pages.pointFromPin(.screen, sel.end()).?); - } -} - -test "Selection: adjust down with not full screen" { - const testing = std.testing; - var s = try Screen.init(testing.allocator, 5, 10, 0); - defer s.deinit(); - try s.testWriteString("A\nB\nC"); - - // On the last line - { - var sel = Selection.init( - s.pages.pin(.{ .screen = .{ .x = 4, .y = 1 } }).?, - s.pages.pin(.{ .screen = .{ .x = 3, .y = 2 } }).?, - false, - ); - defer sel.deinit(&s); - sel.adjust(&s, .down); - - // Start line - try testing.expectEqual(point.Point{ .screen = .{ - .x = 4, - .y = 1, - } }, s.pages.pointFromPin(.screen, sel.start()).?); - try testing.expectEqual(point.Point{ .screen = .{ - .x = 4, - .y = 2, - } }, s.pages.pointFromPin(.screen, sel.end()).?); - } -} - -test "Selection: adjust home" { - const testing = std.testing; - var s = try Screen.init(testing.allocator, 5, 10, 0); - defer s.deinit(); - try s.testWriteString("A\nB\nC"); - - // On the last line - { - var sel = Selection.init( - s.pages.pin(.{ .screen = .{ .x = 4, .y = 1 } }).?, - s.pages.pin(.{ .screen = .{ .x = 1, .y = 2 } }).?, - false, - ); - defer sel.deinit(&s); - sel.adjust(&s, .home); - - // Start line - try testing.expectEqual(point.Point{ .screen = .{ - .x = 4, - .y = 1, - } }, s.pages.pointFromPin(.screen, sel.start()).?); - try testing.expectEqual(point.Point{ .screen = .{ - .x = 0, - .y = 0, - } }, s.pages.pointFromPin(.screen, sel.end()).?); - } -} - -test "Selection: adjust end with not full screen" { - const testing = std.testing; - var s = try Screen.init(testing.allocator, 5, 10, 0); - defer s.deinit(); - try s.testWriteString("A\nB\nC"); - - // On the last line - { - var sel = Selection.init( - s.pages.pin(.{ .screen = .{ .x = 4, .y = 0 } }).?, - s.pages.pin(.{ .screen = .{ .x = 1, .y = 1 } }).?, - false, - ); - defer sel.deinit(&s); - sel.adjust(&s, .end); - - // Start line - try testing.expectEqual(point.Point{ .screen = .{ - .x = 4, - .y = 0, - } }, s.pages.pointFromPin(.screen, sel.start()).?); - try testing.expectEqual(point.Point{ .screen = .{ - .x = 4, - .y = 2, - } }, s.pages.pointFromPin(.screen, sel.end()).?); - } -} - -test "Selection: order, standard" { - const testing = std.testing; - const alloc = testing.allocator; - - var s = try Screen.init(alloc, 100, 100, 1); - defer s.deinit(); - - { - // forward, multi-line - const sel = Selection.init( - s.pages.pin(.{ .screen = .{ .x = 2, .y = 1 } }).?, - s.pages.pin(.{ .screen = .{ .x = 2, .y = 2 } }).?, - false, - ); - defer sel.deinit(&s); - - try testing.expect(sel.order(&s) == .forward); - } - { - // reverse, multi-line - const sel = Selection.init( - s.pages.pin(.{ .screen = .{ .x = 2, .y = 2 } }).?, - s.pages.pin(.{ .screen = .{ .x = 2, .y = 1 } }).?, - false, - ); - defer sel.deinit(&s); - - try testing.expect(sel.order(&s) == .reverse); - } - { - // forward, same-line - const sel = Selection.init( - s.pages.pin(.{ .screen = .{ .x = 2, .y = 1 } }).?, - s.pages.pin(.{ .screen = .{ .x = 3, .y = 1 } }).?, - false, - ); - defer sel.deinit(&s); - - try testing.expect(sel.order(&s) == .forward); - } - { - // forward, single char - const sel = Selection.init( - s.pages.pin(.{ .screen = .{ .x = 2, .y = 1 } }).?, - s.pages.pin(.{ .screen = .{ .x = 2, .y = 1 } }).?, - false, - ); - defer sel.deinit(&s); - - try testing.expect(sel.order(&s) == .forward); - } - { - // reverse, single line - const sel = Selection.init( - s.pages.pin(.{ .screen = .{ .x = 2, .y = 1 } }).?, - s.pages.pin(.{ .screen = .{ .x = 1, .y = 1 } }).?, - false, - ); - defer sel.deinit(&s); - - try testing.expect(sel.order(&s) == .reverse); - } -} - -test "Selection: order, rectangle" { - const testing = std.testing; - const alloc = testing.allocator; - - var s = try Screen.init(alloc, 100, 100, 1); - defer s.deinit(); - - // Conventions: - // TL - top left - // BL - bottom left - // TR - top right - // BR - bottom right - { - // forward (TL -> BR) - const sel = Selection.init( - s.pages.pin(.{ .screen = .{ .x = 1, .y = 1 } }).?, - s.pages.pin(.{ .screen = .{ .x = 2, .y = 2 } }).?, - true, - ); - defer sel.deinit(&s); - - try testing.expect(sel.order(&s) == .forward); - } - { - // reverse (BR -> TL) - const sel = Selection.init( - s.pages.pin(.{ .screen = .{ .x = 2, .y = 2 } }).?, - s.pages.pin(.{ .screen = .{ .x = 1, .y = 1 } }).?, - true, - ); - defer sel.deinit(&s); - - try testing.expect(sel.order(&s) == .reverse); - } - { - // mirrored_forward (TR -> BL) - const sel = Selection.init( - s.pages.pin(.{ .screen = .{ .x = 3, .y = 1 } }).?, - s.pages.pin(.{ .screen = .{ .x = 1, .y = 3 } }).?, - true, - ); - defer sel.deinit(&s); - - try testing.expect(sel.order(&s) == .mirrored_forward); - } - { - // mirrored_reverse (BL -> TR) - const sel = Selection.init( - s.pages.pin(.{ .screen = .{ .x = 1, .y = 3 } }).?, - s.pages.pin(.{ .screen = .{ .x = 3, .y = 1 } }).?, - true, - ); - defer sel.deinit(&s); - - try testing.expect(sel.order(&s) == .mirrored_reverse); - } - { - // forward, single line (left -> right ) - const sel = Selection.init( - s.pages.pin(.{ .screen = .{ .x = 1, .y = 1 } }).?, - s.pages.pin(.{ .screen = .{ .x = 3, .y = 1 } }).?, - true, - ); - defer sel.deinit(&s); - - try testing.expect(sel.order(&s) == .forward); - } - { - // reverse, single line (right -> left) - const sel = Selection.init( - s.pages.pin(.{ .screen = .{ .x = 3, .y = 1 } }).?, - s.pages.pin(.{ .screen = .{ .x = 1, .y = 1 } }).?, - true, - ); - defer sel.deinit(&s); - - try testing.expect(sel.order(&s) == .reverse); - } - { - // forward, single column (top -> bottom) - const sel = Selection.init( - s.pages.pin(.{ .screen = .{ .x = 2, .y = 1 } }).?, - s.pages.pin(.{ .screen = .{ .x = 2, .y = 3 } }).?, - true, - ); - defer sel.deinit(&s); - - try testing.expect(sel.order(&s) == .forward); - } - { - // reverse, single column (bottom -> top) - const sel = Selection.init( - s.pages.pin(.{ .screen = .{ .x = 2, .y = 3 } }).?, - s.pages.pin(.{ .screen = .{ .x = 2, .y = 1 } }).?, - true, - ); - defer sel.deinit(&s); - - try testing.expect(sel.order(&s) == .reverse); - } - { - // forward, single cell - const sel = Selection.init( - s.pages.pin(.{ .screen = .{ .x = 1, .y = 1 } }).?, - s.pages.pin(.{ .screen = .{ .x = 1, .y = 1 } }).?, - true, - ); - defer sel.deinit(&s); - - try testing.expect(sel.order(&s) == .forward); - } -} - -test "topLeft" { - const testing = std.testing; - - var s = try Screen.init(testing.allocator, 5, 10, 0); - defer s.deinit(); - { - // forward - const sel = Selection.init( - s.pages.pin(.{ .screen = .{ .x = 1, .y = 1 } }).?, - s.pages.pin(.{ .screen = .{ .x = 3, .y = 1 } }).?, - true, - ); - defer sel.deinit(&s); - const tl = sel.topLeft(&s); - try testing.expectEqual(point.Point{ .screen = .{ - .x = 1, - .y = 1, - } }, s.pages.pointFromPin(.screen, tl)); - } - { - // reverse - const sel = Selection.init( - s.pages.pin(.{ .screen = .{ .x = 3, .y = 1 } }).?, - s.pages.pin(.{ .screen = .{ .x = 1, .y = 1 } }).?, - true, - ); - defer sel.deinit(&s); - const tl = sel.topLeft(&s); - try testing.expectEqual(point.Point{ .screen = .{ - .x = 1, - .y = 1, - } }, s.pages.pointFromPin(.screen, tl)); - } - { - // mirrored_forward - const sel = Selection.init( - s.pages.pin(.{ .screen = .{ .x = 3, .y = 1 } }).?, - s.pages.pin(.{ .screen = .{ .x = 1, .y = 3 } }).?, - true, - ); - defer sel.deinit(&s); - const tl = sel.topLeft(&s); - try testing.expectEqual(point.Point{ .screen = .{ - .x = 1, - .y = 1, - } }, s.pages.pointFromPin(.screen, tl)); - } - { - // mirrored_reverse - const sel = Selection.init( - s.pages.pin(.{ .screen = .{ .x = 1, .y = 3 } }).?, - s.pages.pin(.{ .screen = .{ .x = 3, .y = 1 } }).?, - true, - ); - defer sel.deinit(&s); - const tl = sel.topLeft(&s); - try testing.expectEqual(point.Point{ .screen = .{ - .x = 1, - .y = 1, - } }, s.pages.pointFromPin(.screen, tl)); - } -} - -test "bottomRight" { - const testing = std.testing; - - var s = try Screen.init(testing.allocator, 5, 10, 0); - defer s.deinit(); - { - // forward - const sel = Selection.init( - s.pages.pin(.{ .screen = .{ .x = 1, .y = 1 } }).?, - s.pages.pin(.{ .screen = .{ .x = 3, .y = 1 } }).?, - false, - ); - defer sel.deinit(&s); - const br = sel.bottomRight(&s); - try testing.expectEqual(point.Point{ .screen = .{ - .x = 3, - .y = 1, - } }, s.pages.pointFromPin(.screen, br)); - } - { - // reverse - const sel = Selection.init( - s.pages.pin(.{ .screen = .{ .x = 3, .y = 1 } }).?, - s.pages.pin(.{ .screen = .{ .x = 1, .y = 1 } }).?, - false, - ); - defer sel.deinit(&s); - const br = sel.bottomRight(&s); - try testing.expectEqual(point.Point{ .screen = .{ - .x = 3, - .y = 1, - } }, s.pages.pointFromPin(.screen, br)); - } - { - // mirrored_forward - const sel = Selection.init( - s.pages.pin(.{ .screen = .{ .x = 3, .y = 1 } }).?, - s.pages.pin(.{ .screen = .{ .x = 1, .y = 3 } }).?, - true, - ); - defer sel.deinit(&s); - const br = sel.bottomRight(&s); - try testing.expectEqual(point.Point{ .screen = .{ - .x = 3, - .y = 3, - } }, s.pages.pointFromPin(.screen, br)); - } - { - // mirrored_reverse - const sel = Selection.init( - s.pages.pin(.{ .screen = .{ .x = 1, .y = 3 } }).?, - s.pages.pin(.{ .screen = .{ .x = 3, .y = 1 } }).?, - true, - ); - defer sel.deinit(&s); - const br = sel.bottomRight(&s); - try testing.expectEqual(point.Point{ .screen = .{ - .x = 3, - .y = 3, - } }, s.pages.pointFromPin(.screen, br)); - } -} - -test "ordered" { - const testing = std.testing; - - var s = try Screen.init(testing.allocator, 5, 10, 0); - defer s.deinit(); - { - // forward - const sel = Selection.init( - s.pages.pin(.{ .screen = .{ .x = 1, .y = 1 } }).?, - s.pages.pin(.{ .screen = .{ .x = 3, .y = 1 } }).?, - false, - ); - const sel_reverse = Selection.init( - s.pages.pin(.{ .screen = .{ .x = 3, .y = 1 } }).?, - s.pages.pin(.{ .screen = .{ .x = 1, .y = 1 } }).?, - false, - ); - try testing.expect(sel.ordered(&s, .forward).eql(sel)); - try testing.expect(sel.ordered(&s, .reverse).eql(sel_reverse)); - try testing.expect(sel.ordered(&s, .mirrored_forward).eql(sel)); - } - { - // reverse - const sel = Selection.init( - s.pages.pin(.{ .screen = .{ .x = 3, .y = 1 } }).?, - s.pages.pin(.{ .screen = .{ .x = 1, .y = 1 } }).?, - false, - ); - const sel_forward = Selection.init( - s.pages.pin(.{ .screen = .{ .x = 1, .y = 1 } }).?, - s.pages.pin(.{ .screen = .{ .x = 3, .y = 1 } }).?, - false, - ); - try testing.expect(sel.ordered(&s, .forward).eql(sel_forward)); - try testing.expect(sel.ordered(&s, .reverse).eql(sel)); - try testing.expect(sel.ordered(&s, .mirrored_forward).eql(sel_forward)); - } - { - // mirrored_forward - const sel = Selection.init( - s.pages.pin(.{ .screen = .{ .x = 3, .y = 1 } }).?, - s.pages.pin(.{ .screen = .{ .x = 1, .y = 3 } }).?, - true, - ); - const sel_forward = Selection.init( - s.pages.pin(.{ .screen = .{ .x = 1, .y = 1 } }).?, - s.pages.pin(.{ .screen = .{ .x = 3, .y = 3 } }).?, - true, - ); - const sel_reverse = Selection.init( - s.pages.pin(.{ .screen = .{ .x = 3, .y = 3 } }).?, - s.pages.pin(.{ .screen = .{ .x = 1, .y = 1 } }).?, - true, - ); - try testing.expect(sel.ordered(&s, .forward).eql(sel_forward)); - try testing.expect(sel.ordered(&s, .reverse).eql(sel_reverse)); - try testing.expect(sel.ordered(&s, .mirrored_reverse).eql(sel_forward)); - } - { - // mirrored_reverse - const sel = Selection.init( - s.pages.pin(.{ .screen = .{ .x = 1, .y = 3 } }).?, - s.pages.pin(.{ .screen = .{ .x = 3, .y = 1 } }).?, - true, - ); - const sel_forward = Selection.init( - s.pages.pin(.{ .screen = .{ .x = 1, .y = 1 } }).?, - s.pages.pin(.{ .screen = .{ .x = 3, .y = 3 } }).?, - true, - ); - const sel_reverse = Selection.init( - s.pages.pin(.{ .screen = .{ .x = 3, .y = 3 } }).?, - s.pages.pin(.{ .screen = .{ .x = 1, .y = 1 } }).?, - true, - ); - try testing.expect(sel.ordered(&s, .forward).eql(sel_forward)); - try testing.expect(sel.ordered(&s, .reverse).eql(sel_reverse)); - try testing.expect(sel.ordered(&s, .mirrored_forward).eql(sel_forward)); - } -} - -test "Selection: contains" { - const testing = std.testing; - - var s = try Screen.init(testing.allocator, 5, 10, 0); - defer s.deinit(); - { - const sel = Selection.init( - s.pages.pin(.{ .screen = .{ .x = 5, .y = 1 } }).?, - s.pages.pin(.{ .screen = .{ .x = 3, .y = 2 } }).?, - false, - ); - - try testing.expect(sel.contains(&s, s.pages.pin(.{ .screen = .{ .x = 6, .y = 1 } }).?)); - try testing.expect(sel.contains(&s, s.pages.pin(.{ .screen = .{ .x = 1, .y = 2 } }).?)); - try testing.expect(!sel.contains(&s, s.pages.pin(.{ .screen = .{ .x = 1, .y = 1 } }).?)); - try testing.expect(!sel.contains(&s, s.pages.pin(.{ .screen = .{ .x = 5, .y = 2 } }).?)); - } - - // Reverse - { - const sel = Selection.init( - s.pages.pin(.{ .screen = .{ .x = 3, .y = 2 } }).?, - s.pages.pin(.{ .screen = .{ .x = 5, .y = 1 } }).?, - false, - ); - - try testing.expect(sel.contains(&s, s.pages.pin(.{ .screen = .{ .x = 6, .y = 1 } }).?)); - try testing.expect(sel.contains(&s, s.pages.pin(.{ .screen = .{ .x = 1, .y = 2 } }).?)); - try testing.expect(!sel.contains(&s, s.pages.pin(.{ .screen = .{ .x = 1, .y = 1 } }).?)); - try testing.expect(!sel.contains(&s, s.pages.pin(.{ .screen = .{ .x = 5, .y = 2 } }).?)); - } - - // Single line - { - const sel = Selection.init( - s.pages.pin(.{ .screen = .{ .x = 5, .y = 1 } }).?, - s.pages.pin(.{ .screen = .{ .x = 10, .y = 1 } }).?, - false, - ); - - try testing.expect(sel.contains(&s, s.pages.pin(.{ .screen = .{ .x = 6, .y = 1 } }).?)); - try testing.expect(!sel.contains(&s, s.pages.pin(.{ .screen = .{ .x = 2, .y = 1 } }).?)); - try testing.expect(!sel.contains(&s, s.pages.pin(.{ .screen = .{ .x = 12, .y = 1 } }).?)); - } -} - -test "Selection: contains, rectangle" { - const testing = std.testing; - - var s = try Screen.init(testing.allocator, 15, 15, 0); - defer s.deinit(); - { - const sel = Selection.init( - s.pages.pin(.{ .screen = .{ .x = 3, .y = 3 } }).?, - s.pages.pin(.{ .screen = .{ .x = 7, .y = 9 } }).?, - true, - ); - - try testing.expect(sel.contains(&s, s.pages.pin(.{ .screen = .{ .x = 5, .y = 6 } }).?)); // Center - try testing.expect(sel.contains(&s, s.pages.pin(.{ .screen = .{ .x = 3, .y = 6 } }).?)); // Left border - try testing.expect(sel.contains(&s, s.pages.pin(.{ .screen = .{ .x = 7, .y = 6 } }).?)); // Right border - try testing.expect(sel.contains(&s, s.pages.pin(.{ .screen = .{ .x = 5, .y = 3 } }).?)); // Top border - try testing.expect(sel.contains(&s, s.pages.pin(.{ .screen = .{ .x = 5, .y = 9 } }).?)); // Bottom border - - try testing.expect(!sel.contains(&s, s.pages.pin(.{ .screen = .{ .x = 5, .y = 2 } }).?)); // Above center - try testing.expect(!sel.contains(&s, s.pages.pin(.{ .screen = .{ .x = 5, .y = 10 } }).?)); // Below center - try testing.expect(!sel.contains(&s, s.pages.pin(.{ .screen = .{ .x = 2, .y = 6 } }).?)); // Left center - try testing.expect(!sel.contains(&s, s.pages.pin(.{ .screen = .{ .x = 8, .y = 6 } }).?)); // Right center - try testing.expect(!sel.contains(&s, s.pages.pin(.{ .screen = .{ .x = 8, .y = 3 } }).?)); // Just right of top right - try testing.expect(!sel.contains(&s, s.pages.pin(.{ .screen = .{ .x = 2, .y = 9 } }).?)); // Just left of bottom left - } - - // Reverse - { - const sel = Selection.init( - s.pages.pin(.{ .screen = .{ .x = 7, .y = 9 } }).?, - s.pages.pin(.{ .screen = .{ .x = 3, .y = 3 } }).?, - true, - ); - - try testing.expect(sel.contains(&s, s.pages.pin(.{ .screen = .{ .x = 5, .y = 6 } }).?)); // Center - try testing.expect(sel.contains(&s, s.pages.pin(.{ .screen = .{ .x = 3, .y = 6 } }).?)); // Left border - try testing.expect(sel.contains(&s, s.pages.pin(.{ .screen = .{ .x = 7, .y = 6 } }).?)); // Right border - try testing.expect(sel.contains(&s, s.pages.pin(.{ .screen = .{ .x = 5, .y = 3 } }).?)); // Top border - try testing.expect(sel.contains(&s, s.pages.pin(.{ .screen = .{ .x = 5, .y = 9 } }).?)); // Bottom border - - try testing.expect(!sel.contains(&s, s.pages.pin(.{ .screen = .{ .x = 5, .y = 2 } }).?)); // Above center - try testing.expect(!sel.contains(&s, s.pages.pin(.{ .screen = .{ .x = 5, .y = 10 } }).?)); // Below center - try testing.expect(!sel.contains(&s, s.pages.pin(.{ .screen = .{ .x = 2, .y = 6 } }).?)); // Left center - try testing.expect(!sel.contains(&s, s.pages.pin(.{ .screen = .{ .x = 8, .y = 6 } }).?)); // Right center - try testing.expect(!sel.contains(&s, s.pages.pin(.{ .screen = .{ .x = 8, .y = 3 } }).?)); // Just right of top right - try testing.expect(!sel.contains(&s, s.pages.pin(.{ .screen = .{ .x = 2, .y = 9 } }).?)); // Just left of bottom left - } - - // Single line - // NOTE: This is the same as normal selection but we just do it for brevity - { - const sel = Selection.init( - s.pages.pin(.{ .screen = .{ .x = 5, .y = 1 } }).?, - s.pages.pin(.{ .screen = .{ .x = 10, .y = 1 } }).?, - true, - ); - - try testing.expect(sel.contains(&s, s.pages.pin(.{ .screen = .{ .x = 6, .y = 1 } }).?)); - try testing.expect(!sel.contains(&s, s.pages.pin(.{ .screen = .{ .x = 2, .y = 1 } }).?)); - try testing.expect(!sel.contains(&s, s.pages.pin(.{ .screen = .{ .x = 12, .y = 1 } }).?)); - } -} diff --git a/src/terminal2/point.zig b/src/terminal2/point.zig deleted file mode 100644 index 4f1d7836b..000000000 --- a/src/terminal2/point.zig +++ /dev/null @@ -1,86 +0,0 @@ -const std = @import("std"); -const Allocator = std.mem.Allocator; -const assert = std.debug.assert; - -/// The possible reference locations for a point. When someone says "(42, 80)" in the context of a terminal, that could mean multiple -/// things: it is in the current visible viewport? the current active -/// area of the screen where the cursor is? the entire scrollback history? -/// etc. This tag is used to differentiate those cases. -pub const Tag = enum { - /// Top-left is part of the active area where a running program can - /// jump the cursor and make changes. The active area is the "editable" - /// part of the screen. - /// - /// The bottom-right of the active tag differs from all other tags - /// because it includes the full height (rows) of the screen, including - /// rows that may not be written yet. This is required because the active - /// area is fully "addressable" by the running program (see below) whereas - /// the other tags are used primarliy for reading/modifying past-written - /// data so they can't address unwritten rows. - /// - /// Note for those less familiar with terminal functionality: there - /// are escape sequences to move the cursor to any position on - /// the screen, but it is limited to the size of the viewport and - /// the bottommost part of the screen. Terminal programs can't -- - /// with sequences at the time of writing this comment -- modify - /// anything in the scrollback, visible viewport (if it differs - /// from the active area), etc. - active, - - /// Top-left is the visible viewport. This means that if the user - /// has scrolled in any direction, top-left changes. The bottom-right - /// is the last written row from the top-left. - viewport, - - /// Top-left is the furthest back in the scrollback history - /// supported by the screen and the bottom-right is the bottom-right - /// of the last written row. Note this last point is important: the - /// bottom right is NOT necessarilly the same as "active" because - /// "active" always allows referencing the full rows tall of the - /// screen whereas "screen" only contains written rows. - screen, - - /// The top-left is the same as "screen" but the bottom-right is - /// the line just before the top of "active". This contains only - /// the scrollback history. - history, -}; - -/// An x/y point in the terminal for some definition of location (tag). -pub const Point = union(Tag) { - active: Coordinate, - viewport: Coordinate, - screen: Coordinate, - history: Coordinate, - - pub const Coordinate = struct { - x: usize = 0, - y: usize = 0, - }; - - pub fn coord(self: Point) Coordinate { - return switch (self) { - .active, - .viewport, - .screen, - .history, - => |v| v, - }; - } -}; - -/// A point in the terminal that is always in the viewport area. -pub const Viewport = struct { - x: usize = 0, - y: usize = 0, - - pub fn eql(self: Viewport, other: Viewport) bool { - return self.x == other.x and self.y == other.y; - } -}; - -/// A point in the terminal that is in relation to the entire screen. -pub const Screen = struct { - x: usize = 0, - y: usize = 0, -}; diff --git a/src/termio/Exec.zig b/src/termio/Exec.zig index 3d1277a8a..490d4cd5d 100644 --- a/src/termio/Exec.zig +++ b/src/termio/Exec.zig @@ -78,7 +78,7 @@ pub const DerivedConfig = struct { palette: terminal.color.Palette, image_storage_limit: usize, - cursor_style: terminal.Cursor.Style, + cursor_style: terminal.CursorStyle, cursor_blink: ?bool, cursor_color: ?configpkg.Config.Color, foreground: configpkg.Config.Color, @@ -155,7 +155,7 @@ pub fn init(alloc: Allocator, opts: termio.Options) !Exec { ); // Set our default cursor style - term.screen.cursor.style = opts.config.cursor_style; + term.screen.cursor.cursor_style = opts.config.cursor_style; var subprocess = try Subprocess.init(alloc, opts); errdefer subprocess.deinit(); @@ -1666,7 +1666,7 @@ const StreamHandler = struct { /// The default cursor state. This is used with CSI q. This is /// set to true when we're currently in the default cursor state. default_cursor: bool = true, - default_cursor_style: terminal.Cursor.Style, + default_cursor_style: terminal.CursorStyle, default_cursor_blink: ?bool, default_cursor_color: ?terminal.color.RGB, @@ -1843,7 +1843,7 @@ const StreamHandler = struct { .decscusr => { const blink = self.terminal.modes.get(.cursor_blinking); - const style: u8 = switch (self.terminal.screen.cursor.style) { + const style: u8 = switch (self.terminal.screen.cursor.cursor_style) { .block => if (blink) 1 else 2, .underline => if (blink) 3 else 4, .bar => if (blink) 5 else 6, @@ -2358,7 +2358,7 @@ const StreamHandler = struct { switch (style) { .default => { self.default_cursor = true; - self.terminal.screen.cursor.style = self.default_cursor_style; + self.terminal.screen.cursor.cursor_style = self.default_cursor_style; self.terminal.modes.set( .cursor_blinking, self.default_cursor_blink orelse true, @@ -2366,32 +2366,32 @@ const StreamHandler = struct { }, .blinking_block => { - self.terminal.screen.cursor.style = .block; + self.terminal.screen.cursor.cursor_style = .block; self.terminal.modes.set(.cursor_blinking, true); }, .steady_block => { - self.terminal.screen.cursor.style = .block; + self.terminal.screen.cursor.cursor_style = .block; self.terminal.modes.set(.cursor_blinking, false); }, .blinking_underline => { - self.terminal.screen.cursor.style = .underline; + self.terminal.screen.cursor.cursor_style = .underline; self.terminal.modes.set(.cursor_blinking, true); }, .steady_underline => { - self.terminal.screen.cursor.style = .underline; + self.terminal.screen.cursor.cursor_style = .underline; self.terminal.modes.set(.cursor_blinking, false); }, .blinking_bar => { - self.terminal.screen.cursor.style = .bar; + self.terminal.screen.cursor.cursor_style = .bar; self.terminal.modes.set(.cursor_blinking, true); }, .steady_bar => { - self.terminal.screen.cursor.style = .bar; + self.terminal.screen.cursor.cursor_style = .bar; self.terminal.modes.set(.cursor_blinking, false); },